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

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

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

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

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

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

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

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

View file

@ -0,0 +1,81 @@
// Email/password auth + the sign-in page (which also hosts passkey + CoinPay buttons).
import { Router } from "express";
import { page, footer, esc } from "../lib/html.mjs";
import { csrfInput, createSession, destroySession, takeNext } from "../lib/session.mjs";
import { hashPassword, verifyPassword } from "../lib/crypto.mjs";
import { createUserWithPassword, userByEmail } from "../lib/users.mjs";
import { dashboardHandler } from "./pages.mjs";
import { config } from "../config.mjs";
export const authRouter = Router();
function authPage(req, { error = "", mode = "in" } = {}) {
const body = `
<main class="wrap" style="max-width:440px;padding-top:8vh">
<a class="brand" href="/" style="justify-content:center;font-size:1.5rem;margin-bottom:6px"><span class="mark">LS</span>LogicSRC<span class="app">credentials</span></a>
<p class="label" style="text-align:center;margin-bottom:26px">Share secrets, end-to-end encrypted</p>
<div class="card"><div class="card-body">
${error ? `<div class="notice err">${esc(error)}</div>` : ""}
<form method="post" action="/auth/${mode === "up" ? "register" : "login"}">
${csrfInput(req)}
<label class="field"><span>Email</span>
<input type="email" name="email" autocomplete="username" required placeholder="you@example.com" value="${esc(req.query.email || "")}"></label>
<label class="field"><span>Password</span>
<input type="password" name="password" autocomplete="${mode === "up" ? "new-password" : "current-password"}" required minlength="8" placeholder="8+ characters"></label>
<button class="btn acid block" type="submit">${mode === "up" ? "Create account" : "Sign in"}</button>
</form>
<p class="mono" style="text-align:center;font-size:.74rem;margin:14px 0 0">
${mode === "up"
? `Already have an account? <a class="acid" href="/?mode=in">Sign in</a>`
: `New here? <a class="acid" href="/?mode=up">Create account</a>`}
</p>
<div class="divider">or</div>
<button class="btn block" type="button" id="passkey-btn" style="margin-bottom:10px">🔑 Continue with a passkey</button>
${config.coinpayLoginEnabled
? `<a class="btn block" href="/auth/coinpay/start">◆ Continue with CoinPay</a>`
: `<button class="btn block" type="button" disabled title="Set COINPAY_OAUTH_* to enable">◆ Continue with CoinPay</button>`}
<p id="passkey-msg" class="mono faint" style="font-size:.72rem;text-align:center;margin:12px 0 0"></p>
</div></div>
</main>${footer}
<script src="/vendor/simplewebauthn-browser.umd.js"></script>
<script src="/passkey.js"></script>`;
return page({ title: "LogicSRC ▸ sign in", body });
}
authRouter.get("/", (req, res) => {
if (req.user) {
const next = takeNext(req, res);
if (next) return res.redirect(next);
return dashboardHandler(req, res); // dashboard lives at the root
}
res.type("html").send(authPage(req, { mode: req.query.mode === "up" ? "up" : "in" }));
});
authRouter.post("/auth/register", async (req, res) => {
const email = String(req.body.email || "").trim().toLowerCase();
const password = String(req.body.password || "");
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return res.type("html").send(authPage(req, { mode: "up", error: "Enter a valid email." }));
if (password.length < 8) return res.type("html").send(authPage(req, { mode: "up", error: "Password must be at least 8 characters." }));
if (await userByEmail(email)) return res.type("html").send(authPage(req, { mode: "up", error: "That email already has an account — sign in." }));
const user = await createUserWithPassword(email, hashPassword(password));
await createSession(res, user.id);
res.redirect(takeNext(req, res) || "/");
});
authRouter.post("/auth/login", async (req, res) => {
const email = String(req.body.email || "").trim().toLowerCase();
const password = String(req.body.password || "");
const user = await userByEmail(email);
if (!user || !verifyPassword(password, user.password_hash)) {
return res.type("html").send(authPage(req, { mode: "in", error: "Wrong email or password." }));
}
await createSession(res, user.id);
res.redirect(takeNext(req, res) || "/");
});
authRouter.post("/auth/logout", async (req, res) => {
await destroySession(req, res);
res.redirect("/");
});

View file

