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
25 changes: 25 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,35 @@ description = "Trivela API key pattern"
regex = '''tvl_[a-zA-Z0-9]{32,}'''
tags = ["trivela", "api-key"]

[[rules]]
id = "pem-private-key"
description = "PEM-encoded private key block"
regex = '''-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----'''
tags = ["pem", "private-key"]

[[rules]]
id = "jwt-secret-env"
description = "JWT_SECRET value assignment in env files or shell"
regex = '''JWT_SECRET\s*=\s*['"]?[A-Za-z0-9+/=\-_]{16,}['"]?'''
tags = ["jwt", "secret"]

[[rules]]
id = "generic-high-entropy-secret"
description = "Assignment to variables named *_SECRET or *_PRIVATE_KEY with high-entropy values"
regex = '''(?i)(secret|private_key)\s*[:=]\s*['"]?[A-Za-z0-9+/]{32,}['"]?'''
tags = ["generic", "secret"]

[[rules]]
id = "keeper-secret-key"
description = "KEEPER_SECRET_KEY Stellar value"
regex = '''KEEPER_SECRET_KEY\s*=\s*['"]?S[A-Z2-7]{55}['"]?'''
tags = ["stellar", "keeper"]

[allowlist]
description = "Safe paths — example files, test fixtures, docs"
paths = [
'''.env\.example''',
'''test[s]?/fixtures/''',
'''docs/''',
'''\.gitleaks\.toml''',
]
61 changes: 52 additions & 9 deletions backend/src/deprecations.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,60 @@
// @ts-check

/**
* @typedef {{ deprecatedAt: string, removedAt: string, replacement: string, message: string }} DeprecationEntry
*/

/**
* Deprecation registry — maps route patterns to lifecycle metadata.
* Add entries here before removing or replacing any endpoint.
*
* @type {Record<string, { deprecatedAt: string, removedAt: string, replacement: string, message: string }>}
* All legacy /api/* routes are deprecated in favour of their /api/v1/* equivalents.
* A minimum 90-day notice period applies. Add entries here before removing or
* replacing any endpoint; the deprecationNotice middleware reads this map at
* runtime and injects RFC 8594 Deprecation / Sunset / Link headers automatically.
*
* @type {Record<string, DeprecationEntry>}
*/
export const DEPRECATION_REGISTRY = {
// Example — uncomment when real deprecations land:
// 'GET /api/v1/campaigns/:id/stats': {
// deprecatedAt: '2024-09-01',
// removedAt: '2024-12-01',
// replacement: '/api/v1/campaigns/:id/analytics',
// message: 'Use the /analytics endpoint for richer campaign stats.',
// },
'GET /api/campaigns': {
deprecatedAt: '2026-06-01',
removedAt: '2026-09-01',
replacement: '/api/v1/campaigns',
message: 'Use GET /api/v1/campaigns for the versioned campaign list.',
},
'GET /api/campaigns/:id': {
deprecatedAt: '2026-06-01',
removedAt: '2026-09-01',
replacement: '/api/v1/campaigns/:id',
message: 'Use GET /api/v1/campaigns/:id for the versioned campaign detail.',
},
'POST /api/campaigns': {
deprecatedAt: '2026-06-01',
removedAt: '2026-09-01',
replacement: '/api/v1/campaigns',
message: 'Use POST /api/v1/campaigns to create campaigns.',
},
'PUT /api/campaigns/:id': {
deprecatedAt: '2026-06-01',
removedAt: '2026-09-01',
replacement: '/api/v1/campaigns/:id',
message: 'Use PUT /api/v1/campaigns/:id to update campaigns.',
},
'DELETE /api/campaigns/:id': {
deprecatedAt: '2026-06-01',
removedAt: '2026-09-01',
replacement: '/api/v1/campaigns/:id',
message: 'Use DELETE /api/v1/campaigns/:id to delete campaigns.',
},
'GET /api/campaigns/:id/stats': {
deprecatedAt: '2026-06-01',
removedAt: '2026-09-01',
replacement: '/api/v1/campaigns/:id/stats',
message: 'Use GET /api/v1/campaigns/:id/stats for the versioned stats endpoint.',
},
'GET /api/campaigns/:id/export': {
deprecatedAt: '2026-06-01',
removedAt: '2026-09-01',
replacement: '/api/v1/campaigns/:id/export',
message: 'Use GET /api/v1/campaigns/:id/export for the versioned export endpoint.',
},
};
19 changes: 12 additions & 7 deletions backend/src/middleware/deprecationNotice.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@
import { DEPRECATION_REGISTRY } from '../deprecations.js';

