diff --git a/comebackhere-backend/src/app.ts b/comebackhere-backend/src/app.ts index 4266f14..5f55908 100644 --- a/comebackhere-backend/src/app.ts +++ b/comebackhere-backend/src/app.ts @@ -11,6 +11,7 @@ import thresholdRouter from "./routes/threshold.js" import disputesRouter from "./routes/disputes.js" import analyticsRouter from "./routes/analytics.js" import webhookDeadLettersRouter from "./routes/webhook-dead-letters.js" +import merchantKeysRouter from "./routes/api-keys.js" import { startComplianceIndexer } from "./services/compliance-indexer.js" import { rateLimitMiddleware } from "./middleware/rateLimiter.js" import { idempotencyMiddleware } from "./middleware/idempotency.js" @@ -171,6 +172,7 @@ export function createApp(options: CreateAppOptions = {}) { app.use("/disputes", disputesRouter) app.use("/api/analytics", analyticsRouter) app.use("/webhooks/dead-letters", webhookDeadLettersRouter) + app.use("/api/merchant-keys", merchantKeysRouter) // ── Errors ────────────────────────────────────────────────────────────────── // Everything below produces { error: { code, message, details, correlationId } } diff --git a/comebackhere-backend/src/db/mongo.ts b/comebackhere-backend/src/db/mongo.ts index 7f5c4b5..a49c91d 100644 --- a/comebackhere-backend/src/db/mongo.ts +++ b/comebackhere-backend/src/db/mongo.ts @@ -32,6 +32,7 @@ export interface MerchantApiKeyRecord { key_hash: string created_at: Date revoked_at?: Date + last_used_at?: Date } export interface WebhookReplayRecord { @@ -162,6 +163,9 @@ export function ensureIndexes(database: Db): Promise { { collection: "compliance_audit", keys: { address: 1, ledger: -1 } }, { collection: "compliance_audit", keys: { event_type: 1, ledger: -1 } }, { collection: "compliance_audit", keys: { ledger: -1 } }, + { collection: "merchant_api_keys", keys: { key_id: 1 }, unique: true }, + { collection: "merchant_api_keys", keys: { merchant_address: 1 } }, + { collection: "merchant_api_keys", keys: { key_hash: 1 } }, ] const creating = Promise.all( diff --git a/comebackhere-backend/src/middleware/apiKey.ts b/comebackhere-backend/src/middleware/apiKey.ts index 9762860..a06cef9 100644 --- a/comebackhere-backend/src/middleware/apiKey.ts +++ b/comebackhere-backend/src/middleware/apiKey.ts @@ -2,6 +2,7 @@ import { createHash } from "crypto" import type { RequestHandler } from "express" import { connectMongo, type MerchantApiKeyRecord } from "../db/mongo.js" import { UnauthorizedError } from "../lib/errors.js" +import { logger } from "../lib/logger.js" export function hashMerchantApiKey(key: string): string { return createHash("sha256").update(key).digest("hex") @@ -20,6 +21,9 @@ export const requireMerchantApiKey: RequestHandler = (req, res, next) => { const record = await keys.findOne({ key_hash: hashMerchantApiKey(match[1]), revoked_at: { $exists: false } }) if (!record) throw new UnauthorizedError("Merchant API key is invalid or revoked") res.locals.merchantAddress = record.merchant_address + void keys.updateOne({ key_id: record.key_id }, { $set: { last_used_at: new Date() } }).catch((err: unknown) => { + logger.error({ err, key_id: record.key_id }, "Failed to update merchant API key last_used_at") + }) next() })().catch(next) } diff --git a/comebackhere-backend/src/routes/api-keys.ts b/comebackhere-backend/src/routes/api-keys.ts index ec52855..903c39f 100644 --- a/comebackhere-backend/src/routes/api-keys.ts +++ b/comebackhere-backend/src/routes/api-keys.ts @@ -1,24 +1,156 @@ import { randomBytes, randomUUID } from "crypto" -import { Router, type Request, type Response } from "express" +import { Router, type Request, type Response, type NextFunction } from "express" +import { Keypair } from "stellar-sdk" import { connectMongo, type MerchantApiKeyRecord } from "../db/mongo.js" -import { asyncHandler, NotFoundError } from "../lib/errors.js" -import { requireAdmin } from "../middleware/adminAuth.js" +import { + asyncHandler, + ForbiddenError, + NotFoundError, + ServiceMisconfiguredError, + UnauthorizedError, +} from "../lib/errors.js" import { hashMerchantApiKey } from "../middleware/apiKey.js" import { validateBody, validateParams } from "../middleware/validate.js" import { merchantApiKeySchema, merchantApiKeyIdSchema } from "../schemas/index.js" const router = Router() +/** + * Middleware allowing either admin credentials (X-Admin-Key) or merchant + * credentials (X-Merchant-Address or Bearer API key). + */ +export function requireMerchantOrAdmin(req: Request, res: Response, next: NextFunction) { + const adminKey = req.get("x-admin-key") + const configuredAdminKey = process.env.ADMIN_KEY + + if (adminKey) { + if (!configuredAdminKey) { + next(new ServiceMisconfiguredError("ADMIN_KEY is not configured")) + return + } + if (adminKey !== configuredAdminKey) { + next(new ForbiddenError("Admin credentials are invalid")) + return + } + res.locals.isAdmin = true + next() + return + } + + // Check merchant address from header, body, or query + const merchantAddress = (req.get("x-merchant-address") || req.body?.merchant_address || req.query?.merchant_address) as string | undefined + if (merchantAddress) { + try { + Keypair.fromPublicKey(merchantAddress) + res.locals.merchantAddress = merchantAddress + res.locals.isAdmin = false + next() + return + } catch { + next(new ForbiddenError("Invalid merchant Stellar address")) + return + } + } + + // Check bearer token (existing API key) + const authHeader = req.get("authorization") + const bearerMatch = authHeader?.match(/^Bearer (\S+)$/i) + if (bearerMatch) { + void (async () => { + const keys = (await connectMongo()).collection("merchant_api_keys") + const record = await keys.findOne({ key_hash: hashMerchantApiKey(bearerMatch[1]), revoked_at: { $exists: false } }) + if (!record) throw new UnauthorizedError("Merchant API key is invalid or revoked") + res.locals.merchantAddress = record.merchant_address + res.locals.isAdmin = false + next() + })().catch(next) + return + } + + next(new UnauthorizedError("Merchant address (X-Merchant-Address) or Admin credentials (X-Admin-Key) required")) +} + +/** + * @openapi + * /api/merchant-keys: + * get: + * tags: [Merchant Authentication] + * summary: Inspect API key metadata for an authorized merchant + * parameters: + * - in: query + * name: merchant_address + * required: false + * schema: { type: string, example: GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX } + * - in: header + * name: X-Merchant-Address + * required: false + * schema: { type: string } + * - in: header + * name: X-Admin-Key + * required: false + * schema: { type: string } + * responses: + * 200: + * description: Array of API key metadata; secret keys and hashes are omitted + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * key_id: { type: string, format: uuid } + * merchant_address: { type: string } + * created_at: { type: string, format: date-time } + * revoked_at: { type: string, format: date-time, nullable: true } + * last_used_at: { type: string, format: date-time, nullable: true } + * is_active: { type: boolean } + * 401: + * description: Merchant authorization or admin credentials are required + * 403: + * description: Credentials or address invalid + */ +router.get("/", requireMerchantOrAdmin, asyncHandler(async (req: Request, res: Response) => { + const targetMerchant = res.locals.isAdmin + ? (typeof req.query.merchant_address === "string" ? req.query.merchant_address : undefined) + : res.locals.merchantAddress + + const filter: Record = {} + if (targetMerchant) { + filter.merchant_address = targetMerchant + } else if (!res.locals.isAdmin) { + throw new UnauthorizedError("Merchant address is required") + } + + const keys = (await connectMongo()).collection("merchant_api_keys") + const records = await keys.find(filter).sort({ created_at: -1 }).toArray() + + const metadata = records.map((r) => ({ + key_id: r.key_id, + merchant_address: r.merchant_address, + created_at: r.created_at, + revoked_at: r.revoked_at, + last_used_at: r.last_used_at, + is_active: !r.revoked_at, + })) + + res.json(metadata) +})) + /** * @openapi * /api/merchant-keys: * post: * tags: [Merchant Authentication] - * summary: Create or rotate a merchant API key + * summary: Create a merchant API key (plaintext returned only at creation) * parameters: * - in: header + * name: X-Merchant-Address + * required: false + * schema: { type: string } + * - in: header * name: X-Admin-Key - * required: true + * required: false * schema: { type: string } * requestBody: * required: true @@ -29,16 +161,21 @@ const router = Router() * required: [merchant_address] * properties: * merchant_address: { type: string, example: GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX } + * rotate: { type: boolean, default: false } * responses: * 201: - * description: API key created; plaintext is returned only once + * description: API key created; plaintext secret is returned only once * 401: - * description: Admin credentials are missing + * description: Merchant authorization or admin credentials missing * 403: - * description: Admin credentials are invalid + * description: Credentials or address invalid */ -router.post("/", requireAdmin, validateBody(merchantApiKeySchema), asyncHandler(async (req: Request, res: Response) => { +router.post("/", requireMerchantOrAdmin, validateBody(merchantApiKeySchema), asyncHandler(async (req: Request, res: Response) => { const merchantAddress = req.body.merchant_address as string + if (!res.locals.isAdmin && res.locals.merchantAddress && res.locals.merchantAddress !== merchantAddress) { + throw new ForbiddenError("Cannot create key for another merchant address") + } + const key = `ch_${randomBytes(32).toString("hex")}` const keyId = randomUUID() const createdAt = new Date() @@ -50,12 +187,86 @@ router.post("/", requireAdmin, validateBody(merchantApiKeySchema), asyncHandler( key_hash: hashMerchantApiKey(key), created_at: createdAt, }) + + if (req.body.rotate) { + await keys.updateMany( + { merchant_address: merchantAddress, key_id: { $ne: keyId }, revoked_at: { $exists: false } }, + { $set: { revoked_at: createdAt } }, + ) + } + + res.status(201).json({ + key_id: keyId, + merchant_address: merchantAddress, + api_key: key, + created_at: createdAt, + is_active: true, + }) +})) + +/** + * @openapi + * /api/merchant-keys/rotate: + * post: + * tags: [Merchant Authentication] + * summary: Rotate a merchant API key (revokes current active keys and generates a new one) + * parameters: + * - in: header + * name: X-Merchant-Address + * required: false + * schema: { type: string } + * - in: header + * name: X-Admin-Key + * required: false + * schema: { type: string } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [merchant_address] + * properties: + * merchant_address: { type: string, example: GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX } + * responses: + * 201: + * description: API key rotated; new plaintext secret is returned only once + * 401: + * description: Merchant authorization or admin credentials missing + * 403: + * description: Credentials or address invalid + */ +router.post("/rotate", requireMerchantOrAdmin, validateBody(merchantApiKeySchema), asyncHandler(async (req: Request, res: Response) => { + const merchantAddress = req.body.merchant_address as string + if (!res.locals.isAdmin && res.locals.merchantAddress && res.locals.merchantAddress !== merchantAddress) { + throw new ForbiddenError("Cannot rotate key for another merchant address") + } + + const key = `ch_${randomBytes(32).toString("hex")}` + const keyId = randomUUID() + const rotatedAt = new Date() + const keys = (await connectMongo()).collection("merchant_api_keys") + + await keys.insertOne({ + key_id: keyId, + merchant_address: merchantAddress, + key_hash: hashMerchantApiKey(key), + created_at: rotatedAt, + }) + await keys.updateMany( { merchant_address: merchantAddress, key_id: { $ne: keyId }, revoked_at: { $exists: false } }, - { $set: { revoked_at: createdAt } }, + { $set: { revoked_at: rotatedAt } }, ) - res.status(201).json({ key_id: keyId, merchant_address: merchantAddress, api_key: key, created_at: createdAt }) + res.status(201).json({ + key_id: keyId, + merchant_address: merchantAddress, + api_key: key, + created_at: rotatedAt, + rotated_at: rotatedAt, + is_active: true, + }) })) /** @@ -70,26 +281,39 @@ router.post("/", requireAdmin, validateBody(merchantApiKeySchema), asyncHandler( * required: true * schema: { type: string, format: uuid } * - in: header + * name: X-Merchant-Address + * required: false + * schema: { type: string } + * - in: header * name: X-Admin-Key - * required: true + * required: false * schema: { type: string } * responses: * 204: * description: API key revoked * 401: - * description: Admin credentials are missing + * description: Credentials or authorization missing * 403: - * description: Admin credentials are invalid + * description: Not authorized to revoke this key * 404: * description: Active key not found */ -router.delete("/:keyId", requireAdmin, validateParams(merchantApiKeyIdSchema), asyncHandler(async (req: Request, res: Response) => { +router.delete("/:keyId", requireMerchantOrAdmin, validateParams(merchantApiKeyIdSchema), asyncHandler(async (req: Request, res: Response) => { const keys = (await connectMongo()).collection("merchant_api_keys") - const result = await keys.updateOne( - { key_id: req.params.keyId, revoked_at: { $exists: false } }, + const record = await keys.findOne({ key_id: req.params.keyId }) + if (!record || record.revoked_at) { + throw new NotFoundError("Active merchant API key not found") + } + + if (!res.locals.isAdmin && res.locals.merchantAddress && record.merchant_address !== res.locals.merchantAddress) { + throw new ForbiddenError("You are not authorized to revoke this key") + } + + await keys.updateOne( + { key_id: req.params.keyId }, { $set: { revoked_at: new Date() } }, ) - if (result.matchedCount === 0) throw new NotFoundError("Active merchant API key not found") + res.status(204).end() })) diff --git a/comebackhere-backend/src/schemas/index.ts b/comebackhere-backend/src/schemas/index.ts index 762ad4d..320f150 100644 --- a/comebackhere-backend/src/schemas/index.ts +++ b/comebackhere-backend/src/schemas/index.ts @@ -44,7 +44,10 @@ export const createInvoiceSchema = z.object({ .optional(), }) -export const merchantApiKeySchema = z.object({ merchant_address: stellarAddress }) +export const merchantApiKeySchema = z.object({ + merchant_address: stellarAddress, + rotate: z.boolean().optional(), +}) export const merchantApiKeyIdSchema = z.object({ keyId: z.string().uuid("keyId must be a UUID") }) export const webhookDeliveryIdSchema = z.object({ deliveryId: z.string().uuid("deliveryId must be a UUID") }) diff --git a/comebackhere-backend/src/tests/api-keys.test.ts b/comebackhere-backend/src/tests/api-keys.test.ts new file mode 100644 index 0000000..6b8443a --- /dev/null +++ b/comebackhere-backend/src/tests/api-keys.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import request from "supertest" +import { Keypair } from "stellar-sdk" +import { createApp } from "../app.js" +import * as mongoModule from "../db/mongo.js" +import { hashMerchantApiKey, requireMerchantApiKey } from "../middleware/apiKey.js" + +const VALID_MERCHANT = Keypair.random().publicKey() +const OTHER_MERCHANT = Keypair.random().publicKey() +const ADMIN_KEY = "test-secret-admin-key" + +describe("Merchant API Keys Management (/api/merchant-keys)", () => { + const app = createApp() + let envBackup: Record + let mockKeys: any[] + + beforeEach(() => { + envBackup = { ADMIN_KEY: process.env.ADMIN_KEY } + process.env.ADMIN_KEY = ADMIN_KEY + mockKeys = [] + + const mockCollection = { + insertOne: vi.fn(async (doc: any) => { + mockKeys.push({ ...doc }) + return { insertedId: doc.key_id } + }), + find: vi.fn((filter: any = {}) => ({ + sort: vi.fn(() => ({ + toArray: vi.fn(async () => { + return mockKeys.filter((k) => { + if (filter.merchant_address && k.merchant_address !== filter.merchant_address) { + return false + } + return true + }) + }), + })), + })), + findOne: vi.fn(async (filter: any) => { + return ( + mockKeys.find((k) => { + for (const [key, val] of Object.entries(filter)) { + if (key === "revoked_at" && val && typeof val === "object" && "$exists" in (val as any)) { + if ((val as any).$exists === false && k.revoked_at !== undefined) return false + } else if (k[key] !== val) { + return false + } + } + return true + }) ?? null + ) + }), + updateOne: vi.fn(async (filter: any, update: any) => { + const item = mockKeys.find((k) => { + for (const [key, val] of Object.entries(filter)) { + if (key === "revoked_at" && val && typeof val === "object" && "$exists" in (val as any)) { + if ((val as any).$exists === false && k.revoked_at !== undefined) return false + } else if (k[key] !== val) { + return false + } + } + return true + }) + if (!item) return { matchedCount: 0, modifiedCount: 0 } + if (update.$set) Object.assign(item, update.$set) + return { matchedCount: 1, modifiedCount: 1 } + }), + updateMany: vi.fn(async (filter: any, update: any) => { + let count = 0 + for (const k of mockKeys) { + let matches = true + if (filter.merchant_address && k.merchant_address !== filter.merchant_address) matches = false + if (filter.key_id && filter.key_id.$ne && k.key_id === filter.key_id.$ne) matches = false + if (filter.revoked_at && filter.revoked_at.$exists === false && k.revoked_at !== undefined) matches = false + if (matches) { + count++ + if (update.$set) Object.assign(k, update.$set) + } + } + return { matchedCount: count, modifiedCount: count } + }), + } + + const mockDb = { + collection: vi.fn((name: string) => { + if (name === "merchant_api_keys") return mockCollection + return mockCollection + }), + } + + vi.spyOn(mongoModule, "connectMongo").mockResolvedValue(mockDb as any) + }) + + afterEach(() => { + for (const [key, val] of Object.entries(envBackup)) { + if (val === undefined) delete process.env[key] + else process.env[key] = val + } + vi.restoreAllMocks() + }) + + describe("Authorization & Validation", () => { + it("returns 401 when no authorization is provided for GET", async () => { + const res = await request(app).get("/api/merchant-keys") + expect(res.status).toBe(401) + }) + + it("returns 403 when invalid admin key is provided", async () => { + const res = await request(app) + .get("/api/merchant-keys") + .set("X-Admin-Key", "invalid-key") + expect(res.status).toBe(403) + }) + + it("returns 403 when invalid Stellar address is provided as merchant address", async () => { + const res = await request(app) + .get("/api/merchant-keys") + .set("X-Merchant-Address", "not-a-valid-address") + expect(res.status).toBe(403) + }) + }) + + describe("API Key Creation & Secret Visibility", () => { + it("creates a key and returns plaintext secret ONLY at creation", async () => { + const res = await request(app) + .post("/api/merchant-keys") + .set("X-Merchant-Address", VALID_MERCHANT) + .send({ merchant_address: VALID_MERCHANT }) + + expect(res.status).toBe(201) + expect(res.body.key_id).toBeDefined() + expect(res.body.merchant_address).toBe(VALID_MERCHANT) + expect(res.body.api_key).toMatch(/^ch_[0-9a-f]{64}$/) + expect(res.body.is_active).toBe(true) + + // Verify that database does not store plaintext key + expect(mockKeys[0].api_key).toBeUndefined() + expect(mockKeys[0].key_hash).toBe(hashMerchantApiKey(res.body.api_key)) + + // GET inspects metadata: secret must NOT be exposed in list + const listRes = await request(app) + .get(`/api/merchant-keys?merchant_address=${VALID_MERCHANT}`) + .set("X-Merchant-Address", VALID_MERCHANT) + + expect(listRes.status).toBe(200) + expect(listRes.body).toHaveLength(1) + expect(listRes.body[0].key_id).toBe(res.body.key_id) + expect(listRes.body[0].merchant_address).toBe(VALID_MERCHANT) + expect(listRes.body[0].api_key).toBeUndefined() + expect(listRes.body[0].key_hash).toBeUndefined() + expect(listRes.body[0].is_active).toBe(true) + }) + }) + + describe("Metadata Inspection (last_used_at)", () => { + it("reflects last_used_at and revoked_at metadata in list", async () => { + const createRes = await request(app) + .post("/api/merchant-keys") + .set("X-Merchant-Address", VALID_MERCHANT) + .send({ merchant_address: VALID_MERCHANT }) + + const keyId = createRes.body.key_id + + // Initial state: last_used_at is undefined + let listRes = await request(app) + .get(`/api/merchant-keys?merchant_address=${VALID_MERCHANT}`) + .set("X-Merchant-Address", VALID_MERCHANT) + + expect(listRes.body[0].last_used_at).toBeUndefined() + + // Simulate API key usage updating last_used_at + const usedDate = new Date() + mockKeys[0].last_used_at = usedDate + + listRes = await request(app) + .get(`/api/merchant-keys?merchant_address=${VALID_MERCHANT}`) + .set("X-Merchant-Address", VALID_MERCHANT) + + expect(listRes.body[0].last_used_at).toBeDefined() + }) + }) + + describe("Rotation & Revocation", () => { + it("rotates key: revokes existing active keys and returns new secret", async () => { + // Create initial key + const key1 = await request(app) + .post("/api/merchant-keys") + .set("X-Merchant-Address", VALID_MERCHANT) + .send({ merchant_address: VALID_MERCHANT }) + expect(key1.status).toBe(201) + + // Rotate key + const rotateRes = await request(app) + .post("/api/merchant-keys/rotate") + .set("X-Merchant-Address", VALID_MERCHANT) + .send({ merchant_address: VALID_MERCHANT }) + + expect(rotateRes.status).toBe(201) + expect(rotateRes.body.api_key).toBeDefined() + expect(rotateRes.body.key_id).not.toBe(key1.body.key_id) + expect(rotateRes.body.rotated_at).toBeDefined() + + // Check that key1 was revoked + const oldKey = mockKeys.find((k) => k.key_id === key1.body.key_id) + expect(oldKey.revoked_at).toBeDefined() + + const newKey = mockKeys.find((k) => k.key_id === rotateRes.body.key_id) + expect(newKey.revoked_at).toBeUndefined() + }) + + it("revokes active key via DELETE", async () => { + const createRes = await request(app) + .post("/api/merchant-keys") + .set("X-Merchant-Address", VALID_MERCHANT) + .send({ merchant_address: VALID_MERCHANT }) + + const keyId = createRes.body.key_id + + const delRes = await request(app) + .delete(`/api/merchant-keys/${keyId}`) + .set("X-Merchant-Address", VALID_MERCHANT) + + expect(delRes.status).toBe(204) + + const key = mockKeys.find((k) => k.key_id === keyId) + expect(key.revoked_at).toBeDefined() + + // Attempting to revoke again returns 404 + const secondDel = await request(app) + .delete(`/api/merchant-keys/${keyId}`) + .set("X-Merchant-Address", VALID_MERCHANT) + + expect(secondDel.status).toBe(404) + }) + }) +}) diff --git a/comebackhere-frontend/src/App.tsx b/comebackhere-frontend/src/App.tsx index 3c28699..dd48b0f 100644 --- a/comebackhere-frontend/src/App.tsx +++ b/comebackhere-frontend/src/App.tsx @@ -6,26 +6,32 @@ import { TokenAllowlist } from "./components/TokenAllowlist" import { BatchExpireInvoices } from "./components/BatchExpireInvoices" import { TreasuryManager } from "./components/TreasuryManager" import SignerManagement from "./components/SignerManagement/SignerManagement" +import { CreateInvoice } from "./components/CreateInvoice" +import { InvoiceList } from "./components/InvoiceList" +import { ApiKeyManagement } from "./components/ApiKeyManagement" import { useInvoice } from "./hooks/useInvoice" import { useTheme } from "./hooks/useTheme" import { useWallet } from "./hooks/useWallet" -import { useHashTab, TABS, type Tab } from "./hooks/useHashTab" +import { useHashTab, type Tab } from "./hooks/useHashTab" import { CopyableText } from "./components/CopyableText" import { formatAmount, USDC_DECIMALS } from "./utils/format" import NetworkMismatchBanner from "./components/NetworkMismatchBanner" import OnboardingWizard, { useOnboarding } from "./components/OnboardingWizard" +import { useT } from "./i18n" import "./App.css" import "./components/ErrorBoundary.css" -type Tab = "payment" | "refund" | "compliance" | "tokens" | "batch-expire" | "treasury" | "signers" - -const TABS: { id: Tab; label: string }[] = [ +const TABS_CONFIG: { id: Tab; label: string }[] = [ { id: "payment", label: "Pay Invoice" }, + { id: "create", label: "Create Invoice" }, + { id: "invoices", label: "Invoices" }, { id: "refund", label: "Request Refund" }, { id: "compliance", label: "Compliance" }, { id: "tokens", label: "Token Allowlist" }, { id: "batch-expire", label: "Batch Expire" }, { id: "treasury", label: "Treasury" }, + { id: "signers", label: "Signers" }, + { id: "api-keys", label: "API Keys" }, ] function RefundTab() { @@ -103,49 +109,13 @@ function RefundTab() { ) } -interface TabContext { - address: string | null - notReadyReason: string | null - setTab: (tab: Tab) => void - openInvoice: (invoiceId: string) => void -} - -function renderTab(tab: Tab, { address, notReadyReason, setTab, openInvoice }: TabContext) { - switch (tab) { - case "payment": - return - case "create": - return - case "invoices": - return ( - setTab("create")} - /> - ) - case "refund": - return - case "tokens": - return - case "compliance": - return - case "batch-expire": - return - case "treasury": - return - default: { - const unreachable: never = tab - return unreachable - } - } -} - export default function App() { const { address, network, connected, connect, connecting, disconnect, error: walletError } = useWallet() const { showWizard, openWizard, closeWizard } = useOnboarding() useTheme() const [tab, setTab] = useHashTab() + const tabRefs = useRef>(new Map()) + const t = useT() const handleDisconnect = useCallback(() => { disconnect() @@ -164,41 +134,40 @@ export default function App() { // Activate a tab and move focus to it. const activateTab = useCallback((id: Tab) => { setTab(id) - // Focus the button on the next tick so the DOM has updated tabIndex. requestAnimationFrame(() => { tabRefs.current.get(id)?.focus() }) - }, []) + }, [setTab]) // Keyboard handler for WAI-ARIA roving tabindex pattern. const handleTabKeyDown = useCallback( (event: React.KeyboardEvent) => { - const currentIndex = TABS.findIndex((t) => t.id === tab) + const currentIndex = TABS_CONFIG.findIndex((item) => item.id === tab) + if (currentIndex === -1) return switch (event.key) { case "ArrowRight": { event.preventDefault() - const next = TABS[(currentIndex + 1) % TABS.length] + const next = TABS_CONFIG[(currentIndex + 1) % TABS_CONFIG.length] activateTab(next.id) break } case "ArrowLeft": { event.preventDefault() - const prev = TABS[(currentIndex - 1 + TABS.length) % TABS.length] + const prev = TABS_CONFIG[(currentIndex - 1 + TABS_CONFIG.length) % TABS_CONFIG.length] activateTab(prev.id) break } case "Home": { event.preventDefault() - activateTab(TABS[0].id) + activateTab(TABS_CONFIG[0].id) break } case "End": { event.preventDefault() - activateTab(TABS[TABS.length - 1].id) + activateTab(TABS_CONFIG[TABS_CONFIG.length - 1].id) break } - // Space and Enter are handled by the default button behaviour (onClick). default: break } @@ -206,13 +175,12 @@ export default function App() { [tab, activateTab], ) - // Sync URL hash with active tab so bookmarks and back-button work. useEffect(() => { const hash = window.location.hash.slice(1) as Tab - if (TABS.some((t) => t.id === hash)) { + if (TABS_CONFIG.some((item) => item.id === hash)) { setTab(hash) } - }, []) + }, [setTab]) useEffect(() => { window.history.replaceState(null, "", `#${tab}`) @@ -236,15 +204,23 @@ export default function App() { Connected: {address?.slice(0, 6)}...{address?.slice(-4)} - )} - + + ) : ( + + )} @@ -256,95 +232,106 @@ export default function App() {
- {/* - Each panel uses role="tabpanel" + aria-labelledby pointing back to - its controlling tab button, and tabIndex={0} so the panel itself is - reachable after Tab leaves the tablist. - */} + {connected && ( + + )} +
{showWizard && ( diff --git a/comebackhere-frontend/src/components/ApiKeyManagement.css b/comebackhere-frontend/src/components/ApiKeyManagement.css new file mode 100644 index 0000000..d08d4a3 --- /dev/null +++ b/comebackhere-frontend/src/components/ApiKeyManagement.css @@ -0,0 +1,220 @@ +.api-key-management { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 24px; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 20px; +} + +.api-key-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.api-key-header__title { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text); + margin: 0; +} + +.api-key-header__actions { + display: flex; + gap: 8px; + align-items: center; +} + +.api-key-auth-box { + background: var(--color-surface-muted); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.api-key-auth-box__title { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-secondary); +} + +.api-key-auth-box__input-row { + display: flex; + gap: 8px; + align-items: center; +} + +.api-key-auth-box__input { + flex: 1; + padding: 6px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-family: monospace; + font-size: 0.85rem; + background: var(--color-surface); + color: var(--color-text); +} + +.secret-banner { + background: var(--color-warning-bg); + color: var(--color-warning-text); + border: 2px solid #f59e0b; + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.secret-banner__header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 700; +} + +.secret-banner__warning { + font-size: 0.875rem; + font-weight: 600; +} + +.secret-banner__key-container { + display: flex; + align-items: center; + gap: 8px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 8px 12px; +} + +.secret-banner__key { + font-family: "Courier New", Courier, monospace; + font-size: 0.95rem; + font-weight: bold; + word-break: break-all; + color: var(--color-text); + flex: 1; +} + +.api-key-table-wrapper { + overflow-x: auto; +} + +.api-key-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + text-align: left; +} + +.api-key-table th { + background: var(--color-surface-muted); + color: var(--color-text-secondary); + font-weight: 600; + padding: 10px 12px; + border-bottom: 1px solid var(--color-border); +} + +.api-key-table td { + padding: 12px; + border-bottom: 1px solid var(--color-border); + color: var(--color-text); + vertical-align: middle; +} + +.api-key-table tr:hover { + background: var(--color-surface-muted); +} + +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.status-badge--active { + background: var(--color-success-bg); + color: var(--color-success); + border: 1px solid var(--color-success-border); +} + +.status-badge--revoked { + background: var(--color-muted-bg); + color: var(--color-muted-text); + border: 1px solid var(--color-border); +} + +.last-used-meta { + display: flex; + flex-direction: column; + gap: 2px; +} + +.last-used-meta__date { + font-weight: 500; +} + +.last-used-meta__never { + color: var(--color-text-secondary); + font-style: italic; +} + +.api-key-actions { + display: flex; + gap: 6px; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: var(--color-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 16px; +} + +.modal { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + max-width: 480px; + width: 100%; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + box-shadow: var(--shadow); +} + +.modal__title { + font-size: 1.15rem; + font-weight: 700; + color: var(--color-text); + margin: 0; +} + +.modal__message { + font-size: 0.9rem; + color: var(--color-text-secondary); + line-height: 1.5; +} + +.modal__actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} diff --git a/comebackhere-frontend/src/components/ApiKeyManagement.tsx b/comebackhere-frontend/src/components/ApiKeyManagement.tsx new file mode 100644 index 0000000..7d675a8 --- /dev/null +++ b/comebackhere-frontend/src/components/ApiKeyManagement.tsx @@ -0,0 +1,477 @@ +import { useState, useEffect, useCallback } from "react" +import { + listMerchantApiKeys, + createMerchantApiKey, + rotateMerchantApiKey, + revokeMerchantApiKey, + type MerchantApiKeyMetadata, + type CreateApiKeyResponse, + ApiError, +} from "../utils/api" +import { CopyableText } from "./CopyableText" +import "./ApiKeyManagement.css" + +export interface ApiKeyManagementProps { + /** Connected merchant wallet address. */ + merchantAddress: string | null +} + +function formatDate(isoString?: string | null): string { + if (!isoString) return "—" + try { + const d = new Date(isoString) + return isNaN(d.getTime()) ? isoString : `${d.toLocaleDateString()} ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` + } catch { + return isoString + } +} + +function shortenKeyId(id: string): string { + if (id.length <= 16) return id + return `${id.slice(0, 8)}...${id.slice(-6)}` +} + +export function ApiKeyManagement({ merchantAddress }: ApiKeyManagementProps) { + const [keys, setKeys] = useState([]) + const [loading, setLoading] = useState(false) + const [actionPending, setActionPending] = useState(false) + const [error, setError] = useState(null) + const [successMessage, setSuccessMessage] = useState(null) + + // Secret is shown strictly only at creation/rotation, then cleared + const [newKeySecret, setNewKeySecret] = useState<{ + keyId: string + apiKey: string + isRotation: boolean + } | null>(null) + const [copiedSecret, setCopiedSecret] = useState(false) + + // Confirmation modals + const [showRotateConfirm, setShowRotateConfirm] = useState(false) + const [revokeTargetId, setRevokeTargetId] = useState(null) + + // Optional admin key for administrator actions + const [adminKey, setAdminKey] = useState("") + const [showAdminInput, setShowAdminInput] = useState(false) + + const activeMerchant = merchantAddress?.trim() || null + + const loadKeys = useCallback(async () => { + if (!activeMerchant) { + setKeys([]) + return + } + setLoading(true) + setError(null) + try { + const data = await listMerchantApiKeys(activeMerchant, adminKey.trim() || undefined) + setKeys(data) + } catch (err: unknown) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError(err instanceof Error ? err.message : "Failed to load merchant API keys") + } + } finally { + setLoading(false) + } + }, [activeMerchant, adminKey]) + + useEffect(() => { + if (activeMerchant) { + void loadKeys() + } else { + setKeys([]) + setError(null) + } + }, [activeMerchant, loadKeys]) + + const handleCreate = async () => { + if (!activeMerchant || actionPending) return + setActionPending(true) + setError(null) + setSuccessMessage(null) + try { + const res: CreateApiKeyResponse = await createMerchantApiKey( + activeMerchant, + adminKey.trim() || undefined + ) + setNewKeySecret({ + keyId: res.key_id, + apiKey: res.api_key, + isRotation: false, + }) + setSuccessMessage("API key generated successfully. Save your secret key immediately.") + await loadKeys() + } catch (err: unknown) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError(err instanceof Error ? err.message : "Failed to create API key") + } + } finally { + setActionPending(false) + } + } + + const handleRotateConfirm = async () => { + if (!activeMerchant || actionPending) return + setActionPending(true) + setError(null) + setSuccessMessage(null) + try { + const res: CreateApiKeyResponse = await rotateMerchantApiKey( + activeMerchant, + adminKey.trim() || undefined + ) + setNewKeySecret({ + keyId: res.key_id, + apiKey: res.api_key, + isRotation: true, + }) + setShowRotateConfirm(false) + setSuccessMessage("API key rotated. All previous active keys have been revoked.") + await loadKeys() + } catch (err: unknown) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError(err instanceof Error ? err.message : "Failed to rotate API key") + } + } finally { + setActionPending(false) + } + } + + const handleRevokeConfirm = async () => { + if (!activeMerchant || !revokeTargetId || actionPending) return + setActionPending(true) + setError(null) + setSuccessMessage(null) + try { + await revokeMerchantApiKey( + revokeTargetId, + activeMerchant, + adminKey.trim() || undefined + ) + setSuccessMessage(`API key ${shortenKeyId(revokeTargetId)} was revoked.`) + setRevokeTargetId(null) + await loadKeys() + } catch (err: unknown) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError(err instanceof Error ? err.message : "Failed to revoke API key") + } + } finally { + setActionPending(false) + } + } + + const handleCopySecret = async () => { + if (!newKeySecret) return + try { + await navigator.clipboard.writeText(newKeySecret.apiKey) + setCopiedSecret(true) + setTimeout(() => setCopiedSecret(false), 2500) + } catch { + // Fallback + setCopiedSecret(false) + } + } + + const handleDismissSecret = () => { + // Clear secret key from memory + setNewKeySecret(null) + setCopiedSecret(false) + } + + const hasActiveKey = keys.some((k) => k.is_active) + + return ( +
+
+
+

Merchant API Keys

+

+ Manage authentication keys for programmatically issuing invoices and listening to webhooks. +

+
+
+ + + {hasActiveKey ? ( + + ) : ( + + )} +
+
+ + {showAdminInput && ( +
+ Admin Authorization (Optional) +
+ setAdminKey(e.target.value)} + aria-label="Admin Key" + /> + +
+
+ )} + + {!activeMerchant && ( +
+ Authorization Required: Connect your merchant wallet to inspect and manage your API keys. + Your connected Stellar public key verifies merchant authorization. +
+ )} + + {error && ( +
+
+ {error} + +
+
+ )} + + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Secret shown ONLY at creation/rotation */} + {newKeySecret && ( +
+
+ + {newKeySecret.isRotation ? "New API Key Generated via Rotation" : "New API Key Generated"} + + +
+

+ ⚠️ Make sure to copy your API secret key now. For security purposes, this secret is never + stored in plaintext and will NEVER be displayed again. +

+
+ + {newKeySecret.apiKey} + + +
+
+ )} + + {/* API Key Metadata Table */} +
+ {loading && keys.length === 0 ? ( +

+ Loading merchant API keys... +

+ ) : keys.length === 0 ? ( +

+ {activeMerchant + ? "No API keys found for this merchant. Generate one to get started." + : "Wallet not connected. Connect your wallet to view keys."} +

+ ) : ( + + + + + + + + + + + + + {keys.map((k) => ( + + + + + + + + + ))} + +
Key IDStatusCreated AtLast Used MetadataRevoked AtActions
+ + {shortenKeyId(k.key_id)} + + + + + {k.is_active ? "Active" : "Revoked"} + + {formatDate(k.created_at)} +
+ {k.last_used_at ? ( + + {formatDate(k.last_used_at)} + + ) : ( + + Never used + + )} +
+
{formatDate(k.revoked_at)} +
+ {k.is_active && ( + <> + + + + )} +
+
+ )} +
+ + {/* Rotate Confirmation Modal */} + {showRotateConfirm && ( +
+
+

+ Rotate Merchant API Key +

+

+ Rotating your API key will immediately revoke your active API key and generate a brand new one. + Any existing servers or applications using the old key will be rejected until updated with the new secret. + Do you wish to proceed? +

+
+ + +
+
+
+ )} + + {/* Revoke Confirmation Modal */} + {revokeTargetId && ( +
+
+

+ Revoke API Key +

+

+ Are you sure you want to revoke API key {shortenKeyId(revokeTargetId)}? + This key will immediately lose access to the protocol API and cannot be restored. +

+
+ + +
+
+
+ )} +
+ ) +} + +export default ApiKeyManagement diff --git a/comebackhere-frontend/src/hooks/useHashTab.ts b/comebackhere-frontend/src/hooks/useHashTab.ts index 45986fc..7f7defa 100644 --- a/comebackhere-frontend/src/hooks/useHashTab.ts +++ b/comebackhere-frontend/src/hooks/useHashTab.ts @@ -9,6 +9,8 @@ export const TABS = [ "tokens", "batch-expire", "treasury", + "signers", + "api-keys", ] as const export type Tab = (typeof TABS)[number] diff --git a/comebackhere-frontend/src/tests/ApiKeyManagement.test.tsx b/comebackhere-frontend/src/tests/ApiKeyManagement.test.tsx new file mode 100644 index 0000000..459dfc3 --- /dev/null +++ b/comebackhere-frontend/src/tests/ApiKeyManagement.test.tsx @@ -0,0 +1,263 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { ApiKeyManagement } from "../components/ApiKeyManagement" + +const MERCHANT = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + +function jsonResponse(status: number, body: unknown) { + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }) + ) +} + +let fetchMock: ReturnType + +beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("ApiKeyManagement — Authorization and Initial States", () => { + it("renders authorization warning and disables key generation when wallet is not connected", () => { + render() + expect(screen.getByTestId("auth-required-warning")).toBeInTheDocument() + expect(screen.getByRole("button", { name: /Generate new API key/i })).toBeDisabled() + }) + + it("loads and displays existing API key metadata when merchant is connected", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, [ + { + key_id: "test-key-uuid-1", + merchant_address: MERCHANT, + created_at: "2026-10-21T00:00:00.000Z", + revoked_at: null, + last_used_at: "2026-10-21T00:15:00.000Z", + is_active: true, + }, + ]) + ) + + render() + + await waitFor(() => { + expect(screen.getByTestId("key-row-test-key-uuid-1")).toBeInTheDocument() + }) + + expect(screen.getByText("Active")).toBeInTheDocument() + expect(screen.getByTestId("last-used-test-key-uuid-1")).not.toHaveTextContent("Never used") + expect(screen.queryByTestId("secret-key-banner")).not.toBeInTheDocument() + }) + + it("displays 'Never used' for keys with no last_used_at timestamp", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, [ + { + key_id: "test-key-uuid-2", + merchant_address: MERCHANT, + created_at: "2026-10-21T00:00:00.000Z", + revoked_at: null, + last_used_at: null, + is_active: true, + }, + ]) + ) + + render() + + await waitFor(() => { + expect(screen.getByTestId("key-row-test-key-uuid-2")).toBeInTheDocument() + }) + + expect(screen.getByTestId("last-used-test-key-uuid-2")).toHaveTextContent("Never used") + }) +}) + +describe("ApiKeyManagement — Secret Shown ONLY at Creation", () => { + it("generates an API key, reveals secret, and hides secret upon dismissal", async () => { + const user = userEvent.setup() + + // 1st call: initial list (empty) + fetchMock.mockResolvedValueOnce(jsonResponse(200, [])) + + // 2nd call: POST /api/merchant-keys + fetchMock.mockResolvedValueOnce( + jsonResponse(201, { + key_id: "created-key-1234", + merchant_address: MERCHANT, + api_key: "ch_super_secret_plaintext_key", + created_at: "2026-10-21T00:00:00.000Z", + is_active: true, + }) + ) + + // 3rd call: reload list after create + fetchMock.mockResolvedValueOnce( + jsonResponse(200, [ + { + key_id: "created-key-1234", + merchant_address: MERCHANT, + created_at: "2026-10-21T00:00:00.000Z", + revoked_at: null, + last_used_at: null, + is_active: true, + }, + ]) + ) + + render() + + const createBtn = await screen.findByRole("button", { name: /Generate new API key/i }) + await user.click(createBtn) + + // Secret is displayed in banner + await waitFor(() => { + expect(screen.getByTestId("secret-key-banner")).toBeInTheDocument() + }) + expect(screen.getByTestId("plaintext-api-key")).toHaveTextContent("ch_super_secret_plaintext_key") + + // Dismiss secret + const dismissBtn = screen.getByRole("button", { name: /Dismiss and hide secret key/i }) + await user.click(dismissBtn) + + // Banner is gone; secret is nowhere in the DOM + expect(screen.queryByTestId("secret-key-banner")).not.toBeInTheDocument() + expect(screen.queryByText("ch_super_secret_plaintext_key")).not.toBeInTheDocument() + }) +}) + +describe("ApiKeyManagement — Rotation & Revocation Flows", () => { + it("opens rotation confirmation and executes rotation", async () => { + const user = userEvent.setup() + + fetchMock.mockResolvedValueOnce( + jsonResponse(200, [ + { + key_id: "active-key-1", + merchant_address: MERCHANT, + created_at: "2026-10-21T00:00:00.000Z", + revoked_at: null, + last_used_at: null, + is_active: true, + }, + ]) + ) + + render() + + const rotateBtn = await screen.findByRole("button", { name: /Rotate API key/i }) + await user.click(rotateBtn) + + // Confirmation modal should be visible + expect(screen.getByText(/Rotate Merchant API Key/i)).toBeInTheDocument() + + // Mock rotate response + fetchMock.mockResolvedValueOnce( + jsonResponse(201, { + key_id: "rotated-key-2", + merchant_address: MERCHANT, + api_key: "ch_rotated_secret_key", + created_at: "2026-10-21T00:10:00.000Z", + rotated_at: "2026-10-21T00:10:00.000Z", + is_active: true, + }) + ) + + // Mock subsequent list response + fetchMock.mockResolvedValueOnce( + jsonResponse(200, [ + { + key_id: "rotated-key-2", + merchant_address: MERCHANT, + created_at: "2026-10-21T00:10:00.000Z", + revoked_at: null, + last_used_at: null, + is_active: true, + }, + { + key_id: "active-key-1", + merchant_address: MERCHANT, + created_at: "2026-10-21T00:00:00.000Z", + revoked_at: "2026-10-21T00:10:00.000Z", + last_used_at: null, + is_active: false, + }, + ]) + ) + + const confirmRotateBtn = screen.getByTestId("confirm-rotate-button") + await user.click(confirmRotateBtn) + + await waitFor(() => { + expect(screen.getByTestId("plaintext-api-key")).toHaveTextContent("ch_rotated_secret_key") + }) + }) + + it("handles revocation confirmation and updates UI", async () => { + const user = userEvent.setup() + + fetchMock.mockResolvedValueOnce( + jsonResponse(200, [ + { + key_id: "key-to-revoke-1234", + merchant_address: MERCHANT, + created_at: "2026-10-21T00:00:00.000Z", + revoked_at: null, + last_used_at: null, + is_active: true, + }, + ]) + ) + + render() + + const revokeBtn = await screen.findByTestId("revoke-btn-key-to-revoke-1234") + await user.click(revokeBtn) + + expect(screen.getByText(/Revoke API Key/i)).toBeInTheDocument() + + // Mock delete 204 + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })) + + // Mock subsequent list response showing revoked + fetchMock.mockResolvedValueOnce( + jsonResponse(200, [ + { + key_id: "key-to-revoke-1234", + merchant_address: MERCHANT, + created_at: "2026-10-21T00:00:00.000Z", + revoked_at: "2026-10-21T00:10:00.000Z", + last_used_at: null, + is_active: false, + }, + ]) + ) + + const confirmRevokeBtn = screen.getByTestId("confirm-revoke-button") + await user.click(confirmRevokeBtn) + + await waitFor(() => { + expect(screen.getByText("Revoked")).toBeInTheDocument() + }) + }) + + it("displays server error banner when API call fails", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(500, { error: "Database connection failed" }) + ) + + render() + + await waitFor(() => { + expect(screen.getByTestId("api-key-error")).toHaveTextContent("Database connection failed") + }) + }) +}) diff --git a/comebackhere-frontend/src/utils/api.ts b/comebackhere-frontend/src/utils/api.ts index 456f84f..06c347b 100644 --- a/comebackhere-frontend/src/utils/api.ts +++ b/comebackhere-frontend/src/utils/api.ts @@ -111,3 +111,75 @@ export async function listInvoices( } return result } + +export interface MerchantApiKeyMetadata { + key_id: string + merchant_address: string + created_at: string + revoked_at?: string | null + last_used_at?: string | null + is_active: boolean +} + +export interface CreateApiKeyResponse { + key_id: string + merchant_address: string + api_key: string + created_at: string + rotated_at?: string + is_active: boolean +} + +export function listMerchantApiKeys(merchantAddress: string, adminKey?: string): Promise { + const headers: Record = { + "X-Merchant-Address": merchantAddress, + } + if (adminKey) { + headers["X-Admin-Key"] = adminKey + } + return request(`/api/merchant-keys?merchant_address=${encodeURIComponent(merchantAddress)}`, { + headers, + }) +} + +export function createMerchantApiKey(merchantAddress: string, adminKey?: string): Promise { + const headers: Record = { + "X-Merchant-Address": merchantAddress, + } + if (adminKey) { + headers["X-Admin-Key"] = adminKey + } + return request("/api/merchant-keys", { + method: "POST", + headers, + body: JSON.stringify({ merchant_address: merchantAddress }), + }) +} + +export function rotateMerchantApiKey(merchantAddress: string, adminKey?: string): Promise { + const headers: Record = { + "X-Merchant-Address": merchantAddress, + } + if (adminKey) { + headers["X-Admin-Key"] = adminKey + } + return request("/api/merchant-keys/rotate", { + method: "POST", + headers, + body: JSON.stringify({ merchant_address: merchantAddress }), + }) +} + +export function revokeMerchantApiKey(keyId: string, merchantAddress: string, adminKey?: string): Promise { + const headers: Record = { + "X-Merchant-Address": merchantAddress, + } + if (adminKey) { + headers["X-Admin-Key"] = adminKey + } + return request(`/api/merchant-keys/${encodeURIComponent(keyId)}`, { + method: "DELETE", + headers, + }) +} + diff --git a/docs/api-reference.md b/docs/api-reference.md index eced157..7582e34 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -98,24 +98,81 @@ Checks Soroban RPC reachability and current ledger. ## Merchant authentication -Invoice creation and webhook replay require a merchant API key. An admin -creates or rotates a key with `POST /api/merchant-keys`, sending `x-admin-key` -and a JSON body containing the merchant's Stellar address. The returned -`api_key` is shown only once; MongoDB stores only its SHA-256 hash. Creating a -new key revokes the merchant's prior active keys. Revoke a key with -`DELETE /api/merchant-keys/{keyId}` and the admin header. +Invoice creation and webhook replay require a merchant API key. Authorized merchants +or admins can create, rotate, and revoke keys, and inspect last-used metadata. +Requests are authorized via `X-Merchant-Address` (with the merchant's Stellar public key), +an existing active key via `Authorization: Bearer `, or `X-Admin-Key`. + +The returned plaintext `api_key` secret is shown **only once at creation or rotation**; +MongoDB stores only its SHA-256 hash. Revoke a key with `DELETE /api/merchant-keys/{keyId}`. + +### `GET /api/merchant-keys` + +List API key metadata for an authorized merchant. Secret keys and hashes are omitted. + +#### Headers / Query + +- `X-Merchant-Address`: Merchant Stellar public key (`G...`) +- `X-Admin-Key`: Optional admin key (allows specifying any `?merchant_address=G...`) + +**Response `200`** + +```json +[ + { + "key_id": "c1f721d2-7fb2-47b2-841f-889ad4db1a7f", + "merchant_address": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "created_at": "2026-10-21T00:00:00.000Z", + "revoked_at": null, + "last_used_at": "2026-10-21T00:15:30.000Z", + "is_active": true + } +] +``` + +### `POST /api/merchant-keys` + +Create a new API key for the merchant. ```http POST /api/merchant-keys -X-Admin-Key: +X-Merchant-Address: Content-Type: application/json {"merchant_address":"G..."} ``` -The `201` response contains `key_id`, `merchant_address`, `api_key`, and -`created_at`. The key is returned only at creation time; store it securely. -Revocation returns `204 No Content`. +The `201` response contains `key_id`, `merchant_address`, `api_key` (plaintext secret returned only once), +`created_at`, and `is_active: true`. Store the key securely. + +### `POST /api/merchant-keys/rotate` + +Rotate a merchant's API key. Revokes existing active keys for the merchant and returns a new plaintext secret. + +```http +POST /api/merchant-keys/rotate +X-Merchant-Address: +Content-Type: application/json + +{"merchant_address":"G..."} +``` + +**Response `201`** + +```json +{ + "key_id": "96b6d57a-9a94-4d22-8cc6-4eb13e00be92", + "merchant_address": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "api_key": "ch_5f...", + "created_at": "2026-10-21T00:30:00.000Z", + "rotated_at": "2026-10-21T00:30:00.000Z", + "is_active": true +} +``` + +### `DELETE /api/merchant-keys/:keyId` + +Revoke an active merchant API key. Revocation returns `204 No Content`. Use `Authorization: Bearer ` on merchant requests. Missing, invalid, or revoked keys return `401 UNAUTHORIZED` in the standard error envelope.