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

64
apps/pwa/src/config.mjs Normal file
View file

@ -0,0 +1,64 @@
// Central config. Reads .env (if present) with zero deps, then process.env wins.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
// Tiny .env loader — does not override anything already in the environment.
function loadEnv() {
const file = path.join(ROOT, ".env");
if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i.exec(line);
if (!m) continue;
const key = m[1];
if (process.env[key] !== undefined) continue;
let val = m[2];
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
process.env[key] = val;
}
}
loadEnv();
const origin = (process.env.PUBLIC_ORIGIN || `http://localhost:${process.env.PORT || 8080}`).replace(/\/+$/, "");
const rpID = new URL(origin).hostname;
export const config = {
root: ROOT,
env: process.env.NODE_ENV || "development",
port: Number(process.env.PORT || 8080),
origin,
// WebAuthn relying party = this host.
rpID,
rpName: "LogicSRC",
sessionSecret: process.env.SESSION_SECRET || "dev-insecure-secret-change-me",
db: {
// Turso (libSQL) in prod; a local file for dev. TURSO_* takes precedence.
url: process.env.TURSO_DATABASE_URL || process.env.DATABASE_URL || "file:./data/local.db",
authToken: process.env.TURSO_AUTH_TOKEN || process.env.DATABASE_AUTH_TOKEN || undefined,
},
resend: {
apiKey: process.env.RESEND_API_KEY || "",
from: process.env.CREDSHARE_EMAIL_FROM || process.env.RESEND_FROM || "LogicSRC <noreply@logicsrc.com>",
},
coinpay: {
apiBase: (process.env.COINPAY_API_BASE || "https://coinpayportal.com").replace(/\/+$/, ""),
businessId: process.env.COINPAY_BUSINESS_ID || "",
webhookSecret: process.env.COINPAY_WEBHOOK_SECRET || "",
oauth: {
authorizeUrl: process.env.COINPAY_OAUTH_AUTHORIZE_URL || "",
tokenUrl: process.env.COINPAY_OAUTH_TOKEN_URL || "",
userinfoUrl: process.env.COINPAY_OAUTH_USERINFO_URL || "",
clientId: process.env.COINPAY_OAUTH_CLIENT_ID || "",
redirectUri: `${origin}/auth/coinpay/callback`,
scope: process.env.COINPAY_OAUTH_SCOPE || "openid profile",
},
},
get coinpayLoginEnabled() {
return Boolean(this.coinpay.oauth.authorizeUrl && this.coinpay.oauth.clientId);
},
secure: (process.env.NODE_ENV || "development") === "production",
};

29
apps/pwa/src/db.mjs Normal file
View file

@ -0,0 +1,29 @@
// libSQL (SQLite / Turso) client + a tiny query helper.
import { createClient } from "@libsql/client";
import fs from "node:fs";
import path from "node:path";
import { config } from "./config.mjs";
// For a local file: url, make sure the directory exists.
if (config.db.url.startsWith("file:")) {
const p = config.db.url.slice("file:".length);
const dir = path.dirname(path.resolve(config.root, p));
fs.mkdirSync(dir, { recursive: true });
}
export const db = createClient({ url: config.db.url, authToken: config.db.authToken });
/** Run a statement; returns the raw result. */
export const run = (sql, args = []) => db.execute({ sql, args });
/** First row (or null). */
export async function get(sql, args = []) {
const r = await db.execute({ sql, args });
return r.rows[0] ?? null;
}
/** All rows. */
export async function all(sql, args = []) {
const r = await db.execute({ sql, args });
return r.rows;
}

View file

