From a76f1b40d0ec9c2403e7104b364f459fe4579834 Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Mon, 11 May 2026 19:48:03 +0530 Subject: [PATCH] feat: admin dashboard, API key management, response compression - Admin dashboard at /admin with stats, key CRUD, error log viewer - Firestore-backed API keys: create, rotate, revoke, delete - Auth middleware validates owner + sandbox + developer keys - Response compression (gzip/brotli) via compression middleware - Owner key rotation via /api/keys/rotate + Secret Manager - 10 new admin key API tests - Auto-refresh stats every 30s, logout, copy-to-clipboard --- CHANGELOG.md | 4 +- README.md | 2 +- api.js | 139 +++++++++++++++++++-- lib/api-keys.js | 120 ++++++++++++++++++ public/admin/admin.css | 267 ++++++++++++++++++++++++++++++++++++++++ public/admin/admin.js | 246 ++++++++++++++++++++++++++++++++++++ public/admin/index.html | 53 ++++++++ terraform/README.md | 8 +- test/api-test.js | 84 +++++++++++++ 9 files changed, 910 insertions(+), 13 deletions(-) create mode 100644 lib/api-keys.js create mode 100644 public/admin/admin.css create mode 100644 public/admin/admin.js create mode 100644 public/admin/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index febb31c..18ef74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,9 @@ Initial public beta. ### Infrastructure - Cloud Run `casecomp-api` (API) + `casecomp-site` (frontend SSR with Cloud CDN) - HTTPS LB routes by host: casecomp.xyz → site, api.casecomp.xyz → API -- Firestore, managed SSL certs, Secret Manager (incl. sandbox key) +- Cloudflare SSL + edge caching for casecomp.xyz (~85ms TTFB, down from 1,210ms) +- GCP managed SSL for api.casecomp.xyz +- Firestore, Secret Manager (incl. sandbox key) - Cloud Monitoring: error alerts + uptime check on /api/health - Terraform with GCS state backend - Workload Identity Federation for GitHub Actions → GCP (no stored keys) diff --git a/README.md b/README.md index 32219bf..c96a308 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ All caches use Firestore (shared across Cloud Run instances, persists across dep ## Infrastructure -GCP (Terraform managed): Cloud Run `casecomp-api` (API) + `casecomp-site` (frontend SSR), Firestore, HTTPS load balancer with Cloud CDN, managed SSL, Secret Manager, Cloud Monitoring alerts. State in GCS bucket. Same LB IP routes by host: `casecomp.xyz` → site, `api.casecomp.xyz` → API. See `terraform/`. +GCP (Terraform managed): Cloud Run `casecomp-api` (API) + `casecomp-site` (frontend SSR with Cloud CDN), Firestore, HTTPS LB, Secret Manager, Cloud Monitoring. Cloudflare handles SSL + edge caching for `casecomp.xyz` (~85ms TTFB). GCP managed SSL for `api.casecomp.xyz`. Same LB IP routes by host. See `terraform/`. ## Chrome Extension diff --git a/api.js b/api.js index 98d7362..a719665 100644 --- a/api.js +++ b/api.js @@ -1,6 +1,7 @@ import "dotenv/config"; import crypto from "crypto"; import express from "express"; +import compression from "compression"; import helmet from "helmet"; import rateLimit from "express-rate-limit"; import swaggerUi from "swagger-ui-express"; @@ -17,6 +18,7 @@ import { EBAY_CATEGORY_TCG_SINGLE_CARDS_US } from "./lib/ebayCategories.js"; import { getRedisStatus, sha256 } from "./lib/redis-cache.js"; import { saveGradeLog, getGradeLogs, saveDrop, getDrops, getDrop, saveWebhook, getWebhooks, deleteWebhook, getFirestoreStatus, saveAlert, saveErrorLog, getErrorLogs } from "./lib/firestore.js"; import { getDemoSearchResult, listDemoCards } from "./lib/demo.js"; +import { createApiKey, listApiKeys, getApiKey, updateApiKey, deleteApiKey, rotateApiKey, validateApiKey } from "./lib/api-keys.js"; import { fileURLToPath } from "url"; import path from "path"; @@ -31,6 +33,7 @@ app.use(helmet({ crossOriginEmbedderPolicy: false, })); +app.use(compression()); app.use(express.json({ limit: "100kb" })); app.use((req, res, next) => { @@ -80,7 +83,6 @@ const demoLimiter = rateLimit({ max: 20, standardHeaders: true, legacyHeaders: false, - keyGenerator: (req) => req.ip, message: { error: "Too many requests, please try again later" }, }); @@ -89,7 +91,6 @@ const sandboxLimiter = rateLimit({ max: 5, standardHeaders: true, legacyHeaders: false, - keyGenerator: (req) => req.ip, message: { error: "Sandbox rate limit: 5 requests per minute" }, }); @@ -403,13 +404,25 @@ app.get("/api/health", async (req, res) => { // ============ V1 API — Drop Intelligence ============ -function authMiddleware(req, res, next) { - const key = process.env.CASECOMP_API_KEY; +async function authMiddleware(req, res, next) { + const ownerKey = process.env.CASECOMP_API_KEY; const sandboxKey = process.env.CASECOMP_SANDBOX_KEY; - if (!key) return next(); + if (!ownerKey) return next(); + const token = getRequestToken(req); + if (!token) return res.status(401).json({ error: "Invalid or missing API key" }); + if (token === ownerKey || token === sandboxKey) return next(); + const devKey = await validateApiKey(token); + if (devKey) { + req._devKey = devKey; + return next(); + } + return res.status(401).json({ error: "Invalid or missing API key" }); +} + +function ownerOnly(req, res, next) { const token = getRequestToken(req); - if (!token || (token !== key && token !== sandboxKey)) { - return res.status(401).json({ error: "Invalid or missing API key" }); + if (token !== process.env.CASECOMP_API_KEY) { + return res.status(403).json({ error: "Owner key required" }); } next(); } @@ -566,6 +579,118 @@ app.post("/api/alerts", authMiddleware, async (req, res) => { } }); +// ============ Admin — API Key Management ============ + +const admin = express.Router(); +admin.use(ownerOnly); + +// GET /admin/keys — list all developer keys +admin.get("/keys", async (req, res) => { + try { + const keys = await listApiKeys(); + res.json({ keys, count: keys.length }); + } catch (e) { + logError("admin", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + +// POST /admin/keys — create a new developer key +admin.post("/keys", async (req, res) => { + const { label, rateLimit: rl } = req.body; + if (!label) return res.status(400).json({ error: "Missing label" }); + try { + const result = await createApiKey({ label, rateLimit: rl }); + res.status(201).json(result); + } catch (e) { + logError("admin", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + +// GET /admin/keys/:id — get a single key +admin.get("/keys/:id", async (req, res) => { + try { + const key = await getApiKey(req.params.id); + if (!key) return res.status(404).json({ error: "Key not found" }); + res.json(key); + } catch (e) { + logError("admin", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + +// PATCH /admin/keys/:id — update label, rateLimit, active +admin.patch("/keys/:id", async (req, res) => { + try { + const updated = await updateApiKey(req.params.id, req.body); + if (!updated) return res.status(404).json({ error: "Key not found" }); + res.json(updated); + } catch (e) { + logError("admin", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + +// DELETE /admin/keys/:id — delete a key +admin.delete("/keys/:id", async (req, res) => { + try { + const deleted = await deleteApiKey(req.params.id); + if (!deleted) return res.status(404).json({ error: "Key not found" }); + res.json({ ok: true, id: req.params.id }); + } catch (e) { + logError("admin", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + +// POST /admin/keys/:id/rotate — rotate a developer key +admin.post("/keys/:id/rotate", async (req, res) => { + try { + const result = await rotateApiKey(req.params.id); + if (!result) return res.status(404).json({ error: "Key not found" }); + res.json(result); + } catch (e) { + logError("admin", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + +app.use("/admin", admin); + +// POST /api/keys/rotate — rotate the owner API key +app.post("/api/keys/rotate", async (req, res) => { + const token = getRequestToken(req); + const ownerKey = process.env.CASECOMP_API_KEY; + if (!token || token !== ownerKey) { + return res.status(401).json({ error: "Only the owner key can rotate keys" }); + } + + try { + const { SecretManagerServiceClient } = await import("@google-cloud/secret-manager"); + const client = new SecretManagerServiceClient(); + const projectId = process.env.GCLOUD_PROJECT || "casecomp-495718"; + + const newKey = `CC_LIVE_${crypto.randomBytes(24).toString("base64url")}`; + + await client.addSecretVersion({ + parent: `projects/${projectId}/secrets/CASECOMP_API_KEY`, + payload: { data: Buffer.from(newKey) }, + }); + + process.env.CASECOMP_API_KEY = newKey; + + res.json({ + ok: true, + key: newKey, + note: "New key is active immediately. Old key is invalid. Store this key — it won't be shown again.", + }); + } catch (e) { + logError("keys", e.message, req.originalUrl, req.requestId); + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + const PORT = process.env.API_PORT || 3000; app.listen(PORT, async () => { console.log(`Casecomp API listening on http://localhost:${PORT}`); diff --git a/lib/api-keys.js b/lib/api-keys.js new file mode 100644 index 0000000..9e76126 --- /dev/null +++ b/lib/api-keys.js @@ -0,0 +1,120 @@ +import crypto from "crypto"; +import { Firestore } from "@google-cloud/firestore"; + +const COLLECTION = "api-keys"; + +let db = null; +function getDb() { + if (db) return db; + try { db = new Firestore(); return db; } catch { return null; } +} + +function hashKey(key) { + return crypto.createHash("sha256").update(key).digest("hex"); +} + +function generateKey() { + return `CC_LIVE_${crypto.randomBytes(24).toString("base64url")}`; +} + +export async function createApiKey({ label, rateLimit = 60 }) { + const fs = getDb(); + if (!fs) throw new Error("Firestore unavailable"); + + const key = generateKey(); + const id = `key_${Date.now().toString(36)}`; + const doc = { + id, + keyHash: hashKey(key), + keyPrefix: key.slice(0, 16) + "...", + label: label || "Unnamed", + rateLimit: rateLimit || 60, + active: true, + createdAt: new Date().toISOString(), + lastUsedAt: null, + requestCount: 0, + }; + + await fs.collection(COLLECTION).doc(id).set(doc); + return { ...doc, key }; +} + +export async function listApiKeys() { + const fs = getDb(); + if (!fs) return []; + const snap = await fs.collection(COLLECTION).orderBy("createdAt", "desc").get(); + return snap.docs.map(d => d.data()); +} + +export async function getApiKey(id) { + const fs = getDb(); + if (!fs) return null; + const doc = await fs.collection(COLLECTION).doc(id).get(); + return doc.exists ? doc.data() : null; +} + +export async function updateApiKey(id, updates) { + const fs = getDb(); + if (!fs) throw new Error("Firestore unavailable"); + const doc = await fs.collection(COLLECTION).doc(id).get(); + if (!doc.exists) return null; + const allowed = {}; + if (updates.label !== undefined) allowed.label = updates.label; + if (updates.rateLimit !== undefined) allowed.rateLimit = Number(updates.rateLimit); + if (updates.active !== undefined) allowed.active = Boolean(updates.active); + await fs.collection(COLLECTION).doc(id).update(allowed); + return { ...doc.data(), ...allowed }; +} + +export async function deleteApiKey(id) { + const fs = getDb(); + if (!fs) return false; + const doc = await fs.collection(COLLECTION).doc(id).get(); + if (!doc.exists) return false; + await fs.collection(COLLECTION).doc(id).delete(); + return true; +} + +export async function rotateApiKey(id) { + const fs = getDb(); + if (!fs) throw new Error("Firestore unavailable"); + const doc = await fs.collection(COLLECTION).doc(id).get(); + if (!doc.exists) return null; + const newKey = generateKey(); + await fs.collection(COLLECTION).doc(id).update({ + keyHash: hashKey(newKey), + keyPrefix: newKey.slice(0, 16) + "...", + }); + return { ...doc.data(), key: newKey, keyPrefix: newKey.slice(0, 16) + "..." }; +} + +let keyCache = null; +let keyCacheAt = 0; +const KEY_CACHE_TTL = 30_000; + +export async function validateApiKey(token) { + if (!token) return null; + + const now = Date.now(); + if (!keyCache || now - keyCacheAt > KEY_CACHE_TTL) { + const fs = getDb(); + if (!fs) return null; + const snap = await fs.collection(COLLECTION).where("active", "==", true).get(); + keyCache = snap.docs.map(d => d.data()); + keyCacheAt = now; + } + + const hash = hashKey(token); + const match = keyCache.find(k => k.keyHash === hash); + if (!match) return null; + + const fs = getDb(); + if (fs) { + fs.collection(COLLECTION).doc(match.id).update({ + lastUsedAt: new Date().toISOString(), + requestCount: Firestore.FieldValue.increment(1), + }).catch(() => {}); + } + + return match; +} diff --git a/public/admin/admin.css b/public/admin/admin.css new file mode 100644 index 0000000..24bd796 --- /dev/null +++ b/public/admin/admin.css @@ -0,0 +1,267 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #07070a; + --panel: #0c0d12; + --inset: #14151c; + --gold: #d9b676; + --gold-dim: rgba(217, 182, 118, 0.15); + --green: #7ce0a8; + --red: #ff5d5d; + --text: #e8e8ec; + --muted: #8a8a9a; + --border: rgba(255, 255, 255, 0.08); +} + +body { + background: var(--bg); + color: var(--text); + font-family: 'Inter', system-ui, sans-serif; + font-size: 14px; + line-height: 1.5; + min-height: 100vh; + padding: 0 24px; +} + +h2, h3 { font-family: 'Space Grotesk', system-ui, sans-serif; font-weight: 600; } + +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 0; + border-bottom: 1px solid var(--border); + max-width: 900px; + margin: 0 auto; +} + +.logo { + font-family: 'Space Grotesk', system-ui, sans-serif; + font-size: 18px; + font-weight: 700; + color: var(--gold); +} + +.auth-bar { display: flex; gap: 8px; } + +input { + padding: 8px 12px; + background: var(--inset); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 13px; + font-family: inherit; + outline: none; +} +input:focus { border-color: rgba(217, 182, 118, 0.4); } +input[type="password"] { width: 260px; } +input[type="text"] { flex: 1; } +input[type="number"] { width: 120px; } + +button { + padding: 8px 16px; + background: var(--gold); + color: var(--bg); + border: none; + border-radius: 6px; + font-family: 'Space Grotesk', system-ui, sans-serif; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +button:hover { opacity: 0.85; } +button.danger { background: var(--red); } +button.secondary { + background: none; + border: 1px solid var(--border); + color: var(--muted); +} +button.secondary:hover { color: var(--text); border-color: rgba(255,255,255,0.15); } + +main { + max-width: 900px; + margin: 24px auto; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + margin-bottom: 12px; +} + +#create-form { + display: flex; + gap: 8px; + align-items: center; +} + +.key-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + margin-bottom: 10px; +} + +.key-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.key-label { + font-family: 'Space Grotesk', system-ui, sans-serif; + font-size: 15px; + font-weight: 600; +} + +.key-meta { + display: flex; + gap: 20px; + font-size: 12px; + color: var(--muted); +} + +.key-meta .value { + color: var(--text); + font-family: 'Space Grotesk', system-ui, sans-serif; +} + +.key-prefix { + font-family: monospace; + font-size: 12px; + color: var(--gold); + background: var(--gold-dim); + padding: 2px 8px; + border-radius: 4px; +} + +.key-actions { + display: flex; + gap: 6px; + margin-top: 10px; +} + +.status-active { color: var(--green); } +.status-revoked { color: var(--red); } + +.new-key-display { + margin-top: 10px; + padding: 10px; + background: var(--inset); + border: 1px solid var(--gold); + border-radius: 6px; + font-family: monospace; + font-size: 12px; + word-break: break-all; + color: var(--gold); +} + +#status { + padding: 10px; + border-radius: 6px; + font-size: 13px; + margin-bottom: 12px; +} +#status.success { background: rgba(124, 224, 168, 0.1); color: var(--green); } +#status.error { background: rgba(255, 93, 93, 0.1); color: var(--red); } + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; + margin-bottom: 16px; +} +.stat-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; + text-align: center; +} +.stat-card .stat-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 4px; +} +.stat-card .stat-value { + font-family: 'Space Grotesk', system-ui, sans-serif; + font-size: 22px; + font-weight: 700; +} +.stat-card .stat-value.ok { color: var(--green); } +.stat-card .stat-value.warn { color: var(--gold); } +.stat-card .stat-value.bad { color: var(--red); } + +.quick-links { + display: flex; + gap: 8px; + margin-bottom: 20px; +} +.quick-links a { + font-size: 12px; + color: var(--muted); + border: 1px solid var(--border); + padding: 4px 12px; + border-radius: 14px; + text-decoration: none; + transition: all 0.2s; +} +.quick-links a:hover { color: var(--gold); border-color: var(--gold); } + +.error-row { + display: flex; + gap: 12px; + align-items: baseline; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + font-size: 12px; +} +.error-row:last-child { border-bottom: none; } +.error-time { + color: var(--muted); + font-family: monospace; + white-space: nowrap; + min-width: 130px; +} +.error-type { + font-family: 'Space Grotesk', system-ui, sans-serif; + font-weight: 600; + color: var(--red); + min-width: 60px; +} +.error-msg { + color: var(--text); + flex: 1; +} +.error-detail { + color: var(--muted); + font-family: monospace; + font-size: 11px; +} +.error-rid { + color: var(--muted); + font-family: monospace; + font-size: 10px; +} +.no-errors { + color: var(--muted); + text-align: center; + padding: 20px; + font-size: 13px; +} + +.hidden { display: none !important; } diff --git a/public/admin/admin.js b/public/admin/admin.js new file mode 100644 index 0000000..07597f6 --- /dev/null +++ b/public/admin/admin.js @@ -0,0 +1,246 @@ +let ownerKey = ""; + +const $ = (s) => document.querySelector(s); +const $$ = (s) => document.querySelectorAll(s); + +async function api(path, opts = {}) { + const headers = { Authorization: `Bearer ${ownerKey}`, ...opts.headers }; + if (opts.body) headers["Content-Type"] = "application/json"; + const res = await fetch(`/admin${path}`, { ...opts, headers }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; +} + +function showStatus(msg, type) { + const el = $("#status"); + el.textContent = msg; + el.className = type; + el.classList.remove("hidden"); + setTimeout(() => el.classList.add("hidden"), 4000); +} + +function formatDate(d) { + if (!d) return "never"; + return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} + +function renderKey(k) { + const statusClass = k.active ? "status-active" : "status-revoked"; + const statusText = k.active ? "Active" : "Revoked"; + return ` +
+
+
+ ${esc(k.label)} + ${esc(k.keyPrefix)} +
+ ${statusText} +
+
+
Rate limit: ${k.rateLimit}/min
+
Requests: ${(k.requestCount || 0).toLocaleString()}
+
Last used: ${formatDate(k.lastUsedAt)}
+
Created: ${formatDate(k.createdAt)}
+
+
+ + + +
+
+ `; +} + +async function loadStats() { + try { + const [health, errors, { keys }] = await Promise.all([ + fetch("/api/health").then(r => r.json()), + fetch(`/api/errors?limit=100`, { headers: { Authorization: `Bearer ${ownerKey}` } }).then(r => r.json()), + api("/keys"), + ]); + + const totalRequests = keys.reduce((s, k) => s + (k.requestCount || 0), 0); + const activeKeys = keys.filter(k => k.active).length; + const ebayUsage = health.ebay?.usageToday || 0; + const ebayPct = Math.round((ebayUsage / (health.ebay?.dailyCap || 5000)) * 100); + const fsLatency = health.firestore?.latencyMs || "—"; + const errorCount = errors.count || 0; + + $("#stats").innerHTML = ` +
+
Total Requests
+
${totalRequests.toLocaleString()}
+
+
+
Active Keys
+
${activeKeys}
+
+
+
eBay Usage
+
${ebayUsage} / ${health.ebay?.dailyCap || "?"}
+
+
+
Errors
+
${errorCount}
+
+
+
Firestore
+
${fsLatency}ms
+
+
+
Uptime
+
${formatUptime(health.uptime)}
+
+ `; + + } catch {} +} + +function formatUptime(seconds) { + if (!seconds) return "—"; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} + +async function loadKeys() { + try { + const { keys } = await api("/keys"); + $("#key-list").innerHTML = keys.length + ? keys.map(renderKey).join("") + : '
No developer keys yet
'; + } catch (e) { + showStatus(e.message, "error"); + } +} + +$("#auth-btn").addEventListener("click", async () => { + ownerKey = $("#owner-key").value.trim(); + if (!ownerKey) return; + try { + await api("/keys"); + $("#main").classList.remove("hidden"); + $("#owner-key").type = "text"; + $("#owner-key").value = "Authenticated"; + $("#owner-key").disabled = true; + $("#auth-btn").disabled = true; + $("#logout-btn").classList.remove("hidden"); + loadStats(); + loadKeys(); + loadErrors(); + window._refreshInterval = setInterval(() => { loadStats(); loadKeys(); loadErrors(); }, 30000); + } catch (e) { + showStatus("Authentication failed: " + e.message, "error"); + $("#main").classList.add("hidden"); + } +}); + +$("#logout-btn").addEventListener("click", () => { + if (window._refreshInterval) clearInterval(window._refreshInterval); + ownerKey = ""; + $("#main").classList.add("hidden"); + $("#logout-btn").classList.add("hidden"); + $("#owner-key").type = "password"; + $("#owner-key").value = ""; + $("#owner-key").disabled = false; + $("#auth-btn").disabled = false; + $("#key-list").innerHTML = ""; + showStatus("Logged out", "success"); +}); + +$("#owner-key").addEventListener("keydown", (e) => { + if (e.key === "Enter") $("#auth-btn").click(); +}); + +$("#create-btn").addEventListener("click", () => { + $("#create-form").classList.toggle("hidden"); + $("#new-label").focus(); +}); + +$("#submit-create").addEventListener("click", async () => { + const label = $("#new-label").value.trim(); + const rateLimit = parseInt($("#new-rate").value) || 60; + if (!label) return showStatus("Label is required", "error"); + try { + const result = await api("/keys", { + method: "POST", + body: JSON.stringify({ label, rateLimit }), + }); + $("#create-form").classList.add("hidden"); + $("#new-label").value = ""; + showStatus("Key created", "success"); + await loadKeys(); + const card = $(`.key-card[data-id="${result.id}"]`); + if (card) { + card.insertAdjacentHTML("beforeend", `
New key (won't be shown again):
${esc(result.key)}
`); + } + } catch (e) { + showStatus(e.message, "error"); + } +}); + +async function rotateKey(id) { + if (!confirm("Rotate this key? The old key will stop working immediately.")) return; + try { + const result = await api(`/keys/${id}/rotate`, { method: "POST" }); + showStatus("Key rotated", "success"); + await loadKeys(); + const card = $(`.key-card[data-id="${id}"]`); + if (card) { + card.insertAdjacentHTML("beforeend", `
New key (won't be shown again):
${esc(result.key)}
`); + } + } catch (e) { + showStatus(e.message, "error"); + } +} + +async function toggleKey(id, active) { + try { + await api(`/keys/${id}`, { method: "PATCH", body: JSON.stringify({ active }) }); + showStatus(active ? "Key activated" : "Key revoked", "success"); + loadKeys(); + } catch (e) { + showStatus(e.message, "error"); + } +} + +async function deleteKey(id) { + if (!confirm("Delete this key permanently?")) return; + try { + await api(`/keys/${id}`, { method: "DELETE" }); + showStatus("Key deleted", "success"); + loadKeys(); + } catch (e) { + showStatus(e.message, "error"); + } +} + +async function loadErrors() { + try { + const res = await fetch(`/api/errors?limit=20`, { headers: { Authorization: `Bearer ${ownerKey}` } }); + const { errors } = await res.json(); + if (!errors || !errors.length) { + $("#error-list").innerHTML = '
No errors
'; + return; + } + $("#error-list").innerHTML = `
${errors.map(e => ` +
+ ${formatDate(e.ts)} + ${esc(e.type)} + ${esc(e.message)} + ${esc(e.detail)} + ${e.requestId ? `${esc(e.requestId)}` : ""} +
+ `).join("")}
`; + } catch (e) { + $("#error-list").innerHTML = `
${esc(e.message)}
`; + } +} + +function esc(s) { + if (s == null) return ""; + const d = document.createElement("div"); + d.textContent = String(s); + return d.innerHTML; +} diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100644 index 0000000..b3fa454 --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,53 @@ + + + + + + Casecomp Admin — API Keys + + + + + +
+ +
+ + + +
+
+ +
+
+ + + +
+

Developer Keys

+ +
+ + + +
+ +
+

Recent Errors

+ +
+
+ + +
+ + + + diff --git a/terraform/README.md b/terraform/README.md index db8e155..7425fa1 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -11,7 +11,7 @@ GCP infrastructure for Casecomp. State is stored in a GCS bucket (`casecomp-terr | Firestore | Grade logs, drops, webhooks, alerts, all caches | | HTTPS Load Balancer | Global IP (`34.107.143.136`), URL map routes by host | | Cloud CDN | Caches static assets from frontend Cloud Run | -| SSL Certificates | Managed certs for `api.casecomp.xyz` and `casecomp.xyz` + `www` | +| SSL Certificates | GCP managed cert for `api.casecomp.xyz`; Cloudflare handles `casecomp.xyz` SSL | | GCS Bucket `casecomp-site` | (Legacy) Static site bucket, replaced by Cloud Run SSR | | Secret Manager | EBAY_CLIENT_ID/SECRET, ANTHROPIC_API_KEY, PSA_AUTH_TOKEN, CASECOMP_API_KEY, CASECOMP_SANDBOX_KEY | | Cloud Monitoring | Log-based metric on `[ERROR]`, error + uptime alerts → email | @@ -19,9 +19,9 @@ GCP infrastructure for Casecomp. State is stored in a GCS bucket (`casecomp-terr ## Routing -Same LB IP, routed by hostname: -- `casecomp.xyz` / `www.casecomp.xyz` → Cloud Run `casecomp-site` (CDN enabled) -- `api.casecomp.xyz` → Cloud Run `casecomp-api` +Same LB IP (`34.107.143.136`), routed by hostname: +- `casecomp.xyz` / `www.casecomp.xyz` → Cloudflare (SSL) → GCP LB → Cloud Run `casecomp-site` (CDN enabled) +- `api.casecomp.xyz` → GCP LB (managed SSL) → Cloud Run `casecomp-api` ## Variables diff --git a/test/api-test.js b/test/api-test.js index 4c76506..3382c74 100644 --- a/test/api-test.js +++ b/test/api-test.js @@ -296,6 +296,90 @@ async function run() { assert(body._demo === true); }); + // ── Admin keys ── + + console.log("\n\x1b[1m=== admin keys ===\x1b[0m"); + + let testKeyId = null; + + await test("GET /admin/keys without owner key returns 403", async () => { + const { res } = await jsonNoAuth("/admin/keys"); + assert(res.status === 403 || res.status === 401, `expected 401/403, got ${res.status}`); + }); + + await test("GET /admin/keys with owner key returns list", async () => { + const { res, body } = await json("/admin/keys"); + assert(res.status === 200, `status ${res.status}`); + assert(Array.isArray(body.keys)); + assert(typeof body.count === "number"); + }); + + await test("POST /admin/keys creates a key", async () => { + const { res, body } = await json("/admin/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: "test-key", rateLimit: 10 }), + }); + assert(res.status === 201, `status ${res.status}`); + assert(body.id?.startsWith("key_"), `bad id: ${body.id}`); + assert(body.key?.startsWith("CC_LIVE_"), "key should start with CC_LIVE_"); + assert(body.label === "test-key"); + assert(body.rateLimit === 10); + assert(body.active === true); + testKeyId = body.id; + }); + + await test("POST /admin/keys requires label", async () => { + const { res } = await json("/admin/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + assert(res.status === 400, `expected 400, got ${res.status}`); + }); + + if (testKeyId) { + await test("GET /admin/keys/:id returns single key", async () => { + const { res, body } = await json(`/admin/keys/${testKeyId}`); + assert(res.status === 200); + assert(body.id === testKeyId); + assert(body.label === "test-key"); + }); + + await test("PATCH /admin/keys/:id updates key", async () => { + const { res, body } = await json(`/admin/keys/${testKeyId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: "updated-key", active: false }), + }); + assert(res.status === 200); + assert(body.label === "updated-key"); + assert(body.active === false); + }); + + await test("POST /admin/keys/:id/rotate returns new key", async () => { + const { res, body } = await json(`/admin/keys/${testKeyId}/rotate`, { method: "POST" }); + assert(res.status === 200); + assert(body.key?.startsWith("CC_LIVE_"), "rotated key should start with CC_LIVE_"); + }); + + await test("DELETE /admin/keys/:id deletes key", async () => { + const { res, body } = await json(`/admin/keys/${testKeyId}`, { method: "DELETE" }); + assert(res.status === 200); + assert(body.ok === true); + }); + + await test("GET /admin/keys/:id after delete returns 404", async () => { + const { res } = await json(`/admin/keys/${testKeyId}`); + assert(res.status === 404, `expected 404, got ${res.status}`); + }); + } + + await test("GET /admin/keys/nonexistent returns 404", async () => { + const { res } = await json("/admin/keys/key_nonexistent_xyz"); + assert(res.status === 404); + }); + // ── Demo data ── console.log("\n\x1b[1m=== demo data ===\x1b[0m");