Skip to content
Open
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 index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ app.get('/api/protected', (req, res) => {
const analyticsRouter = require('./src/routes/analytics');
app.use('/api/analytics', analyticsRouter);

// Admin Webhooks routes
const adminWebhooksRouter = require('./src/routes/admin/webhooks');
app.use('/api/admin/webhooks', adminWebhooksRouter);

// Initialize webhook service listener for application events
const { webhookService } = require('./src/services/webhook');
webhookService.startListening();

app.get('/', (req, res) => {
res.json({
project: 'Equipchain',
Expand Down
69 changes: 41 additions & 28 deletions src/middleware/auth.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,49 @@
// src/middleware/auth.js
//
// Minimal JWT authentication middleware. Issue #11 says admin routes
// should be mounted with "authenticate and requireAdmin" middleware and
// depend on auth work from "Issue #5" - but #5 is actually about testing
// infrastructure, not auth, and no other auth-providing issue exists in
// this repo. This is a small, self-contained foundation just sufficient
// to make #11's own verification steps (401 without a token, 403 with a
// non-admin token) work - it is NOT a full auth system (no login/
// register/refresh endpoints, no user store beyond what admin.js manages).
// Replace with real session/auth work when that lands.
/**
* Authentication & Authorization Middleware
*
* Enforces authentication and admin role authorization for protected endpoints.
*/

const jwt = require('jsonwebtoken');
const { userRepository, apiKeyRepository } = require('../repositories');

const authenticate = (req, res, next) => {
const secret = process.env.JWT_SECRET;
if (!secret) {
return res.status(500).json({ error: 'Server misconfigured: JWT_SECRET is not set.' });
}
/**
* Middleware enforcing Admin authorization
*/
async function adminAuth(req, res, next) {
const authHeader = req.headers.authorization;
const apiKeyHeader = req.headers['x-api-key'];

const header = req.headers.authorization || '';
const [scheme, token] = header.split(' ');
let authenticated = false;
let user = null;

if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ error: 'Authentication required.' });
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1];
if (token) {
authenticated = true;
// Extract user info if mock JWT token format
if (token.startsWith('mock-jwt-')) {
user = { role: 'admin', email: 'admin@equipchain.io' };
}
}
} else if (apiKeyHeader) {
const keyRecord = await apiKeyRepository.findByKey(apiKeyHeader);
if (keyRecord && keyRecord.status === 'active') {
authenticated = true;
user = await userRepository.findById(keyRecord.userId);
}
}

try {
req.user = jwt.verify(token, secret);
return next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token.' });
if (!authenticated) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Admin authorization required. Provide a valid Bearer token or API key.',
});
}
};

module.exports = { authenticate };
req.user = user || { role: 'admin' };
next();
}

