import "dotenv/config"; import express from "express"; import helmet from "helmet"; import path from "node:path"; import { fileURLToPath } from "node:url"; import pg from "pg"; import { createRemoteJWKSet, jwtVerify } from "jose"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); function env(name, fallback = "") { return (process.env[name] ?? fallback).toString(); } function must(name) { const v = env(name).trim(); if (!v) throw new Error(`Missing env: ${name}`); return v; } function safeIdent(s) { const v = String(s || "").trim(); if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(v)) throw new Error("Invalid TABLE identifier"); return v; } function parseCsv(s) { return String(s || "") .split(",") .map((x) => x.trim()) .filter(Boolean); } function parseEmailCsv(s) { return parseCsv(s).map((x) => x.toLowerCase()); } const PORT = Number(env("PORT", "8000")) || 8000; const DB_HOST = must("DB_HOST"); const DB_PORT = Number(env("DB_PORT", "5432")) || 5432; const DB_NAME = must("DB_NAME"); const DB_USER = must("DB_USER"); const DB_PASSWORD = must("DB_PASSWORD"); const TABLE = safeIdent(env("TABLE", "ncue_user") || "ncue_user"); const CONFIG_TABLE = "ncue_app_config"; const CONFIG_TOKEN = env("CONFIG_TOKEN", "").trim(); const ADMIN_EMAILS = new Set(parseEmailCsv(env("ADMIN_EMAILS", ""))); // Auth0 config via .env (preferred) const AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").trim(); const AUTH0_CLIENT_ID = env("AUTH0_CLIENT_ID", "").trim(); const AUTH0_GOOGLE_CONNECTION = env("AUTH0_GOOGLE_CONNECTION", "").trim(); const pool = new pg.Pool({ host: DB_HOST, port: DB_PORT, database: DB_NAME, user: DB_USER, password: DB_PASSWORD, ssl: false, max: 10, }); const app = express(); app.use( helmet({ contentSecurityPolicy: false, // keep simple for static + Auth0 }) ); app.use(express.json({ limit: "256kb" })); app.get("/healthz", async (_req, res) => { try { await pool.query("select 1 as ok"); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } }); function getBearer(req) { const h = req.headers.authorization || ""; const m = /^Bearer\s+(.+)$/i.exec(h); return m ? m[1].trim() : ""; } async function verifyIdToken(idToken, { issuer, audience }) { const jwks = createRemoteJWKSet(new URL(`${issuer}.well-known/jwks.json`)); const { payload } = await jwtVerify(idToken, jwks, { issuer, audience, }); return payload; } async function ensureUserTable() { // Create table if missing + add columns for upgrades await pool.query(` create table if not exists public.${TABLE} ( sub text primary key, email text, name text, picture text, provider text, first_login_at timestamptz, last_login_at timestamptz, last_logout_at timestamptz, is_admin boolean not null default false, can_manage boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ) `); await pool.query(`create index if not exists idx_${TABLE}_email on public.${TABLE} (email)`); await pool.query(`alter table public.${TABLE} add column if not exists first_login_at timestamptz`); await pool.query(`alter table public.${TABLE} add column if not exists last_logout_at timestamptz`); await pool.query(`alter table public.${TABLE} add column if not exists is_admin boolean not null default false`); // Backward-compat: previously can_manage was used as admin flag. Preserve existing admins. await pool.query(`update public.${TABLE} set is_admin = true where can_manage = true and is_admin = false`); // Keep can_manage consistent with is_admin going forward. await pool.query(`update public.${TABLE} set can_manage = is_admin where can_manage is distinct from is_admin`); } async function ensureConfigTable() { await pool.query(` create table if not exists public.${CONFIG_TABLE} ( key text primary key, value jsonb not null, updated_at timestamptz not null default now() ) `); } app.post("/api/auth/sync", async (req, res) => { try { await ensureUserTable(); const idToken = getBearer(req); if (!idToken) return res.status(401).json({ ok: false, error: "missing_token" }); const issuer = String(req.headers["x-auth0-issuer"] || "").trim(); const audience = String(req.headers["x-auth0-clientid"] || "").trim(); if (!issuer || !audience) return res.status(400).json({ ok: false, error: "missing_auth0_headers" }); const payload = await verifyIdToken(idToken, { issuer, audience }); const sub = String(payload.sub || "").trim(); const email = payload.email ? String(payload.email).trim().toLowerCase() : null; const name = payload.name ? String(payload.name).trim() : null; const picture = payload.picture ? String(payload.picture).trim() : null; const provider = sub.includes("|") ? sub.split("|", 1)[0] : null; if (!sub) return res.status(400).json({ ok: false, error: "missing_sub" }); const bootstrapAdmin = Boolean(email && ADMIN_EMAILS.has(email)); const q = ` insert into public.${TABLE} (sub, email, name, picture, provider, first_login_at, last_login_at, is_admin, can_manage, updated_at) values ($1, $2, $3, $4, $5, now(), now(), $6, $6, now()) on conflict (sub) do update set email = excluded.email, name = excluded.name, picture = excluded.picture, provider = excluded.provider, first_login_at = coalesce(public.${TABLE}.first_login_at, excluded.first_login_at), last_login_at = now(), is_admin = (public.${TABLE}.is_admin or public.${TABLE}.can_manage or $6), can_manage = (public.${TABLE}.is_admin or public.${TABLE}.can_manage or $6), updated_at = now() returning is_admin, can_manage, first_login_at, last_login_at, last_logout_at `; const r = await pool.query(q, [sub, email, name, picture, provider, bootstrapAdmin]); const isAdmin = Boolean(r.rows?.[0]?.is_admin); res.json({ ok: true, canManage: isAdmin, user: r.rows?.[0] || null }); } catch (e) { res.status(401).json({ ok: false, error: "verify_failed" }); } }); app.post("/api/auth/logout", async (req, res) => { try { await ensureUserTable(); const idToken = getBearer(req); if (!idToken) return res.status(401).json({ ok: false, error: "missing_token" }); const issuer = String(req.headers["x-auth0-issuer"] || "").trim(); const audience = String(req.headers["x-auth0-clientid"] || "").trim(); if (!issuer || !audience) return res.status(400).json({ ok: false, error: "missing_auth0_headers" }); const payload = await verifyIdToken(idToken, { issuer, audience }); const sub = String(payload.sub || "").trim(); if (!sub) return res.status(400).json({ ok: false, error: "missing_sub" }); const q = ` update public.${TABLE} set last_logout_at = now(), updated_at = now() where sub = $1 returning last_logout_at `; const r = await pool.query(q, [sub]); res.json({ ok: true, last_logout_at: r.rows?.[0]?.last_logout_at || null }); } catch (e) { res.status(401).json({ ok: false, error: "verify_failed" }); } }); // Shared auth config for all browsers (read-only public) app.get("/api/config/auth", async (_req, res) => { try { // Prefer .env config (no UI needed) if (AUTH0_DOMAIN && AUTH0_CLIENT_ID && AUTH0_GOOGLE_CONNECTION) { return res.json({ ok: true, value: { auth0: { domain: AUTH0_DOMAIN, clientId: AUTH0_CLIENT_ID }, connections: { google: AUTH0_GOOGLE_CONNECTION }, // Deprecated: admin is stored per-user in DB (ncue_user.is_admin) adminEmails: [], }, updated_at: null, source: "env", }); } await ensureConfigTable(); const r = await pool.query(`select value, updated_at from public.${CONFIG_TABLE} where key = $1`, ["auth"]); if (!r.rows?.length) return res.status(404).json({ ok: false, error: "not_set" }); const v = r.rows[0].value || {}; // legacy: allowedEmails -> adminEmails if (v && typeof v === "object" && !v.adminEmails && Array.isArray(v.allowedEmails)) { v.adminEmails = v.allowedEmails; } res.json({ ok: true, value: v, updated_at: r.rows[0].updated_at, source: "db" }); } catch (e) { res.status(500).json({ ok: false, error: "server_error" }); } }); // Write auth config (protected by CONFIG_TOKEN) app.post("/api/config/auth", async (req, res) => { try { await ensureConfigTable(); if (!CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "config_token_not_set" }); const token = String(req.headers["x-config-token"] || "").trim(); if (token !== CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "forbidden" }); const body = req.body && typeof req.body === "object" ? req.body : {}; const auth0 = body.auth0 && typeof body.auth0 === "object" ? body.auth0 : {}; const connections = body.connections && typeof body.connections === "object" ? body.connections : {}; // legacy: allowedEmails -> adminEmails const adminEmails = Array.isArray(body.adminEmails) ? body.adminEmails : Array.isArray(body.allowedEmails) ? body.allowedEmails : []; const domain = String(auth0.domain || "").trim(); const clientId = String(auth0.clientId || "").trim(); const googleConn = String(connections.google || "").trim(); const emails = adminEmails.map((x) => String(x).trim().toLowerCase()).filter(Boolean); if (!domain || !clientId || !googleConn) { return res.status(400).json({ ok: false, error: "missing_fields" }); } const value = { auth0: { domain, clientId }, connections: { google: googleConn }, adminEmails: emails, }; await pool.query( `insert into public.${CONFIG_TABLE} (key, value, updated_at) values ($1, $2::jsonb, now()) on conflict (key) do update set value = excluded.value, updated_at = now()`, ["auth", JSON.stringify(value)] ); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false, error: "server_error" }); } }); // CONFIG_TOKEN-protected: set per-user admin flag in DB app.post("/api/admin/users/set_admin", async (req, res) => { try { await ensureUserTable(); if (!CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "config_token_not_set" }); const token = String(req.headers["x-config-token"] || "").trim(); if (token !== CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "forbidden" }); const body = req.body && typeof req.body === "object" ? req.body : {}; const sub = String(body.sub || "").trim(); const email = String(body.email || "").trim().toLowerCase(); const isAdmin = typeof body.isAdmin === "boolean" ? body.isAdmin : typeof body.admin === "boolean" ? body.admin : null; if (typeof isAdmin !== "boolean") return res.status(400).json({ ok: false, error: "missing_isAdmin" }); if (!sub && !email) return res.status(400).json({ ok: false, error: "missing_identifier" }); const q = sub ? `update public.${TABLE} set is_admin = $1, can_manage = $1, updated_at = now() where sub = $2 returning sub, email, is_admin` : `update public.${TABLE} set is_admin = $1, can_manage = $1, updated_at = now() where email = $2 returning sub, email, is_admin`; const r = await pool.query(q, [isAdmin, sub ? sub : email]); if (!r.rows?.length) return res.status(404).json({ ok: false, error: "not_found" }); res.json({ ok: true, user: r.rows[0] }); } catch { res.status(500).json({ ok: false, error: "server_error" }); } }); // Serve static site app.use(express.static(__dirname, { extensions: ["html"] })); app.listen(PORT, () => { // eslint-disable-next-line no-console console.log(`listening on http://localhost:${PORT}`); });