Skip to content
Open
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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
168 changes: 168 additions & 0 deletions src/admin-reset.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
6 changes: 4 additions & 2 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): void {
eventLog.push({ id: randomUUID(), ts: Date.now(), type, payload });
export function recordEvent(type: string, payload: Record<string, unknown>): AppEvent {
const event = { id: randomUUID(), ts: Date.now(), type, payload };
eventLog.push(event);
if (eventLog.length > EVENT_LOG_CAP) eventLog.shift();
return event;
}
100 changes: 98 additions & 2 deletions src/routes/admin.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
};

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.
Expand All @@ -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;
}
7 changes: 5 additions & 2 deletions src/store/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
/** Default process-local runtime config values. */
export const DEFAULT_CONFIG: Record<string, number> = {
rateLimitPerWindow: 60,
rateLimitWindowMs: 60_000,
bulkMaxItems: 100,
eventLogCap: 10_000,
};

/** Runtime-tunable in-memory configuration returned by /api/v1/config. */
export const config: Record<string, number> = { ...DEFAULT_CONFIG };

/** Opaque API keys keyed by full secret token. */
export const apiKeyStore = new Map<string, ApiKeyRecord>();

Expand Down