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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ docs rather than duplicating them.
```bash
curl -X POST http://localhost:3000/invoices \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $MERCHANT_API_KEY" \
-d '{
"merchant_address": "G...",
"token": "USDC",
"amount": 1000000,
"due_date": 1720000000
Expand Down Expand Up @@ -66,6 +66,9 @@ function verifyWebhook(rawBody: string, signature: string, secret: string): bool
}
```

The body-only digest is temporarily available as `X-COMEBACKHERE-Legacy-Signature`
for one release while receivers migrate.

> Webhook events and configuration: [docs/api-reference.md](docs/api-reference.md#webhooks)

### 3. Minimal payment flow
Expand Down
30 changes: 30 additions & 0 deletions comebackhere-backend/src/db/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,36 @@ export interface InvoiceRecord {
updated_at: Date
}

export interface MerchantApiKeyRecord {
key_id: string
merchant_address: string
key_hash: string
created_at: Date
revoked_at?: Date
}

export interface WebhookReplayRecord {
replay_id: string
request_id: string | null
attempted_at: Date
status: "delivered" | "failed"
status_code: number | null
error: string | null
}

export interface WebhookDeliveryHistoryRecord {
delivery_id: string
merchant_address: string
endpoint: string
payload: unknown
status: "delivered" | "failed"
attempts: number
last_status_code: number | null
last_error: string | null
created_at: Date
replays: WebhookReplayRecord[]
}

export interface SettlementRecord {
id: number
merchant_address: string
Expand Down
28 changes: 28 additions & 0 deletions comebackhere-backend/src/middleware/adminAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createHash } from "crypto"
import type { RequestHandler } from "express"
import { ForbiddenError, ServiceMisconfiguredError, UnauthorizedError } from "../lib/errors.js"

export const requireAdmin: RequestHandler = (req, res, next) => {
const configuredKey = process.env.ADMIN_KEY
if (!configuredKey) {
next(new ServiceMisconfiguredError("ADMIN_KEY is not configured"))
return
}

const suppliedKey = req.get("x-admin-key")
if (!suppliedKey) {
next(new UnauthorizedError("Admin credentials are required"))
return
}
if (suppliedKey !== configuredKey) {
next(new ForbiddenError("Admin credentials are invalid"))
return
}

const keyFingerprint = createHash("sha256").update(configuredKey).digest("hex").slice(0, 12)
const adminIdentity = process.env.ADMIN_IDENTITY ?? `admin-${keyFingerprint}`
res.locals.adminIdentity = adminIdentity
const requestId = typeof res.locals.requestId === "string" ? res.locals.requestId : "-"
console.info(`[admin-audit] requestId=${requestId} admin=${adminIdentity} action=${req.method} ${req.path}`)
next()
}
25 changes: 25 additions & 0 deletions comebackhere-backend/src/middleware/apiKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createHash } from "crypto"
import type { RequestHandler } from "express"
import { connectMongo, type MerchantApiKeyRecord } from "../db/mongo.js"
import { UnauthorizedError } from "../lib/errors.js"

export function hashMerchantApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex")
}

export const requireMerchantApiKey: RequestHandler = (req, res, next) => {
const authorization = req.get("authorization")
const match = authorization?.match(/^Bearer (\S+)$/i)
if (!match) {
next(new UnauthorizedError("A merchant API key is required"))
return
}

void (async () => {
const keys = (await connectMongo()).collection<MerchantApiKeyRecord>("merchant_api_keys")
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
next()
})().catch(next)
}
96 changes: 96 additions & 0 deletions comebackhere-backend/src/routes/api-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { randomBytes, randomUUID } from "crypto"
import { Router, type Request, type Response } from "express"
import { connectMongo, type MerchantApiKeyRecord } from "../db/mongo.js"
import { asyncHandler, NotFoundError } from "../lib/errors.js"
import { requireAdmin } from "../middleware/adminAuth.js"
import { hashMerchantApiKey } from "../middleware/apiKey.js"
import { validateBody, validateParams } from "../middleware/validate.js"
import { merchantApiKeySchema, merchantApiKeyIdSchema } from "../schemas/index.js"

const router = Router()

