Add ncue_app_config and /api/config/auth endpoints, and hydrate Auth0 config from the server so other browsers don't see the setup modal. Co-authored-by: Cursor <cursoragent@cursor.com>
256 lines
8.7 KiB
JavaScript
256 lines
8.7 KiB
JavaScript
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;
|
|
}
|
|
|
|
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(["dosangyoon@gmail.com", "dsyoon@ncue.net"]);
|
|
|
|
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,
|
|
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`);
|
|
}
|
|
|
|
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()
|
|
)
|
|
`);
|
|
}
|
|
|
|
function isAdminEmail(email) {
|
|
const e = String(email || "").trim().toLowerCase();
|
|
return ADMIN_EMAILS.has(e);
|
|
}
|
|
|
|
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 q = `
|
|
insert into public.${TABLE}
|
|
(sub, email, name, picture, provider, first_login_at, last_login_at, updated_at)
|
|
values
|
|
($1, $2, $3, $4, $5, now(), now(), 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(),
|
|
updated_at = now()
|
|
returning can_manage, first_login_at, last_login_at, last_logout_at
|
|
`;
|
|
const r = await pool.query(q, [sub, email, name, picture, provider]);
|
|
const canManage = Boolean(r.rows?.[0]?.can_manage);
|
|
|
|
res.json({ ok: true, canManage, 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 {
|
|
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" });
|
|
res.json({ ok: true, value: r.rows[0].value, updated_at: r.rows[0].updated_at });
|
|
} 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 : {};
|
|
const allowedEmails = 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 = allowedEmails.map((x) => String(x).trim().toLowerCase()).filter(Boolean);
|
|
|
|
if (!domain || !clientId || !googleConn) {
|
|
return res.status(400).json({ ok: false, error: "missing_fields" });
|
|
}
|
|
|
|
// Optional safety: ensure at least one admin is present in allowedEmails
|
|
if (emails.length && !emails.some(isAdminEmail)) {
|
|
return res.status(400).json({ ok: false, error: "admin_email_missing" });
|
|
}
|
|
|
|
const value = {
|
|
auth0: { domain, clientId },
|
|
connections: { google: googleConn },
|
|
allowedEmails: 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" });
|
|
}
|
|
});
|
|
|
|
// 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}`);
|
|
});
|
|
|