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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ When preparing a new release:
1. Review the `[Unreleased]` section and ensure all notable changes are documented.
2. Change the `[Unreleased]` heading to the new version number and current date (e.g., `## [1.0.0] - YYYY-MM-DD`).
3. Create a new empty `## [Unreleased]` section at the top of the file, with `### Added`, `### Changed`, `### Fixed`, etc., as needed.

- Consistent token-scope enforcement across transfer, user, audit, and admin
surfaces (`admin:read` scope, service-boundary checks, `POST /api/transfers/bulk`).
See `docs/SCOPE_MATRIX.md`. Malformed and missing transfer ids now share one
non-enumerating `404 Transfer not found` response.
4. Update the `version` field in `package.json` to match the new version.
5. Commit these changes with a conventional commit message (e.g., `chore: release v1.0.0`).
6. Tag the commit with the new version (e.g., `git tag v1.0.0`) and push the changes and tags to the repository.
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,23 @@ Authorization: Bearer <token>

### Scopes

Canonical catalog: `src/config/scopes.js`. Full route matrix: [`docs/SCOPE_MATRIX.md`](docs/SCOPE_MATRIX.md).

| Scope | Grants access to |
|-------|-----------------|
| `transfers:read` | `GET /api/transfers`, `GET /api/transfers/stats`, `GET /api/transfers/:id` |
| `transfers:write` | `POST /api/transfers`, `POST /api/transfers/:id/claim`, `POST /api/transfers/:id/cancel`, `POST /api/transfers/:id/archive`, `POST /api/transfers/:id/unarchive` |
| `transfers:write` | `POST /api/transfers`, `POST /api/transfers/bulk`, `POST /api/transfers/:id/claim`, `POST /api/transfers/:id/cancel`, `POST /api/transfers/:id/archive`, `POST /api/transfers/:id/unarchive` |
| `users:read` | `GET /api/users`, `GET /api/users/:id` |
| `users:write` | `POST /api/users` |
| `audit:read` | `GET /api/audit` |
| `admin:read` | `GET /api/admin/diagnostics` (also granted by the legacy `ADMIN_API_KEY` / `X-Admin-Token`) |