@ -0,0 +1,36 @@
// API keys (lsk_…) for the logicsrc CLI to authenticate.
import { get, all, run } from "../db.mjs";
import { id, token, sha256 } from "./crypto.mjs";
// Returns { plaintext, row } — plaintext shown ONCE.
export async function createApiKey(userId, name = "cli") {
const plaintext = "lsk_" + token(24);
const prefix = plaintext.slice(0, 12);
const row = { id: id(), user_id: userId, name, token_hash: sha256(plaintext), prefix, created_at: Date.now() };
await run(
`INSERT INTO api_keys (id, user_id, name, token_hash, prefix, created_at) VALUES (?,?,?,?,?,?)`,
[row.id, row.user_id, row.name, row.token_hash, row.prefix, row.created_at]
);
return { plaintext, row };
}
// Resolve a Bearer token to its owning user (or null). Updates last_used_at.
export async function userForApiKey(bearer) {
if (!bearer) return null;
const key = await get(`SELECT * FROM api_keys WHERE token_hash = ?`, [sha256(bearer)]);
if (!key) return null;
await run(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, [Date.now(), key.id]);
return get(`SELECT * FROM users WHERE id = ?`, [key.user_id]);
}
export const listApiKeys = (userId) =>
all(`SELECT id, name, prefix, created_at, last_used_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`, [userId]);
export const revokeApiKey = (userId, keyId) =>
run(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, [keyId, userId]);
// Pull the Bearer token off a request.
export function bearer(req) {
const h = req.get("authorization") || "";
return h.startsWith("Bearer ") ? h.slice(7).trim() : null;
}

View file

@ -0,0 +1,41 @@
// Password hashing (scrypt) + signed cookies + tokens — all node:crypto, no deps.
import crypto from "node:crypto";
import { config } from "../config.mjs";
// ---- passwords (scrypt) ----
export function hashPassword(password) {
const salt = crypto.randomBytes(16);
const dk = crypto.scryptSync(String(password), salt, 32);
return `scrypt$${salt.toString("hex")}$${dk.toString("hex")}`;
}
export function verifyPassword(password, stored) {
if (!stored || !stored.startsWith("scrypt$")) return false;
const [, saltHex, hashHex] = stored.split("$");
const salt = Buffer.from(saltHex, "hex");
const expected = Buffer.from(hashHex, "hex");
const dk = crypto.scryptSync(String(password), salt, expected.length);
return dk.length === expected.length && crypto.timingSafeEqual(dk, expected);
}
// ---- ids / tokens ----
export const id = () => crypto.randomUUID();
export const token = (bytes = 32) => crypto.randomBytes(bytes).toString("base64url");
export const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
// ---- signed cookies (stateless ceremony state) ----
export function sign(value) {
const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
const mac = crypto.createHmac("sha256", config.sessionSecret).update(payload).digest("base64url");
return `${payload}.${mac}`;
}
export function unsign(signed) {
if (!signed || typeof signed !== "string" || !signed.includes(".")) return null;
const [payload, mac] = signed.split(".");
const expected = crypto.createHmac("sha256", config.sessionSecret).update(payload).digest("base64url");
const a = Buffer.from(mac);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
try { return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); } catch { return null; }
}

121
apps/pwa/src/lib/html.mjs Normal file
View file

