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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,28 @@ SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
# DB (when added)
# DATABASE_URL=postgresql://user:pass@localhost:5432/liquifact
# REDIS_URL=redis://localhost:6379

# --------------------
# JWT Authentication |
# --------------------
# Secret used to sign and verify JSON Web Tokens.
# Must be a long, random string in production. Defaults to "test-secret" locally.
# JWT_SECRET=replace-with-a-long-random-secret

# ------------------------
# API Key Authentication |
# ------------------------
# Semicolon-separated list of API key entries, each a JSON object.
# Schema per entry:
# key (string, required) — must start with "lf_", min 10 chars
# clientId (string, required) — unique identifier for the service client
# scopes (array, required) — non-empty list from: invoices:read, invoices:write, escrow:read
# revoked (bool, optional) — set to true to disable the key without removing it
#
# Example (two entries — one active, one revoked):
# API_KEYS={"key":"lf_prod_service_a_key","clientId":"billing-service","scopes":["invoices:read","invoices:write"]};{"key":"lf_old_service_b_key","clientId":"legacy-service","scopes":["invoices:read"],"revoked":true}
#
# Key rotation: add the new key entry, deploy, then set "revoked": true on the
# old entry and redeploy. The old key is rejected immediately; the new key works
# from the first deploy.
# API_KEYS=
36 changes: 36 additions & 0 deletions __mocks__/knex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use strict';

// Root-level manual mock for the 'knex' npm package.
// Applied automatically via moduleNameMapper in jest config.
// Makes the query builder thenable so `await query` resolves to [].

const makeQueryBuilder = () => {
const qb = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
first: jest.fn().mockReturnThis(),
then(resolve, reject) {
return Promise.resolve([]).then(resolve, reject);
},
catch(handler) {
return Promise.resolve([]).catch(handler);
},
finally(handler) {
return Promise.resolve([]).finally(handler);
},
};
return qb;
};

// db is the knex instance — it's callable (db('tableName') returns a query builder)
const db = jest.fn(() => makeQueryBuilder());
db.raw = jest.fn().mockResolvedValue([]);

// knex factory — called with config, returns the db instance
const knex = jest.fn(() => db);

module.exports = knex;
22 changes: 11 additions & 11 deletions src/__tests__/bodySizeLimits.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@

'use strict';

const { describe, it, expect, beforeEach, beforeAll, vi } = require('vitest');
// Uses Jest globals: describe, it, expect, beforeEach, beforeAll, jest
jest.mock('../services/invoice.service');

const request = require('supertest');
const express = require('express');

Expand Down Expand Up @@ -323,7 +325,6 @@ describe('payloadTooLargeHandler()', () => {
next(err);
});
app.use(payloadTooLargeHandler);
// eslint-disable-next-line no-unused-vars
app.use((_err, _req, res, _next) => res.status(500).json({ error: 'other' }));

