mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 23:07:29 +00:00
feat(pwa): logicsrc credentials app — real auth + Turso, redesigned; retire commandboard-api credshare
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:
parent
f057589d66
commit
9ba044577f
46 changed files with 2785 additions and 1730 deletions
64
apps/pwa/src/config.mjs
Normal file
64
apps/pwa/src/config.mjs
Normal 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
29
apps/pwa/src/db.mjs
Normal 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;
|
||||
}
|
||||
36
apps/pwa/src/lib/apikey.mjs
Normal file
36
apps/pwa/src/lib/apikey.mjs
Normal 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;
|
||||
}
|
||||
41
apps/pwa/src/lib/crypto.mjs
Normal file
41
apps/pwa/src/lib/crypto.mjs
Normal 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
121
apps/pwa/src/lib/html.mjs
Normal 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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>`;
|
||||
86
apps/pwa/src/lib/session.mjs
Normal file
86
apps/pwa/src/lib/session.mjs
Normal 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()); }
|
||||
29
apps/pwa/src/lib/users.mjs
Normal file
29
apps/pwa/src/lib/users.mjs
Normal 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
33
apps/pwa/src/migrate.mjs
Normal 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); });
|
||||
}
|
||||
55
apps/pwa/src/migrations/001_auth.sql
Normal file
55
apps/pwa/src/migrations/001_auth.sql
Normal 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
|
||||
);
|
||||
91
apps/pwa/src/migrations/002_credshare.sql
Normal file
91
apps/pwa/src/migrations/002_credshare.sql
Normal 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);
|
||||
81
apps/pwa/src/routes/auth.mjs
Normal file
81
apps/pwa/src/routes/auth.mjs
Normal 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("/");
|
||||
});
|
||||
85
apps/pwa/src/routes/cli.mjs
Normal file
85
apps/pwa/src/routes/cli.mjs
Normal 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 & 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 & 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 });
|
||||
});
|
||||
68
apps/pwa/src/routes/coinpay.mjs
Normal file
68
apps/pwa/src/routes/coinpay.mjs
Normal 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");
|
||||
}
|
||||
});
|
||||
259
apps/pwa/src/routes/credshare.mjs
Normal file
259
apps/pwa/src/routes/credshare.mjs
Normal 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 });
|
||||
}));
|
||||
168
apps/pwa/src/routes/pages.mjs
Normal file
168
apps/pwa/src/routes/pages.mjs
Normal 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 <team> prod --env .env # share
|
||||
logicsrc teams pull <team> 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");
|
||||
});
|
||||
110
apps/pwa/src/routes/passkey.mjs
Normal file
110
apps/pwa/src/routes/passkey.mjs
Normal 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
58
apps/pwa/src/server.mjs
Normal 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue