130 lines
4.6 KiB
TypeScript
130 lines
4.6 KiB
TypeScript
|
|
/**
|
||
|
|
* admin-relay.ts — Admin endpoints for the relay account whitelist.
|
||
|
|
*
|
||
|
|
* All routes are protected by ADMIN_SECRET env var (Bearer token).
|
||
|
|
* If ADMIN_SECRET is not set, the routes reject all requests in production
|
||
|
|
* and accept only from localhost in development.
|
||
|
|
*
|
||
|
|
* Routes:
|
||
|
|
* GET /api/admin/relay/accounts — list all relay accounts
|
||
|
|
* POST /api/admin/relay/accounts/:pubkey/grant — grant access to a pubkey
|
||
|
|
* POST /api/admin/relay/accounts/:pubkey/revoke — revoke access from a pubkey
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { Router, type Request, type Response, type NextFunction } from "express";
|
||
|
|
import { makeLogger } from "../lib/logger.js";
|
||
|
|
import { relayAccountService } from "../lib/relay-accounts.js";
|
||
|
|
import { RELAY_ACCESS_LEVELS, type RelayAccessLevel } from "@workspace/db";
|
||
|
|
|
||
|
|
const logger = makeLogger("admin-relay");
|
||
|
|
const router = Router();
|
||
|
|
|
||
|
|
const ADMIN_SECRET = process.env["ADMIN_SECRET"] ?? "";
|
||
|
|
const IS_PROD = process.env["NODE_ENV"] === "production";
|
||
|
|
|
||
|
|
if (!ADMIN_SECRET) {
|
||
|
|
if (IS_PROD) {
|
||
|
|
logger.error(
|
||
|
|
"ADMIN_SECRET is not set in production — admin relay routes are unprotected. " +
|
||
|
|
"Set ADMIN_SECRET in the API server environment immediately.",
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
logger.warn("ADMIN_SECRET not set — admin relay routes accept local-only requests (dev mode)");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Admin auth middleware ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||
|
|
if (ADMIN_SECRET) {
|
||
|
|
const authHeader = req.headers["authorization"] ?? "";
|
||
|
|
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
|
||
|
|
if (token !== ADMIN_SECRET) {
|
||
|
|
res.status(401).json({ error: "Unauthorized" });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
const ip = req.ip ?? "";
|
||
|
|
const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
|
||
|
|
if (!isLocal) {
|
||
|
|
logger.warn("admin-relay: no secret configured, rejecting non-local call", { ip });
|
||
|
|
res.status(401).json({ error: "Unauthorized" });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
next();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── GET /admin/relay/accounts ─────────────────────────────────────────────────
|
||
|
|
|
||
|
|
router.get("/admin/relay/accounts", requireAdmin, async (_req: Request, res: Response) => {
|
||
|
|
const accounts = await relayAccountService.list();
|
||
|
|
res.json({ accounts });
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── POST /admin/relay/accounts/:pubkey/grant ──────────────────────────────────
|
||
|
|
|
||
|
|
router.post(
|
||
|
|
"/admin/relay/accounts/:pubkey/grant",
|
||
|
|
requireAdmin,
|
||
|
|
async (req: Request, res: Response) => {
|
||
|
|
const { pubkey } = req.params as { pubkey: string };
|
||
|
|
|
||
|
|
if (!pubkey || pubkey.length !== 64 || !/^[0-9a-f]+$/.test(pubkey)) {
|
||
|
|
res.status(400).json({ error: "pubkey must be a 64-char lowercase hex string" });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const body = req.body as { level?: string; notes?: string };
|
||
|
|
const level = (body.level ?? "write").toLowerCase() as RelayAccessLevel;
|
||
|
|
|
||
|
|
if (!RELAY_ACCESS_LEVELS.includes(level)) {
|
||
|
|
res.status(400).json({
|
||
|
|
error: `Invalid access level '${level}'. Must be one of: ${RELAY_ACCESS_LEVELS.join(", ")}`,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const notes = typeof body.notes === "string" ? body.notes : "admin grant";
|
||
|
|
|
||
|
|
await relayAccountService.grant(pubkey, level, notes, "manual");
|
||
|
|
|
||
|
|
logger.info("admin granted relay access", {
|
||
|
|
pubkey: pubkey.slice(0, 8),
|
||
|
|
level,
|
||
|
|
notes,
|
||
|
|
});
|
||
|
|
|
||
|
|
res.json({ ok: true, pubkey, accessLevel: level, notes });
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
// ── POST /admin/relay/accounts/:pubkey/revoke ─────────────────────────────────
|
||
|
|
|
||
|
|
router.post(
|
||
|
|
"/admin/relay/accounts/:pubkey/revoke",
|
||
|
|
requireAdmin,
|
||
|
|
async (req: Request, res: Response) => {
|
||
|
|
const { pubkey } = req.params as { pubkey: string };
|
||
|
|
|
||
|
|
if (!pubkey || pubkey.length !== 64 || !/^[0-9a-f]+$/.test(pubkey)) {
|
||
|
|
res.status(400).json({ error: "pubkey must be a 64-char lowercase hex string" });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const body = req.body as { reason?: string };
|
||
|
|
const reason = typeof body.reason === "string" ? body.reason : "admin revoke";
|
||
|
|
|
||
|
|
await relayAccountService.revoke(pubkey, reason);
|
||
|
|
|
||
|
|
logger.info("admin revoked relay access", {
|
||
|
|
pubkey: pubkey.slice(0, 8),
|
||
|
|
reason,
|
||
|
|
});
|
||
|
|
|
||
|
|
res.json({ ok: true, pubkey, revokedAt: new Date().toISOString(), reason });
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
export default router;
|