BreakingSecurityImprovements
API documentation backfill

Request validation is now enforced across the API

Every endpoint gained a request validator. A validator is not additive metadata — it replaces the request object, so calls that were previously tolerated in silence can now be rejected.
  • Malformed identifiers return 422, previously 400. An id that is not 24 hexadecimal characters used to reach the database, raise a cast error and surface as a 400 with no field detail. It is now rejected up front with error.details[0].field naming the parameter.
  • limit is capped at 100. A larger value previously ran a query of whatever size was asked for. page or limit of 0, a negative number, or a non-numeric string were silently replaced with defaults. All of these are now 422.
  • sort is validated. A value the database could not parse previously produced a 500; it is now 422.
  • Undeclared query parameters are stripped before the handler runs rather than ignored further down. Behaviour is unchanged, but it is now unchanged deliberately.
If any error handling branches on 400 for these routes, add 422. Anything rendering error.details needs no change and now gets better text.

Issuer scoping fixes

Three endpoints were returning other issuers’ data to issuer_admin users. Their sibling endpoints scoped correctly; these did not. Nothing changes for ta_admin or audit_user.
  • GET /transactions/ledger was returning journal entries for every issuer. It now scopes to the caller, so totalDocs and totalPages shrink and a page that used to be full may now be empty. Entries whose security cannot be attributed to an issuer are no longer returned to an issuer admin.
  • GET /issuers/{id} now returns 404 when an issuer admin requests an issuer that is not its own — 404 rather than 403, deliberately, because a 403 would confirm the other issuer exists.
  • GET /investors/{id}/securities now returns only securities belonging to the calling issuer, and 404 for an investor with no relationship to it.
Any issuer-facing total, export or reconciliation built on the previous output will change. The new figures are the correct ones, and anyone who reconciled against these endpoints should know that earlier figures may have included other issuers.

Webhook requests are now signature-verified

POST /webhook/sync now verifies x-signature as an HMAC and refuses a reused or stale x-nonce.Senders must switch to hex(HMAC-SHA256(secret, "<nonce>.<body>")). The previous receiver accepted any signature, so a sender can change first with no downtime. See Webhooks.

sort now honours multi-field values

Sorting is split on whitespace internally, but the validator only accepted the comma form. So ?sort=name,-createdAt was accepted and then sorted by a single field literally named name,-createdAt, which does not exist — the result came back in natural order with no error. The space form that would have worked was rejected as 422.Both forms are now accepted and normalised. ?sort=name,-createdAt starts sorting. No client change is required, but any list view that sent a multi-field sort and set its expectations against the previous unsorted output will now see a different — correct — order, and stable pagination it did not have before.

Reports gained query validation

The four report endpoints had none.
  • ?securityId= must be a valid id on /reports/mshf and /reports/control-book. Both services guarded it and silently dropped it when it failed to parse, so a typo returned every security in scope. A screen that built a single-security view from a bad id was showing the whole book. A malformed value is now 422.
  • ?limit= is capped at 100 on all four, and ?issuerId= is stripped — scope comes from the token.

Corrections to previously published behaviour

No code changed here; the documentation was wrong.
  • POST /auth/forgot-password never returns 404. It always returns 200, by design, so the endpoint cannot be used to discover which addresses have accounts. Any branch on 404 is dead code and the message it would have shown was a privacy leak.
  • POST /auth/logout does not always revoke other sessions. It bumps tokenVersion only when the request carries a token that still verifies. Treat “log out of all devices” as a separate feature.
  • POST /api-keys no longer accepts expiresIn. It was accepted and never read — keys do not expire. It is now stripped, which is the same outcome at runtime.