diff --git a/stellar-payment-platform/deprecation.test.js b/stellar-payment-platform/deprecation.test.js new file mode 100644 index 00000000..97fea10d --- /dev/null +++ b/stellar-payment-platform/deprecation.test.js @@ -0,0 +1,132 @@ +'use strict'; + +jest.mock('./src/logger', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})); + +const { logger } = require('./src/logger'); +const { + deprecationMiddleware, + findDeprecation, + pathMatches, + toHttpDate, + resetDeprecationLogger, +} = require('./src/middleware/deprecation'); + +const makeRes = () => { + const headers = {}; + return { + headers, + set(key, value) { + headers[key] = value; + return this; + }, + }; +}; + +const run = (entry, method, path) => { + const middleware = deprecationMiddleware({ registry: [entry] }); + const req = { method, path }; + const res = makeRes(); + let nextCalled = false; + middleware(req, res, () => { + nextCalled = true; + }); + return { res, nextCalled }; +}; + +beforeEach(() => { + logger.warn.mockClear(); + resetDeprecationLogger(); +}); + +describe('deprecation middleware', () => { + it('attaches Deprecation, Sunset and Link headers for a matching endpoint', () => { + const { res, nextCalled } = run( + { + method: 'GET', + path: '/api/v1/lookup', + deprecatedSince: '2026-08-29', + sunset: '2027-02-28', + replacement: '/api/v2/lookup', + documentation: 'https://docs.example.com/deprecations/lookup', + }, + 'GET', + '/api/v1/lookup', + ); + + expect(res.headers.Deprecation).toBe(new Date('2026-08-29').toUTCString()); + expect(res.headers.Sunset).toBe(new Date('2027-02-28').toUTCString()); + expect(res.headers.Link).toBe( + '; rel="deprecation"', + ); + expect(res.headers.Warning).toContain('299'); + expect(nextCalled).toBe(true); + }); + + it('ignores requests that do not match any deprecation', () => { + const { res, nextCalled } = run( + { method: 'GET', path: '/api/v1/lookup', deprecatedSince: '2026-08-29', sunset: '2027-02-28' }, + 'GET', + '/api/v1/active', + ); + expect(res.headers.Deprecation).toBeUndefined(); + expect(nextCalled).toBe(true); + }); + + it('matches wildcard paths', () => { + const entry = { method: 'GET', path: '/api/v1/receipts/*', deprecatedSince: '2026-08-29', sunset: '2027-02-28' }; + const { res } = run(entry, 'GET', '/api/v1/receipts/abc123'); + expect(res.headers.Deprecation).toBeDefined(); + }); + + it('logs a server-side warning exactly once per endpoint', () => { + const entry = { method: 'GET', path: '/api/v1/stats', deprecatedSince: '2026-08-29', sunset: '2027-01-31', replacement: '/api/v2/stats' }; + const mw = deprecationMiddleware({ registry: [entry] }); + const req = { method: 'GET', path: '/api/v1/stats' }; + mw(req, makeRes(), () => {}); + mw(req, makeRes(), () => {}); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn.mock.calls[0][1]).toContain('/api/v2/stats'); + }); + + it('uses method "*" to match any HTTP verb', () => { + const entry = { method: '*', path: '/api/v1/legacy', deprecatedSince: '2026-08-29', sunset: '2027-02-28' }; + const { res } = run(entry, 'DELETE', '/api/v1/legacy'); + expect(res.headers.Deprecation).toBeDefined(); + }); +}); + +describe('pathMatches', () => { + it('returns true on exact match', () => { + expect(pathMatches('/api/v1/lookup', '/api/v1/lookup')).toBe(true); + }); + it('returns false on mismatch', () => { + expect(pathMatches('/api/v1/lookup', '/api/v1/other')).toBe(false); + }); + it('supports wildcard', () => { + expect(pathMatches('/api/v1/x/*', '/api/v1/x/1/2')).toBe(true); + }); +}); + +describe('toHttpDate', () => { + it('formats ISO dates as HTTP-dates', () => { + expect(toHttpDate('2027-02-28')).toBe(new Date('2027-02-28').toUTCString()); + }); + it('returns null for invalid dates', () => { + expect(toHttpDate('not-a-date')).toBeNull(); + }); +}); + +describe('findDeprecation', () => { + it('finds a registered deprecation by method and path', () => { + const registry = [{ method: 'POST', path: '/api/v1/payments/bulk', deprecatedSince: '2026-08-29', sunset: '2027-03-31' }]; + expect(findDeprecation('post', '/api/v1/payments/bulk', registry)).toBeDefined(); + expect(findDeprecation('GET', '/api/v1/payments/bulk', registry)).toBeNull(); + }); +}); diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index c55c098e..5731d96a 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -33,6 +33,7 @@ const { buildErrorHandler, notFoundHandler } = require('./src/middleware/errorHa const { ApiError, errorBody } = require('./src/errors'); const { requireJson } = require('./src/middleware/requireJson'); const { apiVersion } = require('./src/middleware/apiVersion'); +const { deprecationMiddleware } = require('./src/middleware/deprecation'); const { registerBodySchema, federationQuerySchema, @@ -929,6 +930,10 @@ app.get('/users', validateSchema({ query: usersQuerySchema }), async (req, res, // API-Version header, defaulting to v1. Routers below then decide routing. app.use(apiVersion); +// RFC 8594 deprecation headers: attaches Deprecation/Sunset/Link to endpoints +// listed in src/config/deprecations.js and logs a server-side warning. +app.use(deprecationMiddleware()); + // v2 first so an explicit /api/v2 request wins over the unversioned fallback. app.use('/api/v2', v2Router); // Explicit v1 mount, then /api (no version) and the legacy unversioned root diff --git a/stellar-payment-platform/src/config/deprecations.js b/stellar-payment-platform/src/config/deprecations.js new file mode 100644 index 00000000..35965545 --- /dev/null +++ b/stellar-payment-platform/src/config/deprecations.js @@ -0,0 +1,53 @@ +'use strict'; + +/** + * Deprecation registry (RFC 8594). + * + * Each entry describes an endpoint that is scheduled for removal. The + * deprecation middleware reads this list and attaches `Deprecation`, + * `Sunset` and `Link` headers to matching requests, while also emitting a + * server-side warning so the deprecation is observable in logs. + * + * Entry shape: + * method HTTP method (uppercase) or '*' to match any method. + * path Request path as seen by Express. May contain a single + * '*' wildcard that matches any trailing segment(s), e.g. + * '/api/v1/receipts/*' matches '/api/v1/receipts/abc123'. + * deprecatedSince ISO-8601 date the endpoint became deprecated. Surfaces + * in the `Deprecation` header. + * sunset ISO-8601 date the endpoint will be removed. Surfaces in + * the `Sunset` header. + * replacement (optional) Path of the endpoint consumers should migrate + * to. Used in the server-side log message. + * documentation (optional) URL describing the deprecation/migration. + * Surfaces in the `Link` header with rel="deprecation". + */ + +const DEPRECATIONS = [ + { + method: 'GET', + path: '/api/v1/lookup', + deprecatedSince: '2026-08-29', + sunset: '2027-02-28', + replacement: '/api/v2/lookup', + documentation: 'https://docs.stellar-tags.example/deprecations/lookup', + }, + { + method: 'GET', + path: '/api/v1/stats', + deprecatedSince: '2026-08-29', + sunset: '2027-01-31', + replacement: '/api/v2/stats', + documentation: 'https://docs.stellar-tags.example/deprecations/stats', + }, + { + method: 'POST', + path: '/api/v1/payments/bulk', + deprecatedSince: '2026-08-29', + sunset: '2027-03-31', + replacement: '/api/v2/payments/bulk', + documentation: 'https://docs.stellar-tags.example/deprecations/bulk-payments', + }, +]; + +module.exports = { DEPRECATIONS }; diff --git a/stellar-payment-platform/src/middleware/deprecation.js b/stellar-payment-platform/src/middleware/deprecation.js new file mode 100644 index 00000000..4cecc5bc --- /dev/null +++ b/stellar-payment-platform/src/middleware/deprecation.js @@ -0,0 +1,139 @@ +'use strict'; + +/** + * src/middleware/deprecation.js + * + * Express middleware implementing the Deprecation and Sunset HTTP headers + * described in RFC 8594. Endpoints listed in the deprecation registry + * (src/config/deprecations.js) receive the following response headers: + * + * Deprecation - HTTP-date the endpoint became deprecated (or "true"). + * Sunset - HTTP-date the endpoint is scheduled for removal. + * Link - rel="deprecation" pointing at migration documentation. + * Warning - 299 stale/deprecation notice for older clients. + * + * Every matched request also emits a server-side warning so operators can + * track consumer reliance on soon-to-be-removed endpoints. + */ + +const { logger } = require('../logger'); +const { DEPRECATIONS } = require('../config/deprecations'); + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Returns true when the registered path pattern matches the request path. + * A single '*' wildcard matches any trailing segment(s). + * + * @param {string} pattern + * @param {string} path + * @returns {boolean} + */ +const pathMatches = (pattern, path) => { + if (pattern === path) return true; + if (pattern.includes('*')) { + const regex = new RegExp( + `^${pattern.split('*').map(escapeRegExp).join('.*')}$`, + ); + return regex.test(path); + } + return false; +}; + +/** + * Locates a deprecation entry for the given method + path. + * + * @param {string} method HTTP method (any case). + * @param {string} path Request path. + * @returns {object|null} Matching registry entry or null. + */ +const findDeprecation = (method, path, registry = DEPRECATIONS) => { + const verb = String(method || '').toUpperCase(); + for (const entry of registry) { + const entryMethod = String(entry.method || '*').toUpperCase(); + if (entryMethod !== '*' && entryMethod !== verb) continue; + if (pathMatches(entry.path, path)) return entry; + } + return null; +}; + +/** + * Formats an ISO-8601 date as an RFC 7231 HTTP-date. + * + * @param {string} iso + * @returns {string|null} + */ +const toHttpDate = (iso) => { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + return date.toUTCString(); +}; + +// Throttles the server-side warning so each deprecated endpoint is logged +// at most once per process, avoiding log floods from high-traffic routes. +const loggedKeys = new Set(); + +const resetDeprecationLogger = () => loggedKeys.clear(); + +/** + * Express middleware factory. + * + * @param {object} [options] + * @param {Array} [options.registry] Override the deprecation registry + * (primarily used by tests). + * @returns {Function} Express middleware. + */ +const deprecationMiddleware = (options = {}) => { + const registry = options.registry || DEPRECATIONS; + + return (req, res, next) => { + const entry = findDeprecation(req.method, req.path, registry); + if (!entry) return next(); + + const deprecatedSince = toHttpDate(entry.deprecatedSince) || 'true'; + const sunset = toHttpDate(entry.sunset); + + res.set('Deprecation', deprecatedSince); + if (sunset) res.set('Sunset', sunset); + + if (entry.documentation) { + res.set( + 'Link', + `<${entry.documentation}>; rel="deprecation"`, + ); + } + + if (sunset) { + const message = entry.replacement + ? `Deprecated endpoint ${req.method} ${req.path} will be removed after ${sunset}; migrate to ${entry.replacement}.` + : `Deprecated endpoint ${req.method} ${req.path} will be removed after ${sunset}.`; + res.set('Warning', `299 - "Deprecated API endpoint, scheduled for removal ${sunset}"`); + + const key = `${req.method} ${req.path}`; + if (!loggedKeys.has(key)) { + loggedKeys.add(key); + logger.warn( + { + type: 'deprecation', + method: req.method, + path: req.path, + sunset, + replacement: entry.replacement || null, + documentation: entry.documentation || null, + }, + message, + ); + } + } + + return next(); + }; +}; + +module.exports = { + deprecationMiddleware, + findDeprecation, + pathMatches, + toHttpDate, + resetDeprecationLogger, +};