Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
139 changes: 132 additions & 7 deletions api.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand All @@ -31,6 +33,7 @@ app.use(helmet({
crossOriginEmbedderPolicy: false,
}));

app.use(compression());
app.use(express.json({ limit: "100kb" }));

app.use((req, res, next) => {
Expand Down Expand Up @@ -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" },
});

Expand All @@ -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" },
});

Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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}`);
Expand Down
120 changes: 120 additions & 0 deletions lib/api-keys.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading