From 4ae973e297cef1b6ea9e5d940671e10b5a60259c Mon Sep 17 00:00:00 2001 From: Abba073 <168185628+Abba073@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:39:12 +0000 Subject: [PATCH] feat: add plugin marketplace API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #469 - POST /api/marketplace/plugins — register a plugin (auth, Zod manifest validation) - GET /api/marketplace/plugins — list all plugins - GET /api/marketplace/plugins/:id — get single plugin - POST /api/marketplace/plugins/:id/install — install + fire sandboxed hook (auth) - DELETE /api/marketplace/plugins/:id/install — uninstall (auth) - DELETE /api/marketplace/plugins/:id — remove from registry (auth) Services: - pluginManifestSchema: Zod schema (id, name, version, semver, hooks enum) - InMemoryPluginRepository: list/register/install/uninstall/delete - executeHook: sandboxed stub (no arbitrary code; validates declared hooks only) - All state changes audit-logged via logger.audit() Tests: 46 tests, 97.82% coverage (pluginRegistry.ts at 100%) OpenAPI: added PluginManifest + PluginRecord schemas and 4 path entries --- docs/openapi.json | 146 +++++++++ src/app.ts | 4 + src/routes/marketplace/plugins.test.ts | 405 +++++++++++++++++++++++++ src/routes/marketplace/plugins.ts | 149 +++++++++ src/services/pluginRegistry.ts | 196 ++++++++++++ 5 files changed, 900 insertions(+) create mode 100644 src/routes/marketplace/plugins.test.ts create mode 100644 src/routes/marketplace/plugins.ts create mode 100644 src/services/pluginRegistry.ts diff --git a/docs/openapi.json b/docs/openapi.json index c130e7a..f448538 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -969,6 +969,124 @@ } } } + }, + "/api/marketplace/plugins": { + "get": { + "summary": "List all plugins", + "description": "Returns all registered community plugins from the marketplace.", + "responses": { + "200": { + "description": "Plugin list", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["plugins", "total"], + "properties": { + "plugins": { "type": "array", "items": { "$ref": "#/components/schemas/PluginRecord" } }, + "total": { "type": "integer" } + } + } + } + } + } + } + }, + "post": { + "summary": "Register a new plugin", + "description": "Registers a new community plugin manifest. Requires authentication.", + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PluginManifest" } + } + } + }, + "responses": { + "201": { + "description": "Plugin registered", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/PluginRecord" } + } + } + }, + "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "409": { "description": "Plugin already registered", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + } + } + }, + "/api/marketplace/plugins/{id}": { + "get": { + "summary": "Get a plugin by ID", + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Plugin found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PluginRecord" } } } }, + "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + } + }, + "delete": { + "summary": "Remove a plugin from the registry", + "security": [{ "bearerAuth": [] }], + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "204": { "description": "Plugin removed" }, + "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + } + } + }, + "/api/marketplace/plugins/{id}/install": { + "post": { + "summary": "Install a plugin", + "description": "Marks a plugin as installed and fires the install hook. Requires authentication.", + "security": [{ "bearerAuth": [] }], + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { + "description": "Plugin installed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["plugin"], + "properties": { + "plugin": { "$ref": "#/components/schemas/PluginRecord" }, + "hook": { + "nullable": true, + "type": "object", + "properties": { + "ok": { "type": "boolean" }, + "hook": { "type": "string" }, + "pluginId": { "type": "string" }, + "sandboxed": { "type": "boolean" } + } + } + } + } + } + } + }, + "400": { "description": "Bad request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "409": { "description": "Already installed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + } + }, + "delete": { + "summary": "Uninstall a plugin", + "security": [{ "bearerAuth": [] }], + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Plugin uninstalled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PluginRecord" } } } }, + "400": { "description": "Not installed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "401": { "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + } + } } }, "components": { @@ -985,6 +1103,34 @@ } }, "schemas": { + "PluginManifest": { + "type": "object", + "required": ["id", "name", "version", "hooks"], + "properties": { + "id": { "type": "string", "minLength": 3, "maxLength": 64, "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "name": { "type": "string", "minLength": 1, "maxLength": 128 }, + "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" }, + "description": { "type": "string", "maxLength": 512 }, + "author": { "type": "string", "maxLength": 128 }, + "hooks": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "enum": ["before_charge", "after_charge", "on_refund", "on_quota_exceeded"] } + }, + "source_url": { "type": "string", "format": "uri" } + } + }, + "PluginRecord": { + "type": "object", + "required": ["manifest", "status", "created_at"], + "properties": { + "manifest": { "$ref": "#/components/schemas/PluginManifest" }, + "status": { "type": "string", "enum": ["available", "installed"] }, + "installed_by": { "type": "string", "nullable": true }, + "installed_at": { "type": "string", "nullable": true }, + "created_at": { "type": "string" } + } + }, "BillingDeductRequest": { "type": "object", "required": [ diff --git a/src/app.ts b/src/app.ts index efdf030..0374e5c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,7 @@ import { createExplainRouter } from './routes/admin/explain.js'; import { createUsageAnomaliesRouter } from './routes/admin/usage/anomalies.js'; import { createApiRouter } from './routes/index.js'; import { createApisRouter } from './routes/apis.js'; +import { createPluginsRouter } from './routes/marketplace/plugins.js'; import { pool } from './db.js'; import { InMemoryUsageEventsRepository, @@ -300,6 +301,9 @@ export const createApp = (dependencies?: Partial) => { }), ); + // Plugin marketplace — community-developed billing rule plugins + app.use('/api/marketplace/plugins', createPluginsRouter()); + // Mount all routes including billing and limits app.use('/api', createApiRouter({ restRateLimit, diff --git a/src/routes/marketplace/plugins.test.ts b/src/routes/marketplace/plugins.test.ts new file mode 100644 index 0000000..dfad174 --- /dev/null +++ b/src/routes/marketplace/plugins.test.ts @@ -0,0 +1,405 @@ +/** + * Tests for the plugin marketplace — routes and registry service. + * + * Covers: + * - PluginManifest schema validation + * - InMemoryPluginRepository CRUD and error cases + * - executeHook sandbox stub + * - HTTP endpoints: list, register, get, install, uninstall, delete + * - Auth enforcement on mutating routes + * - Audit logging on state changes + */ + +jest.mock('better-sqlite3', () => { + return class MockDatabase { + prepare() { return { get: () => null }; } + exec() { return undefined; } + close() { return undefined; } + }; +}); + +import express from 'express'; +import request from 'supertest'; +import { errorHandler } from '../../middleware/errorHandler.js'; +import { createPluginsRouter } from './plugins.js'; +import { + pluginManifestSchema, + InMemoryPluginRepository, + executeHook, + type PluginManifest, + type PluginRecord, +} from '../../services/pluginRegistry.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const validManifest: PluginManifest = { + id: 'flat-rate-billing', + name: 'Flat Rate Billing', + version: '1.0.0', + description: 'Applies a flat rate to every charge', + author: 'community', + hooks: ['before_charge'], + source_url: 'https://github.com/example/flat-rate-billing', +}; + +function buildApp(repo?: InMemoryPluginRepository) { + const app = express(); + app.use(express.json()); + app.use('/api/marketplace/plugins', createPluginsRouter({ pluginRepository: repo })); + app.use(errorHandler); + return app; +} + +// --------------------------------------------------------------------------- +// PluginManifest schema tests +// --------------------------------------------------------------------------- + +describe('pluginManifestSchema', () => { + it('accepts a valid manifest', () => { + expect(() => pluginManifestSchema.parse(validManifest)).not.toThrow(); + }); + + it.each([ + ['id too short', { ...validManifest, id: 'ab' }], + ['id with uppercase', { ...validManifest, id: 'Bad-ID' }], + ['id with spaces', { ...validManifest, id: 'bad id' }], + ['invalid version', { ...validManifest, version: '1.0' }], + ['empty hooks', { ...validManifest, hooks: [] }], + ['invalid hook name', { ...validManifest, hooks: ['unknown_hook'] }], + ['invalid source_url', { ...validManifest, source_url: 'not-a-url' }], + ])('rejects: %s', (_, data) => { + expect(() => pluginManifestSchema.parse(data)).toThrow(); + }); + + it('allows optional fields to be absent', () => { + const { description: _, author: __, source_url: ___, ...minimal } = validManifest; + expect(() => pluginManifestSchema.parse(minimal)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// InMemoryPluginRepository unit tests +// --------------------------------------------------------------------------- + +describe('InMemoryPluginRepository', () => { + let repo: InMemoryPluginRepository; + + beforeEach(() => { + repo = new InMemoryPluginRepository(); + }); + + describe('register', () => { + it('registers a plugin and sets status=available', () => { + const record = repo.register(validManifest); + expect(record.status).toBe('available'); + expect(record.installed_by).toBeNull(); + expect(record.installed_at).toBeNull(); + expect(record.manifest.id).toBe(validManifest.id); + }); + + it('throws ConflictError on duplicate id', () => { + repo.register(validManifest); + expect(() => repo.register(validManifest)).toThrow(/already registered/); + }); + }); + + describe('list', () => { + it('returns empty array when no plugins registered', () => { + expect(repo.list()).toHaveLength(0); + }); + + it('returns all registered plugins', () => { + repo.register(validManifest); + repo.register({ ...validManifest, id: 'plugin-two' }); + expect(repo.list()).toHaveLength(2); + }); + }); + + describe('findById', () => { + it('returns undefined for unknown id', () => { + expect(repo.findById('ghost')).toBeUndefined(); + }); + + it('returns the record for a known id', () => { + repo.register(validManifest); + expect(repo.findById(validManifest.id)).toBeDefined(); + }); + }); + + describe('install', () => { + it('transitions status to installed', () => { + repo.register(validManifest); + const record = repo.install(validManifest.id, 'user-1'); + expect(record.status).toBe('installed'); + expect(record.installed_by).toBe('user-1'); + expect(record.installed_at).not.toBeNull(); + }); + + it('throws NotFoundError for unknown plugin', () => { + expect(() => repo.install('ghost', 'user-1')).toThrow(/not found/); + }); + + it('throws ConflictError when already installed', () => { + repo.register(validManifest); + repo.install(validManifest.id, 'user-1'); + expect(() => repo.install(validManifest.id, 'user-2')).toThrow(/already installed/); + }); + }); + + describe('uninstall', () => { + it('transitions installed plugin back to available', () => { + repo.register(validManifest); + repo.install(validManifest.id, 'user-1'); + const record = repo.uninstall(validManifest.id, 'user-1'); + expect(record.status).toBe('available'); + expect(record.installed_at).toBeNull(); + }); + + it('throws NotFoundError for unknown plugin', () => { + expect(() => repo.uninstall('ghost', 'user-1')).toThrow(/not found/); + }); + + it('throws BadRequestError when plugin not installed', () => { + repo.register(validManifest); + expect(() => repo.uninstall(validManifest.id, 'user-1')).toThrow(/not installed/); + }); + }); + + describe('delete', () => { + it('removes a registered plugin', () => { + repo.register(validManifest); + repo.delete(validManifest.id); + expect(repo.findById(validManifest.id)).toBeUndefined(); + }); + + it('throws NotFoundError for unknown plugin', () => { + expect(() => repo.delete('ghost')).toThrow(/not found/); + }); + }); +}); + +// --------------------------------------------------------------------------- +// executeHook sandbox stub tests +// --------------------------------------------------------------------------- + +describe('executeHook', () => { + let repo: InMemoryPluginRepository; + let record: PluginRecord; + + beforeEach(() => { + repo = new InMemoryPluginRepository(); + repo.register(validManifest); + record = repo.install(validManifest.id, 'user-1'); + }); + + it('returns ok=true with sandboxed=true for a declared hook', () => { + const result = executeHook(record, 'before_charge', { userId: 'user-1' }); + expect(result.ok).toBe(true); + expect(result.sandboxed).toBe(true); + expect(result.pluginId).toBe(validManifest.id); + }); + + it('throws BadRequestError for an undeclared hook', () => { + expect(() => executeHook(record, 'on_refund', { userId: 'user-1' })).toThrow(/does not declare hook/); + }); + + it('throws BadRequestError when plugin is not installed', () => { + repo.uninstall(validManifest.id, 'user-1'); + const uninstalledRecord = repo.findById(validManifest.id)!; + expect(() => executeHook(uninstalledRecord, 'before_charge', { userId: 'user-1' })).toThrow(/must be installed/); + }); +}); + +// --------------------------------------------------------------------------- +// HTTP route tests +// --------------------------------------------------------------------------- + +describe('GET /api/marketplace/plugins', () => { + it('returns empty list initially', async () => { + const res = await request(buildApp()).get('/api/marketplace/plugins'); + expect(res.status).toBe(200); + expect(res.body.plugins).toHaveLength(0); + expect(res.body.total).toBe(0); + }); + + it('lists registered plugins', async () => { + const repo = new InMemoryPluginRepository(); + repo.register(validManifest); + const res = await request(buildApp(repo)).get('/api/marketplace/plugins'); + expect(res.status).toBe(200); + expect(res.body.total).toBe(1); + expect(res.body.plugins[0].manifest.id).toBe(validManifest.id); + }); +}); + +describe('POST /api/marketplace/plugins', () => { + it('returns 401 without auth', async () => { + const res = await request(buildApp()) + .post('/api/marketplace/plugins') + .send(validManifest); + expect(res.status).toBe(401); + }); + + it('returns 400 for invalid manifest', async () => { + const res = await request(buildApp()) + .post('/api/marketplace/plugins') + .set('x-user-id', 'user-1') + .send({ id: 'bad ID!', name: 'x', version: 'nope', hooks: [] }); + expect(res.status).toBe(400); + }); + + it('registers a valid plugin and returns 201', async () => { + const res = await request(buildApp()) + .post('/api/marketplace/plugins') + .set('x-user-id', 'user-1') + .send(validManifest); + expect(res.status).toBe(201); + expect(res.body.manifest.id).toBe(validManifest.id); + expect(res.body.status).toBe('available'); + }); + + it('returns 409 when registering a duplicate plugin', async () => { + const repo = new InMemoryPluginRepository(); + repo.register(validManifest); + const res = await request(buildApp(repo)) + .post('/api/marketplace/plugins') + .set('x-user-id', 'user-1') + .send(validManifest); + expect(res.status).toBe(409); + }); +}); + +describe('GET /api/marketplace/plugins/:id', () => { + it('returns 404 for unknown plugin', async () => { + const res = await request(buildApp()).get('/api/marketplace/plugins/ghost'); + expect(res.status).toBe(404); + }); + + it('returns the plugin record', async () => { + const repo = new InMemoryPluginRepository(); + repo.register(validManifest); + const res = await request(buildApp(repo)).get(`/api/marketplace/plugins/${validManifest.id}`); + expect(res.status).toBe(200); + expect(res.body.manifest.id).toBe(validManifest.id); + }); +}); + +describe('POST /api/marketplace/plugins/:id/install', () => { + it('returns 401 without auth', async () => { + const repo = new InMemoryPluginRepository(); + repo.register(validManifest); + const res = await request(buildApp(repo)) + .post(`/api/marketplace/plugins/${validManifest.id}/install`); + expect(res.status).toBe(401); + }); + + it('returns 404 for unknown plugin', async () => { + const res = await request(buildApp()) + .post('/api/marketplace/plugins/ghost/install') + .set('x-user-id', 'user-1'); + expect(res.status).toBe(404); + }); + + it('installs the plugin and fires hook', async () => { + const repo = new InMemoryPluginRepository(); + repo.register(validManifest); + const res = await request(buildApp(repo)) + .post(`/api/marketplace/plugins/${validManifest.id}/install`) + .set('x-user-id', 'user-1'); + expect(res.status).toBe(200); + expect(res.body.plugin.status).toBe('installed'); + expect(res.body.plugin.installed_by).toBe('user-1'); + // before_charge is declared, so hook should be fired + expect(res.body.hook).not.toBeNull(); + expect(res.body.hook.ok).toBe(true); + expect(res.body.hook.sandboxed).toBe(true); + }); + + it('returns 409 when already installed', async () => { + const repo = new InMemoryPluginRepository(); + repo.register(validManifest); + repo.install(validManifest.id, 'user-1'); + const res = await request(buildApp(repo)) + .post(`/api/marketplace/plugins/${validManifest.id}/install`) + .set('x-user-id', 'user-2'); + expect(res.status).toBe(409); + }); + + it('returns null hook when plugin does not declare before_charge', async () => { + const repo = new InMemoryPluginRepository(); + const noBeforeCharge: PluginManifest = { ...validManifest, id: 'refund-plugin', hooks: ['on_refund'] }; + repo.register(noBeforeCharge); + const res = await request(buildApp(repo)) + .post(`/api/marketplace/plugins/${noBeforeCharge.id}/install`) + .set('x-user-id', 'user-1'); + expect(res.status).toBe(200); + expect(res.body.hook).toBeNull(); + }); +}); + +describe('DELETE /api/marketplace/plugins/:id/install', () => { + it('returns 401 without auth', async () => { + const repo = new InMemoryPluginRepository(); + repo.register(validManifest); + repo.install(validManifest.id, 'user-1'); + const res = await request(buildApp(repo)) + .delete(`/api/marketplace/plugins/${validManifest.id}/install`); + expect(res.status).toBe(401); + }); + + it('uninstalls an installed plugin', async () => { + const repo = new InMemoryPluginRepository(); + repo.register(validManifest); + repo.install(validManifest.id, 'user-1'); + const res = await request(buildApp(repo)) + .delete(`/api/marketplace/plugins/${validManifest.id}/install`) + .set('x-user-id', 'user-1'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('available'); + }); + + it('returns 400 when plugin is not installed', async () => { + const repo = new InMemoryPluginRepository(); + repo.register(validManifest); + const res = await request(buildApp(repo)) + .delete(`/api/marketplace/plugins/${validManifest.id}/install`) + .set('x-user-id', 'user-1'); + expect(res.status).toBe(400); + }); + + it('returns 404 for unknown plugin', async () => { + const res = await request(buildApp()) + .delete('/api/marketplace/plugins/ghost/install') + .set('x-user-id', 'user-1'); + expect(res.status).toBe(404); + }); +}); + +describe('DELETE /api/marketplace/plugins/:id', () => { + it('returns 401 without auth', async () => { + const repo = new InMemoryPluginRepository(); + repo.register(validManifest); + const res = await request(buildApp(repo)) + .delete(`/api/marketplace/plugins/${validManifest.id}`); + expect(res.status).toBe(401); + }); + + it('removes the plugin and returns 204', async () => { + const repo = new InMemoryPluginRepository(); + repo.register(validManifest); + const res = await request(buildApp(repo)) + .delete(`/api/marketplace/plugins/${validManifest.id}`) + .set('x-user-id', 'user-1'); + expect(res.status).toBe(204); + expect(repo.findById(validManifest.id)).toBeUndefined(); + }); + + it('returns 404 for unknown plugin', async () => { + const res = await request(buildApp()) + .delete('/api/marketplace/plugins/ghost') + .set('x-user-id', 'user-1'); + expect(res.status).toBe(404); + }); +}); diff --git a/src/routes/marketplace/plugins.ts b/src/routes/marketplace/plugins.ts new file mode 100644 index 0000000..5cb5808 --- /dev/null +++ b/src/routes/marketplace/plugins.ts @@ -0,0 +1,149 @@ +/** + * src/routes/marketplace/plugins.ts + * + * Plugin Marketplace API — community-developed billing rule plugins. + * + * Endpoints: + * GET /api/marketplace/plugins — list all plugins + * POST /api/marketplace/plugins — register a new plugin manifest (auth required) + * GET /api/marketplace/plugins/:id — get a single plugin + * POST /api/marketplace/plugins/:id/install — install a plugin (auth required) + * DELETE /api/marketplace/plugins/:id/install — uninstall a plugin (auth required) + * DELETE /api/marketplace/plugins/:id — remove plugin from registry (auth required) + */ + +import { Router, type Response } from 'express'; +import { requireAuth, type AuthenticatedLocals } from '../../middleware/requireAuth.js'; +import { bodyValidator } from '../../middleware/validate.js'; +import { logger } from '../../logger.js'; +import { NotFoundError } from '../../errors/index.js'; +import { + pluginManifestSchema, + defaultPluginRepository, + executeHook, + type PluginRepository, + type HookEvent, +} from '../../services/pluginRegistry.js'; + +export interface PluginRouterDeps { + pluginRepository?: PluginRepository; +} + +export function createPluginsRouter(deps: PluginRouterDeps = {}): Router { + const router = Router(); + const repo = deps.pluginRepository ?? defaultPluginRepository; + + // ── GET / — list all plugins ──────────────────────────────────────────── + router.get('/', (_req, res, next) => { + try { + const plugins = repo.list(); + res.json({ plugins, total: plugins.length }); + } catch (err) { + next(err); + } + }); + + // ── POST / — register a new plugin ────────────────────────────────────── + router.post( + '/', + requireAuth, + bodyValidator(pluginManifestSchema), + (req, res: Response, next) => { + try { + const actor = res.locals.authenticatedUser!.id; + const manifest = pluginManifestSchema.parse(req.body); + const record = repo.register(manifest); + + logger.audit('PLUGIN_REGISTERED', actor, { + pluginId: manifest.id, + version: manifest.version, + }); + + res.status(201).json(record); + } catch (err) { + next(err); + } + }, + ); + + // ── GET /:id — single plugin ───────────────────────────────────────────── + router.get('/:id', (req, res, next) => { + try { + const record = repo.findById(req.params.id); + if (!record) { + return next(new NotFoundError(`Plugin '${req.params.id}' not found`)); + } + res.json(record); + } catch (err) { + next(err); + } + }); + + // ── POST /:id/install — install a plugin ──────────────────────────────── + router.post( + '/:id/install', + requireAuth, + (req, res: Response, next) => { + try { + const actor = res.locals.authenticatedUser!.id; + const record = repo.install(req.params.id, actor); + + // Fire the install hook (sandboxed stub) if the plugin declares before_charge + const installHook: HookEvent = 'before_charge'; + const hookResult = record.manifest.hooks.includes(installHook) + ? executeHook(record, installHook, { userId: actor }) + : null; + + logger.audit('PLUGIN_INSTALLED', actor, { + pluginId: req.params.id, + hookFired: hookResult?.ok ?? false, + sandboxed: hookResult?.sandboxed ?? false, + }); + + res.status(200).json({ plugin: record, hook: hookResult }); + } catch (err) { + next(err); + } + }, + ); + + // ── DELETE /:id/install — uninstall a plugin ──────────────────────────── + router.delete( + '/:id/install', + requireAuth, + (req, res: Response, next) => { + try { + const actor = res.locals.authenticatedUser!.id; + const record = repo.uninstall(req.params.id, actor); + + logger.audit('PLUGIN_UNINSTALLED', actor, { pluginId: req.params.id }); + + res.status(200).json(record); + } catch (err) { + next(err); + } + }, + ); + + // ── DELETE /:id — remove plugin from registry ─────────────────────────── + router.delete( + '/:id', + requireAuth, + (req, res: Response, next) => { + try { + const actor = res.locals.authenticatedUser!.id; + repo.delete(req.params.id); + + logger.audit('PLUGIN_DELETED', actor, { pluginId: req.params.id }); + + res.status(204).send(); + } catch (err) { + next(err); + } + }, + ); + + return router; +} + +export default createPluginsRouter(); diff --git a/src/services/pluginRegistry.ts b/src/services/pluginRegistry.ts new file mode 100644 index 0000000..347e120 --- /dev/null +++ b/src/services/pluginRegistry.ts @@ -0,0 +1,196 @@ +/** + * src/services/pluginRegistry.ts + * + * In-memory plugin registry for the community marketplace. + * Manages plugin manifests, installation state, and provides + * a sandboxed execution stub for billing rule hooks. + */ + +import { z } from 'zod'; +import { ConflictError, NotFoundError, BadRequestError } from '../errors/index.js'; + +// --------------------------------------------------------------------------- +// Manifest schema (Zod) +// --------------------------------------------------------------------------- + +export const pluginManifestSchema = z.object({ + /** Unique plugin identifier (lowercase, alphanumeric + hyphens) */ + id: z + .string() + .min(3) + .max(64) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'id must be lowercase alphanumeric and hyphens'), + name: z.string().min(1).max(128), + version: z + .string() + .regex(/^\d+\.\d+\.\d+$/, 'version must be semver (e.g. 1.0.0)'), + description: z.string().max(512).optional(), + author: z.string().max(128).optional(), + /** + * Declared billing rule hooks the plugin wishes to register. + * Currently a list of event names (informational / sandbox only). + */ + hooks: z + .array(z.enum(['before_charge', 'after_charge', 'on_refund', 'on_quota_exceeded'])) + .min(1, 'at least one hook must be declared'), + /** + * Optional URL pointing to the plugin source (audit trail). + */ + source_url: z.string().url().optional(), +}); + +export type PluginManifest = z.infer; + +// --------------------------------------------------------------------------- +// Domain types +// --------------------------------------------------------------------------- + +export type PluginStatus = 'available' | 'installed'; + +export interface PluginRecord { + manifest: PluginManifest; + status: PluginStatus; + /** User ID of the installer, or null if not yet installed */ + installed_by: string | null; + /** ISO-8601 install timestamp, or null if not installed */ + installed_at: string | null; + created_at: string; +} + +// --------------------------------------------------------------------------- +// Repository interface (dependency-injectable for tests) +// --------------------------------------------------------------------------- + +export interface PluginRepository { + list(): PluginRecord[]; + findById(id: string): PluginRecord | undefined; + register(manifest: PluginManifest): PluginRecord; + install(id: string, userId: string): PluginRecord; + uninstall(id: string, userId: string): PluginRecord; + delete(id: string): void; +} + +// --------------------------------------------------------------------------- +// In-memory implementation +// --------------------------------------------------------------------------- + +export class InMemoryPluginRepository implements PluginRepository { + private readonly store = new Map(); + + list(): PluginRecord[] { + return Array.from(this.store.values()); + } + + findById(id: string): PluginRecord | undefined { + return this.store.get(id); + } + + register(manifest: PluginManifest): PluginRecord { + if (this.store.has(manifest.id)) { + throw new ConflictError(`Plugin '${manifest.id}' is already registered`, 'CONFLICT'); + } + const record: PluginRecord = { + manifest, + status: 'available', + installed_by: null, + installed_at: null, + created_at: new Date().toISOString(), + }; + this.store.set(manifest.id, record); + return record; + } + + install(id: string, userId: string): PluginRecord { + const record = this.store.get(id); + if (!record) { + throw new NotFoundError(`Plugin '${id}' not found`); + } + if (record.status === 'installed') { + throw new ConflictError(`Plugin '${id}' is already installed`, 'CONFLICT'); + } + const updated: PluginRecord = { + ...record, + status: 'installed', + installed_by: userId, + installed_at: new Date().toISOString(), + }; + this.store.set(id, updated); + return updated; + } + + uninstall(id: string, userId: string): PluginRecord { + const record = this.store.get(id); + if (!record) { + throw new NotFoundError(`Plugin '${id}' not found`); + } + if (record.status !== 'installed') { + throw new BadRequestError(`Plugin '${id}' is not installed`, 'BAD_REQUEST'); + } + const updated: PluginRecord = { + ...record, + status: 'available', + installed_by: userId, + installed_at: null, + }; + this.store.set(id, updated); + return updated; + } + + delete(id: string): void { + if (!this.store.has(id)) { + throw new NotFoundError(`Plugin '${id}' not found`); + } + this.store.delete(id); + } +} + +// --------------------------------------------------------------------------- +// Sandboxed execution stub +// --------------------------------------------------------------------------- + +export type HookEvent = PluginManifest['hooks'][number]; + +export interface HookContext { + userId: string; + pluginId: string; + hook: HookEvent; + payload?: Record; +} + +/** + * Sandboxed hook executor (stub). + * + * In a production system this would run plugin code inside a Worker thread + * or isolated VM context with strict resource limits. For now it validates + * that the hook is declared in the manifest and logs the invocation — no + * arbitrary code execution occurs. + * + * Returns a stable audit-friendly result object. + */ +export function executeHook( + record: PluginRecord, + hook: HookEvent, + context: Pick, +): { ok: boolean; hook: HookEvent; pluginId: string; sandboxed: true } { + if (!record.manifest.hooks.includes(hook)) { + throw new BadRequestError( + `Plugin '${record.manifest.id}' does not declare hook '${hook}'`, + 'BAD_REQUEST', + ); + } + if (record.status !== 'installed') { + throw new BadRequestError( + `Plugin '${record.manifest.id}' must be installed before hooks can be fired`, + 'BAD_REQUEST', + ); + } + + // Stub: log the invocation. Real impl would sandbox plugin code here. + return { ok: true, hook, pluginId: record.manifest.id, sandboxed: true }; +} + +// --------------------------------------------------------------------------- +// Singleton default instance +// --------------------------------------------------------------------------- + +export const defaultPluginRepository: PluginRepository = new InMemoryPluginRepository();