From 8fc728626b3b0d6470853858aeebd9cbe8757230 Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Fri, 28 Aug 2026 02:53:52 +0100 Subject: [PATCH] Structured error types, multi-strategy auth, payment provider bootstrap, typed API client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four independent fixes, each closing one assigned wave issue: - #719: PaymentError/AuthError/ProjectError/DisputeError/NotFoundError/ ValidationError extended `Error` directly, so errorHandler.ts's `err instanceof AppError` check never recognized them — throwing any of them anywhere would have been silently downgraded to an opaque 500, discarding their real statusCode/code/message. They now all extend AppError. types/errors.ts is the single source of truth for AppError; errorHandler.ts re-exports it for the ~130 existing import sites. - #721: `routes/push.ts` imports `authMiddleware` from `middleware/auth.ts` — a module that didn't exist. Added it as a composable multi-strategy authenticator (session/HMAC/token/API-key), reusing the existing single-purpose validators (token-auth.ts, hmac-auth.ts) rather than re-implementing their crypto, and populating `req.user` in the shape BaseController.getUser() already expects. - #726: The PaymentProvider strategy pattern for multi-chain payments (provider-registry.ts, payment-router.ts, the four chain/rail providers, unified-payment-tracker.ts, payment-strategies routes) was already fully built and tested, but provider registration only happened inside `di/container.ts`'s `initialize()`, which nothing in the running app ever called — so `providerRegistry` was empty and every payment would have failed with PROVIDER_UNAVAILABLE. Extracted the registration into `services/payments/bootstrap.ts` and call it (plus mount the payment-strategies router) from index.ts at startup. - #729: frontend/lib/api.ts's automatic retry was already solid (lib/api/client.ts has exponential backoff, jitter, and offline queueing) but most of its endpoint wrappers returned `any`, including one (checkout) whose type didn't even match the real `{ data: ... }` response envelope. Replaced the `any`s with real interfaces (some cross-checked against their backend route/service, e.g. GeneratedInvoice against InvoiceRecord). Also: two stale, pre-existing compiled `.js` files (middleware/errorHandler.js, types/errors.js) sitting alongside their `.ts` source were shadowing the `.ts` files under vitest's module resolution — regenerated both from current source so `err instanceof AppError` actually holds under test. Two other such stale siblings (services/stellar.js, config/env.js) had drifted too far from their `.ts` source to safely regenerate (missing transitive requires), so those two are just removed; nothing runs the app from these `src/**/*.js` files (the real build emits to dist/), so this is test-resolution-only and does not touch prod behavior. Closes #719 Closes #721 Closes #726 Closes #729 --- backend/src/config/env.js | 54 ------ backend/src/di/container.ts | 10 +- backend/src/index.ts | 6 + backend/src/middleware/__tests__/auth.test.ts | 106 +++++++++++ .../middleware/__tests__/errorHandler.test.ts | 19 +- backend/src/middleware/auth.ts | 179 ++++++++++++++++++ backend/src/middleware/errorHandler.js | 99 +++++----- backend/src/middleware/errorHandler.ts | 20 +- backend/src/middleware/index.ts | 12 ++ .../payments/__tests__/bootstrap.test.ts | 34 ++++ backend/src/services/payments/bootstrap.ts | 25 +++ backend/src/services/stellar.js | 153 --------------- backend/src/types/__tests__/errors.test.ts | 45 +++++ backend/src/types/errors.js | 69 +++++++ backend/src/types/errors.ts | 94 ++++++--- frontend/lib/api.test.ts | 99 ++++++++++ frontend/lib/api.ts | 153 ++++++++++++--- 17 files changed, 835 insertions(+), 342 deletions(-) delete mode 100644 backend/src/config/env.js create mode 100644 backend/src/middleware/__tests__/auth.test.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/services/payments/__tests__/bootstrap.test.ts create mode 100644 backend/src/services/payments/bootstrap.ts delete mode 100644 backend/src/services/stellar.js create mode 100644 backend/src/types/__tests__/errors.test.ts create mode 100644 backend/src/types/errors.js create mode 100644 frontend/lib/api.test.ts diff --git a/backend/src/config/env.js b/backend/src/config/env.js deleted file mode 100644 index 909cf0a1..00000000 --- a/backend/src/config/env.js +++ /dev/null @@ -1,54 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.config = exports.validateEnv = void 0; -var zod_1 = require("zod"); -var dotenv_1 = require("dotenv"); -dotenv_1.default.config(); -var envSchema = zod_1.z.object({ - NODE_ENV: zod_1.z.enum(['development', 'test', 'production']).default('development'), - PORT: zod_1.z.coerce.number().default(3001), - CORS_ALLOWED_ORIGINS: zod_1.z.string().default('*'), - STELLAR_NETWORK: zod_1.z.enum(['testnet', 'public']).default('testnet'), - OPENAI_API_KEY: zod_1.z.string({ - required_error: 'OPENAI_API_KEY is required for verification and invoicing services', - }).min(1, 'OPENAI_API_KEY cannot be empty'), - JOBS_ENABLED: zod_1.z.coerce.string().transform(function (val) { return val !== 'false'; }).default('true'), - QUEUE_ENABLED: zod_1.z.coerce.string().transform(function (val) { return val !== 'false'; }).default('true'), - RATE_LIMIT_FREE: zod_1.z.coerce.number().default(100), - RATE_LIMIT_PRO: zod_1.z.coerce.number().default(300), - RATE_LIMIT_ENTERPRISE: zod_1.z.coerce.number().default(1000), - RATE_LIMIT_WINDOW_MS: zod_1.z.coerce.number().default(15 * 60 * 1000), - IP_ALLOWLIST: zod_1.z.string().default(''), - IP_ALLOWLIST_ENABLED: zod_1.z.coerce.string().transform(function (val) { return val === 'true'; }).default('false'), - IP_ALLOWLIST_BYPASS_ENABLED: zod_1.z.coerce.string().transform(function (val) { return val === 'true'; }).default('false'), - IP_ALLOWLIST_BYPASS_EXPIRY_MS: zod_1.z.coerce.number().default(30 * 60 * 1000), - VAPID_PUBLIC_KEY: zod_1.z.string().default(''), - VAPID_PRIVATE_KEY: zod_1.z.string().default(''), - STRIPE_SECRET_KEY: zod_1.z.string().default(''), - STRIPE_WEBHOOK_SECRET: zod_1.z.string().default(''), - STRIPE_PUBLISHABLE_KEY: zod_1.z.string().default(''), -}); -var _config; -var validateEnv = function () { - try { - _config = envSchema.parse(process.env); - return _config; - } - catch (error) { - if (error instanceof zod_1.z.ZodError) { - var missingVars = error.errors.map(function (err) { return "".concat(err.path.join('.'), ": ").concat(err.message); }); - console.error('❌ Invalid environment variables:'); - missingVars.forEach(function (msg) { return console.error(" - ".concat(msg)); }); - process.exit(1); - } - throw error; - } -}; -exports.validateEnv = validateEnv; -var config = function () { - if (!_config) { - return (0, exports.validateEnv)(); - } - return _config; -}; -exports.config = config; diff --git a/backend/src/di/container.ts b/backend/src/di/container.ts index fa8e8973..ea1d16e1 100644 --- a/backend/src/di/container.ts +++ b/backend/src/di/container.ts @@ -13,10 +13,7 @@ import { ProjectRepository } from "../repositories/ProjectRepository.js"; import { ProjectService } from "../services/ProjectService.js"; import { ProjectController } from "../controllers/ProjectController.js"; import { providerRegistry } from "../services/payments/provider-registry.js"; -import { SorobanPaymentProvider } from "../services/payments/providers/soroban.js"; -import { EvmPaymentProvider } from "../services/payments/providers/evm.js"; -import { FiatPaymentProvider } from "../services/payments/providers/fiat.js"; -import { CreditPaymentProvider } from "../services/payments/providers/credit.js"; +import { registerDefaultPaymentProviders } from "../services/payments/bootstrap.js"; export class DIContainer { private static instance: DIContainer; @@ -82,10 +79,7 @@ export class DIContainer { const projectController = new ProjectController(projectService); this.set("ProjectController", projectController); - providerRegistry.register(new SorobanPaymentProvider()); - providerRegistry.register(new EvmPaymentProvider()); - providerRegistry.register(new FiatPaymentProvider()); - providerRegistry.register(new CreditPaymentProvider()); + registerDefaultPaymentProviders(providerRegistry); this.set("PaymentProviderRegistry", providerRegistry); } diff --git a/backend/src/index.ts b/backend/src/index.ts index 100df1e6..f3138ca6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -45,6 +45,8 @@ import dataExportRouter from './routes/dataExport.js'; import securityRouter from './routes/security.js'; import commentsRouter from './routes/comments.js'; import collaborationRouter from './routes/collaboration.js'; +import { paymentStrategiesRouter } from './routes/payment-strategies.js'; +import { registerDefaultPaymentProviders } from './services/payments/bootstrap.js'; dotenv.config(); @@ -284,6 +286,8 @@ apiV1Router.use('/security', securityRouter); apiV1Router.use('/comments', commentsRouter); // Real-time collaboration: presence, field locks, edit history — Issue #714 apiV1Router.use('/collaboration', collaborationRouter); +// Multi-chain payment processing via the PaymentProvider strategy pattern — Issue #726 +apiV1Router.use('/payment-strategies', paymentStrategiesRouter); // Explicit URL-based mounting app.use('/api/v1', apiV1Router); @@ -319,6 +323,8 @@ if (config.queue.enabled) { messageQueue.start(); } +registerDefaultPaymentProviders(); + const server = app.listen(config.server.port, () => { console.log(`AgenticPay backend running on port ${config.server.port} [${config.env}]`); }); diff --git a/backend/src/middleware/__tests__/auth.test.ts b/backend/src/middleware/__tests__/auth.test.ts new file mode 100644 index 00000000..e282898a --- /dev/null +++ b/backend/src/middleware/__tests__/auth.test.ts @@ -0,0 +1,106 @@ +/** + * auth.test.ts — Issue #721 + */ +import { describe, it, expect, vi } from 'vitest'; +import type { Request, Response } from 'express'; +import { + createAuthMiddleware, + sessionStrategy, + apiKeyStrategy, + type AuthStrategy, +} from '../auth.js'; +import { createSession } from '../../services/session.js'; +import { AppError } from '../errorHandler.js'; + +function makeReq(headers: Record = {}): Request { + return { headers } as unknown as Request; +} + +function makeRes(): Response { + return {} as Response; +} + +describe('sessionStrategy', () => { + it('resolves a principal from a valid session', async () => { + const session = createSession('user-1', { deviceId: 'd1', browser: 'chrome', os: 'mac', ip: '127.0.0.1' }); + const req = makeReq({ 'x-session-id': session.id }); + + expect(sessionStrategy.applies(req)).toBe(true); + const principal = await sessionStrategy.authenticate(req, makeRes()); + expect(principal).toMatchObject({ id: 'user-1', method: 'session' }); + }); + + it('rejects an unknown session id', async () => { + const req = makeReq({ 'x-session-id': 'does-not-exist' }); + await expect(sessionStrategy.authenticate(req, makeRes())).rejects.toBeInstanceOf(AppError); + }); + + it('does not apply when no session header is present', () => { + expect(sessionStrategy.applies(makeReq())).toBe(false); + }); +}); + +describe('apiKeyStrategy', () => { + it('resolves a principal for a known demo key', async () => { + const req = makeReq({ 'x-api-key': 'apk_free_demo_key_00000000000000000000000001' }); + expect(apiKeyStrategy.applies(req)).toBe(true); + const principal = await apiKeyStrategy.authenticate(req, makeRes()); + expect(principal).toMatchObject({ role: 'free', method: 'apiKey' }); + }); + + it('rejects an unknown api key', async () => { + const req = makeReq({ 'x-api-key': 'not-a-real-key' }); + await expect(apiKeyStrategy.authenticate(req, makeRes())).rejects.toBeInstanceOf(AppError); + }); +}); + +describe('createAuthMiddleware (orchestration)', () => { + function makeStrategy(name: 'session' | 'apiKey', opts: { applies: boolean; succeed?: boolean }): AuthStrategy { + return { + name, + applies: () => opts.applies, + authenticate: vi.fn(async (req) => { + if (!opts.succeed) throw new AppError(401, `${name} failed`, 'FAIL'); + req.user = { id: 'x', tenantId: 't', role: 'user', method: name }; + return req.user; + }), + }; + } + + it('responds 401 when no strategy applies', async () => { + const middleware = createAuthMiddleware([makeStrategy('session', { applies: false })]); + const req = makeReq(); + const next = vi.fn(); + + await middleware(req, makeRes(), next); + + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect((next.mock.calls[0][0] as AppError).statusCode).toBe(401); + }); + + it('authenticates with the first applicable strategy that succeeds', async () => { + const failing = makeStrategy('session', { applies: true, succeed: false }); + const succeeding = makeStrategy('apiKey', { applies: true, succeed: true }); + const middleware = createAuthMiddleware([failing, succeeding]); + const req = makeReq(); + const next = vi.fn(); + + await middleware(req, makeRes(), next); + + expect(req.user).toMatchObject({ method: 'apiKey' }); + expect(next).toHaveBeenCalledWith(); + }); + + it('propagates the last strategy failure when every applicable strategy fails', async () => { + const first = makeStrategy('session', { applies: true, succeed: false }); + const second = makeStrategy('apiKey', { applies: true, succeed: false }); + const middleware = createAuthMiddleware([first, second]); + const req = makeReq(); + const next = vi.fn(); + + await middleware(req, makeRes(), next); + + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(req.user).toBeUndefined(); + }); +}); diff --git a/backend/src/middleware/__tests__/errorHandler.test.ts b/backend/src/middleware/__tests__/errorHandler.test.ts index d0b0612d..8a3d443a 100644 --- a/backend/src/middleware/__tests__/errorHandler.test.ts +++ b/backend/src/middleware/__tests__/errorHandler.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Request, Response } from 'express'; -import { AppError, asyncHandler, notFoundHandler, errorHandler } from '../errorHandler.js'; +import { AppError, PaymentError, asyncHandler, notFoundHandler, errorHandler } from '../errorHandler.js'; function makeReq(overrides: Partial = {}): Request { return { method: 'GET', originalUrl: '/test', ...overrides } as unknown as Request; @@ -158,4 +158,21 @@ describe('errorHandler', () => { const callArg = (res.json as ReturnType).mock.calls[0][0]; expect(callArg.error.details).toEqual({ field: 'email' }); }); + + it('honors a domain error subclass statusCode/message instead of downgrading it to 500', () => { + // Regression test for Issue #719: PaymentError (and the other domain + // errors in types/errors.ts) used to extend `Error` directly, so + // `err instanceof AppError` was false and this handler discarded their + // statusCode/message in favor of a generic 500 "Internal server error". + const err = new PaymentError('Insufficient balance to cover this payment'); + const req = makeReq(); + const { res } = makeRes(); + const next = vi.fn(); + + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + const callArg = (res.json as ReturnType).mock.calls[0][0]; + expect(callArg.error.message).toBe('Insufficient balance to cover this payment'); + }); }); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 00000000..bd665e85 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,179 @@ +/** + * auth.ts — Issue #721 + * + * Composable, multi-strategy authentication middleware. + * + * `routes/push.ts` (and, going forward, any route that needs a hard + * authentication gate) imports `authMiddleware` from this module — but the + * module didn't exist, so that import was broken. This codebase already had + * three independent, single-purpose credential validators + * (`token-auth.ts`, `hmac-auth.ts`, `ws-auth.ts`) plus a session store + * (`services/session.ts`) and an API-key registry + * (`services/api-key-registry.ts`), none of which populated `req.user` or + * enforced that *some* strategy succeeded. This module is the missing piece: + * an `AuthStrategy` interface, one implementation per credential type + * (reusing the existing validators rather than re-implementing their + * crypto/replay-protection logic), and an orchestrator that tries every + * strategy whose credential is present on the request, populates + * `req.user` on the first success (in the same `{id, tenantId, role}` shape + * `BaseController.getUser()` already expects), and otherwise rejects with a + * 401. + */ + +import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import { AppError } from './errorHandler.js'; +import { hmacAuthMiddleware, HEADER_SIGNATURE } from './hmac-auth.js'; +import { tokenAuthMiddleware } from './token-auth.js'; +import { getSession } from '../services/session.js'; +import { lookupApiKey } from '../services/api-key-registry.js'; + +export type AuthMethod = 'session' | 'hmac' | 'token' | 'apiKey'; + +export interface AuthPrincipal { + id: string; + tenantId: string; + role: string; + method: AuthMethod; +} + +// `req.user` is read throughout the codebase (errorHandler, BaseController, +// responseFormatter, ...) but was never declared on Express's Request type. +declare global { + namespace Express { + interface Request { + user?: AuthPrincipal; + } + } +} + +export interface AuthStrategy { + name: AuthMethod; + /** Does this request carry this strategy's credential? */ + applies(req: Request): boolean; + /** Validate the credential and resolve a principal. Throws on invalid credentials. */ + authenticate(req: Request, res: Response): Promise; +} + +function getHeader(req: Request, name: string): string | undefined { + const raw = req.headers[name]; + return Array.isArray(raw) ? raw[0] : raw; +} + +/** Runs an Express-shaped middleware and resolves once it calls `next()`. */ +function runMiddleware(mw: RequestHandler, req: Request, res: Response): Promise<{ ok: true } | { ok: false; error: unknown }> { + return new Promise((resolve) => { + mw(req, res, ((err?: unknown) => { + resolve(err ? { ok: false, error: err } : { ok: true }); + }) as NextFunction); + }); +} + +// ── Strategies ────────────────────────────────────────────────────────────── + +/** `x-session-id` header, validated against the in-memory session store. */ +export const sessionStrategy: AuthStrategy = { + name: 'session', + applies: (req) => Boolean(getHeader(req, 'x-session-id')), + async authenticate(req) { + const sessionId = getHeader(req, 'x-session-id')!; + const session = getSession(sessionId); + if (!session || session.status === 'terminated') { + throw new AppError(401, 'Invalid or terminated session', 'SESSION_INVALID'); + } + return { + id: session.userId, + tenantId: getHeader(req, 'x-tenant-id') ?? 'default', + role: 'user', + method: 'session', + }; + }, +}; + +/** `X-Signature`/`X-Timestamp`/`X-Nonce` headers — delegates to the existing HMAC verifier. */ +export const hmacStrategy: AuthStrategy = { + name: 'hmac', + applies: (req) => Boolean(getHeader(req, HEADER_SIGNATURE)), + async authenticate(req, res) { + const result = await runMiddleware(hmacAuthMiddleware({ required: true }), req, res); + if (!result.ok) throw result.error; + const augmented = req as Request & { hmacKeyId?: string; hmacTenantId?: string }; + return { + id: augmented.hmacKeyId ?? 'unknown-key', + tenantId: augmented.hmacTenantId ?? 'default', + role: 'service', + method: 'hmac', + }; + }, +}; + +/** `Authorization: Bearer at_...` header — delegates to the existing token format validator. */ +export const tokenStrategy: AuthStrategy = { + name: 'token', + applies: (req) => Boolean(req.headers.authorization?.startsWith('Bearer ')), + async authenticate(req, res) { + const result = await runMiddleware(tokenAuthMiddleware, req, res); + if (!result.ok) throw result.error; + const token = (req as Request & { accessToken?: string }).accessToken; + if (!token) throw new AppError(401, 'Malformed access token', 'TOKEN_MALFORMED'); + return { + id: token, + tenantId: getHeader(req, 'x-tenant-id') ?? 'default', + role: 'user', + method: 'token', + }; + }, +}; + +/** `x-api-key` header, resolved against the API key registry. */ +export const apiKeyStrategy: AuthStrategy = { + name: 'apiKey', + applies: (req) => Boolean(getHeader(req, 'x-api-key')), + async authenticate(req) { + const key = getHeader(req, 'x-api-key')!; + const record = lookupApiKey(key); + if (!record) throw new AppError(401, 'Invalid API key', 'API_KEY_INVALID'); + return { + id: record.key, + tenantId: getHeader(req, 'x-tenant-id') ?? 'default', + role: record.tier, + method: 'apiKey', + }; + }, +}; + +export const DEFAULT_AUTH_STRATEGIES: AuthStrategy[] = [sessionStrategy, hmacStrategy, tokenStrategy, apiKeyStrategy]; + +/** + * Build an auth middleware from a list of strategies. Every strategy whose + * credential is present on the request is tried, in order; the first one to + * successfully authenticate wins and its principal is attached as + * `req.user`. If none of the applicable strategies succeed, the last + * failure is forwarded to the error handler; if no strategy's credential is + * present at all, responds 401 "Authentication required". + */ +export function createAuthMiddleware(strategies: AuthStrategy[] = DEFAULT_AUTH_STRATEGIES): RequestHandler { + return async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise { + const applicable = strategies.filter((s) => s.applies(req)); + if (applicable.length === 0) { + next(new AppError(401, 'Authentication required', 'AUTH_REQUIRED')); + return; + } + + for (let i = 0; i < applicable.length; i++) { + try { + const principal = await applicable[i].authenticate(req, res); + req.user = principal; + next(); + return; + } catch (err) { + if (i === applicable.length - 1) { + next(err); + return; + } + // Otherwise fall through and try the next applicable strategy. + } + } + }; +} + +export const authMiddleware = createAuthMiddleware(); diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js index 2ace351c..607fc256 100644 --- a/backend/src/middleware/errorHandler.js +++ b/backend/src/middleware/errorHandler.js @@ -1,74 +1,63 @@ "use strict"; -var __extends = (this && this.__extends) || (function () { - var extendStatics = function (d, b) { - extendStatics = Object.setPrototypeOf || - ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || - function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; - return extendStatics(d, b); - }; - return function (d, b) { - if (typeof b !== "function" && b !== null) - throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); - extendStatics(d, b); - function __() { this.constructor = d; } - d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); - }; -})(); -var __assign = (this && this.__assign) || function () { - __assign = Object.assign || function(t) { - for (var s, i = 1, n = arguments.length; i < n; i++) { - s = arguments[i]; - for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) - t[p] = s[p]; - } - return t; - }; - return __assign.apply(this, arguments); -}; Object.defineProperty(exports, "__esModule", { value: true }); -exports.AppError = void 0; +exports.NotFoundError = exports.ValidationError = exports.DisputeError = exports.ProjectError = exports.AuthError = exports.PaymentError = exports.AppError = void 0; exports.asyncHandler = asyncHandler; exports.notFoundHandler = notFoundHandler; exports.errorHandler = errorHandler; -var AppError = /** @class */ (function (_super) { - __extends(AppError, _super); - function AppError(statusCode, message, code, details) { - if (code === void 0) { code = 'INTERNAL_SERVER_ERROR'; } - var _this = _super.call(this, message) || this; - _this.name = 'AppError'; - _this.statusCode = statusCode; - _this.code = code; - _this.details = details; - return _this; - } - return AppError; -}(Error)); -exports.AppError = AppError; +const error_codes_1 = require("@agenticpay/error-codes"); +const errors_1 = require("../types/errors"); +Object.defineProperty(exports, "AppError", { enumerable: true, get: function () { return errors_1.AppError; } }); +Object.defineProperty(exports, "PaymentError", { enumerable: true, get: function () { return errors_1.PaymentError; } }); +Object.defineProperty(exports, "AuthError", { enumerable: true, get: function () { return errors_1.AuthError; } }); +Object.defineProperty(exports, "ProjectError", { enumerable: true, get: function () { return errors_1.ProjectError; } }); +Object.defineProperty(exports, "DisputeError", { enumerable: true, get: function () { return errors_1.DisputeError; } }); +Object.defineProperty(exports, "ValidationError", { enumerable: true, get: function () { return errors_1.ValidationError; } }); +Object.defineProperty(exports, "NotFoundError", { enumerable: true, get: function () { return errors_1.NotFoundError; } }); function asyncHandler(handler) { - return function (req, res, next) { + return (req, res, next) => { Promise.resolve(handler(req, res, next)).catch(next); }; } function notFoundHandler(req, _res, next) { - next(new AppError(404, "Route not found: ".concat(req.method, " ").concat(req.originalUrl), 'NOT_FOUND')); + next(new errors_1.NotFoundError(`Route not found: ${req.method} ${req.originalUrl}`)); } -function errorHandler(err, _req, res, _next) { - var isAppError = err instanceof AppError; - var statusCode = isAppError ? err.statusCode : 500; - var code = isAppError ? err.code : 'INTERNAL_SERVER_ERROR'; - var isProduction = process.env.NODE_ENV === 'production'; - var message = isAppError +function errorHandler(err, req, res, _next) { + const isAppError = err instanceof errors_1.AppError; + const statusCode = isAppError ? err.statusCode : 500; + const code = (0, error_codes_1.resolveErrorCode)(isAppError ? err.code : undefined, statusCode); + const registered = error_codes_1.ERROR_CODE_REGISTRY[code]; + const isProduction = process.env.NODE_ENV === 'production'; + const message = isAppError ? err.message : isProduction ? 'Internal server error' : err instanceof Error ? err.message : 'Unexpected error'; - var logMethod = statusCode >= 500 ? console.error : console.warn; - logMethod("[".concat(code, "] ").concat(message), err); - res.status(statusCode).json({ - error: __assign(__assign({ code: code, message: message, status: statusCode }, (isAppError && err.details !== undefined ? { details: err.details } : {})), (!isProduction && !isAppError && err instanceof Error && err.stack - ? { stack: err.stack } - : {})), + const logMethod = registered.httpStatus >= 500 ? console.error : console.warn; + const logContext = { + code, + message, + statusCode: registered.httpStatus || statusCode, + ...(isAppError && err.metadata ? { metadata: err.metadata } : {}), + ...(req.requestId ? { requestId: req.requestId } : {}), + ...(req.user?.id ? { userId: req.user.id } : {}), + ...(!isProduction && !isAppError && err instanceof Error && err.stack ? { stack: err.stack } : {}), + }; + logMethod(`[${code}] ${message}`, logContext); + if (registered.deprecated && registered.sunsetAt) { + res.setHeader('Sunset', registered.sunsetAt); + res.setHeader('Deprecation', 'true'); + } + res.status(registered.httpStatus || statusCode).json({ + error: { + code, + message, + ...(isAppError && err.details !== undefined ? { details: err.details } : {}), + ...(req.requestId ? { requestId: req.requestId } : {}), + ...(!isProduction && !isAppError && err instanceof Error && err.stack + ? { stack: err.stack } + : {}), + }, }); } diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index 6d74892e..22853fe5 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -1,26 +1,10 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express'; import { ERROR_CODE_REGISTRY, resolveErrorCode } from '@agenticpay/error-codes'; -import { PaymentError, AuthError, ProjectError, DisputeError, ValidationError, NotFoundError } from '../types/errors'; +import { AppError, PaymentError, AuthError, ProjectError, DisputeError, ValidationError, NotFoundError } from '../types/errors'; type AsyncRouteHandler = (req: Request, res: Response, next: NextFunction) => Promise; -export class AppError extends Error { - statusCode: number; - code: string; - details?: unknown; - metadata?: Record; - - constructor(statusCode: number, message: string, code = 'INTERNAL_SERVER_ERROR', details?: unknown, metadata?: Record) { - super(message); - this.name = 'AppError'; - this.statusCode = statusCode; - this.code = code; - this.details = details; - this.metadata = metadata; - } -} - -export { PaymentError, AuthError, ProjectError, DisputeError, ValidationError, NotFoundError }; +export { AppError, PaymentError, AuthError, ProjectError, DisputeError, ValidationError, NotFoundError }; export function asyncHandler(handler: AsyncRouteHandler): RequestHandler { return (req, res, next) => { diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index dd1dac99..cea57224 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -21,4 +21,16 @@ export { verifyWebhook, webhookVerifiers, rawBodyCapture, type WebhookVerificati export { composeMiddleware, type MiddlewareFunction, type MiddlewareChain } from './compose.js'; export { tokenAuthMiddleware } from './token-auth.js'; export { hmacAuthMiddleware, invalidateKeyCache, HEADER_SIGNATURE, HEADER_TIMESTAMP, HEADER_NONCE } from './hmac-auth.js'; +export { + authMiddleware, + createAuthMiddleware, + DEFAULT_AUTH_STRATEGIES, + sessionStrategy, + hmacStrategy, + tokenStrategy, + apiKeyStrategy, + type AuthStrategy, + type AuthPrincipal, + type AuthMethod, +} from './auth.js'; export { requestCoalescer, getCoalesceMetrics, setCoalesceConfig, resetCoalesceStore } from './request-coalescer.js'; diff --git a/backend/src/services/payments/__tests__/bootstrap.test.ts b/backend/src/services/payments/__tests__/bootstrap.test.ts new file mode 100644 index 00000000..8f0f52c2 --- /dev/null +++ b/backend/src/services/payments/__tests__/bootstrap.test.ts @@ -0,0 +1,34 @@ +/** + * bootstrap.test.ts — Issue #726 + * + * Regression test: `providerRegistry.register(...)` for the four concrete + * strategies previously only happened inside `di/container.ts`'s + * `initialize()`, which nothing in the running app called — so + * `providerRegistry.list()` was empty and every `/payment-strategies/pay` + * request would fail with PROVIDER_UNAVAILABLE. `registerDefaultPaymentProviders` + * is now called from `index.ts` at startup. + */ +import { describe, it, expect } from 'vitest'; +import { PaymentProviderRegistry } from '../provider-registry.js'; +import { registerDefaultPaymentProviders } from '../bootstrap.js'; +import { selectProviderId } from '../payment-router.js'; + +describe('registerDefaultPaymentProviders', () => { + it('registers all four chain/rail strategies', () => { + const registry = new PaymentProviderRegistry(); + + registerDefaultPaymentProviders(registry); + + expect(registry.list().sort()).toEqual(['credit', 'evm', 'fiat', 'soroban']); + }); + + it('resolves a registered provider for every strategy selectProviderId can pick', () => { + const registry = new PaymentProviderRegistry(); + registerDefaultPaymentProviders(registry); + + for (const id of ['soroban', 'evm', 'fiat', 'credit']) { + expect(registry.get(id)).toBeDefined(); + } + expect(selectProviderId({ amount: 1, currency: 'XLM', toAddress: 'G', network: 'stellar', tenantId: 't' })).toBe('soroban'); + }); +}); diff --git a/backend/src/services/payments/bootstrap.ts b/backend/src/services/payments/bootstrap.ts new file mode 100644 index 00000000..a0ee4afc --- /dev/null +++ b/backend/src/services/payments/bootstrap.ts @@ -0,0 +1,25 @@ +/** + * bootstrap.ts — Issue #726 + * + * Registers the concrete multi-chain PaymentProvider strategies into the + * singleton `providerRegistry` used by `payment-router.ts` / + * `unified-payment-tracker.ts`. This registration previously only happened + * inside `di/container.ts`'s `initialize()` method, which nothing in the + * running app ever calls — so `providerRegistry.list()` was empty and every + * call to `/payment-strategies/pay` would have failed with "All payment + * providers failed" (no provider registered to route to). Call + * `registerDefaultPaymentProviders()` once at app startup, before the + * payment-strategies routes are reachable. + */ +import { providerRegistry, type PaymentProviderRegistry } from './provider-registry.js'; +import { SorobanPaymentProvider } from './providers/soroban.js'; +import { EvmPaymentProvider } from './providers/evm.js'; +import { FiatPaymentProvider } from './providers/fiat.js'; +import { CreditPaymentProvider } from './providers/credit.js'; + +export function registerDefaultPaymentProviders(registry: PaymentProviderRegistry = providerRegistry): void { + registry.register(new SorobanPaymentProvider()); + registry.register(new EvmPaymentProvider()); + registry.register(new FiatPaymentProvider()); + registry.register(new CreditPaymentProvider()); +} diff --git a/backend/src/services/stellar.js b/backend/src/services/stellar.js deleted file mode 100644 index 7ac2d8d1..00000000 --- a/backend/src/services/stellar.js +++ /dev/null @@ -1,153 +0,0 @@ -"use strict"; -var __extends = (this && this.__extends) || (function () { - var extendStatics = function (d, b) { - extendStatics = Object.setPrototypeOf || - ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || - function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; - return extendStatics(d, b); - }; - return function (d, b) { - if (typeof b !== "function" && b !== null) - throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); - extendStatics(d, b); - function __() { this.constructor = d; } - d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); - }; -})(); -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -var __generator = (this && this.__generator) || function (thisArg, body) { - var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); - return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; - function verb(n) { return function (v) { return step([n, v]); }; } - function step(op) { - if (f) throw new TypeError("Generator is already executing."); - while (g && (g = 0, op[0] && (_ = 0)), _) try { - if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; - if (y = 0, t) op = [op[0] & 2, t.value]; - switch (op[0]) { - case 0: case 1: t = op; break; - case 4: _.label++; return { value: op[1], done: false }; - case 5: _.label++; y = op[1]; op = [0]; continue; - case 7: op = _.ops.pop(); _.trys.pop(); continue; - default: - if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } - if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } - if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } - if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } - if (t[2]) _.ops.pop(); - _.trys.pop(); continue; - } - op = body.call(thisArg, _); - } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } - if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; - } -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.InvalidStellarInputError = exports.ValidationError = exports.server = void 0; -exports.isValidStellarAddress = isValidStellarAddress; -exports.isValidTransactionHash = isValidTransactionHash; -exports.getAccountInfo = getAccountInfo; -exports.getTransactionStatus = getTransactionStatus; -var StellarSdk = require("@stellar/stellar-sdk"); -var env_js_1 = require("../config/env.js"); -var NETWORK = (0, env_js_1.config)().STELLAR_NETWORK; -var HORIZON_URL = NETWORK === 'public' - ? 'https://horizon.stellar.org' - : 'https://horizon-testnet.stellar.org'; -exports.server = new StellarSdk.Horizon.Server(HORIZON_URL); -var ValidationError = /** @class */ (function (_super) { - __extends(ValidationError, _super); - function ValidationError(message, statusCode) { - if (statusCode === void 0) { statusCode = 400; } - var _this = _super.call(this, message) || this; - _this.name = 'ValidationError'; - _this.statusCode = statusCode; - return _this; - } - return ValidationError; -}(Error)); -exports.ValidationError = ValidationError; -var InvalidStellarInputError = /** @class */ (function (_super) { - __extends(InvalidStellarInputError, _super); - function InvalidStellarInputError(message) { - var _this = _super.call(this, message) || this; - _this.name = 'InvalidStellarInputError'; - return _this; - } - return InvalidStellarInputError; -}(Error)); -exports.InvalidStellarInputError = InvalidStellarInputError; -function isValidStellarAddress(address) { - if (!(address === null || address === void 0 ? void 0 : address.trim())) { - return false; - } - return StellarSdk.StrKey.isValidEd25519PublicKey(address); -} -function isValidTransactionHash(hash) { - if (!(hash === null || hash === void 0 ? void 0 : hash.trim())) { - return false; - } - return /^[A-Fa-f0-9]{64}$/.test(hash); -} -function assertValidStellarAddress(address) { - if (!isValidStellarAddress(address)) { - throw new InvalidStellarInputError('Invalid Stellar address'); - } -} -function assertValidTransactionHash(hash) { - if (!isValidTransactionHash(hash)) { - throw new InvalidStellarInputError('Invalid transaction hash'); - } -} -function getAccountInfo(address) { - return __awaiter(this, void 0, void 0, function () { - var account; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: - assertValidStellarAddress(address); - return [4 /*yield*/, exports.server.loadAccount(address)]; - case 1: - account = _a.sent(); - return [2 /*return*/, { - address: account.accountId(), - balances: account.balances.map(function (b) { return ({ - type: b.asset_type, - balance: b.balance, - }); }), - sequence: account.sequence, - }]; - } - }); - }); -} -function getTransactionStatus(hash) { - return __awaiter(this, void 0, void 0, function () { - var tx; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: - assertValidTransactionHash(hash); - return [4 /*yield*/, exports.server.transactions().transaction(hash).call()]; - case 1: - tx = _a.sent(); - return [2 /*return*/, { - hash: tx.hash, - successful: tx.successful, - ledger: tx.ledger_attr, - createdAt: tx.created_at, - memo: tx.memo, - operationCount: tx.operation_count, - }]; - } - }); - }); -} diff --git a/backend/src/types/__tests__/errors.test.ts b/backend/src/types/__tests__/errors.test.ts new file mode 100644 index 00000000..59b225e9 --- /dev/null +++ b/backend/src/types/__tests__/errors.test.ts @@ -0,0 +1,45 @@ +/** + * errors.test.ts — Issue #719 + * + * Verifies every domain error extends AppError, so the central error + * handler's `err instanceof AppError` check recognizes them and preserves + * their statusCode/code/message/details instead of downgrading them to a + * generic 500. + */ +import { describe, it, expect } from 'vitest'; +import { + AppError, + PaymentError, + AuthError, + ProjectError, + DisputeError, + NotFoundError, + ValidationError, +} from '../errors.js'; + +describe('domain error hierarchy', () => { + it.each([ + ['PaymentError', PaymentError, 'PAYMENT_ERROR', 400], + ['AuthError', AuthError, 'AUTH_ERROR', 401], + ['ProjectError', ProjectError, 'PROJECT_ERROR', 400], + ['DisputeError', DisputeError, 'DISPUTE_ERROR', 400], + ['NotFoundError', NotFoundError, 'NOT_FOUND', 404], + ['ValidationError', ValidationError, 'VALIDATION_ERROR', 422], + ] as const)('%s is an AppError with the expected default code/statusCode', (_name, ErrorClass, defaultCode, defaultStatus) => { + const err = new ErrorClass('something went wrong'); + + expect(err).toBeInstanceOf(AppError); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe(defaultCode); + expect(err.statusCode).toBe(defaultStatus); + expect(err.message).toBe('something went wrong'); + }); + + it('allows overriding code, statusCode, and metadata', () => { + const err = new PaymentError('Insufficient funds', 'ERR_PAYMENT_INSUFFICIENT_FUNDS', 402, { available: 10 }); + + expect(err.code).toBe('ERR_PAYMENT_INSUFFICIENT_FUNDS'); + expect(err.statusCode).toBe(402); + expect(err.metadata).toEqual({ available: 10 }); + }); +}); diff --git a/backend/src/types/errors.js b/backend/src/types/errors.js new file mode 100644 index 00000000..07a50ed2 --- /dev/null +++ b/backend/src/types/errors.js @@ -0,0 +1,69 @@ +"use strict"; +/** + * types/errors.ts — Issue #719 + * + * Structured error types with error codes. `AppError` is the single base + * every domain error extends, so `err instanceof AppError` — the check + * `middleware/errorHandler.ts` uses to decide whether to trust an error's + * statusCode/code/details or fall back to a generic 500 — recognizes all of + * them uniformly. Before this change each domain error (PaymentError, + * AuthError, ProjectError, DisputeError, NotFoundError, ValidationError) + * extended `Error` directly, so throwing one anywhere in the app would have + * been silently downgraded to an opaque 500 response by the central error + * handler, discarding its statusCode/code/details. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ValidationError = exports.NotFoundError = exports.DisputeError = exports.ProjectError = exports.AuthError = exports.PaymentError = exports.AppError = void 0; +class AppError extends Error { + constructor(statusCode, message, code = 'INTERNAL_SERVER_ERROR', details, metadata) { + super(message); + this.name = 'AppError'; + this.statusCode = statusCode; + this.code = code; + this.details = details; + this.metadata = metadata; + } +} +exports.AppError = AppError; +class PaymentError extends AppError { + constructor(message, code = 'PAYMENT_ERROR', statusCode = 400, metadata) { + super(statusCode, message, code, undefined, metadata); + this.name = 'PaymentError'; + } +} +exports.PaymentError = PaymentError; +class AuthError extends AppError { + constructor(message, code = 'AUTH_ERROR', statusCode = 401, metadata) { + super(statusCode, message, code, undefined, metadata); + this.name = 'AuthError'; + } +} +exports.AuthError = AuthError; +class ProjectError extends AppError { + constructor(message, code = 'PROJECT_ERROR', statusCode = 400, metadata) { + super(statusCode, message, code, undefined, metadata); + this.name = 'ProjectError'; + } +} +exports.ProjectError = ProjectError; +class DisputeError extends AppError { + constructor(message, code = 'DISPUTE_ERROR', statusCode = 400, metadata) { + super(statusCode, message, code, undefined, metadata); + this.name = 'DisputeError'; + } +} +exports.DisputeError = DisputeError; +class NotFoundError extends AppError { + constructor(message, code = 'NOT_FOUND', statusCode = 404, metadata) { + super(statusCode, message, code, undefined, metadata); + this.name = 'NotFoundError'; + } +} +exports.NotFoundError = NotFoundError; +class ValidationError extends AppError { + constructor(message, code = 'VALIDATION_ERROR', statusCode = 422, metadata) { + super(statusCode, message, code, undefined, metadata); + this.name = 'ValidationError'; + } +} +exports.ValidationError = ValidationError; diff --git a/backend/src/types/errors.ts b/backend/src/types/errors.ts index 89e2a9f2..477d1235 100644 --- a/backend/src/types/errors.ts +++ b/backend/src/types/errors.ts @@ -1,71 +1,107 @@ -export class PaymentError extends Error { +/** + * types/errors.ts — Issue #719 + * + * Structured error types with error codes. `AppError` is the single base + * every domain error extends, so `err instanceof AppError` — the check + * `middleware/errorHandler.ts` uses to decide whether to trust an error's + * statusCode/code/details or fall back to a generic 500 — recognizes all of + * them uniformly. Before this change each domain error (PaymentError, + * AuthError, ProjectError, DisputeError, NotFoundError, ValidationError) + * extended `Error` directly, so throwing one anywhere in the app would have + * been silently downgraded to an opaque 500 response by the central error + * handler, discarding its statusCode/code/details. + */ + +export class AppError extends Error { + statusCode: number; + code: string; + details?: unknown; + metadata?: Record; + constructor( + statusCode: number, message: string, - public code: string = 'PAYMENT_ERROR', - public statusCode: number = 400, - public metadata?: Record, + code = 'INTERNAL_SERVER_ERROR', + details?: unknown, + metadata?: Record, ) { super(message); + this.name = 'AppError'; + this.statusCode = statusCode; + this.code = code; + this.details = details; + this.metadata = metadata; + } +} + +export class PaymentError extends AppError { + constructor( + message: string, + code: string = 'PAYMENT_ERROR', + statusCode: number = 400, + metadata?: Record, + ) { + super(statusCode, message, code, undefined, metadata); this.name = 'PaymentError'; } } -export class AuthError extends Error { +export class AuthError extends AppError { constructor( message: string, - public code: string = 'AUTH_ERROR', - public statusCode: number = 401, - public metadata?: Record, + code: string = 'AUTH_ERROR', + statusCode: number = 401, + metadata?: Record, ) { - super(message); + super(statusCode, message, code, undefined, metadata); this.name = 'AuthError'; } } -export class ProjectError extends Error { +export class ProjectError extends AppError { constructor( message: string, - public code: string = 'PROJECT_ERROR', - public statusCode: number = 400, - public metadata?: Record, + code: string = 'PROJECT_ERROR', + statusCode: number = 400, + metadata?: Record, ) { - super(message); + super(statusCode, message, code, undefined, metadata); this.name = 'ProjectError'; } } -export class DisputeError extends Error { +export class DisputeError extends AppError { constructor( message: string, - public code: string = 'DISPUTE_ERROR', - public statusCode: number = 400, - public metadata?: Record, + code: string = 'DISPUTE_ERROR', + statusCode: number = 400, + metadata?: Record, ) { - super(message); + super(statusCode, message, code, undefined, metadata); this.name = 'DisputeError'; } } -export class NotFoundError extends Error { +export class NotFoundError extends AppError { constructor( message: string, - public code: string = 'NOT_FOUND', - public statusCode: number = 404, - public metadata?: Record, + code: string = 'NOT_FOUND', + statusCode: number = 404, + metadata?: Record, ) { - super(message); + super(statusCode, message, code, undefined, metadata); this.name = 'NotFoundError'; } } -export class ValidationError extends Error { +export class ValidationError extends AppError { constructor( message: string, - public code: string = 'VALIDATION_ERROR', - public statusCode: number = 422, - public metadata?: Record, + code: string = 'VALIDATION_ERROR', + statusCode: number = 422, + metadata?: Record, ) { - super(message); + super(statusCode, message, code, undefined, metadata); this.name = 'ValidationError'; } } diff --git a/frontend/lib/api.test.ts b/frontend/lib/api.test.ts new file mode 100644 index 00000000..f0e74682 --- /dev/null +++ b/frontend/lib/api.test.ts @@ -0,0 +1,99 @@ +/** + * api.test.ts — Issue #729 + * + * Verifies the typed `api` wrappers delegate to the retrying `apiCall` + * client with the right method/path/body, and return its (now precisely + * typed) response untouched. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const apiCallMock = vi.fn(); +vi.mock('@/lib/api/client', () => ({ + apiCall: (...args: unknown[]) => apiCallMock(...args), +})); + +const { api } = await import('./api'); + +describe('api.verifyWork / api.generateInvoice', () => { + beforeEach(() => { + apiCallMock.mockReset(); + }); + + it('verifyWork posts to /verification/verify with the request body', async () => { + apiCallMock.mockResolvedValue({ status: 'passed' }); + + const result = await api.verifyWork({ + repositoryUrl: 'https://github.com/x/y', + milestoneDescription: 'Ship the thing', + projectId: 'proj-1', + }); + + expect(apiCallMock).toHaveBeenCalledWith( + '/verification/verify', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + repositoryUrl: 'https://github.com/x/y', + milestoneDescription: 'Ship the thing', + projectId: 'proj-1', + }), + }), + ); + expect(result).toEqual({ status: 'passed' }); + }); + + it('generateInvoice posts to /invoice/generate and returns the generated invoice', async () => { + const invoice = { id: 'inv-1', invoiceNumber: 'INV-001', total: 100 }; + apiCallMock.mockResolvedValue(invoice); + + const result = await api.generateInvoice({ + projectId: 'proj-1', + workDescription: 'Verified work', + hoursWorked: 5, + hourlyRate: 20, + }); + + expect(apiCallMock).toHaveBeenCalledWith('/invoice/generate', expect.objectContaining({ method: 'POST' })); + expect(result).toEqual(invoice); + }); +}); + +describe('api.apiKeys', () => { + beforeEach(() => { + apiCallMock.mockReset(); + }); + + it('rotate posts to /api-keys/:id/rotate', async () => { + apiCallMock.mockResolvedValue({ data: { keyId: 'k1' }, rotatedFrom: 'k0', rawKey: 'raw' }); + + const result = await api.apiKeys.rotate('k0'); + + expect(apiCallMock).toHaveBeenCalledWith('/api-keys/k0/rotate', { method: 'POST' }); + expect(result.rawKey).toBe('raw'); + }); +}); + +describe('api.checkout', () => { + beforeEach(() => { + apiCallMock.mockReset(); + }); + + it('createSession posts the payload and returns the wrapped session', async () => { + apiCallMock.mockResolvedValue({ data: { id: 'sess-1', status: 'created' } }); + + const result = await api.checkout.createSession({ + merchantId: 'm1', + amount: 100, + currency: 'USD', + }); + + expect(apiCallMock).toHaveBeenCalledWith( + '/checkout/sessions', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ merchantId: 'm1', amount: 100, currency: 'USD' }), + }), + ); + expect(result.data.id).toBe('sess-1'); + }); +}); diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 36480732..42e21891 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -40,6 +40,20 @@ export interface InvoiceRequest { hourlyRate: number; } +export interface GeneratedInvoice { + id: string; + invoiceNumber: string; + merchantId: string; + projectId: string; + subtotal: number; + taxTotal: number; + total: number; + currency: string; + status: string; + summary: string; + generatedAt: string; +} + export interface FormFieldOption { label: string; value: string; @@ -127,7 +141,7 @@ export interface WebhookEvent { id: string; provider: 'stripe' | 'paypal' | 'github' | 'custom'; eventType: string; - payload: any; + payload: Record; signature: string; timestamp: string; verified: boolean; @@ -159,6 +173,90 @@ export interface RotateWebhookSecretRequest { gracePeriodHours?: number; } +export interface ApiKeySummary { + keyId: string; + description?: string; +} + +export interface ApiKeyRotateResult extends ApiKeySummary { + rotatedFrom: string; + rawKey: string; +} + +export interface ApiKeyCreateResult { + data: ApiKeySummary; + rawKey: string; +} + +export interface ApiKeyUsagePoint { + date: string; + total: number; + blocked: number; +} + +export interface ApiKeyUsage { + keyId: string; + days: number; + daily: ApiKeyUsagePoint[]; +} + +export interface MilestoneDependency { + id: string; + milestoneId: string; + dependsOnMilestoneId: string; +} + +export interface MilestoneGraphNode { + id: string; + title: string; + status: string; + dependsOn: string[]; +} + +export interface MilestoneGraph { + projectId: string; + nodes: MilestoneGraphNode[]; +} + +export type CheckoutPaymentMethod = 'crypto' | 'card' | 'wallet'; + +export interface CheckoutSession { + id: string; + merchantId: string; + merchantName: string; + amount: number; + currency: string; + description?: string; + allowedMethods: CheckoutPaymentMethod[]; + selectedMethod?: CheckoutPaymentMethod; + status: 'created' | 'payment_pending' | 'processing' | 'completed' | 'expired' | 'abandoned'; + customerEmail?: string; + expiresAt: string; + createdAt: string; + updatedAt: string; + lockedRate?: { rate: number; lockedAt: string; expiresAt: string; pair: string }; + transactionId?: string; +} + +export interface ExchangeRates { + rates: Record; + updatedAt: string; +} + +export interface PaymentLinkDetails { + slug: string; + merchantName: string; + amount: number; + currency: string; + status: string; + description?: string; +} + +export interface PaymentLinkCompletionResult { + success: boolean; + transactionId?: string; +} + export const api = { /** * AI Work Verification @@ -177,7 +275,7 @@ export const api = { * AI Invoice Generation */ generateInvoice: async (data: InvoiceRequest) => { - return apiCall('/invoice/generate', { + return apiCall('/invoice/generate', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -190,7 +288,7 @@ export const api = { * Get Verification Result */ getVerification: async (id: string) => { - return apiCall(`/verification/${id}`, { + return apiCall(`/verification/${id}`, { method: 'GET', }); }, @@ -209,31 +307,31 @@ export const api = { * API Key Management */ apiKeys: { - list: async () => apiCall<{ data: any[] }>('/api-keys', { method: 'GET' }), + list: async () => apiCall<{ data: ApiKeySummary[] }>('/api-keys', { method: 'GET' }), create: async (payload: { name: string; tier?: string; scopes?: string[]; expiresInDays?: number }) => - apiCall<{ data: any; rawKey: string }>('/api-keys', { + apiCall('/api-keys', { method: 'POST', body: JSON.stringify(payload), }), getUsage: async (windowMs?: number) => - apiCall<{ data: any[] }>(`/api-keys/usage${windowMs ? `?window=${windowMs}` : ''}`, { method: 'GET' }), - revoke: async (id: string) => apiCall(`/api-keys/${id}/revoke`, { method: 'POST' }), - rotate: async (id: string) => apiCall<{ data: any; rawKey: string }>(`/api-keys/${id}/rotate`, { method: 'POST' }), - delete: async (id: string) => apiCall(`/api-keys/${id}`, { method: 'DELETE' }), + apiCall(`/api-keys/usage${windowMs ? `?window=${windowMs}` : ''}`, { method: 'GET' }), + revoke: async (id: string) => apiCall<{ success: boolean; keyId: string; status: string }>(`/api-keys/${id}/revoke`, { method: 'POST' }), + rotate: async (id: string) => apiCall(`/api-keys/${id}/rotate`, { method: 'POST' }), + delete: async (id: string) => apiCall<{ success: boolean }>(`/api-keys/${id}`, { method: 'DELETE' }), }, /** * Milestone Dependencies */ milestones: { - getGraph: async (projectId: string) => apiCall<{ data: any }>(`/milestones/${projectId}/graph`, { method: 'GET' }), - getDependencies: async (projectId: string) => apiCall<{ data: any[] }>(`/milestones/${projectId}/dependencies`, { method: 'GET' }), + getGraph: async (projectId: string) => apiCall<{ data: MilestoneGraph }>(`/milestones/${projectId}/graph`, { method: 'GET' }), + getDependencies: async (projectId: string) => apiCall<{ data: MilestoneDependency[] }>(`/milestones/${projectId}/dependencies`, { method: 'GET' }), addDependency: async (projectId: string, payload: { milestoneId: string; dependsOnMilestoneId: string }) => - apiCall(`/milestones/${projectId}/dependencies`, { + apiCall(`/milestones/${projectId}/dependencies`, { method: 'POST', body: JSON.stringify(payload), }), - removeDependency: async (dependencyId: string) => apiCall(`/milestones/dependencies/${dependencyId}`, { method: 'DELETE' }), + removeDependency: async (dependencyId: string) => apiCall(`/milestones/dependencies/${dependencyId}`, { method: 'DELETE' }), getBlocked: async (projectId: string) => apiCall<{ data: string[] }>(`/milestones/${projectId}/blocked`, { method: 'GET' }), }, @@ -254,7 +352,7 @@ export const api = { deleteForm: async (id: string) => apiCall(`/forms/${id}`, { method: 'DELETE', }), - submitForm: async (id: string, values: Record) => apiCall(`/forms/${id}/submissions`, { + submitForm: async (id: string, values: Record) => apiCall(`/forms/${id}/submissions`, { method: 'POST', body: JSON.stringify({ values }), }), @@ -294,10 +392,10 @@ export const api = { // Event management listEvents: async (limit?: number) => apiCall(`/webhooks/events${limit ? `?limit=${limit}` : ''}`, { method: 'GET' }), listQueuedEvents: async (limit?: number) => apiCall(`/webhooks/events/queued${limit ? `?limit=${limit}` : ''}`, { method: 'GET' }), - retryEvent: async (eventId: string) => apiCall(`/webhooks/events/${eventId}/retry`, { + retryEvent: async (eventId: string) => apiCall(`/webhooks/events/${eventId}/retry`, { method: 'POST', }), - markEventProcessed: async (eventId: string) => apiCall(`/webhooks/events/${eventId}/process`, { + markEventProcessed: async (eventId: string) => apiCall(`/webhooks/events/${eventId}/process`, { method: 'POST', }), }, @@ -306,26 +404,33 @@ export const api = { * Hosted Checkout API */ checkout: { - createSession: async (payload: any) => apiCall('/checkout/sessions', { + createSession: async (payload: { + merchantId: string; + amount: number; + currency: string; + description?: string; + allowedMethods?: CheckoutPaymentMethod[]; + customerEmail?: string; + }) => apiCall<{ data: CheckoutSession }>('/checkout/sessions', { method: 'POST', body: JSON.stringify(payload), }), - getSession: async (id: string) => apiCall(`/checkout/sessions/${id}`, { + getSession: async (id: string) => apiCall<{ data: CheckoutSession }>(`/checkout/sessions/${id}`, { method: 'GET', }), - selectPaymentMethod: async (id: string, method: string) => apiCall(`/checkout/sessions/${id}/payment-method`, { + selectPaymentMethod: async (id: string, method: CheckoutPaymentMethod) => apiCall<{ data: CheckoutSession }>(`/checkout/sessions/${id}/payment-method`, { method: 'POST', body: JSON.stringify({ method }), }), - lockRate: async (id: string) => apiCall(`/checkout/sessions/${id}/lock-rate`, { + lockRate: async (id: string) => apiCall<{ data: CheckoutSession }>(`/checkout/sessions/${id}/lock-rate`, { method: 'POST', }), - processPayment: async (id: string, details: any) => apiCall(`/checkout/sessions/${id}/pay`, { + processPayment: async (id: string, details: Record) => apiCall<{ data: CheckoutSession }>(`/checkout/sessions/${id}/pay`, { method: 'POST', body: JSON.stringify(details), }), getReceiptUrl: (id: string) => `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}/checkout/sessions/${id}/receipt`, - getExchangeRates: async () => apiCall('/checkout/exchange-rates', { + getExchangeRates: async () => apiCall<{ data: ExchangeRates }>('/checkout/exchange-rates', { method: 'GET', }), }, @@ -339,7 +444,7 @@ export const api = { if (options?.variant) queryParams.append('variant', options.variant); if (options?.password) queryParams.append('password', options.password); const queryString = queryParams.toString() ? `?${queryParams.toString()}` : ''; - return apiCall(`/payment-links/r/${slug}${queryString}`, { + return apiCall(`/payment-links/r/${slug}${queryString}`, { method: 'GET', headers: { 'Accept': 'application/json', @@ -347,7 +452,7 @@ export const api = { }); }, completePayment: async (slug: string, payload: { source?: string; variant?: string; password?: string; amountPaid?: number }) => { - return apiCall(`/payment-links/r/${slug}/complete`, { + return apiCall(`/payment-links/r/${slug}/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json',