From b7c5a7577a108d443369553f730b1f1e09972dd2 Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Mon, 19 Oct 2026 09:35:41 +0100 Subject: [PATCH] feat: add developer platform reliability tools --- backend/src/index.ts | 61 +++--------- backend/src/middleware/logger.test.ts | 31 ++++++- backend/src/middleware/logger.ts | 6 +- backend/src/plugins/marketplace.test.ts | 22 +++++ backend/src/plugins/marketplace.ts | 93 +++++++++++++++++++ backend/src/plugins/plugin-registry.ts | 52 ++++++----- backend/src/routes/dev/webhook.test.ts | 70 ++++++++++++++ backend/src/routes/dev/webhook.ts | 63 +++++++++++++ backend/src/routes/plugins.ts | 38 ++++++-- docs/developer-platform-tools.md | 55 +++++++++++ .../src/__tests__/client-idempotency.test.ts | 40 ++++++++ .../sdk/src/__tests__/idempotency.test.ts | 56 +++++++++++ packages/sdk/src/client.ts | 20 +++- packages/sdk/src/idempotency.ts | 55 +++++++++++ packages/sdk/src/index.ts | 3 +- 15 files changed, 576 insertions(+), 89 deletions(-) create mode 100644 backend/src/plugins/marketplace.test.ts create mode 100644 backend/src/plugins/marketplace.ts create mode 100644 backend/src/routes/dev/webhook.test.ts create mode 100644 backend/src/routes/dev/webhook.ts create mode 100644 docs/developer-platform-tools.md create mode 100644 packages/sdk/src/__tests__/client-idempotency.test.ts create mode 100644 packages/sdk/src/__tests__/idempotency.test.ts create mode 100644 packages/sdk/src/idempotency.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 556b9524..8bab78c1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,5 +1,3 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import { randomUUID } from 'node:crypto'; import express, { Request, Response, NextFunction } from 'express'; import dotenv from 'dotenv'; import rateLimit from 'express-rate-limit'; @@ -18,6 +16,7 @@ import { messageQueue } from './services/queue.js'; import { registerDefaultProcessors } from './services/queue-producers.js'; import { slaTrackingMiddleware } from './middleware/slaTracking.js'; import { requestIdMiddleware, REQUEST_ID_HEADER } from './middleware/requestId.js'; +import { correlationMiddleware, httpLogger } from './middleware/logger.js'; import { validateEnv, config as getConfig } from './config/env.js'; import { config } from './config.js'; import { flagsRouter } from './routes/flags.js'; @@ -65,35 +64,11 @@ import { cohortAnalyticsRouter } from './routes/cohort-analytics.js'; import { churnPredictionRouter } from './routes/churn-prediction.js'; import { slackRouter } from './routes/slack.js'; import { githubIntegrationRouter } from './routes/github-integration.js'; +import { pluginsRouter } from './routes/plugins.js'; +import { devWebhookRouter } from './routes/dev/webhook.js'; dotenv.config(); -const traceStorage = new AsyncLocalStorage(); - -const originalConsole = { - log: console.log, - info: console.info, - warn: console.warn, - error: console.error, -}; - -function formatMessage(args: any[]): any[] { - const traceId = traceStorage.getStore(); - if (traceId) { - if (typeof args[0] === 'string') { - args[0] = `[TraceID: ${traceId}] ${args[0]}`; - } else { - args.unshift(`[TraceID: ${traceId}]`); - } - } - return args; -} - -console.log = (...args) => originalConsole.log(...formatMessage(args)); -console.info = (...args) => originalConsole.info(...formatMessage(args)); -console.warn = (...args) => originalConsole.warn(...formatMessage(args)); -console.error = (...args) => originalConsole.error(...formatMessage(args)); - const app = express(); type UserTier = 'free' | 'pro' | 'enterprise'; @@ -146,9 +121,7 @@ function tieredRateLimit(req: Request, res: Response, next: NextFunction): void const existingState = tierRateStore.get(storeKey); const state = - !existingState || existingState.resetAtMs <= nowMs - ? { count: 0, resetAtMs: nowMs + tierWindowMs } - : existingState; + !existingState || existingState.resetAtMs <= nowMs ? { count: 0, resetAtMs: nowMs + tierWindowMs } : existingState; state.count += 1; tierRateStore.set(storeKey, state); @@ -206,24 +179,10 @@ app.use(express.json()); app.use(compressionMiddleware({ minSizeBytes: config.compression.threshold })); app.use(requestIdMiddleware); +app.use(correlationMiddleware); +app.use(httpLogger); app.use(auditMiddleware()); -// Trace ID middleware -app.use((req: Request, res: Response, next: NextFunction) => { - const traceId = (req.headers['x-trace-id'] as string) || randomUUID(); - res.setHeader('X-Trace-Id', traceId); - - traceStorage.run(traceId, () => { - console.log(`${req.method} ${req.url} - Started`); - - res.on('finish', () => { - console.log(`${req.method} ${req.url} - Finished with status ${res.statusCode}`); - }); - - next(); - }); -}); - // SLA Tracking middleware app.use(slaTrackingMiddleware); @@ -245,6 +204,11 @@ app.use(healthRouter); // Interactive API documentation & playground — Issue #758 app.use('/docs', docsRouter); +// Local webhook inspection endpoint. Never expose it in production. +if (!config.isProd) { + app.use('/api/dev/webhooks', devWebhookRouter); +} + import { versionMiddleware } from './middleware/versioning.js'; // Apply tiered limiter to all API routes @@ -315,6 +279,7 @@ apiV1Router.use('/analytics/cohorts', cohortAnalyticsRouter); apiV1Router.use('/analytics/churn', churnPredictionRouter); apiV1Router.use('/integrations/slack', slackRouter); apiV1Router.use('/integrations/github', githubIntegrationRouter); +apiV1Router.use('/plugins', pluginsRouter); // Explicit URL-based mounting app.use('/api/v1', apiV1Router); @@ -334,7 +299,7 @@ app.use('/api', (req: Request, res: Response, next: NextFunction) => { if (req.apiVersion === 'v1') { return apiV1Router(req, res, next); } - + next(new AppError(404, `API Version ${req.apiVersion} is not supported`, 'UNSUPPORTED_API_VERSION')); }); diff --git a/backend/src/middleware/logger.test.ts b/backend/src/middleware/logger.test.ts index eefc1d25..5c0f793c 100644 --- a/backend/src/middleware/logger.test.ts +++ b/backend/src/middleware/logger.test.ts @@ -12,7 +12,7 @@ function makeReqRes(overrides: Partial = {}) { socket: { remoteAddress: '127.0.0.1' }, headers: {}, }, - overrides, + overrides ); const res = Object.assign(Object.create(null) as ServerResponse, { @@ -44,9 +44,7 @@ describe('httpLogger middleware', () => { httpLogger(req as any, res as any, next); // pino-http sets req.id - expect((req as any).id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ); + expect((req as any).id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); // X-Request-Id header must be set on the response expect(res.setHeader).toHaveBeenCalledWith('x-request-id', (req as any).id); @@ -89,3 +87,28 @@ describe('logger module', () => { expect(typeof logger.warn).toBe('function'); }); }); + +describe('correlationMiddleware', () => { + it('propagates request and trace IDs through async log context', async () => { + const { correlationMiddleware } = await import('./logger.js'); + const { getLogContext } = await import('../logging/context.js'); + const { req, res } = makeReqRes({ + headers: { 'x-trace-id': 'trace-test' }, + }); + (req as any).requestId = 'request-test'; + + await new Promise((resolve) => { + correlationMiddleware(req as any, res as any, () => { + setTimeout(() => { + expect(getLogContext()).toMatchObject({ + traceId: 'trace-test', + requestId: 'request-test', + }); + resolve(); + }, 0); + }); + }); + + expect(res.setHeader).toHaveBeenCalledWith('X-Trace-Id', 'trace-test'); + }); +}); diff --git a/backend/src/middleware/logger.ts b/backend/src/middleware/logger.ts index fe1a0fae..e129715e 100644 --- a/backend/src/middleware/logger.ts +++ b/backend/src/middleware/logger.ts @@ -61,6 +61,7 @@ export const httpLogger = pinoHttp({ logger, genReqId(req: Request, res: Response) { const existing = + req.requestId ?? (req.headers[REQUEST_ID_HEADER] as string | undefined) ?? (req.headers['x-request-id'] as string | undefined); const id = existing ?? randomUUID(); @@ -113,7 +114,10 @@ export function correlationMiddleware(req: Request, res: Response, next: NextFun }); } -export function bindAsyncContext(fn: () => Promise, partial?: Partial<{ traceId: string; requestId: string; module: string }>): Promise { +export function bindAsyncContext( + fn: () => Promise, + partial?: Partial<{ traceId: string; requestId: string; module: string }> +): Promise { const parent = getLogContext(); const ctx = { traceId: partial?.traceId ?? parent?.traceId ?? randomUUID(), diff --git a/backend/src/plugins/marketplace.test.ts b/backend/src/plugins/marketplace.test.ts new file mode 100644 index 00000000..c23a0518 --- /dev/null +++ b/backend/src/plugins/marketplace.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { listMarketplaceApps, marketplaceCategories } from './marketplace.js'; + +describe('marketplace app directory', () => { + it('lists the built-in integrations with install metadata', () => { + const apps = listMarketplaceApps(); + + expect(apps).toHaveLength(4); + expect(apps.every((app) => app.source.startsWith('builtin:'))).toBe(true); + expect(apps.every((app) => app.compatibility.agenticPay)).toBe(true); + expect(marketplaceCategories()).toEqual(['automation', 'communication', 'developer-tools', 'support']); + }); + + it('filters by category and free-text query', () => { + expect(listMarketplaceApps({ category: 'support' }).map((app) => app.id)).toEqual(['intercom']); + expect(listMarketplaceApps({ query: 'REPOSITORIES' }).map((app) => app.id)).toEqual(['github']); + }); + + it('returns an empty directory when no app matches', () => { + expect(listMarketplaceApps({ query: 'not-an-integration' })).toEqual([]); + }); +}); diff --git a/backend/src/plugins/marketplace.ts b/backend/src/plugins/marketplace.ts new file mode 100644 index 00000000..3f96ee9f --- /dev/null +++ b/backend/src/plugins/marketplace.ts @@ -0,0 +1,93 @@ +export type MarketplaceCategory = 'automation' | 'communication' | 'developer-tools' | 'support'; + +export interface MarketplaceApp { + id: string; + name: string; + description: string; + category: MarketplaceCategory; + version: string; + publisher: string; + source: string; + compatibility: { agenticPay: string }; + tags: string[]; + verified: boolean; +} + +const CORE_VERSION = process.env.AGENTICPAY_VERSION ?? '0.1.0'; + +const BUILT_IN_APPS: readonly MarketplaceApp[] = [ + { + id: 'slack', + name: 'Slack', + description: 'Send payment and workflow notifications to Slack channels.', + category: 'communication', + version: '1.0.0', + publisher: 'AgenticPay', + source: 'builtin:slack', + compatibility: { agenticPay: `^${CORE_VERSION}` }, + tags: ['alerts', 'chat', 'notifications'], + verified: true, + }, + { + id: 'github', + name: 'GitHub', + description: 'Connect repository events to AgenticPay workflows.', + category: 'developer-tools', + version: '1.0.0', + publisher: 'AgenticPay', + source: 'builtin:github', + compatibility: { agenticPay: `^${CORE_VERSION}` }, + tags: ['git', 'repositories', 'webhooks'], + verified: true, + }, + { + id: 'zapier', + name: 'Zapier', + description: 'Automate AgenticPay actions across thousands of applications.', + category: 'automation', + version: '1.0.0', + publisher: 'AgenticPay', + source: 'builtin:zapier', + compatibility: { agenticPay: `^${CORE_VERSION}` }, + tags: ['automation', 'workflows'], + verified: true, + }, + { + id: 'intercom', + name: 'Intercom', + description: 'Surface payment context in customer support conversations.', + category: 'support', + version: '1.0.0', + publisher: 'AgenticPay', + source: 'builtin:intercom', + compatibility: { agenticPay: `^${CORE_VERSION}` }, + tags: ['customers', 'support'], + verified: true, + }, +]; + +export interface MarketplaceFilters { + query?: string; + category?: string; +} + +export function listMarketplaceApps(filters: MarketplaceFilters = {}): MarketplaceApp[] { + const query = filters.query?.trim().toLowerCase(); + const category = filters.category?.trim().toLowerCase(); + + return BUILT_IN_APPS.filter((app) => { + if (category && app.category !== category) return false; + if (!query) return true; + + const searchable = [app.name, app.description, app.publisher, ...app.tags].join(' ').toLowerCase(); + return searchable.includes(query); + }).map((app) => ({ + ...app, + tags: [...app.tags], + compatibility: { ...app.compatibility }, + })); +} + +export function marketplaceCategories(): MarketplaceCategory[] { + return [...new Set(BUILT_IN_APPS.map((app) => app.category))].sort(); +} diff --git a/backend/src/plugins/plugin-registry.ts b/backend/src/plugins/plugin-registry.ts index 58dff4f7..2a8390b0 100644 --- a/backend/src/plugins/plugin-registry.ts +++ b/backend/src/plugins/plugin-registry.ts @@ -2,6 +2,7 @@ import { prisma } from '../lib/prisma.js'; import { PluginHost } from './plugin-host.js'; import type { ExtensionPoint, HookContextByExtensionPoint } from './extension-points.js'; import type { PluginHealth, PluginPrismaClient } from './types.js'; +import { listMarketplaceApps, type MarketplaceFilters } from './marketplace.js'; const CORE_VERSION = process.env.AGENTICPAY_VERSION ?? '0.1.0'; @@ -26,21 +27,14 @@ export class PluginRegistry { return this.db.plugin.findMany({ orderBy: { installedAt: 'desc' } }); } - marketplace() { + marketplace(filters: MarketplaceFilters = {}) { const configured = process.env.PLUGIN_MARKETPLACE - ? JSON.parse(process.env.PLUGIN_MARKETPLACE) as unknown + ? (JSON.parse(process.env.PLUGIN_MARKETPLACE) as unknown) : undefined; if (Array.isArray(configured)) return configured; - return [ - { - name: 'agenticpay-fee-sample', - version: '0.1.0', - description: 'Sample fee:calculate extension', - compatibility: { agenticPay: `^${CORE_VERSION}` }, - }, - ]; + return listMarketplaceApps(filters); } async install(input: { @@ -69,11 +63,19 @@ export class PluginRegistry { }, }); await this.db.pluginConfig.upsert({ - where: { pluginId_environment: { pluginId: record.id, environment: 'default' } }, - create: { pluginId: record.id, environment: 'default', config: input.config ?? {} }, + where: { + pluginId_environment: { pluginId: record.id, environment: 'default' }, + }, + create: { + pluginId: record.id, + environment: 'default', + config: input.config ?? {}, + }, update: { config: input.config ?? {} }, }); - await this.audit(record.id, 'install', input.actorId, { source: input.source }); + await this.audit(record.id, 'install', input.actorId, { + source: input.source, + }); await this.enable(record.id, input.actorId); return record; } @@ -122,7 +124,9 @@ export class PluginRegistry { } async update(pluginId: string, input: { version?: string; source?: string; actorId?: string }) { - const current = await this.db.plugin.findUnique({ where: { id: pluginId } }); + const current = await this.db.plugin.findUnique({ + where: { id: pluginId }, + }); if (!current) throw new Error('Plugin not found'); await this.host.uninstall(pluginId).catch(() => undefined); const updated = await this.db.plugin.update({ @@ -134,7 +138,10 @@ export class PluginRegistry { health: { status: 'healthy', errorCount: 0 } satisfies PluginHealth, }, }); - await this.audit(pluginId, 'update', input.actorId, { version: input.version, source: input.source }); + await this.audit(pluginId, 'update', input.actorId, { + version: input.version, + source: input.source, + }); return updated; } @@ -147,10 +154,7 @@ export class PluginRegistry { await this.audit(pluginId, 'configure', actorId); } - async runHook( - point: Point, - context: HookContextByExtensionPoint[Point] - ) { + async runHook(point: Point, context: HookContextByExtensionPoint[Point]) { try { return await this.host.runHook(point, context); } catch (error) { @@ -169,7 +173,9 @@ export class PluginRegistry { } private async disableFaultyPlugins(message: string) { - const plugins = await this.db.plugin.findMany({ where: { status: 'enabled' } }); + const plugins = await this.db.plugin.findMany({ + where: { status: 'enabled' }, + }); await Promise.all( plugins.map((plugin) => this.db.plugin.update({ @@ -177,7 +183,11 @@ export class PluginRegistry { data: { status: 'error', disabledAt: new Date(), - health: { status: 'disabled', errorCount: 1, lastError: message } satisfies PluginHealth, + health: { + status: 'disabled', + errorCount: 1, + lastError: message, + } satisfies PluginHealth, }, }) ) diff --git a/backend/src/routes/dev/webhook.test.ts b/backend/src/routes/dev/webhook.test.ts new file mode 100644 index 00000000..6e0c07f0 --- /dev/null +++ b/backend/src/routes/dev/webhook.test.ts @@ -0,0 +1,70 @@ +import express from 'express'; +import type { Server } from 'node:http'; +import { afterEach, describe, expect, it } from 'vitest'; +import { requestIdMiddleware } from '../../middleware/requestId.js'; +import { createTestWebhookRouter } from './webhook.js'; + +const servers: Server[] = []; + +async function serve(router: express.Router): Promise { + const app = express(); + app.use(express.json()); + app.use(requestIdMiddleware); + app.use('/api/dev/webhooks', router); + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Failed to bind test server'); + return `http://127.0.0.1:${address.port}/api/dev/webhooks`; +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); +}); + +describe('local test webhook endpoint', () => { + it('captures, redacts, lists, and clears webhook deliveries', async () => { + const baseUrl = await serve(createTestWebhookRouter({ enabled: true, maxDeliveries: 2 })); + const received = await fetch(baseUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer secret', + 'x-request-id': 'request-123', + }, + body: JSON.stringify({ event: 'payment.completed' }), + }); + + expect(received.status).toBe(202); + const body = (await received.json()) as { data: TestDelivery }; + expect(body.data.payload).toEqual({ event: 'payment.completed' }); + expect(body.data.requestId).toBe('request-123'); + expect(body.data.headers.authorization).toBe('[Redacted]'); + + const listed = (await (await fetch(`${baseUrl}/deliveries`)).json()) as { + data: TestDelivery[]; + meta: { count: number }; + }; + expect(listed.meta.count).toBe(1); + expect(listed.data[0]?.id).toBe(body.data.id); + + expect((await fetch(`${baseUrl}/deliveries`, { method: 'DELETE' })).status).toBe(204); + const cleared = (await (await fetch(`${baseUrl}/deliveries`)).json()) as { + meta: { count: number }; + }; + expect(cleared.meta.count).toBe(0); + }); + + it('is unavailable when disabled', async () => { + const baseUrl = await serve(createTestWebhookRouter({ enabled: false })); + expect((await fetch(baseUrl, { method: 'POST' })).status).toBe(404); + }); +}); + +interface TestDelivery { + id: string; + requestId?: string; + headers: Record; + payload: unknown; +} diff --git a/backend/src/routes/dev/webhook.ts b/backend/src/routes/dev/webhook.ts new file mode 100644 index 00000000..0ab81e63 --- /dev/null +++ b/backend/src/routes/dev/webhook.ts @@ -0,0 +1,63 @@ +import { randomUUID } from 'node:crypto'; +import { Router, type Request } from 'express'; + +const SENSITIVE_HEADERS = new Set(['authorization', 'cookie', 'x-api-key']); + +export interface TestWebhookDelivery { + id: string; + receivedAt: string; + requestId?: string; + headers: Record; + payload: unknown; +} + +function safeHeaders(req: Request): Record { + return Object.fromEntries( + Object.entries(req.headers) + .filter(([, value]) => value !== undefined) + .map(([name, value]) => [name, SENSITIVE_HEADERS.has(name.toLowerCase()) ? '[Redacted]' : value]) + ) as Record; +} + +export function createTestWebhookRouter(options: { enabled?: boolean; maxDeliveries?: number } = {}) { + const enabled = options.enabled ?? process.env.NODE_ENV !== 'production'; + const maxDeliveries = options.maxDeliveries ?? 100; + const deliveries: TestWebhookDelivery[] = []; + const router = Router(); + + router.use((_req, res, next) => { + if (!enabled) { + res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Not found' } }); + return; + } + next(); + }); + + router.post('/', (req, res) => { + const delivery: TestWebhookDelivery = { + id: randomUUID(), + receivedAt: new Date().toISOString(), + requestId: req.requestId, + headers: safeHeaders(req), + payload: req.body, + }; + + deliveries.unshift(delivery); + if (deliveries.length > maxDeliveries) deliveries.length = maxDeliveries; + + res.status(202).json({ data: delivery }); + }); + + router.get('/deliveries', (_req, res) => { + res.json({ data: deliveries, meta: { count: deliveries.length } }); + }); + + router.delete('/deliveries', (_req, res) => { + deliveries.length = 0; + res.status(204).send(); + }); + + return router; +} + +export const devWebhookRouter = createTestWebhookRouter(); diff --git a/backend/src/routes/plugins.ts b/backend/src/routes/plugins.ts index a072f016..0c020d01 100644 --- a/backend/src/routes/plugins.ts +++ b/backend/src/routes/plugins.ts @@ -1,13 +1,20 @@ import { Router } from 'express'; import { AppError, asyncHandler } from '../middleware/errorHandler.js'; import { pluginRegistry } from '../plugins/index.js'; +import { marketplaceCategories } from '../plugins/marketplace.js'; export const pluginsRouter = Router(); pluginsRouter.get( '/marketplace', - asyncHandler(async (_req, res) => { - res.json({ data: pluginRegistry.marketplace() }); + asyncHandler(async (req, res) => { + const query = typeof req.query.q === 'string' ? req.query.q : undefined; + const category = typeof req.query.category === 'string' ? req.query.category : undefined; + const data = pluginRegistry.marketplace({ query, category }); + res.json({ + data, + meta: { count: data.length, categories: marketplaceCategories() }, + }); }) ); @@ -46,7 +53,9 @@ pluginsRouter.post( pluginsRouter.post( '/:pluginId/enable', asyncHandler(async (req, res) => { - res.json({ data: await pluginRegistry.enable(req.params.pluginId, req.headers['x-user-id'] as string | undefined) }); + res.json({ + data: await pluginRegistry.enable(req.params.pluginId, req.headers['x-user-id'] as string | undefined), + }); }) ); @@ -54,14 +63,19 @@ pluginsRouter.post( '/:pluginId/disable', asyncHandler(async (req, res) => { const { reason } = req.body as { reason?: string }; - res.json({ data: await pluginRegistry.disable(req.params.pluginId, req.headers['x-user-id'] as string | undefined, reason) }); + res.json({ + data: await pluginRegistry.disable(req.params.pluginId, req.headers['x-user-id'] as string | undefined, reason), + }); }) ); pluginsRouter.put( '/:pluginId', asyncHandler(async (req, res) => { - const { version, source } = req.body as { version?: string; source?: string }; + const { version, source } = req.body as { + version?: string; + source?: string; + }; if (!version && !source) { throw new AppError(400, 'version or source is required', 'VALIDATION_ERROR'); } @@ -78,7 +92,11 @@ pluginsRouter.put( pluginsRouter.put( '/:pluginId/config', asyncHandler(async (req, res) => { - await pluginRegistry.updateConfig(req.params.pluginId, req.body as Record, req.headers['x-user-id'] as string | undefined); + await pluginRegistry.updateConfig( + req.params.pluginId, + req.body as Record, + req.headers['x-user-id'] as string | undefined + ); res.status(204).send(); }) ); @@ -86,13 +104,17 @@ pluginsRouter.put( pluginsRouter.delete( '/:pluginId', asyncHandler(async (req, res) => { - res.json({ data: await pluginRegistry.remove(req.params.pluginId, req.headers['x-user-id'] as string | undefined) }); + res.json({ + data: await pluginRegistry.remove(req.params.pluginId, req.headers['x-user-id'] as string | undefined), + }); }) ); pluginsRouter.get( '/audit', asyncHandler(async (req, res) => { - res.json({ data: await pluginRegistry.auditLog(req.query.pluginId as string | undefined) }); + res.json({ + data: await pluginRegistry.auditLog(req.query.pluginId as string | undefined), + }); }) ); diff --git a/docs/developer-platform-tools.md b/docs/developer-platform-tools.md new file mode 100644 index 00000000..f411c178 --- /dev/null +++ b/docs/developer-platform-tools.md @@ -0,0 +1,55 @@ +# Developer platform tools + +## Idempotent SDK retries + +The TypeScript SDK automatically adds an `Idempotency-Key` header to `POST`, `PATCH`, and +`DELETE` requests. The same key is retained for every built-in retry. Callers can provide their +own `Idempotency-Key` (or `X-Idempotency-Key`) header when a key must survive across processes. + +For custom transports, use the retry helper directly: + +```ts +import { withIdempotencyRetry } from '@agenticpay/sdk'; + +const payment = await withIdempotencyRetry( + ({ idempotencyKey }) => + customTransport.createPayment(payload, { + 'Idempotency-Key': idempotencyKey, + }), + { maxRetries: 2, shouldRetry: (error) => isTemporary(error) } +); +``` + +`createIdempotencyKey()` is also exported for callers that only need key generation. + +## Correlated request logs + +Every backend request has an `X-Request-Id` and `X-Trace-Id`. Send either header to retain an +existing identifier; otherwise the server generates one and returns it in the response. Both +identifiers are included in structured HTTP logs and remain available across asynchronous work. +Authorization, cookies, API keys, and webhook signatures are redacted from request logs. + +## Marketplace directory + +`GET /api/v1/plugins/marketplace` lists the built-in app integrations and their install source, +version, category, publisher, tags, and AgenticPay compatibility. It supports these optional query +parameters: + +- `q`: case-insensitive text search across names, descriptions, publishers, and tags +- `category`: exact category filter + +The response includes the result count and available categories in `meta`. + +## Local webhook receiver + +In development and test environments, send sample webhook traffic to: + +```text +POST /api/dev/webhooks +GET /api/dev/webhooks/deliveries +DELETE /api/dev/webhooks/deliveries +``` + +The receiver keeps the latest 100 deliveries in memory and returns `202 Accepted` when a payload +is captured. Authorization, cookie, and API-key headers are redacted. Deliveries disappear when +the backend restarts. The route is not mounted in production. diff --git a/packages/sdk/src/__tests__/client-idempotency.test.ts b/packages/sdk/src/__tests__/client-idempotency.test.ts new file mode 100644 index 00000000..75866896 --- /dev/null +++ b/packages/sdk/src/__tests__/client-idempotency.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AgenticPayClient } from '../client.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('AgenticPayClient idempotency', () => { + it('reuses one generated key across automatic retries', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ error: 'temporary' }), { status: 503 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + const client = new AgenticPayClient({ + baseUrl: 'https://example.test', + retry: { attempts: 1, baseDelayMs: 0 }, + }); + + await expect(client.post('/payments', { amount: 10 })).resolves.toEqual({ ok: true }); + const firstHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Record; + const secondHeaders = fetchMock.mock.calls[1]?.[1]?.headers as Record; + expect(firstHeaders['Idempotency-Key']).toMatch(/^ap_/); + expect(secondHeaders['Idempotency-Key']).toBe(firstHeaders['Idempotency-Key']); + }); + + it('preserves a caller-provided idempotency key', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + const client = new AgenticPayClient({ baseUrl: 'https://example.test' }); + + await client.post('/payments', {}, { 'idempotency-key': 'payment_123' }); + + const headers = fetchMock.mock.calls[0]?.[1]?.headers as Record; + expect(headers['idempotency-key']).toBe('payment_123'); + expect(headers['Idempotency-Key']).toBeUndefined(); + }); +}); diff --git a/packages/sdk/src/__tests__/idempotency.test.ts b/packages/sdk/src/__tests__/idempotency.test.ts new file mode 100644 index 00000000..f395e14e --- /dev/null +++ b/packages/sdk/src/__tests__/idempotency.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createIdempotencyKey, withIdempotencyRetry } from '../idempotency.js'; + +describe('idempotency retry helpers', () => { + it('creates namespaced UUID keys', () => { + expect(createIdempotencyKey('payment')).toMatch( + /^payment_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); + + it('reuses the same key until a retry succeeds', async () => { + const operation = vi + .fn() + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce({ accepted: true }); + const sleep = vi.fn().mockResolvedValue(undefined); + + await expect( + withIdempotencyRetry(operation, { + idempotencyKey: 'payment_stable', + maxRetries: 2, + baseDelayMs: 10, + sleep, + }) + ).resolves.toEqual({ accepted: true }); + expect(operation.mock.calls.map(([context]) => context)).toEqual([ + { idempotencyKey: 'payment_stable', attempt: 0 }, + { idempotencyKey: 'payment_stable', attempt: 1 }, + ]); + expect(sleep).toHaveBeenCalledWith(10); + }); + + it('surfaces the last failure after the retry budget', async () => { + const failure = new Error('still unavailable'); + const operation = vi.fn().mockRejectedValue(failure); + + await expect( + withIdempotencyRetry(operation, { + idempotencyKey: 'payment_failure', + maxRetries: 1, + sleep: async () => undefined, + }) + ).rejects.toBe(failure); + expect(operation).toHaveBeenCalledTimes(2); + }); + + it('does not retry errors rejected by the predicate', async () => { + const operation = vi.fn().mockRejectedValue(new Error('invalid request')); + await expect( + withIdempotencyRetry(operation, { + shouldRetry: () => false, + }) + ).rejects.toThrow('invalid request'); + expect(operation).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 14b170be..f5e05baf 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -13,6 +13,7 @@ import { RateLimitError, ValidationError, } from './errors.js'; +import { createIdempotencyKey } from './idempotency.js'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -71,7 +72,19 @@ export class AgenticPayClient { ...(this.apiKey ? { 'x-api-key': this.apiKey } : {}), }; - let context: RequestContext = { method, path, headers: mergedHeaders, body }; + const hasIdempotencyKey = Object.keys(mergedHeaders).some( + (header) => header.toLowerCase() === 'idempotency-key' || header.toLowerCase() === 'x-idempotency-key' + ); + if (method !== 'GET' && !hasIdempotencyKey) { + mergedHeaders['Idempotency-Key'] = createIdempotencyKey(); + } + + let context: RequestContext = { + method, + path, + headers: mergedHeaders, + body, + }; for (const interceptor of this.requestInterceptors) { context = await interceptor(context); } @@ -100,10 +113,7 @@ export class AgenticPayClient { } if (!response.ok) { - if ( - this.retryConfig.retryableStatusCodes.includes(response.status) && - attempt < this.retryConfig.attempts - ) { + if (this.retryConfig.retryableStatusCodes.includes(response.status) && attempt < this.retryConfig.attempts) { await sleep(this.retryConfig.baseDelayMs * Math.pow(2, attempt)); attempt += 1; continue; diff --git a/packages/sdk/src/idempotency.ts b/packages/sdk/src/idempotency.ts new file mode 100644 index 00000000..032aae04 --- /dev/null +++ b/packages/sdk/src/idempotency.ts @@ -0,0 +1,55 @@ +export interface IdempotencyRetryContext { + idempotencyKey: string; + attempt: number; +} + +export interface IdempotencyRetryOptions { + idempotencyKey?: string; + keyPrefix?: string; + maxRetries?: number; + baseDelayMs?: number; + shouldRetry?: (error: unknown, context: IdempotencyRetryContext) => boolean; + sleep?: (delayMs: number) => Promise; +} + +export function createIdempotencyKey(prefix = 'ap'): string { + const cryptoApi = globalThis.crypto; + if (!cryptoApi?.randomUUID) { + throw new Error('Web Crypto randomUUID is required to create an idempotency key'); + } + return `${prefix}_${cryptoApi.randomUUID()}`; +} + +/** + * Retry a mutating operation while reusing one idempotency key for every attempt. + * The operation owns transport-specific headers and must attach the provided key. + */ +export async function withIdempotencyRetry( + operation: (context: IdempotencyRetryContext) => Promise, + options: IdempotencyRetryOptions = {} +): Promise { + const maxRetries = options.maxRetries ?? 2; + const baseDelayMs = options.baseDelayMs ?? 250; + if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) { + throw new RangeError('maxRetries must be a non-negative integer'); + } + if (!Number.isFinite(baseDelayMs) || baseDelayMs < 0) { + throw new RangeError('baseDelayMs must be a non-negative number'); + } + + const idempotencyKey = options.idempotencyKey ?? createIdempotencyKey(options.keyPrefix); + const sleep = options.sleep ?? ((delayMs: number) => new Promise((resolve) => setTimeout(resolve, delayMs))); + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + const context = { idempotencyKey, attempt }; + try { + return await operation(context); + } catch (error) { + const retry = options.shouldRetry?.(error, context) ?? true; + if (!retry || attempt === maxRetries) throw error; + await sleep(baseDelayMs * 2 ** attempt); + } + } + + throw new Error('Idempotency retry loop terminated unexpectedly'); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index af7fed32..e3c6b0bd 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -22,7 +22,7 @@ export * from './invoices.js'; export * from './stellar.js'; export * from './sandbox.js'; export * from './wallet.js'; - +export * from './idempotency.js'; export class AgenticPaySDK { readonly client: AgenticPayClient; @@ -65,7 +65,6 @@ export class AgenticPaySDK { this.sandbox = new SandboxApi(this.client); this.wallet = new WalletApi(this.client); } - } export function createAgenticPaySDK(options: AgenticPayClientOptions, authProvider?: AuthProvider) {