Skip to content
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
43 changes: 43 additions & 0 deletions docs/validation/archive-reason-20261004.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Archive reason input validation — 2026-10-04

PR: https://github.com/RemitFlow/RemitFlow-Backend/pull/144

## Change

Reject object and array `reason` values with a 400 validation error before archive or unarchive looks up a transfer. The previous `String(reason)` conversion could throw for valid JSON such as `{"toString":null}`; the application error handler then rendered an internal-error envelope (500). Plain objects and arrays could also become meaningless audit-reason strings.

The three-line guard preserves the existing normalization of omitted, null, empty-string, string, number, and boolean reasons. This change does not alter archive history, timestamp ordering, or concurrency checks.

## Source pins

| Input | Git object |
| --- | --- |
| Baseline branch head | `a9d97a6d386119a8fe01d7cc867c70b09bd76355` |
| Original `src/services/transferService.js` | `0d421aeeac2cd3d7e9f131918c74d1cc086269bf` |
| Candidate `src/services/transferService.js` | `dc550d29e0fc0463de352ac834a835acac0c9b88` |
| `test/archiveReasonValidation.test.js` | `fd295a1489615673eaf11f7b7d2e44d280ddd94a` |
| Unchanged `src/utils/ApiError.js` | `6b586f60d991ab4e5174e0af4e0d94cf1e3ceb58` |
| Unchanged `src/middleware/errorHandler.js` | `e75a516bde4ec4052d39bea396298e9f2ac5c981` |
| Unchanged `src/services/errorTrackingService.js` | `205391bbd86d48478edc854bf4d5e4a4813596ac` |

## Focused execution

Node.js v24.19.0; the same three `node:test` cases executed once against each service version.

| Case | Original | Candidate |
| --- | --- | --- |
| Reject structured reasons before transfer lookup | Failed: reached missing-transfer 404 | Passed: 400 for both actions |
| Preserve scalar normalization | Passed: retained missing-transfer 404 | Passed: retained missing-transfer 404 |
| Render malformed JSON reason through the application error handler | Failed: 500 instead of 400 | Passed: 400 validation envelope and request ID |

Original: **1 passed, 2 failed**, exit 1. Candidate: **3 passed, 0 failed**, exit 0. Both executions had zero skipped or cancelled cases.

The execution used `node --test-reporter=tap` with a CommonJS source loader in memory. It loaded the complete service, ApiError, error handler, and error-tracking source at the pins above, plus the unchanged regression-test source in both phases. Explicit collaborators were an empty transfer map, a logging sink, error tracking disabled in config, and empty unused service dependencies. The cases stop at reason normalization or missing-transfer lookup.

This establishes the source and error-envelope behavior. It does not establish full Express routing, authentication, successful archive mutations, history integration, or full-suite acceptance. No dependency install or full-suite run was performed for this change.

The maintained regression file can be run in a normal repository checkout with dependencies installed:

```bash
node --test test/archiveReasonValidation.test.js
```
81 changes: 81 additions & 0 deletions docs/validation/rfb144-body-precondition-20261004.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# PR 144 body-precondition acceptance — 4 October 2026

## Result

The existing archive acceptance suites passed **46 tests, with 0 failures, skips,
or cancellations**, on source commit
`45d0bd4e8f1ad71a22ee3d759a84388ae2a9f968`.

This completes the previously unexecuted native validation of the body-precondition
continuation on [RemitFlow-Backend PR 144](https://github.com/RemitFlow/RemitFlow-Backend/pull/144).
That continuation was published while its execution environment was unavailable.

[Successful GitHub Actions run 37191775463](https://github.com/woahwhattheheck/RemitFlow-Backend/actions/runs/37191775463)
contains the complete command output. The acceptance job is `111405306802`.
It ran on the public contributor fork and checked out the exact source commit
above before installing or executing anything.

## Execution

Runtime: **Node.js v22.23.3**, **npm 10.9.9**, Linux x64, GitHub-hosted
`ubuntu-latest`. The existing repository CI also selects Node.js 22.

The job used the unchanged manifest and lockfile with `npm ci`, followed by:

```bash
node --test --test-concurrency=1 \
test/transferArchive.test.js \
test/transferArchiveLifecycle.test.js
```

The test runner reported a total duration of 589.94016 ms. This is the duration
of that suite invocation, excluding checkout and dependency installation; it is
not a backend throughput or latency benchmark.

The existing HTTP cases execute the application through a local listener and
use its in-memory store and mock settlement adapter. The ten pending additions
all passed:

- Eight malformed body-precondition cases cover number, boolean, array, and
object values for both archive and unarchive. They require
`409 STALE_ARCHIVE_COMMAND` and unchanged transfer history and audit records.
- Two compatibility groups cover optional values, trimmed body precedence,
bare and weak-quoted headers, valid header fallback for a non-string body,
and a stale header fallback. The existing successful and stale-command
behavior is preserved.

All 36 earlier tests in these two suites also passed. No source correction was
needed after execution.

## Source and artifact identities

| Item | Identity |
| --- | --- |
| Tested repository | `woahwhattheheck/RemitFlow-Backend` |
| Tested source commit | `45d0bd4e8f1ad71a22ee3d759a84388ae2a9f968` |
| Validation workflow commit | `8ab10b252c90b28d161ad804b65fd0a22e1f95bb` |
| Package manifest Git blob | `a1bf7b6726b103f58de2e8cf6f2bab71fca6883b` |
| Package lock Git blob | `be3ad84b812121d2ec4f47541df3a55816bb210c` |
| Package lock SHA-256 | `74077a5a97f6548a4049aa9756e14dfbc04beab1c82b57923942ff0d99524894` |
| Locked dependencies | 76 |
| Actions artifact | `11299062132` / `rfb144-body-precondition-a993` |
| Artifact ZIP SHA-256 | `523eb61c2ef23e0f865b7387bef0dad14931688b2f7939995b9de88f5cfb8a8f` |
| Dependency tar SHA-256 | `0ae2e8dba5fb52549442edcae73b55a93d4e7f5d5141c842431373c565f09680` |

The artifact contains `acceptance.tap`, `runtime-metadata.json`, and the
`node_modules.tar.gz` produced by that same lockfile installation. It expires
on **11 October 2026 at 09:19:31 UTC**. Its downloaded ZIP and dependency-tar
digests were verified. Reuse of these dependencies requires the matching
lockfile; the artifact does not establish execution of another source revision.

## Limits

Only the two named maintained suites ran in this continuation. There is no new
full-suite result, live-provider settlement result, deployment, or upstream
maintainer acceptance claim. The successful fork job does not replace upstream
CI approval.

The original contribution remains on `latch/remitflow-126-archive-timestamps`.
This receipt is an additive documentation change; its evidence refers to the
tested source commit above. The separate validation workflow stays on
`validation/rfb144-body-precondition-a993`.
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
Loading