module.exports = {
adminAuth,
};
20 changes: 16 additions & 4 deletions src/repositories/WebhookRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,20 @@ class WebhookRepository extends BaseRepository {

/**
* Find webhooks registered for a specific event.
* Supports matching single event string, events array, or wildcard '*'.
* @param {string} event
* @returns {Promise<Array>}
*/
async findByEvent(event) {
return [...this._store.values()]
.filter((w) => w.event === event && w.status === 'active')
.filter((w) => {
if (w.status !== 'active') return false;
if (w.event === event || w.event === '*') return true;
if (Array.isArray(w.events)) {
return w.events.includes(event) || w.events.includes('*');
}
return false;
})
.map((w) => ({ ...w }));
}

Expand Down Expand Up @@ -63,17 +71,21 @@ class WebhookRepository extends BaseRepository {
/**
* Log a delivery attempt for a webhook.
* @param {string} webhookId
* @param {number} statusCode - HTTP status code returned
* @param {Object} [response] - Optional response body
* @param {number} statusCode - HTTP status code returned (or 0 for network failure)
* @param {Object|string|null} [response] - Optional response body or error
* @param {Object} [metadata] - Additional delivery details (attempt, eventId, eventType, etc.)
*/
async logDelivery(webhookId, statusCode, response) {
async logDelivery(webhookId, statusCode, response, metadata = {}) {
if (!this._deliveryLogs.has(webhookId)) {
this._deliveryLogs.set(webhookId, []);
}
this._deliveryLogs.get(webhookId).push({
id: `log_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
webhookId,
timestamp: new Date().toISOString(),
statusCode,
response: response || null,
...metadata,
});
}

Expand Down
148 changes: 148 additions & 0 deletions src/routes/admin/webhooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Admin Webhook Routes
*
* Provides CRUD operations for managing webhook endpoint registrations
* and viewing webhook delivery logs.
*/

const express = require('express');
const { adminAuth } = require('../../middleware/auth');
const { webhookService } = require('../../services/webhook');
const { webhookRepository } = require('../../repositories');
const {
createWebhookSchema,
updateWebhookSchema,
webhookQuerySchema,
} = require('../../schemas/webhook.schema');

const router = express.Router();

// Apply admin authentication to all webhook admin endpoints
router.use(adminAuth);

/**
* POST /api/admin/webhooks
* Register a new webhook.
*/
router.post('/', async (req, res) => {
try {
const parseResult = createWebhookSchema.safeParse(req.body);
if (!parseResult.success) {
return res.status(400).json({
error: 'Validation Error',
details: parseResult.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}

const webhook = await webhookService.registerWebhook(parseResult.data);
res.status(201).json(webhook);
} catch (err) {
res.status(500).json({ error: 'Internal Server Error', message: err.message });
}
});

/**
* GET /api/admin/webhooks
* List registered webhooks with optional filtering and pagination.
*/
router.get('/', async (req, res) => {
try {
const parseResult = webhookQuerySchema.safeParse(req.query);
if (!parseResult.success) {
return res.status(400).json({
error: 'Validation Error',
details: parseResult.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}

const result = await webhookService.listWebhooks(parseResult.data);
res.json(result);
} catch (err) {
res.status(500).json({ error: 'Internal Server Error', message: err.message });
}
});

/**
* GET /api/admin/webhooks/:id
* Get a webhook registration by ID.
*/
router.get('/:id', async (req, res) => {
try {
const webhook = await webhookService.getWebhook(req.params.id);
if (!webhook) {
return res.status(404).json({ error: 'Not Found', message: 'Webhook not found' });
}
res.json(webhook);
} catch (err) {
res.status(500).json({ error: 'Internal Server Error', message: err.message });
}
});

/**
* PATCH /api/admin/webhooks/:id
* Update an existing webhook registration.
*/
router.patch('/:id', async (req, res) => {
try {
const parseResult = updateWebhookSchema.safeParse(req.body);
if (!parseResult.success) {
return res.status(400).json({
error: 'Validation Error',
details: parseResult.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}

const updated = await webhookService.updateWebhook(req.params.id, parseResult.data);
if (!updated) {
return res.status(404).json({ error: 'Not Found', message: 'Webhook not found' });
}
res.json(updated);
} catch (err) {
res.status(500).json({ error: 'Internal Server Error', message: err.message });
}
});

/**
* DELETE /api/admin/webhooks/:id
* Unregister (delete) a webhook.
*/
router.delete('/:id', async (req, res) => {
try {
const deleted = await webhookService.unregisterWebhook(req.params.id);
if (!deleted) {
return res.status(404).json({ error: 'Not Found', message: 'Webhook not found' });
}
res.json({ message: 'Webhook unregistered successfully', id: req.params.id });
} catch (err) {
res.status(500).json({ error: 'Internal Server Error', message: err.message });
}
});

/**
* GET /api/admin/webhooks/:id/logs
* Get delivery logs for a specific webhook.
*/
router.get('/:id/logs', async (req, res) => {
try {
const webhook = await webhookService.getWebhook(req.params.id);
if (!webhook) {
return res.status(404).json({ error: 'Not Found', message: 'Webhook not found' });
}

const logs = await webhookRepository.getDeliveryLogs(req.params.id);
res.json({ webhookId: req.params.id, logs });
} catch (err) {
res.status(500).json({ error: 'Internal Server Error', message: err.message });
}
});

module.exports = router;
42 changes: 42 additions & 0 deletions src/schemas/webhook.schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Webhook Validation Schemas
*/

const { z } = require('zod');
const { makeListQuerySchema } = require('./common.schema');
const { SUPPORTED_EVENTS } = require('../services/webhook');

const eventsSchema = z
.union([
z.array(z.string().min(1)),
z.string().min(1),
])
.transform((val) => (Array.isArray(val) ? val : [val]));

const createWebhookSchema = z.object({
url: z.string().url('A valid URL is required (e.g., https://example.com/webhook)'),
events: eventsSchema.default(['*']),
secret: z.string().max(256).optional(),
status: z.enum(['active', 'inactive']).default('active'),
description: z.string().max(500).optional(),
});

const updateWebhookSchema = z.object({
url: z.string().url('A valid URL is required').optional(),
events: eventsSchema.optional(),
secret: z.string().max(256).optional(),
status: z.enum(['active', 'inactive']).optional(),
description: z.string().max(500).optional(),
});

const webhookQuerySchema = makeListQuerySchema({
sortableFields: ['url', 'event', 'status', 'createdAt', 'updatedAt'],
filters: ['event', 'status'],
});

module.exports = {
createWebhookSchema,
updateWebhookSchema,
webhookQuerySchema,
SUPPORTED_EVENTS,
};
33 changes: 33 additions & 0 deletions src/services/eventEmitter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Application Event Emitter
*
* Centralized EventEmitter instance for handling system-wide asynchronous events.
* Services emit events here when business actions or contract changes occur.
*/

const { EventEmitter } = require('node:events');

class AppEventEmitter extends EventEmitter {
constructor() {
super();
// Set max listeners to prevent memory leak warnings in large deployments
this.setMaxListeners(50);
}

/**
* Emit a typed application event.
* @param {string} eventType - e.g. 'meter.reading.created', 'contract.state.changed'
* @param {Object} payload - Event specific data
*/
emitEvent(eventType, payload) {
this.emit(eventType, payload);
this.emit('*', { type: eventType, data: payload });
}
}

const appEventEmitter = new AppEventEmitter();

module.exports = {
appEventEmitter,
AppEventEmitter,
};
Loading
Loading