@ -0,0 +1,85 @@
// `logicsrc login` OAuth-style flow (authorization code + PKCE + loopback):
// GET /cli/authorize browser lands here (login required) → approve page
// POST /cli/authorize approve → mint a code, redirect to the CLI's loopback
// POST /cli/token CLI exchanges code + verifier → an lsk_ API key (bearer)
// GET /api/me Bearer → who am I (for `logicsrc whoami`)
import { Router } from "express";
import crypto from "node:crypto";
import { get, run } from "../db.mjs";
import { token } from "../lib/crypto.mjs";
import { page, footer, appBar, esc } from "../lib/html.mjs";
import { requireAuth, csrfInput } from "../lib/session.mjs";
import { createApiKey, bearer, userForApiKey } from "../lib/apikey.mjs";
export const cliRouter = Router();
// Only loopback redirect URIs are allowed (the CLI listens on 127.0.0.1).
function loopbackOk(uri) {
try {
const u = new URL(uri);
return u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost");
} catch { return false; }
}
cliRouter.get("/cli/authorize", requireAuth, (req, res) => {
const { redirect_uri, state, code_challenge } = req.query;
if (!loopbackOk(redirect_uri) || !state || !code_challenge) {
return res.status(400).type("html").send(page({ body: `<main class="wrap" style="padding-top:12vh"><h1>Bad CLI request</h1><p class="dim mono">missing/invalid redirect_uri, state, or code_challenge.</p></main>` }));
}
const name = String(req.query.name || "logicsrc cli").slice(0, 40);
const body = `${appBar(req.user)}
<main class="wrap" style="max-width:460px;padding-top:8vh">
<div class="card"><div class="card-body" style="text-align:center">
<div style="font-size:2rem">🔑</div>
<h1 style="font-size:1.4rem;margin:10px 0">Authorize the LogicSRC CLI</h1>
<p class="dim mono" style="font-size:.82rem">Grant <b class="green">${esc(name)}</b> on this machine access to manage teams &amp; encrypted credentials as <b>${esc(req.user.email || req.user.display_name)}</b>.</p>
<form method="post" action="/cli/authorize" style="margin-top:18px">
${csrfInput(req)}
<input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
<input type="hidden" name="state" value="${esc(state)}">
<input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
<input type="hidden" name="name" value="${esc(name)}">
<button class="btn acid block" type="submit">Authorize &amp; connect</button>
</form>
<p class="faint mono" style="font-size:.72rem;margin-top:12px">You'll return to your terminal.</p>
</div></div>
</main>${footer}`;
res.type("html").send(page({ title: "LogicSRC ▸ authorize CLI", body }));
});
cliRouter.post("/cli/authorize", requireAuth, async (req, res) => {
const { redirect_uri, state, code_challenge, name } = req.body;
if (!loopbackOk(redirect_uri) || !state || !code_challenge) return res.status(400).send("bad request");
const code = token(24);
const now = Date.now();
await run(
`INSERT INTO cli_auth_codes (code,user_id,code_challenge,redirect_uri,name,created_at,expires_at) VALUES (?,?,?,?,?,?,?)`,
[code, req.user.id, code_challenge, redirect_uri, String(name || "cli").slice(0, 40), now, now + 5 * 60 * 1000]
);
const u = new URL(redirect_uri);
u.searchParams.set("code", code);
u.searchParams.set("state", state);
res.redirect(u.toString());
});
cliRouter.post("/cli/token", async (req, res) => {
const { code, code_verifier } = req.body || {};
if (!code || !code_verifier) return res.status(400).json({ error: "code and code_verifier required" });
const row = await get(`SELECT * FROM cli_auth_codes WHERE code = ?`, [code]);
if (!row || row.used || row.expires_at < Date.now()) return res.status(400).json({ error: "invalid or expired code" });
// PKCE: base64url(sha256(verifier)) must equal the stored challenge
const challenge = crypto.createHash("sha256").update(String(code_verifier)).digest("base64url");
if (challenge !== row.code_challenge) return res.status(400).json({ error: "PKCE verification failed" });
await run(`UPDATE cli_auth_codes SET used = 1 WHERE code = ?`, [code]);
const user = await get(`SELECT * FROM users WHERE id = ?`, [row.user_id]);
const { plaintext } = await createApiKey(user.id, row.name || "logicsrc cli");
res.json({ access_token: plaintext, token_type: "bearer", user: { id: user.id, email: user.email || null, name: user.display_name } });
});
cliRouter.get("/api/me", async (req, res) => {
const user = await userForApiKey(bearer(req));
if (!user) return res.status(401).json({ error: "invalid or missing API key" });
res.json({ id: user.id, email: user.email || null, name: user.display_name });
});

View file

@ -0,0 +1,68 @@
// "Sign in with CoinPay" — OAuth 2.0 authorization-code + PKCE.
import { Router } from "express";
import crypto from "node:crypto";
import { config } from "../config.mjs";
import { token } from "../lib/crypto.mjs";
import { createSession, setCeremony, getCeremony, clearCeremony, takeNext } from "../lib/session.mjs";
import { userByCoinpay, createUserForCoinpay } from "../lib/users.mjs";
export const coinpayRouter = Router();
const b64url = (buf) => Buffer.from(buf).toString("base64url");
coinpayRouter.get("/auth/coinpay/start", (req, res) => {
if (!config.coinpayLoginEnabled) return res.redirect("/?err=coinpay-not-configured");
const state = token(16);
const verifier = b64url(crypto.randomBytes(32));
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
setCeremony(res, "cp", { state, verifier }, 1000 * 60 * 10);
const u = new URL(config.coinpay.oauth.authorizeUrl);
u.searchParams.set("response_type", "code");
u.searchParams.set("client_id", config.coinpay.oauth.clientId);
u.searchParams.set("redirect_uri", config.coinpay.oauth.redirectUri);
u.searchParams.set("scope", config.coinpay.oauth.scope);
u.searchParams.set("state", state);
u.searchParams.set("code_challenge", challenge);
u.searchParams.set("code_challenge_method", "S256");
res.redirect(u.toString());
});
coinpayRouter.get("/auth/coinpay/callback", async (req, res) => {
const ceremony = getCeremony(req, "cp");
clearCeremony(res, "cp");
if (!ceremony || req.query.state !== ceremony.state) return res.redirect("/?err=coinpay-state");
if (!req.query.code) return res.redirect("/?err=coinpay-denied");
try {
const tokenRes = await fetch(config.coinpay.oauth.tokenUrl, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: String(req.query.code),
redirect_uri: config.coinpay.oauth.redirectUri,
client_id: config.coinpay.oauth.clientId,
code_verifier: ceremony.verifier,
}),
});
if (!tokenRes.ok) throw new Error(`token exchange ${tokenRes.status}`);
const tok = await tokenRes.json();
const infoRes = await fetch(config.coinpay.oauth.userinfoUrl, {
headers: { authorization: `Bearer ${tok.access_token}` },
});
if (!infoRes.ok) throw new Error(`userinfo ${infoRes.status}`);
const info = await infoRes.json();
const sub = String(info.sub || info.id || info.user_id || "");
if (!sub) throw new Error("no subject in userinfo");
let user = await userByCoinpay(sub);
if (!user) user = await createUserForCoinpay(sub, info.name || info.username);
await createSession(res, user.id);
res.redirect(takeNext(req, res) || "/");
} catch (e) {
console.error("coinpay login failed:", e.message);
res.redirect("/?err=coinpay-failed");
}
});

