From 40baec332d4045d576baeeb0de0db162e367a716 Mon Sep 17 00:00:00 2001 From: Alan Date: Sat, 27 Jun 2026 12:02:16 -0700 Subject: [PATCH] feat: add guarded admin reset endpoint --- README.md | 48 ++++++++++++ src/admin-reset.test.ts | 168 ++++++++++++++++++++++++++++++++++++++++ src/events.ts | 6 +- src/routes/admin.ts | 100 +++++++++++++++++++++++- src/store/state.ts | 7 +- 5 files changed, 323 insertions(+), 6 deletions(-) create mode 100644 src/admin-reset.test.ts diff --git a/README.md b/README.md index 337ee4f..2bb13ab 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,54 @@ BASE_URL=http://localhost:3001 } ``` +## Admin reset + +`POST /api/v1/admin/reset` is a destructive maintenance endpoint for local +tests and demos. It clears the process-local usage counters, service registry, +service metadata, disabled-service flags, API keys, webhooks, audit events, rate +buckets, pause flag, and runtime config. + +The endpoint is disabled by default. It only responds when +`ALLOW_ADMIN_RESET` is explicitly set to `true`, `1`, `yes`, or `on`. + +Do not enable `ALLOW_ADMIN_RESET` in production unless a separate admin-auth +layer protects this route. When disabled, the endpoint returns `404 not_found`. +When enabled, it emits an `admin.reset` audit event before clearing state and +returns a summary: + +```json +{ + "reset": true, + "cleared": { + "usage": 1, + "services": 1, + "servicesMetadata": 1, + "servicesDisabled": 1, + "apiKeys": 1, + "webhooks": 1, + "eventLog": 1, + "rateBuckets": 1, + "paused": true, + "config": { + "rateLimitPerWindow": 60, + "rateLimitWindowMs": 60000, + "bulkMaxItems": 100, + "eventLogCap": 10000 + } + }, + "paused": false, + "config": { + "rateLimitPerWindow": 60, + "rateLimitWindowMs": 60000, + "bulkMaxItems": 100, + "eventLogCap": 10000 + }, + "auditEvent": { + "type": "admin.reset" + } +} +``` + ## CI/CD On push/PR to `main`, GitHub Actions runs: diff --git a/src/admin-reset.test.ts b/src/admin-reset.test.ts new file mode 100644 index 0000000..03bd0ff --- /dev/null +++ b/src/admin-reset.test.ts @@ -0,0 +1,168 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import request from "supertest"; +import { createApp } from "./index.js"; +import { eventLog, recordEvent } from "./events.js"; +import { + apiKeyStore, + config, + DEFAULT_CONFIG, + pauseState, + rateBuckets, + servicesDisabled, + servicesMetadata, + servicesStore, + usageStore, + webhookStore, +} from "./store/state.js"; + +const originalAllowAdminReset = process.env.ALLOW_ADMIN_RESET; + +function resetTestState(): void { + apiKeyStore.clear(); + eventLog.length = 0; + rateBuckets.clear(); + servicesDisabled.clear(); + servicesMetadata.clear(); + servicesStore.clear(); + usageStore.clear(); + webhookStore.clear(); + pauseState.paused = false; + Object.assign(config, DEFAULT_CONFIG); +} + +function restoreAllowAdminReset(): void { + if (originalAllowAdminReset === undefined) { + delete process.env.ALLOW_ADMIN_RESET; + } else { + process.env.ALLOW_ADMIN_RESET = originalAllowAdminReset; + } +} + +function seedState(): void { + apiKeyStore.set("apk_seeded", { label: "ops", createdAt: 1 }); + rateBuckets.set("127.0.0.1", [1, 2, 3]); + servicesDisabled.add("svc-reset"); + servicesMetadata.set("svc-reset", { description: "demo", owner: "ops" }); + servicesStore.set("svc-reset", { priceStroops: 7 }); + usageStore.set("agent-reset::svc-reset", 9); + webhookStore.set("wh_seeded", { + url: "https://example.test/hook", + events: ["usage.recorded"], + createdAt: 2, + }); + pauseState.paused = true; + config.bulkMaxItems = 42; + recordEvent("usage.recorded", { agent: "agent-reset", serviceId: "svc-reset" }); +} + +beforeEach(() => { + resetTestState(); + delete process.env.ALLOW_ADMIN_RESET; +}); + +afterEach(() => { + resetTestState(); + restoreAllowAdminReset(); +}); + +void describe("admin reset route", () => { + void it("refuses reset while the explicit env gate is disabled", async () => { + const app = createApp(); + seedState(); + + const res = await request(app).post("/api/v1/admin/reset"); + + assert.strictEqual(res.status, 404); + assert.strictEqual(res.body.error, "not_found"); + assert.strictEqual(res.body.message, "admin reset is disabled"); + assert.strictEqual(usageStore.size, 1); + assert.strictEqual(servicesStore.size, 1); + assert.strictEqual(webhookStore.size, 1); + assert.strictEqual(eventLog.length, 1); + assert.strictEqual(rateBuckets.size, 1); + assert.strictEqual(pauseState.paused, true); + assert.strictEqual(config.bulkMaxItems, 42); + }); + + void it("clears all in-memory stores and returns a cleared-state summary", async () => { + process.env.ALLOW_ADMIN_RESET = "true"; + const app = createApp(); + seedState(); + + const res = await request(app).post("/api/v1/admin/reset"); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.reset, true); + assert.deepStrictEqual(res.body.cleared, { + usage: 1, + services: 1, + servicesMetadata: 1, + servicesDisabled: 1, + apiKeys: 1, + webhooks: 1, + eventLog: 1, + rateBuckets: 1, + paused: true, + config: { + rateLimitPerWindow: 60, + rateLimitWindowMs: 60_000, + bulkMaxItems: 42, + eventLogCap: 10_000, + }, + }); + assert.strictEqual(res.body.paused, false); + assert.deepStrictEqual(res.body.config, DEFAULT_CONFIG); + assert.strictEqual(res.body.auditEvent.type, "admin.reset"); + assert.deepStrictEqual(res.body.auditEvent.payload.cleared, res.body.cleared); + + assert.strictEqual(usageStore.size, 0); + assert.strictEqual(servicesStore.size, 0); + assert.strictEqual(servicesMetadata.size, 0); + assert.strictEqual(servicesDisabled.size, 0); + assert.strictEqual(apiKeyStore.size, 0); + assert.strictEqual(webhookStore.size, 0); + assert.strictEqual(eventLog.length, 0); + assert.strictEqual(rateBuckets.size, 0); + assert.strictEqual(pauseState.paused, false); + assert.deepStrictEqual(config, DEFAULT_CONFIG); + }); + + void it("is safe to call repeatedly on an already-empty backend", async () => { + process.env.ALLOW_ADMIN_RESET = "1"; + const app = createApp(); + + const first = await request(app).post("/api/v1/admin/reset"); + const second = await request(app).post("/api/v1/admin/reset"); + + assert.strictEqual(first.status, 200); + assert.strictEqual(second.status, 200); + assert.deepStrictEqual(first.body.cleared, { + usage: 0, + services: 0, + servicesMetadata: 0, + servicesDisabled: 0, + apiKeys: 0, + webhooks: 0, + eventLog: 0, + rateBuckets: 0, + paused: false, + config: DEFAULT_CONFIG, + }); + assert.deepStrictEqual(second.body.cleared, first.body.cleared); + assert.strictEqual(eventLog.length, 0); + assert.strictEqual(rateBuckets.size, 0); + }); + + void it("does not treat arbitrary env values as enabling production reset", async () => { + process.env.ALLOW_ADMIN_RESET = "enabled"; + const app = createApp(); + seedState(); + + const res = await request(app).post("/api/v1/admin/reset"); + + assert.strictEqual(res.status, 404); + assert.strictEqual(usageStore.size, 1); + assert.strictEqual(eventLog.length, 1); + }); +}); diff --git a/src/events.ts b/src/events.ts index e830988..2af05d6 100644 --- a/src/events.ts +++ b/src/events.ts @@ -13,7 +13,9 @@ export const eventLog: AppEvent[] = []; /** * Appends an audit event to the bounded in-memory event log. */ -export function recordEvent(type: string, payload: Record): void { - eventLog.push({ id: randomUUID(), ts: Date.now(), type, payload }); +export function recordEvent(type: string, payload: Record): AppEvent { + const event = { id: randomUUID(), ts: Date.now(), type, payload }; + eventLog.push(event); if (eventLog.length > EVENT_LOG_CAP) eventLog.shift(); + return event; } diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 0804ef0..2808200 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,5 +1,76 @@ -import { Router, type Response } from "express"; -import { pauseState } from "../store/state.js"; +import { Router, type Request, type Response } from "express"; +import { eventLog, recordEvent, type AppEvent } from "../events.js"; +import { + apiKeyStore, + config, + DEFAULT_CONFIG, + pauseState, + rateBuckets, + servicesDisabled, + servicesMetadata, + servicesStore, + usageStore, + webhookStore, +} from "../store/state.js"; +import { getRequestId } from "../types.js"; + +type AdminResetSummary = { + usage: number; + services: number; + servicesMetadata: number; + servicesDisabled: number; + apiKeys: number; + webhooks: number; + eventLog: number; + rateBuckets: number; + paused: boolean; + config: Record; +}; + +const ENABLED_RESET_VALUES = new Set(["1", "true", "yes", "on"]); + +function isAdminResetEnabled(): boolean { + return ENABLED_RESET_VALUES.has( + (process.env.ALLOW_ADMIN_RESET ?? "").trim().toLowerCase() + ); +} + +function getResetSummary(): AdminResetSummary { + return { + usage: usageStore.size, + services: servicesStore.size, + servicesMetadata: servicesMetadata.size, + servicesDisabled: servicesDisabled.size, + apiKeys: apiKeyStore.size, + webhooks: webhookStore.size, + eventLog: eventLog.length, + rateBuckets: rateBuckets.size, + paused: pauseState.paused, + config: { ...config }, + }; +} + +function resetConfig(): void { + Object.assign(config, DEFAULT_CONFIG); +} + +function clearInMemoryState(): { cleared: AdminResetSummary; auditEvent: AppEvent } { + const cleared = getResetSummary(); + const auditEvent = recordEvent("admin.reset", { cleared }); + + usageStore.clear(); + servicesStore.clear(); + servicesMetadata.clear(); + servicesDisabled.clear(); + apiKeyStore.clear(); + webhookStore.clear(); + eventLog.length = 0; + rateBuckets.clear(); + pauseState.paused = false; + resetConfig(); + + return { cleared, auditEvent }; +} /** * Builds the admin router that controls and reports the pause flag. @@ -21,5 +92,30 @@ export function createAdminRouter(): Router { res.json({ paused: pauseState.paused }); }); + /** + * Destructively clears process-local demo/test state behind an explicit + * environment gate. Production deployments should leave this disabled unless + * a separate admin-auth layer is added in front of the route. + */ + router.post("/api/v1/admin/reset", (req: Request, res: Response) => { + if (!isAdminResetEnabled()) { + res.status(404).json({ + error: "not_found", + message: "admin reset is disabled", + requestId: getRequestId(req), + }); + return; + } + + const { cleared, auditEvent } = clearInMemoryState(); + res.json({ + reset: true, + cleared, + paused: pauseState.paused, + config, + auditEvent, + }); + }); + return router; } diff --git a/src/store/state.ts b/src/store/state.ts index 6c573f4..b8d3627 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -12,14 +12,17 @@ export type WebhookRecord = { url: string; events: string[]; createdAt: number } /** Mirrors the on-chain pause flag for write-gated endpoints. */ export const pauseState = { paused: false }; -/** Runtime-tunable in-memory configuration returned by /api/v1/config. */ -export const config: Record = { +/** Default process-local runtime config values. */ +export const DEFAULT_CONFIG: Record = { rateLimitPerWindow: 60, rateLimitWindowMs: 60_000, bulkMaxItems: 100, eventLogCap: 10_000, }; +/** Runtime-tunable in-memory configuration returned by /api/v1/config. */ +export const config: Record = { ...DEFAULT_CONFIG }; + /** Opaque API keys keyed by full secret token. */ export const apiKeyStore = new Map();