Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ 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';
import { funnelTrackingRouter } from './routes/funnel-tracking.js';
import { mlForecastRouter } from './routes/ml-forecast.js';
import { abTestingRouter } from './routes/ab-testing.js';
Expand Down Expand Up @@ -262,6 +264,11 @@ app.use(cacheControlNoStore);
app.use(healthRouter);
app.use('/docs', docsRouter);

// Local webhook inspection endpoint. Never expose it in production.
if (!config.isProd) {
app.use('/api/dev/webhooks', devWebhookRouter);
}

// Cold start monitoring dashboard — available before auth/rate-limit middleware
app.use('/api/v1/cold-start', coldStartMonitorRouter);

Expand Down Expand Up @@ -344,6 +351,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);
apiV1Router.use('/funnels', funnelTrackingRouter);
apiV1Router.use('/forecast/ml', mlForecastRouter);
apiV1Router.use('/ab-tests', abTestingRouter);
Expand Down
31 changes: 27 additions & 4 deletions backend/src/middleware/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function makeReqRes(overrides: Partial<IncomingMessage> = {}) {
socket: { remoteAddress: '127.0.0.1' },
headers: {},
},
overrides,
overrides
);

const res = Object.assign(Object.create(null) as ServerResponse, {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void>((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');
});
});
6 changes: 5 additions & 1 deletion backend/src/middleware/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -113,7 +114,10 @@ export function correlationMiddleware(req: Request, res: Response, next: NextFun
});
}

export function bindAsyncContext<T>(fn: () => Promise<T>, partial?: Partial<{ traceId: string; requestId: string; module: string }>): Promise<T> {
export function bindAsyncContext<T>(
fn: () => Promise<T>,
partial?: Partial<{ traceId: string; requestId: string; module: string }>
): Promise<T> {
const parent = getLogContext();
const ctx = {
traceId: partial?.traceId ?? parent?.traceId ?? randomUUID(),
Expand Down
22 changes: 22 additions & 0 deletions backend/src/plugins/marketplace.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
93 changes: 93 additions & 0 deletions backend/src/plugins/marketplace.ts
Original file line number Diff line number Diff line change
@@ -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();
}
52 changes: 31 additions & 21 deletions backend/src/plugins/plugin-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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: {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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({
Expand All @@ -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;
}

Expand All @@ -147,10 +154,7 @@ export class PluginRegistry {
await this.audit(pluginId, 'configure', actorId);
}

async runHook<Point extends ExtensionPoint>(
point: Point,
context: HookContextByExtensionPoint[Point]
) {
async runHook<Point extends ExtensionPoint>(point: Point, context: HookContextByExtensionPoint[Point]) {
try {
return await this.host.runHook(point, context);
} catch (error) {
Expand All @@ -169,15 +173,21 @@ 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({
where: { id: plugin.id },
data: {
status: 'error',
disabledAt: new Date(),
health: { status: 'disabled', errorCount: 1, lastError: message } satisfies PluginHealth,
health: {
status: 'disabled',
errorCount: 1,
lastError: message,
} satisfies PluginHealth,
},
})
)
Expand Down
Loading
Loading