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
2 changes: 2 additions & 0 deletions comebackhere-backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 } }
Expand Down
4 changes: 4 additions & 0 deletions comebackhere-backend/src/db/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface MerchantApiKeyRecord {
key_hash: string
created_at: Date
revoked_at?: Date
last_used_at?: Date
}

export interface WebhookReplayRecord {
Expand Down Expand Up @@ -162,6 +163,9 @@ export function ensureIndexes(database: Db): Promise<void> {
{ 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(
Expand Down
4 changes: 4 additions & 0 deletions comebackhere-backend/src/middleware/apiKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
}
260 changes: 242 additions & 18 deletions comebackhere-backend/src/routes/api-keys.ts
Original file line number Diff line number Diff line change
@@ -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<MerchantApiKeyRecord>("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<string, unknown> = {}
if (targetMerchant) {
filter.merchant_address = targetMerchant
} else if (!res.locals.isAdmin) {
throw new UnauthorizedError("Merchant address is required")
}

const keys = (await connectMongo()).collection<MerchantApiKeyRecord>("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
Expand All @@ -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()
Expand All @@ -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<MerchantApiKeyRecord>("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,
})
}))

/**
Expand All @@ -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<MerchantApiKeyRecord>("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()
}))

Expand Down
5 changes: 4 additions & 1 deletion comebackhere-backend/src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") })

Expand Down
Loading
Loading