View file

@ -0,0 +1,259 @@
// LogicSRC credential sharing API (end-to-end encrypted team vaults).
//
// Zero-knowledge: this server only ever stores ciphertext, per-member sealed
// vault keys, and member identity public keys. All crypto happens in the CLI.
//
// Auth: the acting user comes from a browser session (req.user) OR a
// `Bearer lsk_…` API key (the logicsrc CLI). Mounted at /api/credshare.
import { Router } from "express";
import { get, all, run } from "../db.mjs";
import { id, token, sha256 } from "../lib/crypto.mjs";
import { bearer, userForApiKey } from "../lib/apikey.mjs";
import { config } from "../config.mjs";
export const credshareRouter = Router();
const ROLE_RANK = { member: 0, admin: 1, owner: 2 };
const INVITE_TTL = 1000 * 60 * 60 * 24 * 7;
const norm = (e) => String(e || "").trim().toLowerCase();
const slugify = (s) => {
const v = String(s || "").trim().toLowerCase();
return /^[a-z0-9][a-z0-9-]{0,62}$/.test(v) ? v : null;
};
// Resolve the acting user from session or API key.
async function actor(req) {
if (req.user) return req.user;
return userForApiKey(bearer(req));
}
function api(handler) {
return async (req, res) => {
const user = await actor(req);
if (!user) return res.status(401).json({ error: "Not authenticated. Run: logicsrc login" });
try {
await handler(req, res, user);
} catch (e) {
console.error("credshare:", e);
res.status(500).json({ error: e.message || String(e) });
}
};
}
async function requireMember(res, slug, userId) {
const team = await get(`SELECT * FROM credshare_teams WHERE slug = ?`, [slug]);
if (!team) { res.status(404).json({ error: `Unknown team: ${slug}` }); return null; }
const member = await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ?`, [team.id, userId]);
if (!member || member.status !== "active") { res.status(403).json({ error: "You are not a member of this team." }); return null; }
return { team, member };
}
async function publicKeyFor(userId) {
const r = await get(`SELECT public_key FROM credshare_keys WHERE user_id = ?`, [userId]);
return r?.public_key ?? null;
}
async function audit(ev) {
await run(`INSERT INTO credshare_audit (id, team_id, vault_id, actor_user_id, action, key_name, fingerprint, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), ev.teamId ?? null, ev.vaultId ?? null, ev.actorUserId, ev.action, ev.keyName ?? null, ev.fingerprint ?? null, Date.now()]);
}
async function sendInviteEmail(to, tok, team, fromEmail) {
if (!config.resend.apiKey) return false;
const url = `${config.origin}/teams/accept?token=${encodeURIComponent(tok)}`;
const r = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { authorization: `Bearer ${config.resend.apiKey}`, "content-type": "application/json" },
body: JSON.stringify({
from: config.resend.from, to,
subject: `You're invited to the "${team.name}" credential team on LogicSRC`,
text: `${fromEmail} invited you to share credentials on ${team.name} (${team.slug}).\n\nAccept in the CLI:\n logicsrc login\n logicsrc teams accept ${tok}\n\nOr on the web: ${url}\n\nSecrets are end-to-end encrypted — the server never sees them.`
})
}).catch(() => null);
return Boolean(r && r.ok);
}
// ---- identity key + lookup ----
credshareRouter.post("/api/credshare/keys", api(async (req, res, user) => {
const publicKey = req.body?.publicKey;
if (typeof publicKey !== "string" || !publicKey) return res.status(422).json({ error: "Expected { publicKey }." });
await run(`INSERT INTO credshare_keys (user_id, public_key, updated_at) VALUES (?,?,?) ON CONFLICT(user_id) DO UPDATE SET public_key = excluded.public_key, updated_at = excluded.updated_at`,
[user.id, publicKey, Date.now()]);
res.json({ email: user.email, publicKey });
}));
credshareRouter.get("/api/credshare/me", api(async (_req, res, user) => {
const teams = await all(`SELECT t.id, t.slug, t.name FROM credshare_teams t JOIN credshare_members m ON m.team_id = t.id WHERE m.user_id = ? AND m.status = 'active'`, [user.id]);
res.json({ user: { id: user.id, email: user.email, publicKey: await publicKeyFor(user.id) }, teams });
}));
credshareRouter.get("/api/credshare/users", api(async (req, res) => {
const email = norm(req.query.email);
if (!email) return res.status(422).json({ error: "Expected ?email=" });
const u = await get(`SELECT id FROM users WHERE email = ?`, [email]);
res.json({ email, userId: u?.id ?? null, publicKey: u ? await publicKeyFor(u.id) : null });
}));
// ---- teams / members / invites ----
credshareRouter.post("/api/credshare/teams", api(async (req, res, user) => {
const slug = slugify(req.body?.slug);
if (!slug) return res.status(422).json({ error: "Slug must be lowercase letters, numbers, and dashes." });
if (await get(`SELECT 1 FROM credshare_teams WHERE slug = ?`, [slug])) return res.status(409).json({ error: `Team slug "${slug}" is taken.` });
const team = { id: id(), slug, name: (req.body?.name && String(req.body.name)) || slug, createdBy: user.id, createdAt: Date.now() };
await run(`INSERT INTO credshare_teams (id, slug, name, created_by, created_at) VALUES (?,?,?,?,?)`, [team.id, team.slug, team.name, team.createdBy, team.createdAt]);
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, joined_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), team.id, user.id, norm(user.email) || user.id, "owner", "active", Date.now(), Date.now()]);
await audit({ teamId: team.id, actorUserId: user.id, action: "team:create" });
res.status(201).json({ team: { id: team.id, slug: team.slug, name: team.name } });
}));
credshareRouter.get("/api/credshare/teams", api(async (_req, res, user) => {
const teams = await all(`SELECT t.id, t.slug, t.name FROM credshare_teams t JOIN credshare_members m ON m.team_id = t.id WHERE m.user_id = ? AND m.status = 'active' ORDER BY t.created_at`, [user.id]);
res.json({ teams });
}));
credshareRouter.get("/api/credshare/teams/:slug/members", api(async (req, res, user) => {
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
const rows = await all(`SELECT * FROM credshare_members WHERE team_id = ? ORDER BY created_at`, [ctx.team.id]);
const members = [];
for (const m of rows) members.push({ email: m.email, role: m.role, status: m.status, hasPublicKey: m.user_id ? Boolean(await publicKeyFor(m.user_id)) : false, joinedAt: m.joined_at });
res.json({ members });
}));
credshareRouter.post("/api/credshare/teams/:slug/invites", api(async (req, res, user) => {
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
if (ROLE_RANK[ctx.member.role] < ROLE_RANK.admin) return res.status(403).json({ error: "Only owners and admins can invite." });
const email = norm(req.body?.email);
if (!email) return res.status(422).json({ error: "Expected { email, role? }." });
const role = req.body?.role && ROLE_RANK[req.body.role] != null ? req.body.role : "member";
const existing = await get(`SELECT 1 FROM credshare_members WHERE team_id = ? AND email = ?`, [ctx.team.id, email]);
if (!existing) {
const invitedUser = await get(`SELECT id FROM users WHERE email = ?`, [email]);
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, invited_by, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), ctx.team.id, invitedUser?.id ?? null, email, role, "invited", user.id, Date.now()]);
}
const tok = token(24);
await run(`INSERT INTO credshare_invites (id, team_id, email, role, token_hash, created_by, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), ctx.team.id, email, role, sha256(tok), user.id, Date.now() + INVITE_TTL, Date.now()]);
await audit({ teamId: ctx.team.id, actorUserId: user.id, action: "team:invite", keyName: email });
const emailSent = await sendInviteEmail(email, tok, ctx.team, user.email);
res.status(201).json({ invite: { email, role }, emailSent, ...(emailSent ? {} : { token: tok }) });
}));
credshareRouter.post("/api/credshare/invites/accept", api(async (req, res, user) => {
const raw = req.body?.token;
if (!raw) return res.status(422).json({ error: "Expected { token }." });
const invite = await get(`SELECT * FROM credshare_invites WHERE token_hash = ?`, [sha256(String(raw))]);
if (!invite) return res.status(404).json({ error: "Invite not found." });
if (invite.accepted_at) return res.status(409).json({ error: "Invite already used." });
if (invite.expires_at < Date.now()) return res.status(410).json({ error: "Invite expired." });
if (norm(invite.email) !== norm(user.email)) return res.status(403).json({ error: `This invite is for ${invite.email}, not ${user.email}.` });
await run(`UPDATE credshare_members SET user_id = ?, status = 'active', joined_at = ? WHERE team_id = ? AND email = ?`, [user.id, Date.now(), invite.team_id, norm(invite.email)]);
await run(`UPDATE credshare_invites SET accepted_at = ? WHERE id = ?`, [Date.now(), invite.id]);
await audit({ teamId: invite.team_id, actorUserId: user.id, action: "team:join" });
const team = await get(`SELECT id, slug, name FROM credshare_teams WHERE id = ?`, [invite.team_id]);
res.json({ ok: true, team });
}));
// ---- vaults ----
credshareRouter.get("/api/credshare/teams/:slug/vaults", api(async (req, res, user) => {
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
const vaults = await all(`SELECT * FROM credshare_vaults WHERE team_id = ? ORDER BY name`, [ctx.team.id]);
const out = [];
for (const v of vaults) {
const grant = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [v.id, user.id]);
const count = await get(`SELECT COUNT(*) AS n FROM credshare_secrets WHERE vault_id = ?`, [v.id]);
out.push({ id: v.id, name: v.name, hasAccess: Boolean(grant), secretCount: Number(count?.n || 0) });
}
res.json({ vaults: out });
}));
credshareRouter.post("/api/credshare/teams/:slug/vaults", api(async (req, res, user) => {
const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return;
const name = slugify(req.body?.name);
if (!name) return res.status(422).json({ error: "Vault name must be lowercase letters, numbers, and dashes." });
const existing = await get(`SELECT id, name FROM credshare_vaults WHERE team_id = ? AND name = ?`, [ctx.team.id, name]);
if (existing) return res.json({ vault: { id: existing.id, name: existing.name } });
const vault = { id: id(), name };
await run(`INSERT INTO credshare_vaults (id, team_id, name, created_by, created_at) VALUES (?,?,?,?,?)`, [vault.id, ctx.team.id, name, user.id, Date.now()]);
await audit({ teamId: ctx.team.id, vaultId: vault.id, actorUserId: user.id, action: "vault:create" });
res.status(201).json({ vault });
}));
// ---- vault grants / secrets / audit (by vault id) ----
async function vaultCtx(res, vaultId, userId) {
const vault = await get(`SELECT * FROM credshare_vaults WHERE id = ?`, [vaultId]);
if (!vault) { res.status(404).json({ error: "Unknown vault." }); return null; }
const member = await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ?`, [vault.team_id, userId]);
if (!member || member.status !== "active") { res.status(403).json({ error: "You are not a member of this vault's team." }); return null; }
return vault;
}
credshareRouter.get("/api/credshare/vaults/:id/grant", api(async (req, res, user) => {
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
const grant = await get(`SELECT wrapped_dek FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [vault.id, user.id]);
if (!grant) return res.status(403).json({ error: "You do not have access to this vault yet. Ask a member to grant you." });
res.json({ wrappedDek: grant.wrapped_dek });
}));
credshareRouter.get("/api/credshare/vaults/:id/grants", api(async (req, res, user) => {
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
const granted = new Set((await all(`SELECT user_id FROM credshare_vault_grants WHERE vault_id = ?`, [vault.id])).map((r) => r.user_id));
const members = await all(`SELECT * FROM credshare_members WHERE team_id = ?`, [vault.team_id]);
const grants = [];
for (const m of members) grants.push({ email: m.email, hasPublicKey: m.user_id ? Boolean(await publicKeyFor(m.user_id)) : false, hasAccess: Boolean(m.user_id && granted.has(m.user_id)) });
res.json({ grants });
}));
credshareRouter.post("/api/credshare/vaults/:id/grants", api(async (req, res, user) => {
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
const iHold = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [vault.id, user.id]);
const anyGrants = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ?`, [vault.id]);
if (!iHold && anyGrants) return res.status(403).json({ error: "Only a member with vault access can grant others." });
const email = norm(req.body?.email), wrappedDek = req.body?.wrappedDek;
if (!email || typeof wrappedDek !== "string" || !wrappedDek) return res.status(422).json({ error: "Expected { email, wrappedDek }." });
const target = await get(`SELECT id FROM users WHERE email = ?`, [email]);
if (!target) return res.status(404).json({ error: "Target user has not logged in yet." });
if (!(await publicKeyFor(target.id))) return res.status(409).json({ error: "Target user has not uploaded a public key yet." });
await run(`INSERT INTO credshare_vault_grants (vault_id, user_id, wrapped_dek, granted_by, created_at) VALUES (?,?,?,?,?) ON CONFLICT(vault_id, user_id) DO UPDATE SET wrapped_dek = excluded.wrapped_dek, granted_by = excluded.granted_by, created_at = excluded.created_at`,
[vault.id, target.id, wrappedDek, user.id, Date.now()]);
await audit({ teamId: vault.team_id, vaultId: vault.id, actorUserId: user.id, action: "vault:grant", keyName: email });
res.status(201).json({ ok: true });
}));
credshareRouter.get("/api/credshare/vaults/:id/secrets", api(async (req, res, user) => {
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
const rows = await all(`SELECT * FROM credshare_secrets WHERE vault_id = ? ORDER BY name`, [vault.id]);
res.json({ vaultId: vault.id, secrets: rows.map((s) => ({ name: s.name, nonce: s.nonce, ciphertext: s.ciphertext, fingerprint: s.fingerprint, version: s.version, updatedAt: s.updated_at })) });
}));
credshareRouter.put("/api/credshare/vaults/:id/secrets", api(async (req, res, user) => {
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
const upserts = Array.isArray(req.body?.upserts) ? req.body.upserts : [];
const deletes = Array.isArray(req.body?.deletes) ? req.body.deletes : [];
const applied = [];
for (const u of upserts) {
if (!u || typeof u.name !== "string" || typeof u.nonce !== "string" || typeof u.ciphertext !== "string" || typeof u.fingerprint !== "string") {
return res.status(422).json({ error: "Each upsert needs { name, nonce, ciphertext, fingerprint }." });
}
const prev = await get(`SELECT version FROM credshare_secrets WHERE vault_id = ? AND name = ?`, [vault.id, u.name]);
const version = (prev?.version ?? 0) + 1;
await run(`INSERT INTO credshare_secrets (vault_id, name, nonce, ciphertext, fingerprint, version, updated_by, updated_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(vault_id, name) DO UPDATE SET nonce = excluded.nonce, ciphertext = excluded.ciphertext, fingerprint = excluded.fingerprint, version = excluded.version, updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
[vault.id, u.name, u.nonce, u.ciphertext, u.fingerprint, version, user.id, Date.now()]);
await audit({ teamId: vault.team_id, vaultId: vault.id, actorUserId: user.id, action: prev ? "secret:update" : "secret:add", keyName: u.name, fingerprint: u.fingerprint });
applied.push(u.name);
}
for (const raw of deletes) {
const name = typeof raw === "string" ? raw : null;
if (!name) continue;
await run(`DELETE FROM credshare_secrets WHERE vault_id = ? AND name = ?`, [vault.id, name]);
await audit({ teamId: vault.team_id, vaultId: vault.id, actorUserId: user.id, action: "secret:remove", keyName: name });
applied.push(name);
}
res.json({ ok: true, applied });
}));
credshareRouter.get("/api/credshare/vaults/:id/audit", api(async (req, res, user) => {
const vault = await vaultCtx(res, req.params.id, user.id); if (!vault) return;
const auditRows = await all(`SELECT * FROM credshare_audit WHERE vault_id = ? ORDER BY created_at DESC`, [vault.id]);
res.json({ audit: auditRows });
}));

View file

@ -0,0 +1,168 @@
// Teams dashboard (/) + accept-invite (/teams/accept) + settings (/settings).
// The browser holds no private key, so it never decrypts — it manages teams,
// members, vaults (ciphertext metadata), invites, and CLI API keys.
import { Router } from "express";
import { get, all, run } from "../db.mjs";
import { id, token, sha256 } from "../lib/crypto.mjs";
import { page, footer, appBar, esc } from "../lib/html.mjs";
import { requireAuth, csrfInput } from "../lib/session.mjs";
import { createApiKey, listApiKeys, revokeApiKey } from "../lib/apikey.mjs";
import { config } from "../config.mjs";
export const pagesRouter = Router();
// placeholder replaced per-request (teamCard can't see req to render csrfInput)
const CSRF = "__CSRF__";
const CLI_HINT = (origin) => `<div class="card" style="margin-bottom:22px"><div class="card-head"><span class="h">Connect the CLI</span><span class="pill on">end-to-end encrypted</span></div>
<div class="card-body">
<p class="dim" style="margin-top:0;font-size:.9rem">Secrets are encrypted on your machine decrypt them with the <code>logicsrc</code> CLI, never here.</p>
<pre class="mono" style="background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;font-size:.8rem;margin:0">LOGICSRC_API=${esc(origin)} logicsrc login
logicsrc teams push &lt;team&gt; prod --env .env # share
logicsrc teams pull &lt;team&gt; prod --env .env # receive</pre>
</div></div>`;
async function teamCard(team, uid) {
const members = await all(`SELECT * FROM credshare_members WHERE team_id = ? ORDER BY created_at`, [team.id]);
const me = members.find((m) => m.user_id === uid);
const vaults = await all(`SELECT * FROM credshare_vaults WHERE team_id = ? ORDER BY name`, [team.id]);
const canInvite = me && (me.role === "owner" || me.role === "admin");
const memberRows = [];
for (const m of members) {
const key = m.user_id ? await get(`SELECT 1 FROM credshare_keys WHERE user_id = ?`, [m.user_id]) : null;
memberRows.push(`<tr><td>${esc(m.email)}</td><td>${esc(m.role)}</td><td><span class="pill ${m.status === "active" ? "on" : ""}">${esc(m.status)}</span></td><td>${key ? "✓" : "—"}</td></tr>`);
}
const vaultRows = [];
for (const v of vaults) {
const count = await get(`SELECT COUNT(*) AS n FROM credshare_secrets WHERE vault_id = ?`, [v.id]);
const mine = await get(`SELECT 1 FROM credshare_vault_grants WHERE vault_id = ? AND user_id = ?`, [v.id, uid]);
vaultRows.push(`<tr><td><code>${esc(v.name)}</code></td><td>${Number(count?.n || 0)}</td><td>${mine ? "✓ you have access" : "— ask a member to grant you"}</td></tr>`);
}
return `<div class="card" style="margin-bottom:22px">
<div class="card-head"><span class="h">${esc(team.name)} <span class="faint">/${esc(team.slug)}</span></span><span class="pill">${me ? esc(me.role) : "member"}</span></div>
<div class="card-body">
<div class="label" style="margin-bottom:6px">Members</div>
<table><thead><tr><th>Email</th><th>Role</th><th>Status</th><th>Key</th></tr></thead><tbody>${memberRows.join("")}</tbody></table>
${canInvite ? `<form method="post" action="/teams/${esc(team.slug)}/invite" style="display:flex;gap:8px;margin-top:12px">${CSRF}
<input type="email" name="email" placeholder="teammate@example.com" required style="flex:1"><button class="btn">Invite</button></form>` : ""}
<div class="label" style="margin:18px 0 6px">Vaults</div>
${vaults.length ? `<table><thead><tr><th>Vault</th><th>Secrets</th><th>Your access</th></tr></thead><tbody>${vaultRows.join("")}</tbody></table>`
: `<p class="faint mono" style="font-size:.82rem">No vaults yet — create one from the CLI: <code>logicsrc teams push ${esc(team.slug)} prod</code></p>`}
</div></div>`;
}
export async function dashboardHandler(req, res) {
const uid = req.user.id;
const teams = await all(`SELECT t.* FROM credshare_teams t JOIN credshare_members m ON m.team_id = t.id WHERE m.user_id = ? AND m.status = 'active' ORDER BY t.created_at`, [uid]);
let cards = "";
for (const t of teams) cards += await teamCard(t, uid);
cards = cards.split(CSRF).join(csrfInput(req));
const body = `${appBar(req.user)}
<main class="wrap" style="max-width:820px;padding:26px 0 40px">
<div class="section-title"><h1 style="font-size:1.6rem">Your teams</h1><span class="count">${teams.length}</span></div>
${CLI_HINT(config.origin)}
${cards || `<div class="card"><div class="card-body dim">You're not on any teams yet. Create one below or accept an invite.</div></div>`}
<div class="card" style="margin-top:22px"><div class="card-head"><span class="h">New team</span></div>
<div class="card-body"><form method="post" action="/teams" style="display:flex;gap:8px">${csrfInput(req)}
<input name="slug" placeholder="team-slug" required style="flex:1"><button class="btn acid">Create team</button></form></div></div>
</main>${footer}`;
res.type("html").send(page({ title: "LogicSRC ▸ teams", body }));
}
pagesRouter.get("/dashboard", requireAuth, dashboardHandler);
// ---- team + invite form actions (session + CSRF) ----
pagesRouter.post("/teams", requireAuth, async (req, res) => {
const slug = String(req.body.slug || "").trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(slug)) return res.redirect("/dashboard?err=bad-slug");
if (await get(`SELECT 1 FROM credshare_teams WHERE slug = ?`, [slug])) return res.redirect("/dashboard?err=slug-taken");
const teamId = id(), now = Date.now();
await run(`INSERT INTO credshare_teams (id, slug, name, created_by, created_at) VALUES (?,?,?,?,?)`, [teamId, slug, slug, req.user.id, now]);
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, joined_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), teamId, req.user.id, String(req.user.email || req.user.id).toLowerCase(), "owner", "active", now, now]);
res.redirect("/dashboard");
});
pagesRouter.post("/teams/:slug/invite", requireAuth, async (req, res) => {
const team = await get(`SELECT * FROM credshare_teams WHERE slug = ?`, [req.params.slug]);
const me = team && await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ?`, [team.id, req.user.id]);
if (!team || !me || (me.role !== "owner" && me.role !== "admin")) return res.redirect("/dashboard?err=not-allowed");
const email = String(req.body.email || "").trim().toLowerCase();
if (!email) return res.redirect("/dashboard");
const now = Date.now();
if (!(await get(`SELECT 1 FROM credshare_members WHERE team_id = ? AND email = ?`, [team.id, email]))) {
const u = await get(`SELECT id FROM users WHERE email = ?`, [email]);
await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, invited_by, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), team.id, u?.id ?? null, email, "member", "invited", req.user.id, now]);
}
const tok = token(24);
await run(`INSERT INTO credshare_invites (id, team_id, email, role, token_hash, created_by, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)`,
[id(), team.id, email, "member", sha256(tok), req.user.id, now + 7 * 864e5, now]);
res.redirect("/teams/accept?token=" + encodeURIComponent(tok) + "&shared=1");
});
// ---- accept invite ----
pagesRouter.get("/teams/accept", requireAuth, (req, res) => {
const tok = String(req.query.token || "");
const shared = req.query.shared;
const err = req.query.err;
const body = `${appBar(req.user)}
<main class="wrap" style="max-width:460px;padding-top:8vh">
<div class="card"><div class="card-body" style="text-align:center">
<h1 style="font-size:1.4rem;margin-bottom:12px">Accept team invite</h1>
${err ? `<div class="notice err">${esc(String(err).replace(/-/g, " "))}</div>` : ""}
${shared ? `<div class="notice ok">Invite created. Share this link with the teammate, or accept below if it's for you.</div>` : ""}
<form method="post" action="/teams/accept">${csrfInput(req)}
<label class="field"><span>Invite token</span><input name="token" value="${esc(tok)}" required></label>
<button class="btn acid block">Accept invite</button>
</form>
</div></div>
</main>${footer}`;
res.type("html").send(page({ title: "LogicSRC ▸ accept invite", body }));
});
pagesRouter.post("/teams/accept", requireAuth, async (req, res) => {
const invite = await get(`SELECT * FROM credshare_invites WHERE token_hash = ?`, [sha256(String(req.body.token || ""))]);
if (!invite || invite.accepted_at || invite.expires_at < Date.now()) return res.redirect("/teams/accept?err=invalid-or-expired");
if (String(invite.email).toLowerCase() !== String(req.user.email || "").toLowerCase()) return res.redirect("/teams/accept?err=wrong-account");
const now = Date.now();
await run(`UPDATE credshare_members SET user_id = ?, status = 'active', joined_at = ? WHERE team_id = ? AND email = ?`, [req.user.id, now, invite.team_id, String(invite.email).toLowerCase()]);
await run(`UPDATE credshare_invites SET accepted_at = ? WHERE id = ?`, [now, invite.id]);
res.redirect("/dashboard");
});
// ---- settings: CLI API keys ----
pagesRouter.get("/settings", requireAuth, async (req, res) => {
const keys = await listApiKeys(req.user.id);
const newKey = req.query.key ? String(req.query.key) : "";
const keysHtml = keys.length ? keys.map((k) => `
<div style="display:flex;gap:12px;align-items:center;padding:10px 0;border-bottom:1px solid var(--line)" class="mono">
<span style="flex:1">${esc(k.name)} <span class="faint">${esc(k.prefix)}</span></span>
<form method="post" action="/settings/apikeys/${k.id}/delete" style="margin:0">${csrfInput(req)}<button class="btn danger" style="padding:5px 10px;font-size:.72rem">revoke</button></form>
</div>`).join("") : `<div class="faint mono" style="font-size:.78rem;padding:6px 0">no keys yet</div>`;
const body = `${appBar(req.user)}
<main class="wrap" style="max-width:640px;padding-top:30px">
<h1 style="font-size:1.5rem;margin-bottom:20px">Settings</h1>
${newKey ? `<div class="notice ok">New API key (copy it now — shown once):<br><b class="mono" style="word-break:break-all">${esc(newKey)}</b></div>` : ""}
<div class="card"><div class="card-head"><span class="h">API keys · for the logicsrc CLI</span></div>
<div class="card-body">
<p class="dim" style="font-size:.85rem;margin-top:0">Usually you don't need these <code>logicsrc login</code> creates one automatically. Manual keys are for CI.</p>
${keysHtml}
<form method="post" action="/settings/apikeys" style="margin-top:14px;display:flex;gap:10px">${csrfInput(req)}
<input name="name" placeholder="key name (e.g. ci)" style="flex:1"><button class="btn">Create key</button></form>
</div></div>
</main>${footer}`;
res.type("html").send(page({ title: "LogicSRC ▸ settings", body }));
});
pagesRouter.post("/settings/apikeys", requireAuth, async (req, res) => {
const { plaintext } = await createApiKey(req.user.id, String(req.body.name || "cli").slice(0, 40));
res.redirect("/settings?key=" + encodeURIComponent(plaintext));
});
pagesRouter.post("/settings/apikeys/:id/delete", requireAuth, async (req, res) => {
await revokeApiKey(req.user.id, req.params.id);
res.redirect("/settings");
});

View file

@ -0,0 +1,110 @@
// Passkey (WebAuthn) auth: register a new passkey-first user, or sign in with a
// discoverable credential. Client uses @simplewebauthn/browser (served at /vendor).
import { Router } from "express";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import { get, run } from "../db.mjs";
import { config } from "../config.mjs";
import { id } from "../lib/crypto.mjs";
import { createSession, setCeremony, getCeremony, clearCeremony, takeNext } from "../lib/session.mjs";
import { createUserPasskey, userById } from "../lib/users.mjs";
export const passkeyRouter = Router();
const enc = (s) => new TextEncoder().encode(s);
// ---- registration (new passkey-first account, or add to the logged-in user) ----
passkeyRouter.post("/auth/passkey/register/options", async (req, res) => {
const handle = req.user?.id || id();
const name = req.user?.email || req.user?.display_name || `logicsrc-${handle.slice(0, 6)}`;
const options = await generateRegistrationOptions({
rpName: config.rpName,
rpID: config.rpID,
userName: name,
userID: enc(handle),
attestationType: "none",
authenticatorSelection: { residentKey: "required", userVerification: "preferred" },
});
setCeremony(res, "reg", { challenge: options.challenge, handle, name, existing: Boolean(req.user) });
res.json(options);
});
passkeyRouter.post("/auth/passkey/register/verify", async (req, res) => {
const ceremony = getCeremony(req, "reg");
if (!ceremony) return res.status(400).json({ error: "registration expired — try again" });
let verification;
try {
verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: ceremony.challenge,
expectedOrigin: config.origin,
expectedRPID: config.rpID,
});
} catch (e) {
return res.status(400).json({ error: String(e.message || e) });
}
if (!verification.verified) return res.status(400).json({ error: "could not verify passkey" });
const { credential } = verification.registrationInfo;
const user = ceremony.existing ? await userById(ceremony.handle) : await createUserPasskey(ceremony.name);
await run(
`INSERT INTO webauthn_credentials (id, user_id, public_key, counter, transports, created_at) VALUES (?,?,?,?,?,?)`,
[
credential.id,
user.id,
Buffer.from(credential.publicKey).toString("base64url"),
credential.counter || 0,
JSON.stringify(credential.transports || req.body.response?.transports || []),
Date.now(),
]
);
clearCeremony(res, "reg");
await createSession(res, user.id);
res.json({ ok: true, redirect: takeNext(req, res) || "/" });
});
// ---- authentication (discoverable / usernameless sign-in) ----
passkeyRouter.post("/auth/passkey/login/options", async (req, res) => {
const options = await generateAuthenticationOptions({
rpID: config.rpID,
allowCredentials: [], // discoverable credentials
userVerification: "preferred",
});
setCeremony(res, "auth", { challenge: options.challenge });
res.json(options);
});
passkeyRouter.post("/auth/passkey/login/verify", async (req, res) => {
const ceremony = getCeremony(req, "auth");
if (!ceremony) return res.status(400).json({ error: "sign-in expired — try again" });
const cred = await get(`SELECT * FROM webauthn_credentials WHERE id = ?`, [req.body.id]);
if (!cred) return res.status(400).json({ error: "unknown passkey — create an account" });
let verification;
try {
verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: ceremony.challenge,
expectedOrigin: config.origin,
expectedRPID: config.rpID,
credential: {
id: cred.id,
publicKey: Buffer.from(cred.public_key, "base64url"),
counter: Number(cred.counter),
transports: JSON.parse(cred.transports || "[]"),
},
});
} catch (e) {
return res.status(400).json({ error: String(e.message || e) });
}
if (!verification.verified) return res.status(400).json({ error: "passkey did not verify" });
await run(`UPDATE webauthn_credentials SET counter = ? WHERE id = ?`,
[verification.authenticationInfo.newCounter, cred.id]);
clearCeremony(res, "auth");
await createSession(res, cred.user_id);
res.json({ ok: true, redirect: takeNext(req, res) || "/" });
});