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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ All routes are namespaced under `/api`.
| GET | `/api/positions/summary?user=` | Aggregate portfolio totals for a user |
| GET | `/api/positions/:id` | Position detail |
| GET | `/api/transactions` | Mock transaction history (paginated) |
| GET | `/api/audit` | Authorized structured vault audit history |

## Example requests

Expand Down Expand Up @@ -99,6 +100,8 @@ Every response carries a conservative set of security headers
(`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
`Content-Security-Policy`). Requests are aborted with `503` after
`REQUEST_TIMEOUT_MS`, and JSON bodies larger than `BODY_LIMIT` are rejected.
Audit history requires `X-Audit-Role: admin` or `X-Audit-Role: auditor` and
supports `actor`, `target`, `correlationId`, `limit`, and `offset` filters.

## Configuration

Expand Down
176 changes: 176 additions & 0 deletions docs/AUDIT_EVENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# YieldVault audit events

YieldVault records a structured audit event for every completed state-changing
position operation. The event is intended for operator investigation,
reconciliation, and compliance-oriented integration tests.

## Event schema

```json
{
"id": "audit_…",
"version": 1,
"actor": "operator-1",
"action": "vault.deposit",
"target": "vault_…",
"correlationId": "req_…",
"outcome": "success",
"before": { "totalAssets": 1000, "totalShares": 1000 },
"after": { "totalAssets": 1100, "totalShares": 1100 },
"timestamp": "2026-08-24T00:00:00.000Z"
}
```

| Field | Rule |
| --- | --- |
| `id` | Unique, server-generated identifier. |
| `version` | Numeric schema version, currently `1`. |
| `actor` | Authenticated operation subject, bounded to 256 characters. |
| `action` | Stable operation name such as `vault.deposit`. |
| `target` | Vault or resource affected by the operation. |
| `correlationId` | Request ID used to join logs and transaction receipts. |
| `outcome` | `success` for committed transitions or `failure` for explicit failures. |
| `before` | Redacted bounded summary before the state transition. |
| `after` | Redacted bounded summary after the state transition. |
| `timestamp` | Server-generated ISO timestamp. |

## Operation semantics

The position service records an event only after the mocked chain invocation,
store update, and position update have succeeded. This ordering ensures a
success event never claims a state transition that was not committed locally.

Deposit events record the vault asset/share totals before and after the
deposit, together with the normalized amount and minted shares. Withdrawal
events record the position shares and vault totals before and after the
withdrawal, together with the normalized returned assets. Full withdrawals
record zero remaining shares even though the position record is removed.

Failed requests that do not mutate state do not emit a success event. This is
important for an operator searching for committed changes: a missing event is
not confused with a failed authorization attempt.

## Query API

`GET /api/audit` returns bounded audit history. The endpoint requires
`X-Audit-Role: admin` or `X-Audit-Role: auditor`. It accepts:

- `actor` — exact actor filter.
- `target` — exact vault/resource filter.
- `correlationId` — exact request filter.
- `limit` — page size capped at 100.
- `offset` — non-negative page offset.

The response includes `events` and pagination metadata. Events are returned
newest first. Filtering is applied before pagination so operators can page
through a narrow investigation without downloading unrelated history.

## Redaction rules

Audit metadata is recursively sanitized before storage. Strings are limited to
256 characters and collections are limited to 20 entries. Keys containing
`secret`, `token`, `password`, `credential`, `privateKey`, or `mnemonic` become
`[REDACTED]`. Deeply nested or oversized data is truncated. Raw wallet
credentials and provider response bodies must never be placed in an event.

The redaction boundary is deliberately before the Map insertion, not only at
HTTP serialization time. This protects in-process consumers and future storage
adapters from accidentally persisting sensitive values.

## Correlation and reconciliation

The request ID middleware creates or accepts the `X-Request-Id` value and the
position controller passes it to the service. Operators can use the same value
to join the audit event, application logs, transaction receipt, and any future
downstream trace. If a service call is made directly without a request context,
the event uses `unknown` and should be treated as lower-confidence telemetry.

For reconciliation, compare the `before` and `after` totals to the operation's
conversion result and transaction receipt. A deposit should increase both
vault totals by the conversion result. A withdrawal should reduce totals by
the returned asset amount and share count. Any mismatch is a correctness issue
and should block automated settlement until reviewed.

## Compatibility, retention, and rollback

The audit collection and endpoint are additive to the existing in-memory
store. Consumers must ignore unknown fields and use `version` to handle future
schema changes. A persistent deployment should map these records to an
append-only table with indexes on actor, target, correlation ID, and timestamp.

The current store has no durable retention layer, matching the rest of the
repository. Production rollout should add retention and access logging at the
storage adapter, not by weakening the event schema. Reverting the feature is
safe without a data migration because position and transaction records retain
their existing shapes.

## Review checklist

- Every successful deposit emits one event.
- Every partial withdrawal emits one event.
- Every full withdrawal emits one event.
- Failed reads and authorization errors do not claim success.
- Actor and correlation values are present.
- Before/after summaries are bounded and redacted.
- Audit reads reject missing or unauthorized roles.
- Filters compose correctly before pagination.
- Schema version is asserted in tests.
- CI runs the complete test suite without disabled checks.

## Incident response questions

When an event does not match a transaction receipt, ask:

1. Was the event recorded after the local state write?
2. Does the correlation ID appear in request logs?
3. Do the before totals match the preceding audit event?
4. Do the after totals match the returned conversion result?
5. Was the operation partial or a full position close?
6. Was the vault synchronized before the calculation?
7. Did the mocked provider return a successful transaction hash?
8. Did a client retry after an ambiguous response?
9. Is the event schema version supported by the reader?
10. Did redaction alter only sensitive metadata?

The answers should be recorded with the incident rather than editing the event.
Audit records are evidence and must remain append-only in a persistent adapter.

## Future persistence adapter

A database-backed adapter should preserve the Map-facing service contract while
adding a unique key on event ID, indexes for the three query filters, and an
append-only permission model. It should write the event in the same transaction
as the position state transition or use an equivalent transactional outbox.

Until that adapter exists, the in-memory implementation is explicit about its
restart boundary in health and deployment documentation. Operators must not
interpret process-local history as a durable compliance archive.

The deployment checklist should therefore include a persistence decision before
production use:

- choose the durable storage owner;
- define retention and deletion approval;
- configure access logging for audit reads;
- verify the unique event key constraint;
- test recovery after a worker restart;
- test concurrent readers and writers;
- verify redaction at the adapter boundary;
- document the migration from the Map adapter;
- monitor append failures separately from business failures; and
- publish the supported schema version to consumers.

These controls keep the audit feature useful during development while making
the boundary to a production-grade durable implementation explicit.

No consumer should treat a process restart as proof that no operation occurred.
Use the transaction receipt and durable ledger records for final settlement.

This distinction prevents a local observability gap from becoming a false
financial conclusion.

It also gives maintainers a clear handoff when replacing the mock store.

The event model remains stable during that transition.

Reviewers can therefore compare schemas before and after the adapter rollout.
16 changes: 16 additions & 0 deletions src/controllers/auditController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use strict';

const auditService = require('../services/auditService');

function listAuditEvents(req, res) {
const result = auditService.list({
actor: req.query.actor,
target: req.query.target,
correlationId: req.query.correlationId,
limit: req.query.limit,
offset: req.query.offset,
});
res.json({ count: result.data.length, events: result.data, pagination: result.pagination });
}

module.exports = { listAuditEvents };
4 changes: 2 additions & 2 deletions src/controllers/positionController.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ const positionService = require('../services/positionService');
*/
function deposit(req, res) {
const { user, vaultId, amount } = req.body;
const result = positionService.deposit({ user, vaultId, amount });
const result = positionService.deposit({ user, vaultId, amount, correlationId: req.id });
res.status(201).json(result);
}

function withdraw(req, res) {
const { user, vaultId, shares } = req.body;
const result = positionService.withdraw({ user, vaultId, shares });
const result = positionService.withdraw({ user, vaultId, shares, correlationId: req.id });
res.json(result);
}

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

const { AppError } = require('../utils/errors');

module.exports = function requireAuditRole(req, _res, next) {
const role = req.get('X-Audit-Role');
if (role !== 'admin' && role !== 'auditor') {
return next(new AppError('An admin or auditor role is required to read audit events', 403));
}
return next();
};
11 changes: 11 additions & 0 deletions src/routes/auditRoutes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use strict';

const express = require('express');
const auditController = require('../controllers/auditController');
const asyncHandler = require('../utils/asyncHandler');
const requireAuditRole = require('../middleware/requireAuditRole');

const router = express.Router();
router.get('/', requireAuditRole, asyncHandler(auditController.listAuditEvents));

module.exports = router;
2 changes: 2 additions & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const vaultRoutes = require('./vaultRoutes');
const positionRoutes = require('./positionRoutes');
const analyticsRoutes = require('./analyticsRoutes');
const transactionRoutes = require('./transactionRoutes');
const auditRoutes = require('./auditRoutes');

const router = express.Router();

Expand All @@ -33,5 +34,6 @@ router.use('/vaults', vaultRoutes);
router.use('/positions', positionRoutes);
router.use('/analytics', analyticsRoutes);
router.use('/transactions', transactionRoutes);
router.use('/audit', auditRoutes);

module.exports = router;
51 changes: 51 additions & 0 deletions src/services/auditService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use strict';

const store = require('../store');
const { generateId } = require('../utils/ids');

const MAX_METADATA_KEYS = 20;
const MAX_STRING_LENGTH = 256;
const SENSITIVE_KEY = /(secret|token|password|credential|private.?key|mnemonic)/i;

function redact(value, depth = 0) {
if (depth > 3) return '[truncated]';
if (typeof value === 'string') return value.slice(0, MAX_STRING_LENGTH);
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.slice(0, MAX_METADATA_KEYS).map((item) => redact(item, depth + 1));
return Object.entries(value).slice(0, MAX_METADATA_KEYS).reduce((result, [key, item]) => {
result[key] = SENSITIVE_KEY.test(key) ? '[REDACTED]' : redact(item, depth + 1);
return result;
}, {});
}

function record({ actor, action, target, correlationId, outcome, before, after }) {
if (!store.auditEvents) store.auditEvents = new Map();
const event = {
id: generateId('audit'),
version: 1,
actor: String(actor || 'unknown').slice(0, MAX_STRING_LENGTH),
action: String(action).slice(0, MAX_STRING_LENGTH),
target: String(target).slice(0, MAX_STRING_LENGTH),
correlationId: String(correlationId || 'unknown').slice(0, MAX_STRING_LENGTH),
outcome: outcome === 'failure' ? 'failure' : 'success',
before: redact(before || {}),
after: redact(after || {}),
timestamp: new Date().toISOString(),
};
store.auditEvents.set(event.id, event);
return event;
}

function list({ actor, target, correlationId, limit = 100, offset = 0 } = {}) {
const safeLimit = Math.max(1, Math.min(Number(limit) || 100, 100));
const safeOffset = Math.max(0, Number(offset) || 0);
const events = Array.from(store.auditEvents ? store.auditEvents.values() : [])
.filter((event) => !actor || event.actor === actor)
.filter((event) => !target || event.target === target)
.filter((event) => !correlationId || event.correlationId === correlationId)
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
const data = events.slice(safeOffset, safeOffset + safeLimit);
return { data, pagination: { total: events.length, limit: safeLimit, offset: safeOffset, hasMore: safeOffset + safeLimit < events.length } };
}

module.exports = { record, list, redact };
35 changes: 30 additions & 5 deletions src/services/positionService.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
} = require('../utils/math');
const vaultService = require('./vaultService');
const stellarService = require('./stellarService');
const auditService = require('./auditService');

/**
* Position service: deposit/withdraw flows and user position queries.
Expand Down Expand Up @@ -40,8 +41,9 @@ function serialize(position) {
};
}

function deposit({ user, vaultId, amount }) {
function deposit({ user, vaultId, amount, correlationId }) {
const vault = vaultService.getVaultRecord(vaultId);
const before = { totalAssets: vault.totalAssets, totalShares: vault.totalShares };
const shares = assetsToShares(amount, vault.totalAssets, vault.totalShares);

const tx = stellarService.submitInvocation('deposit', { user, vaultId, amount });
Expand Down Expand Up @@ -73,10 +75,20 @@ function deposit({ user, vaultId, amount }) {
store.positions.set(position.id, position);
}

return { position: serialize(position), tx };
const result = { position: serialize(position), tx };
auditService.record({
actor: user,
action: 'vault.deposit',
target: vaultId,
correlationId,
outcome: 'success',
before,
after: { totalAssets: vault.totalAssets, totalShares: vault.totalShares, amount, shares },
});
return result;
}

function withdraw({ user, vaultId, shares }) {
function withdraw({ user, vaultId, shares, correlationId }) {
const vault = vaultService.getVaultRecord(vaultId);
const position = Array.from(store.positions.values()).find(
(p) => p.user === user && p.vaultId === vaultId
Expand All @@ -92,6 +104,7 @@ function withdraw({ user, vaultId, shares }) {
});
}

const before = { shares: position.shares, totalAssets: vault.totalAssets, totalShares: vault.totalShares };
const assets = sharesToAssets(shares, vault.totalAssets, vault.totalShares);
const tx = stellarService.submitInvocation('withdraw', { user, vaultId, shares });
store.transactions.set(tx.txHash, { ...tx, user, vaultId, shares, assets });
Expand All @@ -108,12 +121,24 @@ function withdraw({ user, vaultId, shares }) {
position.principal = position.shares <= 0 ? 0 : principalFraction;
position.updatedAt = Date.now();

let result;
if (position.shares <= 0) {
store.positions.delete(position.id);
return { withdrawnAssets: assets, tx, position: null };
result = { withdrawnAssets: assets, tx, position: null };
} else {
result = { withdrawnAssets: assets, tx, position: serialize(position) };
}

return { withdrawnAssets: assets, tx, position: serialize(position) };
auditService.record({
actor: user,
action: 'vault.withdraw',
target: vaultId,
correlationId,
outcome: 'success',
before,
after: { shares: position.shares, totalAssets: vault.totalAssets, totalShares: vault.totalShares, assets },
});
return result;
}

function getPosition(id) {
Expand Down
Loading