@ -0,0 +1,121 @@
// Server-rendered views in the LogicSRC brand (matches logicsrc.com):
// light ground (#f6f7f4), ink text (#101418), green accent (#0a7d59), Inter.
// Keeps the same class vocabulary the auth routes use so they reskin for free.
export function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])
);
}
export const BRAND_CSS = `
:root{
color-scheme:light;
--bg:#f6f7f4;--surface:#ffffff;--surface-2:#f0f2ec;
--ink:#101418;--text:#101418;--dim:#58615b;--faint:#8b938a;
--line:#d9ded4;--line-2:#c7cec1;
--green:#0a7d59;--green-2:#0b8f66;--green-ink:#ffffff;--mint:#5ac8a6;
--danger:#c23a3a;--warn:#b7791f;
--rail:#101418;--rail-text:#f6f7f4;--rail-dim:#b5beb2;--rail-line:#263039;
--mono:ui-monospace,"JetBrains Mono","SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
--sans:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
--maxw:72rem;--r:10px;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:var(--sans);line-height:1.55;-webkit-font-smoothing:antialiased}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
a{color:var(--green);text-decoration:none}
a:hover{text-decoration:underline}
::selection{background:var(--mint);color:#06110c}
h1,h2,h3{margin:0;font-weight:800;letter-spacing:-.01em;text-wrap:balance}
h1{font-size:2.2rem;line-height:1.05}
.label{font-family:var(--mono);font-size:.64rem;letter-spacing:.18em;text-transform:uppercase;color:var(--faint)}
.mono{font-family:var(--mono)}
.dim{color:var(--dim)}.faint{color:var(--faint)}.acid,.green{color:var(--green)}
.pill{font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;text-transform:uppercase;padding:3px 9px;border-radius:999px;border:1px solid var(--line-2);color:var(--dim);white-space:nowrap}
.pill.on{color:var(--green);border-color:color-mix(in srgb,var(--green) 45%,var(--line));background:color-mix(in srgb,var(--green) 8%,transparent)}
.pill.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,var(--line))}
.btn{font-family:var(--sans);font-size:.85rem;font-weight:600;padding:10px 16px;border-radius:8px;cursor:pointer;border:1px solid var(--line-2);background:var(--surface);color:var(--text);display:inline-flex;align-items:center;justify-content:center;gap:8px;transition:border-color .14s,background .14s,transform .05s;white-space:nowrap;text-align:center}
.btn:hover{border-color:var(--faint);background:var(--surface-2);text-decoration:none}
.btn:active{transform:translateY(1px)}
.btn.acid,.btn.primary{background:var(--green);color:var(--green-ink);border-color:var(--green);font-weight:700}
.btn.acid:hover,.btn.primary:hover{background:var(--green-2);border-color:var(--green-2)}
.btn.danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 45%,var(--line))}
.btn.danger:hover{background:color-mix(in srgb,var(--danger) 10%,transparent);border-color:var(--danger)}
.btn.block{width:100%}
.btn:focus-visible{outline:2px solid var(--green);outline-offset:2px}
input,textarea,select{font-family:var(--mono);font-size:.85rem;color:var(--text);background:var(--surface);border:1px solid var(--line-2);border-radius:9px;padding:11px 13px;width:100%}
input:focus,textarea:focus{outline:none;border-color:var(--green)}
input::placeholder,textarea::placeholder{color:var(--faint)}
label.field{display:block;margin-bottom:14px}
label.field span{display:block;font-family:var(--mono);font-size:.66rem;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:6px}
.card{border:1px solid var(--line);border-radius:var(--r);background:var(--surface)}
.card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 16px;border-bottom:1px solid var(--line)}
.card-head .h{font-family:var(--mono);font-size:.72rem;letter-spacing:.14em;text-transform:uppercase;color:var(--faint)}
.card-body{padding:18px}
.bar{position:sticky;top:0;z-index:30;background:var(--rail);color:var(--rail-text);border-bottom:1px solid var(--rail-line)}
.bar a{color:var(--rail-text)}
.bar-inner{display:flex;align-items:center;gap:16px;height:60px}
.brand{display:flex;align-items:center;gap:10px;font-weight:800;letter-spacing:-.01em;font-size:1.12rem}
.brand .mark{width:26px;height:26px;border-radius:6px;border:1px solid var(--mint);color:var(--mint);display:grid;place-items:center;font-family:var(--mono);font-weight:800;font-size:.8rem;background:transparent}
.brand .app{font-family:var(--mono);font-weight:600;font-size:.62rem;letter-spacing:.2em;color:var(--faint);text-transform:uppercase;border:1px solid var(--line-2);padding:2px 6px;border-radius:5px}
.bar .brand .app{color:var(--rail-dim);border-color:var(--rail-line)}
.bar-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.grid{display:grid;grid-template-columns:1.55fr .95fr;gap:22px;align-items:start}
.col{display:flex;flex-direction:column;gap:22px}
.section-title{display:flex;align-items:baseline;gap:12px;margin-bottom:14px}
.section-title h2{font-size:1.24rem}
.section-title .count{font-family:var(--mono);font-size:.74rem;color:var(--green)}
.notice{border:1px solid var(--line-2);border-radius:9px;padding:11px 14px;font-family:var(--mono);font-size:.78rem;margin-bottom:16px}
.notice.err{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 45%,var(--line));background:color-mix(in srgb,var(--danger) 6%,transparent)}
.notice.ok{color:var(--green);border-color:color-mix(in srgb,var(--green) 45%,var(--line));background:color-mix(in srgb,var(--green) 6%,transparent)}
.divider{display:flex;align-items:center;gap:12px;color:var(--faint);font-family:var(--mono);font-size:.66rem;letter-spacing:.16em;text-transform:uppercase;margin:18px 0}
.divider::before,.divider::after{content:"";height:1px;background:var(--line);flex:1}
table{width:100%;border-collapse:collapse;font-size:.9rem}
th{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);font-family:var(--mono);font-size:.66rem;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);font-weight:600}
td{padding:8px 10px;border-bottom:1px solid var(--line)}
code{font-family:var(--mono);font-size:.85em;background:var(--surface-2);padding:1px 5px;border-radius:4px}
footer{border-top:1px solid var(--line);padding:26px 0;margin-top:40px}
.foot{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;align-items:center;font-family:var(--mono);font-size:.74rem;color:var(--faint)}
.foot .green b{color:var(--green);font-weight:600}
@media (max-width:940px){.grid{grid-template-columns:1fr}}
`;
/** Full HTML document with the brand shell. */
export function page({ title = "LogicSRC ▸ credentials", body = "", head = "" }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#101418">
<title>${esc(title)}</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<style>${BRAND_CSS}</style>
${head}
</head>
<body>${body}
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(()=>{})}</script>
</body>
</html>`;
}
export function appBar(user) {
return `<header class="bar"><div class="wrap bar-inner">
<a class="brand" href="/"><span class="mark">LS</span>LogicSRC<span class="app">credentials</span></a>
<div class="bar-right">
${user
? `<span class="mono faint" style="font-size:.78rem">${esc(user.email || user.display_name || "signed in")}</span>
<a class="btn" href="/settings">Settings</a>
<form method="post" action="/auth/logout" style="margin:0"><button class="btn">Sign out</button></form>`
: `<a class="btn acid" href="/">Sign in</a>`}
</div>
</div></header>`;
}
export const footer = `<footer><div class="wrap foot">
<div class="brand" style="font-size:.9rem;color:var(--ink)"><span class="mark" style="width:20px;height:20px;font-size:.62rem">LS</span>LogicSRC</div>
<div style="display:flex;gap:20px;flex-wrap:wrap"><a href="https://logicsrc.com">logicsrc.com</a><a href="/">Teams</a><a href="/settings">Settings</a></div>
<div class="green">end-to-end encrypted. <b>the server never sees your secrets.</b></div>
</div></footer>`;

View file

@ -0,0 +1,86 @@
// Cookie sessions + auth middleware + CSRF (double-submit).
import { get, run } from "../db.mjs";
import { id, token, sign, unsign } from "./crypto.mjs";
import { config } from "../config.mjs";
const COOKIE = "mc_sess";
const CSRF = "mc_csrf";
const TTL = 1000 * 60 * 60 * 24 * 30; // 30 days
function cookieOpts(extra = {}) {
return { httpOnly: true, sameSite: "lax", secure: config.secure, path: "/", ...extra };
}
export async function createSession(res, userId) {
const t = token();
const now = Date.now();
await run(`INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)`,
[t, userId, now, now + TTL]);
res.cookie(COOKIE, t, cookieOpts({ maxAge: TTL }));
}
export async function destroySession(req, res) {
const t = req.cookies?.[COOKIE];
if (t) await run(`DELETE FROM sessions WHERE token = ?`, [t]);
res.clearCookie(COOKIE, cookieOpts());
}
// Attach req.user (or null) from the session cookie, and ensure a CSRF token.
export async function sessionMiddleware(req, res, next) {
req.user = null;
const t = req.cookies?.[COOKIE];
if (t) {
const row = await get(
`SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?`,
[t, Date.now()]
);
if (row) req.user = row;
else res.clearCookie(COOKIE, cookieOpts());
}
// double-submit CSRF token
let csrf = req.cookies?.[CSRF];
if (!csrf) { csrf = token(16); res.cookie(CSRF, csrf, cookieOpts({ httpOnly: false, maxAge: TTL })); }
req.csrfToken = csrf;
next();
}
export function requireAuth(req, res, next) {
if (!req.user) { setNext(res, req.originalUrl); return res.redirect("/"); }
next();
}
// Remember where to go after login (safe local paths only), across any auth method.
export function setNext(res, pathname) {
if (typeof pathname === "string" && pathname.startsWith("/") && !pathname.startsWith("//")) {
res.cookie("mc_next", sign(pathname), cookieOpts({ maxAge: 1000 * 60 * 10 }));
}
}
export function takeNext(req, res) {
const p = unsign(req.cookies?.mc_next);
if (req.cookies?.mc_next) res.clearCookie("mc_next", cookieOpts());
return typeof p === "string" && p.startsWith("/") && !p.startsWith("//") ? p : null;
}
// CSRF guard for unsafe methods on browser (form) routes. API/webhooks are Bearer/HMAC.
export function csrfGuard(req, res, next) {
if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
// machine endpoints are Bearer/HMAC/PKCE-authenticated, not cookie sessions
if (req.path.startsWith("/api/") || req.path.startsWith("/webhooks/") ||
req.path === "/cli/token" || req.path.startsWith("/cli/device/")) return next();
const sent = req.body?._csrf || req.get("x-csrf-token");
if (!sent || sent !== req.cookies?.[CSRF]) return res.status(403).send("bad csrf token");
next();
}
export const csrfInput = (req) => `<input type="hidden" name="_csrf" value="${req.csrfToken}">`;
// ---- ephemeral auth-ceremony state (webauthn challenge / oauth pkce) in signed cookies ----
export function setCeremony(res, name, value, ttlMs = 1000 * 60 * 5) {
res.cookie(`mc_c_${name}`, sign({ v: value, exp: Date.now() + ttlMs }), cookieOpts({ maxAge: ttlMs }));
}
export function getCeremony(req, name) {
const data = unsign(req.cookies?.[`mc_c_${name}`]);
if (!data || data.exp < Date.now()) return null;
return data.v;
}
export function clearCeremony(res, name) { res.clearCookie(`mc_c_${name}`, cookieOpts()); }

View file

@ -0,0 +1,29 @@
// User creation + lookups.
import { get, run } from "../db.mjs";
import { id } from "./crypto.mjs";
export async function createUserWithPassword(email, passwordHash, displayName) {
const uid = id();
await run(`INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?,?,?,?,?)`,
[uid, email, passwordHash, displayName || email.split("@")[0], Date.now()]);
return get(`SELECT * FROM users WHERE id = ?`, [uid]);
}
export async function createUserForCoinpay(sub, displayName) {
const uid = id();
await run(`INSERT INTO users (id, coinpay_sub, display_name, created_at) VALUES (?,?,?,?)`,
[uid, sub, displayName || "logicsrc user", Date.now()]);
return get(`SELECT * FROM users WHERE id = ?`, [uid]);
}
// Passkey-first signup (no email/password yet).
export async function createUserPasskey(displayName) {
const uid = id();
await run(`INSERT INTO users (id, display_name, created_at) VALUES (?,?,?)`,
[uid, displayName || "logicsrc user", Date.now()]);
return get(`SELECT * FROM users WHERE id = ?`, [uid]);
}
export const userByEmail = (email) => get(`SELECT * FROM users WHERE email = ?`, [String(email).toLowerCase()]);
export const userByCoinpay = (sub) => get(`SELECT * FROM users WHERE coinpay_sub = ?`, [sub]);
export const userById = (uid) => get(`SELECT * FROM users WHERE id = ?`, [uid]);

33
apps/pwa/src/migrate.mjs Normal file
View file

@ -0,0 +1,33 @@
// Apply SQL migrations in order. Idempotent — tracks applied files in _migrations.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { db, run, all } from "./db.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const DIR = path.join(HERE, "migrations");
export async function migrate() {
// Bootstrap the tracking table (the first migration also declares it IF NOT EXISTS).
await run(`CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`);
const done = new Set((await all(`SELECT name FROM _migrations`)).map((r) => r.name));
const files = fs.readdirSync(DIR).filter((f) => f.endsWith(".sql")).sort();
for (const file of files) {
if (done.has(file)) { console.log(`· ${file} (already applied)`); continue; }
const sql = fs.readFileSync(path.join(DIR, file), "utf8");
// libSQL executes one statement per call — split on semicolons at line ends.
const statements = sql.split(/;\s*(?:\n|$)/).map((s) => s.trim()).filter(Boolean);
for (const stmt of statements) await run(stmt);
await run(`INSERT INTO _migrations (name, applied_at) VALUES (?, ?)`, [file, Date.now()]);
console.log(`${file}`);
}
console.log("migrations up to date");
}
// Run directly (npm run migrate) — not when imported by the server.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
migrate()
.then(() => db.close?.())
.catch((e) => { console.error(e); process.exit(1); });
}

View file

@ -0,0 +1,55 @@
-- LogicSRC credentials app — auth schema (libSQL / SQLite).
-- Ported from the moshcode PWA auth stack: email/password, passkeys, CoinPay
-- OAuth, server sessions, and CLI API keys.
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
password_hash TEXT, -- null when the user only uses passkey / coinpay
coinpay_sub TEXT UNIQUE, -- subject from "sign in with CoinPay"
display_name TEXT,
created_at INTEGER NOT NULL
);
-- WebAuthn / passkey credentials
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY, -- credential id (base64url)
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key TEXT NOT NULL, -- base64url COSE public key
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT, -- json array
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id);
-- server-side sessions (revocable cookie tokens)
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
-- API keys (lsk_…) for the logicsrc CLI to authenticate
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT,
token_hash TEXT NOT NULL, -- sha256 of the key; prefix stored for display
prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_apikeys_user ON api_keys(user_id);
-- Short-lived authorization codes for `logicsrc login` (loopback PKCE flow).
CREATE TABLE IF NOT EXISTS cli_auth_codes (
code TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_challenge TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
name TEXT,
used INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);

View file

@ -0,0 +1,91 @@
-- LogicSRC credential sharing — teams, vaults, and end-to-end-encrypted secrets.
-- Zero-knowledge: only ciphertext, per-member sealed vault keys, and member
-- identity public keys are stored. Members are the app's `users`.
-- A member's X25519 identity public key (one per user; the secret key stays on
-- their device in ~/.logicsrc/identity.json and is never uploaded).
CREATE TABLE IF NOT EXISTS credshare_keys (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
public_key TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS credshare_teams (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS credshare_members (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member', -- owner | admin | member
status TEXT NOT NULL DEFAULT 'invited', -- active | invited
invited_by TEXT REFERENCES users(id),
joined_at INTEGER,
created_at INTEGER NOT NULL,
UNIQUE(team_id, email)
);
CREATE INDEX IF NOT EXISTS idx_credshare_members_team ON credshare_members(team_id);
CREATE INDEX IF NOT EXISTS idx_credshare_members_user ON credshare_members(user_id);
CREATE TABLE IF NOT EXISTS credshare_invites (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
token_hash TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL,
accepted_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS credshare_vaults (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL,
UNIQUE(team_id, name)
);
-- Vault data-encryption key sealed to a member's public key (one row per member).
CREATE TABLE IF NOT EXISTS credshare_vault_grants (
vault_id TEXT NOT NULL REFERENCES credshare_vaults(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
wrapped_dek TEXT NOT NULL,
granted_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL,
PRIMARY KEY (vault_id, user_id)
);
-- Encrypted secrets (ciphertext + nonce decrypt only with the vault DEK, which
-- the server never sees). fingerprint is a salted hash for redacted diffs.
CREATE TABLE IF NOT EXISTS credshare_secrets (
vault_id TEXT 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,
updated_by TEXT NOT NULL REFERENCES users(id),
updated_at INTEGER NOT NULL,
PRIMARY KEY (vault_id, name)
);
CREATE TABLE IF NOT EXISTS credshare_audit (
id TEXT PRIMARY KEY,
team_id TEXT,
vault_id TEXT,
actor_user_id TEXT NOT NULL REFERENCES users(id),
action TEXT NOT NULL,
key_name TEXT,
fingerprint TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_credshare_audit_vault ON credshare_audit(vault_id, created_at DESC);

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) || "/" });
});

58
apps/pwa/src/server.mjs Normal file
View file

@ -0,0 +1,58 @@
// LogicSRC credentials — Express PWA entrypoint (auth + team credential sharing).
import express from "express";
import cookieParser from "cookie-parser";
import path from "node:path";
import { config } from "./config.mjs";
import { migrate } from "./migrate.mjs";
import { sessionMiddleware, csrfGuard } from "./lib/session.mjs";
import { authRouter } from "./routes/auth.mjs";
import { passkeyRouter } from "./routes/passkey.mjs";
import { coinpayRouter } from "./routes/coinpay.mjs";
import { credshareRouter } from "./routes/credshare.mjs";
import { cliRouter } from "./routes/cli.mjs";
import { pagesRouter } from "./routes/pages.mjs";
const app = express();
app.disable("x-powered-by");
if (config.secure) app.set("trust proxy", 1); // Railway terminates TLS
// body parsing — keep the raw body for HMAC signature verification
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } }));
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
// static
app.use(express.static(path.join(config.root, "public"), { maxAge: "1h" }));
// the @simplewebauthn/browser UMD bundle, served from node_modules (no CDN)
app.get("/vendor/simplewebauthn-browser.umd.js", (_req, res) =>
res.sendFile(path.join(config.root, "node_modules/@simplewebauthn/browser/dist/bundle/index.umd.min.js")));
app.get("/healthz", (_req, res) => res.json({ ok: true, env: config.env }));
app.use(sessionMiddleware);
app.use(csrfGuard);
// routes
app.use(authRouter); // GET / (+ /auth/login|register|logout)
app.use(passkeyRouter);
app.use(coinpayRouter);
app.use(credshareRouter); // /api/credshare/* (session or lsk_ Bearer)
app.use(cliRouter); // /cli/authorize, /cli/token, /api/me
app.use(pagesRouter); // /dashboard, /teams/*, /settings
app.use((req, res) => res.status(404).type("html").send(
`<body style="background:#f6f7f4;color:#101418;font-family:system-ui,sans-serif;padding:14vh 24px;text-align:center"><h1 style="color:#0a7d59">404</h1><p>no such page.</p><a style="color:#0a7d59" href="/">back to your teams →</a></body>`));
// eslint-disable-next-line no-unused-vars
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).type("html").send(`<body style="background:#f6f7f4;color:#c23a3a;font-family:system-ui,sans-serif;padding:14vh 24px;text-align:center"><h1>500</h1><p>something broke.</p></body>`);
});
async function main() {
await migrate();
app.listen(config.port, () => console.log(`🔐 logicsrc credentials on :${config.port} (${config.env}) — ${config.origin}`));
}
main().catch((e) => { console.error("boot failed:", e); process.exit(1); });
export { app };