/**
* Match a request path+method against the deprecation registry.
* @typedef {{ deprecatedAt: string, removedAt: string, replacement: string, message: string }} DeprecationEntry
*/

/**
* Match a request path+method against a deprecation registry.
* Registry keys are like "GET /api/v1/campaigns/:id/stats"; path segments
* starting with ":" are treated as wildcards.
*
* @param {string} method e.g. "GET"
* @param {string} path e.g. "/api/v1/campaigns/42/stats"
* @returns {import('../deprecations.js').DeprecationEntry | null}
* @param {Record<string, DeprecationEntry>} registry
* @returns {DeprecationEntry | null}
*/
function matchDeprecation(method, path) {
for (const [pattern, entry] of Object.entries(DEPRECATION_REGISTRY)) {
function matchDeprecation(method, path, registry) {
for (const [pattern, entry] of Object.entries(registry)) {
const [patternMethod, ...rest] = pattern.split(' ');
const patternPath = rest.join(' ');

Expand All @@ -36,12 +41,12 @@ function matchDeprecation(method, path) {
* any route registered in the deprecation registry, and WARN-logs usage
* so operators know which deprecated endpoints are still being hit.
*
* @param {{ log?: { warn?: Function } }} [options]
* @param {{ log?: { warn?: Function }, registry?: Record<string, DeprecationEntry> }} [options]
* @returns {import('express').RequestHandler}
*/
export function createDeprecationMiddleware({ log = console } = {}) {
export function createDeprecationMiddleware({ log = console, registry = DEPRECATION_REGISTRY } = {}) {
return function deprecationNotice(req, res, next) {
const entry = matchDeprecation(req.method, req.path);
const entry = matchDeprecation(req.method, req.path, registry);

if (entry) {
const deprecationDate = new Date(entry.deprecatedAt).toUTCString();
Expand Down
159 changes: 159 additions & 0 deletions backend/src/middleware/deprecationNotice.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// @ts-check
/**
* Unit tests for the deprecation notice middleware.
* Run with: node --test src/middleware/deprecationNotice.test.js
*/

import test, { describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { createDeprecationMiddleware } from './deprecationNotice.js';

// ── Helpers ───────────────────────────────────────────────────────────────────

function makeReqRes({ method = 'GET', path = '/api/v1/campaigns' } = {}) {
const req = { method, path };
const headers = {};
const res = {
setHeader(k, v) { headers[k] = v; },
getHeaders: () => headers,
_headers: headers,
};
return { req, res, headers };
}

function makeLogger() {
const warns = [];
return {
warn: (...args) => warns.push(args.join(' ')),
warns,
};
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('createDeprecationMiddleware', () => {
test('calls next() even when no deprecation matches', (_, done) => {
const mw = createDeprecationMiddleware({});
const { req, res } = makeReqRes({ path: '/api/v1/campaigns' });
mw(req, res, () => done());
});

test('does not set headers when route is not deprecated', () => {
const mw = createDeprecationMiddleware({});
const { req, res, headers } = makeReqRes({ path: '/api/v1/campaigns' });
mw(req, res, () => {});
assert.ok(!headers['Deprecation'], 'should not set Deprecation header');
assert.ok(!headers['Sunset'], 'should not set Sunset header');
assert.ok(!headers['Link'], 'should not set Link header');
});

test('sets Deprecation, Sunset, and Link headers for a registered route', () => {
const registry = {
'GET /api/campaigns': {
deprecatedAt: '2026-01-01',
removedAt: '2026-12-31',
replacement: '/api/v1/campaigns',
message: 'Use /api/v1/campaigns instead.',
},
};
const mw = createDeprecationMiddleware({ registry });
const { req, res, headers } = makeReqRes({ method: 'GET', path: '/api/campaigns' });
mw(req, res, () => {});
assert.ok(headers['Deprecation'], 'missing Deprecation header');
assert.ok(headers['Sunset'], 'missing Sunset header');
assert.ok(headers['Link'], 'missing Link header');
assert.ok(headers['Link'].includes('/api/v1/campaigns'), 'Link should point to replacement');
});

test('matches parameterized segments — /api/campaigns/:id', () => {
const registry = {
'GET /api/campaigns/:id': {
deprecatedAt: '2026-01-01',
removedAt: '2026-12-31',
replacement: '/api/v1/campaigns/:id',
message: 'Use /api/v1/campaigns/:id instead.',
},
};
const mw = createDeprecationMiddleware({ registry });
const { req, res, headers } = makeReqRes({ method: 'GET', path: '/api/campaigns/abc123' });
mw(req, res, () => {});
assert.ok(headers['Deprecation'], 'parameterized path should match');
});

test('does not match wrong HTTP method', () => {
const registry = {
'GET /api/campaigns': {
deprecatedAt: '2026-01-01',
removedAt: '2026-12-31',
replacement: '/api/v1/campaigns',
message: '',
},
};
const mw = createDeprecationMiddleware({ registry });
const { req, res, headers } = makeReqRes({ method: 'POST', path: '/api/campaigns' });
mw(req, res, () => {});
assert.ok(!headers['Deprecation'], 'should not match a different HTTP method');
});

test('does not match a path with a different segment count', () => {
const registry = {
'GET /api/campaigns': {
deprecatedAt: '2026-01-01',
removedAt: '2026-12-31',
replacement: '/api/v1/campaigns',
message: '',
},
};
const mw = createDeprecationMiddleware({ registry });
const { req, res, headers } = makeReqRes({ method: 'GET', path: '/api/campaigns/extra/segment' });
mw(req, res, () => {});
assert.ok(!headers['Deprecation'], 'should not match paths with extra segments');
});

test('WARN-logs on deprecated endpoint hit', () => {
const registry = {
'GET /api/campaigns': {
deprecatedAt: '2026-01-01',
removedAt: '2026-12-31',
replacement: '/api/v1/campaigns',
message: 'Legacy route.',
},
};
const log = makeLogger();
const mw = createDeprecationMiddleware({ log, registry });
const { req, res } = makeReqRes({ method: 'GET', path: '/api/campaigns' });
mw(req, res, () => {});
assert.equal(log.warns.length, 1);
assert.ok(log.warns[0].includes('deprecated_endpoint_hit'));
assert.ok(log.warns[0].includes('/api/campaigns'));
});

test('does not WARN-log for non-deprecated routes', () => {
const log = makeLogger();
const mw = createDeprecationMiddleware({ log, registry: {} });
const { req, res } = makeReqRes({ path: '/api/v1/campaigns' });
mw(req, res, () => {});
assert.equal(log.warns.length, 0);
});

test('Deprecation header value is a valid HTTP date string', () => {
const registry = {
'GET /api/campaigns': {
deprecatedAt: '2026-01-01',
removedAt: '2026-12-31',
replacement: '/api/v1/campaigns',
message: '',
},
};
const mw = createDeprecationMiddleware({ registry });
const { req, res, headers } = makeReqRes({ method: 'GET', path: '/api/campaigns' });
mw(req, res, () => {});
assert.ok(!isNaN(Date.parse(headers['Deprecation'])), 'Deprecation header should be a parseable date');
});

test('handles an empty registry without errors', () => {
const mw = createDeprecationMiddleware({ registry: {} });
const { req, res } = makeReqRes({ path: '/api/v1/anything' });
assert.doesNotThrow(() => mw(req, res, () => {}));
});
});
Loading
Loading