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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ When preparing a new release:

### Added

- Monotonic, auditable archive/unarchive lifecycle for transfers: each
archive or unarchive appends an immutable `archiveHistory` event with
actor, reason, and a non-decreasing timestamp; `lastArchivedAt` retains
the prior archive instant after unarchive so reconciliation is not lost.
Optional `expectedUpdatedAt` (body or `If-Match`) rejects stale concurrent
commands with `409 STALE_ARCHIVE_COMMAND`. Audit log entries
`transfer.archived` / `transfer.unarchived` carry the same actor/reason.
- Cursor pagination for `GET /api/transfers` and `GET /api/audit`. Pass
`?cursor=` (with optional `?order=asc|desc`) to page by an indexed position
instead of a row offset; responses carry a `pageInfo` block with
Expand Down Expand Up @@ -55,6 +62,21 @@ When preparing a new release:

### Fixed

- Archive and unarchive HTTP requests preserve supplied non-string
`expectedUpdatedAt` values when there is no nonblank `If-Match` fallback.
The existing service check rejects those requests with
`409 STALE_ARCHIVE_COMMAND` instead of treating them as unversioned writes.
Body/header selection, omitted/null/blank optional values, actor attribution,
timestamp ordering, and no-token retries keep their existing behavior.

- Archive and unarchive HTTP requests now store the existing keyed actor
fingerprint in lifecycle history and audit entries, keeping bearer credentials
out of transfer and audit responses while preserving attribution across cycles.
Fingerprints share the pagination signing secret and the process-local store's
lifetime; trusted service callers retain their explicit actor labels. Existing
event timestamps, reasons, request IDs, retries, and stale-command checks are
unchanged.

- Offset pagination over transfer and audit history repeated or skipped rows
when records were written while a client was paging, because the window was
defined by a row count rather than a position. Cursor pagination anchors to
Expand Down
48 changes: 46 additions & 2 deletions src/controllers/transferController.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const transferService = require('../services/transferService');
const { buildHistoryPage } = require('../utils/historyPage');
const { actorFingerprint } = require('../utils/cursor');
const idempotencyService = require('../services/idempotencyService');
const ApiError = require('../utils/ApiError');

Expand Down Expand Up @@ -148,21 +149,64 @@ function cancelTransfer(req, res) {
res.json(transfer);
}

