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
3 changes: 3 additions & 0 deletions drizzle/0012_api_key_rotation.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- #40 API key rotation: store revocation timestamp so old keys can be
-- invalidated without deleting the row. NULL means the key is still active.
ALTER TABLE "publishers" ADD COLUMN "api_key_revoked_at" timestamp;
3 changes: 3 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export const publishers = pgTable("publishers", {
// Subscribed WebhookEvent values; null/empty means all events.
webhookEvents: text("webhook_events").array(),
webhookEnabled: boolean("webhook_enabled").notNull().default(false),
// Set when the publisher rotates their API key. Any key whose hash matches
// a row with a non-null api_key_revoked_at is rejected by apiKeyAuth (#40).
apiKeyRevokedAt: timestamp("api_key_revoked_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});

Expand Down
5 changes: 5 additions & 0 deletions src/middleware/apiKeyAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export async function apiKeyAuth(req: Request, res: Response, next: NextFunction
throw new AppError("UNAUTHORIZED", "Invalid API key");
}

// Reject keys that have been rotated / revoked (#40).
if (publisher.apiKeyRevokedAt !== null) {
throw new AppError("UNAUTHORIZED", "API key has been revoked");
}

req.publisher = publisher;
next();
}
285 changes: 285 additions & 0 deletions src/routes/publishers.api-key-rotation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
/**
* Tests for POST /publishers/me/api-keys/rotate (#40)
*
* Acceptance criteria:
* - The old key returns 401 after rotation.
* - The new key works (200 on subsequent authenticated endpoints).
* - The rotate endpoint itself returns the new plaintext key + rotatedAt.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import express from "express";
import request from "supertest";
import { createHash } from "node:crypto";

// ---------------------------------------------------------------------------
// Schema + DB mocks (same pattern as publishers.test.ts)
// ---------------------------------------------------------------------------

const { PUBLISHERS_MARKER, RESOURCES_MARKER, PAYMENTS_MARKER } = vi.hoisted(() => ({
PUBLISHERS_MARKER: { __table: "publishers" },
RESOURCES_MARKER: { __table: "resources" },
PAYMENTS_MARKER: { __table: "payments" },
}));

vi.mock("../db/schema.js", () => ({
publishers: PUBLISHERS_MARKER,
resources: RESOURCES_MARKER,
payments: PAYMENTS_MARKER,
}));

// Pre-compute known key/hash pairs so we can assert auth outcomes precisely.
const OLD_API_KEY = "mv_old-key";
const OLD_API_KEY_HASH = createHash("sha256").update(OLD_API_KEY).digest("hex");

const NEW_API_KEY = "mv_new-key";
const NEW_API_KEY_HASH = createHash("sha256").update(NEW_API_KEY).digest("hex");

/**
* Rows returned by the DB mock for publisher lookups.
* Tests control this to simulate which key is currently valid.
* The mock always returns this exact array — set it to [] for "no match".
*/
let publishersLookupRows: any[];

vi.mock("../db/client.js", () => ({
db: {
select: (_cols: unknown) => ({
from: (table: unknown) => {
if (table === PUBLISHERS_MARKER) {
return {
// apiKeyAuth calls .where(eq(publishers.apiKeyHash, hash)).then(rows => rows[0]).
// The drizzle eq() expression stores the compared value at queryChunks[3],
// so we can filter the lookup rows by the queried hash.
where: (expr: any) => {
const queriedHash: string | undefined = expr?.queryChunks?.[3];
const matched = queriedHash
? publishersLookupRows.filter((r) => r.apiKeyHash === queriedHash)
: publishersLookupRows;
return {
then: (resolve: (rows: any[]) => unknown) =>
Promise.resolve(matched).then(resolve),
};
},
};
}
throw new Error(`unexpected table in select().from(): ${JSON.stringify(table)}`);
},
}),
},
}));

vi.mock("../config.js", () => ({
config: {
BASE_URL: "http://localhost:4021",
PUBLISHER_RATE_LIMIT_RPM_DEFAULT: 60,
PUBLISHER_RATE_LIMIT_MAX_RPM: 600,
PUBLISHER_RATE_LIMIT_WINDOW_MS: 60_000,
},
}));

// ---------------------------------------------------------------------------
// Service mock — rotateApiKey
// ---------------------------------------------------------------------------

const { mockRotateApiKey, mockRegisterPublisher, mockGetPublisherResources } = vi.hoisted(() => ({
mockRotateApiKey: vi.fn(),
mockRegisterPublisher: vi.fn(),
mockGetPublisherResources: vi.fn(),
}));

vi.mock("../services/publisherService.js", () => ({
registerPublisher: mockRegisterPublisher,
getPublisherResources: mockGetPublisherResources,
rotateApiKey: mockRotateApiKey,
}));

import publisherRouter from "./publishers.js";
import { errorHandler } from "../middleware/errorHandler.js";

function createTestApp() {
const app = express();
app.use(express.json());
app.use(publisherRouter);
app.use(errorHandler);
return app;
}

// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------

function makePublisher(overrides: Record<string, unknown> = {}) {
return {
id: "pub-1",
name: "Alice",
email: "alice@example.com",
walletAddress: "GALICE",
apiKeyHash: OLD_API_KEY_HASH,
apiKeyRevokedAt: null,
rateLimitRpm: null,
webhookUrl: null,
webhookSecret: null,
webhookEvents: null,
webhookEnabled: false,
createdAt: new Date("2026-01-01"),
...overrides,
};
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("POST /publishers/me/api-keys/rotate (#40)", () => {
beforeEach(() => {
mockRotateApiKey.mockReset();
// Default: old key is valid and active.
publishersLookupRows = [makePublisher()];
});

it("returns 401 when the x-api-key header is missing", async () => {
const res = await request(createTestApp()).post("/publishers/me/api-keys/rotate");

expect(res.status).toBe(401);
expect(res.body.error).toBe("Missing x-api-key header");
expect(mockRotateApiKey).not.toHaveBeenCalled();
});

it("returns 401 when an unrecognised key is supplied", async () => {
// No matching row for an unknown key.
publishersLookupRows = [];

const res = await request(createTestApp())
.post("/publishers/me/api-keys/rotate")
.set("x-api-key", "mv_unknown");

expect(res.status).toBe(401);
expect(res.body.error).toBe("Invalid API key");
expect(mockRotateApiKey).not.toHaveBeenCalled();
});

it("rotates the key and returns the new plaintext key with a timestamp", async () => {
mockRotateApiKey.mockResolvedValue(NEW_API_KEY);

const before = Date.now();
const res = await request(createTestApp())
.post("/publishers/me/api-keys/rotate")
.set("x-api-key", OLD_API_KEY);
const after = Date.now();

expect(res.status).toBe(200);
expect(res.body.apiKey).toBe(NEW_API_KEY);
expect(res.body.rotatedAt).toBeDefined();
expect(new Date(res.body.rotatedAt).getTime()).toBeGreaterThanOrEqual(before);
expect(new Date(res.body.rotatedAt).getTime()).toBeLessThanOrEqual(after);
expect(mockRotateApiKey).toHaveBeenCalledWith("pub-1");
});

it("calls rotateApiKey with the authenticated publisher's id", async () => {
mockRotateApiKey.mockResolvedValue(NEW_API_KEY);

await request(createTestApp())
.post("/publishers/me/api-keys/rotate")
.set("x-api-key", OLD_API_KEY);

expect(mockRotateApiKey).toHaveBeenCalledTimes(1);
expect(mockRotateApiKey).toHaveBeenCalledWith("pub-1");
});

it("does not expose the old key in the response", async () => {
mockRotateApiKey.mockResolvedValue(NEW_API_KEY);

const res = await request(createTestApp())
.post("/publishers/me/api-keys/rotate")
.set("x-api-key", OLD_API_KEY);

expect(res.status).toBe(200);
expect(JSON.stringify(res.body)).not.toContain(OLD_API_KEY);
expect(JSON.stringify(res.body)).not.toContain(OLD_API_KEY_HASH);
});
});

describe("Old key → 401 after rotation, new key → 200 (#40 acceptance criteria)", () => {
beforeEach(() => {
mockRotateApiKey.mockReset();
});

it("old key returns 401 after rotation (hash replaced in the DB)", async () => {
// Initially: old key is valid.
publishersLookupRows = [makePublisher({ apiKeyHash: OLD_API_KEY_HASH })];

// The service rotation replaces the hash in the DB. Simulate by swapping
// publishersLookupRows when rotateApiKey is called.
mockRotateApiKey.mockImplementation(async () => {
publishersLookupRows = [makePublisher({ apiKeyHash: NEW_API_KEY_HASH })];
return NEW_API_KEY;
});

// Perform the rotation.
const rotateRes = await request(createTestApp())
.post("/publishers/me/api-keys/rotate")
.set("x-api-key", OLD_API_KEY);

expect(rotateRes.status).toBe(200);
expect(rotateRes.body.apiKey).toBe(NEW_API_KEY);

// Old key is now rejected — its hash is no longer in the DB.
const oldKeyRes = await request(createTestApp())
.get("/publishers/me")
.set("x-api-key", OLD_API_KEY);

expect(oldKeyRes.status).toBe(401);
expect(oldKeyRes.body.error).toBe("Invalid API key");
});

it("new key returns 200 after rotation", async () => {
publishersLookupRows = [makePublisher({ apiKeyHash: OLD_API_KEY_HASH })];

mockRotateApiKey.mockImplementation(async () => {
publishersLookupRows = [makePublisher({ apiKeyHash: NEW_API_KEY_HASH })];
return NEW_API_KEY;
});

// Rotate using the old key.
await request(createTestApp())
.post("/publishers/me/api-keys/rotate")
.set("x-api-key", OLD_API_KEY);

// New key is now accepted.
const newKeyRes = await request(createTestApp())
.get("/publishers/me")
.set("x-api-key", NEW_API_KEY);

expect(newKeyRes.status).toBe(200);
expect(newKeyRes.body.id).toBe("pub-1");
});

it("revoked key (apiKeyRevokedAt set) returns 401 even if the hash matches", async () => {
// Simulate an admin revocation: same hash but apiKeyRevokedAt is non-null.
publishersLookupRows = [
makePublisher({ apiKeyRevokedAt: new Date("2026-06-01T00:00:00Z") }),
];

const res = await request(createTestApp())
.get("/publishers/me")
.set("x-api-key", OLD_API_KEY);

expect(res.status).toBe(401);
expect(res.body.error).toBe("API key has been revoked");
expect(res.body.code).toBe("UNAUTHORIZED");
});

it("rotate endpoint itself is blocked when the caller's key is already revoked", async () => {
publishersLookupRows = [
makePublisher({ apiKeyRevokedAt: new Date("2026-06-01T00:00:00Z") }),
];

const res = await request(createTestApp())
.post("/publishers/me/api-keys/rotate")
.set("x-api-key", OLD_API_KEY);

expect(res.status).toBe(401);
expect(res.body.error).toBe("API key has been revoked");
expect(mockRotateApiKey).not.toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions src/routes/publishers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const authedPublisher = {
email: "alice@example.com",
walletAddress: "GALICE",
apiKeyHash: VALID_API_KEY_HASH,
apiKeyRevokedAt: null,
createdAt: new Date("2026-01-01"),
};

Expand Down
12 changes: 11 additions & 1 deletion src/routes/publishers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
updateRateLimitSchema,
updateWebhookConfigSchema,
} from "../schemas/requests.js";
import { registerPublisher, getPublisherResources } from "../services/publisherService.js";
import { registerPublisher, getPublisherResources, rotateApiKey } from "../services/publisherService.js";
import { deliver, webhookUrlProblem } from "../services/webhookService.js";
import { eq, inArray, sum, count, sql, desc } from "drizzle-orm";
import { db } from "../db/client.js";
Expand Down Expand Up @@ -80,6 +80,16 @@ router.get("/publishers/me/resources", apiKeyAuth, async (req, res) => {
res.json(resources);
});

// POST /publishers/me/api-keys/rotate — issue a new key, invalidate the old one (#40)
// The new key is returned once in plaintext; the old key is immediately invalid.
router.post("/publishers/me/api-keys/rotate", apiKeyAuth, publisherRateLimit, async (req, res) => {
const newApiKey = await rotateApiKey(req.publisher!.id);
res.json({
apiKey: newApiKey, // shown once — store it securely
rotatedAt: new Date().toISOString(),
});
});

// GET /publishers/me/analytics — earnings and stats (authenticated)
router.get("/publishers/me/analytics", apiKeyAuth, async (req, res) => {
const publisherId = req.publisher!.id;
Expand Down
25 changes: 25 additions & 0 deletions src/services/publisherService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,31 @@ export async function getPublisherResources(publisherId: string) {
return db.select().from(resources).where(eq(resources.publisherId, publisherId));
}

/**
* Rotate the API key for a publisher (#40).
*
* Atomically replaces the stored key hash with a new one and clears any
* previous revocation so the fresh key is immediately accepted by apiKeyAuth.
* The old key stops working the instant its hash is overwritten.
*
* Returns the new plaintext key (shown to the caller once, never stored).
*/
export async function rotateApiKey(publisherId: string): Promise<string> {
const newApiKey = generateApiKey();
const newApiKeyHash = hashApiKey(newApiKey);

await db
.update(publishers)
.set({
apiKeyHash: newApiKeyHash,
// Clear any prior revocation — the new key is active.
apiKeyRevokedAt: null,
})
.where(eq(publishers.id, publisherId));

return newApiKey;
}

export async function getPublisherWebhookConfig(publisherId: string) {
const publisher = await getPublisherById(publisherId);
if (!publisher) {
Expand Down