const res = await request(app).post('/trigger');
Expand All @@ -335,7 +336,6 @@ describe('payloadTooLargeHandler()', () => {
const app = express();
app.post('/trigger', (_req, _res, next) => next(new Error('unrelated')));
app.use(payloadTooLargeHandler);
// eslint-disable-next-line no-unused-vars
app.use((err, _req, res, _next) => res.status(500).json({ error: err.message }));

const res = await request(app).post('/trigger');
Expand All @@ -349,9 +349,9 @@ describe('payloadTooLargeHandler()', () => {
// ═══════════════════════════════════════════════════════════════════════════

describe('parseAllowedOrigins()', () => {
it('returns null for undefined', () => expect(parseAllowedOrigins(undefined)).toBeNull());
it('returns null for empty string', () => expect(parseAllowedOrigins('')).toBeNull());
it('returns null for blank string', () => expect(parseAllowedOrigins(' ')).toBeNull());
it('returns [] for undefined', () => expect(parseAllowedOrigins(undefined)).toEqual([]));
it('returns [] for empty string', () => expect(parseAllowedOrigins('')).toEqual([]));
it('returns [] for blank string', () => expect(parseAllowedOrigins(' ')).toEqual([]));
it('parses a single origin', () => expect(parseAllowedOrigins('https://a.com')).toEqual(['https://a.com']));
it('parses multiple origins', () => expect(parseAllowedOrigins('https://a.com,https://b.com')).toEqual(['https://a.com','https://b.com']));
it('trims whitespace around commas', () => expect(parseAllowedOrigins(' https://a.com , https://b.com ')).toEqual(['https://a.com','https://b.com']));
Expand Down Expand Up @@ -548,17 +548,17 @@ describe('callSorobanContract()', () => {
describe('handleCorsError()', () => {
it('responds 403 for a CORS rejection error', () => {
const err = Object.assign(new Error('blocked origin'), { isCorsOriginRejected: true });
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
const next = vi.fn();
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
const next = jest.fn();
handleCorsError(err, {}, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});

it('calls next for non-CORS errors', () => {
const err = new Error('something else');
const next = vi.fn();
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
const next = jest.fn();
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
handleCorsError(err, {}, res, next);
expect(next).toHaveBeenCalledWith(err);
expect(res.status).not.toHaveBeenCalled();
Expand All @@ -568,7 +568,7 @@ describe('handleCorsError()', () => {
describe('handleInternalError()', () => {
it('responds 500 with generic message', () => {
const err = new Error('boom');
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
handleInternalError(err, {}, res, () => {});
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
Expand Down
13 changes: 10 additions & 3 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ module.exports = app;
require('dotenv').config();

const { callSorobanContract } = require('./services/soroban');
const invoiceService = require('./services/invoice.service');
const { createCorsOptions, isCorsOriginRejectedError } = require('./config/cors');
const { validateInvoiceQueryParams } = require('./utils/validators');
const {
jsonBodyLimit,
urlencodedBodyLimit,
Expand Down Expand Up @@ -185,10 +187,15 @@ function createApp() {
});

// Invoices — GET (list)
app.get('/api/invoices', (req, res) => {
app.get('/api/invoices', async (req, res) => {
const { isValid, errors, validatedParams } = validateInvoiceQueryParams(req.query);
if (!isValid) {
return res.status(400).json({ errors });
}
const invoices = await invoiceService.getInvoices(validatedParams);
res.json({
data: [],
message: 'Invoice service will list tokenized invoices here.',
data: invoices,
message: 'Invoices retrieved successfully.',
});
});

Expand Down
177 changes: 177 additions & 0 deletions src/config/apiKeys.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* API Key configuration and registry.
*
* Parses and validates the API key store from the environment variable
* `API_KEYS`. Each entry is a JSON-formatted object in a semicolon-separated
* list. Example:
*
* API_KEYS={"key":"lf_abc123","clientId":"service-a","scopes":["invoices:read"]};{"key":"lf_xyz789","clientId":"service-b","scopes":["invoices:write","escrow:read"],"revoked":true}
*
* @module config/apiKeys
*/

/** Prefix that every valid API key must carry. */
const API_KEY_PREFIX = 'lf_';

/**
* All scopes recognised by the system.
* @type {string[]}
*/
const VALID_SCOPES = [
'invoices:read',
'invoices:write',
'escrow:read',
];

/**
* The minimum required length of the full API key (prefix included).
* @type {number}
*/
const MIN_KEY_LENGTH = 10;

/**
* @typedef {Object} ApiKeyEntry
* @property {string} key - The raw API key string (must start with `lf_`).
* @property {string} clientId - Unique identifier for the service client.
* @property {string[]} scopes - Permissions granted to this key.
* @property {boolean} [revoked] - When `true` the key is rejected at auth time.
*/

/**
* Validates that a raw key entry object satisfies all structural and value
* constraints before it is admitted to the registry.
*
* @param {unknown} entry - Candidate entry decoded from the environment.
* @param {number} index - Position in the input list (for error messages).
* @returns {ApiKeyEntry} The validated entry cast to the expected shape.
* @throws {Error} When any field is missing, wrong type, or holds an invalid value.
*/
function validateEntry(entry, index) {
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
throw new Error(`API_KEYS[${index}]: entry must be a JSON object`);
}

const { key, clientId, scopes, revoked } = entry;

if (typeof key !== 'string' || key.trim() === '') {
throw new Error(`API_KEYS[${index}]: "key" must be a non-empty string`);
}

if (!key.startsWith(API_KEY_PREFIX)) {
throw new Error(
`API_KEYS[${index}]: "key" must start with "${API_KEY_PREFIX}"`
);
}

if (key.length < MIN_KEY_LENGTH) {
throw new Error(
`API_KEYS[${index}]: "key" must be at least ${MIN_KEY_LENGTH} characters long`
);
}

if (typeof clientId !== 'string' || clientId.trim() === '') {
throw new Error(`API_KEYS[${index}]: "clientId" must be a non-empty string`);
}

if (!Array.isArray(scopes) || scopes.length === 0) {
throw new Error(`API_KEYS[${index}]: "scopes" must be a non-empty array`);
}

for (const scope of scopes) {
if (!VALID_SCOPES.includes(scope)) {
throw new Error(
`API_KEYS[${index}]: unknown scope "${scope}". Valid scopes: ${VALID_SCOPES.join(', ')}`
);
}
}

if (revoked !== undefined && typeof revoked !== 'boolean') {
throw new Error(`API_KEYS[${index}]: "revoked" must be a boolean when present`);
}

return {
key: key.trim(),
clientId: clientId.trim(),
scopes: [...scopes],
revoked: Boolean(revoked),
};
}

/**
* Parses the raw `API_KEYS` environment variable string into a list of
* validated {@link ApiKeyEntry} objects.
*
* Returns an empty array when the variable is absent or blank so that the
* middleware can remain in an optional / disabled state without crashing.
*
* @param {string | undefined} raw - The raw value of the `API_KEYS` env var.
* @returns {ApiKeyEntry[]} Ordered list of parsed and validated key entries.
* @throws {Error} When any entry fails structural or value validation.
*/
function parseApiKeys(raw) {
if (!raw || raw.trim() === '') {
return [];
}

return raw
.split(';')
.map((chunk) => chunk.trim())
.filter(Boolean)
.map((chunk, index) => {
let parsed;
try {
parsed = JSON.parse(chunk);
} catch (_err) {
throw new Error(
`API_KEYS[${index}]: failed to parse JSON — ${_err.message}`
);
}
return validateEntry(parsed, index);
});
}

/**
* Builds a Map from key string → {@link ApiKeyEntry} for O(1) lookup.
*
* @param {ApiKeyEntry[]} entries - The list produced by {@link parseApiKeys}.
* @returns {Map<string, ApiKeyEntry>} Lookup map keyed by the raw key string.
* @throws {Error} When the same key string appears more than once.
*/
function buildKeyRegistry(entries) {
const registry = new Map();

for (const entry of entries) {
if (registry.has(entry.key)) {
throw new Error(
`API_KEYS: duplicate key detected for clientId "${entry.clientId}"`
);
}
registry.set(entry.key, entry);
}

return registry;
}

/**
* Loads and returns the API key registry from the current process environment.
*
* The result is built fresh on every call so that unit tests can override
* `process.env.API_KEYS` without module-level caching interfering.
*
* @param {NodeJS.ProcessEnv} [env=process.env] - Environment variables source.
* @returns {Map<string, ApiKeyEntry>} The populated key registry.
*/
function loadApiKeyRegistry(env = process.env) {
const entries = parseApiKeys(env.API_KEYS);
return buildKeyRegistry(entries);
}

module.exports = {
API_KEY_PREFIX,
MIN_KEY_LENGTH,
VALID_SCOPES,
parseApiKeys,
buildKeyRegistry,
loadApiKeyRegistry,
validateEntry,
};
Loading
Loading