/**
* @openapi
* /api/merchant-keys:
* post:
* tags: [Merchant Authentication]
* summary: Create or rotate a merchant API key
* parameters:
* - in: header
* name: X-Admin-Key
* required: true
* 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 created; plaintext is returned only once
* 401:
* description: Admin credentials are missing
* 403:
* description: Admin credentials are invalid
*/
router.post("/", requireAdmin, validateBody(merchantApiKeySchema), asyncHandler(async (req: Request, res: Response) => {
const merchantAddress = req.body.merchant_address as string
const key = `ch_${randomBytes(32).toString("hex")}`
const keyId = randomUUID()
const createdAt = 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: createdAt,
})
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 })
}))

/**
* @openapi
* /api/merchant-keys/{keyId}:
* delete:
* tags: [Merchant Authentication]
* summary: Revoke a merchant API key
* parameters:
* - in: path
* name: keyId
* required: true
* schema: { type: string, format: uuid }
* - in: header
* name: X-Admin-Key
* required: true
* schema: { type: string }
* responses:
* 204:
* description: API key revoked
* 401:
* description: Admin credentials are missing
* 403:
* description: Admin credentials are invalid
* 404:
* description: Active key not found
*/
router.delete("/:keyId", requireAdmin, 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 } },
{ $set: { revoked_at: new Date() } },
)
if (result.matchedCount === 0) throw new NotFoundError("Active merchant API key not found")
res.status(204).end()
}))

export default router
17 changes: 4 additions & 13 deletions comebackhere-backend/src/routes/compliance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
} from "stellar-sdk"
import { validateBody, validateQuery } from "../middleware/validate.js"
import { requireEnv } from "../lib/env.js"
import { asyncHandler, UnauthorizedError } from "../lib/errors.js"
import { asyncHandler } from "../lib/errors.js"
import { requireAdmin } from "../middleware/adminAuth.js"
import { allowBodySchema, blockBodySchema, complianceAuditQuerySchema } from "../schemas/index.js"
import { connectMongo, getComplianceAuditCollection } from "../db/mongo.js"