HTTP controllers pass `authFromRequest(req)` so scopes are enforced at the route
**and** again at the service boundary. Trusted internal callers can omit that
context, so new request adapters must pass it explicitly; see the
[service integration contract](docs/SCOPE_MATRIX.md#service-integration).
Missing, malformed, and unknown transfer ids share one `404 Transfer not found`
response so callers cannot enumerate identifiers by status or message shape.

### Public endpoints (no token required)

Expand All @@ -85,12 +95,21 @@ strings to their scope arrays:
API_TOKENS='{"my-production-token":["transfers:read","transfers:write","users:read","users:write","audit:read"],"reporting-token":["transfers:read","audit:read"]}'
```

An explicitly supplied value must be a JSON object with non-empty token keys,
no leading or trailing key whitespace, and arrays of scopes from the catalog.
Malformed JSON, blank values, invalid shapes, and unknown scopes stop startup
with a configuration error that does not print credentials. They never enable
the demo tokens. An empty object `{}` is valid and disables all API-token
credentials; the separately configured legacy admin key retains its behavior.
Only configured own entries are tokens, so inherited names such as `constructor`
are unknown unless explicitly configured.

If `API_TOKENS` is not set, the server starts with three **demo tokens** (safe
for local development only — rotate before deploying):

| Demo token | Scopes |
|------------|--------|
| `test-token-admin` | all scopes |
| `test-token-admin` | all scopes (including `admin:read`) |
| `test-token-readonly` | `transfers:read`, `users:read`, `audit:read` |
| `test-token-transfers` | `transfers:read`, `transfers:write` |

Expand Down
87 changes: 87 additions & 0 deletions docs/SCOPE_MATRIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Scope matrix

Canonical scope strings live in `src/config/scopes.js` (`SCOPES`, `SCOPE_MATRIX`).
Route middleware (`requireScope` / `adminAuth`) is the first gate. HTTP controllers
pass an explicit auth context to the service helpers in `src/utils/authz.js`, which
re-check the same scopes. New request handlers must preserve both steps; see
[Service integration](#service-integration).

| Method | Path | Required scopes | Surface |
|--------|------|-----------------|---------|
| GET | `/api/transfers` | `transfers:read` | list |
| GET | `/api/transfers/stats` | `transfers:read` | direct |
| GET | `/api/transfers/:id` | `transfers:read` | direct |
| POST | `/api/transfers` | `transfers:write` | direct |
| POST | `/api/transfers/bulk` | `transfers:write` | bulk |
| POST | `/api/transfers/:id/claim` | `transfers:write` | direct |
| POST | `/api/transfers/:id/cancel` | `transfers:write` | direct |
| POST | `/api/transfers/:id/archive` | `transfers:write` | direct |
| POST | `/api/transfers/:id/unarchive` | `transfers:write` | direct |
| GET | `/api/users` | `users:read` | list |
| GET | `/api/users/:id` | `users:read` | direct |
| POST | `/api/users` | `users:write` | direct |
| GET | `/api/audit` | `audit:read` | list |
| GET | `/api/admin/diagnostics` | `admin:read` | admin |

## Service integration

Service checks run when an `auth` context is supplied. `assertScopes(auth, required)`
deliberately returns without checking when `auth` is `null` or `undefined`, so
trusted internal callers such as seed code retain their existing behavior.
Omitting the argument is not an anonymous-request denial.

For every HTTP adapter, keep the route's `requireScope` / `adminAuth` middleware
and derive the service context with `authFromRequest(req)`. This copies the
middleware-populated `req.tokenScopes`; a request with no scope array produces
an explicit empty array and is rejected by the service with
`403 Insufficient token scopes`. Do not substitute client-supplied body or query
fields for that context.

Pass the context in the operation's existing argument position. For example,
the archive controller calls:

```js
const { authFromRequest } = require('../utils/authz');

const transfer = transferService.archiveTransfer(
req.params.id,
authFromRequest(req)
);
```

The create controller instead passes it as the fourth argument, after the
payload, request ID and idempotency descriptor. Follow the corresponding
controller in `src/controllers/transferController.js` when adding an adapter.
The service guard therefore protects a missing route scope check only when
the adapter still supplies an explicit request context; it is not a replacement
for request authentication.

## Admin credentials

`GET /api/admin/diagnostics` accepts either:

1. `Authorization: Bearer <api-token>` whose catalog entry includes `admin:read`, or
2. Legacy `X-Admin-Token: <ADMIN_API_KEY>` / `Authorization: Bearer <ADMIN_API_KEY>`,
which is mapped onto `[admin:read]` so the path stays scope-gated.

A recognized API bearer token is checked first. If it lacks `admin:read`, the
request returns `403` even when a valid legacy `X-Admin-Token` is also present.
Choose the intended credential instead of combining both authentication modes.

## Non-enumeration

Transfer lookups (direct and bulk) return the same `404 Transfer not found`
envelope for malformed ids and for unknown well-formed ids. Bulk per-id failures
use a stable `error.code` of `not_found` in both cases.

## Bulk mutations

```http
POST /api/transfers/bulk
Authorization: Bearer <token with transfers:write>
Content-Type: application/json

{ "action": "claim" | "cancel" | "archive" | "unarchive", "ids": ["txn_…", …] }
```

Up to 50 ids per call. The response is `{ results: [{ id, ok, transfer? | error? }] }`.
19 changes: 19 additions & 0 deletions docs/validation/bulk-error-boundary-20261004.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Bulk error boundary

Bulk transfer actions now use the same error-disclosure boundary as the central HTTP handler: only an intentional ApiError controls the public status/message; other thrown values become 500 / Internal server error. Canonical missing-transfer errors, intended API conflicts and scope checks remain intact. Null and string throws remain per-item failures instead of crashing the batch. Independent items still execute.

This change does not alter transfer state transitions or claim to roll back provider failures.

## Executed evidence

Base `b0c161f1c9c8200d762e5810ba82c15995a1be0a`; source before `e82ec442dec7a190eae24f0efddcaabfceff4a17`, after `41b91b665e183547619af829b2b5e82d739728e3`; new test `aeceda6e5cf69b31f20488af440442e941c5d16d`. Runtime `v22.23.3`.

Original production with new HTTP regressions: 2 pass / 6 fail, exit 1. Failures cover ordinary and status-bearing provider errors, null/string throws, and single-item/bulk message parity.

Repaired source with maintained token-scope suite and new HTTP regressions: 33 pass / 0 fail / 0 cancelled / 0 skipped, exit 0. Existing tests unchanged.

```sh
NODE_ENV=test node --test --test-reporter=tap test/tokenScopes.test.js test/bulkErrorBoundary.test.js
```

Real createApp, Express authentication, service and loopback HTTP with synthetic provider failures and existing mock settlement. No live credentials, external provider, durable storage, full-suite or settlement rollback claim.
39 changes: 29 additions & 10 deletions src/config/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

require('dotenv').config();
const { assertKnownScopes } = require('./scopes');

/**
* Centralized application configuration.
Expand Down Expand Up @@ -71,18 +72,36 @@ const config = {

apiTokens: (() => {
try {
if (process.env.API_TOKENS) {
return JSON.parse(process.env.API_TOKENS);
// Demo credentials apply only when no token map was supplied. A broken
// deployment value must never silently enable the public demo tokens.
const configured = process.env.API_TOKENS === undefined ? {
'test-token-admin': [
'transfers:read',
'transfers:write',
'users:read',
'users:write',
'audit:read',
'admin:read',
],
'test-token-readonly': ['transfers:read', 'users:read', 'audit:read'],
'test-token-transfers': ['transfers:read', 'transfers:write'],
} : JSON.parse(process.env.API_TOKENS);

if (configured === null || typeof configured !== 'object' || Array.isArray(configured)) {
throw new Error('Invalid token map');
}
} catch (err) {
console.warn('Failed to parse API_TOKENS env var, falling back to defaults');

const tokens = Object.create(null);
for (const [token, scopes] of Object.entries(configured)) {
if (token === '' || token.trim() !== token) throw new Error('Invalid token key');
tokens[token] = assertKnownScopes(scopes, 'configured scope').slice();
}
return tokens;
} catch {
// Neither parser diagnostics nor scope-validation errors may expose
// configured token keys or values in startup logs.
throw new Error('API_TOKENS must be a JSON object mapping non-empty, unpadded token strings to arrays of known scopes');
}
// Default tokens for demo purposes
return {
'test-token-admin': ['transfers:read', 'transfers:write', 'users:read', 'users:write', 'audit:read'],
'test-token-readonly': ['transfers:read', 'users:read', 'audit:read'],
'test-token-transfers': ['transfers:read', 'transfers:write']
};
})(),
};

Expand Down
73 changes: 73 additions & 0 deletions src/config/scopes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use strict';

/**
* Canonical action/resource scope catalog.
*
* Every secured route and service boundary must require one of these values.
* Unknown scope strings are rejected at configuration time so a typo cannot
* silently open an endpoint.
*
* Format: `<resource>:<action>`
*/

const SCOPES = Object.freeze({
TRANSFERS_READ: 'transfers:read',
TRANSFERS_WRITE: 'transfers:write',
USERS_READ: 'users:read',
USERS_WRITE: 'users:write',
AUDIT_READ: 'audit:read',
ADMIN_READ: 'admin:read',
});

/** All known scope strings, for validation and docs. */
const ALL_SCOPES = Object.freeze(Object.values(SCOPES));

/**
* Route → required scopes matrix. Used by docs and the scope-matrix test so
* the documented contract cannot drift from the mounted routers.
*
* `anyOf` is reserved for future OR-semantics; today every entry is AND.
*
* @type {ReadonlyArray<{ method: string, path: string, scopes: string[], surface: string }>}
*/
const SCOPE_MATRIX = Object.freeze([
{ method: 'GET', path: '/api/transfers', scopes: [SCOPES.TRANSFERS_READ], surface: 'list' },
{ method: 'GET', path: '/api/transfers/stats', scopes: [SCOPES.TRANSFERS_READ], surface: 'direct' },
{ method: 'GET', path: '/api/transfers/:id', scopes: [SCOPES.TRANSFERS_READ], surface: 'direct' },
{ method: 'POST', path: '/api/transfers', scopes: [SCOPES.TRANSFERS_WRITE], surface: 'direct' },
{ method: 'POST', path: '/api/transfers/:id/claim', scopes: [SCOPES.TRANSFERS_WRITE], surface: 'direct' },
{ method: 'POST', path: '/api/transfers/:id/cancel', scopes: [SCOPES.TRANSFERS_WRITE], surface: 'direct' },
{ method: 'POST', path: '/api/transfers/:id/archive', scopes: [SCOPES.TRANSFERS_WRITE], surface: 'direct' },
{ method: 'POST', path: '/api/transfers/:id/unarchive', scopes: [SCOPES.TRANSFERS_WRITE], surface: 'direct' },
{ method: 'POST', path: '/api/transfers/bulk', scopes: [SCOPES.TRANSFERS_WRITE], surface: 'bulk' },
{ method: 'GET', path: '/api/users', scopes: [SCOPES.USERS_READ], surface: 'list' },
{ method: 'GET', path: '/api/users/:id', scopes: [SCOPES.USERS_READ], surface: 'direct' },
{ method: 'POST', path: '/api/users', scopes: [SCOPES.USERS_WRITE], surface: 'direct' },
{ method: 'GET', path: '/api/audit', scopes: [SCOPES.AUDIT_READ], surface: 'list' },
{ method: 'GET', path: '/api/admin/diagnostics', scopes: [SCOPES.ADMIN_READ], surface: 'admin' },
]);

/**
* Assert every entry in `scopes` is a known catalog value.
* @param {string[]} scopes
* @param {string} [label]
* @returns {string[]}
*/
function assertKnownScopes(scopes, label = 'scope') {
if (!Array.isArray(scopes)) {
throw new Error(`${label} must be an array of scope strings`);
}
for (const scope of scopes) {
if (!ALL_SCOPES.includes(scope)) {
throw new Error(`Unknown ${label}: ${scope}`);
}
}
return scopes;
}

module.exports = {
SCOPES,
ALL_SCOPES,
SCOPE_MATRIX,
assertKnownScopes,
};
11 changes: 11 additions & 0 deletions src/controllers/adminController.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,23 @@
const config = require('../config');
const userService = require('../services/userService');
const transferService = require('../services/transferService');
const { SCOPES, assertScopes, authFromRequest } = require('../utils/authz');

/**
* GET /api/admin/diagnostics
* Returns system diagnostics, active configurations, and usage statistics.
*
* Requires the explicit `admin:read` scope (enforced at the route by adminAuth
* and re-checked here so the privileged aggregate cannot be reached through a
* future helper that forgets the middleware).
*/
function getDiagnostics(req, res) {
const auth = authFromRequest(req);
assertScopes(auth, SCOPES.ADMIN_READ);

// Admin diagnostics aggregates across tenants; call services without a
// caller auth context so the read-scope gates do not reject an admin-only
// token that intentionally has no transfers:read / users:read grants.
const transferStats = transferService.getStats();
const userCount = userService.listUsers().length;

Expand Down
8 changes: 5 additions & 3 deletions src/controllers/auditController.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const auditService = require('../services/auditService');
const { buildHistoryPage } = require('../utils/historyPage');
const { authFromRequest } = require('../utils/authz');

/**
* Audit log controllers.
Expand All @@ -20,15 +21,16 @@ function listAuditEntries(req, res) {
: String(req.query.resourceId);

const filters = { resourceId };
const auth = authFromRequest(req);

const { items, envelope } = buildHistoryPage({
req,
collection: 'audit',
filters,
defaultOrder: 'desc',
query: (args) => auditService.queryEntries({ resourceId, ...args }),
countTotal: () => auditService.countEntries(resourceId),
resolvePosition: (seq) => auditService.positionKeyAt(seq, resourceId),
query: (args) => auditService.queryEntries({ resourceId, ...args, auth }),
countTotal: () => auditService.countEntries(resourceId, auth),
resolvePosition: (seq) => auditService.positionKeyAt(seq, resourceId, auth),
});

res.json({ ...envelope, entries: items });
Expand Down
Loading