/**
* Read optional optimistic-concurrency token for archive mutations.
* Prefers body.expectedUpdatedAt; falls back to a bare If-Match header value.
* @param {import('express').Request} req
* @returns {string|undefined}
*/
function readExpectedUpdatedAt(req) {
const fromBody = req.body && req.body.expectedUpdatedAt;
if (typeof fromBody === 'string' && fromBody.trim() !== '') {
return fromBody.trim();
}
const ifMatch = req.get('If-Match');
if (typeof ifMatch === 'string' && ifMatch.trim() !== '') {
return ifMatch.trim().replace(/^W\//, '').replace(/^"|"$/g, '');
}
// Preserve supplied non-string tokens for the service's strict freshness check.
return fromBody != null && typeof fromBody !== 'string' ? fromBody : undefined;
}

/**
* Shared options for archive / unarchive mutations.
* @param {import('express').Request} req
* @returns {object}
*/
function archiveMutationOptions(req) {
const body = req.body || {};
return {
requestId: req.id,
// History and audit responses must identify the caller without storing credentials.
actor: req.token ? actorFingerprint(req) : null,
reason: body.reason,
expectedUpdatedAt: readExpectedUpdatedAt(req),
};
}

/**
* POST /api/transfers/:id/archive
* Archive a transfer, hiding it from default list results.
* Body may include `reason` and `expectedUpdatedAt` (optimistic concurrency).
*/
function archiveTransfer(req, res) {
const transfer = transferService.archiveTransfer(req.params.id);
const transfer = transferService.archiveTransfer(
req.params.id,
archiveMutationOptions(req)
);
res.json(transfer);
}

/**
* POST /api/transfers/:id/unarchive
* Unarchive a transfer, restoring it to default list results.
* Body may include `reason` and `expectedUpdatedAt` (optimistic concurrency).
*/
function unarchiveTransfer(req, res) {
const transfer = transferService.unarchiveTransfer(req.params.id);
const transfer = transferService.unarchiveTransfer(
req.params.id,
archiveMutationOptions(req)
);
res.json(transfer);
}

Expand Down
191 changes: 177 additions & 14 deletions src/services/transferService.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ const config = require('../config');

// Keep lifecycle timestamps strictly increasing even when multiple operations
// happen within the same millisecond (common in tests and API batches).
function nextTimestamp(previous) {
const now = Date.now();
const previousMs = previous ? Date.parse(previous) : NaN;
return new Date(Math.max(now, Number.isFinite(previousMs) ? previousMs + 1 : now)).toISOString();
function nextTimestamp(...previous) {
const next = previous.reduce((latest, value) => {
const previousMs = value ? Date.parse(value) : NaN;
return Number.isFinite(previousMs) ? Math.max(latest, previousMs + 1) : latest;
}, Date.now());
return new Date(next).toISOString();
}

/**
Expand Down Expand Up @@ -264,6 +266,10 @@ function createTransferUnchecked(data, requestId, idempotency) {
createdAt: new Date().toISOString(),
updatedAt: null,
archivedAt: null,
lastArchivedAt: null,
// Append-only archive/unarchive events. Timestamps here are immutable once
// written so repeated archive cycles never erase earlier lifecycle order.
archiveHistory: [],
};
transfer.updatedAt = nextTimestamp(transfer.createdAt);

Expand Down Expand Up @@ -354,35 +360,192 @@ function cancelTransfer(id, requestId) {
return transfer;
}

/**
* Normalise archive/unarchive options. Accepts either an options object or a
* legacy bare requestId string so existing call sites keep working.
* @param {string|object} [optionsOrRequestId]
* @returns {{ requestId: string|undefined, actor: string|null, reason: string|null, expectedUpdatedAt: string|undefined }}
*/
function normaliseArchiveOptions(optionsOrRequestId) {
if (optionsOrRequestId == null) {
return { requestId: undefined, actor: null, reason: null, expectedUpdatedAt: undefined };
}
if (typeof optionsOrRequestId === 'string') {
return {
requestId: optionsOrRequestId,
actor: null,
reason: null,
expectedUpdatedAt: undefined,
};
}
const reason = optionsOrRequestId.reason;
return {
requestId: optionsOrRequestId.requestId,
actor: optionsOrRequestId.actor == null ? null : String(optionsOrRequestId.actor),
reason: reason == null || reason === '' ? null : String(reason),
expectedUpdatedAt: optionsOrRequestId.expectedUpdatedAt,
};
}

/**
* Reject a command whose caller observed a stale `updatedAt`.
* Omitting expectedUpdatedAt preserves the previous unversioned behaviour.
* @param {object} transfer
* @param {string|undefined} expectedUpdatedAt
*/
function assertFreshArchiveCommand(transfer, expectedUpdatedAt) {
if (expectedUpdatedAt == null) return;
if (transfer.updatedAt !== expectedUpdatedAt) {
throw ApiError.conflict(
`Stale archive command for transfer ${transfer.id}`,
{
code: 'STALE_ARCHIVE_COMMAND',
expectedUpdatedAt,
actualUpdatedAt: transfer.updatedAt,
archivedAt: transfer.archivedAt,
}
);
}
}

/**
* Latest immutable archive-history timestamp, used as a monotonic floor.
* @param {object} transfer
* @returns {string|null}
*/
function lastArchiveHistoryAt(transfer) {
const history = transfer.archiveHistory;
if (!Array.isArray(history) || history.length === 0) return null;
return history[history.length - 1].at;
}

/**
* Append an immutable archive lifecycle event and return it.
* @param {object} transfer
* @param {object} event
* @returns {object}
*/
function appendArchiveHistory(transfer, event) {
if (!Array.isArray(transfer.archiveHistory)) {
transfer.archiveHistory = [];
}
const frozen = Object.freeze({
action: event.action,
at: event.at,
actor: event.actor,
reason: event.reason,
requestId: event.requestId,
});
transfer.archiveHistory.push(frozen);
return frozen;
}

/**
* Archive a transfer. An archived transfer is hidden from default list results
* but remains queryable. Archiving is idempotent and orthogonal to the transfer
* lifecycle status.
* but remains queryable. Archiving is idempotent for retries that observe the
* current archived state, orthogonal to the transfer lifecycle status, and
* records an immutable, auditable event with actor and reason.
*
* Pass `expectedUpdatedAt` to opt into optimistic concurrency: a mismatched
* value is rejected as a stale command so concurrent archive/unarchive cycles
* cannot silently overwrite each other.
*
* @param {string} id
* @param {string|object} [optionsOrRequestId]
* @returns {object}
*/
function archiveTransfer(id) {
function archiveTransfer(id, optionsOrRequestId) {
const options = normaliseArchiveOptions(optionsOrRequestId);
const transfer = getTransferOrThrow(id);
if (!transfer.archivedAt) {
const timestamp = nextTimestamp(transfer.updatedAt);
transfer.archivedAt = timestamp;
transfer.updatedAt = timestamp;
assertFreshArchiveCommand(transfer, options.expectedUpdatedAt);

// Already archived: idempotent retry. Do not move archivedAt / updatedAt
// backwards or rewrite history — that was the original failure mode.
if (transfer.archivedAt) {
return transfer;
}

const timestamp = nextTimestamp(
transfer.updatedAt,
lastArchiveHistoryAt(transfer)
);
transfer.archivedAt = timestamp;
transfer.lastArchivedAt = timestamp;
transfer.updatedAt = timestamp;

appendArchiveHistory(transfer, {
action: 'archive',
at: timestamp,
actor: options.actor,
reason: options.reason,
requestId: options.requestId || null,
});

auditService.addEntry({
action: 'transfer.archived',
resourceId: transfer.id,
payload: {
archivedAt: timestamp,
actor: options.actor,
reason: options.reason,
},
requestId: options.requestId,
});

return transfer;
}

/**
* Unarchive a previously archived transfer, restoring it to default list results.
* Clears the current-state `archivedAt` flag but retains `lastArchivedAt` and
* an immutable history entry so the lifecycle order stays reconcilable.
*
* @param {string} id
* @param {string|object} [optionsOrRequestId]
* @returns {object}
*/
function unarchiveTransfer(id) {
function unarchiveTransfer(id, optionsOrRequestId) {
const options = normaliseArchiveOptions(optionsOrRequestId);
const transfer = getTransferOrThrow(id);
assertFreshArchiveCommand(transfer, options.expectedUpdatedAt);

if (!transfer.archivedAt) {
throw ApiError.conflict(`Transfer is not archived: ${id}`);
throw ApiError.conflict(`Transfer is not archived: ${id}`, {
code: 'NOT_ARCHIVED',
});
}

const previousArchivedAt = transfer.archivedAt;
const timestamp = nextTimestamp(
transfer.updatedAt,
lastArchiveHistoryAt(transfer),
previousArchivedAt
);

transfer.archivedAt = null;
transfer.updatedAt = nextTimestamp(transfer.updatedAt);
transfer.lastArchivedAt = previousArchivedAt;
transfer.updatedAt = timestamp;

appendArchiveHistory(transfer, {
action: 'unarchive',
at: timestamp,
actor: options.actor,
reason: options.reason,
requestId: options.requestId || null,
});

auditService.addEntry({
action: 'transfer.unarchived',
resourceId: transfer.id,
payload: {
previousArchivedAt,
unarchivedAt: timestamp,
actor: options.actor,
reason: options.reason,
},
requestId: options.requestId,
});

return transfer;
}

Expand Down
Loading