Expand Down Expand Up @@ -183,12 +184,7 @@ export interface AllowBody {
* Body: { address: string, until?: number }
* Returns: { address, status, hash }
*/
router.post("/allow", validateBody(allowBodySchema), asyncHandler(async (req: Request, res: Response) => {
const adminKey = req.headers["x-admin-key"]
if (!adminKey || adminKey !== process.env.ADMIN_KEY) {
throw new UnauthorizedError()
}

router.post("/allow", requireAdmin, validateBody(allowBodySchema), asyncHandler(async (req: Request, res: Response) => {
const { address, until } = req.body as { address: string; until?: number }

const env = requireEnv({
Expand Down Expand Up @@ -227,12 +223,7 @@ export interface BlockBody {
* Body: { address: string }
* Returns: { address, status, hash }
*/
router.post("/block", validateBody(blockBodySchema), asyncHandler(async (req: Request, res: Response) => {
const adminKey = req.headers["x-admin-key"]
if (!adminKey || adminKey !== process.env.ADMIN_KEY) {
throw new UnauthorizedError()
}

router.post("/block", requireAdmin, validateBody(blockBodySchema), asyncHandler(async (req: Request, res: Response) => {
const { address } = req.body as { address: string }

const env = requireEnv({
Expand Down
3 changes: 2 additions & 1 deletion comebackhere-backend/src/routes/invoice-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { requireEnv } from "../lib/env.js"
import { asyncHandler } from "../lib/errors.js"
import { validateBody } from "../middleware/validate.js"
import { requireAdmin } from "../middleware/adminAuth.js"
import { graceWindowSchema } from "../schemas/index.js"

const router = Router()
Expand Down Expand Up @@ -136,7 +137,7 @@ export async function setGraceWindow(
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.post("/grace-window", validateBody(graceWindowSchema), asyncHandler(async (req: Request, res: Response) => {
router.post("/grace-window", requireAdmin, validateBody(graceWindowSchema), asyncHandler(async (req: Request, res: Response) => {
const env = requireEnv({
invoiceContractId: "INVOICE_CONTRACT_ID",
signerSecret: "SIGNER_SECRET_KEY",
Expand Down
23 changes: 15 additions & 8 deletions comebackhere-backend/src/routes/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,18 +634,19 @@ router.get("/:id/events", validateParams(invoiceIdParamSchema), asyncHandler(asy
* post:
* tags: [Invoices]
* summary: Create a new invoice
* parameters:
* - in: header
* name: Authorization
* required: true
* schema: { type: string, example: Bearer merchant-api-key }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [merchant_address, token, amount, due_date]
* required: [token, amount, due_date]
* properties:
* merchant_address:
* type: string
* description: Valid Stellar public key (G…)
* example: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
* token:
* type: string
* example: "USDC"
Expand Down Expand Up @@ -681,6 +682,12 @@ router.get("/:id/events", validateParams(invoiceIdParamSchema), asyncHandler(asy
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 401:
* description: Missing, invalid, or revoked merchant API key
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 422:
* description: Soroban simulation or transaction failure
* content:
Expand All @@ -700,15 +707,16 @@ router.get("/:id/events", validateParams(invoiceIdParamSchema), asyncHandler(asy
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.post("/", validateBody(createInvoiceSchema), asyncHandler(async (req: Request, res: Response) => {
router.post("/", requireMerchantApiKey, validateBody(createInvoiceSchema), asyncHandler(async (req: Request, res: Response) => {
const env = requireEnv({
invoiceContractId: "INVOICE_CONTRACT_ID",
signerSecret: "SIGNER_SECRET_KEY",
})

const client = buildSorobanClient(env.rpcUrl)
const body = { ...req.body, merchant_address: res.locals.merchantAddress } as CreateInvoiceBody
const result = await createInvoice(
req.body as CreateInvoiceBody,
body,
client,
env.invoiceContractId,
env.signerSecret,
Expand All @@ -717,7 +725,6 @@ router.post("/", validateBody(createInvoiceSchema), asyncHandler(async (req: Req

const db = await connectMongo()
const collection = getInvoicesCollection(db)
const body = req.body as CreateInvoiceBody
const now = new Date()
await collection.insertOne({
invoice_id: result.invoice_id,
Expand Down
11 changes: 3 additions & 8 deletions comebackhere-backend/src/routes/release-escrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import {
type SorobanClient,
} from "../lib/soroban.js"
import { requireEnv } from "../lib/env.js"
import { asyncHandler, ContractError, parseContractErrorCode, UnauthorizedError } from "../lib/errors.js"
import { asyncHandler, ContractError, parseContractErrorCode } from "../lib/errors.js"
import { validateBody, validateParams } from "../middleware/validate.js"
import { requireAdmin } from "../middleware/adminAuth.js"
import { releaseEscrowIdParamSchema } from "../schemas/index.js"

const router = Router({ mergeParams: true })
Expand Down Expand Up @@ -71,13 +72,7 @@ export async function releaseEscrow(
* 503 required environment variables missing
* 5xx unexpected Soroban / network error
*/
router.post("/:id/release-escrow", validateParams(releaseEscrowIdParamSchema), asyncHandler(async (req: Request, res: Response) => {
// Admin-only authorization
const adminKey = req.headers["x-admin-key"]
if (!adminKey || adminKey !== process.env.ADMIN_KEY) {
throw new UnauthorizedError()
}

router.post("/:id/release-escrow", requireAdmin, validateParams(releaseEscrowIdParamSchema), asyncHandler(async (req: Request, res: Response) => {
const { id } = req.params

const invoiceId = parseInt(id, 10)
Expand Down
3 changes: 2 additions & 1 deletion comebackhere-backend/src/routes/threshold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { requireEnv } from "../lib/env.js"
import { asyncHandler } from "../lib/errors.js"
import { validateBody } from "../middleware/validate.js"
import { requireAdmin } from "../middleware/adminAuth.js"
import { thresholdSchema } from "../schemas/index.js"

const router = Router()
Expand Down Expand Up @@ -70,7 +71,7 @@ export async function setThreshold(
return { threshold, tx_hash: txHash }
}

router.post("/threshold", validateBody(thresholdSchema), asyncHandler(async (req: Request, res: Response) => {
router.post("/threshold", requireAdmin, validateBody(thresholdSchema), asyncHandler(async (req: Request, res: Response) => {
const env = requireEnv({
treasuryContractId: "TREASURY_CONTRACT_ID",
signerSecret: "SIGNER_SECRET_KEY",
Expand Down
Loading