feat(api-keys): add structured metrics and logging - #1
Conversation
- Add listJobs() to jobPersistence.js with keyset cursor pagination (limit clamped 1-100, sortBy, order, status/type filters) - Add encodeJobCursor/decodeJobCursor with HMAC-SHA256 signed opaque cursors - Add JobCursorError domain error (maps to HTTP 400 at route layer) - Add GET /api/admin/jobs route with full input validation and admin auth - Mount new route at /api/admin/jobs in app.js - 77 new tests: 45 unit (listJobs edge cases) + 32 route (auth, validation, pagination, cursor errors, filters, DB error path) Closes Liquifact#682
…e env registry Closes Liquifact#590 Migrate the remaining consumer of the legacy SQLite API key middleware (src/routes/adminWebhooks.js) to use the env-backed registry authenticator (src/middleware/apiKeyAuth.js), preserving the X-API-KEY contract, scope semantics, the req.apiClient shape, and the timing-safe comparison. - src/middleware/apiKeyAuth.js: add structured pino logger calls for every auth outcome (missing / invalid / revoked / insufficient_scope / success) through the existing log channel. Never log raw key material; only clientId, requiredScope, scopes, outcome, ip, and path. - src/routes/adminWebhooks.js: replace the legacy require with the registry middleware, and switch req.apiKey?.name to req.apiClient?.clientId so the logger payload continues to identify the admin client. - tests/webhooks.retry.test.js: point the jest.mock target at the new apiKeyAuth module and stub req.apiClient instead of the legacy req.apiKey. - tests/apiKey.test.js: extend coverage with X-API-KEY header contract tests, no-key-material-leak-in-response assertions, no-SQLite-on-the-hot-path source/surface checks, and an audit service integration block that confirms the shared logger is invoked on every outcome. - docs/wasm-ops.md: drop the retired API_KEYS_DB_PATH row from the env table and reference API_KEYS plus the src/middleware/apiKeyAuth.js migration. - .env.example: append a security-notes block covering env-backed registry, removed SQLite store, constant-time SHA-256 comparison, and structured logging with no raw-key output. - scripts/migrate.js: delete the vestigial SQLite migration script (no callers, no entry in package.json scripts).
Add opaque-cursor pagination to GET /api/health/checks endpoint. - New src/utils/healthCursorPagination.js: HMAC-SHA256 signed cursor utility with encodeHealthCursor / decodeHealthCursor / resolveLimit. Signing uses CURSOR_SECRET > JWT_SECRET > dev fallback (dev/test only). Optional TTL enforcement via CURSOR_TTL_ENABLED / CURSOR_TTL_SECONDS. - New src/routes/health.js: GET /api/health/checks returns a paginated snapshot of all named dependency health checks (soroban, database, kyc, indexerStaleness, storage, reconciliation). Supports limit (1-100, default 10) and cursor query params; invalid/tampered cursors -> 400. - src/services/health.js: add listHealthChecks() which runs all individual checks in parallel and returns a flat, deterministically- ordered array of HealthCheckRecord objects (id, name, status, timestamp, detail). - src/app.js: mount /api/health router via mountFeatureRouter. - tests/health.listing.test.js: 32 tests covering default first page, custom limit, cursor navigation, last page, exact-page boundary, over-limit clamp, invalid cursor, unknown cursor, empty list, and upstream error propagation. Plus unit tests for the cursor utility (encode/decode/resolveLimit including TTL expiry). Closes Liquifact#677
- Add GET /api/admin/indexer/events with cursor + offset pagination - Bounded page size (default 20, max 100), HMAC-signed opaque cursors - Filters: invoiceId, eventType, contractId (stable across pages) - Sort fields: observed_at (default), ledger_sequence - CursorError maps to HTTP 400; admin-only via adminStack - Extend ALLOWED_SORT_FIELDS in cursorPagination.js - 57 tests covering cursor mode, offset mode, edge cases, auth, validation Closes Liquifact#667
…fact#724) Require every file under src/routes/ at test time so that broken imports (e.g. require('../middleware/apiKey') when the real module is apiKeyAuth.js) are caught at CI time instead of crashing the server on startup. 16 route files verified across admin, SME, v1, and all feature routers — all require without error. Closes Liquifact#610
…tralization (Liquifact#723) Co-authored-by: Akinpelu Oladapo <akinpeluoladapo19@gmail.com>
Co-authored-by: Chigozirim Favour <205050683+Gozirimdev@users.noreply.github.com>
…and subdomain bypass tests (Liquifact#631) (Liquifact#720)
Co-authored-by: GitHub Copilot <copilot@example.com>
- Add src/workers/persistenceValidation.js with three pure helpers:
- assertJobStructure(job): replaces inline !job || !job.id || !job.type
guards; validates required fields are non-empty strings with
descriptive error messages
- validatePayloadRoundTrip(raw): centralises the JSONB round-trip +
plain-object check previously inlined in sanitisePayload
- parseEnvInt(value, defaultValue, min, max): unifies the repeated
parseInt + isFinite + clamp pattern used across worker config reads
- Refactor jobPersistence.js: sanitisePayload now delegates to
validatePayloadRoundTrip; behaviour is unchanged
- Refactor worker.js: _processJob now calls assertJobStructure(job)
instead of the inline conditional
- Add src/workers/persistenceValidation.test.js (214 tests, all pass):
covers all rejection codes, parity with original sanitisePayload
cases, real-world env var patterns, and edge cases
No behaviour changes. No new dependencies.
Closes Liquifact#686
…uifact#710) - Add getSmeInvoiceList() service method with keyset pagination (created_at DESC, id DESC) - Update GET /api/sme/metrics route to accept optional cursor and limit query params - Return aggregated counts in data and paginated invoices in meta - Add comprehensive tests covering: backward compat, pagination flow, empty set, exact boundary, limit clamping, invalid cursor, tenant/owner isolation, soft-delete exclusion - Backward compatible: existing callers see identical response when no cursor/limit provided
…rmalization bypasses (Liquifact#703)
…se counters (Liquifact#702) Adds a Prometheus histogram soroban_rpc_call_duration_seconds with bounded labels {method, outcome} and a counter soroban_rpc_retry_causes_total with bounded label {cause} (timeout, 429, 5xx). Instruments callSorobanContract() and withRetry() in src/services/soroban.js; preserves prom-client shim compatibility; keeps retry/backoff behavior and returns semantics unchanged. Adds a Jest test that records the latency histogram with outcome=error for calls failing with non-transient errors. Closes Liquifact#595
Instruments the SME persistence endpoints (POST /api/sme/invoice and /invoice/presigned-url) with request duration + status metrics, an error-cause counter, and structured no-PII logs, exposed on the existing /metrics endpoint. Adds a reusable instrumentPersistence wrapper. 100% coverage on the new module.
…r delivery - Wrap transport.sendMail with bounded exponential backoff via sendMailWithRetry - Classify SMTP errors as permanent (5xx, invalid recipient) vs transient (4xx, network) - Permanent failures dead-letter immediately; transient failures retry up to SMTP_MAX_RETRIES (default 3, clamped 1-10) - Increment maturityReminderDeliverySuccessTotal after confirmed successful delivery (was never called before) - Export normalizeReminderReason and all previously-unexported metric counters from src/metrics.js - Persist sanitized dead-letter records asynchronously; DB outage never stalls the reminder handler - No PII (recipient email, customer name, amount, raw SMTP errors) in logs, dead-letter records, or Prometheus labels - Keep dry-run mock transport intact when SMTP_HOST is absent - Preserve invoiceJobs cancellation map cleanup in finally block Tests (67 passing): - Transport: dry-run mode, SMTP config, no credential leak - Template rendering - Retry config bounds (clamp 1-10, truncate floats) - scheduleReminder / cancelReminder lifecycle - Queue start/stop idempotency - persistReminderDeadLetter: PII exclusion, column selection, created_at timestamp - listReminderDeadLetters: limit clamping, reason filter, column selection - Successful delivery: all 3 counters verified, invoiceJobs cleanup - Transient retry exhaustion: attempt count, dead-letter counter, PII-free record - Permanent SMTP failure (550): single attempt, smtp_reject reason - Transport setup failure: dead-letters with attempts=0 - Persistence error resilience: slow persist, Error rejection, string rejection - Dry-run transport delivery - normalizeReminderReason mapping - Cancelled reminder never delivered Security notes: - SMTP credentials never written to any log line - Recipient addresses not stored in dead-letter records or metric labels - Attempts bound (1-10) prevents unbounded retry loops - Dead-letter persistence is fire-and-forget; DB outage cannot stall delivery Closes Liquifact#573
Add POST /api/health/reports endpoint that accepts external service health reports with Idempotency-Key header support. Retried submissions with the same key and body return the original cached response (201 replay) instead of double-processing. Reusing a key with a different body returns 409 Conflict (RFC 7807). Also fix two pre-existing bugs in idempotency middleware: - idempotencyStorageFailureTotal could be undefined when metrics module exists but doesn't export the counter - persistResponse used committed trx instead of global db, causing 'Transaction query already complete' errors Changes: - src/schemas/healthReport.js: Zod strict schema for report payload - src/routes/health.js: POST /reports endpoint with idempotency middleware - src/app.js: mount health routes at /api/health - src/middleware/idempotency.js: fix undefined metrics + committed trx bugs - tests/healthWrite.idempotency.test.js: 55 integration tests Closes Liquifact#770
Enforces tenant scoping in the validateMetricsRequest utility to prevent cross-tenant spoofing. Adds tests covering unauthorized responses, missing tenant contexts, and cross-tenant access denial. Also fixes a pre-existing defect where CursorError was not correctly handled by the test assertions due to the global error envelope wrapper. Closes Liquifact#747
Add Idempotency-Key header handling to api-keys write endpoints (POST /api/keys and POST /api/api-keys) so retried creation requests return the original result instead of double-applying. Changes: - middleware/idempotency.js: Fix persistResponse to use global db instead of trx (was failing with "Transaction query already complete" errors, affecting ALL idempotent endpoints) - routes/apiKeys.js: Add idempotencyMiddleware to both POST routes - app.js: Mount apiKeysRoutes at /api (was imported but never mounted) - tests/apiKeys.idempotency.test.js: 42 new integration tests covering header validation, first-write, replay, conflict (409), TTL expiry, concurrency, security, handler coexistence, and validation caching Closes Liquifact#765
…e/metrics (Liquifact#803) Adds integration tests for success, not-found, validation-failure, and idempotent-repeat paths on both metrics endpoints (Prometheus /metrics and /api/sme/metrics), asserting status codes, error codes, and response shapes. Fixes two defects uncovered while writing these tests: - src/metrics.js omitted several already-implemented exports (safeEqual, extractClientIp, LOOPBACK, normalizeReminderReason, the maturity-reminder counters, contractWasmVersionMismatchAlertsTotal, idempotencyStorageFailureTotal, cacheStoreErrorsTotal, redisCacheFailOpenTotal, sorobanCircuitBreakerStateTransitionsTotal). Several call sites (src/cache/redis.js, src/middleware/cache.js, src/jobs/maturityReminders.js, src/jobs/contractListRefresh.js) destructure these directly and call .inc() unconditionally, so every one of these call sites threw TypeError: Cannot read properties of undefined at runtime — including inside redis.js's documented "never throws" fail-open path. - /api/sme/metrics returned malformed-cursor errors as { error: 'Bad Request', message: err.message }. The response-envelope wrapper in src/app.js (toStandardEnvelope) checks `typeof payloadError === 'string'` before it checks `payload.message`, so it silently replaced the specific cursor error with the generic "Bad Request" label before it reached the client. Fixed by nesting the message under `error.message`, matching the shape the AppError path already round-trips correctly. The same now-fixed pattern is used elsewhere (invoiceRoutes.js, invoiceFile.js, metricsValidation.js) and would benefit from the same fix or a global fix in toStandardEnvelope — left out of scope here to keep this PR focused. Also fixed 3 pre-existing tests in tests/metrics.test.js that called registry.metrics() synchronously and asserted a string; real prom-client's Registry.metrics() returns a Promise (the production metricsHandler already awaited it correctly — only these test assertions were stale). Closes Liquifact#654 Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…#800) Adds a configurable, per-client (X-API-Key / socket-IP fallback) rate limit to POST /api/admin/config and GET /api/admin/config/sections. The limiter is mounted BEFORE the admin auth stack so failed authentication attempts still consume quota (auth-flooding defence). Defaults: 20 requests per 60 s window per client. Operators can tune without rebuild via CONFIG_RATE_LIMIT_WINDOW_MS / CONFIG_RATE_LIMIT_MAX. Reaching the cap returns the project's canonical RFC 7807 problem+json 429 with a precise Retry-After header from express-rate-limit, and a snake_case `retry_hint` field matching src/utils/problemDetails.js. Implementation notes: - New resolveRateLimitStore(scope) helper is shared between createRateLimiter (restored to pre-issue behaviour) and the new adminConfigLimiter, so cluster-safe Redis behaviour stays consistent across scopes (Liquifact#429). - validate: { xForwardedForHeader: false } prevents XFF-spoofed requests from dodging the IP-fallback bucket. - Retry-After is intentionally NOT overridden — the framework value is strictly more accurate than any Math.ceil(windowMs/1000) estimate. Files: - src/middleware/rateLimit.js (new exports + helper) - src/routes/adminConfig.js (mount limiter before adminStack) - .env.example (document the two new env vars) - docs/configuration.md (env-reference rows) - tests/mocks/setup.js (global mock: limiter noops) - tests/unit/adminConfig.rateLimit.test.js (NEW, 19 cases) - PR_DESCRIPTION_754.md (very comprehensive PR body) Closes Liquifact#754
Adds docs/runbook-persistence.md covering configuration, failure modes, alerts, and recovery steps for the durable job-queue persistence subsystem (src/workers/jobPersistence.js). Cross-references the existing persistence.md contract, storage-ops.md, observability, and prometheus alert docs. All config vars, error codes, and recovery steps verified against source. Co-authored-by: YerimahOfTimes <yerimahjr@gmail.com>
* test(persistence): cover success and error paths * fix: address Copilot review comments
Add docs/cors.md covering the full CORS API contract for issue Liquifact#660: - Configuration reference (CORS_ORIGINS, CORS_ALLOWED_ORIGINS, CORS_MAX_AGE) - Origin normalization rules (WHATWG URL parser, case/trailing-slash handling) - Preflight (OPTIONS) request/response shapes - Simple and credentialed request flows - Six labelled request/response examples (allowed, blocked, no-Origin, null origin, preflight success, preflight blocked) - Error code table: CORS_ORIGIN_REJECTED (HTTP 403), JSON body shape, detection snippet for client code - Dev-mode fallback origins - Runtime allowlist reload via reloadCorsOrigins() - Security notes - Full module exports reference for src/config/cors.js Cross-referenced against src/config/cors.js and src/app.js.
…, and audit logging (Liquifact#649) (Liquifact#782) Co-authored-by: geekyfocus <geekyfocus@users.noreply.github.com>
… rules (Liquifact#781) The config.validate() boot gate (src/index.js, src/server.js, src/config/index.js) was already implemented, but src/config/index.test.js had 3 pre-existing failures on a clean checkout: - Asserted JWT_ISSUER/JWT_AUDIENCE default to 'liquifact-platform'/ 'liquifact-client', but the schema has no such defaults and src/middleware/auth.js only enforces these claims when explicitly set. Fixed the assertions to match the intentional optional-with-no-default behavior. - Two tests exercised NODE_ENV=production without PUBLIC_API_BASE_URL, which the superRefine rule requires in production — added the missing env var to both. Also adds dedicated coverage for the PUBLIC_API_BASE_URL production rules (missing / non-HTTPS / loopback / valid), bringing src/config/index.js to 95% statements / 96.8% branches / 100% funcs / 97.3% lines. Closes Liquifact#591 Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
- Create Zod schema (src/schemas/indexerEvent.js) that rejects unknown fields, validates types, and bounds string lengths / numeric ranges. - Update normalizeEvent() to use the schema and throw a structured ValidationError with machine-readable error codes and field-level details. - Add ValidationError class exported from escrowIndexer.js. - Reject unknown query parameters in adminIndexer _parseQuery(). - Add comprehensive tests covering unknown fields, type mismatches, string length boundaries, numeric range boundaries, and structured error codes. Closes Liquifact#668
📝 WalkthroughWalkthroughAPI key authentication now records bounded Prometheus duration and error metrics, emits structured success and failure logs, and documents and tests these observability behaviors without exposing API key material. ChangesAPI key authentication observability
Estimated code review effort: 3 (Moderate) | ~20 minutes Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant authenticateApiKey
participant Response
participant Metrics
participant Logger
authenticateApiKey->>Response: register finish handler
Response->>authenticateApiKey: emit final status
authenticateApiKey->>Metrics: record duration and classified outcome
authenticateApiKey->>Metrics: increment error cause for failures
authenticateApiKey->>Logger: emit structured success or failure log
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Closes Liquifact#766 - Added api_key_auth_duration_seconds histogram with bounded labels (endpoint, method, status, outcome) - Added api_key_auth_errors_total counter with bounded cause labels (unauthorized, forbidden, validation_error, not_found, internal_error) - Added structured request logging via res.on('finish') with duration, status, outcome, and error_type - Exposed metrics through existing /metrics Prometheus endpoint - Added 14 comprehensive tests covering success, client error, and server error paths - Updated README.md with API Key Auth Metrics documentation - No API keys logged or exposed in metric labels - No Authorization headers exposed - No PII logged - Bounded metric label cardinality - Existing authentication behavior preserved
8d0637c to
02c31e5
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/unit/apiKeyAuth.test.js (1)
391-448: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoffTests rely on prom-client's internal
hashMapstorage.
apiKeyAuthDurationSeconds.hashMapand entry.labelsare undocumented internals; prom-client's own changelog notes this storage is being renamed/restructured toLabelMapin an upcoming breaking release. Works fine on the pinned 14.2.0, but will silently break on a future major upgrade.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/apiKeyAuth.test.js` around lines 391 - 448, Replace the metrics assertions in the duration histogram tests with checks through prom-client’s supported public API rather than apiKeyAuthDurationSeconds.hashMap or entry.labels. Update the success, 401, 403, and API-key exclusion cases to inspect collected metric samples and their public label values while preserving the existing expected outcomes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/unit/apiKeyAuth.test.js`:
- Around line 391-448: Replace the metrics assertions in the duration histogram
tests with checks through prom-client’s supported public API rather than
apiKeyAuthDurationSeconds.hashMap or entry.labels. Update the success, 401, 403,
and API-key exclusion cases to inspect collected metric samples and their public
label values while preserving the existing expected outcomes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 1d59b5c5-ec0b-4810-9aad-b29ba208e0df
📒 Files selected for processing (4)
README.mdsrc/metrics.jssrc/middleware/apiKeyAuth.jstests/unit/apiKeyAuth.test.js
There was a problem hiding this comment.
Actionable comments posted: 5
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
tests/mocks/setup.js (1)
18-33: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winClear
invoiceFilesin the mock reset path.invoiceFilesis module-scoped likeinvestorLocks, but the reset helper only trimsinvestorLocks, soinvoice_filesrows can leak between tests.filterInvoiceFilesis also a copy offilterInvestorLocks; a sharedmakeFilter(rows)helper would keep them in sync.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/mocks/setup.js` around lines 18 - 33, Update the mock reset helper to clear the module-scoped invoiceFiles collection alongside investorLocks, preventing invoice_files data from leaking between tests. Refactor filterInvestorLocks and filterInvoiceFiles to use a shared makeFilter(rows) helper while preserving their existing queryWheres filtering behavior.src/routes/invoiceFile.js (1)
63-83: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winPresigned-upload route missed the fixes applied to the sibling routes in this same file.
Two inconsistencies vs. the four routes updated by this PR in the same file:
- Line 73 derives
tenantIdasreq.user?.id || req.user?.sub || 'unknown', omittingreq.user?.tenantId, whilePOST /:id/file,GET /:id/file,GET /:id/file/verify, andPOST /:id/file/verifynow all preferreq.user?.tenantIdfirst. If a caller'sreq.user.tenantIddiffers from.id/.sub, a file uploaded via this presigned-upload flow can end up scoped to a different tenant than lookups via the other routes expect.- Line 77 checks
error.messageagainst a list of short error codes ('INVALID_MIME_TYPE','FILE_TOO_LARGE', ...), butstorage.jssets those strings onerr.code, noterr.message(which is a full sentence). This check can never match, so all storage validation errors here fall through to the generic 500 instead of the intended 400 — the siblingPOST /:id/filehandler in this file correctly checkserr.code(line 120).🐛 Suggested fix
- const tenantId = req.user?.id || req.user?.sub || 'unknown'; + const tenantId = req.user?.tenantId || req.user?.id || req.user?.sub || 'unknown'; const result = await storageService.getPresignedUploadUrl({ tenantId, invoiceId: id, fileName, mimeType, fileSize }); return res.status(201).json({ data: { invoiceId: id, uploadUrl: result.url, fileKey: result.key }, message: 'Presigned upload URL generated' }); } catch (error) { - if (['INVALID_MIME_TYPE','FILE_TOO_LARGE','INVALID_FILENAME','INVALID_TENANT_ID','INVALID_INVOICE_ID','INVALID_EXPIRY'].includes(error.message)) { + if (['INVALID_MIME_TYPE','FILE_TOO_LARGE','INVALID_FILENAME','INVALID_TENANT_ID','INVALID_INVOICE_ID','INVALID_EXPIRY'].includes(error.code)) { return res.status(400).json({ error: 'Bad Request', message: error.message }); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/routes/invoiceFile.js` around lines 63 - 83, Update the presigned-upload handler to derive tenantId with req.user?.tenantId before the existing id/sub fallbacks, matching the sibling file routes. In its catch block, use error.code rather than error.message when matching the storage validation error codes, while preserving the existing 400 responses and generic 500 handling.src/app.js (1)
95-113: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick winKeep the status-aware branch limited to sanitized errors
src/app.js:95— this now returnserr.detail || err.title || err.messagefor anyerr.statusin 400–599, so a non-AppError5xx with a status tag can leak its raw message instead of reaching the redacted 500 fallback. Restrict this path to known-safe errors or use a fixed message for generic 5xxs.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/app.js` around lines 95 - 113, Restrict the status-aware branch in handleInternalError to recognized sanitized AppError instances before returning err.detail, err.title, or err.message. Ensure generic errors with 5xx status tags bypass this response and reach the redacted 500 fallback, or return a fixed safe message for those cases.
🟠 Major comments (26)
src/errors/mapError.js-101-117 (1)
101-117: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winSerialize the normalized generic-error extensions.
mapError()now computescode,retryable, andretryHintfor generic errors, butproblemJsonHandleronly forwards rawAppErrorextensions. Consequently, generic 429/503 responses drop the documented retry contract, and generic 500 responses dropINTERNAL_SERVER_ERROR.
src/errors/mapError.js#L101-L117: update the error-to-response path to passmappedError.code,mappedError.retryable, andmappedError.retryHintintoformatProblemDetails.tests/problems.test.js#L571-L575: update generic-response expectations and add generic 429/503 serialization coverage.docs/RFC7807-Error-Handling.md#L95-L124: align the table’s universal extension and retry-hint claims with the implemented response contract.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/errors/mapError.js` around lines 101 - 117, Update the generic error response path in mapError() to pass mappedError.code, mappedError.retryable, and mappedError.retryHint to formatProblemDetails. In tests/problems.test.js, update generic-response expectations and add serialization coverage for generic 429 and 503 errors. In docs/RFC7807-Error-Handling.md, revise the extension and retry-hint table claims to match the implemented universal response contract.src/middleware/apiKeyAuth.js-139-142 (1)
139-142: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winRemove request IPs from API-key audit logs.
req.ipis PII and directly contradicts the module’s and PR’s no-PII logging guarantee.
src/middleware/apiKeyAuth.js#L139-L142: omitip: req.ipfrom missing-header logs.src/middleware/apiKeyAuth.js#L154-L188: omitip: req.ipfrom invalid, revoked, and scope-rejection logs.src/middleware/apiKeyAuth.js#L201-L213: omitip: req.ipfrom successful-auth logs.src/middleware/apiKeyAuth.js#L11-L13: retain this guarantee only after removing the IP fields.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/middleware/apiKeyAuth.js` around lines 139 - 142, Remove the req.ip field from all API-key audit log objects in src/middleware/apiKeyAuth.js: the missing-header logger at lines 139-142, invalid/revoked/scope-rejection logs at lines 154-188, and successful-auth logs at lines 201-213. Update the no-PII guarantee at lines 11-13 only as needed so it remains accurate after these removals.src/middleware/apiKeyAuth.js-103-112 (1)
103-112: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick winDo not use
req.pathas a Prometheus label.
req.pathincludes concrete route parameters, so identifiers create an unbounded time-series cardinality. Use a fixed middleware label or a bounded route identifier supplied by the caller.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/middleware/apiKeyAuth.js` around lines 103 - 112, Replace the req.path label in the apiKeyAuthDurationSeconds.observe call with a fixed middleware label or bounded route identifier supplied by the caller, while preserving the existing method, status, outcome, and duration values.src/middleware/rateLimit.js-246-252 (1)
246-252: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winDon't use the raw API key as the rate-limit bucket key.
adminConfigKeyGeneratorreturnsapikey_<plaintext-key>. WithresolveRateLimitStore('config')active, that value becomes a live Redis key (rate-limit:config:apikey_<secret>), so admin API keys land in Redis keyspace,KEYS/MONITORoutput, and any dashboard that lists bucket keys — the JSDoc explicitly cites dashboard legibility as a goal. Hash the key instead; bucket isolation is preserved.🔒 Proposed fix
+const crypto = require('node:crypto'); + function adminConfigKeyGenerator(req) { const apiKey = getApiKey(req); if (apiKey) { - return `apikey_${apiKey}`; + // Hash so the plaintext credential never becomes a Redis key / log field. + return `apikey_${crypto.createHash('sha256').update(apiKey).digest('hex').slice(0, 32)}`; } return req.ip || req.socket?.remoteAddress || '127.0.0.1'; }Note this also changes the expectation in
tests/unit/adminConfig.rateLimit.test.js(Lines 242-278), which asserts the plaintext prefix.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/middleware/rateLimit.js` around lines 246 - 252, Update adminConfigKeyGenerator to avoid embedding the plaintext API key in rate-limit bucket keys: hash apiKey with the project’s existing cryptographic utility and return a stable hashed key with an appropriate non-secret prefix, preserving per-key bucket isolation. Update the affected adminConfig rate-limit unit test expectations to assert the hashed format rather than the plaintext key.src/schemas/config.js-39-90 (1)
39-90: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winReplace legacy Zod 3 error options with
errorcallbacks
invalid_type_error,required_error, anderrorMapare ignored in Zod 4.3.6, so these schemas fall back to generic messages on type/required failures. Update the shared helpers and the inline schema definitions (includingruntimeConfigSchema.sectionandconfig) to useerrorinstead.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/schemas/config.js` around lines 39 - 90, Replace the Zod 3 invalid_type_error, required_error, and errorMap options throughout the shared helpers and inline schemas with Zod 4 error callbacks, preserving each field’s existing custom messages for type, required, and validation failures. Update _boundedString, boundedUrl, boundedInt, boundedNumber, runtimeConfigSchema.section, config, and all other inline definitions in the file without changing their validation rules.tsconfig.build.json-5-5 (1)
5-5: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winKeep
rootDironsrcunless the dist entrypoint moves too. Withincludelimited tosrc/**, this emitsdist/src/index.js, butstart:diststill runsnode dist/index.js. Update the dist consumer or revert this change.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tsconfig.build.json` at line 5, Revert the rootDir change in tsconfig.build.json to src so the build continues emitting the entrypoint at dist/index.js, matching the existing start:dist command; only retain a repository-root rootDir if the dist consumer is updated to run the resulting dist/src/index.js path.tests/maturityReminders.test.js-26-32 (1)
26-32: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winInaccurate
normalizeReminderReasonmock is also the subject of its own test block. Both sites stem from one root cause: the inline mock reimplements the helper with wrong precedence and noerr.codehandling, and section 14 then asserts against that reimplementation rather thansrc/metrics.js.
tests/maturityReminders.test.js#L26-L32: align the mock withsrc/metrics.js— checktemplatebeforereject|smtp|recipient, and build the match string fromerr.codepluserr.message.tests/maturityReminders.test.js#L862-L892: remove this describe block; it exercises the mock, and the real helper is already covered bytests/metrics.test.jslines 363-387.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/maturityReminders.test.js` around lines 26 - 32, Align the inline normalizeReminderReason mock in tests/maturityReminders.test.js:26-32 with src/metrics.js by matching template errors before reject|smtp|recipient cases and building the match input from err.code plus err.message. Remove the describe block in tests/maturityReminders.test.js:862-892 because it only tests the mock; the real helper is covered elsewhere.src/schemas/indexerEvent.js-16-33 (1)
16-33: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winTrim before length validation, not after.
.transform(v => v.trim())runs after.min/.max, soeventId: ' 'passesmin(1)and normalizes to'', and a 257-char value with trailing whitespace is rejected despite trimming to 256.normalizeEventthen persists an emptyeventId/eventType. Preferz.string().trim()up front (andinvoiceId's regex will then apply to the trimmed value).🐛 Proposed fix
eventId: z .string({ invalid_type_error: 'eventId must be a string' }) + .trim() .min(1, { message: 'eventId is required' }) - .max(256, { message: 'eventId must not exceed 256 characters' }) - .transform((v) => v.trim()), + .max(256, { message: 'eventId must not exceed 256 characters' }), invoiceId: z .string({ invalid_type_error: 'invoiceId must be a string' }) + .trim() .regex(INVOICE_ID_REGEX, { message: 'invoiceId must be 1-128 alphanumeric/underscore/hyphen characters', - }) - .transform((v) => v.trim()), + }), eventType: z .string({ invalid_type_error: 'eventType must be a string' }) + .trim() .min(1, { message: 'eventType is required' }) - .max(128, { message: 'eventType must not exceed 128 characters' }) - .transform((v) => v.trim()), + .max(128, { message: 'eventType must not exceed 128 characters' }),🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/schemas/indexerEvent.js` around lines 16 - 33, Update the eventId, invoiceId, and eventType schemas in the indexer event definition to trim input before applying length or regex validation. Replace the trailing transform-based normalization with Zod’s trim preprocessing, preserving the existing validation messages and limits so whitespace-only values fail and trimmed values are validated.src/schemas/indexerEvent.js-9-13 (1)
9-13: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winRestore checksum validation for
contractIdnormalizeEvent()uses this schema directly, so a regex-shaped but checksum-invalid Stellar contract address will still be accepted and written through. ReuseStrKey.isValidContract()here (or the existingisValidStellarContractIdhelper) instead of the regex alone.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/schemas/indexerEvent.js` around lines 9 - 13, Update contractIdSchema to validate contract addresses with StrKey.isValidContract() or the existing isValidStellarContractId helper, rather than relying solely on CONTRACT_ID_REGEX. Preserve the existing validation message and ensure normalizeEvent() rejects regex-matching addresses with invalid checksums.src/services/kycService.js-649-706 (1)
649-706: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winNon-object JSON bodies crash with a
TypeError.
JSON.parse('null')(or a bare number/string) parses successfully, so line 704 dereferencesdata.recordIdonnulland throws an untypedTypeErroroutside the hardened error contract. Validate the parsed shape before use.🐛 Proposed fix
let data; try { data = JSON.parse(responseText); } catch (parseErr) { throw new KycProviderError( `KYC provider returned non-JSON response: ${parseErr.message}`, { status: 502, retryable: false, code: 'invalid_response_body' }, ); } + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new KycProviderError( + 'KYC provider returned an unexpected response shape', + { status: 502, retryable: false, code: 'invalid_response_body' }, + ); + } + return data;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/kycService.js` around lines 649 - 706, Validate the parsed result in the KYC provider operation before dereferencing it in the recordId, verifiedAt, and status assignments. Accept only non-null object JSON bodies (excluding arrays); otherwise throw a non-retryable KycProviderError with the existing invalid-response contract, so primitive or null responses do not produce an untyped TypeError.src/services/kycService.js-620-647 (1)
620-647: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
verifySignaturecan throwRangeErroron a malformed provider signature, escaping the typed-error contract.
verifySignatureends withcrypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))(src/services/webhooks.js:314-318). A provider (or MITM) supplying a short, odd-length, or non-hexv1=value yields a buffer of different length andtimingSafeEqualthrowsRangeErrorinstead of returning{valid:false}. That raw error propagates out ofoperation, is classified asnon-retryable, and is re-thrown as a plainError— not the documentedKycProviderError{code:'invalid_response_signature'}. Fail-closed still holds viagetKycStatus, but direct callers ofverifyWithExternalProviderlose the typed contract.🛡️ Proposed fix
if (responseSig) { - const verification = verifySignature(config.apiSecret, responseText, responseSig); + let verification; + try { + verification = verifySignature(config.apiSecret, responseText, responseSig); + } catch (sigErr) { + verification = { valid: false, error: 'Malformed signature header' }; + } if (!verification.valid) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/kycService.js` around lines 620 - 647, Update the response-signature verification flow in verifyWithExternalProvider so malformed signatures cannot let verifySignature’s RangeError escape. Catch or pre-validate failures around verifySignature, treat them as invalid verification, and preserve the existing KycProviderError with code invalid_response_signature for all invalid signatures.src/services/storage.js-69-89 (1)
69-89: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
MAX_FILE_SIZEduplicates and diverges from the validatedINVOICE_FILE_MAX_SIZEconfig.
storage.jsreadsprocess.env.INVOICE_FILE_MAX_SIZEdirectly and parses it with its ownparseSize(), instead of usinggetInvoiceFileMaxSize()fromsrc/config/index.js. The two implementations disagree on strictness:InvoiceFileMaxSizeSchema(config) rejects invalid values by throwing, whileparseSize()here silently falls back toDEFAULT_MAX_FILE_SIZEon a non-match. This means the size limit enforced on the HTTP body (express.raw({ limit: UPLOAD_SIZE_LIMIT }), viagetInvoiceFileMaxSize()) and the size limit enforced insideStorageService(this.maxFileSize, via this local parser) can silently diverge for the same misconfigured env value — one path fails fast, the other quietly falls back to a possibly different default, and there's an extra untracked fallback (BODY_LIMIT_INVOICE) not present in the config schema at all.♻️ Suggested fix: single source of truth
-function parseSize(sizeStr) { - if (typeof sizeStr !== 'string' || sizeStr.trim() === '') { - return DEFAULT_MAX_FILE_SIZE; - } - const match = sizeStr.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i); - if (!match) { - return DEFAULT_MAX_FILE_SIZE; - } - const value = parseFloat(match[1]); - const unit = (match[2] || 'b').toLowerCase(); - const multipliers = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 }; - return Math.floor(value * multipliers[unit]); -} - -const MAX_FILE_SIZE = parseSize(process.env.INVOICE_FILE_MAX_SIZE || process.env.BODY_LIMIT_INVOICE || '5mb'); +const { getInvoiceFileMaxSize } = require('../config'); + +function parseSize(sizeStr) { + // reuse same regex/units as InvoiceFileMaxSizeSchema + ... +} + +const MAX_FILE_SIZE = parseSize(getInvoiceFileMaxSize());🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/storage.js` around lines 69 - 89, Remove the local parseSize-based MAX_FILE_SIZE configuration and use getInvoiceFileMaxSize() from src/config/index.js as the single source of truth for StorageService limits. Eliminate the untracked BODY_LIMIT_INVOICE fallback and ensure invalid INVOICE_FILE_MAX_SIZE values follow the config schema’s existing validation behavior.src/services/storage.js-375-394 (1)
375-394: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winSwallowed errors bypass the PDF-header validation instead of failing safe.
The
catchblock only re-throws whene.code === 'INVALID_PDF_HEADER'; any other failure from the rangedGetObjectCommand(network error, permission denial, malformed response) is silently discarded, and the function proceeds to returnvalidas if the header check had passed. This defeats the purpose of the check whenever the range-GET fails for any external reason.🛡️ Suggested fix: don't swallow unexpected errors
} catch (e) { if (e.code === 'INVALID_PDF_HEADER') { throw e; } + logger.error({ err: e, key }, 'PDF header verification failed unexpectedly'); + throw e; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/storage.js` around lines 375 - 394, Update the PDF validation block around the ranged GetObjectCommand so every failure from s3Client.send or response processing propagates instead of being swallowed. Preserve the existing rethrow behavior for INVALID_PDF_HEADER and rethrow all other caught errors as well, preventing the function from returning valid when validation cannot complete.src/routes/apiKeys.js-17-17 (1)
17-17: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
POSTreturns 201 but nothing is actually registered.Created entries land only in the module-level
runtimeEntriesMap: they are not visible toauthenticateApiKey(which reads the env-backed registry), are not shared across workers, and vanish on restart — yet the response claims'API key created successfully.'. Either persist through the registry contract or make the endpoint read-only / clearly test-only and keep it out of the mounted app.Also worth noting: the
catchmaps every thrown error to 422, so an unexpected runtime failure is reported as a client validation error, andvalidateEntry's messages leak the internalAPI_KEYS[0]:prefix into the response.Also applies to: 65-91
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/routes/apiKeys.js` at line 17, Update the POST handler and module-level runtimeEntries so created API keys use the same persistent registry consumed by authenticateApiKey, or remove the endpoint from the mounted app if it is only test-only; do not return a success response for memory-only registration. In the POST catch path, distinguish client validation failures from unexpected runtime errors instead of mapping every exception to 422. Sanitize validateEntry messages before returning them so internal API_KEYS[0]: prefixes are not exposed.src/metrics.js-1077-1190 (1)
1077-1190: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winPersistence metrics block is entirely unreferenced and fails lint.
PERSISTENCE_STATUS_CLASS_ENUM,PERSISTENCE_CAUSE_ENUM, the threenormalizePersistence*helpers, and all threepersistenceRequest*metrics are defined but never used or exported (8 ESLintno-unused-varserrors). Either add them tomodule.exportssosrc/middleware/persistenceMetrics.jscan consume them, or drop the block.Note also that
normalizePersistenceCause(null, undefined)returns'internal'rather than'none', sinceNumber(undefined)isNaNandNaN < 400is false — worth guarding when the callers land.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/metrics.js` around lines 1077 - 1190, Expose the persistence metrics block for consumption by exporting PERSISTENCE_STATUS_CLASS_ENUM, PERSISTENCE_CAUSE_ENUM, all normalizePersistence* helpers, and the three persistenceRequest* metrics from the module. Ensure normalizePersistenceCause treats null or undefined status with no error as the success cause “none” before numeric status comparisons. Remove nothing else from the persistence metrics implementation.Source: Linters/SAST tools
src/middleware/stacks.js-33-36 (1)
33-36: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winPresence check contradicts the documented branching contract.
docs/request-lifecycle-middleware-order.md(Lines 51-54, added in this PR) states that empty-string and array-valuedx-api-keyheaders are non-branching and must fall back to JWT auth.hasOwnPropertybranches on any present header, soX-API-Key: ''now routes to_adminApiKeyMiddlewareand yields 401 instead of falling through toauthenticateToken. Notesrc/routes/adminWebhooks.jsLine 62 still uses the truthiness check, so the two admin auth paths disagree.🐛 Proposed fix matching the documented contract
- const apiKeyHeaderPresent = Object.prototype.hasOwnProperty.call(req.headers, 'x-api-key'); - - if (apiKeyHeaderPresent) { + const rawApiKey = req.headers['x-api-key']; + const hasUsableApiKey = typeof rawApiKey === 'string' && rawApiKey.trim() !== ''; + + if (hasUsableApiKey) { return _adminApiKeyMiddleware(req, res, next); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/middleware/stacks.js` around lines 33 - 36, Replace the hasOwnProperty-based branching in the middleware stack with a truthiness check for a valid scalar x-api-key value, so empty-string and array-valued headers fall through to authenticateToken. Align this behavior with the existing check in the admin authentication path while preserving _adminApiKeyMiddleware for usable API key headers.src/routes/adminWebhooks.js-35-51 (1)
35-51: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winRestore the dead-letter route helpers
GET /dead-lettersstill usesresponseHelper,MAX_LIMIT,DEFAULT_LIMIT,decodeCursor,encodeCursor,CursorError,SAFE_COLUMNS,DEAD_LETTER_SORT_FIELD, andredactRow, but this module no longer defines them. Any request to that route will hit aReferenceErrorbefore returning a response; add the missing imports/constants back or move the helpers into this file.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/routes/adminWebhooks.js` around lines 35 - 51, The dead-letter route in adminWebhooks.js references undefined helpers and constants, causing ReferenceErrors. Restore or import responseHelper, MAX_LIMIT, DEFAULT_LIMIT, decodeCursor, encodeCursor, CursorError, SAFE_COLUMNS, DEAD_LETTER_SORT_FIELD, and redactRow so GET /dead-letters can execute without changing its existing behavior.src/services/indexerService.js-170-176 (1)
170-176: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy liftUnconditional
COUNT(*)defeats the point of keyset pagination.Every request — including cursor mode, whose comment at Line 202 claims
hasMoreis derived "without a second COUNT query" — runs a full filter-aware count overescrow_events, which the file header describes as growing unboundedly. On Postgres that is a sequential/index-only scan proportional to table size on each page fetch.Consider computing
totalonly in offset mode (wheretotalPagesneeds it), or gating it behind an opt-inincludeTotalflag.♻️ Proposed change
- const countQ = baseQuery(); - _applyFilters(countQ, filters); - const countRow = await countQ.count('* as total').first(); - const total = parseInt(countRow.total ?? countRow['count(*)'] ?? 0, 10); - const useCursor = Boolean(pagination.cursor); + + let total = null; + if (!useCursor) { + const countQ = baseQuery(); + _applyFilters(countQ, filters); + const countRow = await countQ.count('* as total').first(); + total = parseInt(countRow.total ?? countRow['count(*)'] ?? 0, 10); + }Note this changes the documented
meta.totalcontract for cursor mode, so update the Swagger block insrc/routes/adminIndexer.jsandtests/indexerListing.test.jsaccordingly.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/indexerService.js` around lines 170 - 176, Make the count query in the indexer listing flow conditional on offset pagination, so cursor requests avoid executing countQ and set meta.total to the cursor-mode contract value while preserving totalPages for offset requests. Update the cursor pagination documentation in the adminIndexer Swagger block and the corresponding expectations in indexerListing.test.js to reflect the changed meta.total behavior.tests/jobPersistence.listJobs.test.js-177-203 (1)
177-203: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winShared in-memory Knex fake mis-evaluates
orWhere, invalidating every cursor-traversal assertion. Both files copy the same evaluator, which ANDs theor_groupwith the preceding comparison instead of ORing it.listJobsemitssortField < v OR (sortField = v AND id < cursorId), so under the fake no row can satisfy both branches and pages after the first come back empty.
tests/jobPersistence.listJobs.test.js#L177-L203: change theor_groupbranch to disjunction —result = result || orResult(semantically, accumulate the non-group conditions and OR the group in), then confirm the traversal expectations at Lines 453-528 and 695-712 pass.tests/adminJobs.route.test.js#L185-L210: apply the same OR fix inevalConds, and re-check the multi-page tests at Lines 460-503, which currently dereferencep3.body.metaafter acursor=nullrequest would 400.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/jobPersistence.listJobs.test.js` around lines 177 - 203, The shared in-memory evaluators incorrectly AND each or_group with prior conditions; update evalConditions in tests/jobPersistence.listJobs.test.js lines 177-203 and evalConds in tests/adminJobs.route.test.js lines 185-210 to accumulate the OR group as a disjunction, preserving the intended cursor predicate behavior. Recheck the traversal expectations in tests/jobPersistence.listJobs.test.js lines 453-528 and 695-712, and the multi-page tests in tests/adminJobs.route.test.js lines 460-503; these sites require no direct changes.src/utils/healthCursorPagination.js-36-94 (1)
36-94: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy liftTwo copies of the HMAC cursor implementation, already diverging. Both files independently implement secret resolution, base64url+HMAC-SHA256 signing, and constant-time verification; the copies differ in dev-secret constant, TTL enforcement, and the zero-length signature guard, so a future fix to one will silently miss the other.
src/utils/healthCursorPagination.js#L36-L94: extract_resolveSecret/_sign/encode/decode into a shared module (e.g.src/utils/signedCursor.js) parameterised by payload validator and dev-secret label, and re-export the health-specific wrappers from it.src/workers/jobPersistence.js#L72-L165: replace_resolveJobCursorSecret/encodeJobCursor/decodeJobCursorwith calls into that shared module, keepingJobCursorErrorand theLIST_JOBS_SORT_FIELDSpayload check as the job-specific validator, and pick up the TTL enforcement the health version already has.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/healthCursorPagination.js` around lines 36 - 94, Extract the duplicated secret resolution, HMAC signing, encoding, decoding, constant-time verification, and TTL handling into a shared signed-cursor module parameterized by payload validation and development-secret configuration. In src/utils/healthCursorPagination.js lines 36-94, replace the local _resolveSecret, _sign, and cursor encode/decode logic with health-specific wrappers using the shared module. In src/workers/jobPersistence.js lines 72-165, replace _resolveJobCursorSecret and the job cursor encode/decode implementation with the shared module while preserving JobCursorError, LIST_JOBS_SORT_FIELDS validation, and enabling the shared TTL enforcement.src/routes/sme/metrics.js-116-150 (1)
116-150: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winInconsistent error envelope for cursor errors vs. tenant-validation errors.
validateMetricsRequest(Line 118) sends{ error: 'Bad Request', message: '...' }/{ error: 'Forbidden', message: '...' }on failure, but theCursorErrorbranch here (Lines 144-148) sends a completely different shape:{ error: { message: err.message } }. Both are 400/403 failures on the same endpoint, yet callers must handle two incompatible error contracts. TheCursorErrorresponse also drops thedata/meta/timestampenvelope fields present in every success response, unlike the RFC7807-style shape used for the equivalentCursorErrorcase insrc/app.js's/api/invoiceshandler.🔧 Suggested fix — align with the existing tenant-validation shape
if (err.name === 'CursorError' || err instanceof CursorError) { return res.status(400).json({ - error: { message: err.message }, + error: 'Bad Request', + message: err.message, }); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/routes/sme/metrics.js` around lines 116 - 150, Update the CursorError handling in the /metrics route to use the same error response envelope as validateMetricsRequest, including the established error status/message fields and required data, meta, and timestamp fields. Preserve the 400 status and cursor error message while aligning with the endpoint’s existing validation contract.tests/invoice.pagination.test.js-59-74 (1)
59-74: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winFixtures only use string ids, hiding the
encodeCursorid-type contract.
makeRow('a')producesid: 'a', soencodeCursor's "id must be a non-empty string" guard is never exercised with the numeric ids theinvoicestable actually produces (seegetSmeInvoiceList, which stringifieslastRow.id). Add a case withmakeRow(1)andhasMore=trueto cover the real shape — see the related comment onsrc/services/invoiceService.js.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/invoice.pagination.test.js` around lines 59 - 74, Extend the pagination fixtures around makeRow and makeCursor with a case using makeRow(1) and hasMore=true, verifying cursor creation succeeds for the numeric database id shape after the service stringifies it. Keep the existing string-id coverage unchanged and target the behavior exercised by getSmeInvoiceList.src/services/invoiceService.js-754-762 (1)
754-762: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winTiebreaker comparison coerces
idto an integer and silently falls back to0.
parseInt(String(cursorData.id), 10) || 0yields0for any non-numeric id (UUID/prefixed string), makingid < 0always false. Rows sharing the samecreated_atas the cursor row are then dropped from the next page. It also compares a numeric literal against a possibly text column. Compare the id in its native form instead of coercing, matching the encode side which storesString(lastRow.id).🐛 Proposed fix
- this.where('created_at', cursorData.sortValue) - .andWhere('id', '<', parseInt(String(cursorData.id), 10) || 0); + this.where('created_at', cursorData.sortValue) + .andWhere('id', '<', cursorData.id);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/invoiceService.js` around lines 754 - 762, Update the cursor tiebreaker in the dataQuery filter to compare id using cursorData.id in its native string form, removing parseInt and the fallback to 0. Preserve the existing created_at and id ordering logic, matching the String(lastRow.id) representation used when encoding the cursor.src/services/invoiceStateMachine.js-226-252 (1)
226-252: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
reasonlength bound is only enforced for terminal targets, not universally.
MAX_TRANSITION_REASON_LENGTHis only checked insideTERMINAL_REASON_REQUIRED_STATES.includes(targetState). A transition toapprovedorlinked_escrowwith an arbitrarily longreasonbypassesTRANSITION_REASON_TOO_LONGentirely and gets persisted verbatim into the audit-log metadata. This contradicts the documented contract ("capped at 1024 characters", seedocs/invoice-lifecycle-api.mdline 44 anddocs/invoice-state.mdline 236) and the independent bound declared insrc/schemas/invoiceState.js.🐛 Proposed fix
const reason = normalizeTransitionReason(rawReason); if (isTerminalState(currentState)) { return { isValid: false, error: `Cannot transition from terminal state: ${currentState}`, code: 'TERMINAL_STATE', }; } + if (reason && reason.length > MAX_TRANSITION_REASON_LENGTH) { + return { + isValid: false, + error: `Transition reason must be ${MAX_TRANSITION_REASON_LENGTH} characters or fewer`, + code: 'TRANSITION_REASON_TOO_LONG', + }; + } + if (TERMINAL_REASON_REQUIRED_STATES.includes(targetState)) { if (!reason) { return { isValid: false, error: `Reason is required for terminal transition to ${targetState}`, code: 'MISSING_TRANSITION_REASON', }; } - - if (reason.length > MAX_TRANSITION_REASON_LENGTH) { - return { - isValid: false, - error: `Transition reason must be ${MAX_TRANSITION_REASON_LENGTH} characters or fewer`, - code: 'TRANSITION_REASON_TOO_LONG', - }; - } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/invoiceStateMachine.js` around lines 226 - 252, Enforce MAX_TRANSITION_REASON_LENGTH for every transition reason in the transition validation flow, not only when TERMINAL_REASON_REQUIRED_STATES includes targetState. Preserve the existing missing-reason validation for required terminal states, and return TRANSITION_REASON_TOO_LONG whenever a provided reason exceeds the limit before it can be persisted.tests/invoice.stateValidation.test.js-611-630 (1)
611-630: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winRoute integration tests call the wrong endpoint
postTransition()sends requests to/api/invoices/transition, butsrc/routes/invoiceStateRoutes.jsonly exposesPOST /:id/transitionunder/api/invoices. These cases will 404 instead of exercising the transition handler or returning the assertedrequiresKYC/ RFC 7807 payloads. Update the helper/tests to include an invoice id.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/invoice.stateValidation.test.js` around lines 611 - 630, The route integration helper postTransition() targets the collection endpoint instead of the id-scoped transition route. Update postTransition() and its verified, funded, and settled callers to include a valid invoice id so requests reach POST /:id/transition under /api/invoices and preserve the existing assertions.src/schemas/invoiceState.js-387-399 (1)
387-399: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winUpdate the integration test route
src/routes/invoiceStateRoutes.jsonly exposesPOST /api/invoices/:id/transition(mounted under/api/invoices), so the/api/invoices/transitionassertions intests/invoice.stateValidation.test.jsmiss the real handler. Point those tests at an invoice id or they won’t exercise this route.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/schemas/invoiceState.js` around lines 387 - 399, Update the integration tests in invoice.stateValidation.test.js to call the mounted POST /api/invoices/:id/transition endpoint using a valid invoice id, replacing assertions that target /api/invoices/transition. Keep the existing validation expectations unchanged while ensuring requests reach the handler defined by invoiceStateRoutes.js.
| function adminAuth(req, res, next) { | ||
| if (req.headers['x-api-key']) { | ||
| return _adminApiKeyMiddleware(req, res, next); | ||
| } | ||
| return out; | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
adminAuth returns an undefined identifier and is never applied to any route.
Line 65 return out; references a nonexistent binding — the JWT fallback was lost in editing, so any non-API-key request throws ReferenceError: out is not defined. Worse, ESLint reports both authenticateToken and adminAuth as unused, meaning none of the four admin routes below actually attach auth, directly contradicting the @fileoverview claim that every route requires an admin JWT or API key. /api/admin/webhooks/* (replay, resolve, dead-letter listing) is currently open to anonymous callers.
🔒 Proposed fix
function adminAuth(req, res, next) {
if (req.headers['x-api-key']) {
return _adminApiKeyMiddleware(req, res, next);
}
- return out;
+ return authenticateToken(req, res, next);
}Then attach it (plus tenant extraction) to each route, e.g.:
router.get('/dead-letters', adminAuth, extractTenant, async (req, res, next) => { /* … */ });
router.post('/replay/:id', adminAuth, extractTenant, async (req, res) => { /* … */ });
router.post('/replay', adminAuth, extractTenant, async (req, res) => { /* … */ });
router.post('/resolve/:id', adminAuth, extractTenant, async (req, res) => { /* … */ });📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function adminAuth(req, res, next) { | |
| if (req.headers['x-api-key']) { | |
| return _adminApiKeyMiddleware(req, res, next); | |
| } | |
| return out; | |
| } | |
| function adminAuth(req, res, next) { | |
| if (req.headers['x-api-key']) { | |
| return _adminApiKeyMiddleware(req, res, next); | |
| } | |
| return authenticateToken(req, res, next); | |
| } |
🧰 Tools
🪛 ESLint
[error] 61-61: 'adminAuth' is defined but never used. Allowed unused vars must match /^_/u.
(no-unused-vars)
[error] 65-65: 'out' is not defined.
(no-undef)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/routes/adminWebhooks.js` around lines 61 - 66, Fix adminAuth so
non-API-key requests fall back to authenticateToken instead of returning the
undefined out identifier, then attach adminAuth and extractTenant to every admin
webhook route, including dead-letter listing, replay, and resolve handlers.
Ensure all four routes require either an admin API key or JWT plus tenant
extraction.
Source: Linters/SAST tools
| function listApiKeysHandler(req, res) { | ||
| const entries = buildEntries(req.app.locals?.env || process.env); | ||
|
|
||
| return res.status(200).json({ | ||
| data: entries, | ||
| count: entries.length, | ||
| message: 'API keys retrieved successfully.', | ||
| }); | ||
| } | ||
|
|
||
| function getApiKeyHandler(req, res) { | ||
| const entry = getEntryByKey(req.params.key, req.app.locals?.env || process.env); | ||
|
|
||
| if (!entry) { | ||
| return res.status(404).json({ | ||
| error: 'API key not found.', | ||
| code: 'NOT_FOUND', | ||
| }); | ||
| } | ||
|
|
||
| return res.status(200).json({ | ||
| data: entry, | ||
| message: 'API key retrieved successfully.', | ||
| }); | ||
| } | ||
|
|
||
| router.get('/api-keys', listApiKeysHandler); | ||
| router.post('/api-keys', idempotencyMiddleware, createApiKeyHandler); | ||
| router.get('/api-keys/:key', getApiKeyHandler); | ||
|
|
||
| router.get('/keys', listApiKeysHandler); | ||
| router.post('/keys', idempotencyMiddleware, createApiKeyHandler); | ||
| router.get('/keys/:key', getApiKeyHandler); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm whether the apiKeys router is mounted in the production app and with what auth.
rg -nP --type=js -C4 "routes/apiKeys" src/
rg -nP --type=js -C3 "adminStack|adminAuth|authenticateApiKey" src/routes/apiKeys.jsRepository: Ukorstack/Liquifact-backend
Length of output: 1067
Protect API-key routes and redact secrets. listApiKeysHandler and getApiKeyHandler return plaintext key values, and these routes are mounted without any auth middleware. Gate them behind admin auth and return only a masked/fingerprinted key instead of the raw secret.
🧰 Tools
🪛 ESLint
[error] 93-94: Missing JSDoc comment.
(jsdoc/require-jsdoc)
[error] 103-104: Missing JSDoc comment.
(jsdoc/require-jsdoc)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/routes/apiKeys.js` around lines 93 - 125, Protect the API-key list and
lookup routes by applying the existing admin-auth middleware to both `/api-keys`
and `/keys` route families, including parameterized endpoints. Update
`listApiKeysHandler` and `getApiKeyHandler` to replace each plaintext `key` with
the established masked or fingerprinted representation before responding, while
preserving the existing response structure and status behavior.
| // Fetch the full ordered list of health check records. | ||
| const allChecks = await listHealthChecks(); | ||
|
|
||
| // Apply keyset filtering when a cursor was supplied. | ||
| // Records are ordered by (timestamp ASC, id ASC); the cursor points to the | ||
| // last item on the previous page so we skip everything up to and including | ||
| // that item. | ||
| let filteredChecks; | ||
| if (afterTimestamp !== null && afterId !== null) { | ||
| const cursorIdx = allChecks.findIndex( | ||
| (c) => c.timestamp === afterTimestamp && c.id === afterId, | ||
| ); | ||
|
|
||
| if (cursorIdx === -1) { | ||
| // The cursor pointed to a record that no longer exists in the current | ||
| // snapshot (e.g. a check was removed). Return an empty page so the | ||
| // caller knows it has reached the end rather than silently restarting. | ||
| filteredChecks = []; | ||
| } else { | ||
| filteredChecks = allChecks.slice(cursorIdx + 1); | ||
| } | ||
| } else { | ||
| filteredChecks = allChecks; | ||
| } | ||
|
|
||
| // Slice to the requested page size + 1 to detect whether more pages exist. | ||
| const page = filteredChecks.slice(0, limit); | ||
| const hasMore = filteredChecks.length > limit; | ||
|
|
||
| // Build the next cursor from the last item on this page. | ||
| let nextCursor = null; | ||
| if (hasMore && page.length > 0) { | ||
| const last = page[page.length - 1]; | ||
| nextCursor = encodeHealthCursor({ timestamp: last.timestamp, id: last.id }); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Cursor pagination can never advance past page 1.
listHealthChecks() is called without snapshotTime, so it stamps every record with a fresh new Date(Date.now()).toISOString() on each request (see src/services/health.js Lines 564-566). The cursor encodes the previous request's timestamp, so on the follow-up request findIndex((c) => c.timestamp === afterTimestamp && c.id === afterId) never matches and the handler returns an empty page with hasMore: false — clients silently lose every record after limit.
Either carry the snapshot instant in the cursor and pass it back into listHealthChecks({ snapshotTime }), or key the cursor on id alone (the roster is a fixed, ordered set within a snapshot).
🐛 Minimal fix: match on the stable `id`
- const cursorIdx = allChecks.findIndex(
- (c) => c.timestamp === afterTimestamp && c.id === afterId,
- );
+ const cursorIdx = allChecks.findIndex((c) => c.id === afterId);Secondary concern on the same path: each page re-executes all six live dependency probes (Soroban RPC, DB, KYC, storage, reconciliation) and throws away everything outside the page, so paginating amplifies upstream load on an endpoint with no auth in front of it. Consider caching the snapshot for a short TTL keyed by the cursor's instant.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Fetch the full ordered list of health check records. | |
| const allChecks = await listHealthChecks(); | |
| // Apply keyset filtering when a cursor was supplied. | |
| // Records are ordered by (timestamp ASC, id ASC); the cursor points to the | |
| // last item on the previous page so we skip everything up to and including | |
| // that item. | |
| let filteredChecks; | |
| if (afterTimestamp !== null && afterId !== null) { | |
| const cursorIdx = allChecks.findIndex( | |
| (c) => c.timestamp === afterTimestamp && c.id === afterId, | |
| ); | |
| if (cursorIdx === -1) { | |
| // The cursor pointed to a record that no longer exists in the current | |
| // snapshot (e.g. a check was removed). Return an empty page so the | |
| // caller knows it has reached the end rather than silently restarting. | |
| filteredChecks = []; | |
| } else { | |
| filteredChecks = allChecks.slice(cursorIdx + 1); | |
| } | |
| } else { | |
| filteredChecks = allChecks; | |
| } | |
| // Slice to the requested page size + 1 to detect whether more pages exist. | |
| const page = filteredChecks.slice(0, limit); | |
| const hasMore = filteredChecks.length > limit; | |
| // Build the next cursor from the last item on this page. | |
| let nextCursor = null; | |
| if (hasMore && page.length > 0) { | |
| const last = page[page.length - 1]; | |
| nextCursor = encodeHealthCursor({ timestamp: last.timestamp, id: last.id }); | |
| } | |
| // Fetch the full ordered list of health check records. | |
| const allChecks = await listHealthChecks(); | |
| // Apply keyset filtering when a cursor was supplied. | |
| // Records are ordered by (timestamp ASC, id ASC); the cursor points to the | |
| // last item on the previous page so we skip everything up to and including | |
| // that item. | |
| let filteredChecks; | |
| if (afterTimestamp !== null && afterId !== null) { | |
| const cursorIdx = allChecks.findIndex((c) => c.id === afterId); | |
| if (cursorIdx === -1) { | |
| // The cursor pointed to a record that no longer exists in the current | |
| // snapshot (e.g. a check was removed). Return an empty page so the | |
| // caller knows it has reached the end rather than silently restarting. | |
| filteredChecks = []; | |
| } else { | |
| filteredChecks = allChecks.slice(cursorIdx + 1); | |
| } | |
| } else { | |
| filteredChecks = allChecks; | |
| } | |
| // Slice to the requested page size + 1 to detect whether more pages exist. | |
| const page = filteredChecks.slice(0, limit); | |
| const hasMore = filteredChecks.length > limit; | |
| // Build the next cursor from the last item on this page. | |
| let nextCursor = null; | |
| if (hasMore && page.length > 0) { | |
| const last = page[page.length - 1]; | |
| nextCursor = encodeHealthCursor({ timestamp: last.timestamp, id: last.id }); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/routes/health.js` around lines 117 - 151, Update the cursor matching
logic in the health route to use the stable record id rather than requiring an
exact timestamp match, so pagination can advance across requests despite
refreshed timestamps. Preserve the existing ordered slicing and empty-page
behavior when the cursor id is absent; do not address the separate snapshot
caching concern.
| const runtimeConfigSchema = z | ||
| .object({ | ||
| /** The configuration section being written. */ | ||
| section: z.enum( | ||
| /** @type {[string, ...string[]]} */ (CONFIG_SECTIONS), | ||
| { | ||
| invalid_type_error: 'section must be a string', | ||
| required_error: 'section is required', | ||
| errorMap: () => ({ | ||
| message: `section must be one of: ${CONFIG_SECTIONS.join(', ')}`, | ||
| }), | ||
| }, | ||
| ), | ||
|
|
||
| /** Section-specific configuration payload. Validated per-section below. */ | ||
| config: z | ||
| .record(z.unknown(), { invalid_type_error: 'config must be an object' }) | ||
| .refine((v) => v !== null && typeof v === 'object' && !Array.isArray(v), { | ||
| message: 'config must be a plain object', | ||
| }), | ||
| }) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n '"zod"' package.json
rg -n 'invalid_type_error|required_error|errorMap|ZodIssueCode|z\.record\(' src/schemas/ | head -50Repository: Ukorstack/Liquifact-backend
Length of output: 4528
src/schemas/config.js needs a Zod 4 migration. Update the module to the Zod 4 API:
src/schemas/config.js#L39-L90and the inline schemas atL113,L120-L123,L156,L189,L231-L234: replaceinvalid_type_error/required_error/errorMapwitherror.src/schemas/config.js#L270-L283: replacecode: z.ZodIssueCode.customwithcode: 'custom'.src/schemas/config.js#L316-L336: change the record schema toz.record(z.string(), z.unknown()); the current call shape is not valid for Zod 4.
📍 Affects 1 file
src/schemas/config.js#L316-L336(this comment)src/schemas/config.js#L270-L283src/schemas/config.js#L39-L90
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/schemas/config.js` around lines 316 - 336, Update src/schemas/config.js
across lines 39-90, 113, 120-123, 156, 189, 231-234, 270-283, and 316-336 for
Zod 4 compatibility: replace legacy invalid_type_error, required_error, and
errorMap options with the error API; use the string literal custom issue code
instead of z.ZodIssueCode.custom; and change runtimeConfigSchema.config to
z.record(z.string(), z.unknown()) while preserving its plain-object validation.
| const rows = await dataQ.limit(limit + 1); | ||
| const hasMore = rows.length > limit; | ||
| const data = hasMore ? rows.slice(0, limit) : rows; | ||
|
|
||
| let nextCursor = null; | ||
| if (hasMore && data.length > 0) { | ||
| const lastRow = data[data.length - 1]; | ||
| nextCursor = encodeCursor({ | ||
| sortField, | ||
| sortValue: lastRow[sortField], | ||
| id: lastRow.id, | ||
| }); | ||
| } | ||
|
|
||
| return { data, meta: { total, limit, hasMore, nextCursor } }; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
Inconsistent id typing across the cursor encode/decode boundary. encodeCursor requires a non-empty string id and decodeCursor returns one, but this file passes raw ids in one function and integer-coerces them in the other; the tests only use string ids, so neither path is covered for the real numeric invoices.id.
src/services/invoiceService.js#L249-L263: passid: String(lastRow.id)toencodeCursor(apply the same change in the offset branch at Lines 281-285) so a non-string id cannot throw a genericErrorand surface as a 500.src/services/invoiceService.js#L754-L762: compare the tiebreaker as.andWhere('id', '<', cursorData.id)instead ofparseInt(String(cursorData.id), 10) || 0, which silently collapses non-numeric ids to0and drops rows sharing the cursor'screated_at.tests/invoice.pagination.test.js#L59-L74: add a fixture with a numericid(e.g.makeRow(1)) andhasMore=trueto cover the real row shape.
📍 Affects 2 files
src/services/invoiceService.js#L249-L263(this comment)src/services/invoiceService.js#L754-L762tests/invoice.pagination.test.js#L59-L74
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/services/invoiceService.js` around lines 249 - 263, Normalize invoice
cursor IDs to strings when calling encodeCursor in the limit and offset
pagination branches, update the cursor tiebreaker query to compare directly with
cursorData.id instead of parseInt fallback coercion, and add a pagination
fixture using a numeric invoice ID with hasMore=true in
tests/invoice.pagination.test.js:59-74.
There was a problem hiding this comment.
Actionable comments posted: 3
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/services/invoiceService.js (1)
552-575: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftTransition is not atomic: the audit log is committed before the status write.
executeTransitionpersists theSTATE_TRANSITIONaudit entry, thenupdateInvoiceruns separately. If the update fails (or the process dies in between) the audit trail claims a transition that never happened, and the caller still gets a 500. Wrap both writes in a single Knex transaction, or move audit-log creation after a successful status update.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/invoiceService.js` around lines 552 - 575, The transition flow around executeTransition and updateInvoice is non-atomic because the audit entry is persisted before the invoice update. Use a single Knex transaction encompassing both the STATE_TRANSITION audit write and status/metadata update, ensuring both commit together or roll back together; preserve the existing result return behavior.src/metrics.js (1)
1077-1244: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick winPersistence metric symbols are defined but never exported — breaks
persistenceMetrics.js.
PERSISTENCE_ENDPOINT_ENUM/PERSISTENCE_STATUS_CLASS_ENUM/PERSISTENCE_CAUSE_ENUM,normalizePersistenceEndpoint/normalizePersistenceStatusClass/normalizePersistenceCause, andpersistenceRequestDurationSeconds/persistenceRequestsTotal/persistenceRequestErrorsTotalare all defined in this hunk but none of them appear inmodule.exports(Lines 1225-1244). ESLint'sno-unused-varshints for every one of these symbols confirm they are unreferenced within this file.Cross-file evidence (
src/middleware/persistenceMetrics.js'srecordPersistenceOutcome) destructure-imports exactly these names from../metricsand call.labels(...)/.observe(...)/.inc(...)on them directly — with the exports missing, those destructured bindings resolve toundefined, so the very first persistence-tracked request (e.g. SME invoice upload) would throwTypeError: Cannot read properties of undefined (reading 'labels').The Biome duplicate-key hint on
normalizeReminderReason(Lines 1240 and 1244) suggests the second occurrence was meant to hold one of these missing exports and was pasted incorrectly instead.🐛 Proposed fix — add the missing exports and drop the duplicate key
sorobanCircuitBreakerStateTransitionsTotal, apiKeyAuthDurationSeconds, apiKeyAuthErrorsTotal, API_KEY_ERROR_CAUSE_ENUM, API_KEY_OUTCOME_ENUM, classifyApiKeyOutcome, classifyApiKeyErrorCause, + PERSISTENCE_ENDPOINT_ENUM, + PERSISTENCE_STATUS_CLASS_ENUM, + PERSISTENCE_CAUSE_ENUM, + normalizePersistenceEndpoint, + normalizePersistenceStatusClass, + normalizePersistenceCause, + persistenceRequestDurationSeconds, + persistenceRequestsTotal, + persistenceRequestErrorsTotal, normalizeJobType, normalizeReminderReason, normalizeSorobanRpcMethod, normalizeSorobanRpcOutcome, normalizeSorobanRetryCause, - normalizeReminderReason,Verification:
#!/bin/bash rg -n "normalizePersistenceEndpoint|persistenceRequestDurationSeconds|PERSISTENCE_CAUSE_ENUM" src/middleware/persistenceMetrics.js rg -n "module.exports" -A 30 src/metrics.js | rg -n "persistence|Persistence"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/metrics.js` around lines 1077 - 1244, Export all persistence metric enums, normalization helpers, and metric instances from module.exports in metrics.js so persistenceMetrics.js can use them. Remove the duplicate normalizeReminderReason entry while adding the missing persistence symbols, preserving the existing export names and behavior.Source: Linters/SAST tools
🟠 Major comments (19)
src/errors/mapError.js-109-117 (1)
109-117: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winForward normalized extensions to every problem response.
mapError()computescode,retryable, andretryHint, butproblemJsonHandlerforwards raw fields only forAppError. Consequently generic 429/503, parser, CORS, and upstream errors omit the documented extensions; anAppErrorrelying on status-derivedcodealso loses it.
src/errors/mapError.js#L109-L117: preserve this normalized contract by passingmappedError.code,mappedError.retryable, andmappedError.retryHinttoformatProblemDetails.src/utils/problemDetails.js#L4-L10: scope the “every error response” claim until all error paths use the builder.docs/RFC7807-Error-Handling.md#L97-L124: do not promise extensions on every response until the handler forwards mapped values.tests/problems.test.js#L571-L575: expect generic responses to contain the normalized extension fields after fixing the handler.Proposed handler change
- code: isAppError ? error.code : undefined, - retryable: isAppError ? error.retryable : undefined, - retryHint: isAppError ? error.retryHint : undefined, + code: mappedError.code, + retryable: mappedError.retryable, + retryHint: mappedError.retryHint,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/errors/mapError.js` around lines 109 - 117, Update problemJsonHandler in src/errors/mapError.js (lines 109-117) to pass mappedError.code, mappedError.retryable, and mappedError.retryHint into formatProblemDetails for every error path. In src/utils/problemDetails.js (lines 4-10) and docs/RFC7807-Error-Handling.md (lines 97-124), limit claims about extensions to responses that use the builder. Update tests/problems.test.js (lines 571-575) to expect normalized extension fields on generic responses.tests/escrow.read.test.js-218-234 (1)
218-234: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winAlign unmapped and blank-ID coverage with the resolver contract.
resolveEscrowAddressthrows when no active mapping exists, but these mocks/tests fabricate or accept mappings and normalize unmapped/blank IDs into successful neutral reads.
tests/escrow.read.test.js#L218-L234: use an explicitly mapped ID with no projection to test the neutral stub; assert the chosen invalid/unmapped behavior separately.tests/integration/v1.escrow.indexer.test.js#L58-L65: throw the production-equivalent not-found error for unmapped IDs.tests/v1.escrow.read.test.js#L24-L30: do not synthesize escrow addresses for arbitrary invoice IDs.docs/escrow-read.md#L228-L235: make the documented validation/not-found contract match the enforced endpoint behavior.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/escrow.read.test.js` around lines 218 - 234, Align the escrow resolver contract across all affected sites: in tests/escrow.read.test.js lines 218-234, use an explicitly mapped invoice ID without projection for the neutral-stub case and separately assert the invalid/unmapped behavior; in tests/integration/v1.escrow.indexer.test.js lines 58-65, make the mock throw the production-equivalent not-found error for unmapped IDs; in tests/v1.escrow.read.test.js lines 24-30, stop synthesizing escrow addresses for arbitrary invoice IDs; and in docs/escrow-read.md lines 228-235, document the enforced validation and not-found behavior..kiro/specs/escrow-read-cursor-pagination/requirements.md-5-5 (1)
5-5: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy liftReplace this with the escrow-read requirements.
This spec defines an InvestService pagination refactor, not escrow-read behavior. It will direct implementation and acceptance tests at unrelated files/routes; move it to the invest feature or rewrite it for the escrow endpoints.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.kiro/specs/escrow-read-cursor-pagination/requirements.md at line 5, Replace the requirements content with escrow-read behavior and acceptance criteria, removing references to InvestService, listInvestments, investment routes, and cursor pagination. Ensure the rewritten requirements target the escrow-read endpoints, files, response shapes, and expected validation/error handling.src/routes/adminConfig.js-160-164 (1)
160-164: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winEchoing
configback reflects submitted secrets in the response body.
webhook.secretandkyc.apiKey(seesrc/schemas/config.jsLines 112-115, 188-191) are returned verbatim. Reflected secrets end up in proxy access logs, browser devtools, and any client-side response caching. Redact secret-bearing fields, or return onlysectionplus the non-secret keys that were accepted.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/routes/adminConfig.js` around lines 160 - 164, Update the response construction in the admin configuration validation handler to avoid returning validatedConfig, since it may contain secrets such as webhook.secret and kyc.apiKey. Return section plus only explicitly non-secret accepted configuration fields, or apply the established redaction behavior before res.status(200).json responds.src/schemas/config.js-53-58 (1)
53-58: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
boundedUrlacceptshttp://, contradicting the documented HTTPS requirement.The JSDoc for
webhook.url(Line 98) andkyc.providerUrl(Line 174) both say HTTPS, but.url()allows any scheme — includinghttp:(plaintext delivery of signed payloads / API keys) and, depending on the Zod version, non-HTTP schemes. Enforce the scheme explicitly.🔒 Proposed fix
function boundedUrl(label) { return z .string({ invalid_type_error: `${label} must be a string` }) .max(2048, { message: `${label} must not exceed 2048 characters` }) - .url({ message: `${label} must be a valid URL` }); + .url({ message: `${label} must be a valid URL` }) + .refine((v) => v.startsWith('https://'), { + message: `${label} must use https`, + }); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/schemas/config.js` around lines 53 - 58, Update boundedUrl to enforce HTTPS explicitly after URL validation, rejecting http and non-HTTP(S) schemes while preserving the existing string type and 2048-character constraints and label-specific error messages.src/middleware/rateLimit.js-246-252 (1)
246-252: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winHash the API key before using it as a rate-limit bucket identifier.
apikey_${apiKey}embeds the raw credential in the Redis key (rate-limit:config:apikey_<secret>), where it is exposed toKEYS/SCAN/MONITOR/SLOWLOGand to any key-level monitoring dashboards — the same dashboards the JSDoc points at. A truncated SHA-256 preserves per-client bucketing and legibility without persisting secret material.🔒 Proposed fix
+const crypto = require('node:crypto'); + function adminConfigKeyGenerator(req) { const apiKey = getApiKey(req); if (apiKey) { - return `apikey_${apiKey}`; + const digest = crypto.createHash('sha256').update(apiKey).digest('hex'); + return `apikey_${digest.slice(0, 32)}`; } return req.ip || req.socket?.remoteAddress || '127.0.0.1'; }Note
tests/unit/adminConfig.rateLimit.test.jsLines 242-266 assert the plaintext prefix and would need updating.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/middleware/rateLimit.js` around lines 246 - 252, Update adminConfigKeyGenerator to hash the API key with SHA-256 and use a truncated digest in the apikey_ bucket identifier instead of embedding the raw credential. Preserve distinct per-client buckets and the existing IP fallback, and update affected tests to assert the hashed key format.src/schemas/config.js-331-335 (1)
331-335: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winMigrate these schemas to the Zod 4 API
src/schemas/config.js#L319-L335and the other primitive helpers in this file: replaceinvalid_type_error/required_error/errorMapwitherror.src/schemas/config.js#L331-L335:z.record()needs a real value schema; usez.record(z.string(), z.unknown())instead of passing the options object in the second slot.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/schemas/config.js` around lines 331 - 335, Migrate the affected schemas in src/schemas/config.js at lines 319-335, 270-283, and 331-335 to Zod 4 error options: replace invalid_type_error, required_error, and errorMap with error while preserving existing messages. In the config schema at lines 331-335, update z.record to provide z.string() as the key schema and z.unknown() as the value schema, retaining the plain-object refinement.src/services/kycService.js-795-804 (1)
795-804: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winShared root cause: in-memory
mockKycRecordsis written before the DB persist is awaited in all three status mutators. A rejectedpersistKycRecordleaves the process serving a status that was never persisted, andgetKycStatusprefers the mock record when the DB row is absent.
src/services/kycService.js#L795-L804: movemockKycRecords.set(smeId, record)to after thepersistKycRecordawait inverifySmeSafe.src/services/kycService.js#L876-L884: movemockKycRecords.set(smeId, record)to after thepersistKycRecordawait inexemptSmeFromKyc(exempted also passes the funding gate).src/services/kycService.js#L836-L844: movemockKycRecords.set(smeId, record)to after thepersistKycRecordawait inrejectSmeKycfor consistency.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/kycService.js` around lines 795 - 804, In src/services/kycService.js lines 795-804, move mockKycRecords.set in verifySmeSafe to after the awaited persistKycRecord call; apply the same ordering in exemptSmeFromKyc at lines 876-884 and rejectSmeKyc at lines 836-844. Ensure each in-memory status is updated only after database persistence succeeds.src/services/kycService.js-620-647 (1)
620-647: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winShared root cause: strict response-signature mode is gated on
config.apiSecret, so it silently does nothing when no secret is configured — while the docs promise the header is required.
src/services/kycService.js#L620-L647: fail closed whenverifyResponseSignatureis true butapiSecretis missing, instead of skipping the whole verification block.docs/compliance.md#L358-L358: state that strict mode additionally requiresKYC_PROVIDER_SECRET, matching the corrected behavior.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/kycService.js` around lines 620 - 647, Update the response-signature validation in src/services/kycService.js lines 620-647 so verifyResponseSignature true with no apiSecret fails closed with the existing missing-signature KycProviderError path, rather than skipping verification; retain normal signature verification when apiSecret is configured. Update docs/compliance.md line 358 to state that strict mode additionally requires KYC_PROVIDER_SECRET.src/services/kycService.js-548-686 (1)
548-686: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winTimeout is per attempt only — no overall deadline for a request-path call.
config.timeoutMsbounds each fetch, butwithRetrycan issue1 + maxRetriesattempts plus backoff. Worst case with the clamp ceilings (30s timeout, 10 retries, 5s cap) is minutes of latency on an inbound request, sincegetKycStatusruns on the request thread via KYC gating. Consider computing a single deadline before the retry loop and deriving each attempt's timeout from the remaining budget (or boundingtimeoutMs * (maxRetries + 1)).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/kycService.js` around lines 548 - 686, Add an overall deadline before the `sharedKycBreaker.execute`/`withRetry` flow and ensure each `operation` attempt derives its AbortController timeout from the remaining budget rather than always using `config.timeoutMs`. Preserve retry behavior while preventing retries and backoff from exceeding the request-path deadline, and stop retrying when the deadline is exhausted.tests/indexerListing.test.js-197-264 (1)
197-264: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winThe "cursor-mode" tests never pass a cursor — the keyset branch is untested.
Only
pagination: { limit }is supplied in the tests at lines 198, 212, 231 and 251, souseCursorisfalseinlistIndexerEventsand every one of these exercises the offset branch. Combined with the fake knex dropping the keyset callback (line 56,if (typeof fieldOrFn === 'function') { return q; }), the actual keyset predicate insrc/services/indexerService.jslines 188–200 has no passing-path coverage — a wrong comparison operator or a swapped ASC/DESC tiebreaker would not be caught.Suggest: capture the
where(function(){…})callback in the fake (invoke it against a recording builder) and add a round-trip test that feedsresult.meta.nextCursorback in aspagination.cursorand asserts the second page excludes the first page's rows.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/indexerListing.test.js` around lines 197 - 264, Update the cursor-mode tests around listIndexerEvents to actually pass a cursor: add a round-trip case that requests the first page, feeds result.meta.nextCursor into pagination.cursor for the next request, and verifies the second page excludes the first page’s rows. Modify makeFakeKnex so its where(function(){…}) path invokes the callback against a recording builder, allowing the keyset predicate in the cursor pagination branch to be exercised, including sort-field and ASC/DESC tie-breaker behavior.tests/invest.list.test.js-110-147 (1)
110-147: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winMocking
../src/appreimplements the route under test, voiding integration coverage.
createAppis replaced with a hand-written handler that duplicates the real/api/invest/opportunitieslogic (the comment on line 120 concedes it only "mirrors" it). Any drift in the real route — parameter parsing, validation, response envelope — now passes here, and the inline error handler returns{ error: message }instead of the app's RFC 7807 problem shape, so error-path assertions validate a contract that doesn't exist in production.Mount the real router in a minimal app instead of re-implementing the handler:
♻️ Suggested direction
jest.mock('../src/app', () => { const express = require('express'); - const { authenticatedTenantStack } = require('../src/middleware/stacks'); - const { listOpportunities } = require('../src/services/investService'); + const investRouter = require('../src/routes/invest'); return { createApp: () => { const app = express(); - app.get('/api/invest/opportunities', ...authenticatedTenantStack, async (req, res, next) => { - // …duplicated handler… - }); + app.use(express.json()); + app.use('/api/invest', investRouter); app.use((error, _req, res, _next) => { res.status(error.status || 500).json({ error: error.message }); }); return app; }, }; });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/invest.list.test.js` around lines 110 - 147, Replace the `jest.mock('../src/app')` hand-written `/api/invest/opportunities` handler with a minimal Express test app that mounts the real opportunities router and uses the production-compatible error middleware. Remove duplicated parsing, service invocation, response shaping, and inline `{ error: ... }` handling so `createApp` exercises the actual route and RFC 7807 error contract.src/workers/jobPersistence.js-394-398 (1)
394-398: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winNon-numeric
limitproducesNaN, not the default.
parseInt(rawLimit ?? LIST_JOBS_DEFAULT_LIMIT, 10)returnsNaNfor e.g.'abc'or{};Math.max(1, NaN)andMath.min(NaN, 100)are bothNaN, so.limit(NaN)reaches Knex,rows.length > NaNis false (sohasMoreis always false and the page is unbounded), andmeta.limitserializes asnull. The admin route happens to pre-validate, butlistJobsis exported as a standalone API.🐛 Proposed fix
- const limit = Math.min( - Math.max(1, Number.isInteger(rawLimit) ? rawLimit : parseInt(rawLimit ?? LIST_JOBS_DEFAULT_LIMIT, 10)), - LIST_JOBS_MAX_LIMIT, - ); + const parsedLimit = Number.isInteger(rawLimit) ? rawLimit : parseInt(rawLimit, 10); + const limit = Number.isFinite(parsedLimit) + ? Math.min(Math.max(1, parsedLimit), LIST_JOBS_MAX_LIMIT) + : LIST_JOBS_DEFAULT_LIMIT;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/workers/jobPersistence.js` around lines 394 - 398, Update the limit normalization in listJobs to detect non-numeric or non-finite parsed values and fall back to LIST_JOBS_DEFAULT_LIMIT before applying the existing [1, LIST_JOBS_MAX_LIMIT] clamp. Preserve valid integer handling and ensure downstream query, hasMore, and metadata values always receive a finite limit.src/routes/adminJobs.js-219-229 (1)
219-229: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy liftListing is not tenant-scoped despite the documented contract.
Line 8 and the Swagger block (Line 59) both state the endpoint is tenant-scoped, but
listJobsaccepts no tenant parameter andbackground_jobsis queried without any tenant predicate (seesrc/workers/jobPersistence.jslines 422-444). Any admin of one tenant sees every tenant's jobs. Either add a tenant filter or correct the documentation.🔒 Sketch of the fix
const result = await persistence.listJobs({ limit: rawLimit !== undefined ? parseInt(rawLimit, 10) : LIST_JOBS_DEFAULT_LIMIT, cursor: req.query.cursor, sortBy: rawSortBy, order: rawOrder, status: rawStatus, type: req.query.type, + tenantId: req.tenantId, });
listJobswould then need to apply.where('tenant_id', tenantId)when provided (and the column must exist onbackground_jobs).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/routes/adminJobs.js` around lines 219 - 229, Make the admin job listing tenant-scoped as documented: obtain the authenticated tenant identifier in the route around createJobPersistence/listJobs, pass it to listJobs, and update jobPersistence.listJobs to apply a tenant_id filter when provided. Ensure the background_jobs schema supports tenant_id and preserve unscoped behavior only where explicitly intended.docs/api-keys.md-5-5 (1)
5-5: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winDoc claims no key-management endpoint exists, but this PR adds one.
This document states There is no key-management HTTP endpoint (no create/list/revoke route) — keys are provisioned via an environment variable and validated at request time by middleware. However,
src/routes/apiKeys.js(part of this same cohort) adds CRUD routes, andtests/unit/apiKeyAuth.test.js(Lines 390-476) exercisesPOST /api-keys,GET /api-keys/:key, 201/404/422 responses, and idempotent creation. As the dedicated registry-contract doc, this file should describe those endpoints instead of denying their existence.Also applies to: 9-11
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/api-keys.md` at line 5, Update the API key contract documentation to describe the CRUD routes implemented by the apiKeys route module, including create, list, and revoke/get behavior and relevant success/error responses. Remove the claim that keys are only provisioned through an environment variable, while preserving the middleware validation details that still apply.src/services/storage.js-229-234 (1)
229-234: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
STORAGE_IN_MEMORY=truehas no effect outsideNODE_ENV=test.
uploadFileroutes to the in-memory path whenNODE_ENV==='test' || STORAGE_IN_MEMORY==='true'(line 230), but the calleeuploadFileInMemory(line 422) only checksNODE_ENV==='test'— outside tests it falls through to the realPutObjectCommand/S3 call regardless ofSTORAGE_IN_MEMORY. The same gate mismatch exists invalidateUploadedObject(line 355) andgetFile(line 438), which also check onlyNODE_ENV==='test'. Net effect: theSTORAGE_IN_MEMORYflag — presumably intended to let local dev run without AWS credentials — is silently a no-op outside the test environment.Extract a single helper (e.g.
_useInMemoryStore()returningNODE_ENV==='test' || STORAGE_IN_MEMORY==='true') and use it consistently in all four places.♻️ Proposed fix
+ _useInMemoryStore() { + return process.env.NODE_ENV === 'test' || process.env.STORAGE_IN_MEMORY === 'true'; + } + async uploadFileInMemory({ key, body, mimeType }) { - if (process.env.NODE_ENV === 'test') { + if (this._useInMemoryStore()) { this._inMemoryStore.set(key, { body, mimeType }); return; } const command = new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body, ContentType: mimeType }); await s3Client.send(command); }Apply the same substitution to
validateUploadedObjectandgetFile.Also applies to: 421-428
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/storage.js` around lines 229 - 234, Extract a shared _useInMemoryStore() helper that returns true when NODE_ENV is test or STORAGE_IN_MEMORY is 'true'. Use this helper consistently in uploadFile, uploadFileInMemory, validateUploadedObject, and getFile so the in-memory path is selected in both tests and local development.src/services/storage.js-370-396 (1)
370-396: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winErrors during the PDF magic-byte check are silently swallowed.
In the
catchblock (389-393), onlyerr.code === 'INVALID_PDF_HEADER'is rethrown; any other error fromGetObjectCommand/stream iteration (network failure, permission error, throttling) is dropped, and execution falls through toreturn { valid: ct === declaredMime && cl === declaredSize, ... }based solely onHeadObjectmetadata. This can reportvalid: truefor an object whose actual PDF header was never verified because the check itself failed, defeating the purpose of the integrity check.🐛 Proposed fix
} catch (e) { - if (e.code === 'INVALID_PDF_HEADER') { - throw e; - } + throw e; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/storage.js` around lines 370 - 396, Update the PDF validation try/catch in the storage validation method to rethrow every error from GetObjectCommand or body stream iteration, while preserving the existing INVALID_PDF_HEADER propagation. Do not allow execution to reach the metadata-based return when the PDF header check fails for any reason.src/services/storage.js-69-89 (1)
69-89: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
MAX_FILE_SIZEbypasses the validatedINVOICE_FILE_MAX_SIZEconfig contract.
parseSize/MAX_FILE_SIZEreadprocess.env.INVOICE_FILE_MAX_SIZEdirectly and silently fall back toDEFAULT_MAX_FILE_SIZEon malformed input. Meanwhilesrc/config/index.js'sInvoiceFileMaxSizeSchema/getInvoiceFileMaxSize()(used bysrc/routes/invoiceFile.js) throws on the exact same malformed input. Ifstorage.jsis loaded before boot validation runs (e.g. in isolated test contexts, or if boot validation is ever bypassed),StorageService.maxFileSizecan silently diverge from the value the route layer enforces/validates, with two different regexes (unit is optional here, mandatory in the Zod schema).Use the shared
getInvoiceFileMaxSize()(or export/reuse a single parser) instead of independently reading and parsingprocess.envhere.♻️ Proposed fix
+const { getInvoiceFileMaxSize } = require('../config'); + function parseSize(sizeStr) { if (typeof sizeStr !== 'string' || sizeStr.trim() === '') { return DEFAULT_MAX_FILE_SIZE; } const match = sizeStr.trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i); if (!match) { return DEFAULT_MAX_FILE_SIZE; } const value = parseFloat(match[1]); const unit = (match[2] || 'b').toLowerCase(); const multipliers = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 }; return Math.floor(value * multipliers[unit]); } -const MAX_FILE_SIZE = parseSize(process.env.INVOICE_FILE_MAX_SIZE || process.env.BODY_LIMIT_INVOICE || '5mb'); +const MAX_FILE_SIZE = parseSize(getInvoiceFileMaxSize() || process.env.BODY_LIMIT_INVOICE || '5mb');🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/services/storage.js` around lines 69 - 89, Update the MAX_FILE_SIZE initialization in storage.js to use the shared getInvoiceFileMaxSize() configuration accessor instead of parseSize and direct process.env reads. Remove or stop using the independent parseSize fallback so storage and the invoice route enforce the same validated InvoiceFileMaxSizeSchema contract.docs/PR_DESCRIPTION_invoice_state.md-63-73 (1)
63-73: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win"No source code was modified" contradicts the actual diff scope.
This note claims the PR is documentation-only and that
invoiceStateRoutes.jsis "a minimalPOST /transitionstub," butsrc/services/invoiceStateMachine.jsadds substantial new logic (VALID_TRANSITIONS,validateTransition,executeTransition,getTransitionHistory,canLinkToEscrow), andtests/invoice.state.test.jsexercises a fully-implemented six-endpoint route set (state/transition/approve/link-escrow/reject/history) with tenant scoping and KYC gating. This note appears to be stale or copied from a different change and will mislead reviewers about the actual scope of this PR.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/PR_DESCRIPTION_invoice_state.md` around lines 63 - 73, The reviewer notes inaccurately describe the change as documentation-only and the route handler as a minimal stub. Update the “Notes for reviewers” section to reflect the implemented invoice state machine and six-endpoint route set, including tenant scoping and KYC gating; remove or revise the stale claims about no source changes and an incomplete handler while preserving the implementation-specification context where applicable.
| const { authenticateToken } = require('../middleware/auth'); | ||
| // Legacy src/middleware/apiKey.js has been retired in favour of the env-backed | ||
| // registry authenticator. The implementation lives in apiKeyAuth.js and never | ||
| // opens a SQLite connection per request — see issue #590. | ||
| const { authenticateApiKey } = require('../middleware/apiKeyAuth'); | ||
| const logger = require('../logger'); | ||
|
|
||
| // ── Constants ──────────────────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * Maximum page size for the dead-letter listing endpoint. | ||
| * @constant {number} | ||
| */ | ||
| const MAX_LIMIT = 100; | ||
|
|
||
| /** | ||
| * Default page size when `limit` is not supplied. | ||
| * @constant {number} | ||
| */ | ||
| const DEFAULT_LIMIT = 20; | ||
|
|
||
| /** | ||
| * The cursor sort field used for dead-letter keyset pagination. | ||
| * Dead-letters are always ordered by `created_at` descending, with `id` as | ||
| * the tiebreaker. Because `cursorPagination.js` only allows fields in its | ||
| * ALLOWED_SORT_FIELDS allowlist we encode the cursor with `created_at`. | ||
| * | ||
| * @constant {string} | ||
| */ | ||
| const DEAD_LETTER_SORT_FIELD = 'created_at'; | ||
|
|
||
| /** | ||
| * Columns that are safe to return to the caller. | ||
| * `webhook_url` is returned so operators know where delivery was attempted. | ||
| * `payload` is returned (it is the event body, not a secret). | ||
| * `webhook_secret` is NOT a column in the table — secrets live in | ||
| * `tenants.settings` and are never persisted to `webhook_dead_letters`. | ||
| * The `X-Signature` header stored in `last_error` might contain partial | ||
| * signature data, but `last_error` is a plain error message string and safe | ||
| * to surface. We explicitly exclude no columns here beyond what the table | ||
| * holds — this comment documents the security decision. | ||
| * Pre-built admin API key middleware (no required scope — any valid, non-revoked | ||
| * key is accepted). Built once so the factory overhead is paid at module load | ||
| * rather than on every request. | ||
| * | ||
| * @constant {string[]} | ||
| * @type {import('express').RequestHandler} | ||
| */ | ||
| const SAFE_COLUMNS = [ | ||
| 'id', | ||
| 'tenant_id', | ||
| 'invoice_id', | ||
| 'event', | ||
| 'webhook_url', | ||
| 'attempts', | ||
| 'last_error', | ||
| 'resolved', | ||
| 'resolved_at', | ||
| 'created_at', | ||
| // payload is intentionally included — it is the event body, not a secret | ||
| 'payload', | ||
| ]; | ||
|
|
||
| // ── Apply admin auth + tenant extraction to every route ────────────────────── | ||
| router.use(...adminStack); | ||
|
|
||
| // ── Helper ─────────────────────────────────────────────────────────────────── | ||
| const _adminApiKeyMiddleware = authenticateApiKey(); | ||
|
|
||
| /** | ||
| * Redacts secret material from a dead-letter row before sending it to the | ||
| * caller. Currently the `webhook_dead_letters` table does not store HMAC | ||
| * secrets (they live in `tenants.settings`), but as a defence-in-depth | ||
| * measure we strip any key whose name matches the sensitive-field pattern | ||
| * used throughout the codebase. | ||
| * Accepts either a valid admin JWT or a valid X-API-Key. | ||
| * Honours the existing X-API-KEY contract: when the header is present the | ||
| * request is authenticated against the env-backed key registry; otherwise it | ||
| * falls through to JWT auth. | ||
| * | ||
| * @param {Object} row - Raw DB row. | ||
| * @returns {Object} Sanitised row safe for external consumption. | ||
| * @type {import('express').RequestHandler} | ||
| */ | ||
| function redactRow(row) { | ||
| const SENSITIVE_KEYS = /password|secret|token|apiKey|authorization|privateKey|seed|mnemonic/i; | ||
| const out = {}; | ||
| for (const [k, v] of Object.entries(row)) { | ||
| if (SENSITIVE_KEYS.test(k)) { | ||
| // omit | ||
| } else { | ||
| out[k] = v; | ||
| } | ||
| function adminAuth(req, res, next) { | ||
| if (req.headers['x-api-key']) { | ||
| return _adminApiKeyMiddleware(req, res, next); | ||
| } | ||
| return out; | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
Admin webhook routes are unauthenticated — adminAuth is broken and never wired up.
Two compounding defects here:
return out;(Line 65) references an undeclared variable. This is almost certainly meant to bereturn authenticateToken(req, res, next);— as written, any request without anx-api-keyheader throwsReferenceError: out is not defined.adminAuthis never applied anywhere in this file — there's norouter.use(adminAuth)and it isn't passed to any of the four route handlers (/dead-letters,/replay/:id,/replay,/resolve/:id). ESLint'sno-unused-varson bothauthenticateTokenandadminAuthcorroborates this.
Combined, every admin webhook route currently runs with no authentication at all (contradicting the module's own header doc), and once wired up, the JWT fallback path would immediately crash on the out bug.
🔒 Proposed fix
function adminAuth(req, res, next) {
if (req.headers['x-api-key']) {
return _adminApiKeyMiddleware(req, res, next);
}
- return out;
+ return authenticateToken(req, res, next);
}
+
+router.use(adminAuth);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { authenticateToken } = require('../middleware/auth'); | |
| // Legacy src/middleware/apiKey.js has been retired in favour of the env-backed | |
| // registry authenticator. The implementation lives in apiKeyAuth.js and never | |
| // opens a SQLite connection per request — see issue #590. | |
| const { authenticateApiKey } = require('../middleware/apiKeyAuth'); | |
| const logger = require('../logger'); | |
| // ── Constants ──────────────────────────────────────────────────────────────── | |
| /** | |
| * Maximum page size for the dead-letter listing endpoint. | |
| * @constant {number} | |
| */ | |
| const MAX_LIMIT = 100; | |
| /** | |
| * Default page size when `limit` is not supplied. | |
| * @constant {number} | |
| */ | |
| const DEFAULT_LIMIT = 20; | |
| /** | |
| * The cursor sort field used for dead-letter keyset pagination. | |
| * Dead-letters are always ordered by `created_at` descending, with `id` as | |
| * the tiebreaker. Because `cursorPagination.js` only allows fields in its | |
| * ALLOWED_SORT_FIELDS allowlist we encode the cursor with `created_at`. | |
| * | |
| * @constant {string} | |
| */ | |
| const DEAD_LETTER_SORT_FIELD = 'created_at'; | |
| /** | |
| * Columns that are safe to return to the caller. | |
| * `webhook_url` is returned so operators know where delivery was attempted. | |
| * `payload` is returned (it is the event body, not a secret). | |
| * `webhook_secret` is NOT a column in the table — secrets live in | |
| * `tenants.settings` and are never persisted to `webhook_dead_letters`. | |
| * The `X-Signature` header stored in `last_error` might contain partial | |
| * signature data, but `last_error` is a plain error message string and safe | |
| * to surface. We explicitly exclude no columns here beyond what the table | |
| * holds — this comment documents the security decision. | |
| * Pre-built admin API key middleware (no required scope — any valid, non-revoked | |
| * key is accepted). Built once so the factory overhead is paid at module load | |
| * rather than on every request. | |
| * | |
| * @constant {string[]} | |
| * @type {import('express').RequestHandler} | |
| */ | |
| const SAFE_COLUMNS = [ | |
| 'id', | |
| 'tenant_id', | |
| 'invoice_id', | |
| 'event', | |
| 'webhook_url', | |
| 'attempts', | |
| 'last_error', | |
| 'resolved', | |
| 'resolved_at', | |
| 'created_at', | |
| // payload is intentionally included — it is the event body, not a secret | |
| 'payload', | |
| ]; | |
| // ── Apply admin auth + tenant extraction to every route ────────────────────── | |
| router.use(...adminStack); | |
| // ── Helper ─────────────────────────────────────────────────────────────────── | |
| const _adminApiKeyMiddleware = authenticateApiKey(); | |
| /** | |
| * Redacts secret material from a dead-letter row before sending it to the | |
| * caller. Currently the `webhook_dead_letters` table does not store HMAC | |
| * secrets (they live in `tenants.settings`), but as a defence-in-depth | |
| * measure we strip any key whose name matches the sensitive-field pattern | |
| * used throughout the codebase. | |
| * Accepts either a valid admin JWT or a valid X-API-Key. | |
| * Honours the existing X-API-KEY contract: when the header is present the | |
| * request is authenticated against the env-backed key registry; otherwise it | |
| * falls through to JWT auth. | |
| * | |
| * @param {Object} row - Raw DB row. | |
| * @returns {Object} Sanitised row safe for external consumption. | |
| * @type {import('express').RequestHandler} | |
| */ | |
| function redactRow(row) { | |
| const SENSITIVE_KEYS = /password|secret|token|apiKey|authorization|privateKey|seed|mnemonic/i; | |
| const out = {}; | |
| for (const [k, v] of Object.entries(row)) { | |
| if (SENSITIVE_KEYS.test(k)) { | |
| // omit | |
| } else { | |
| out[k] = v; | |
| } | |
| function adminAuth(req, res, next) { | |
| if (req.headers['x-api-key']) { | |
| return _adminApiKeyMiddleware(req, res, next); | |
| } | |
| return out; | |
| } | |
| const { authenticateToken } = require('../middleware/auth'); | |
| // Legacy src/middleware/apiKey.js has been retired in favour of the env-backed | |
| // registry authenticator. The implementation lives in apiKeyAuth.js and never | |
| // opens a SQLite connection per request — see issue `#590`. | |
| const { authenticateApiKey } = require('../middleware/apiKeyAuth'); | |
| const logger = require('../logger'); | |
| // ── Constants ──────────────────────────────────────────────────────────────── | |
| /** | |
| * Pre-built admin API key middleware (no required scope — any valid, non-revoked | |
| * key is accepted). Built once so the factory overhead is paid at module load | |
| * rather than on every request. | |
| * | |
| * `@type` {import('express').RequestHandler} | |
| */ | |
| const _adminApiKeyMiddleware = authenticateApiKey(); | |
| /** | |
| * Accepts either a valid admin JWT or a valid X-API-Key. | |
| * Honours the existing X-API-KEY contract: when the header is present the | |
| * request is authenticated against the env-backed key registry; otherwise it | |
| * falls through to JWT auth. | |
| * | |
| * `@type` {import('express').RequestHandler} | |
| */ | |
| function adminAuth(req, res, next) { | |
| if (req.headers['x-api-key']) { | |
| return _adminApiKeyMiddleware(req, res, next); | |
| } | |
| return authenticateToken(req, res, next); | |
| } | |
| router.use(adminAuth); |
🧰 Tools
🪛 ESLint
[error] 35-35: 'authenticateToken' is assigned a value but never used. Allowed unused vars must match /^_/u.
(no-unused-vars)
[error] 61-61: 'adminAuth' is defined but never used. Allowed unused vars must match /^_/u.
(no-unused-vars)
[error] 65-65: 'out' is not defined.
(no-undef)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/routes/adminWebhooks.js` around lines 35 - 66, Fix adminAuth by
delegating the no-X-API-Key path to authenticateToken(req, res, next) instead of
the undefined out variable, then apply adminAuth to all admin webhook routes
(/dead-letters, /replay/:id, /replay, and /resolve/:id), preferably through
router.use(adminAuth) before their definitions. Ensure every route requires
either valid API-key or JWT authentication.
Source: Linters/SAST tools
| router.use(extractTenant); | ||
|
|
||
| /** | ||
| * Resolves the acting principal identifier from the authenticated request. | ||
| * | ||
| * @param {import('express').Request} req - Express request object. | ||
| * @returns {string} Actor identifier. | ||
| */ | ||
| function getActorFromRequest(req) { | ||
| if (req.user && req.user.id) { | ||
| return req.user.id; | ||
| } | ||
| if (req.user && req.user.sub) { | ||
| return req.user.sub; | ||
| } | ||
| return req.ip || (req.socket && req.socket.remoteAddress) || 'unknown'; | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift
State-mutating routes have no authentication; unauthenticated callers can drive the lifecycle.
Only extractTenant is mounted. POST /:id/transition, /approve, /reject never require req.user, and getActorFromRequest silently falls back to req.ip, so an anonymous caller with just an x-tenant-id header can approve or reject any invoice in that tenant and the audit trail records an IP instead of a principal. It also makes the documented MISSING_ACTOR 400 (docs/invoice-state.md line 724) unreachable.
Require an authenticated principal on the write routes (and ideally reject when no req.user is present rather than falling back to IP).
🔒 Suggested guard
router.use(extractTenant);
+
+/** Rejects unauthenticated callers on state-mutating routes. */
+function requireActor(req, res, next) {
+ if (!req.user || !(req.user.id || req.user.sub)) {
+ return res.status(400).json(
+ responseHelper.error('Authenticated actor could not be resolved', 'MISSING_ACTOR'),
+ );
+ }
+ return next();
+}Then apply requireActor (after your auth middleware) to /:id/transition, /:id/approve, /:id/reject, and /:id/link-escrow.
Also applies to: 110-119
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/routes/invoiceStateRoutes.js` around lines 31 - 47, Require an
authenticated principal on the state-mutating routes `/:id/transition`,
`/:id/approve`, `/:id/reject`, and `/:id/link-escrow` by applying `requireActor`
after the existing authentication middleware. Update `getActorFromRequest` to
reject missing `req.user` instead of falling back to the client IP, preserving
the documented `MISSING_ACTOR` response for unauthenticated requests.
| router.post('/:id/transition', async (req, res, next) => { | ||
| const { id } = req.params; | ||
| const { targetState, reason } = req.body || {}; | ||
|
|
||
| router.post('/transition', (req, res) => { | ||
| const { targetState } = req.body; | ||
|
|
||
| if (CAPITAL_MOVING_STATES.has(targetState)) { | ||
| return res.status(200).json({ requiresKYC: true, state: targetState }); | ||
| try { | ||
| if (!targetState) { | ||
| return res.status(400).json( | ||
| responseHelper.error('Target state is required', 'MISSING_TARGET_STATE'), | ||
| ); | ||
| } | ||
|
|
||
| return res.status(200).json({ requiresKYC: false, state: targetState }); | ||
|
|
||
| const actor = getActorFromRequest(req); | ||
| const ipAddress = req.ip || (req.socket && req.socket.remoteAddress) || 'unknown'; | ||
| const userAgent = req.get('user-agent') || 'unknown'; | ||
|
|
||
| const result = await invoiceService.transitionInvoice(id, targetState, req.tenantId, { | ||
| actor, | ||
| reason, | ||
| ipAddress, | ||
| userAgent, | ||
| metadata: { | ||
| method: req.method, | ||
| path: req.path, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift
The new validation schema is never wired in, and the validation test suite targets a route that does not exist.
src/schemas/invoiceState.js (safeParseTransitionBody) is not imported here, so targetState/reason/actor/metadata bounds and unknown-key rejection are unenforced at the HTTP layer. Meanwhile tests/invoice.stateValidation.test.js posts to /api/invoices/transition (no :id) and asserts an RFC 7807 body with fieldErrors plus a { requiresKYC, state } success shape — none of which this router provides, so that suite cannot pass.
Wire safeParseTransitionBody into the transition handler and reconcile the test's path/response contract with the implemented routes.
🔧 Sketch for wiring validation
+const { safeParseTransitionBody } = require('../schemas/invoiceState');
@@
router.post('/:id/transition', async (req, res, next) => {
const { id } = req.params;
- const { targetState, reason } = req.body || {};
-
try {
- if (!targetState) {
- return res.status(400).json(
- responseHelper.error('Target state is required', 'MISSING_TARGET_STATE'),
- );
- }
+ const parsed = safeParseTransitionBody(req.body);
+ if (!parsed.success) {
+ return res.status(400).json({
+ type: 'https://liquifact.io/problems/validation-error',
+ title: 'Invalid invoice-state request body',
+ status: 400,
+ code: 'INVOICE_STATE_VALIDATION_FAILED',
+ fieldErrors: parsed.fieldErrors,
+ });
+ }
+ const { targetState, reason } = parsed.data;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| router.post('/:id/transition', async (req, res, next) => { | |
| const { id } = req.params; | |
| const { targetState, reason } = req.body || {}; | |
| router.post('/transition', (req, res) => { | |
| const { targetState } = req.body; | |
| if (CAPITAL_MOVING_STATES.has(targetState)) { | |
| return res.status(200).json({ requiresKYC: true, state: targetState }); | |
| try { | |
| if (!targetState) { | |
| return res.status(400).json( | |
| responseHelper.error('Target state is required', 'MISSING_TARGET_STATE'), | |
| ); | |
| } | |
| return res.status(200).json({ requiresKYC: false, state: targetState }); | |
| const actor = getActorFromRequest(req); | |
| const ipAddress = req.ip || (req.socket && req.socket.remoteAddress) || 'unknown'; | |
| const userAgent = req.get('user-agent') || 'unknown'; | |
| const result = await invoiceService.transitionInvoice(id, targetState, req.tenantId, { | |
| actor, | |
| reason, | |
| ipAddress, | |
| userAgent, | |
| metadata: { | |
| method: req.method, | |
| path: req.path, | |
| }, | |
| }); | |
| const { safeParseTransitionBody } = require('../schemas/invoiceState'); | |
| router.post('/:id/transition', async (req, res, next) => { | |
| const { id } = req.params; | |
| try { | |
| const parsed = safeParseTransitionBody(req.body); | |
| if (!parsed.success) { | |
| return res.status(400).json({ | |
| type: 'https://liquifact.io/problems/validation-error', | |
| title: 'Invalid invoice-state request body', | |
| status: 400, | |
| code: 'INVOICE_STATE_VALIDATION_FAILED', | |
| fieldErrors: parsed.fieldErrors, | |
| }); | |
| } | |
| const { targetState, reason } = parsed.data; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/routes/invoiceStateRoutes.js` around lines 110 - 134, Wire the invoice
transition request validation into the POST ':id/transition' handler by
importing and applying safeParseTransitionBody before calling
invoiceService.transitionInvoice, returning the established validation error
shape and rejecting unknown or out-of-bounds fields. Update
tests/invoice.stateValidation.test.js to use the implemented
'/api/invoices/:id/transition' route and assert this router’s actual success and
error response contracts.
Closes Liquifact#766
Summary
endpoint,method,status,outcome)unauthorized,forbidden,validation_error,not_found,internal_error)res.on('finish')withduration_ms,status,outcome, anderror_type/metricsPrometheus endpointSecurity Notes
Validation
Summary by CodeRabbit
New Features
Tests