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
132 changes: 132 additions & 0 deletions stellar-payment-platform/deprecation.test.js
Original file line number Diff line number Diff line change
@@ -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(
'<https://docs.example.com/deprecations/lookup>; 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();
});
});
5 changes: 5 additions & 0 deletions stellar-payment-platform/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions stellar-payment-platform/src/config/deprecations.js
Original file line number Diff line number Diff line change
@@ -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 };
139 changes: 139 additions & 0 deletions stellar-payment-platform/src/middleware/deprecation.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading