120 Commits
Author SHA1 Message Date
codex-bot e7827dcac6 Merge branch 'develop' into test/105-portfolio-publish-rollback
Backend verify / commits (pull_request) Failing after 27s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
2026-10-28 20:45:48 +03:00
codex-bot 99b137ed0b test: replace operations sync delays with explicit barriers (WealthLedger/backend#104) (#145)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 6m12s
2026-10-28 20:41:24 +03:00
ksv741 5cf914752d test: cover portfolio publish rollback and claim fence (WealthLedger/backend#105)
Backend verify / commits (pull_request) Successful in 31s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 5m13s
2026-10-28 20:28:58 +03:00
codex-bot d37fa2c903 refactor: publish typed problem detail codes (WealthLedger/backend#117) (#144)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m8s
2026-10-28 17:11:41 +03:00
codex-bot 5295945ab2 refactor: expose tbank-sync public api and guard its boundary (WealthLedger/backend#118) (#143)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m28s
2026-10-28 16:40:01 +03:00
codex-bot 22668da3fa refactor: remove tbank-sync operations import cycle (WealthLedger/backend#116) (#142)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m14s
2026-10-28 16:26:49 +03:00
ksv741 2325b63883 fix: reset portfolio value points inflated by double-counted cash (WealthLedger/backend#140)
Backend verify / commits (pull_request) Successful in 27s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 4m33s
2026-10-28 08:00:20 +03:00
ksv741 f2843e30bd fix: count brokerage cash once and skip short-position return percent (WealthLedger/backend#140) 2026-10-28 08:00:20 +03:00
codex-bot 97fc41fa29 feat: extend account list portfolio summary (#138)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 4m43s
2026-10-27 05:44:07 +03:00
ksv741 3dcd1a69a6 chore: restore main ancestry in develop (WealthLedger/backend#135)
Backend verify / commits (pull_request) Successful in 31s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 5m47s
2026-10-25 10:06:30 +03:00
codex-botandksv741 8503429b46 chore(release): 2.3.1 (#132)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 23s
Backend verify / verify (push) Successful in 6m19s
Backend image / publish (push) Successful in 13m17s
Backend patch #129 for WealthLedger/deploy#12. Verified release SHA `0dff4b43773041a73a725fa0b9d0c665eac0cab2`; fast-forward-only main then tag on exact SHA.

---------

Co-authored-by: Sergey Krylov <ksv741@mail.ru>
Reviewed-on: #132
Co-authored-by: codex-bot <codex-bot@ksv741.ru>
2026-10-24 21:32:57 +03:00
ksv741 0dff4b4377 chore(release): 2.3.1
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / commits (pull_request) Successful in 33s
Backend verify / verify (push) Failing after 6m28s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Successful in 6m13s
2026-10-24 21:00:38 +03:00
ksv741 a250142a86 fix: use available Debian packages in image build (#129)
Backend verify / commits (pull_request) Successful in 38s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 6m18s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m38s
2026-10-24 20:48:55 +03:00
ksv741 0a0e70b273 chore(release): 2.3.0
Backend verify / commits (pull_request) Successful in 35s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 6m23s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend image / publish (push) Failing after 6m34s
Backend verify / verify (push) Successful in 6m36s
2026-10-24 19:15:04 +03:00
ksv741 449cfcd6c5 chore(deps): update non-major dependencies #11
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 5m31s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 5m51s
2026-10-24 18:20:20 +03:00
codex-bot 966a1f793a feat: log unhandled errors and pretty dev output (WealthLedger/backend#120) (#126)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 5m39s
feat: log unhandled errors and pretty dev output (#120)
2026-10-24 15:06:34 +03:00
codex-bot d1f6969dc7 fix: persist refresh cookie across browser restarts (WealthLedger/backend#123)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 4m58s
2026-10-23 08:34:39 +03:00
ksv741 b20080ac85 chore(release): 2.2.0
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 13s
Backend verify / verify (pull_request) Successful in 5m38s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend image / publish (push) Successful in 7m58s
Backend verify / verify (push) Successful in 5m40s
2026-10-18 16:25:22 +03:00
codex-bot 32622c1dad test: cover auth token, cookie and expiry boundaries (WealthLedger/backend#61) (#113)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 6m26s
2026-10-18 15:55:05 +03:00
codex-bot 4f485ab0e9 chore: adopt linear git model in backend docs (#111)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 30s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 4m44s
2026-10-17 14:19:19 +03:00
codex-bot 8a6f60d965 fix: translate permanent invalid-token errors on online entrypoints (WealthLedger/backend#59) (#110)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 33s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m40s
2026-10-16 23:23:12 +03:00
codex-bot e03fad622b fix: align password policy to 8-72 printable ASCII on register (WealthLedger/backend#60) (#109)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 32s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m21s
2026-10-16 10:00:47 +03:00
ksv741 774e58ef80 Merge branch 'chore/30-sync-release' into develop
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 28s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 5m38s
2026-10-16 07:38:58 +03:00
codex-bot 702302126b test: await portfolio publication done instead of polling ticks (WealthLedger/backend#63) (#103)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 5m25s
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m39s
2026-10-15 15:40:03 +03:00
codex-bot ec6d9a2639 test: replace metadata sleeps with upstream start barriers (WealthLedger/backend#63) (#102)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 15s
Backend verify / verify (push) Failing after 6m46s
2026-10-15 14:46:09 +03:00
codex-bot 7bc7d8ddec test: replace late portfolio sleep with started and done barriers (WealthLedger/backend#63) (#101)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 6m21s
2026-10-15 12:23:14 +03:00
codex-bot 9e4f9d9cab test: isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend#65) (#100)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m59s
2026-10-15 11:50:47 +03:00
codex-bot a93f2d55e7 chore: make OpenAPI export atomic and add read-only check (WealthLedger/backend#3) (#97)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m4s
2026-10-15 11:24:38 +03:00
codex-bot 96d4de4195 refactor: remove unreachable catalog producer and sync orchestration (WealthLedger/backend#62) (#98)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m47s
2026-10-15 10:46:58 +03:00
codex-bot c789e33c41 chore: drop unused direct source-map-support dependency (WealthLedger/backend#4) (#96)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m22s
2026-10-15 06:15:47 +03:00
codex-bot 2ad3517d87 fix: restore screener OpenAPI constraints (WealthLedger/backend#70) (#95)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m50s
2026-10-15 06:00:39 +03:00
codex-bot 0d7839cf79 fix: separate T-Bank RPC network time from queue wait (WealthLedger/backend#81) (#94)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Successful in 5m20s
2026-10-15 05:41:21 +03:00
codex-bot 6975853656 fix: skip hourly terms overlapped by a long walk (WealthLedger/backend#54)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m56s
2026-10-14 22:43:08 +03:00
codex-bot 14bcb9da5f fix: surface durable hourly attempt in accounts and income meta (WealthLedger/backend#58)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 17s
Backend verify / verify (push) Failing after 7m31s
2026-10-14 18:00:20 +03:00
codex-bot b90cedd580 chore: unify verification preparation (WealthLedger/backend#66)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m44s
2026-10-14 17:01:53 +03:00
codex-bot da2e1d0781 perf: start bond search prices with details (WealthLedger/backend#71) (#89)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 14s
Backend verify / verify (push) Failing after 6m48s
2026-10-14 14:04:07 +03:00
codex-bot bbb5afff84 ci: validate edited PR titles (WealthLedger/backend#74)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m37s
Closes #74
2026-10-14 09:02:53 +03:00
codex-bot 285dd08472 perf: batch bank operation page relations (WealthLedger/backend#68)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m20s
Closes #68
2026-10-14 08:48:37 +03:00
codex-bot 1033d2114e test: slim sqlite runner regression workload (WealthLedger/backend#72)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 5m47s
Closes #72
2026-10-14 08:34:37 +03:00
codex-bot 203cc72cb2 chore: align SQLite driver version (WealthLedger/backend#67)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 4m18s
Closes #67
2026-10-14 08:12:51 +03:00
codex-bot 9b7e5e1215 refactor: extract shared public instrument projection (#69) (#84)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 18s
Backend verify / verify (push) Failing after 6m47s
2026-10-13 21:54:51 +03:00
codex-bot 31b0e15579 feat: unify income payment cards and drop period comparisons (WealthLedger/main#33) (#83)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 7m1s
feat: unify income payment cards and drop period comparisons (WealthLedger/main#33)
2026-10-13 18:16:46 +03:00
ksv741 3d50db7903 ci: validate release PR commits independently (WealthLedger/main#30)
Backend verify / commits (push) Skipped
Backend image / publish (push) Successful in 7m28s
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Successful in 5m3s
Backend verify / commits (pull_request) Successful in 27s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Successful in 5m11s
2026-10-12 23:39:30 +03:00
ksv741 8d5569a3c7 chore(release): 2.1.0
Backend verify / commits (pull_request) Failing after 31s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
2026-10-12 23:19:12 +03:00
ksv741 90486d4237 Merge pull request 'fix: bound hourly step awaits by the per-step wait limit (#56)' (#78) from fix/56-hourly-step-await-bound into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 7m0s
Reviewed-on: #78
2026-10-12 22:25:27 +03:00
ksv741 52fa3a36ba fix: bound hourly step awaits by the per-step wait limit (#56)
Backend verify / commits (pull_request) Successful in 32s
Backend verify / security (pull_request) Successful in 12s
Backend verify / verify (pull_request) Successful in 5m45s
2026-10-12 22:17:53 +03:00
ksv741 b9b76bab91 Merge pull request 'fix: restore terminal hourly lifecycle after persistence failure (#56)' (#77) from fix/56-harden-hourly-sync-lifecycle into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 7m10s
Reviewed-on: #77
Reviewed-by: hermes-agent <hermes@ksv741.ru>
2026-10-12 21:59:27 +03:00
ksv741 fa57a201fd fix: restore terminal hourly lifecycle after persistence failure (#56)
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 12s
Backend verify / verify (pull_request) Successful in 5m48s
2026-10-12 20:42:19 +03:00
codex-bot 96a442253e Merge pull request 'chore: align backend engineering workflow (WealthLedger/main#27)' (#76) from chore/27-engineering-workflow into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m24s
2026-10-12 11:00:36 +03:00
ksv741 c5248dbefc docs: pin backend workflow reference (WealthLedger/main#27)
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Successful in 5m3s
2026-10-12 10:22:13 +03:00
ksv741 c7d060023b fix: align backend workflow rules (WealthLedger/main#27)
Backend verify / commits (pull_request) Canceled after 0s
Backend verify / security (pull_request) Canceled after 0s
Backend verify / verify (pull_request) Canceled after 0s
2026-10-12 10:20:50 +03:00
ksv741 a31e8d2c4d chore: align backend engineering workflow (WealthLedger/main#27)
Backend verify / commits (pull_request) Successful in 31s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m56s
2026-10-12 09:20:31 +03:00
codex-bot 2f7c15205f Merge pull request 'docs: clarify backend verification policy' (#75) from codex/issue-24-process into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m9s
2026-10-11 23:23:10 +03:00
ksv741 d7895f287e docs: clarify backend verification policy
Backend verify / commits (pull_request) Successful in 34s
Backend verify / security (pull_request) Successful in 16s
Backend verify / verify (pull_request) Successful in 5m15s
2026-10-11 23:06:51 +03:00
codex-bot bcd5cb3dd0 feat: add stock screener
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 5m33s
2026-10-11 11:45:09 +03:00
ksv741 0ad84ff4f0 ci: skip checks on PR metadata edits
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m38s
2026-10-11 11:36:51 +03:00
ksv741 7d10ef8adf feat: add stock screener
Backend verify / commits (pull_request) Successful in 28s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m45s
2026-10-11 11:18:21 +03:00
ksv741 bc631e72de test: align resource availability metadata
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m21s
2026-10-09 23:10:50 +03:00
ksv741 947ffefa9b fix: keep candle retries read-only 2026-10-09 23:10:50 +03:00
ksv741 db375cbee7 test: align all accounts metadata 2026-10-09 23:10:50 +03:00
ksv741 a46a4112cb chore: prune obsolete eslint suppressions 2026-10-09 23:10:50 +03:00
ksv741 966b2a5836 test: align upcoming events with local reads 2026-10-09 23:10:50 +03:00
ksv741 323b83b582 feat: reconcile hourly investment resource states 2026-10-09 23:10:50 +03:00
codex-bot 5d857de7a5 Merge pull request 'feat: add hourly portfolio sync history' (#49) from release/2.0.0 into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m46s
Merge hourly portfolio sync history backend when checks pass
2026-10-09 09:57:20 +03:00
ksv741 2facd8675c test: increase ledger request timeout
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 5m21s
2026-10-09 09:50:59 +03:00
ksv741 4870d49503 test: extend ledger sqlite timeout
Backend verify / commits (pull_request) Successful in 32s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Failing after 4m32s
2026-10-09 09:43:41 +03:00
ksv741 85858a160f fix: unblock backend dependency audit
Backend verify / commits (pull_request) Successful in 36s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Failing after 4m47s
2026-10-09 09:14:28 +03:00
ksv741 0057925888 feat: add hourly portfolio sync history
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Failing after 12s
Backend verify / verify (pull_request) Skipped
2026-10-09 09:04:41 +03:00
ksv741 03e75f8d2b chore(release): 2.0.0
Backend image / publish (push) Successful in 9m4s
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 13s
Backend verify / verify (pull_request) Successful in 5m16s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 14s
Backend verify / verify (push) Successful in 6m13s
2026-10-07 18:14:23 +03:00
ksv741 dbb1748a5f chore: synchronize main history before release 2026-10-07 18:13:12 +03:00
ksv741 01e2de6a04 test: separate performance budget from e2e gate
Backend verify / commits (pull_request) Successful in 38s
Backend verify / security (pull_request) Successful in 39s
Backend verify / verify (pull_request) Successful in 5m46s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 16s
Backend verify / verify (push) Successful in 6m50s
2026-10-07 16:35:23 +03:00
ksv741 30e96e51e2 fix(deps): restore backend security gate
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 14s
Backend verify / verify (push) Failing after 10m22s
2026-10-07 15:19:42 +03:00
ksv741 a7619d5c8f perf(income): compute Moscow dates once
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 12s
Backend verify / verify (pull_request) Successful in 9m53s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 10m3s
2026-10-03 19:44:39 +03:00
ksv741 d698ed9425 refactor!: simplify runtime coordination #7
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 9m36s
Keep financial ownership, atomic publication and refresh CAS.
Failed refresh leaves cookies untouched; logout still clears them.

Remove full-history candle prefetch and special metadata retries.
Use native Pino request context instead of parallel middleware.

BREAKING CHANGE: remove investment stateVersion, queued sync state,
and tbank-sync status progress/resources. Consumers must observe
resources through sync=observe with the updated frontend contract.
2026-10-03 11:38:08 +03:00
ksv741 51f345bdc5 Merge branch 'develop' into main
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 1m46s
Backend verify / commits (pull_request) Successful in 35s
Backend verify / security (pull_request) Failing after 10s
Backend verify / verify (pull_request) Skipped
2026-10-02 08:22:31 +03:00
ksv741 0f27da9fd1 Merge main into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 1m48s
2026-10-02 08:21:02 +03:00
codex-bot 1be6db65d7 refactor: simplify investment data flow
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 7s
Backend verify / verify (push) Failing after 1m43s
2026-10-02 08:12:02 +03:00
codex-bot 99de4af678 Merge pull request #42 from codex/backend-eslint
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Canceled after 3m5s
2026-10-02 08:05:04 +03:00
ksv741 14a204de0d chore: guard backend responsibility boundaries
Backend verify / commits (pull_request) Successful in 35s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 10m0s
2026-10-01 20:53:01 +03:00
ksv741 a13cf84ead refactor: isolate operation ranges and intents 2026-10-01 20:53:01 +03:00
ksv741 7fe88a12c7 refactor: centralize provider projections 2026-10-01 20:53:00 +03:00
ksv741 190eb90ffe refactor: clarify nullable card metadata 2026-10-01 20:53:00 +03:00
ksv741 fb2e851e7e refactor: share authentication result type 2026-10-01 20:53:00 +03:00
ksv741 15a3d5f331 fix(deps): resolve npm audit advisories
Patch gRPC and the existing multer override.
Refresh vulnerable transitive versions.
Scope patched undici/basic-ftp to release-it; retain the audit gate.
2026-10-01 20:51:32 +03:00
ksv741 bee34290fb chore: synchronize develop before audit fixes 2026-10-01 20:08:37 +03:00
ksv741 6cd9c2dd28 perf: group catalog source reads by identity
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 11m54s
2026-09-28 16:37:01 +03:00
ksv741 b24433092b fix: resolve build imports before linting
Resolve dist imports in runtime scripts against their TypeScript sources.

Keep missing-module checks enabled and cover absent build output in the lint gate.

Verified npm ci, prisma generate and full verify with dist absent at startup.
2026-09-28 16:37:01 +03:00
ksv741 33592c1d5f refactor: enforce enum machine codes
Use owner enums for error and T-Bank protocol values with ESLint regression checks.

Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma.

Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
2026-09-28 16:37:01 +03:00
ksv741 183b7f1efd style: align multiline ternary operators 2026-09-28 16:37:01 +03:00
ksv741 b78c215ff1 refactor: replace ignored numeric literals with constants 2026-09-28 16:37:01 +03:00
ksv741 a970eaaf7a chore: strengthen backend eslint profile 2026-09-28 16:37:01 +03:00
ksv741 e76eb545d7 perf: group catalog source reads by identity
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 10m48s
2026-09-28 16:23:58 +03:00
ksv741 7d2262fb43 fix: resolve build imports before linting
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 11m25s
Resolve dist imports in runtime scripts against their TypeScript sources.

Keep missing-module checks enabled and cover absent build output in the lint gate.

Verified npm ci, prisma generate and full verify with dist absent at startup.
2026-09-28 15:48:44 +03:00
ksv741 33a5135741 chore: merge develop into eslint branch
Backend verify / commits (pull_request) Successful in 31s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m54s
2026-09-28 15:19:14 +03:00
ksv741 53b2794963 ci: reduce duplicate actions and serialize checks
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 10m28s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 3m32s
2026-09-28 14:12:38 +03:00
ksv741 2d14081d27 refactor: enforce enum machine codes
Backend security / security (push) Successful in 8s
Backend security / security (pull_request) Canceled after 0s
Backend verify / verify (push) Canceled after 9s
Backend verify / verify (pull_request) Canceled after 4s
Conventional commits / commits (pull_request) Successful in 31s
Use owner enums for error and T-Bank protocol values with ESLint regression checks.

Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma.

Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
2026-09-28 13:18:06 +03:00
ksv741 0303e60b64 style: align multiline ternary operators
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m11s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 34s
2026-09-28 12:33:56 +03:00
ksv741 9cc9fbdcff refactor: replace ignored numeric literals with constants
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m56s
2026-09-28 11:24:59 +03:00
ksv741 9b3558f96c chore: strengthen backend eslint profile 2026-09-28 11:04:18 +03:00
codex-bot ca5dbcb382 fix: harden auth and investment synchronization (#38)
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 11m48s
2026-09-27 14:32:06 +03:00
ksv741 4796d9d3f0 fix: harden auth and investment synchronization
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
Resolve A01-A10 and C01-C07 from the backend audit.
Preserve auth failures, forced candle reconciliation and forecast freshness.
Validate MOEX responses and nested HTTP errors; remove unused legacy paths.

Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability.
Record the remaining macOS native SQLite abort in the audit.
2026-09-27 14:09:05 +03:00
codex-bot 5d3835e796 fix(investments): align roles and date contracts (#37)
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 11m33s
2026-09-27 12:05:57 +03:00
ksv741 516e43203e fix(investments): align roles and date contracts
Backend security / security (push) Successful in 9s
Backend verify / verify (push) Successful in 10m23s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 10m39s
Conventional commits / commits (pull_request) Successful in 29s
Identify income dependencies by role and document screen-specific dates.
Convert inclusive calendar days into half-open source ranges while
preserving operation timestamps and existing stored coverage.
2026-09-27 11:38:13 +03:00
ksv741 92f216d520 chore(release): 1.0.0
Backend security / security (push) Successful in 8s
Backend image / publish (push) Successful in 12m35s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 9m53s
Backend verify / verify (push) Successful in 9m56s
2026-09-26 22:33:48 +03:00
ksv741 e4232138d0 test: prove reads finish before source synchronization
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 10m47s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 12m14s
Block synthetic upstream with an explicit barrier instead of comparing
total HTTP batch time with a two-second timer. Keep the 500 ms server
p95 gate and RPC assertions unchanged.
2026-09-26 21:45:50 +03:00
ksv741 d6e6c4ace5 feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite.
Coalesce scoped refreshes, fence connection revisions, preserve complete financial
snapshots and stabilize operation period state.

BREAKING CHANGE: investment reads return data/meta resource envelopes and locally
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
synchronization endpoints.
2026-09-26 21:29:15 +03:00
ksv741 0862d56c4e chore(release): 0.2.3
Backend image / publish (push) Successful in 5m20s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 2m32s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 3m36s
2026-09-19 16:25:12 +03:00
ksv741 7c0150c56b fix: correct Nexus routing and SonarQube host 2026-09-19 16:24:08 +03:00
ksv741 39f2ce5bc4 chore(release): 0.2.2
Backend image / publish (push) Failing after 2m30s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 2m27s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Failing after 2m55s
2026-09-19 15:07:57 +03:00
ksv741 4c916829ea fix: update Nexus registry host
Conventional commits / commits (pull_request) Successful in 26s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 2m29s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Failing after 3m4s
2026-09-19 14:51:27 +03:00
ksv741 386c91670e chore(release): 0.2.1
Conventional commits / commits (pull_request) Successful in 24s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 2m14s
Backend image / publish (push) Failing after 2m28s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Failing after 2m46s
2026-09-18 08:12:28 +03:00
ksv741 9437b6a311 docs: use shared Nexus account #25
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 2m25s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Successful in 9m3s
2026-09-17 20:53:04 +03:00
ksv741 0ec7ae51af ci: publish backend images to Nexus #25
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Successful in 2m27s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 2m22s
Conventional commits / commits (pull_request) Failing after 22s
2026-09-17 20:41:23 +03:00
ksv741 1946192be9 ci: add backend SonarQube analysis #23
Conventional commits / commits (pull_request) Successful in 27s
Backend security / security (pull_request) Successful in 1m14s
Backend verify / verify (pull_request) Successful in 2m39s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 4m26s
2026-09-16 21:15:21 +03:00
ksv741 0557f4ad1f chore(release): 0.2.0
Backend image / publish (push) Successful in 5m29s
Conventional commits / commits (pull_request) Successful in 27s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 2m28s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Successful in 2m29s
2026-09-16 11:35:52 +03:00
ksv741 d7cb7e3e20 fix: allow reviewed container smoke fixture
Conventional commits / commits (pull_request) Successful in 26s
Backend security / security (pull_request) Successful in 6s
Backend verify / verify (pull_request) Successful in 2m19s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Successful in 2m22s
2026-09-16 10:48:27 +03:00
ksv741 1a6be432f2 feat: add backend container delivery
Backend security / security (push) Failing after 5s
Backend verify / verify (push) Successful in 2m19s
Backend security / security (pull_request) Failing after 5s
Backend verify / verify (pull_request) Successful in 2m17s
Conventional commits / commits (pull_request) Successful in 25s
2026-09-16 09:41:56 +03:00
ksv741 32c12fb10d feat: generate OpenAPI without peer repositories
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 2m28s
Conventional commits / commits (pull_request) Successful in 25s
Backend security / security (push) Successful in 6s
Backend verify / verify (push) Successful in 2m16s
2026-09-15 20:45:39 +03:00
ksv741 016782657a docs: simplify conventions layout
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 2m3s
Conventional commits / commits (pull_request) Successful in 24s
Backend security / security (push) Successful in 7s
Backend verify / verify (push) Successful in 2m10s
2026-09-15 05:58:33 +03:00
ksv741 9810d47e42 docs: refine backend conventions and routing
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 2m12s
Conventional commits / commits (pull_request) Successful in 25s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 2m3s
2026-09-15 05:42:16 +03:00
259 changed files with 29142 additions and 9414 deletions
-25
View File
@@ -1,25 +0,0 @@
# Backend router
Объединяй triggers из смысла задачи, путей и обнаруженных boundaries.
Читай выбранные guides до решения; якорь допускает чтение только нужного раздела.
Просто backend-файл не требует всех conventions.
| Решение / сигнал | Читать |
| --- | --- |
| Новый файл/suffix/layer; поиск feature owner; progressive structure; перенос responsibility | [Architecture](conventions/backend-architecture.md) |
| TS vocabulary, domain literals, magic values | [Domain literals](conventions/backend-architecture.md#domain-literals-and-magic-values) |
| Controller, DTO, route/status, validation/exception, публичная HTTP-семантика | [HTTP API](conventions/backend-http-api.md) |
| Repository/data access; Prisma/schema/query, transaction, migration, atomic write | [Data](conventions/backend-data.md) |
| Тесты или behavior с testing risk | [Testing](conventions/backend-testing.md) |
| JWT, cookie, login/refresh/logout, guard/access, token encryption, sensitive logs | [Security](conventions/security-auth.md) |
| External client/integration boundary; T-Bank/gRPC/proto, retry/rate limit/cache | [Integrations](conventions/backend-integrations.md) |
| Logging/request context без изменения secrets/auth | [Logging](conventions/backend-architecture.md#logging) |
| Новый abstraction/dependency, refactor, неясный scope | [Общий выбор реализации](../../AGENTS.md) |
| Изменение request/response/route/status/error shape или Swagger metadata | HTTP API + [Источник HTTP-контракта](../../docs/architecture/http-api.md#источник-контракта) |
Новый endpoint обычно требует HTTP API + Testing; Architecture/Data/Security
добавляются по новым ответственности, persistence или access rules.
Изменение одного Prisma query без HTTP/auth границы не требует HTTP API/Security.
Auth refresh активирует Security + Data + Testing; HTTP API добавляется, если
меняется controller/HTTP contract. Exact docs/spec sources выбирай через SOURCES.
Повтори routing при расширении diff, включая новые и unstaged файлы.
-19
View File
@@ -1,19 +0,0 @@
# Источники backend
| Что нужно установить | Владелец |
| --- | --- |
| Implementation decision и безопасный pattern | [CONVENTIONS](CONVENTIONS.md) |
| HTTP errors, DTO semantics, public wire shape | [docs: HTTP API](../../docs/architecture/http-api.md); runtime OpenAPI из Nest — единственный editable source |
| Auth/session, account access, token encryption | [Аутентификация](../../docs/domains/authentication/overview.md), [доступ к данным](../../docs/architecture/containers.md#хранимые-данные-и-границы-доступа), [подключение брокера](../../docs/integrations/tbank.md) |
| Prisma schema/data ownership и migration context | [docs: Data](../../docs/architecture/containers.md#хранимые-данные-и-границы-доступа) |
| T-Bank/gRPC/cache, logging | [T-Bank](../../docs/integrations/tbank.md), [свежесть данных](../../docs/architecture/cross-cutting/data-freshness.md), [наблюдаемость](../../docs/architecture/http-api.md#наблюдаемость) |
| Money, account/position/income/cache meanings | [docs: Domain](../../docs/glossary.md) |
| Поведение конкретной feature | [docs: Features](../../docs/README.md#предметные-области) |
| Существующие behavioral requirements при необходимости | [specs index](../../specs/AI_INDEX.md) |
| Versions/scripts и scope автоматических checks | package.json/lockfile, ESLint/TypeScript/Jest/Prisma configs |
SOURCES — карта, не требование прочитать все ссылки. Code показывает реализацию,
ADR — rationale, docs — текущие факты; старый пример не отменяет контракт.
Исследование предлагает варианты, а actual source разрешает выбор. При missing
или conflicting security/data facts сначала установи источник. Для обычной
работы OpenSpec/SDD не требуется.
@@ -1,12 +1,54 @@
# Backend architecture # Backend architecture
## Устройство и data flow
NestJS + TypeScript modular monolith. Composition root `AppModule` подключает
бизнес-модули, validated configuration, Prisma и Pino. Общая `configureApp`
задаёт `/api/v1`, CORS/cookies, validation, request logging и error filter.
Точные versions/scripts — в [package.json](../package.json); source code
определяет фактическую реализацию.
```text
HTTP controller → application service/use case → pure domain rules
↓
repository/data access → Prisma → SQLite
↓
integration client → T-Bank gRPC / MOEX / NCR HTTP
```
`modules/auth`, `modules/banking/bank-cards` и области `modules/investments`
владеют пользовательскими возможностями. Сложные модули разделены на
presentation/application/domain/infrastructure; компактные остаются плоскими.
`*.module.ts` — Nest DI, `*.api.ts` — используемые cross-module exports.
Application использует конкретные providers; обязательных ports на каждый
класс нет.
Stocks/bonds — slices одного instruments module: catalog владеет identity/cache,
market — котировками, income — выплатами/личными позициями. Общая orchestration
в `instruments/application` использует DTO/provider records и не становится pure
domain из-за extraction в функцию. Boundary mappers преобразуют внешние records;
чистые расчёты portfolio/banking остаются у domain owner.
PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные
через concrete repositories; существующие связные CRUD/transaction services
сохраняют прямые queries по [data convention](conventions/data.md).
Money — currency + decimal string; расчёты используют точные общие helpers.
Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и
connection generation. Integration clients владеют protocol/deadline/retry;
provider records преобразуются перед чистыми domain calculations.
Пользовательский `tbank-connection` не владеет низкоуровневым gRPC client.
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
для публичных consumers.
Problem Details фильтруются общей HTTP boundary; native Pino добавляет request
context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
## Module-first organization ## Module-first organization
Backend — domain-oriented modular monolith с лёгким разделением Сначала выбери business owner в `modules/`, затем ответственность внутри него.
по ответственности. `src/modules` группирует код по бизнес-возможности: `auth`, `banking/bank-cards`, области `investments`.
В инвестициях отдельные Nest modules владеют `brokerage-accounts`, `portfolio`,
`operations`, `income`, `upcoming-events`, `instruments`, `tbank-connection`.
`instruments/stocks` и `instruments/bonds` — slices внутри одного модуля.
### Progressive structure ### Progressive structure
@@ -36,15 +78,6 @@ feature/
Для одного controller или пары DTO дополнительная поддиректория не нужна. Для одного controller или пары DTO дополнительная поддиректория не нужна.
Пустые папки, новые barrels, CQRS и interfaces не требуются для этой организации. Пустые папки, новые barrels, CQRS и interfaces не требуются для этой организации.
В текущем коде слои используют `auth`, `bank-cards`, `portfolio`, `operations`
и slices `instruments/stocks`, `instruments/bonds`. Общие application helpers
инструментов находятся в `instruments/application`; `instruments.module.ts`
компонует оба slice. `stock-position.ts` и `instrument-position.ts` формируют
результат из portfolio DTO; `instrument-market.ts` обрабатывает внешние данные
и ошибки Nest. Это application code, а не независимая domain model.
`brokerage-accounts`, `income`, `upcoming-events`, `tbank-connection` остаются
компактными плоскими modules. Наличие tests само по себе не требует слоёв.
## Поиск и создание файла ## Поиск и создание файла
При работе над feature проходи владельцев в порядке: При работе над feature проходи владельцев в порядке:
@@ -116,7 +149,7 @@ Cross-module consumers во всех строках ограничены пуб
`*.decorator.ts`, `*.config.ts`, `*.validation.ts`) сохраняют соответствующие `*.decorator.ts`, `*.config.ts`, `*.validation.ts`) сохраняют соответствующие
существующие роли в common/config. `tbank.crypto.ts` — локальная реализация существующие роли в common/config. `tbank.crypto.ts` — локальная реализация
шифрования токена; имя не является разрешением создавать generic crypto layer. шифрования токена; имя не является разрешением создавать generic crypto layer.
HTTP/e2e naming и расположение — в [Testing](backend-testing.md). HTTP/e2e naming и расположение — в [Testing](conventions/testing.md).
Без зафиксированной необходимости не вводи `*.helper.ts`, `*.utils.ts`, Без зафиксированной необходимости не вводи `*.helper.ts`, `*.utils.ts`,
`*.manager.ts`, `*.processor.ts`, `*.handler.ts`, `*.provider.ts`, `*.factory.ts`. `*.manager.ts`, `*.processor.ts`, `*.handler.ts`, `*.provider.ts`, `*.factory.ts`.
@@ -127,13 +160,20 @@ HTTP/e2e naming и расположение — в [Testing](backend-testing.md)
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими `*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
модулями providers, функции и типы. Внешний production import идёт через этот модулями providers, функции и типы. Внешний production import идёт через этот
API или файл Nest module. Чистый `integrations/tbank/decimal.ts` — отдельный API или файл Nest module. Чистые `integrations/tbank/decimal.ts`,
публичный вход для protobuf conversion: импорт арифметики не загружает DI/Prisma. `integrations/tbank/tbank-protocol.ts`, `integrations/tbank/tbank-error-code.ts` и
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
публичные входы для protobuf conversion, protocol/error vocabulary и interval
rules: импорт этих значений и расчётов не загружает DI/Prisma.
Внутри модуля используй прямые локальные импорты. Внутри модуля используй прямые локальные импорты.
Tests могут импортировать внутренний owner для проверки поведения и mocks. Tests могут импортировать внутренний owner для проверки поведения и mocks.
Новый export требует текущего потребителя; controller и persistence остаются Новый export требует текущего потребителя; controller и persistence остаются
приватными. Не добавляй `index.ts` на каждом уровне. приватными. Не добавляй `index.ts` на каждом уровне.
Circular dependencies устраняй изменением ownership/composition. `forwardRef()`
не является стандартным исправлением boundaries; исключение требует конкретной
неизбежной связи и проверки DI initialization.
Каждый provider регистрируется у одного владельца. Потребители импортируют Каждый provider регистрируется у одного владельца. Потребители импортируют
его module, а не повторяют provider: это сохраняет единственный кэш и лимитер. его module, а не повторяют provider: это сохраняет единственный кэш и лимитер.
`AuthModule` экспортирует guard; наличие JWT не заменяет account access check. `AuthModule` экспортирует guard; наличие JWT не заменяет account access check.
@@ -162,37 +202,25 @@ Tests могут импортировать внутренний owner для п
может использовать application types/input и domain rules; новые interfaces может использовать application types/input и domain rules; новые interfaces
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
по [Data](backend-data.md); use case получает данные через repository и сам по [Data](conventions/data.md); use case получает данные через repository и сам
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется. Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
DTO остаются транспортными runtime classes; существующее использование DTO DTO остаются транспортными runtime classes; существующее использование DTO
на application boundary допустимо по [HTTP API](backend-http-api.md). на application boundary допустимо по [HTTP API](conventions/http-api.md).
Сохраняй существующий error contract: HTTP exceptions остаются на application/ Сохраняй существующий error contract: HTTP exceptions остаются на application/
transport boundary. Чистому расчёту не нужен новый framework ошибок. transport boundary. Чистому расчёту не нужен новый framework ошибок.
Точные ограничения — [HTTP](backend-http-api.md), [Data](backend-data.md), Mapping и stable codes — по [Errors](conventions/error-handling.md).
[Security](security-auth.md), [Integrations](backend-integrations.md). Точные ограничения — [HTTP](conventions/http-api.md), [Data](conventions/data.md),
[Security](conventions/security-auth.md), [Integrations](conventions/integrations.md).
## Сценарии и размер сервисов ## Сценарии и размер сервисов
Разделяй независимые операции, а не строки. `BankCardsService` отвечает за карты, Разделяй независимые операции, а не строки. Пример: `BankCardsService` владеет
`BankOperationsService` — журнал/переводы/возвраты, `ExpenseCategoriesService` — картами, `BankOperationsService` — журналом/переводами, `GetBankSummaryUseCase` —
категории, `GetBankSummaryUseCase` — сводку. `BankCardsRepository` сохраняет сводкой через repository и чистый расчёт. Не разрывай денежную transaction,
общий transaction retry и owner lookup; денежные транзакции не разрываются. общий cache/limiter или pending promises при extraction.
Чистые расчёты банковских карт находятся в `domain` этого модуля.
У инструментов `StockCatalogService` / `BondCatalogService` хранят идентичность Расширение abstractions — по [change scope](conventions/engineering.md#change-scope).
листинга и общий кэш slice; market service — котировки/свечи, income service — выплаты/личные позиции.
`GetStockCompanyUseCase` владеет сведениями об эмитенте. Ключи, TTL и pending
promises разделяют прежний экземпляр кэша; extract не должен менять generation.
Portfolio и operations сохраняют связную координацию снимков/страниц и кэшей.
Расчёты портфеля находятся в `portfolio/domain`; преобразование брокерских
позиций — в `tbank-portfolio.mapper.ts`. `bond-money.mapper.ts` преобразует
ответ клиента и не является value object.
Выбирай обычную функцию для чистого вычисления; repository contract — только
для реальной границы. CQRS/buses/events, BaseRepository/BaseService, DI token
для каждого класса, mapper/presenter для каждого DTO и новые dependencies
не нужны для структурного переноса.
## Common ## Common
@@ -205,67 +233,42 @@ protobuf units/nano conversion — T-Bank integration. Domain используе
## Domain literals and magic values ## Domain literals and magic values
Finite business vocabulary must be modeled explicitly; incidental implementation Общий выбор symbolic values и числовых policies —
literals stay inline. Это относится к error codes, statuses, operation/account/ [Domain values](conventions/engineering.md#domain-values).
instrument/transaction types, permissions, roles, feature states и бизнес-режимам. Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай
`unknown` + runtime narrowing; ESLint запрещает explicit `any`
Перед новым определением найди existing owner/type/value. В branching и comparisons в domain/application model, тестах и конфигурации TypeScript. `declare` допустим для заполняемых извне
используй символическое значение владельца; не повторяй raw domain literals. DTO fields, не для сокрытия неинициализированного owned state.
Храни словарь у domain/module/entity с соблюдением границ слоёв, без глобальной
свалки `constants.ts`. Сохраняй принятый pattern; для нового project-owned словаря
без подходящего аналога используй `as const` с derived union:
```ts
const BankErrorCode = {
InvalidDate: "BANK_INVALID_DATE",
ImmutableOperation: "BANK_IMMUTABLE_OPERATION",
} as const;
type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
// Вместо code === "BANK_INVALID_DATE":
if (code === BankErrorCode.InvalidDate) { /* existing handling */ }
```
Значимые numeric policies (timeout, retry, page size, threshold, coefficient)
называй по смыслу и единицам: `REQUEST_TIMEOUT_MS`, `CACHE_TTL_SECONDS`,
`MAX_RETRY_COUNT`, `PAGE_SIZE`. Оставляй inline UI labels в принятом месте,
log messages, индексы, `index + 1`, `Math.max(value, 0)`, `page ?? 1`,
`percentage * 100` и очевидные локальные детали без semantic benefit.
При неясном owner/смысле оставь значение и укажи ограничение в review.
Не мигрируй корректные existing enum/union styles массово. Не добавляй ради
правила factories, registries, schemas, loaders, abstraction layers, dependencies,
custom lint/AST rules, generation/validation scripts или тесты существования
констант. Semantic review проверяет ownership, единое написание и неизменность
wire values; обычные behavior tests проверяют поведение.
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary, OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
что application/domain. Например, `BankOperationKind` принадлежит bank-cards/domain, что application/domain. Например, `BankOperationKind` принадлежит bank-cards/domain,
а DTO получает допустимые значения через `Object.values(BankOperationKind)`. а DTO получает допустимые значения через `Object.values(BankOperationKind)`.
Frontend переиспользует generated OpenAPI types/values по возможностям генератора, Публичные enum values входят в OpenAPI artifact. Внешние/generated enums
а не определяет независимый backend-owned словарь. Внешние/generated enums
сохраняй; generated Prisma/protocol code вручную не меняй. сохраняй; generated Prisma/protocol code вручную не меняй.
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule `@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
отключено в пользу TS-версии. Исключения для очевидных чисел и их bigint форм, отключено в пользу TS-версии. Общего списка `ignore` для чисел и bigint нет:
индексов, default values, enum/type declarations и readonly properties заданы значения, проверяемые правилом, получают предметные имена у владельца.
в ESLint config. Test fixtures/expected values остаются inline, остальные lint Исключения для индексов, default values, enum/type declarations и readonly
правила продолжают действовать. Numeric policy получает предметное имя и единицы; properties заданы в ESLint config. Test fixtures/expected values остаются inline,
semantic magic strings проверяются conventions/review без нового инструмента. остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
Machine codes и protocol values задаются строковыми enum у владельца.
`no-restricted-syntax` запрещает inline-коды и значения известных protocol families
в production TS; `no-unsafe-enum-comparison` проверяет сравнения enum.
Внешние открытые строки проверяются на boundary без cast и без потери unknown fallback.
Tests сохраняют независимые literal wire assertions; обычный текст и env keys
не считаются кодами. Новые словари требуют проверки владельца и расширения selectors.
## Logging ## Logging
Единственный logger — nestjs-pino; application events используют существующий События, levels, correlation и safe fields — по
[StructuredLogger](../../src/common/logging/structured-logger.service.ts) и [Observability](conventions/observability.md).
request context. Выбирай стабильное имя события и разрешённые scalar fields.
Wrapper отбрасывает неизвестные поля, но это не разрешение помещать secret
в `message`/`stack`: review sensitive logging обязателен.
Не добавляй второй logger или сырой request/body/error payload.
Источник rationale — [ADR 008](../../../docs/adr/008-pino-backend-logging.md);
при secrets/user data читай также Security.
## Проверка ## Проверка
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case. `npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
Смысл boundaries проверяется review: backend не имеет отдельного автоматического ESLint проверяет прямые imports: domain не зависит от transport/persistence,
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner. controllers делегируют application, use cases не импортируют Prisma. Чистые
protocol inputs разрешены; existing CRUD/transaction services сохраняют DB owner.
Транзитивные зависимости, DI composition и смысл ownership проверяются review:
backend не имеет полного FSD/DI graph checker. Tests/build не доказывают owner.
-58
View File
@@ -1,58 +0,0 @@
# Backend HTTP API
## Выбор boundary
Сначала определи, меняется wire contract или только реализация существующего
use case. Для route/request/response/status/error shape читай
[HTTP semantics и источник контракта](../../../docs/architecture/http-api.md).
| Проверка | Где выполнять |
| --- | --- |
| Тип/формат/диапазон входа | Runtime DTO class с class-validator/class-transformer |
| Согласованность полей, бизнес-ограничение | Feature service/domain helper |
| Session и identity | Existing guard/strategy |
| Доступ user к конкретному account | Existing access service |
| HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter |
Controller объявляет contract и делегирует; не обращается напрямую к Prisma/gRPC
и не выполняет финансовые расчёты или сложную оркестрацию.
Controller вызывает application use case/service. Identity берётся из verified
`request.user`, не из body/userId, предоставленного клиентом. Access rules активируют Security.
## DTO и errors
DTO — runtime class: TS interface не предоставляет validation metadata.
`*.controller.ts` и `*.dto.ts` остаются около HTTP boundary, в root компактного
module или presentation. DTO можно передать application boundary при совпадении
формы; отдельный Input с теми же полями не нужен. Domain не импортирует DTO:
например, чистые денежные расчёты используют `Money`, а Swagger — `MoneyDto`.
Нормализованная форма input нужна только при смысловом отличии от wire contract;
выбор suffix принадлежит [Architecture](backend-architecture.md).
ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`).
Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён.
Для query numbers/arrays выбирай явное преобразование и валидаторы, как в
[operations DTO](../../src/modules/investments/operations/presentation/brokerage-operation.dto.ts). Swagger metadata
описывает те же required/optional/nullable/enum/range значения.
Используй типизированные response DTO, `ApiOkResponse`/другой фактический status
и существующий `ApiProblemResponses`. При новых ошибках задай публичный stable
`code`; сообщение внутреннего exception не является UI contract. Не возвращай
raw upstream error, Prisma record с secrets или stack. Текущая форма ошибок
описана у HTTP owner; не добавляй поля RFC только потому, что они встречаются в tutorial.
**Безопасный путь:** используй exception и общий filter, а не локальный
`res.status(...).json(...)`. Исключение — транспортная операция вроде cookie;
следуй существующему auth controller с `passthrough` и явно заданным status.
## Пример и проверка
[Operations controller](../../src/modules/investments/operations/presentation/brokerage-operations.controller.ts)
показывает guard, user.sub, DTO query и service delegation.
Для нового field обновляй canonical source и generated artifacts в определённом
направлении; body type frontend не исправляется вручную.
Проверь happy path и применимые invalid input/access/not-found/domain/upstream
errors на boundary, где они возникают. Unit вызов controller не запускает pipes,
guards или serialization. `npm run test:openapi-contract` проверяет schema parity,
но не поведение invalid request; выбирай соответствующий test по
[Testing](backend-testing.md). Финальный gate не заменяет semantic review ошибок.
@@ -15,13 +15,20 @@ PrismaService — существующий shared DB provider с lifecycle пр
| Независимые reads | Не добавляй transaction ради внешнего вида | | Независимые reads | Не добавляй transaction ради внешнего вида |
| Общая persistence abstraction | Только при реальной общей ответственности; generic repository для одной Prisma реализации не нужен | | Общая persistence abstraction | Только при реальной общей ответственности; generic repository для одной Prisma реализации не нужен |
Размещение repository определяется [Architecture](backend-architecture.md): Размещение repository определяется [Architecture](../architecture.md):
в слоистом module это `infrastructure/`. в слоистом module это `infrastructure/`.
Repository — конкретный класс `*.repository.ts` у business owner, без обязательных Repository — конкретный класс `*.repository.ts` у business owner, без обязательных
interface/port/adapter. Он владеет queries, includes/filtering, DB mapping и interface/port/adapter. Он владеет queries, includes/filtering, DB mapping и
согласованным чтением. Суффиксы `*.persistence.ts`, `*.store.ts`, `*.dao.ts` согласованным чтением. Суффиксы `*.persistence.ts`, `*.store.ts`, `*.dao.ts`
для той же роли не используем. Nest module регистрирует repository приватно. для той же роли не используем. Nest module регистрирует repository приватно.
Новые business decisions (можно ли менять архивную карту, допустим ли перевод)
принадлежат application/domain; repository сохраняет persistence и ownership
ограничения. Existing checks/HTTP helpers в BankCardsRepository — migration
candidate, не повод дублировать их при новом use case или разрывать transaction.
`BaseRepository<T>`, `GenericRepository<T>` и RepositoryFactory поверх Prisma
не нужны без отдельной общей ответственности.
`BankCardsRepository.summaryData` проверяет владельца карты и получает операции `BankCardsRepository.summaryData` проверяет владельца карты и получает операции
с категориями в одной read transaction. `GetBankSummaryUseCase` выбирает период, с категориями в одной read transaction. `GetBankSummaryUseCase` выбирает период,
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
@@ -44,16 +51,16 @@ application checks. Предварительный lookup не заменяет
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
lock во время retry внешней сети. Access/cache generation semantics — у lock во время retry внешней сети. Access/cache generation semantics — у
[T-Bank source](../../../docs/integrations/tbank.md#граница-финансового-кэша). [T-Bank integration](integrations.md#retry-cache-и-protocol).
## Деньги и точность ## Деньги и точность
Для денежных значений используй existing decimal helpers и строковый wire value, Для денежных значений используй existing decimal helpers и строковый wire value,
не JS floating-point арифметику для сумм. Currency — отдельная часть Money; не JS floating-point арифметику для сумм. Currency — отдельная часть Money;
разные валюты не складываются без явно заданного conversion contract. разные валюты не складываются без явно заданного conversion contract.
Источник: [Money](../../../docs/glossary.md#money), Источник формы — `src/common/money.types.ts`;
пример: [decimal.ts](../../src/common/decimal.ts). UI formatting не меняет пример точной арифметики: [decimal.ts](../../src/common/decimal.ts).
backend ownership расчётов и доменную семантику. Форматирование ответа не меняет доменную семантику и точность расчёта.
## Schema и migration ## Schema и migration
@@ -66,12 +73,12 @@ client обновляй из schema. Не переписывай уже прим
**Безопасный путь:** additive изменение, заполнение/проверка данных и только затем **Безопасный путь:** additive изменение, заполнение/проверка данных и только затем
удаление прежней формы, если задача этого требует. Примеры schema/migrations: удаление прежней формы, если задача этого требует. Примеры schema/migrations:
[Prisma source](../../prisma/schema.prisma); исторический rationale — [Prisma source](../../prisma/schema.prisma). SQLite — embedded storage текущего
[ADR 001](../../../docs/adr/001-prisma-sqlite-local-mvp.md), не текущий список таблиц. приложения; файл БД и encryption key требуют согласованного backup/restore.
## Проверка ## Проверка
`npx prisma validate`; после schema change `npx prisma generate` и `npx prisma validate`; после schema change `npx prisma generate` и
`npm run typecheck:app`. Invariants/atomicity/migration safety требуют tests `npm run typecheck:app`. Invariants/atomicity/migration safety требуют tests
на соответствующей boundary: mock Prisma не доказывает реальную transaction. на соответствующей boundary: mock Prisma не доказывает реальную transaction.
Выбор integration/e2e и известные ограничения — в [Testing](backend-testing.md). Выбор integration/e2e и известные ограничения — в [Testing](testing.md).
+97
View File
@@ -0,0 +1,97 @@
# Работа в репозитории
## Граница и порядок работы
Репозиторий сам содержит инструкции, исходники, конфигурацию и входы своих
проверок. Сборка, CI и обязательный маршрут чтения работают без соседних
checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Выбери lightweight или SDD по Engineering Workflow из AGENTS; OpenSpec lifecycle
начинай после явного решения Change Owner.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из backend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope
Сначала расширь существующего владельца. Иначе используй standard library,
платформу или установленную dependency; небольшая новая реализация допустима
при собственной ответственности. Не создавай helper/interface/wrapper ради
возможного reuse. Общее имя не означает общую бизнес-ответственность.
Сохраняй validation, authorization, atomicity, accessibility и migration safety.
После diff выполни Boy Scout review: проверь новое дублирование, owner, callers,
небольшие связанные legacy issues и соответствие local conventions. Исправляй
непосредственно связанные low-risk проблемы; широкий cleanup — отдельная задача.
## Domain values
Finite business vocabulary принадлежит одному domain/module/entity owner.
Переиспользуй его symbols; для machine codes и protocol values используй
строковые `enum`, для структуры с разными payload — discriminated union.
Сохраняй wire values; generated/external enums не переписывай вручную.
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
обычные labels не выноси в constants без semantic benefit.
Локальный TypeScript/lint профиль определяет дополнительные ограничения.
## Dependencies
Проверь аналоги и direct dependencies в местном manifest до добавления пакета.
Новая dependency оправдана текущей потребностью и меньшей общей сложностью;
оцени compatibility, runtime/bundle и maintenance/security. Импортируемый пакет
объявляется напрямую; транзитивная установка не является контрактом.
Сохраняй exact versions и lockfile; воспроизводимая установка — `npm ci`.
Peer-конфликт решай совместимыми версиями без `--force`/`--legacy-peer-deps`.
Используй существующий analyzer; не добавляй второй для той же проверки.
## Test Intent
Перед нетривиальным тестом кратко определи наблюдаемый исход, regression risk,
значимые edge/error/concurrency cases и самую дешёвую надёжную boundary.
Отдельный artifact не нужен. Проверяй результат, а не private call order,
декларации, тривиальные wrappers или поведение framework.
При bug сначала воспроизведи исход. Mock race не доказывает DB atomicity.
Уровни и runnable commands выбирай по местной testing convention.
## Проверки и завершение
| Когда | Что проверить |
| --- | --- |
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Имя ветки и связь PR с реальной Issue проверяются вручную: commitlint
проверяет синтаксис commit/PR title, но не существование и соответствие Issue.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits с parser-recognized Issue reference; допустим
`spec:`. Каждый commit содержит одну задачу. Исключения release и правила
ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. PR сливается squash или rebase-merge; merge-коммит не
используется. `main` обновляется только fast-forward из `develop`, поэтому
`origin/main` всегда предок `origin/develop`:
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
Перед релизом прочитай местный README, раздел «Релизы».
+40
View File
@@ -0,0 +1,40 @@
# Backend errors
## Поток
```text
application/domain outcome → application mapping → HttpExceptionFilter
→ application/problem+json
```
Сохраняй принятый профиль RFC 9457: `status`, стабильный публичный `code`,
для validation — `errors: [{ pointer, detail }]`. Точный контракт validation
описан в [HTTP API](http-api.md#источник-контракта).
Не добавляй `type/title/instance` как обязательные поля без contract change.
Чистый domain не импортирует Nest HTTP exceptions. Существующий application
service может бросить подходящий `HttpException` с public code; не нужен новый
error framework/registry. Если domain outcome требует HTTP interpretation,
маппинг принадлежит application/transport boundary.
## Codes и безопасность
- Используй existing code владельца, не сравнивай внутренние сообщения.
Новое значение имеет один symbolic owner; metadata/validation и consumers
согласуются по [domain values](engineering.md#domain-values).
- Общий envelope, default status mapping и unexpected errors принадлежат
[HttpExceptionFilter](../../src/common/filters/http-exception.filter.ts).
Не дублируй `res.status().json()` в controllers/services.
- Неожиданный exception → `500 / INTERNAL_SERVER_ERROR`, без message/stack,
SQL, credentials или raw upstream payload в ответе.
- External auth failure и временная недоступность имеют разные outcomes;
translation выполняется на existing integration/access boundary.
- Логи используют [observability](observability.md). Redaction не
делает произвольное сообщение ошибки безопасным.
## Проверка
Для изменённого кода проверь observable status/code и сохранность данных.
Для validation нужен request через `configureApp`: fabricated `ValidationError`
в unit test filter не доказывает форму, выходящую из реального ValidationPipe.
OpenAPI parity проверяет schema, а не response pipeline; [Testing](testing.md).
+88
View File
@@ -0,0 +1,88 @@
# Backend HTTP API
## Выбор boundary
Сначала определи, меняется wire contract или только реализация существующего
use case. Для route/request/response/status/error shape используй
[локальный публичный контракт](#источник-контракта) и configured HTTP tests.
| Проверка | Где выполнять |
| --- | --- |
| Тип/формат/диапазон входа | Runtime DTO class с class-validator/class-transformer |
| Согласованность полей, бизнес-ограничение | Feature service/domain helper |
| Session и identity | Existing guard/strategy |
| Доступ user к конкретному account | Existing access service |
| HTTP envelope/безопасная ошибка | Общий HttpExceptionFilter |
Controller объявляет contract и делегирует; не обращается напрямую к Prisma/gRPC
и не выполняет финансовые расчёты или сложную оркестрацию.
Controller вызывает application use case/service. Identity берётся из verified
`request.user`, не из body/userId, предоставленного клиентом. Access rules активируют Security.
## DTO и errors
DTO — runtime class: TS interface не предоставляет validation metadata.
`*.controller.ts` и `*.dto.ts` остаются около HTTP boundary, в root компактного
module или presentation. DTO можно передать application boundary при совпадении
формы; отдельный Input с теми же полями не нужен. Domain не импортирует DTO:
например, чистые денежные расчёты используют `Money`, а Swagger — `MoneyDto`.
Нормализованная форма input нужна только при смысловом отличии от wire contract;
выбор suffix принадлежит [Architecture](../architecture.md).
ValidationPipe уже настроен в `configureApp` (`transform`, `whitelist`).
Не обещай rejection неизвестных fields: `forbidNonWhitelisted` не включён.
Для query numbers/arrays выбирай явное преобразование и валидаторы, как в
[operations DTO](../../src/modules/investments/operations/presentation/brokerage-operation.dto.ts). Swagger metadata
описывает те же required/optional/nullable/enum/range значения.
Используй типизированные response DTO, `ApiOkResponse`/другой фактический status
и существующий `ApiProblemResponses`. Prisma model не является public contract:
response выбирает поля явно. Stable codes, RFC 9457 профиль и mapping принадлежат
[Errors](error-handling.md).
**Безопасный путь:** используй exception и общий filter, а не локальный
`res.status(...).json(...)`. Исключение — транспортная операция вроде cookie;
следуй существующему auth controller с `passthrough` и явно заданным status.
## Пример и проверка
Prefix `/api/v1` задаёт `configureApp`. Route/status/request/response/OpenAPI
change — изменение публичного backend contract; несовместимое изменение требует
согласованного перехода consumers, а не незаметной правки DTO. Новый API version
не вводи для внутреннего refactor.
Pagination сохраняет contract endpoint: operations использует opaque cursor,
limit и DTO bounds. Не заменяй cursor offset/page и не добавляй global pagination
helper ради первого endpoint. Новая pagination явно определяет порядок,
границу страницы и end-of-list; reused cursor не должен менять filters/owner.
[Operations controller](../../src/modules/investments/operations/presentation/brokerage-operations.controller.ts)
показывает guard, user.sub, DTO query и service delegation.
Для нового field обновляй Nest DTO/metadata и публикуемый OpenAPI artifact.
Потребители получают публичный контракт независимо от внутреннего устройства API.
Проверь happy path и применимые invalid input/access/not-found/domain/upstream
errors на boundary, где они возникают. Unit вызов controller не запускает pipes,
guards или serialization. `npm run test:openapi-contract` проверяет schema parity,
но не поведение invalid request; выбирай соответствующий test по
[Testing](testing.md). Финальный gate не заменяет semantic review ошибок.
## Источник контракта
Nest DTO/controllers/Swagger metadata определяют публичный HTTP API.
`npm run openapi:generate` собирает backend и записывает корневой `openapi.json`.
Nest preview читает metadata без запуска HTTP-порта, провайдеров, БД и T-Bank;
команда не требует deployment environment. Artifact исключён из Git.
При изменении API обнови DTO/metadata и выполни генерацию. Потребители получают
artifact отдельно; backend не читает их репозитории. `test:openapi-contract`
сравнивает вывод генератора с runtime schema configured app во временном каталоге.
Проверка подтверждает правильность генерации, но без versioned baseline не
выявляет breaking change относительно предыдущего выпуска. Для такого изменения
согласуй переход consumers и проверь relevant HTTP behavior.
HTTP prefix — `/api/v1`; Swagger доступен согласно `src/config/swagger.config.ts`.
Общий error response — `application/problem+json` со `status` и публичным `code`.
Validation требует `400 / VALIDATION_ERROR` и `errors: [{ pointer, detail }]`.
ValidationPipe сохраняет структурированные field errors без `target/value`,
а filter формирует JSON pointers, включая вложенные поля. Schema parity не
доказывает правильность `errors`; изменения проверяются через реальный HTTP pipeline.
@@ -2,12 +2,12 @@
## Выбор boundary ## Выбор boundary
T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient. Сервисы use case T-Bank gRPC принадлежит TbankClient; MOEX metadata — MoexClient; NCR ratings —
NcrRatingsClient. Сервисы use case
используют объявленные клиентом records; SDK clients, metadata и transport errors используют объявленные клиентом records; SDK clients, metadata и transport errors
остаются внутри integration. Frontend не вызывает остаются внутри integration. Credentials и proto client не входят в публичный
T-Bank и не получает credentials/proto client. Reference source — HTTP API. Connection lifecycle проверяется на границе соответствующего module
[ADR 003](../../../docs/adr/003-tbank-server-side-grpc-boundary.md) и и [Security](security-auth.md).
[connection behavior](../../../docs/integrations/tbank.md).
`modules/investments/tbank-connection` — пользовательская capability чтения, `modules/investments/tbank-connection` — пользовательская capability чтения,
создания/замены и удаления подключения. `integrations/tbank` — технические создания/замены и удаления подключения. `integrations/tbank` — технические
@@ -17,12 +17,14 @@ RPC, credentials/access и resilience. Эти владельцы не объед
`*.client.ts` содержит transport и его retries; `*.api.ts` только перечисляет `*.client.ts` содержит transport и его retries; `*.api.ts` только перечисляет
публичные exports. Все шесть `auth/operations/portfolio/tbank/moex/ncr.api.ts` публичные exports. Все шесть `auth/operations/portfolio/tbank/moex/ncr.api.ts`
имеют одинаковую роль программной границы, определённую в имеют одинаковую роль программной границы, определённую в
[Architecture](backend-architecture.md). Логика, DTO definitions и HTTP routes [Architecture](../architecture.md). Логика, DTO definitions и HTTP routes
в эти файлы не помещаются. Type exports обозначаются `export type`. в эти файлы не помещаются. Type exports обозначаются `export type`.
Преобразование provider records происходит на boundary: например, Преобразование provider records происходит на boundary: например,
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper `tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
остаётся отдельным чистым входом integration. Domain получает внутренние остаётся отдельным чистым входом integration. `tbank-protocol.ts` и
`tbank-error-code.ts` также импортируются напрямую: их enums и guards
не должны загружать DI/Prisma через service exports. Domain получает внутренние
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
client или mapper для простого копирования полей. Даты/units/nano разбираются client или mapper для простого копирования полей. Даты/units/nano разбираются
на границе клиента/его mapping, а не становятся моделью чистых расчётов. на границе клиента/его mapping, а не становятся моделью чистых расчётов.
@@ -30,7 +32,7 @@ client или mapper для простого копирования полей.
Для нового account use case сначала используй TbankAccessService: access, Для нового account use case сначала используй TbankAccessService: access,
connection generation и invalid-token translation не дублируются в callers. connection generation и invalid-token translation не дублируются в callers.
Mapper внешних финансовых значений остаётся внутри business capability, Mapper внешних финансовых значений остаётся внутри business capability,
обычно в infrastructure по [Architecture](backend-architecture.md). обычно в infrastructure по [Architecture](../architecture.md).
## Retry, cache и protocol ## Retry, cache и protocol
@@ -38,13 +40,23 @@ Existing client уже владеет bounded FIFO queue, rate limit, deadline
классификацией. Новый RPC проходит тот же путь; не добавляй вложенный retry loop классификацией. Новый RPC проходит тот же путь; не добавляй вложенный retry loop
в service. Не отключай TLS verification ради окружения: используй документированный в service. Не отключай TLS verification ради окружения: используй документированный
CA mechanism. Точные параметры и ограничения принадлежат CA mechanism. Точные параметры и ограничения принадлежат
[connection configuration](../../../docs/integrations/tbank.md#конфигурация-и-ограничения). [validated environment](../../src/config/env.validation.ts) и
[TbankClient](../../src/integrations/tbank/tbank.client.ts).
Все RPC, включая metadata, допускают один повтор временной upstream-ошибки.
Переполнение локальной очереди не повторяется. Свечи загружаются по требуемому
диапазону; prefetch портфеля и автоматический переход к `all` не поддерживаются.
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
собственный bounded timeout и безопасный unavailable outcome; не приписывай им
gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
При query/cache change установи key, user/account/generation ownership, При query/cache change установи key, user/account/generation ownership,
freshness и поведение late response. Старый результат не может обслужить новую freshness и поведение late response. Старый результат не может обслужить новую
connection generation. Client Cached Snapshot frontend отличается от backend connection generation. Успешная замена/deletion подключения меняет generation
cache/stale policy; semantics конкретного use case читай через даже при сохранении прежнего token; ошибка оставляет прежнее подключение.
[feature index](../../../docs/README.md#предметные-области). Кэш конкретного use case принадлежит его service и tests. Не переноси
сохранённый snapshot между пользователями или поколениями подключения.
Внешний auth failure и временная недоступность имеют разные public codes; Внешний auth failure и временная недоступность имеют разные public codes;
raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService raw gRPC exception не уходит клиенту. Existing translation — в TbankTokenService
@@ -64,4 +76,4 @@ Generated artifacts, если есть, создавай из source, без р
upstream responses. Проверь queue/deadline/retry limit, token translation, upstream responses. Проверь queue/deadline/retry limit, token translation,
cache isolation и late response для затронутых ветвей. Fixtures синтетические; cache isolation и late response для затронутых ветвей. Fixtures синтетические;
реальная сеть, секреты и пользовательские accounts не нужны. реальная сеть, секреты и пользовательские accounts не нужны.
Logging использует [existing logger](backend-architecture.md#logging). Logging использует [existing logger](../architecture.md#logging).
+24
View File
@@ -0,0 +1,24 @@
# Observability
- Единственный logger — nestjs-pino. Application events используют
[StructuredLogger](../../src/common/logging/structured-logger.service.ts),
стабильное имя события и разрешённые scalar fields. `console.log` и второй
logger в production не используются.
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
- Native `nestjs-pino` context и `pino-http` completion владеют correlation:
server request ID возвращается как `X-Request-ID`, client ID хранится отдельно.
Настройки принадлежат `common/logging/logging.module.ts`; второй AsyncLocalStorage
или HTTP completion middleware не нужен.
Передавай существующий context, не создавай собственный UUID в каждом service.
- StructuredLogger ограничивает поля, Pino redacts request secrets. Не логируй passwords,
tokens, cookie, Authorization, credentials, connection generation, raw body
или upstream error. Secret в `message/stack` не становится безопасным из-за
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
- Для ошибки используй public code и безопасный context операции/попыток;
[error mapping](error-handling.md) не отдаёт внутренние детали клиенту.
Проверяй новые поля/redaction/correlation existing logging tests; не добавляй
тест на каждую строку log. Existing ESLint не запрещает все вызовы `console`
отдельным правилом: до настройки встроенного `no-console` это semantic review.
+24 -12
View File
@@ -10,20 +10,25 @@ account access/connection generation — TbankAccessService; token crypto —
TbankTokenService/TbankCrypto. Расширяй существующую boundary, не добавляй TbankTokenService/TbankCrypto. Расширяй существующую boundary, не добавляй
второй JWT middleware, decrypt helper или refresh coordinator. второй JWT middleware, decrypt helper или refresh coordinator.
[Authentication](../../../docs/domains/authentication/overview.md) и Access проверяется JWT strategy; refresh передаётся httpOnly cookie.
[ADR 006](../../../docs/adr/006-auth-session-policy.md)/[009](../../../docs/adr/009-concurrent-refresh-rotation-policy.md) Сроки token/session принадлежат auth source и configured HTTP tests.
задают session semantics. Access хранится только в памяти frontend; refresh Source code конфигурации cookie —
передаётся httpOnly cookie. Source code конфигурации cookie —
[AuthController](../../src/modules/auth/presentation/auth.controller.ts); изменение flags/path требует [AuthController](../../src/modules/auth/presentation/auth.controller.ts); изменение flags/path требует
проверки browser/HTTP поведения, не только TypeScript. проверки browser/HTTP поведения, не только TypeScript.
Refresh rotation атомарна: один победитель, проигравший/superseded token получает Refresh rotation атомарна: один победитель, проигравший/superseded token получает
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace 401, но не меняет cookie и сам по себе не отзывает session победителя.
или иной security contract без текущей задачи. Logout идемпотентен; in-memory Cookie очищает logout; межвкладочная синхронизация не требуется, допустим re-login.
frontend session/cache очищается и при network failure. Не вводи Web Locks, replay history/grace
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
сессию и очищает refresh cookie на HTTP-границе.
## Trust boundaries ## Trust boundaries
Клиентский интерфейс не является security boundary. DTO runtime validation выполняется
server-side; whitelist не заменяет access checks. Environment/secret inputs
проверяет existing config owner; реальные credentials не попадают в fixtures.
Verified JWT не доказывает доступ к accountId из URL. Используй user.sub и Verified JWT не доказывает доступ к accountId из URL. Используй user.sub и
existing account access policy; не доверяй userId/token из клиента как authority. existing account access policy; не доверяй userId/token из клиента как authority.
Cross-user, invalid/expired/superseded token и concurrent requests — обязательные Cross-user, invalid/expired/superseded token и concurrent requests — обязательные
@@ -32,13 +37,20 @@ cases для соответствующего изменения.
T-Bank credentials остаются server-side и хранятся encrypted. Используй T-Bank credentials остаются server-side и хранятся encrypted. Используй
existing AES-256-GCM boundary с validated 32-byte key; не печатай plaintext, existing AES-256-GCM boundary с validated 32-byte key; не печатай plaintext,
шифротекст, cookie, Authorization или connection generation в logs/errors/API. шифротекст, cookie, Authorization или connection generation в logs/errors/API.
[Connection source](../../../docs/integrations/tbank.md) определяет Успешная замена/deletion меняет connection generation и инвалидирует её кэши;
cache invalidation при замене/deletion и ограничение key rotation. ошибка сохраняет прежнее подключение. Ключ не ротируется автоматически:
новый ключ не расшифрует сохранённые credentials. Нужен исходный ключ или
повторное сохранение token пользователем; сохраняй ключ отдельно от backup БД.
Не передавай Prisma User/Session целиком наружу; выбирай public DTO. Пароль Не передавай Prisma User/Session целиком наружу; выбирай public DTO. Пароль
обрабатывает существующий bcrypt flow. Не копируй требования о нормализации обрабатывает существующий bcrypt flow. Не копируй требования о нормализации
или длине из tutorial: сверяй runtime DTO, tests и feature source. или длине из tutorial: сверяй runtime DTO, tests и feature source.
Предпочитай Prisma query API. Raw SQL допускается только по конкретной нужде
с параметризованными значениями; не конкатенируй untrusted input и не передавай
его в `queryRawUnsafe`/`executeRawUnsafe`. Dynamic identifiers выбирай из
закрытого server-owned набора. Проверка user ownership сохраняется и в SQL.
## Безопасный путь и проверка ## Безопасный путь и проверка
При неясной ownership/crypto/session семантике прочитай exact source до edits. При неясной ownership/crypto/session семантике прочитай exact source до edits.
@@ -46,6 +58,6 @@ cache invalidation при замене/deletion и ограничение key ro
фиктивным success или log. Logger allowlist/redaction не делает произвольный фиктивным success или log. Logger allowlist/redaction не делает произвольный
message безопасным. message безопасным.
Targeted auth/access/crypto/logging tests + [Testing](backend-testing.md). Targeted auth/access/crypto/logging tests + [Testing](testing.md).
Cookie/guard/HTTP changes требуют configured HTTP proof; frontend Bearer/retry Cookie/guard/HTTP changes требуют configured HTTP proof.
проверяется MSW. Schema parity не доказывает отсутствие утечки secret. Schema parity не доказывает отсутствие утечки secret.
@@ -1,5 +1,9 @@
# Backend testing # Backend testing
Перед нетривиальными тестами сформулируй
[общий Test Intent](engineering.md#test-intent); отдельный документ
не требуется. Trivial low-risk изменения обходятся без него.
## Выбор уровня ## Выбор уровня
Проверяй риски доступа, денежных расчётов, целостности данных, конкурентных Проверяй риски доступа, денежных расчётов, целостности данных, конкурентных
@@ -11,7 +15,7 @@
| Pure mapper/decimal/domain branch | Unit со значимыми edge cases | | Pure mapper/decimal/domain branch | Unit со значимыми edge cases |
| Service orchestration/access/retry policy | Jest с mocks внешних boundaries и observable outcome | | Service orchestration/access/retry policy | Jest с mocks внешних boundaries и observable outcome |
| Pipe/guard/cookie/status/HTTP error | Configured Nest HTTP boundary; прямого вызова метода controller недостаточно | | Pipe/guard/cookie/status/HTTP error | Configured Nest HTTP boundary; прямого вызова метода controller недостаточно |
| OpenAPI metadata/wire schema | `test:openapi-contract` + потребительский contract check | | OpenAPI metadata/wire schema | `test:openapi-contract` + configured HTTP tests |
| DB constraint/CAS/transaction/migration | Integration с изолированной реальной DB | | DB constraint/CAS/transaction/migration | Integration с изолированной реальной DB |
| Только DTO declarations/thin wiring | Existing contract/type/build без искусственного unit test | | Только DTO declarations/thin wiring | Existing contract/type/build без искусственного unit test |
@@ -25,7 +29,7 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
- [BrokerageAccountsService tests](../../src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts): отсутствие connection не вызывает upstream, нормализация и точные суммы. - [BrokerageAccountsService tests](../../src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts): отсутствие connection не вызывает upstream, нормализация и точные суммы.
- [AuthService tests](../../src/modules/auth/application/auth.service.spec.ts): invalid/expired/superseded token и concurrent rotation; mock race test не является доказательством DB transaction. - [AuthService tests](../../src/modules/auth/application/auth.service.spec.ts): invalid/expired/superseded token и concurrent rotation; mock race test не является доказательством DB transaction.
- [HTTP filter tests](../../src/common/filters/http-exception.filter.spec.ts): безопасный error envelope. - [HTTP filter tests](../../src/common/filters/http-exception.filter.spec.ts): безопасный error envelope.
- [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): configured app + frontend snapshot, без записи snapshot и без T-Bank. - [OpenAPI integration](../../test/openapi-contract.e2e-spec.ts): генератор без сервера + configured app; временный JSON, без versioned snapshot и без T-Bank.
При bug сначала воспроизведи именно его исход, затем исправь responsible owner. При bug сначала воспроизведи именно его исход, затем исправь responsible owner.
Не создавай tests для каждой декларации, logs строки или поведения Prisma/Nest Не создавай tests для каждой декларации, logs строки или поведения Prisma/Nest
@@ -37,11 +41,13 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
Полный `npm run verify` включает lint, оба typecheck, Jest и build. Полный `npm run verify` включает lint, оба typecheck, Jest и build.
`npm run test:openapi-contract` — отдельный required check при wire contract change. `npm run test:openapi-contract` — отдельный required check при wire contract change.
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner; `npm run test:e2e` запускает функциональные HTTP suites через изолированный SQLite runner;
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную `npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов. базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`, Большой latency/load benchmark запускается отдельно через `npm run test:performance`:
вызывающего ошибку fresh migration на Prisma 7.9.1. Подробности и регрессия запуска — его абсолютный wall-clock budget не является merge gate на shared runner.
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных). [Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
HTTP suites требуют разрешения открывать локальные порты. Ошибка sandbox HTTP suites требуют разрешения открывать локальные порты. Ошибка sandbox
не считается успешным тестом; повторите проверку в подходящем окружении. не считается успешным тестом; повторите проверку в подходящем окружении.
-3
View File
@@ -1,3 +0,0 @@
{
"frontend": "0244acf429c525b23117f165bbe52ef5403eefd3"
}
+13
View File
@@ -0,0 +1,13 @@
**
!package.json
!package-lock.json
!.nvmrc
!nest-cli.json
!tsconfig*.json
!prisma.config.ts
!prisma/
!prisma/schema.prisma
!prisma/migrations/
!prisma/migrations/**
!src/
!src/**
+12
View File
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
-26
View File
@@ -1,26 +0,0 @@
name: Conventional commits
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
commits:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
+58
View File
@@ -0,0 +1,58 @@
name: Backend image
on:
push:
tags: ['v*']
concurrency:
group: backend-image-publication
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
env:
IMAGE: nexus.personal.ksv741.ru/docker-hosted/wealthledger/backend
RELEASE_TAG: ${{ gitea.ref_name }}
REVISION: ${{ gitea.sha }}
DOCKER_CONFIG: ${{ runner.temp }}/wealthledger-backend-docker
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Validate release version
run: |
node --input-type=commonjs -e 'const p = require("./package.json"); const l = require("./package-lock.json"); if (!/^v\d+\.\d+\.\d+$/.test(process.env.RELEASE_TAG) || process.env.RELEASE_TAG !== `v${p.version}` || l.version !== p.version || l.packages[""].version !== p.version) process.exit(1)'
test "$(git rev-parse HEAD)" = "$REVISION"
docker version
docker buildx version
- run: npm ci
- run: npm run verify
- run: node test/sqlite.cjs --all
- name: Login with dedicated Nexus writer
env:
REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.NEXUS_PASSWORD }}
run: |
test -n "$REGISTRY_USERNAME" && test -n "$REGISTRY_TOKEN"
install -d -m 700 "$DOCKER_CONFIG"
printf '%s' "$REGISTRY_TOKEN" | docker login nexus.personal.ksv741.ru --username "$REGISTRY_USERNAME" --password-stdin
- name: Publish immutable release tag
run: |
VERSION="${RELEASE_TAG#v}"
if docker manifest inspect "$IMAGE:$VERSION" >/dev/null 2>manifest-error.log; then
echo 'Refusing to overwrite an existing release tag'; exit 1
fi
if ! grep -Eq 'manifest unknown|no such manifest' manifest-error.log; then
echo 'Cannot prove tag absence; refusing publication'; exit 1
fi
docker buildx build --target production --build-arg VERSION="$VERSION" --build-arg REVISION="$REVISION" --tag "$IMAGE:$VERSION" --metadata-file image-metadata.json --push .
DIGEST="$(node --input-type=commonjs -p 'require("./image-metadata.json")["containerimage.digest"]')"
docker pull "$IMAGE@$DIGEST"
test "$(docker image inspect "$IMAGE@$DIGEST" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$REVISION"
npm run test:container -- "$IMAGE@$DIGEST"
printf 'Backend image: %s@%s\n' "$IMAGE" "$DIGEST"
- name: Remove registry session
if: always()
run: rm -rf "$DOCKER_CONFIG"
-31
View File
@@ -1,31 +0,0 @@
name: Backend security
on:
push:
branches:
- '**'
pull_request:
schedule:
- cron: '17 5 * * *'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
+84 -33
View File
@@ -2,49 +2,100 @@ name: Backend verify
on: on:
push: push:
branches: ['**'] branches: [develop, main]
pull_request: pull_request:
types: [opened, synchronize, reopened, edited]
schedule:
- cron: '47 5 * * *'
# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event.
concurrency:
group: backend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs: jobs:
verify: commits:
if: gitea.event_name == 'pull_request'
timeout-minutes: 15
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
with: with:
path: backend ref: ${{ github.event.pull_request.head.sha }}
- name: Checkout compatible peer revisions fetch-depth: 0
env:
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
TARGET_BRANCH: ${{ github.base_ref || github.ref_name }}
run: |
set -eu
for repo in frontend; do
ref=develop
case "$SOURCE_BRANCH:$TARGET_BRANCH" in
release/*:*|*:main)
ref=$(jq -er --arg repo "$repo" '.[$repo]' .ci/refs.json)
printf '%s\n' "$ref" | grep -Eq '^[0-9a-f]{40}$'
;;
esac
target="../$repo"
if [ "$repo" = main ]; then target=..; fi
git init "$target"
git -C "$target" remote add origin "https://git.personal.ksv741.ru/WealthLedger/$repo.git"
git -C "$target" fetch --depth 1 origin "$ref"
git -C "$target" checkout --detach FETCH_HEAD
git -C "$target" rev-parse HEAD
done
git rev-parse HEAD
- uses: https://github.com/actions/setup-node@v4 - uses: https://github.com/actions/setup-node@v4
with: with:
node-version-file: backend/.nvmrc node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
else
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
security:
needs: commits
if: >-
!cancelled() &&
gitea.event.action != 'edited' &&
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Audit runtime and development dependencies
run: |
npm audit --omit=dev --audit-level=low
npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
verify:
needs: [commits, security]
if: >-
!cancelled() && gitea.event.action != 'edited' &&
needs.security.result == 'success' &&
gitea.event_name != 'schedule'
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci - run: npm ci
- name: Generate Prisma client before typechecks
run: npx --no-install prisma generate
- name: Run all backend gates - name: Run all backend gates
run: npm run verify run: npm run verify
- name: Run isolated SQLite and runtime OpenAPI suites - name: Run isolated SQLite and runtime OpenAPI suites
run: npm run test:e2e run: node test/sqlite.cjs --all
- name: Analyze develop with SonarQube
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
# v2.3.0: same Docker-based scanner used for SonarQube 9.9.
uses: https://github.com/SonarSource/sonarqube-scan-action@aecaf43ae57e412bd97d70ef9ce6076e672fe0a9
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
args: -Dsonar.qualitygate.wait=true
+4
View File
@@ -21,6 +21,7 @@ lerna-debug.log*
# Tests # Tests
/coverage /coverage
/.scannerwork
/.nyc_output /.nyc_output
# IDEs and editors # IDEs and editors
@@ -64,3 +65,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/dev.db-* /dev.db-*
/prisma/*.db /prisma/*.db
/prisma/*.db-* /prisma/*.db-*
# Generated public API artifact
/openapi.json
+2
View File
@@ -15,3 +15,5 @@ dfca11c81f7e6dadce3ca6f961d265c5e3f8ff8e:src/tbank/portfolio.service.spec.ts:gen
# Historical CircleCI badge copied verbatim from nestjs/typescript-starter README. # Historical CircleCI badge copied verbatim from nestjs/typescript-starter README.
# Verified against https://github.com/nestjs/typescript-starter/blob/master/README.md # Verified against https://github.com/nestjs/typescript-starter/blob/master/README.md
f8dcfb71c1767696a9aa6741ddaf6575416c901e:README.md:generic-api-key:5 f8dcfb71c1767696a9aa6741ddaf6575416c901e:README.md:generic-api-key:5
# Container smoke fixture: same deterministic 32-byte test key, never a runtime secret.
1a6be432f24f757728a03415051392069112af1f:test/container-smoke.cjs:generic-api-key:14
+1 -1
View File
@@ -1 +1 @@
24.21.0 24.22.1
-4
View File
@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+36 -25
View File
@@ -1,30 +1,41 @@
# Backend # Backend router
NestJS API; общие правила работы — в root `AGENTS.md`. Репозиторий самостоятельный: обязательные инструкции и проверки используют
До backend-решения прочитай [router](.ai/CONVENTIONS.md) и только его файлы и предоставленные публичные контракты. Перед работой прочитай
[sources](.ai/SOURCES.md), затем только применимые документы/разделы. [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения.
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`).
## Проверка | Решение / сигнал | Читать |
| --- | --- |
| Устройство/data flow, module/layer/public API/DI/suffix, owner | [Architecture](.ai/architecture.md) |
| TS vocabulary, domain literals, magic values | [Types/values](.ai/architecture.md#domain-literals-and-magic-values) |
| Controller/DTO/validation, route/status/response, OpenAPI | [HTTP API](.ai/conventions/http-api.md) |
| Application/domain/HTTP error, exception, code | [Errors](.ai/conventions/error-handling.md) |
| Repository/Prisma/query, transaction, migration, atomic write | [Data](.ai/conventions/data.md) |
| Tests или behavior с regression risk | [Testing](.ai/conventions/testing.md) |
| JWT/cookie/session/guard/access, encryption, secrets | [Security](.ai/conventions/security-auth.md) |
| External client/gateway/SDK/proto, retry/rate limit/cache | [Integrations](.ai/conventions/integrations.md) |
| Logging/context/levels, sensitive logs | [Observability](.ai/conventions/observability.md); sensitive data также Security |
| Изменение публичного wire contract / Swagger metadata | [HTTP API](.ai/conventions/http-api.md#источник-контракта) |
| Helper/dependency/новый pattern, refactor/scope | [Change scope](.ai/conventions/engineering.md#change-scope), для пакета — [Dependencies](.ai/conventions/engineering.md#dependencies) |
| Выбор gate, tooling, завершение | [Проверки](.ai/conventions/engineering.md#проверки-и-завершение) |
Перед Node-командами: `nvm use --silent` по `.nvmrc`. Новый endpoint обычно требует HTTP API + Testing; Data/Security добавляются
После lockfile install `npm ci` полный gate — `npm run verify`: по persistence/access trigger. Auth refresh активирует Security + Data + Testing.
zero-warning ESLint, application/test TypeScript, Jest и build. Перед завершением повтори routing по новым и изменённым файлам. Для Git/release
`lint:fix`/`format` пишут файлы и запускаются отдельно от доказательства проверки. прочитай [Git и релизы](.ai/conventions/engineering.md#git-и-релизы).
`test:cov` — диагностика без общего threshold; scripts принадлежат `package.json`.
HTTP contract changes сохраняют [направление контракта](../docs/architecture/http-api.md#источник-контракта) ## Источники
и проходят contract check. Общий e2e запускается на отдельных временных DB;
применимость и маршрут проверки
описаны в [backend testing](.ai/conventions/backend-testing.md).
Generated Prisma client не редактируется вручную. Credentials, авторизация
доступа к счетам и внешние интеграции остаются server-side.
ESLint/TypeScript не доказывают правильность domain boundaries, security или | Вопрос | Локальный источник |
денежных вычислений: для них нужны semantic review и risk-based tests. | --- | --- |
| HTTP errors/DTO/wire shape | [HTTP API](.ai/conventions/http-api.md), генерация `npm run openapi:generate`, configured HTTP tests |
## Ветки и релизы | Auth/session, account access, secrets | [Security](.ai/conventions/security-auth.md) и auth/access tests |
| Prisma/data ownership | [Data](.ai/conventions/data.md), Prisma schema/migrations |
Работа — в отдельной ветке от `develop`, затем PR в `develop`; в `main` напрямую | T-Bank/cache/logging | [Integrations](.ai/conventions/integrations.md), [Observability](.ai/conventions/observability.md) |
не пушить. Release/hotfix и синхронизация веток — по [root README](../README.md#релизы-и-ветки). | Money и финансовые расчёты | [Data](.ai/conventions/data.md#деньги-и-точность), domain source и tests владельца |
Commits и заголовки PR — Conventional Commits; для спецификаций допустим `spec:`. | Поведение области | Module source и tests; неясное ожидание уточни до изменения |
Перед подготовкой релиза прочитай root README; версии вручную не повышать. | Versions/scripts/check scope | Manifest/lockfile, ESLint/TypeScript/Jest/Prisma configs |
+673
View File
@@ -0,0 +1,673 @@
# Аудит backend WealthLedger и план исправлений
Дата аудита: 2026-09-27. Репозиторий: `/Users/ksv741/Project/vibe/WealthLedger/backend`.
База: `516e43203eb3fee8e6a0c7018be2fee11f1b900c`, ветка `codex/investment-contracts`.
При аудите рабочее дерево было чистым; production-код не изменялся.
Этот документ сохраняет результаты аудита и задаёт проверяемый план исправлений.
Само сохранение документа не означает выполнения исправлений.
Номера строк относятся к указанному commit. Перед изменением повторно найти
symbol и проверить актуальный flow: после предыдущих исправлений строки сдвинутся.
Все относительные пути и ссылки относятся только к backend.
## Общая оценка
Backend имеет понятных владельцев ответственности: контроллеры тонкие,
денежные расчёты преимущественно точные, транзакции и очереди сосредоточены
у своих владельцев. Массовое дробление файлов не требуется.
Наиболее проблемные области — обработка ошибок auth, проверка ответов MOEX,
согласование состояния фоновой синхронизации и временных признаков кешированных данных.
Изучены локальные [AGENTS.md](AGENTS.md), [порядок работы](.ai/conventions/engineering.md),
[архитектура](.ai/architecture.md) и активированные темы HTTP, errors, data,
testing, security, integrations и observability. Соседние репозитории не использовались.
Инвентаризированы 135 production-файлов TypeScript, 16 004 строки, 17 Nest module-файлов.
Generated Prisma, vendor protocols, миграции и тесты отделены от production-рейтинга.
Приоритеты: **высокий** — существенный риск безопасности или искажения данных;
**средний** — ограниченный дефект поведения, контракта или доказанная стоимость связности;
**низкий** — локальное упрощение. Уверенность всех перечисленных находок высокая
для описанного механизма; ограничения конкретного воспроизведения указаны отдельно.
## План выполнения и границы
1. Прочитать действующий backend AGENTS и активированные conventions; зафиксировать
текущие HEAD/status. Сохранить существующие staged/unstaged/untracked изменения.
2. Работать по находкам A01–A10 и cleanup C01–C07 ниже, в порядке пакетов из таблицы.
Перед каждым исправлением перечитать владельца целиком, callers, связанные tests
и 1–3 релевантных примера. Если факт больше не воспроизводится, отметить это
с доказательством вместо механического применения рекомендации.
3. Для дефекта сначала получить падающую проверку наблюдаемого результата на самой
дешёвой надёжной boundary. Для concurrency/atomicity использовать реальную
изолированную SQLite; mock race не доказывает DB-инвариант.
4. Исправлять у существующего владельца минимальным связанным diff. Сохранять
precision, ownership, public error codes, transaction/fencing, cache isolation,
limiter и выполняющиеся promises. Предложения ниже задают результат, а не
обязательную форму реализации.
5. После каждого пакета выполнить targeted checks; в конце — актуальные обязательные
gates по backend conventions. Перед каждой Node-командой выполнить `nvm use --silent`.
6. Сохранить исходный снимок аудита; результаты реализации записывать в таблицу
«Статус исправлений». Для каждого ID указать diff/решение, команды и результат,
остаточный риск либо точную причину невыполнения.
| Пакет | Пункты | Условие завершения |
| --- | --- | --- |
| 1. Auth и безопасные ошибки | A01, A02 | Отказы хранилища не маскируются; секреты не попадают в completion log |
| 2. MOEX trust boundary | A03 | Malformed response не публикуется как полный результат; pagination завершается |
| 3. Статус синхронизации | A04 | Screen учитывает состояние successor через владельца operations |
| 4. Свечи | A05 | Force не теряется при coalescing; forced publication остаётся атомарной |
| 5. Свежесть и время | A06, A07 | Stale-флаг актуален; wire timestamp совпадает с используемым при фильтрации |
| 6. HTTP validation | A08 | Реальный pipeline возвращает структурированные field errors |
| 7. Связность и удаление legacy | A09, A10, C01–C07 | Живые boundaries сохранены; лишние реализации и exports удалены |
Пакеты независимы по цели, но могут пересекаться файлами и общими fixtures.
Параллелить только области с непересекающимся владением файлами, затем выполнить
общую проверку. A02 и A08 изменяют один HTTP filter и должны быть согласованы.
В scope входят исправления подтверждённых находок и их regression tests.
Гипотезы в конце документа не являются задачами на изменение кода.
Новые зависимости, generic repositories, scheduler framework, интерфейсы ради
паттерна, schema migrations и size-only splits для этих исправлений не требуются.
Отдельную необходимость такого расширения следует доказать до реализации.
Коммиты, push и PR выполняются только по отдельному запросу пользователя.
## Подтверждённые находки
### A01. Auth lifecycle смешивает invalid credentials и operational failure
**Приоритет:** высокий. **Уверенность:** высокая.
**Места и потребители:** [AuthService.logout, строки 104–126](src/modules/auth/application/auth.service.ts),
[AuthController.logout, 95–104; refresh, 85–91](src/modules/auth/presentation/auth.controller.ts).
Общий catch в logout подавляет JWT verification errors и отказ Prisma при записи
`revokedAt`. Controller затем очищает cookie и возвращает 204, хотя серверная
сессия осталась действительной. Сохранённая копия refresh token продолжает работать.
Смежное проявление: refresh controller удаляет cookie при любой ошибке, включая
временный отказ БД, без доказательства недействительности токена.
**Доказательство:** inline fault injection на настоящих AuthService, JwtService
и AuthController с синтетической сессией подтвердил:
```text
logoutResolved=true
cookieCleared=true
sessionNotRevoked=true
sameRefreshTokenStillUsable=true
unexpectedRefreshErrorPropagated=true
validCookieRemoved=true
```
Это доказательство обработки injected boundary failure, а не реального отказа БД.
[Auth HTTP suite](test/app.e2e-spec.ts) проверяет обычный logout, но не этот отказ.
Нарушен [контракт отзыва сессии](.ai/conventions/security-auth.md): идемпотентность
не превращает несостоявшуюся запись в успешную.
**Минимальное действие:** ограничить подавление ошибок logout недействительным JWT;
ошибки хранилища пробрасывать. При refresh очищать cookie только при установленном
auth failure. Существующий AuthService.refresh уже отделяет verification от DB reads;
BankCardsRepository.write также пробрасывает неизвестные ошибки.
**Приёмка:**
- Отсутствующий/недействительный/уже отозванный refresh сохраняет идемпотентный logout.
- Ошибка записи revocation не возвращает фиктивный 204.
- Успешный logout делает последующий refresh невозможным.
- Временный отказ refresh не удаляет действующую cookie, повтор после восстановления возможен.
- Проверены status/code/cookie через configured HTTP; успешный отзыв — с SQLite.
**Риск:** сохранить существующую семантику недействительных токенов. Изменение
access-JWT lifetime или введение replay history не входят в этот пункт.
### A02. Многострочное сообщение исключения попадает в completion log
**Приоритет:** высокий. **Уверенность:** высокая.
**Места:** [HttpExceptionFilter, 30–37](src/common/filters/http-exception.filter.ts).
**Потребители:** [RequestLoggingMiddleware, 43–57](src/common/logging/request-logging.middleware.ts),
[StructuredLogger, 47–63](src/common/logging/structured-logger.service.ts).
Фильтр удаляет только первую строку `exception.stack`. Остальные строки
многострочного Error.message сохраняются. Поле stack разрешено logger allowlist;
redaction HTTP body к нему не применяется.
**Воспроизведение:** configured HTTP app; AuthService.login выбрасывает синтетический
`Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET')`. Ответ остаётся
`500 / INTERNAL_SERVER_ERROR`, но completion log содержит вторую строку сообщения.
Реальные секреты не использовались. Существующий filter spec проверяет однострочное сообщение.
Нарушены [observability](.ai/conventions/observability.md) и security rules.
**Минимальное действие:** убрать передачу сырого stack через общую HTTP boundary,
сохранив code/request context. Если stack необходим, нужна проверенная форма кадров
без произвольного текста исключения; удаление только первой строки недостаточно.
**Приёмка:** multiline synthetic secrets отсутствуют в response и в конечном
payload logger, а status/code/correlation сохраняются. Проверить настоящий
filter → middleware → logger, не только mock вызова filter.
**Риск:** диагностическая подробность может уменьшиться; raw message возвращать нельзя.
### A03. MOEX malformed response превращается в полный пустой прогноз
**Приоритет:** высокий. **Уверенность:** высокая.
**Место:** [MoexClient, 122–142 и 163–165](src/integrations/moex/moex.client.ts).
Отсутствующий запрошенный ISS block преобразуется в `[]`. Неполный cursor может
завершить чтение с частичными данными; cursor с нулевым pageSize может зациклить его.
**Реальная цепочка:** [CatalogEnrichmentService, 444 и 509–548](src/modules/investments/instruments/application/catalog-enrichment.service.ts)
публикует `bondEvents: []`; readMany считает существующий row complete;
[UpcomingEventsService, 137–197](src/modules/investments/upcoming-events/upcoming-events.service.ts)
строит пустые events/totals; [ForecastRepository, 124–150](src/modules/investments/portfolio/infrastructure/forecast.repository.ts)
может заменить прежний непустой forecast. Владелец protocol validation — integration client.
**Воспроизведение:** настоящий MoexClient с mock fetch HTTP 200 без coupons/offers
вернул `[]`. Cursor `INDEX=0,TOTAL=1,PAGESIZE=0` дал пять запросов `start=0`;
проба остановлена искусственной ошибкой. Timeout каждого fetch не ограничивает весь цикл.
**Расхождение тестов:** [moex.client.spec, 97–107](src/integrations/moex/moex.client.spec.ts)
считает отсутствующий block пустым результатом;
[upcoming-events.e2e, 439–451](test/upcoming-events.e2e-spec.ts) обещает обратное,
но подменяет весь клиент готовым rejection и не проверяет parser.
**Минимальное действие:** различать отсутствующий/повреждённый block и корректный
`data: []`; проверять структуру строк и cursor, положительный pageSize и строгое
продвижение start. Нарушение протокола → `MOEX_UNAVAILABLE`, без частичного success.
**Приёмка:**
- Malformed block/cursor не меняет сохранённый полный календарь/forecast.
- Корректный пустой block остаётся успешным пустым результатом.
- Непродвигающаяся pagination завершается ошибкой, без бесконечных fetch.
- Семантика HTTP 404 проверена отдельно и не изменена случайно.
- Downstream regression использует настоящий MoexClient с mock fetch, а не mock rejection клиента.
**Риск:** перепутать законное отсутствие данных с ошибкой protocol shape.
### A04. Screen sync сообщает succeeded до завершения successor
**Приоритет:** средний. **Уверенность:** высокая.
**Места:** [TbankSyncService.status, 330–345](src/modules/investments/tbank-sync/tbank-sync.service.ts),
[TbankSyncRepository.read, 70–80](src/modules/investments/tbank-sync/tbank-sync.repository.ts).
[Operations repository, 441–445](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts)
помечает объединяемую группу `superseded` и сохраняет successorId. Владелец операций
умеет разрешать цепочку в observe, строки 564–590, но screen читает прямого child.
**Сценарий:** две активные screen-группы на части января; третья объединяет интервалы.
Screen вытесненной группы сообщает succeeded, хотя successor ещё running; его
последующая ошибка также теряется.
**Воспроизведение настоящего status с mocked persistence:**
```text
screenState=succeeded
resource.availability=missing
resource.sync.state=running
actualOperationState=running
```
SQLite bridge покрыт [operations-sync e2e, 455–474](test/tbank-operations-sync.e2e-spec.ts),
но существующая проверка наблюдает operations.observe, а не screen status.
**Минимальное действие:** разрешать operations successors через существующего
владельца перед агрегацией screen state/progress. Не копировать второй алгоритм
разрешения цепочки в screen service.
**Приёмка:** configured HTTP + SQLite bridge; задержанный successor → running,
затем успех → succeeded или отказ → failed; progress согласован, чужая revision
не видна. **Риск:** fencing, цепочки successors и корректный подсчёт progress.
### A05. Force обновления свечей теряется при coalescing
**Приоритет:** средний. **Уверенность:** высокая.
**Места:** [CandleService.request/load, 326–365](src/modules/investments/instruments/application/candle.service.ts),
[CandleRepository.reserve, 76–82](src/modules/investments/instruments/infrastructure/candle.repository.ts).
Публичный caller: [TbankSyncService, 288–306](src/modules/investments/tbank-sync/tbank-sync.service.ts)
вызывает candles.request с force=true; candleRange необязателен, ключ может совпасть
с ordinary GET/prefetch.
При launch=false service возвращает существующий id без сохранения новых force/priority.
При присоединении cross-user observer требования также не повышаются.
**Воспроизведение настоящего класса с mocked repository/upstream:** январь покрыт,
ordinary period=all заблокирован перед загрузкой detail, затем приходит force all.
После release единственный RPC идёт от `2026-02-01T00:00:00.000Z` до
`2026-02-02T12:00:00.000Z`; январь не перечитан. Исправленные исторические значения
остаются прежними. Existing candle suite отдельно проверяет force и coalescing.
**Минимальное действие:** сохранить forced требование и выполнить полную reconciliation.
Если gap-план уже начат, безопасно перепланировать или выполнить следующую forced
попытку. Простого `force ||= true` после публикации первых окон недостаточно.
Аналог повышения требований есть в CatalogDiscoveryService, но его алгоритм нельзя
механически переносить на staged свечи.
**Приёмка:** ordinary load + force того же scope действительно обновляют уже
закрытую свечу. Возвращённый work ID завершается только после выполнения forced
требования с актуальным диапазоном запроса. Проверить join до запуска и после
публикации первого обычного окна, включая observer другого пользователя;
экранный запрос получает экранный приоритет. Ошибка следующего forced окна сохраняет
прежнюю историю и timestamps; user/revision fencing сохраняется.
Proof атомарности — существующая SQLite boundary.
**Риск:** высокий риск неверного локального исправления из-за staged/atomic publication.
### A06. Payout snapshot замораживает positionIsStale
**Приоритет:** средний. **Уверенность:** высокая.
**Место:** [ForecastRepository.resolve, 132–137](src/modules/investments/portfolio/infrastructure/forecast.repository.ts).
**Потребители:** [StockIncomeService, 67–87 и 161–163](src/modules/investments/instruments/stocks/application/stock-income.service.ts),
[BondIncomeService, 180–182](src/modules/investments/instruments/bonds/application/bond-income.service.ts).
Истечение 60-секундного portfolio TTL меняет isStale, но не source revision/timestamps.
Repository возвращает прежний payload вместо нового candidate. Возможны одновременно
`meta.freshness: stale` и `data.positionIsStale: false`.
**Воспроизведение:** настоящий repository с mocked transaction; candidate false,
затем true при тех же версиях → повторный результат false, всего одна запись.
Pure payout tests принимают stale input, но не проверяют aging persisted forecast.
**Минимальное действие:** вычислять возраст сохранённой позиции при выдаче по её
positionAt и действующей freshness policy. Учитывать именно позицию сохранённого
fallback, а не случайно подставлять свежесть более нового несовместимого портфеля.
Не сравнивать весь candidate без разбора: текущий calculatedAt у events вызовет
запись на каждом чтении. Не менять financial revision только из-за хода времени.
**Приёмка:** controllable clock, TTL истёк без изменения источников, sync=observe:
stale=true, суммы и source timestamps прежние, новых RPC нет; fallback остаётся
согласованным. **Риск:** смешение возраста сохранённой и текущей позиции.
### A07. Timestamp операций расходится между persistence и wire
**Приоритет:** средний. **Уверенность:** высокая.
**Место:** [brokerage-operation.mapper, 85–90](src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts)
игнорирует nanos; [repository, 653–657 и publish](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts)
учитывает миллисекунды. Consumers mapper: BrokerageOperationsService, обычный ledger
и income reads (строки 359 и 619 на базе аудита).
**Воспроизведение:** seconds=1768000000, nanos=600000000 → persistence
`2026-01-09T23:06:40.600Z`, wire `2026-01-09T23:06:40.000Z`.
Запрос from=`…40.500Z` возвращает строку с видимым временем раньше from.
Existing mapper specs проверяют money/quantity, но не это соответствие.
**Минимальное действие:** согласовать конвертацию protobuf timestamp; при возможности
использовать уже сохранённый occurredAt для wire. Предотвратить дальнейшее расхождение
реализаций без создания универсальной библиотеки дат.
**Приёмка:** subsecond mapper case и HTTP boundary внутри секунды; порядок/cursor,
обычный ledger и income используют согласованное время. **Риск:** исправляется
публичное значение timestamp; формат и точность Money сохраняются.
### A08. Configured validation response теряет ошибки полей
**Приоритет:** средний. **Уверенность:** высокая.
**Места:** [configureApp, 29](src/main.ts),
[HttpExceptionFilter, 44–48 и 70–78](src/common/filters/http-exception.filter.ts).
ValidationPipe выдаёт message:string[], filter приводит его к ValidationError[]
и ищет constraints. children вложенных ошибок также не обходятся.
**Configured HTTP reproduction:** invalid email и отсутствующий password:
```json
{"status":400,"code":"VALIDATION_ERROR","errors":[]}
```
Ожидаемый [HTTP contract](.ai/conventions/http-api.md) требует pointer/detail.
Conventions уже фиксируют это известное расхождение. OpenAPI parity проверяет
schema, но не наблюдаемый response pipeline.
**Минимальное действие:** согласовать exceptionFactory и filter, сформировать
структурированные ошибки, включая вложенные поля, без target/value исходного DTO.
**Приёмка:** реальные requests credentials и nested Money дают непустые корректные
pointer/detail; status/code/content-type сохраняются; неизвестные исключения
по-прежнему безопасны. Проверить согласование с A02 и публичной metadata.
**Риск:** public error shape и корректность вложенных путей.
### A09. Planner зависит от service barrel и образует runtime import cycle
**Приоритет:** средний. **Уверенность:** высокая.
**Места:** [investment-sync-plan, 3–6](src/modules/investments/investment-sync-plan.ts)
→ [operations.api](src/modules/investments/operations/operations.api.ts)
→ operations services → investment-sync-plan.
Алгоритму нужны interval rules, но barrel загружает services, TbankClient и Prisma.
[Planner spec, строка 1](src/modules/investments/investment-sync-plan.spec.ts) уже
вынужден mock Prisma. Это доказанная стоимость связности; production crash из-за
цикла не обнаружен. Type-only цикл T-Bank records — отдельный безвредный случай.
**Минимальное действие:** предоставить interval rules чистый публичный вход,
аналогичный разрешённому integrations/tbank/decimal.ts; service API оставить отдельно.
Не заменять cycle на forwardRef и не вводить новый DI слой.
**Приёмка:** planner импортируется/тестируется без Prisma mock; cycle устранён,
interval semantics и DI initialization сохранены. **Риск:** низкий, import/export surface.
### A10. Неиспользуемый legacy T-Bank access flow
**Приоритет:** низкий. **Уверенность:** высокая.
**Место:** [TbankAccessService, 255–330](src/integrations/tbank/tbank-access.service.ts).
listSupportedAccounts/withSupportedAccounts/withConnection/withAccount/
withAccountForConnection/private connection вызываются только внутри собственной
цепочки и tests. Проверены production consumers, DI и decorators.
Legacy flow поддерживает generation=hash(ciphertext), тогда как живой persisted
flow использует DB revision, local и tokenForAttempt. Поддерживаются две модели
без production-потребности; около 76 строк, context types и TbankClient dependency.
**Минимальное действие:** повторно проверить callers на актуальном HEAD, удалить
legacy-цепочку, неиспользуемые context exports и зависимость; отдельно проверить
оставшихся consumers accessibleTbankAccount до его удаления.
**Приёмка:** нет мёртвой цепочки, живые access/invalid-token/revision/fencing cases
сохранены. Перенести полезные security assertions, если они были только в удаляемых
specs. **Риск:** удалить общее правило или полезную проверку вместе с legacy.
## Локальные упрощения
Все пункты: низкий приоритет, высокая уверенность. Перед удалением повторить поиск
callers; отсутствие production callers в базе аудита не гарантирует его на новом HEAD.
| ID | Место и доказательство | Минимальное действие и приёмка |
| --- | --- | --- |
| C01 | [portfolio.service, 515–522](src/modules/investments/portfolio/application/portfolio.service.ts): локальный decimalFromString повторяет common parser без validation; callers position/value | Импортировать существующий common owner; удалить дубликат. Exact-money tests и typecheck. Текущая ошибка сумм не доказана; проверить допустимые входы. |
| C02 | [tbank.api](src/integrations/tbank/tbank.api.ts): TbankSyncAttempt импортируют напрямую семь внешних файлов sync/instruments; enrichment также обходит API для market types | Экспортировать используемые типы, перенаправить imports через API. TbankCoupon уже экспортируется. Typecheck и поиск обходов; runtime-риск минимален. |
| C03 | [auth.service, 190–192](src/modules/auth/application/auth.service.ts): normalizeEmail возвращает аргумент; register/login вызывают его, реальная нормализация в CredentialsDto | Удалить no-op, сохранить DTO transform. Existing HTTP case/whitespace tests; дополнительную нормализацию без необходимости не вводить. |
| C04 | [bank-card.calculations, 56–76](src/modules/banking/bank-cards/domain/bank-card.calculations.ts): string period используется только tests; production summary передаёт from/to | Сузить API до рабочего диапазона; перевести полезные unit cases. Проверить границы месяца/range. |
| C05 | [instrument-market, 39–51](src/modules/investments/instruments/application/instrument-market.ts): optional без consumers; [stock-position wrapper](src/modules/investments/instruments/stocks/application/stock-position.ts) нужен только своему spec | Удалить optional, перенаправить полезные wrapper tests на instrumentPosition. Сохранить fractional/mixed/offset holdings cases. |
| C06 | [portfolio.api, 2](src/modules/investments/portfolio/portfolio.api.ts): totalsByCurrency не имеет внешнего production consumer; внутренний использует mapper напрямую | Удалить только лишний export, mapper сохранить; typecheck. |
| C07 | [auth.controller.spec, 9–20](src/modules/auth/presentation/auth.controller.spec.ts): проверяет HTTP_CODE_METADATA через prototype; те же 200 проверяются configured HTTP suite | Удалить дублирующий wiring spec только при сохранении реальных HTTP status/cookie assertions. |
## Карта ответственности и проверенные сценарии
| Область / владелец | Интерфейс и consumers | Состояние, зависимости, инварианты и эффекты |
| --- | --- | --- |
| App/config/common/Prisma | Composition root, configureApp, Money/decimal, logging | Env validation, HTTP pipeline, DB lifecycle, request context; общая арифметика без Nest/Prisma |
| Auth | Auth HTTP; guard/AccessPayload API для protected controllers | User/Session, bcrypt/JWT, refresh hash/CAS; cookies у controller |
| Bank cards/operations/categories | BankCardsController → профильные services | Ownership, archive/version/replay/tombstones; refund budget и перевод в общей transaction boundary |
| Bank summary | Controller → use case → repository → pure calculation | Согласованное чтение и BigInt-сводка по периоду |
| Brokerage accounts | HTTP/read API для screen → service → PortfolioService | Собственной persistence нет; missing membership отличается от confirmed empty |
| Portfolio/forecast | HTTP и API для instruments/events/accounts | Private user/revision/account snapshots; mapping/decimal; aggregate completeness; atomic dependency version checks |
| Operations | HTTP/API для income/screen | Local journal, signed cursor binding; durable work/staging, intervals, recovery, atomic reconciliation |
| Income analytics | HTTP/read API → Operations API | Period roles/completeness, grouping и точные signed totals по валютам; своей DB нет |
| Upcoming events | HTTP/read API → portfolio/calendars → forecast | Events по positions; missing calendar не равен пустому; redemption исключён из income totals |
| Catalog discovery/enrichment | Stock/bond services и public source APIs | Shared identity/aliases/source rows; private work, TTL, batching, coalescing и generation-fenced publication |
| Candles | Market services/screen → CandleService/Repository | Shared candles/coverage; mutable tail, gaps, windows, очередь и staged force replacement |
| Stock/bond position/income | Controllers → application services | UID/type matching, exact weighted cost, mixed currencies, payout estimates; Portfolio/Catalog/Forecast APIs |
| T-Bank connection | Guarded GET/PUT/DELETE | Проверка токена до replacement, AES-GCM, новая revision/cascade; failed validation сохраняет connection |
| T-Bank client/access/membership/metadata | Integration API для business modules | gRPC/TLS, единые limiter/retry/deadline; credentials, account access, membership и publication fencing |
| Screen sync | POST descriptor / GET status | Orchestration через APIs; durable children/attempts; passive observation |
| MOEX/NCR | Integration APIs → enrichment | ISS parser/pagination; NCR strict registry parser/exact ISIN/shared cache |
Прослежены auth/session flows; создание/изменение карт, операций, переводов,
возвратов и summary; ledger→sync→staging→publication; portfolio→mapping→snapshot;
payouts/events→forecast; catalog/candles и screen orchestration.
Повторной регистрации владельцев caches/queues и циклов Nest modules не обнаружено.
InstrumentSourcesModule позволяет portfolio пользоваться источниками без DI-цикла
с InstrumentsModule. Type-only цикл T-Bank records не является finding.
## Размеры production-файлов
Количество строк включает comments/blank lines. Размер — основание для чтения,
а не самостоятельный дефект. Обязательные splits не обоснованы.
| Файл | Строк | Решение и причина |
| --- | ---: | --- |
| [brokerage-operations.repository](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts) | 924 | Оставить: overlap groups, staging, recovery и publication держат общий transactional invariant |
| [catalog-enrichment.service](src/modules/investments/instruments/application/catalog-enrichment.service.ts) | 665 | Упростить локально: protocol boundary/imports; сохранить producers, batching, completions вместе |
| [brokerage-operations.service](src/modules/investments/operations/application/brokerage-operations.service.ts) | 636 | Оставить: ledger/income имеют различную completeness; generic builder добавит скрытые режимы |
| [tbank-sync.service](src/modules/investments/tbank-sync/tbank-sync.service.ts) | 595 | Упростить: разрешение successors вернуть operations owner, generic job framework не нужен |
| [tbank.client](src/integrations/tbank/tbank.client.ts) | 560 | Оставить: records/RPC methods и единый limiter/retry owner |
| [portfolio.service](src/modules/investments/portfolio/application/portfolio.service.ts) | 522 | Упростить: удалить duplicate parser, scheduling state не разрывать |
| [candle.service](src/modules/investments/instruments/application/candle.service.ts) | 481 | Оставить: исправить force join, сохранить queue/publication invariant |
| [catalog-discovery.repository](src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts) | 480 | Оставить: identity/aliases/source publication и CAS связны |
| [catalog-discovery.service](src/modules/investments/instruments/application/catalog-discovery.service.ts) | 390 | Оставить: search/detail и producer lifecycle имеют одного owner |
| [bank-operations.service](src/modules/banking/bank-cards/application/bank-operations.service.ts) | 343 | Оставить: разрыв validation/refund/transfer writes повышает риск |
| [tbank-access.service](src/integrations/tbank/tbank-access.service.ts) | 331 | Упростить удалением legacy flow |
| [investment-sync-plan](src/modules/investments/investment-sync-plan.ts) | 327 | Упростить зависимость: убрать service barrel из алгоритма |
| [instrument-metadata.repository](src/integrations/tbank/instrument-metadata.repository.ts) | 320 | Оставить: identity, batching и publication согласованы |
| [income-analytics.service](src/modules/investments/income/income-analytics.service.ts) | 318 | Оставить: связные projections, выгода дополнительных моделей не доказана |
| [candle.repository](src/modules/investments/instruments/infrastructure/candle.repository.ts) | 306 | Оставить: reconciliation, coverage, fencing и stage cleanup |
| [upcoming-events.service](src/modules/investments/upcoming-events/upcoming-events.service.ts) | 297 | Оставить: один сценарий, основной дефект у MOEX boundary |
| [brokerage-operations-sync.service](src/modules/investments/operations/application/brokerage-operations-sync.service.ts) | 294 | Оставить: связный владелец durable work execution |
Отдельные категории: 37 unit specs / 3319 строк; 21 HTTP/SQLite specs / 7878 строк;
10 test support/fixtures / 1993 строки; 6 vendor proto / 3588 строк;
5 SQL migrations / 345 строк. Generated Prisma и build artifacts не ранжировались.
## Удачные решения, которые требуется сохранить
- BigInt/decimal и string Money; разные валюты не складываются неявно.
- Refresh CAS, ownership queries, версии операций, replay fingerprints/tombstones.
- Network calls отделены от DB transactions.
- Revision/attempt fencing, late-result discard, staging перед force reconciliation.
- Missing/partial/confirmed empty/saved complete различаются явно.
- Concrete providers, компактные плоские модули и разрешённый прямой Prisma
в связных CRUD/transaction services не являются автоматическими дефектами.
- SQLite behavior tests проверяют реальные денежные границы, races и rollback.
## Выполненные проверки и пределы доказательства
Перед успешными Node-командами загружалась `~/.nvm/nvm.sh`, затем `nvm use --silent`.
Первый вызов nvm без загрузки shell function завершился command not found;
после загрузки проверок это ограничение не осталось.
| Команда / проверка | Результат аудита |
| --- | --- |
| git status, git rev-parse HEAD, git diff --check | Чистое дерево, неизменный commit, diff errors отсутствуют |
| npm run lint | Успешно |
| npm run typecheck:app; npm run typecheck:test | Успешно |
| npm test -- --runInBand | 37 suites / 200 tests passed |
| Build в npm run test:e2e | Prisma generation и Nest build успешны |
| Полный e2e в sandbox | listen EPERM на localhost; не засчитан |
| LOG_LEVEL=error node test/sqlite.cjs --all, с разрешением localhost | all-accounts: 18 passed; auth-suite затем завершилась native SIGABRT |
| node test/sqlite.cjs tbank-screen-sync.e2e-spec.ts tbank-operations-persistence.e2e-spec.ts tbank-candles-persistence.e2e-spec.ts tbank-metadata-persistence.e2e-spec.ts tbank-local-reads.e2e-spec.ts openapi-contract.e2e-spec.ts | Первые три: 27 + 5 + 9 passed; metadata native abort; последующие не достигнуты |
| node test/sqlite.cjs openapi-contract.e2e-spec.ts, отдельно | 3 tests passed |
| Inline reproductions | Подтверждены описанные случаи; validation/logging через configureApp, остальные с указанными mocked boundaries |
Native abort произошёл в `node::RemoveEnvironmentCleanupHook` / `better_sqlite3.node`
на Node 24.21.0. Причина не установлена; связывать его с конкретной source-находкой
нельзя. Полный e2e gate не подтверждён. При реализации сначала повторить в штатном
окружении; если проблема остаётся, диагностировать отдельно и явно сообщить,
не отключая падающие suites и не выдавая неполную проверку за зелёную.
Логи текущей сессии (временные файлы, могут исчезнуть):
`/tmp/wealthledger-audit-e2e.log`, `/tmp/wealthledger-audit-targeted-e2e.log`,
`/tmp/wealthledger-audit-openapi.log`. Документ не зависит от сохранности этих файлов.
**Проверено:** все production-модули, composition/API, основные consumers,
значимые flows, связанные tests кандидатов. Все production auth/banking,
integrations/sync/operations и остальные investment slices прочитаны.
**Частично проверено:** весь набор HTTP fixtures и integration suites не проходил
одинаково глубокий построчный аудит; реальное выполнение e2e ограничено указанным
native abort. Schema прочитана; история миграций не проходила полный compatibility audit.
**Не проверено:** clean npm ci, полный verify с coverage как единая команда,
durability/restart/backup, container/deployment, production performance,
реальные внешние API и полная сверка vendor proto с upstream.
### Финальная проверка реализации
Использовать актуальные scripts/conventions backend. На базе аудита для code changes
требуются npm ci, npm run verify, relevant SQLite/HTTP checks; для этих HTTP изменений
также OpenAPI generation/parity. Полный npm run test:e2e нужен для итоговой проверки
совокупных изменений. Перед Node — nvm use --silent.
Автоисправления lint/format не являются доказательством. Не запускать миграции,
reset/drop или tests на пользовательской БД. Штатный SQLite runner создаёт временные
базы и защищает от использования внешнего DATABASE_URL.
По завершении повторить routing по staged/unstaged/new files, выполнить Boy Scout
и semantic review: callers, owners, money/access/atomicity/error contracts,
generation/cache/async lifecycle. Указать точные команды, outcomes и ограничения.
## Неподтверждённые гипотезы — вне scope исправлений
- Logout/refresh interleaving и browser cookie overwrite требуют явных ordering
semantics и отдельного HTTP/browser proof.
- N+1 в карточных views виден, но измеренная latency/timeout проблема не установлена.
- JSON casts внутренних сохранённых payload сами по себе не доказывают небезопасную boundary.
- Cash/accrued-interest valuation: локального контракта недостаточно для обвинения в ошибке.
- Native SQLite abort требует отдельной диагностики runtime/dependency lifecycle.
## Статус исправлений
Начало исправлений: HEAD `516e43203eb3fee8e6a0c7018be2fee11f1b900c`,
ветка `codex/investment-contracts`; `git status --short`: только
`?? BACKEND_AUDIT.md`, staged/unstaged tracked changes отсутствовали.
Исходные разделы аудита сохранены. `npm ci` успешно: 1065 packages, 44s;
manifest/lockfile не менялись. Перед Node-командами загружается `~/.nvm/nvm.sh`
и выполняется `nvm use --silent`.
Все A01–A10 и C01–C07 повторно подтверждены и реализованы в порядке пакетов.
Статусы ниже разделяют готовность исправления и полноту проверки: native abort
не засчитывается как успешный behavioral test. Неподтвердившихся и уже исправленных
на исходном HEAD пунктов не обнаружено.
| ID | Статус | Изменение / доказательство | Проверки и ограничения |
| --- | --- | --- | --- |
| A01 | Исправлено и проверено | Logout подавляет только JWT verification failure; refresh удаляет cookie только при UnauthorizedException. Configured HTTP red: logout 204 вместо 500, refresh удалил cookie при отказе БД. | `node test/sqlite.cjs app.e2e-spec.ts`: regression воспроизведена на временной SQLite; macOS green повтор и первый полный e2e столкнулись с native SIGABRT. Итоговый Linux e2e: auth 12/12 passed, включая revocation failure и сохранение cookie для retry. |
| A02 | Исправлено и проверено | Удалены raw stack и его неиспользуемый transport/allowlist; regression проверяет multiline synthetic secret в конечном Pino payload и correlation. | Targeted filter/logging: 3 suites / 4 tests passed, unit red → green; macOS configured HTTP runner завершился native SIGABRT; итоговый Linux auth 12/12 passed, включая настоящий filter → middleware → Pino payload. |
| A03 | Исправлено и проверено | MoexClient отклоняет отсутствующий/повреждённый block, неверную ширину row, malformed/nonprogressing cursor. Пустые блоки и 404 сохранены. | Unit red 12 failures → 21 passed; `node test/sqlite.cjs upcoming-events.e2e-spec.ts`: downstream red 2 failures → 18 passed, настоящий client + mock fetch сохраняют calendar/forecast payload/revisions/timestamps. Typecheck и scoped lint passed. |
| A04 | Исправлено и проверено | Screen status разрешает superseded child через operations.observe перед агрегацией state/progress; attempt/revision fencing сохранён. | `node test/sqlite.cjs tbank-screen-sync.e2e-spec.ts`: HTTP/SQLite red succeeded вместо running → 29 passed; successor success/failure, progress, чужой user и удалённая revision. |
| A05 | Исправлено и проверено | Каждый join повышает force/priority при более сильном требовании; идентичный join не меняет updatedAt. Load фиксирует force/now на проход, при новой forced потребности выполняет полную reconciliation. Финальная публикация и observers сериализованы с reservations; screen priority=0. | Service unit red → green (полный исторический диапазон и более поздний now); `node test/sqlite.cjs tbank-candles-persistence.e2e-spec.ts`: 14 passed на финальном коде, same/cross-user join до загрузки и после обычного окна, rollback второго forced окна, timestamps и fencing. Native abort в первом финальном запуске; единственный повтор без изменений прошёл. |
| A06 | Исправлено и проверено | ForecastService вычисляет возвращаемый positionIsStale из сохранённого positionAt через существующую portfolio TTL policy; не меняет финансовый snapshot. | `node test/sqlite.cjs upcoming-events.e2e-spec.ts`: red stock/bond false на TTL → 20 passed; TTL−1/TTL, observe без RPC, прежние суммы/revisions/timestamps, fallback при несовместимом свежем candidate. Review дополнительно воспроизвёл meta.freshness=fresh для старого fallback (2 red); freshForMs теперь учитывает сохранённый stale flag, repeat 20 passed, stateVersion рассчитывается штатно. |
| A07 | Исправлено и проверено | Одна operationTimestamp conversion у mapper для stage/publish; ledger/income wire используют сохранённый occurredAt. Missing seconds по-прежнему отклоняется. | Unit red .600→.000 → 9 passed; `node test/sqlite.cjs tbank-ledger-local.e2e-spec.ts`: HTTP red → 11 passed (from=.500, cursor .800/.600, income timestamp/exact total). Оба typecheck/lint passed. На macOS operations-persistence завершилась native SIGABRT и не засчитана. Итоговый Linux полный e2e: ledger 11/11 и operations-persistence 5/5 passed. |
| A08 | Исправлено и проверено | Global/screen ValidationPipe сохраняют structured errors без target/value; filter обходит children и экранирует JSON pointers. Удалён descriptorErrors; OpenAPI errors.items согласован с object shape. | Nested Money HTTP red errors:[] → bank22 / screen29 passed; escaped pointer и отсутствие дублей/secret проверены. Unit7 и scoped lint passed. Metadata red items:string → финальный OpenAPI parity 3 passed. На macOS auth suite native SIGABRT; итоговый Linux auth 12/12 passed, включая credential field errors без input leak. |
| A09 | Исправлено и проверено | Planner импортирует существующий чистый operations/domain/operations-sync-plan как явно документированный публичный вход; service barrel больше не экспортирует runtime interval rules. | Planner и CandleService tests выполняются без Prisma mock; targeted пакет 7 suites / 49 tests passed. DI подтверждается configured HTTP и итоговым build. |
| A10 | Исправлено и проверено | Удалены шесть legacy methods, context types, TbankClient constructor dependency и accessibleTbankAccount без живых callers; legacy-only spec удалён. | Поиск src/test не находит legacy symbols; 5 unit suites / 11 tests, оба typecheck, scoped lint/build; connection HTTP/SQLite4 passed: missing/invalid/unsupported/cross-user, replacement/deletion, revision/fencing. Token error/crypto assertions сохранены. MacOS metadata-persistence15 passed, operations-sync/catalog-discovery native SIGABRT. Итоговый Linux e2e: metadata15, operations-sync17 и catalog-discovery10 passed. |
| C01 | Исправлено и проверено | PortfolioService использует common decimalFromString; локальный parser удалён. Входы из money/formatDecimal уже canonical; текущая ошибка сумм не заявляется. | Exact-money portfolio tests в targeted49 passed; итоговые types/verify ниже. |
| C02 | Исправлено и проверено | TbankSyncAttempt/TbankLastPrice экспортируются type-only через tbank.api; семь production consumers используют API, TbankCoupon переиспользован. | Поиск прямых imports tbank-access.service/tbank-market.types в src/modules пуст; оба typecheck и scoped lint passed. |
| C03 | Исправлено и проверено | Удалён no-op normalizeEmail; CredentialsDto transform trim/lowercase сохранён. | Auth unit passed; реальные case/whitespace HTTP assertions сохранены. После macOS native SIGABRT полный Linux auth gate прошёл: 12/12. |
| C04 | Исправлено и проверено | bankSummary принимает только рабочий диапазон from/to; строковый month overload удалён, полезные unit cases переведены на диапазоны. | Targeted49 passed: month start/end, outside range, signed64/BigInt суммы и refunds. |
| C05 | Исправлено и проверено | Удалены optional без callers и stockPosition wrapper; spec перенесён к instrumentPosition, проверяет напрямую владельца. | Targeted49 passed: fractional/large quantity, weighted costs, mixed/unknown currencies, no position и offset long/short. |
| C06 | Исправлено и проверено | Удалён неиспользуемый внешний totalsByCurrency export; живой mapper и внутренний caller сохранены. | Поиск imports подтверждает единственный внутренний caller; exact-money targeted tests passed, итоговый typecheck ниже. |
| C07 | Исправлено и проверено | Удалён HTTP_CODE_METADATA wiring spec; реальные register/login/refresh status200 и logout/cookie assertions сохранены в app.e2e. | Проверены callers/оставшиеся HTTP assertions; полный Linux auth gate прошёл: 12/12. Предыдущий macOS native SIGABRT остаётся отдельным ограничением среды. |
### Gates первого прогона (macOS)
Все команды выполнены из backend на Node `v24.21.0` после `nvm use --silent`.
Проверки данных использовали штатный runner, fresh temporary SQLite и штатные
migrations; рабочая БД не использовалась. Для localhost/Prisma cache запуски
получили разрешение вне sandbox; native abort повторялся и там.
| Команда | Итог |
| --- | --- |
| `npm ci` | PASS; manifest и lockfile без изменений. |
| `npm run verify` | PASS: lint, app/test typecheck, 36 suites / 209 unit tests с coverage, build. |
| `npm run openapi:generate` | PASS; актуальный игнорируемый `openapi.json` сформирован. |
| `npm run test:openapi-contract` | PASS: 1 suite / 3 tests, включая object shape validation errors. |
| `npx prisma validate` | PASS. Schema и migrations не менялись. |
| `npm run test:durability -- /tmp/wealthledger-fix-baseline-current-runtime-fjftdF` | PASS. Baseline `0862d56`, до последней T-Bank migration, отдельно установлен и собран на текущем Node; результат `/var/folders/j_/h2ft23gd3qq35ps3v5bcftfm0000gn/T/wealthledger-bank-durability-SIeCq0`. |
| `LOG_LEVEL=error npm run test:e2e` | INCOMPLETE, exit 1: all-accounts 18/18 passed, затем app.e2e native SIGABRT до Jest summary. Остальные suites общий gate не выполнил. |
| `node test/sqlite.cjs tbank-candles-persistence.e2e-spec.ts` | PASS на финальном коде: 14/14 после одного native abort и повтора без edits. |
| `node test/sqlite.cjs tbank-metadata-persistence.e2e-spec.ts` | PASS на финальном коде: 15/15. |
| `node test/sqlite.cjs tbank-operations-sync.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. |
| `node test/sqlite.cjs tbank-operations-persistence.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. |
| `node test/sqlite.cjs tbank-catalog-discovery.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. |
| `git diff --check` | PASS. |
Логи итоговых gates: `/tmp/wealthledger-fix-verify.log`,
`/tmp/wealthledger-fix-openapi-generate.log`, `/tmp/wealthledger-fix-openapi-contract.log`,
`/tmp/wealthledger-fix-prisma-validate.log`, `/tmp/wealthledger-fix-durability.log`,
`/tmp/wealthledger-fix-final-e2e.log`. Дополнительные SQLite логи:
`/tmp/wealthledger-fix-a05-latest-sqlite-retry.log`,
`/tmp/wealthledger-fix-final-metadata-persistence.log`,
`/tmp/wealthledger-final-operations-sync.log`,
`/tmp/wealthledger-final-operations-persistence.log`,
`/tmp/wealthledger-fix-final-catalog-discovery.log`.
Native failure: assertion `(env) != nullptr` в `node::RemoveEnvironmentCleanupHook`,
stack содержит destructor из `better_sqlite3.node`. Это конкретное ограничение
текущего native runtime, уже наблюдавшееся при исходном аудите; его первопричина
в рамках исправлений не установлена. Тесты не отключались, runner/dependencies
не изменялись. Эти macOS запуски остаются INCOMPLETE. Полный Linux прогон ниже
закрыл e2e gate и auth/operations/catalog acceptance; локальный native abort
на macOS при этом не исправлен.
### Повторная полная проверка (Linux, 2026-09-27)
**PASS: 21 e2e suites / 262 tests, exit 0**, ни одного skipped/disabled test.
Выполнена вся штатная `npm run test:e2e`: build и `node test/sqlite.cjs --all`.
Каждая suite получила отдельную temporary SQLite со всеми пятью migrations.
Закрыты прежде неполные auth12, operations-persistence5, operations-sync17,
catalog-discovery10; также прошли candles14, forecast/upcoming20, screen29,
OpenAPI3 и большой read-baseline с 300 000 операций / 1 000 000 свечей.
Перед повтором зафиксирован тот же HEAD и hashes всех tracked/untracked files;
до завершения тестов ни один исходник, тест, config или audit не менялся.
Поэтому предыдущие `verify` (209 unit tests), OpenAPI и durability сохраняют
актуальность. В этом повторе изменён только статус данного документа.
MacOS `LOG_LEVEL=error node test/sqlite.cjs app.e2e-spec.ts` снова завершился
native SIGABRT. Для Linux использован существующий Dockerfile target
`development`, Node `v24.21.0` (проверен против `.nvmrc`) и неизменённый lockfile:
SHA-256 `f986979b7922d684fead3a4d32532d11f1e469cec0e35669509343be41cbb2cd`.
Image `wealthledger-backend:audit-verification-20260927`,
ID `sha256:69264cf3dba23566d99fded450104bbe56f92755604e88232beb97825155ae73`.
Сборка выполнила `npm ci`; зависимости и runner не подменялись. Test/scripts
подключались read-only, внешняя сеть отключена, host ports не публиковались;
одноразовый контейнер удалён после завершения, рабочая БД не подключалась.
Команды воспроизведения из backend (на host предварительно `source ~/.nvm/nvm.sh`
и `nvm use --silent`; внутри image закреплён тот же Node):
```bash
docker build --target development --tag wealthledger-backend:audit-verification-20260927 .
docker run --rm --network none \
--mount type=bind,source=/Users/ksv741/Project/vibe/WealthLedger/backend/test,target=/app/test,readonly \
--mount type=bind,source=/Users/ksv741/Project/vibe/WealthLedger/backend/scripts,target=/app/scripts,readonly \
-e LOG_LEVEL=error wealthledger-backend:audit-verification-20260927 npm run test:e2e
```
Логи: `/tmp/wealthledger-recheck-auth.log` (macOS abort),
`/tmp/wealthledger-recheck-linux-build.log` (Linux image build),
`/tmp/wealthledger-recheck-linux-e2e.log` (полный PASS).
Read-only диагностика локализовала macOS crash в GC finalizer Statement/Database
вложенного `better-sqlite3 12.11.1`, используемого Prisma adapter 7.10.0.
Node 24.21 ObjectWrap destructor вызывает RemoveEnvironmentCleanupHook, который
падает на `env != nullptr`. Связь с Jest VM context и происхождением native
binary остаётся гипотезой, а не доказанной первопричиной. Ошибка application
lifecycle не обнаружена: close/disconnect ожидаются. Linux PASS доказывает
поведение backend в этом окружении, но не устранение native crash на macOS.
### Semantic / Boy Scout review и состояние дерева
Повторный routing по staged/unstaged/new files активировал Architecture,
HTTP/errors, Data, Testing, Security, Integrations и Observability. Проверены
owners/callers и связанная документация; независимый review охватил auth/logging,
candle coalescing/publication, forecast fallback и cleanup. После устранения
найденной при review stale metadata у старого forecast fallback оставшихся
semantic замечаний нет. Проверены точная денежная арифметика, ownership,
transactions, generation fencing, сохранение cache/pending promises и отсутствие
новых дублирующих helpers.
На момент завершения проверки, до запроса публикации, HEAD оставался
`516e43203eb3fee8e6a0c7018be2fee11f1b900c`, ветка
`codex/investment-contracts`; staged files отсутствуют. Все изменения локальные,
без commit/push/PR. Новые untracked files: исходный `BACKEND_AUDIT.md`,
`src/modules/investments/instruments/application/candle.service.spec.ts` и
`src/modules/investments/instruments/application/instrument-position.spec.ts`.
Остальные изменения — tracked source/tests и три связанных convention-файла.
Новые зависимости, scripts, migrations и разделение production-файлов не добавлены.
Исходный текст до раздела «Статус исправлений» побайтово совпадает со снимком
`/tmp/wealthledger-backend-audit-before-fixes.md`.
### Подготовка публикации
После успешной полной проверки пользователь разрешил commit, PR и merge.
Ветка публикации — `codex/backend-audit-fixes` от актуального `origin/develop`
`5d3835e`; его tree совпадает с исходным HEAD проверки, поэтому исходники и
результаты gates не изменились. Pre-commit review не обнаружил посторонних
файлов, секретов или незакрытых semantic findings. PR направляется в `develop`;
merge выполняется после обязательных `verify`, `security`, `commits`.
+214
View File
@@ -1,5 +1,219 @@
# Changelog # Changelog
## [2.3.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.0...v2.3.1) (2026-10-24)
### Bug Fixes
* use available Debian packages in image build ([#129](https://git.personal.ksv741.ru/WealthLedger/backend/issues/129)) ([a250142](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a250142a86e07ec7047e642afa16492fa4987f9a))
## [2.3.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.2.0...v2.3.0) (2026-10-24)
### Features
* log unhandled errors and pretty dev output (WealthLedger/backend[#120](https://git.personal.ksv741.ru/WealthLedger/backend/issues/120)) ([#126](https://git.personal.ksv741.ru/WealthLedger/backend/issues/126)) ([966a1f7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966a1f793aa2087cce3e47750e0017f78315bd64))
### Bug Fixes
* persist refresh cookie across browser restarts (WealthLedger/backend[#123](https://git.personal.ksv741.ru/WealthLedger/backend/issues/123)) ([d1f6969](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d1f6969dc7c22ae3cf7396bccdbf0cea2f61db2e))
### Maintenance
* **deps:** update non-major dependencies [#11](https://git.personal.ksv741.ru/WealthLedger/backend/issues/11) ([449cfcd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/449cfcd6c5a91307012bb3a640c8255ba243cbf5))
## [2.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.1.0...v2.2.0) (2026-10-18)
### Features
* unify income payment cards and drop period comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/backend/issues/33)) ([#83](https://git.personal.ksv741.ru/WealthLedger/backend/issues/83)) ([31b0e15](https://git.personal.ksv741.ru/WealthLedger/backend/commit/31b0e1557978019a9293e5fd3efeee319194cddd))
### Bug Fixes
* align password policy to 8-72 printable ASCII on register (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/backend/issues/60)) ([#109](https://git.personal.ksv741.ru/WealthLedger/backend/issues/109)) ([e03fad6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e03fad622bfec0fa7e42d536f65e361d319a1ac9))
* restore screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/backend/issues/70)) ([#95](https://git.personal.ksv741.ru/WealthLedger/backend/issues/95)) ([2ad3517](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2ad3517d87bffa8af9285d72fd6f78b96b3eed9f))
* separate T-Bank RPC network time from queue wait (WealthLedger/backend[#81](https://git.personal.ksv741.ru/WealthLedger/backend/issues/81)) ([#94](https://git.personal.ksv741.ru/WealthLedger/backend/issues/94)) ([0d7839c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0d7839cf79669768bba55f420ed43304545ac8d9))
* skip hourly terms overlapped by a long walk (WealthLedger/backend[#54](https://git.personal.ksv741.ru/WealthLedger/backend/issues/54)) ([6975853](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6975853656698b61781ca056f35c0d68e47ea32a))
* surface durable hourly attempt in accounts and income meta (WealthLedger/backend[#58](https://git.personal.ksv741.ru/WealthLedger/backend/issues/58)) ([14bcb9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14bcb9da5ff081710d672d140175d348a03a2fe5))
* translate permanent invalid-token errors on online entrypoints (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/backend/issues/59)) ([#110](https://git.personal.ksv741.ru/WealthLedger/backend/issues/110)) ([8a6f60d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8a6f60d9651fc7c474dd9b3690314505e18c4fa0))
### Performance
* batch bank operation page relations (WealthLedger/backend[#68](https://git.personal.ksv741.ru/WealthLedger/backend/issues/68)) ([285dd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/285dd08472d9acb66fab8d46d511d9ce607b110f))
* start bond search prices with details (WealthLedger/backend[#71](https://git.personal.ksv741.ru/WealthLedger/backend/issues/71)) ([#89](https://git.personal.ksv741.ru/WealthLedger/backend/issues/89)) ([da2e1d0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/da2e1d0781d23f4ab1c38e7bf3cad2e254d026d5))
### Refactoring
* extract shared public instrument projection ([#69](https://git.personal.ksv741.ru/WealthLedger/backend/issues/69)) ([#84](https://git.personal.ksv741.ru/WealthLedger/backend/issues/84)) ([9b7e5e1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b7e5e1215b7c1c5320effb6ba5b2c2a8b0cd604))
* remove unreachable catalog producer and sync orchestration (WealthLedger/backend[#62](https://git.personal.ksv741.ru/WealthLedger/backend/issues/62)) ([#98](https://git.personal.ksv741.ru/WealthLedger/backend/issues/98)) ([96d4de4](https://git.personal.ksv741.ru/WealthLedger/backend/commit/96d4de4195b105d64ac6ffaf8d3d8aab4142ece4))
### CI
* validate edited PR titles (WealthLedger/backend[#74](https://git.personal.ksv741.ru/WealthLedger/backend/issues/74)) ([bbb5aff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bbb5afff842720374feecfcd74eea83283e0b2ae))
### Tests
* await portfolio publication done instead of polling ticks (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#103](https://git.personal.ksv741.ru/WealthLedger/backend/issues/103)) ([7023021](https://git.personal.ksv741.ru/WealthLedger/backend/commit/702302126b81335959be2e66b4b5ad38e5806b70))
* cover auth token, cookie and expiry boundaries (WealthLedger/backend[#61](https://git.personal.ksv741.ru/WealthLedger/backend/issues/61)) ([#113](https://git.personal.ksv741.ru/WealthLedger/backend/issues/113)) ([32622c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/32622c1dad9649842d8cb78d8e964da33e5df459))
* isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend[#65](https://git.personal.ksv741.ru/WealthLedger/backend/issues/65)) ([#100](https://git.personal.ksv741.ru/WealthLedger/backend/issues/100)) ([9e4f9d9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9e4f9d9cab12aa714449b99280894a521c3514b6))
* replace late portfolio sleep with started and done barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#101](https://git.personal.ksv741.ru/WealthLedger/backend/issues/101)) ([7bc7d8d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7bc7d8ddecc6cb9f387d00be68f61eb99d57d6fc))
* replace metadata sleeps with upstream start barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#102](https://git.personal.ksv741.ru/WealthLedger/backend/issues/102)) ([ec6d9a2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/ec6d9a2639f1845674bf7ce5fc357f2f7b7f5dfb))
* slim sqlite runner regression workload (WealthLedger/backend[#72](https://git.personal.ksv741.ru/WealthLedger/backend/issues/72)) ([1033d21](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1033d2114e99232a90c5d5c321749a9c6e3fbff0))
### Maintenance
* adopt linear git model in backend docs ([#111](https://git.personal.ksv741.ru/WealthLedger/backend/issues/111)) ([4f485ab](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4f485ab0e9f0f9d52ae446e51966525473948b0e))
* align SQLite driver version (WealthLedger/backend[#67](https://git.personal.ksv741.ru/WealthLedger/backend/issues/67)) ([203cc72](https://git.personal.ksv741.ru/WealthLedger/backend/commit/203cc72cb2d1aac34f39c5ba528820b511666f17))
* drop unused direct source-map-support dependency (WealthLedger/backend[#4](https://git.personal.ksv741.ru/WealthLedger/backend/issues/4)) ([#96](https://git.personal.ksv741.ru/WealthLedger/backend/issues/96)) ([c789e33](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c789e33c41281f2fc3de8851f21ae897a81e2b5d))
* make OpenAPI export atomic and add read-only check (WealthLedger/backend[#3](https://git.personal.ksv741.ru/WealthLedger/backend/issues/3)) ([#97](https://git.personal.ksv741.ru/WealthLedger/backend/issues/97)) ([a93f2d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a93f2d55e7a31515ed1cc056cd57101f2b315405))
* unify verification preparation (WealthLedger/backend[#66](https://git.personal.ksv741.ru/WealthLedger/backend/issues/66)) ([b90cedd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b90cedd580321fb560a4ad0d9f9f5ba03303248e))
## [2.1.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.0.0...v2.1.0) (2026-10-12)
### Features
* add hourly portfolio sync history ([0057925](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0057925888dde874830149865fc5e13920ac5783))
* add stock screener ([7d10ef8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d10ef8adfce76ad2ed6368e1022fbd3f6847a23))
* reconcile hourly investment resource states ([323b83b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/323b83b5827d1191fbc14cfdf1d0e62f249fc78d))
### Bug Fixes
* align backend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c7d0600](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c7d060023b612af9e1dd4c2a2fb2d5d1bf173784))
* bound hourly step awaits by the per-step wait limit ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([52fa3a3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/52fa3a36baa5ab236a7ebd64ef1b4f447c91e129))
* keep candle retries read-only ([947ffef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/947ffefa9b8cad7f19355c187f2bd50c9739a822))
* restore terminal hourly lifecycle after persistence failure ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([fa57a20](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa57a201fdd8127774aee9af553da75a75add07c))
* unblock backend dependency audit ([85858a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85858a160f00dd428c80b8fa1824c1779245fdfe))
### Documentation
* clarify backend verification policy ([d7895f2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7895f287ee802e7b13df7a2a4c6dba0b711e5a9))
* pin backend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c5248db](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5248dbefc4b11a9f417bc835041a4294bd61047))
### CI
* skip checks on PR metadata edits ([0ad84ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ad84ff4f0b4712abc7e69dc299856f14af6eadd))
### Tests
* align all accounts metadata ([db375cb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/db375cbee7d8f1eff48822b8e3c74898f40e8886))
* align resource availability metadata ([bc631e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bc631e72de0d9b0ff7b261b1ea644a73c2943d2a))
* align upcoming events with local reads ([966b2a5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966b2a5836cdaf5b761e3c76568ff30a90ba1f4c))
* extend ledger sqlite timeout ([4870d49](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4870d4950303ad1de743e19b665b2fbdc05b781a))
* increase ledger request timeout ([2facd86](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2facd8675c74a97d92e0e8f44ed4a93aabb47674))
### Maintenance
* align backend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([a31e8d2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a31e8d2c4d85a21cdd5efdcc9f5fe503f210804b))
* prune obsolete eslint suppressions ([a46a411](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a46a4112cbcc72b1c3ff17cac4ac8bebcf579bca))
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES
* remove investment stateVersion, queued sync state,
and tbank-sync status progress/resources. Consumers must observe
resources through sync=observe with the updated frontend contract.
### Bug Fixes
* **deps:** resolve npm audit advisories ([15a3d5f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/15a3d5f331a0368032a747b4acd60db824127d99))
* **deps:** restore backend security gate ([30e96e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/30e96e51e2622e3168f9137769d0343777243dcf))
* harden auth and investment synchronization ([4796d9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4796d9d3f0160d96004a982f6c2526f199de4b9f))
* **investments:** align roles and date contracts ([516e432](https://git.personal.ksv741.ru/WealthLedger/backend/commit/516e43203eb3fee8e6a0c7018be2fee11f1b900c))
* resolve build imports before linting ([b244330](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b24433092bd7deeb12589ee4c1fbc7f9e6b2a8b0))
* resolve build imports before linting ([7d2262f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d2262fb439298e73d2651aabae7c6dc6e2107d5))
### Performance
* group catalog source reads by identity ([6cd9c2d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6cd9c2dd28f7f65eebb4dbfc0b459c52b0e8fe35))
* group catalog source reads by identity ([e76eb54](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e76eb545d7d2bbd763e696c947da9e0cd34e1ecd))
* **income:** compute Moscow dates once ([a7619d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a7619d5c8fbb1a741727a0864dbb627ce2a190be))
### Refactoring
* centralize provider projections ([7fe88a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7fe88a12c7cd3c5eb16a1912c5bd55d171a64c95))
* clarify nullable card metadata ([190eb90](https://git.personal.ksv741.ru/WealthLedger/backend/commit/190eb90ffe0b55a0e71047e9778aa77e167841a1))
* enforce enum machine codes ([33592c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/33592c1d5ff582ce57b596ae4e2324b1cea2b0f4))
* enforce enum machine codes ([2d14081](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2d14081d27935dc85da466e5d9713d2f703ca673))
* isolate operation ranges and intents ([a13cf84](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a13cf84eadb0b36bd0c364a1d6f10831a8fdc44c))
* replace ignored numeric literals with constants ([b78c215](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b78c215ff13e908621e6970cfdcdd4f483e45d2a))
* replace ignored numeric literals with constants ([9cc9fbd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9cc9fbdcff91f1a58c78ab83eea5fc0bc80106c7))
* share authentication result type ([fb2e851](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fb2e851e7e3f58c915ba1086212aa7957c3f9ed3))
* simplify investment data flow ([1be6db6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1be6db65d70f6a96629988289500098b2728b2eb))
* simplify runtime coordination [#7](https://git.personal.ksv741.ru/WealthLedger/backend/issues/7) ([d698ed9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d698ed9425a5da49846ffe4e91a396e3f2d91f72))
### CI
* reduce duplicate actions and serialize checks ([53b2794](https://git.personal.ksv741.ru/WealthLedger/backend/commit/53b27949631cadda10f988ff1920ac813d175ccc))
### Tests
* separate performance budget from e2e gate ([01e2de6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/01e2de6a041e75da739b8288d055b1dce82d38a2))
### Styles
* align multiline ternary operators ([183b7f1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/183b7f1efdafb77f2eb5afb82160f79feb642887))
* align multiline ternary operators ([0303e60](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0303e60b64c507b03fba91f9987f024cf28c95cc))
### Maintenance
* guard backend responsibility boundaries ([14a204d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14a204de0d32ad78193c614bef28513d1f97c593))
* strengthen backend eslint profile ([a970eaa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a970eaaf7a5549d1c51844ee6fc06c1aaf5afa56))
* strengthen backend eslint profile ([9b3558f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b3558f96c88ff625deae83c07d2087c2ceabfe9))
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26)
### ⚠ BREAKING CHANGES
* investment reads return data/meta resource envelopes and locally
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
synchronization endpoints.
### Features
* persist T-Bank data and synchronize reads ([d6e6c4a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d6e6c4ace553572ff9e9f635b7a4d21c8afbf97f))
### Tests
* prove reads finish before source synchronization ([e423213](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e4232138d04ad6a2aa8100a7d36fdcb2d7663fa8))
## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.2...v0.2.3) (2026-09-19)
### Bug Fixes
* correct Nexus routing and SonarQube host ([7c0150c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7c0150c56b2dab44347b55344e04c821689543e7))
## [0.2.2](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.1...v0.2.2) (2026-09-19)
### Bug Fixes
* update Nexus registry host ([4c91682](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4c916829eac23326a42c09873e32908cfc9f422c))
## [0.2.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.0...v0.2.1) (2026-09-18)
### Documentation
* use shared Nexus account [#25](https://git.personal.ksv741.ru/WealthLedger/backend/issues/25) ([9437b6a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9437b6a311cd51230b8381c8643c6ea308454d38))
### CI
* add backend SonarQube analysis [#23](https://git.personal.ksv741.ru/WealthLedger/backend/issues/23) ([1946192](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1946192be975eeea75432ec39b27d19952742b15))
* publish backend images to Nexus [#25](https://git.personal.ksv741.ru/WealthLedger/backend/issues/25) ([0ec7ae5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ec7ae51af080288a31dc6e65a738a4bd6396436))
## [0.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.1.0...v0.2.0) (2026-09-16)
### Features
* add backend container delivery ([1a6be43](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1a6be432f24f757728a03415051392069112af1f))
* generate OpenAPI without peer repositories ([32c12fb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/32c12fb10d4f739c6af0ce32b8f2ea6e94c42090))
### Bug Fixes
* allow reviewed container smoke fixture ([d7cb7e3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7cb7e3e206c995ef2fa6f1ee61ef7e93599d88f))
### Documentation
* refine backend conventions and routing ([9810d47](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9810d47e42d9275cf2520556f517d411058ddcb3))
* simplify conventions layout ([0167826](https://git.personal.ksv741.ru/WealthLedger/backend/commit/016782657acfd35ff3fd755462007938fac35156))
## 0.1.0 (2026-09-14) ## 0.1.0 (2026-09-14)
### Features ### Features
+42
View File
@@ -0,0 +1,42 @@
FROM node:24.22.1-bookworm-slim AS base
WORKDIR /app
FROM base AS dependencies
# Native SQLite/bcrypt builds happen only here; compilers stay out of runtime.
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates openssl && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx --no-install prisma generate
FROM dependencies AS development
# Nest watch uses ps to stop the previous process tree before restarting.
RUN apt-get update && apt-get install -y --no-install-recommends procps && rm -rf /var/lib/apt/lists/*
RUN mkdir /data && chown node:node /data && chown -R node:node /app
USER node
ENV NODE_ENV=development DATABASE_URL=file:/data/wealthledger.db
EXPOSE 3000
CMD ["npm", "run", "start:dev"]
FROM dependencies AS build
# The client is already generated at the shared dependencies stage (same revision).
RUN npx --no-install nest build && npm prune --omit=dev --omit=optional
FROM base AS production
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates openssl && rm -rf /var/lib/apt/lists/* \
&& mkdir /data && chown node:node /data
ARG VERSION
ARG REVISION
LABEL org.opencontainers.image.version=$VERSION \
org.opencontainers.image.revision=$REVISION
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
COPY --from=build /app/package.json /app/package-lock.json ./
ENV NODE_ENV=production DATABASE_URL=file:/data/wealthledger.db
USER node
EXPOSE 3000
CMD ["node", "dist/src/main.js"]
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=6 \
CMD node -e 'fetch("http://127.0.0.1:3000/api/v1/auth/me").then(async r=>{await r.arrayBuffer();process.exit(r.status===401?0:1)}).catch(()=>process.exit(1))'
+231 -32
View File
@@ -1,7 +1,8 @@
# WealthLedger backend # WealthLedger backend
NestJS API WealthLedger. Backend владеет auth, SQLite-данными и server-side NestJS API WealthLedger. Backend владеет auth, SQLite-данными и server-side
интеграцией T-Bank; frontend не обращается к T-Bank напрямую. интеграциями. Потребители используют публичный HTTP API и OpenAPI artifact;
их репозитории не нужны для разработки, сборки и проверок backend.
## Быстрый старт ## Быстрый старт
@@ -13,6 +14,7 @@ cp .env.example .env
nvm use --silent nvm use --silent
npm ci npm ci
npx prisma migrate deploy npx prisma migrate deploy
npx prisma generate
npm run start:dev npm run start:dev
``` ```
@@ -21,6 +23,36 @@ npm run start:dev
T-Bank token в `.env`: он вводится пользователем и хранится в зашифрованном T-Bank token в `.env`: он вводится пользователем и хранится в зашифрованном
виде. виде.
## Контейнерный image
`Dockerfile` имеет targets `development` (Nest watch, generated Prisma client и Linux native modules)
и `production` (compiled Node process от UID 1000, Prisma CLI/migrations и T-Bank proto assets). Node соответствует `.nvmrc`.
Build context ограничен `.dockerignore`; `.env`, local DB и host node_modules
не попадают в image.
```bash
docker build --target development -t wealthledger-backend:development .
docker build --target production -t wealthledger-backend:production .
nvm use --silent
npm run test:container -- wealthledger-backend:production
```
Gitea `image.yml` запускается на защищённом release tag `vX.Y.Z`, проверяет
package/lock version и canonical gates. Runner `ubuntu-latest` должен иметь
Docker/buildx. Образы хранятся в Nexus:
`nexus.personal.ksv741.ru/docker-hosted/wealthledger/backend`. Пользователь `wealthledger-ci`
задаётся Actions secrets `NEXUS_USERNAME`/`NEXUS_PASSWORD`; эта же учётная
запись используется сервером для загрузки образов. Docker endpoint и token realm должны использовать
HTTPS; в `docker-hosted` включается запрет redeploy. Прежние Gitea secrets
сохраняются на время перехода и не передаются в Nexus.
Публикуются semver tags, без `latest`; существующие release tags не перезаписываются. Production выбирает опубликованный version tag.
Compose topology принадлежит workspace, эксплуатационная последовательность —
[документация](https://git.personal.ksv741.ru/WealthLedger/docs),
страница `architecture/deployment.md`.
Проверка публикации требует настроенного registry/runner; локальный build
не доказывает package permissions.
## Проверка ## Проверка
Единый completion gate не изменяет versioned-файлы: Единый completion gate не изменяет versioned-файлы:
@@ -29,12 +61,15 @@ T-Bank token в `.env`: он вводится пользователем и хр
npm run verify npm run verify
``` ```
Он запускает zero-warning lint, Он запускает генерацию Prisma client, zero-warning lint,
application/test typechecks, Jest и build. `npm run lint:fix` — отдельная application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
записывающая команда, `npm run test:cov` — диагностика без общего числового записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
threshold. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
и runtime OpenAPI с соседним frontend checkout. Для полной backend-проверки на новых базах и соответствие генерируемого OpenAPI настроенному приложению.
выполните обе команды. Для полной backend-проверки выполните обе команды. Тяжёлый synthetic benchmark
с 300 000 операций и 1 000 000 свечей запускается отдельно командой
`npm run test:performance`: его wall-clock budget зависит от выделенного runner CPU
и не является функциональным merge gate.
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`, Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6 включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
@@ -43,19 +78,40 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
### CI и обновления зависимостей ### CI и обновления зависимостей
`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client, Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
`npm run verify` и `npm run test:e2e` на push и PR. Frontend snapshot для последовательно на PR; на push в `develop`/`main` — `security → verify`.
разработки берётся из `develop`; для `main`, PR в `main` и `release/*` — из Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
полного SHA в `.ci/refs.json`. Лог сохраняет оба SHA. Изменение Изменение заголовка PR повторяет только проверку Conventional Commits; `security`
контракта согласуется с этой frontend revision. Успешный unit gate не заменяет и `verify` на metadata-only событии не запускаются.
SQLite/e2e; ошибка любой suite завершает job с FAIL. Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
Job `verify` запускает `npm ci` и `npm run verify` (генерация Prisma client
внутри gate, один Nest build), затем функциональный `node test/sqlite.cjs --all`
на уже собранных артефактах. Используется только checkout
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
значениями и выполняет `npm audit --include=dev --audit-level=low` при push, значениями, блокирует любой runtime advisory через
PR и ежедневно. `.gitleaksignore` перечисляет только проверенные исторические `npm audit --omit=dev --audit-level=low` и high/critical findings полного дерева
через `npm audit --include=dev --audit-level=high`. Moderate advisory
`sprintf-js` остаётся только в Jest coverage tooling; исправленной версии пока
нет, а предложенный npm downgrade `ts-jest` несовместим с текущим test stack.
`.gitleaksignore` перечисляет только проверенные исторические
совпадения: детерминированные fixtures, placeholder и badge из Nest starter. совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
Новые коммиты этими исключениями не покрываются. Новые коммиты этими исключениями не покрываются.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
workflow в статус. При внедрении администратор должен заменить required checks
для защищённых веток на `Backend verify / commits (pull_request)`,
`Backend verify / security (pull_request)` и
`Backend verify / verify (pull_request)`, удалив старые имена и push-статусы,
если они требовались. Все три PR-проверки должны оставаться обязательными.
Публикация образов по тегам остаётся в `image.yml`.
Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте
`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные
runners. Конфигурация runner управляется вне этого репозитория.
Renovate группирует minor/patch updates, а major требует согласования в Renovate группирует minor/patch updates, а major требует согласования в
Dependency Dashboard. Автоматический merge не включён. Прямые зависимости Dependency Dashboard. Автоматический merge не включён. Прямые зависимости
фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install, фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install,
@@ -76,27 +132,108 @@ Required checks настраиваются отдельно в Gitea: налич
runtime-валидацию. В обычном коде нужны инициализация или сужение типа runtime-валидацию. В обычном коде нужны инициализация или сужение типа
после проверки значения. после проверки значения.
## ESLint
`npm run lint` проверяет весь authored TypeScript и JavaScript, включая scripts
и configs. `npm run lint:fix` (также `npm run format`) применяет безопасные
автоисправления. Форматирование принадлежит ESLint Stylistic: два пробела,
одинарные кавычки и точки с запятой; Prettier не используется.
Профиль: ESLint recommended, typescript-eslint recommendedTypeChecked,
Node, NestJS typed, Jest, import-x с TypeScript resolver, SonarJS, RegExp,
Security, Perfectionist recommended-natural и Stylistic customize.
NestJS проверяется в production TS, Jest — в specs/e2e; generated Prisma,
dist и coverage исключены. DI imports сохраняют runtime metadata.
Исключения конфигурации: private backend не публикуется в npm;
TS imports разрешает import-x вместо Node resolver; IsIn проверяет domain
unions. Автосортировка объектов, классов, модулей и декораторов отключена,
поскольку порядок может влиять на runtime. Остальные точечные исключения
объясняют false positives непосредственно в коде.
`eslint-suppressions.json` — штатный ESLint baseline только для существующих
SonarJS cognitive-complexity, no-nested-conditional и no-nested-template-literals.
Это отложенный refactor, а не исправленные нарушения. Baseline ограничен
файлами и количеством сообщений; новые файлы и превышение количества блокируют
lint. Он не защищает от усложнения уже учтённой функции при прежнем количестве
сообщений — это остаётся предметом review. При сокращении долга обновляйте
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
Рост baseline — изменение quality gate: он требует отдельного обоснования
и review затронутого поведения. При рефакторинге сохраняйте или сокращайте
baseline; прежнее число сообщений не доказывает прежнюю сложность функции.
## SonarQube
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
использует `sonar-project.properties`. Анализируются исходники `src`; unit specs
и HTTP specs учитываются как тесты. Внешние T-Bank proto contracts исключены,
generated Prisma client находится за пределами `sonar.sources`.
`npm run verify` запускает unit tests с покрытием один раз. Jest штатно создаёт
`coverage/lcov.info`; scanner импортирует этот отчёт. Покрытие HTTP/e2e tests
в него не входит, сами HTTP suites остаются обязательным отдельным шагом CI.
Создайте в SonarQube проект с ключом `wealthledger-backend`, а в Gitea → backend →
Settings → Actions → Secrets добавьте `SONAR_TOKEN` с правом анализа этого проекта.
Токен не сохраняется в файлах. Community Edition 9.9.8 получает анализ только
при push в `develop`, после успешных `verify` и SQLite/e2e checks. CI ожидает
Quality Gate; отрицательный результат или ошибка scanner завершает job с ошибкой.
PR и другие ветки проходят штатные проверки без отправки анализа.
Переименование основной ветки SonarQube не требуется; `sonar.branch.name` не задаётся.
Runner должен поддерживать Docker actions и иметь доступ к GitHub, Docker Hub
и HTTPS-адресу SonarQube с действующим сертификатом для этого имени.
Официальный Docker action закреплён на commit версии 2.3.0, как в frontend.
`tsconfig.sonar.json` содержит совместимые со старым анализатором настройки
модулей и decorators с target ES2022, без новых compiler options приложения.
Штатная компиляция приложения остаётся на ES2023.
После обновления сервера пересмотрите scanner и необходимость отдельного tsconfig.
Локально после `nvm use --silent`, `npm ci` и генерации Prisma client выполните
`npm run verify`, затем `sonar-scanner` с совместимым SonarScanner CLI и
`SONAR_TOKEN` в окружении. Первый запуск CI после merge должен подтвердить
загрузку отчёта и покрытия: локальные проверки не доказывают доступность сервера.
## Документация ## Документация
- [Router conventions](.ai/CONVENTIONS.md) и [источники](.ai/SOURCES.md) - [AGENTS: правила и источники](AGENTS.md)
- [Backend architecture](.ai/conventions/backend-architecture.md) - [Backend architecture](.ai/architecture.md)
- [HTTP API](.ai/conventions/backend-http-api.md), [data](.ai/conventions/backend-data.md) и [testing](.ai/conventions/backend-testing.md) - [HTTP API](.ai/conventions/http-api.md), [data](.ai/conventions/data.md) и [testing](.ai/conventions/testing.md)
- [Security/auth](.ai/conventions/security-auth.md) и [integrations](.ai/conventions/backend-integrations.md) - [Security/auth](.ai/conventions/security-auth.md) и [integrations](.ai/conventions/integrations.md)
- [Карта проектных фактов](../docs/README.md) - [Генерация OpenAPI](#генерация-openapi)
- [API contract и runtime OpenAPI](../docs/architecture/http-api.md#источник-контракта)
- [Обзор поставленной системы](../docs/overview.md)
- [Архитектурные решения](../docs/adr/README.md)
Из backend runtime OpenAPI можно read-only сверить с frontend snapshot: Генерация без сервера сверяется с OpenAPI настроенного приложения:
```bash ```bash
npm run test:openapi-contract npm run test:openapi-contract
``` ```
Разработка идёт напрямую по задаче пользователя. Каждый affected repo проходит ## Генерация OpenAPI
свой verify; при изменении wire contract дополнительно выполняется contract check.
Обязательного OpenSpec/SDD lifecycle нет. ```bash
nvm use --silent
npm run openapi:generate
```
Команда собирает backend и записывает `openapi.json` в корень репозитория
атомарно: через временный файл и rename, без частичной записи при сбое.
`npm run openapi:check` только сравнивает runtime schema с файлом и завершается
с ошибкой при расхождении, не перезаписывая его.
Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания
провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты
не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger
metadata; передай полученный artifact потребителю отдельно.
`npm run test:openapi-contract` собирает backend и сравнивает результат генератора
с runtime schema настроенного приложения, используя временный каталог и SQLite.
`npm run test:e2e` также сначала собирает backend. Изменение публичного API
проверяй relevant HTTP tests: без versioned snapshot сравнение не отслеживает
breaking changes относительно предыдущего выпуска.
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу.
## Реальная SQLite и сохранность данных ## Реальная SQLite и сохранность данных
@@ -117,10 +254,14 @@ npm run test:openapi-contract
настраивается отдельно. История migrations применяется штатным `migrate deploy`. настраивается отдельно. История migrations применяется штатным `migrate deploy`.
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
три последовательных и два параллельных полных e2e, проверяет уникальность DB, три последовательных и два параллельных запуска двух обычных e2e suites,
сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup. проверяет уникальность DB, сохранность вымышленного внешнего файла, отказ migration
и SIGINT/SIGTERM cleanup. Полный e2e набор остаётся в `npm run test:e2e`.
Логи остаются в каталоге из вывода. Для одной suite: Логи остаются в каталоге из вывода. Для одной suite:
`node test/sqlite.cjs app.e2e-spec.ts`. `node test/sqlite.cjs app.e2e-spec.ts`. Crash-регрессия в
`tbank-operations-sync.e2e-spec.ts` загружает классы из `dist/`, поэтому перед
адресным запуском этой suite выполните `npm run build`, иначе child/dist может
расходиться с parent/src.
| Риск | Проверка реальной DB | | Риск | Проверка реальной DB |
| --- | --- | | --- | --- |
@@ -161,4 +302,62 @@ npm run test:durability -- "$baseline_dir"
Durability оставляет временные DB, логи и `result.json` по пути из вывода для Durability оставляет временные DB, логи и `result.json` по пути из вывода для
диагностики; он не откатывает schema пользовательской базы. диагностики; он не откатывает schema пользовательской базы.
Версии и ветки: [процесс релиза](../README.md#релизы-и-ветки). ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
PR сливается squash или rebase-merge; merge-коммит не используется. `main`
обновляется только fast-forward из `develop`, поэтому история линейна:
```text
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
```
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
```bash
git fetch -q origin
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
```
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
`nvm use --silent`, `npm ci`.
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
`verify`, `security`, `commits` выполни fast-forward-only merge. Если
`develop` изменился, обнови release-ветку и повтори проверки кандидата.
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег
без нового bump; до merge исправь и проверь существующий кандидат без
повторного повышения версии.
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
затем merge `main → develop` для восстановления инварианта.
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
администратором, включена блокировка устаревшей ветки, обязательны `verify`,
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
Workflow сам не включает эти настройки. Отдельное review approval для одного
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
+10 -2
View File
@@ -1,4 +1,12 @@
module.exports = { module.exports = {
extends: ["@commitlint/config-conventional"], extends: ['@commitlint/config-conventional'],
rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] }, defaultIgnores: false,
ignores: [
(message) => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
(message) => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: {
'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']],
'references-empty': [2, 'never'],
},
}; };
+169
View File
@@ -0,0 +1,169 @@
{
"src/common/decimal.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/integrations/tbank/instrument-metadata.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/integrations/tbank/instrument-metadata.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/integrations/tbank/tbank.client.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/modules/banking/bank-cards/application/bank-operations.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/modules/investments/income/income-analytics.service.ts": {
"sonarjs/no-nested-template-literals": {
"count": 1
}
},
"src/modules/investments/income/income-period.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/application/candle.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 3
},
"sonarjs/no-nested-conditional": {
"count": 4
},
"sonarjs/no-nested-template-literals": {
"count": 1
}
},
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/bonds/application/bond-income.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"src/modules/investments/instruments/bonds/application/bond.spec.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/infrastructure/candle.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 2
}
},
"src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"src/modules/investments/instruments/stocks/application/stock-income.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/investment-sync-plan.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 4
}
},
"src/modules/investments/operations/application/brokerage-operations.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 2
},
"sonarjs/no-nested-conditional": {
"count": 9
}
},
"src/modules/investments/operations/domain/operations-sync-plan.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts": {
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 3
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/portfolio/application/forecast.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/tbank-sync/tbank-screen.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 6
}
},
"test/bank-cards-durability.cjs": {
"sonarjs/no-nested-template-literals": {
"count": 1
}
},
"test/sqlite-regression.cjs": {
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"test/sqlite.cjs": {
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"test/tbank-ledger-local.e2e-spec.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
},
"sonarjs/no-nested-template-literals": {
"count": 3
}
}
}
+206 -57
View File
@@ -1,59 +1,95 @@
// @ts-check // @ts-check
import nest from '@darraghor/eslint-plugin-nestjs-typed';
import eslint from '@eslint/js'; import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import stylistic from '@stylistic/eslint-plugin';
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
import { importX } from 'eslint-plugin-import-x';
import jest from 'eslint-plugin-jest';
import node from 'eslint-plugin-n';
import perfectionist from 'eslint-plugin-perfectionist';
import regexp from 'eslint-plugin-regexp';
import security from 'eslint-plugin-security';
import sonarjs from 'eslint-plugin-sonarjs';
import { defineConfig, globalIgnores } from 'eslint/config';
import globals from 'globals'; import globals from 'globals';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
export default tseslint.config( const tests = ['**/*.spec.ts', 'test/**/*.ts'];
const prismaImports = [
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
];
export default defineConfig(
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
{ {
ignores: ['eslint.config.mjs', 'dist/', 'generated/'], files: ['**/*.{ts,js,cjs,mjs}'],
}, extends: [
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, node.configs['flat/recommended'],
eslintPluginPrettierRecommended, importX.flatConfigs.recommended,
{ sonarjs.configs.recommended,
languageOptions: { regexp.configs['flat/recommended'],
globals: { security.configs.recommended,
...globals.node, perfectionist.configs['recommended-natural'],
...globals.jest, stylistic.configs.customize({ indent: 2, quotes: 'single', semi: true, jsx: false }),
}, ],
sourceType: 'commonjs', languageOptions: { globals: globals.node },
parserOptions: { linterOptions: { reportUnusedDisableDirectives: 'error' },
projectService: true, settings: {
tsconfigRootDir: import.meta.dirname, 'import-x/resolver-next': [createTypeScriptImportResolver()],
}, 'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.22.1' },
},
rules: {
'curly': ['error', 'multi-line'],
// This private application is deployed as a whole, never published to npm.
'n/no-unpublished-import': 'off',
'n/no-unpublished-require': 'off',
// Decorator execution and object insertion order can be observable at runtime.
'perfectionist/sort-decorators': 'off',
'perfectionist/sort-objects': 'off',
'perfectionist/sort-modules': 'off',
'perfectionist/sort-classes': 'off',
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }],
'@stylistic/arrow-parens': ['error', 'always'],
'@stylistic/operator-linebreak': [
'error',
'after',
{ overrides: { '?': 'before', ':': 'before' } },
],
'import-x/no-cycle': 'error',
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
}, },
}, },
{ {
files: ['**/*.mjs'],
extends: [node.configs['flat/recommended-module']],
},
{
files: ['scripts/generate-openapi.cjs', 'test/fixtures/tbank-sync-crash.cjs', 'test/runtime-check.cjs'],
rules: { rules: {
// These scripts run after build; lint validates their source paths before dist exists.
'n/no-missing-require': ['error', {
resolverConfig: {
alias: { '../dist': import.meta.dirname, '../../dist': import.meta.dirname },
extensionAlias: { '.js': ['.ts', '.js'] },
},
}],
},
},
{
files: ['**/*.ts'],
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
languageOptions: {
sourceType: 'module',
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: {
// TypeScript resolves source imports; import-x checks them with the TS resolver.
'n/no-missing-import': 'off',
'no-magic-numbers': 'off', 'no-magic-numbers': 'off',
'@typescript-eslint/no-magic-numbers': [ '@typescript-eslint/no-magic-numbers': [
'error', 'error',
{ {
ignore: [
-60,
-24,
-10,
-2,
-1,
0,
1,
2,
10,
24,
60,
'-60n',
'-24n',
'-10n',
'-2n',
'-1n',
'0n',
'1n',
'2n',
'10n',
'24n',
'60n',
],
ignoreArrayIndexes: true, ignoreArrayIndexes: true,
ignoreDefaultValues: true, ignoreDefaultValues: true,
ignoreEnums: true, ignoreEnums: true,
@@ -63,24 +99,137 @@ export default tseslint.config(
enforceConst: true, enforceConst: true,
}, },
], ],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'error', '@typescript-eslint/no-non-null-assertion': 'error',
'no-restricted-syntax': [ 'no-restricted-syntax': ['error', {
'error', selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
{ message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
selector: }],
'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
message:
'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
},
],
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'prettier/prettier': ['error', { endOfLine: 'auto' }],
}, },
}, },
{ {
files: ['**/*.spec.ts', 'test/**/*.ts'], files: ['src/**/*.ts'],
rules: { '@typescript-eslint/no-magic-numbers': 'off' }, ignores: tests,
extends: [nest.configs.flatRecommended],
rules: {
// Domain string unions are validated with IsIn rather than runtime TS enums.
'@darraghor/nestjs-typed/validated-non-primitive-property-needs-type-decorator': ['error', { additionalTypeDecorators: ['IsIn'] }],
},
},
{
files: tests,
extends: [jest.configs['flat/recommended']],
rules: {
'@typescript-eslint/no-magic-numbers': 'off',
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
},
},
{
files: ['src/modules/**/domain/**/*.ts'],
ignores: tests,
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: [
'@nestjs/*', ...prismaImports, '**/*.dto', '**/*.repository',
'**/*.client', '**/application/**', '**/infrastructure/**',
],
message: 'Domain uses pure rules and value types, not transport or persistence.',
}],
}],
},
},
{
files: ['src/**/*.controller.ts'],
ignores: tests,
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: [...prismaImports, '**/*.repository', '**/*.client'],
message: 'Controllers delegate to application services or use cases.',
},
{
group: ['**'],
importNamePattern: '(?:Client|Repository|PrismaService)$',
message: 'Use application orchestration rather than data providers in controllers.',
},
],
}],
},
},
{
files: ['src/**/*.use-case.ts'],
ignores: tests,
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: prismaImports,
message: 'Use cases obtain data through concrete repositories.',
}],
}],
},
},
{
files: [
'src/**/*.ts',
],
ignores: [
'**/*.spec.ts',
'test/**/*.ts',
],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)',
message: 'Use the owning enum member for machine codes and protocol values.',
},
{
selector: 'TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)',
message: 'Use the owning enum member instead of a template machine code.',
},
{
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]',
message: 'Use an enum member instead of an inline code or protocol value.',
},
{
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value',
message: 'Use an enum member instead of constructing a code or protocol value.',
},
{
selector: 'Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]',
message: 'Use a computed enum member as the protocol mapping key.',
},
{
selector: 'BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]',
message: 'Compare codes with their enum members, not string literals.',
},
{
selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
},
],
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
},
},
{
files: ['src/**/*.ts'],
ignores: [...tests, 'src/modules/investments/tbank-sync/**'],
rules: {
// Separate rule so it does not replace layer-specific no-restricted-imports configs.
'import-x/no-restricted-paths': ['error', {
zones: [{
target: './src',
from: './src/modules/investments/tbank-sync',
except: [
'./tbank-sync.api.ts',
'./tbank-sync.dto.ts',
'./tbank-sync.module.ts',
'./tbank-sync-repository.module.ts',
],
message: 'Use tbank-sync.api for cross-module access.',
}],
}],
},
}, },
); );
+1620 -1645
View File
File diff suppressed because it is too large Load Diff
+72 -47
View File
@@ -1,107 +1,133 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "0.1.0", "version": "2.3.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"engines": { "engines": {
"node": "v24.21.0" "node": "v24.22.1"
}, },
"scripts": { "scripts": {
"build": "prisma generate && nest build", "build": "prisma generate && nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
"format": "npm run lint:fix",
"start": "nest start", "start": "nest start",
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", "start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
"start:debug": "nest start --debug --watch --path tsconfig.dev.json", "start:debug": "NODE_ENV=development nest start --debug --watch --path tsconfig.dev.json",
"start:prod": "node dist/src/main.js", "start:prod": "node dist/src/main.js",
"lint": "npm run lint:check", "lint": "npm run lint:check",
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings=0", "lint:check": "eslint . --max-warnings=0",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint:fix": "eslint . --fix --max-warnings=0",
"typecheck:app": "tsc --project tsconfig.typecheck.app.json", "typecheck:app": "tsc --project tsconfig.typecheck.app.json",
"typecheck:test": "tsc --project tsconfig.typecheck.test.json", "typecheck:test": "tsc --project tsconfig.typecheck.test.json",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test:watch": "npm test -- --watch", "test:watch": "npm test -- --watch",
"test:cov": "npm test -- --coverage", "test:cov": "npm test -- --coverage",
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "node test/sqlite.cjs --all", "test:e2e": "npm run build && node test/sqlite.cjs --all",
"test:performance": "npm run build && node test/sqlite.cjs tbank-read-baseline.e2e-spec.ts",
"test:sqlite": "node test/sqlite.cjs", "test:sqlite": "node test/sqlite.cjs",
"test:container": "node test/container-smoke.cjs",
"test:durability": "node test/bank-cards-durability.cjs", "test:durability": "node test/bank-cards-durability.cjs",
"test:openapi-contract": "node test/sqlite.cjs openapi-contract.e2e-spec.ts", "test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm test -- --runInBand && npm run build", "verify": "prisma generate && npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && nest build",
"release:preview": "release-it --dry-run --ci", "release:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci", "release:prepare": "release-it --ci",
"commitlint": "commitlint" "commitlint": "commitlint",
"test:lint": "node --test test/eslint-config.cjs"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "1.14.4", "@grpc/grpc-js": "1.14.6",
"@grpc/proto-loader": "0.8.1", "@grpc/proto-loader": "0.8.1",
"@nestjs/common": "12.0.1", "@nestjs/common": "12.2.0",
"@nestjs/config": "12.0.0", "@nestjs/config": "12.0.1",
"@nestjs/core": "12.0.1", "@nestjs/core": "12.2.0",
"@nestjs/jwt": "12.0.1", "@nestjs/jwt": "12.0.3",
"@nestjs/passport": "12.0.0", "@nestjs/passport": "12.0.0",
"@nestjs/platform-express": "12.0.1", "@nestjs/platform-express": "12.2.0",
"@nestjs/swagger": "12.0.1", "@nestjs/swagger": "12.0.4",
"@prisma/adapter-better-sqlite3": "7.10.0", "@prisma/adapter-better-sqlite3": "7.10.1",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.1",
"bcrypt": "6.0.0", "bcrypt": "6.0.0",
"better-sqlite3": "13.0.3", "better-sqlite3": "12.11.1",
"cheerio": "1.2.0", "cheerio": "1.2.0",
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
"class-validator": "0.15.1", "class-validator": "0.15.1",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"lru-cache": "11.5.2", "dotenv": "17.4.2",
"nestjs-pino": "5.1.0", "lru-cache": "11.5.3",
"nestjs-pino": "5.3.1",
"passport": "0.7.0", "passport": "0.7.0",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"pino-http": "11.0.0", "pino-http": "11.0.0",
"prisma": "7.10.1",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.2" "rxjs": "7.8.2"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "21.2.2", "@commitlint/cli": "21.2.4",
"@commitlint/config-conventional": "21.2.2", "@commitlint/config-conventional": "21.2.4",
"@eslint/eslintrc": "3.3.7", "@darraghor/eslint-plugin-nestjs-typed": "7.5.8",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@nestjs/cli": "12.0.0", "@nestjs/cli": "12.0.10",
"@nestjs/schematics": "12.0.1", "@nestjs/schematics": "12.0.10",
"@nestjs/testing": "12.0.1", "@nestjs/testing": "12.2.0",
"@release-it/conventional-changelog": "12.0.0", "@release-it/conventional-changelog": "12.0.2",
"@stylistic/eslint-plugin": "5.10.0",
"@types/bcrypt": "6.0.0", "@types/bcrypt": "6.0.0",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/express": "5.0.6", "@types/express": "5.0.6",
"@types/jest": "30.0.0", "@types/jest": "30.0.0",
"@types/node": "24.13.4", "@types/node": "24.19.2",
"@types/passport-jwt": "4.0.1", "@types/passport-jwt": "4.0.1",
"@types/supertest": "7.2.1", "@types/supertest": "7.2.1",
"conventional-changelog-conventionalcommits": "10.4.0", "conventional-changelog-conventionalcommits": "10.4.1",
"eslint": "10.10.0", "eslint": "10.13.0",
"eslint-config-prettier": "10.1.8", "eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-prettier": "5.5.6", "eslint-plugin-import-x": "4.17.1",
"globals": "17.12.0", "eslint-plugin-jest": "29.17.0",
"jest": "30.5.1", "eslint-plugin-n": "18.5.0",
"prettier": "3.9.6", "eslint-plugin-perfectionist": "5.13.0",
"prisma": "7.10.0", "eslint-plugin-regexp": "3.3.1",
"release-it": "21.0.2", "eslint-plugin-security": "4.2.0",
"source-map-support": "0.5.21", "eslint-plugin-sonarjs": "4.2.2",
"supertest": "7.2.2", "globals": "17.13.0",
"ts-jest": "29.4.12", "jest": "30.5.2",
"pino-pretty": "13.2.0",
"release-it": "21.1.1",
"supertest": "7.3.1",
"ts-jest": "29.4.15",
"ts-loader": "9.6.2", "ts-loader": "9.6.2",
"ts-node": "10.9.2", "ts-node": "10.9.2",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.70.0" "typescript-eslint": "8.72.0"
}, },
"overrides": { "overrides": {
"proxy-addr": "2.0.8",
"express": {
"body-parser": "2.4.0"
},
"@nestjs/platform-express": { "@nestjs/platform-express": {
"multer": "2.3.0" "multer": "2.5.0"
},
"ajv": {
"fast-uri": "3.1.9"
}, },
"@prisma/config@7.10.0": { "@prisma/config@7.10.0": {
"deepmerge-ts": "8.0.2" "deepmerge-ts": "8.0.2"
}, },
"release-it": {
"basic-ftp": "6.2.3",
"undici": "7.30.1"
},
"socks": {
"ip-address": "10.7.4"
},
"prisma": { "prisma": {
"mysql2": "3.24.4" "mysql2": "3.24.6"
} }
}, },
"jest": { "jest": {
@@ -128,7 +154,6 @@
"better-sqlite3@12.11.1": true, "better-sqlite3@12.11.1": true,
"fsevents@2.3.3": true, "fsevents@2.3.3": true,
"protobufjs@7.6.5": true, "protobufjs@7.6.5": true,
"unrs-resolver@1.12.2": true, "unrs-resolver@1.12.2": true
"better-sqlite3@13.0.3": true
} }
} }
@@ -0,0 +1,239 @@
PRAGMA foreign_keys=OFF;
BEGIN IMMEDIATE;
-- CreateTable
CREATE TABLE "TbankAccount" (
"userId" TEXT NOT NULL,
"revision" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"openedOn" TEXT,
"upstreamOrder" INTEGER NOT NULL,
"operationsRevision" INTEGER NOT NULL DEFAULT 0,
"historyFrom" DATETIME,
"historyBoundary" TEXT NOT NULL DEFAULT 'unknown',
PRIMARY KEY ("userId", "revision", "accountId"),
CONSTRAINT "TbankAccount_userId_revision_fkey" FOREIGN KEY ("userId", "revision") REFERENCES "TbankConnection" ("userId", "revision") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- CreateTable
CREATE TABLE "TbankPortfolio" (
"userId" TEXT NOT NULL,
"revision" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"mappingVersion" INTEGER NOT NULL DEFAULT 1,
"fetchedAt" DATETIME NOT NULL,
"dataRevision" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY ("userId", "revision", "accountId"),
CONSTRAINT "TbankPortfolio_userId_revision_accountId_fkey" FOREIGN KEY ("userId", "revision", "accountId") REFERENCES "TbankAccount" ("userId", "revision", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- CreateTable
CREATE TABLE "TbankOperation" (
"userId" TEXT NOT NULL,
"revision" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"brokerOperationId" TEXT NOT NULL,
"occurredAt" DATETIME NOT NULL,
"instrumentUid" TEXT,
"category" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"mappingVersion" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY ("userId", "revision", "accountId", "brokerOperationId"),
CONSTRAINT "TbankOperation_userId_revision_accountId_fkey" FOREIGN KEY ("userId", "revision", "accountId") REFERENCES "TbankAccount" ("userId", "revision", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- CreateTable
CREATE TABLE "TbankOperationCoverage" (
"userId" TEXT NOT NULL,
"revision" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"instrumentPredicate" TEXT NOT NULL DEFAULT '',
"from" DATETIME NOT NULL,
"to" DATETIME NOT NULL,
"fetchedAt" DATETIME NOT NULL,
"asOf" DATETIME NOT NULL,
PRIMARY KEY ("userId", "revision", "accountId", "instrumentPredicate", "from", "to"),
CONSTRAINT "TbankOperationCoverage_userId_revision_accountId_fkey" FOREIGN KEY ("userId", "revision", "accountId") REFERENCES "TbankAccount" ("userId", "revision", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- CreateTable
CREATE TABLE "TbankForecast" (
"userId" TEXT NOT NULL,
"revision" TEXT NOT NULL,
"scopeKey" TEXT NOT NULL,
"membershipRevision" INTEGER NOT NULL,
"payload" JSONB NOT NULL,
"dependencyRevisions" JSONB NOT NULL,
"sourceTimestamps" JSONB NOT NULL,
"calculatedAt" DATETIME NOT NULL,
"dataRevision" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY ("userId", "revision", "scopeKey"),
CONSTRAINT "TbankForecast_userId_revision_fkey" FOREIGN KEY ("userId", "revision") REFERENCES "TbankConnection" ("userId", "revision") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- CreateTable
CREATE TABLE "TbankInstrument" (
"provider" TEXT NOT NULL,
"uid" TEXT NOT NULL,
"figi" TEXT,
"ticker" TEXT,
"instrumentType" TEXT NOT NULL,
"metadata" JSONB NOT NULL,
"fetchedAt" DATETIME NOT NULL,
"dataRevision" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY ("provider", "uid")
);
-- CreateTable
CREATE TABLE "TbankCatalogDiscovery" (
"provider" TEXT NOT NULL,
"instrumentType" TEXT NOT NULL,
"query" TEXT NOT NULL,
"instrumentUids" JSONB NOT NULL,
"fetchedAt" DATETIME NOT NULL,
"dataRevision" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY ("provider", "instrumentType", "query")
);
-- CreateTable
CREATE TABLE "TbankInstrumentLookup" (
"provider" TEXT NOT NULL,
"identifierType" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"matchedUids" JSONB NOT NULL,
"fetchedAt" DATETIME NOT NULL,
"dataRevision" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY ("provider", "identifierType", "identifier")
);
-- CreateTable
CREATE TABLE "TbankInstrumentSource" (
"provider" TEXT NOT NULL,
"instrumentUid" TEXT NOT NULL,
"source" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"rangeKey" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"fetchedAt" DATETIME NOT NULL,
"dataRevision" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY ("provider", "instrumentUid", "source", "kind", "rangeKey"),
CONSTRAINT "TbankInstrumentSource_provider_instrumentUid_fkey" FOREIGN KEY ("provider", "instrumentUid") REFERENCES "TbankInstrument" ("provider", "uid") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TbankCandle" (
"provider" TEXT NOT NULL,
"instrumentUid" TEXT NOT NULL,
"interval" TEXT NOT NULL,
"time" DATETIME NOT NULL,
"close" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"isComplete" BOOLEAN NOT NULL,
"fetchedAt" DATETIME NOT NULL,
PRIMARY KEY ("provider", "instrumentUid", "interval", "time"),
CONSTRAINT "TbankCandle_provider_instrumentUid_fkey" FOREIGN KEY ("provider", "instrumentUid") REFERENCES "TbankInstrument" ("provider", "uid") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TbankCandleCoverage" (
"provider" TEXT NOT NULL,
"instrumentUid" TEXT NOT NULL,
"interval" TEXT NOT NULL,
"from" DATETIME NOT NULL,
"to" DATETIME NOT NULL,
"isComplete" BOOLEAN NOT NULL,
"fetchedAt" DATETIME NOT NULL,
"dataRevision" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY ("provider", "instrumentUid", "interval", "from", "to"),
CONSTRAINT "TbankCandleCoverage_provider_instrumentUid_fkey" FOREIGN KEY ("provider", "instrumentUid") REFERENCES "TbankInstrument" ("provider", "uid") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TbankSyncWork" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"revision" TEXT NOT NULL,
"scopeKey" TEXT NOT NULL,
"intent" JSONB NOT NULL,
"state" TEXT NOT NULL,
"attemptId" TEXT NOT NULL,
"processRunId" TEXT NOT NULL,
"priority" INTEGER NOT NULL,
"progress" JSONB NOT NULL,
"errorCode" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "TbankSyncWork_userId_revision_fkey" FOREIGN KEY ("userId", "revision") REFERENCES "TbankConnection" ("userId", "revision") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- CreateTable
CREATE TABLE "TbankSyncStage" (
"workId" TEXT NOT NULL,
"attemptId" TEXT NOT NULL,
"sourceKey" TEXT NOT NULL,
"itemKey" TEXT NOT NULL,
"payload" JSONB NOT NULL,
PRIMARY KEY ("workId", "attemptId", "sourceKey", "itemKey"),
CONSTRAINT "TbankSyncStage_workId_attemptId_fkey" FOREIGN KEY ("workId", "attemptId") REFERENCES "TbankSyncWork" ("id", "attemptId") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- RedefineTables
CREATE TABLE "new_TbankConnection" (
"userId" TEXT NOT NULL,
"encryptedToken" TEXT NOT NULL,
"connectedAt" DATETIME NOT NULL,
"revision" TEXT NOT NULL,
"invalidToken" BOOLEAN NOT NULL DEFAULT false,
"membershipAt" DATETIME,
"membershipRevision" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TbankConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- Preserve credentials and assign an opaque revision to each existing connection.
INSERT INTO "new_TbankConnection" ("connectedAt", "encryptedToken", "userId", "revision") SELECT "connectedAt", "encryptedToken", "userId", lower(hex(randomblob(16))) FROM "TbankConnection";
DROP TABLE "TbankConnection";
ALTER TABLE "new_TbankConnection" RENAME TO "TbankConnection";
CREATE UNIQUE INDEX "TbankConnection_userId_key" ON "TbankConnection"("userId");
CREATE UNIQUE INDEX "TbankConnection_userId_revision_key" ON "TbankConnection"("userId", "revision");
-- CreateIndex
CREATE INDEX "TbankOperation_userId_revision_accountId_occurredAt_brokerOperationId_idx" ON "TbankOperation"("userId", "revision", "accountId", "occurredAt", "brokerOperationId");
-- CreateIndex
CREATE INDEX "TbankOperation_userId_revision_accountId_instrumentUid_occurredAt_idx" ON "TbankOperation"("userId", "revision", "accountId", "instrumentUid", "occurredAt");
-- CreateIndex
CREATE INDEX "TbankOperation_userId_revision_accountId_category_occurredAt_idx" ON "TbankOperation"("userId", "revision", "accountId", "category", "occurredAt");
-- CreateIndex
CREATE INDEX "TbankInstrument_provider_figi_idx" ON "TbankInstrument"("provider", "figi");
-- CreateIndex
CREATE INDEX "TbankInstrument_provider_ticker_instrumentType_idx" ON "TbankInstrument"("provider", "ticker", "instrumentType");
-- CreateIndex
CREATE INDEX "TbankSyncWork_userId_revision_state_priority_idx" ON "TbankSyncWork"("userId", "revision", "state", "priority");
-- CreateIndex
CREATE UNIQUE INDEX "TbankSyncWork_userId_revision_scopeKey_key" ON "TbankSyncWork"("userId", "revision", "scopeKey");
-- CreateIndex
CREATE UNIQUE INDEX "TbankSyncWork_id_attemptId_key" ON "TbankSyncWork"("id", "attemptId");
COMMIT;
PRAGMA foreign_keys=ON;
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "TbankPortfolioValueHistory" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"deletedAt" DATETIME,
"updatedAt" DATETIME NOT NULL,
PRIMARY KEY ("userId", "accountId"),
CONSTRAINT "TbankPortfolioValueHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TbankPortfolioValuePoint" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"attemptId" TEXT NOT NULL,
"currency" TEXT NOT NULL,
"value" TEXT NOT NULL,
"capturedAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "accountId", "attemptId", "currency"),
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- CreateIndex
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
@@ -0,0 +1,4 @@
-- Points written before WealthLedger/backend#140 counted cash twice (cash balance
-- plus the mirroring CURRENCY position). Historical compositions and FX rates are
-- not stored, so the points cannot be recomputed exactly; history restarts here.
DELETE FROM "TbankPortfolioValuePoint";
+234
View File
@@ -17,6 +17,7 @@ model User {
bankCardOperations BankCardOperation[] bankCardOperations BankCardOperation[]
sessions Session[] sessions Session[]
tbankConnection TbankConnection? tbankConnection TbankConnection?
tbankValueHistory TbankPortfolioValueHistory[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -25,7 +26,240 @@ model TbankConnection {
userId String @unique userId String @unique
encryptedToken String encryptedToken String
connectedAt DateTime connectedAt DateTime
revision String @default(uuid())
invalidToken Boolean @default(false)
membershipAt DateTime?
membershipRevision Int @default(0)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accounts TbankAccount[]
forecasts TbankForecast[]
work TbankSyncWork[]
@@unique([userId, revision])
}
model TbankAccount {
userId String
revision String
accountId String
type String
name String
openedOn String?
upstreamOrder Int
operationsRevision Int @default(0)
historyFrom DateTime?
historyBoundary String @default("unknown")
connection TbankConnection @relation(fields: [userId, revision], references: [userId, revision], onDelete: Cascade, onUpdate: Restrict)
portfolio TbankPortfolio?
operations TbankOperation[]
coverage TbankOperationCoverage[]
@@id([userId, revision, accountId])
}
model TbankPortfolio {
userId String
revision String
accountId String
payload Json
mappingVersion Int @default(1)
fetchedAt DateTime
dataRevision Int @default(1)
account TbankAccount @relation(fields: [userId, revision, accountId], references: [userId, revision, accountId], onDelete: Cascade, onUpdate: Restrict)
@@id([userId, revision, accountId])
}
// Account valuation history survives connection replacement and stays hidden
// until the current connection confirms the account again.
model TbankPortfolioValueHistory {
userId String
accountId String
deletedAt DateTime?
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
points TbankPortfolioValuePoint[]
@@id([userId, accountId])
}
// One row per currency of a fully valued successful portfolio attempt.
model TbankPortfolioValuePoint {
userId String
accountId String
attemptId String
currency String
value String
capturedAt DateTime
createdAt DateTime @default(now())
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
@@id([userId, accountId, attemptId, currency])
@@index([userId, accountId, capturedAt])
}
model TbankOperation {
userId String
revision String
accountId String
brokerOperationId String
occurredAt DateTime
instrumentUid String?
category String
payload Json
mappingVersion Int @default(1)
account TbankAccount @relation(fields: [userId, revision, accountId], references: [userId, revision, accountId], onDelete: Cascade, onUpdate: Restrict)
@@id([userId, revision, accountId, brokerOperationId])
@@index([userId, revision, accountId, occurredAt, brokerOperationId])
@@index([userId, revision, accountId, instrumentUid, occurredAt])
@@index([userId, revision, accountId, category, occurredAt])
}
model TbankOperationCoverage {
userId String
revision String
accountId String
instrumentPredicate String @default("")
from DateTime
to DateTime
fetchedAt DateTime
asOf DateTime
account TbankAccount @relation(fields: [userId, revision, accountId], references: [userId, revision, accountId], onDelete: Cascade, onUpdate: Restrict)
@@id([userId, revision, accountId, instrumentPredicate, from, to])
}
model TbankForecast {
userId String
revision String
scopeKey String
membershipRevision Int
payload Json
dependencyRevisions Json
sourceTimestamps Json
calculatedAt DateTime
dataRevision Int @default(1)
connection TbankConnection @relation(fields: [userId, revision], references: [userId, revision], onDelete: Cascade, onUpdate: Restrict)
@@id([userId, revision, scopeKey])
}
model TbankInstrument {
provider String
uid String
figi String?
ticker String?
instrumentType String
metadata Json
fetchedAt DateTime
dataRevision Int @default(1)
sources TbankInstrumentSource[]
candles TbankCandle[]
candleCoverage TbankCandleCoverage[]
@@id([provider, uid])
@@index([provider, figi])
@@index([provider, ticker, instrumentType])
}
model TbankCatalogDiscovery {
provider String
instrumentType String
query String
instrumentUids Json
fetchedAt DateTime
dataRevision Int @default(1)
@@id([provider, instrumentType, query])
}
// Empty and ambiguous successful UID/FIGI/ticker lookups are distinct from failures.
model TbankInstrumentLookup {
provider String
identifierType String
identifier String
matchedUids Json
fetchedAt DateTime
dataRevision Int @default(1)
@@id([provider, identifierType, identifier])
}
// Source records contain no holdings, personal estimates or credentials errors.
model TbankInstrumentSource {
provider String
instrumentUid String
source String
kind String
rangeKey String
payload Json
fetchedAt DateTime
dataRevision Int @default(1)
instrument TbankInstrument @relation(fields: [provider, instrumentUid], references: [provider, uid], onDelete: Cascade)
@@id([provider, instrumentUid, source, kind, rangeKey])
}
model TbankCandle {
provider String
instrumentUid String
interval String
time DateTime
close String
payload Json
isComplete Boolean
fetchedAt DateTime
instrument TbankInstrument @relation(fields: [provider, instrumentUid], references: [provider, uid], onDelete: Cascade)
@@id([provider, instrumentUid, interval, time])
}
model TbankCandleCoverage {
provider String
instrumentUid String
interval String
from DateTime
to DateTime
isComplete Boolean
fetchedAt DateTime
dataRevision Int @default(1)
instrument TbankInstrument @relation(fields: [provider, instrumentUid], references: [provider, uid], onDelete: Cascade)
@@id([provider, instrumentUid, interval, from, to])
}
model TbankSyncWork {
id String @id
userId String
revision String
scopeKey String
intent Json
state String
attemptId String
processRunId String
priority Int
progress Json
errorCode String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
connection TbankConnection @relation(fields: [userId, revision], references: [userId, revision], onDelete: Cascade, onUpdate: Restrict)
staged TbankSyncStage[]
@@unique([userId, revision, scopeKey])
@@index([userId, revision, state, priority])
@@unique([id, attemptId])
}
model TbankSyncStage {
workId String
attemptId String
sourceKey String
itemKey String
payload Json
work TbankSyncWork @relation(fields: [workId, attemptId], references: [id, attemptId], onDelete: Cascade, onUpdate: Restrict)
@@id([workId, attemptId, sourceKey, itemKey])
} }
model Session { model Session {
+10 -9
View File
@@ -1,10 +1,11 @@
const assert = require("node:assert/strict"); const assert = require('node:assert/strict');
const { execFileSync } = require("node:child_process"); const { execFileSync } = require('node:child_process');
const { version } = require("../package.json");
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
assert.equal(git("status", "--porcelain"), "", "Commit all changes before preparing a release"); const { version } = require('../package.json');
const tags = git("tag", "--merged", "HEAD", "--list", "v[0-9]*", "--sort=-version:refname") // eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
.split("\n").filter(tag => /^v\d+\.\d+\.\d+$/.test(tag)); const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
assert.equal(version, tags[0]?.slice(1) ?? "0.0.0",
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another."); const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
'Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.');
+71
View File
@@ -0,0 +1,71 @@
const { randomBytes } = require('node:crypto');
const { readFile, rename, rm, writeFile } = require('node:fs/promises');
const { resolve } = require('node:path');
// Relative to the working directory, as before: the contract test runs the script in a temporary directory.
const target = resolve(process.cwd(), 'openapi.json');
const checkOnly = process.argv.includes('--check');
// Schema generation needs no deployment configuration or real credentials.
Object.assign(process.env, {
DATABASE_URL: 'file::memory:',
FRONTEND_ORIGIN: 'http://localhost',
JWT_SECRET: randomBytes(32).toString('hex'),
TOKEN_ENCRYPTION_KEY: randomBytes(32).toString('base64'),
PORT: '3000',
LOG_LEVEL: 'error',
TBANK_GRPC_ENDPOINT: 'localhost:443',
TBANK_RATE_LIMIT_PER_SECOND: '5',
TBANK_CA_CERT_PATH: '',
});
const { NestFactory } = require('@nestjs/core');
const { AppModule } = require('../dist/src/app.module.js');
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
async function render() {
const app = await NestFactory.create(AppModule, {
preview: true,
logger: false,
});
try {
app.setGlobalPrefix(API_PREFIX);
return JSON.stringify(createOpenApiDocument(app), null, 2) + '\n';
} finally {
await app.close();
}
}
// Read-only: compares the runtime document with the committed artifact and never writes.
async function check(content) {
const current = await readFile(target, 'utf8').catch(() => null);
if (current !== content) {
console.error(
'openapi.json is out of date with the runtime schema. Run `npm run openapi:generate`.',
);
process.exitCode = 1;
return;
}
console.log('openapi.json matches the runtime schema.');
}
// Atomic update: write a sibling temp file, then rename over the target, so a failed run never leaves partial output.
async function update(content) {
const temp = `${target}.${process.pid}.tmp`;
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input.
await writeFile(temp, content);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input.
await rename(temp, target);
} finally {
await rm(temp, { force: true });
}
}
render()
.then((content) => (checkOnly ? check(content) : update(content)))
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
+12
View File
@@ -0,0 +1,12 @@
sonar.projectKey=wealthledger-backend
sonar.projectName=WealthLedger backend
sonar.host.url=https://sonarqube.personal.ksv741.ru
sonar.sourceEncoding=UTF-8
sonar.sources=src
sonar.tests=src,test
sonar.test.inclusions=src/**/*.spec.ts,test/**/*.e2e-spec.ts
sonar.exclusions=src/**/*.spec.ts,src/integrations/tbank/proto/contracts/**
sonar.javascript.lcov.reportPaths=coverage/lcov.info
# SonarQube 9.9 embeds an older TypeScript analyzer than the application compiler.
sonar.typescript.tsconfigPaths=tsconfig.sonar.json
+5 -36
View File
@@ -1,9 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { LoggingModule } from './common/logging/logging.module'; import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation'; import { validateEnv } from './config/env.validation';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module'; import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module'; import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
@@ -12,6 +11,7 @@ import { InstrumentsModule } from './modules/investments/instruments/instruments
import { OperationsModule } from './modules/investments/operations/operations.module'; import { OperationsModule } from './modules/investments/operations/operations.module';
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module'; import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module'; import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module'; import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
@@ -21,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module';
isGlobal: true, isGlobal: true,
validate: validateEnv, validate: validateEnv,
}), }),
LoggerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<Environment, true>) => ({
pinoHttp: {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
autoLogging: false,
genReqId: (request: unknown, response) => {
const existingId = (request as { id?: unknown }).id;
const requestId =
typeof existingId === 'string' ? existingId : randomUUID();
response.setHeader('X-Request-ID', requestId);
return requestId;
},
redact: [
'req.headers.authorization',
'req.headers.cookie',
'req.body',
],
serializers: {
req: (request: unknown) => {
const value = request as { id?: unknown; method?: unknown };
return {
id: typeof value.id === 'string' ? value.id : undefined,
method:
typeof value.method === 'string' ? value.method : undefined,
};
},
},
},
}),
}),
LoggingModule, LoggingModule,
PrismaModule, PrismaModule,
AuthModule, AuthModule,
@@ -64,6 +32,7 @@ import { PrismaModule } from './prisma/prisma.module';
UpcomingEventsModule, UpcomingEventsModule,
InstrumentsModule, InstrumentsModule,
TbankConnectionModule, TbankConnectionModule,
TbankSyncModule,
], ],
}) })
export class AppModule {} export class AppModule {}
+45 -23
View File
@@ -1,12 +1,30 @@
export type Decimal = { value: bigint; scale: number }; export const DECIMAL_RADIX = 10n;
const SIGN_LENGTH = 1;
const NEGATIVE_SIGN = -1n;
const POSITIVE_SIGN = 1n;
const ZERO_UNITS = 0n;
const EQUAL = 0;
const GREATER_THAN = 1;
const LESS_THAN = -1;
const INTEGER_SCALE = 0;
const MIN_WHOLE_DIGITS = 1;
const DIGITS_START = 0;
const PREVIOUS_DIGIT_OFFSET = 1;
const DIGIT_STEP = 1;
const ROUNDING_DIVISOR = 2n;
export type Decimal = { scale: number; value: bigint };
export function decimalFromString(value: string): Decimal { export function decimalFromString(value: string): Decimal {
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) {
throw new Error('Invalid decimal string'); throw new Error('Invalid decimal string');
}
const negative = value.startsWith('-'); const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); const [whole, fraction = ''] = (negative ? value.slice(SIGN_LENGTH) : value).split('.');
return { return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`), value: (negative ? NEGATIVE_SIGN : POSITIVE_SIGN) * BigInt(`${whole}${fraction}`),
scale: fraction.length, scale: fraction.length,
}; };
} }
@@ -15,8 +33,8 @@ export function add(left: Decimal, right: Decimal): Decimal {
const scale = Math.max(left.scale, right.scale); const scale = Math.max(left.scale, right.scale);
return { return {
value: value:
left.value * 10n ** BigInt(scale - left.scale) + left.value * DECIMAL_RADIX ** BigInt(scale - left.scale) +
right.value * 10n ** BigInt(scale - right.scale), right.value * DECIMAL_RADIX ** BigInt(scale - right.scale),
scale, scale,
}; };
} }
@@ -30,33 +48,37 @@ export function multiply(left: Decimal, right: Decimal): Decimal {
} }
export function isZero(value: Decimal): boolean { export function isZero(value: Decimal): boolean {
return value.value === 0n; return value.value === ZERO_UNITS;
} }
export function compare(left: Decimal, right: Decimal): number { export function compare(left: Decimal, right: Decimal): number {
const difference = subtract(left, right).value; const difference = subtract(left, right).value;
return difference === 0n ? 0 : difference > 0n ? 1 : -1; return difference === ZERO_UNITS ? EQUAL : difference > ZERO_UNITS ? GREATER_THAN : LESS_THAN;
} }
export function formatDecimal({ value, scale }: Decimal): string { export function formatDecimal({ value, scale }: Decimal): string {
if (value === 0n) return '0'; if (value === ZERO_UNITS) return '0';
const sign = value < 0n ? '-' : ''; const sign = value < ZERO_UNITS ? '-' : '';
if (scale === 0) return `${sign}${value < 0n ? -value : value}`; if (scale === INTEGER_SCALE) return `${sign}${value < ZERO_UNITS ? -value : value}`;
const digits = (value < 0n ? -value : value) const digits = (value < ZERO_UNITS ? -value : value)
.toString() .toString()
.padStart(scale + 1, '0'); .padStart(scale + MIN_WHOLE_DIGITS, '0');
const whole = digits.slice(0, -scale) || '0'; const whole = digits.slice(DIGITS_START, -scale) || '0';
const fraction = digits.slice(-scale).replace(/0+$/, ''); const rawFraction = digits.slice(-scale);
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`; let fractionEnd = rawFraction.length;
while (rawFraction.charAt(fractionEnd - PREVIOUS_DIGIT_OFFSET) === '0') fractionEnd -= DIGIT_STEP;
const fraction = rawFraction.slice(DIGITS_START, fractionEnd);
const fractionalPart = fraction ? '.' + fraction : '';
return `${sign}${whole}${fractionalPart}`;
} }
// Round half away from zero; callers choose presentation precision explicitly. // Round half away from zero; callers choose presentation precision explicitly.
export function divide(left: Decimal, right: Decimal, scale: number): Decimal { export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
if (right.value === 0n) throw new Error('Division by zero'); if (right.value === ZERO_UNITS) throw new Error('Division by zero');
const numerator = left.value * 10n ** BigInt(right.scale + scale); const numerator = left.value * DECIMAL_RADIX ** BigInt(right.scale + scale);
const denominator = right.value * 10n ** BigInt(left.scale); const denominator = right.value * DECIMAL_RADIX ** BigInt(left.scale);
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n; const sign = (numerator < ZERO_UNITS) !== (denominator < ZERO_UNITS) ? NEGATIVE_SIGN : POSITIVE_SIGN;
const n = numerator < 0n ? -numerator : numerator; const n = numerator < ZERO_UNITS ? -numerator : numerator;
const d = denominator < 0n ? -denominator : denominator; const d = denominator < ZERO_UNITS ? -denominator : denominator;
return { value: sign * ((n + d / 2n) / d), scale }; return { value: sign * ((n + d / ROUNDING_DIVISOR) / d), scale };
} }
@@ -1,23 +1,32 @@
import { HttpStatus, applyDecorators } from '@nestjs/common'; import { applyDecorators, HttpStatus } from '@nestjs/common';
import { import {
ApiExtraModels, ApiExtraModels,
ApiProperty, ApiProperty,
ApiPropertyOptional,
ApiResponse, ApiResponse,
getSchemaPath, getSchemaPath,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { HttpErrorCode } from '../error-code';
export class ProblemDetailsDto { export class ProblemDetailsDto {
@ApiProperty({ example: 401 }) @ApiProperty({ example: 401 })
declare status: number; declare status: number;
@ApiProperty({ example: 'UNAUTHORIZED' }) @ApiProperty({ example: HttpErrorCode.Unauthorized })
declare code: string; declare code: string;
@ApiProperty({ // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
required: false, @ApiPropertyOptional({
type: 'array',
items: {
type: 'object',
required: ['pointer', 'detail'],
properties: { pointer: { type: 'string' }, detail: { type: 'string' } },
},
example: [{ pointer: '#/email', detail: 'must be a valid email address' }], example: [{ pointer: '#/email', detail: 'must be a valid email address' }],
}) })
errors?: { pointer: string; detail: string }[]; errors?: { detail: string; pointer: string }[];
} }
function problemResponse(status: number, description: string) { function problemResponse(status: number, description: string) {
@@ -32,7 +41,7 @@ function problemResponse(status: number, description: string) {
}); });
} }
export function ApiProblemResponses(): MethodDecorator & ClassDecorator { export function ApiProblemResponses(): ClassDecorator & MethodDecorator {
return applyDecorators( return applyDecorators(
ApiExtraModels(ProblemDetailsDto), ApiExtraModels(ProblemDetailsDto),
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'), problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
+58 -3
View File
@@ -1,15 +1,17 @@
import { BadRequestException, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
import { LoginDto, RegisterDto } from '../modules/auth/presentation/auth.dto';
import { import {
CreateBankCardDto, CreateBankCardDto,
UpdateBankCardDto, UpdateBankCardDto,
} from '../modules/banking/bank-cards/presentation/bank-card.dto'; } from '../modules/banking/bank-cards/presentation/bank-card.dto';
describe('DTO validation', () => { describe('DTO validation', () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true }); const pipe = new ValidationPipe({
forbidUnknownValues: true, transform: true, whitelist: true });
it('requires credentials and normalizes a valid email', async () => { it('requires credentials and normalizes a valid email', async () => {
const metadata = { type: 'body' as const, metatype: CredentialsDto }; const metadata = { type: 'body' as const, metatype: RegisterDto };
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf( await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
@@ -27,6 +29,59 @@ describe('DTO validation', () => {
}); });
}); });
// Test intent: register accepts 8..72 printable ASCII characters only (bcrypt
// reads 72 bytes, so non-ASCII could be silently truncated); login only caps
// bytes so accounts created under the older policy can still sign in.
describe('password policy', () => {
const register = { type: 'body' as const, metatype: RegisterDto };
const login = { type: 'body' as const, metatype: LoginDto };
const body = (password: string) => ({ email: 'user@example.com', password });
const accepts = (value: unknown, metadata: typeof login | typeof register) =>
pipe.transform(value, metadata).then(
() => true,
(error: unknown) => {
if (!(error instanceof BadRequestException)) throw error;
return false;
},
);
it.each([
['7 chars', 'a'.repeat(7), false],
['8 chars', 'a'.repeat(8), true],
['72 chars', 'a'.repeat(72), true],
['73 chars', 'a'.repeat(73), false],
['all printable symbols and space', 'Aa1 !~"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}', true],
['Cyrillic, 8+ chars', 'пароль-пароль', false],
['emoji', 'passw😀rd', false],
['tab', 'pass\tword1', false],
['newline', 'password1\n', false],
['DEL', 'password\x7f1', false],
['non-string', 12345678, false],
])('register: %s', async (_, password, accepted) => {
await expect(accepts(body(password as string), register)).resolves.toBe(accepted);
});
it('register keeps raw whitespace untouched', async () => {
const password = ' spaced pass ';
await expect(pipe.transform(body(password), register)).resolves.toEqual(
body(password),
);
});
it.each([
['legacy Cyrillic password', 'пароль-пароль', true],
['short legacy password', 'short', true],
['72 bytes', 'a'.repeat(72), true],
['73 bytes', 'a'.repeat(73), false],
['36 Cyrillic letters = 72 bytes', 'я'.repeat(36), true],
['37 Cyrillic letters = 74 bytes', 'я'.repeat(37), false],
['empty', '', false],
['non-string', 12345678, false],
])('login: %s', async (_, password, accepted) => {
await expect(accepts(body(password as string), login)).resolves.toBe(accepted);
});
});
it('requires and validates nested money in a new card', async () => { it('requires and validates nested money in a new card', async () => {
const metadata = { type: 'body' as const, metatype: CreateBankCardDto }; const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
const card = { const card = {
+8
View File
@@ -0,0 +1,8 @@
export enum HttpErrorCode {
BadRequest = 'BAD_REQUEST',
InternalServerError = 'INTERNAL_SERVER_ERROR',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED',
UnsupportedMediaType = 'UNSUPPORTED_MEDIA_TYPE',
ValidationError = 'VALIDATION_ERROR',
}
@@ -1,20 +1,29 @@
import { ArgumentsHost } from '@nestjs/common'; import { ArgumentsHost, BadRequestException } from '@nestjs/common';
import { StructuredLogger } from '../logging/structured-logger.service';
import { HttpExceptionFilter } from './http-exception.filter'; import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => { describe('HttpExceptionFilter', () => {
it('does not expose internal error details', () => { const setup = () => {
const status = jest.fn().mockReturnThis(); const status = jest.fn().mockReturnThis();
const type = jest.fn().mockReturnThis(); const type = jest.fn().mockReturnThis();
const json = jest.fn(); const json = jest.fn();
const locals: Record<string, unknown> = {}; const locals: Record<string, unknown> = {};
const filter = new HttpExceptionFilter(); const logger = { event: jest.fn() };
const filter = new HttpExceptionFilter(logger as unknown as StructuredLogger);
const host = { const host = {
switchToHttp: () => ({ switchToHttp: () => ({
getResponse: () => ({ status, type, json, locals }), getResponse: () => ({ status, type, json, locals }),
}), }),
} as unknown as ArgumentsHost; } as unknown as ArgumentsHost;
return { filter, host, status, type, json, locals, logger };
};
filter.catch(new Error('database password: secret'), host); it('logs unhandled errors with name and stack frames but never the message', () => {
const { filter, host, status, type, json, locals, logger } = setup();
const error = new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET');
filter.catch(error, host);
expect(status).toHaveBeenCalledWith(500); expect(status).toHaveBeenCalledWith(500);
expect(type).toHaveBeenCalledWith('application/problem+json'); expect(type).toHaveBeenCalledWith('application/problem+json');
@@ -23,7 +32,29 @@ describe('HttpExceptionFilter', () => {
code: 'INTERNAL_SERVER_ERROR', code: 'INTERNAL_SERVER_ERROR',
}); });
expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR'); expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR');
expect(typeof locals.errorStack).toBe('string'); expect(logger.event).toHaveBeenCalledTimes(1);
expect(locals.errorStack).not.toContain('database password: secret'); const [level, event, fields] = logger.event.mock.calls[0] as [string, string, Record<string, unknown>];
expect(level).toBe('error');
expect(event).toBe('http.request.unhandled');
expect(fields).toMatchObject({ code: 'INTERNAL_SERVER_ERROR', errorName: 'Error' });
expect(JSON.stringify(fields)).not.toContain('SYNTHETIC_AUDIT_SECRET');
expect(String(fields['errorStack']).split('\n').every((line) => line.startsWith(' at '))).toBe(true);
});
it('logs field pointers without values for validation errors', () => {
const { filter, host, status, logger, locals } = setup();
const exception = new BadRequestException({
message: [
{ property: 'amount', constraints: { isPositive: 'amount must be positive' }, value: -42 },
{ property: 'amount', constraints: { min: 'amount too small' }, value: -42 },
],
});
filter.catch(exception, host);
expect(status).toHaveBeenCalledWith(400);
expect(locals.invalidFields).toBe('#/amount');
expect(logger.event).not.toHaveBeenCalled();
expect(JSON.stringify(locals)).not.toContain('-42');
}); });
}); });
+56 -24
View File
@@ -1,3 +1,5 @@
import type { Response } from 'express';
import { import {
ArgumentsHost, ArgumentsHost,
Catch, Catch,
@@ -6,10 +8,14 @@ import {
HttpStatus, HttpStatus,
ValidationError, ValidationError,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express';
import { HttpErrorCode } from '../error-code';
import { StructuredLogger } from '../logging/structured-logger.service';
@Catch() @Catch()
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
constructor(private readonly logger: StructuredLogger) {}
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp(); const context = host.switchToHttp();
const response = context.getResponse<Response>(); const response = context.getResponse<Response>();
@@ -24,17 +30,22 @@ export class HttpExceptionFilter implements ExceptionFilter {
(payload as { message?: unknown })?.message, (payload as { message?: unknown })?.message,
); );
const code = isValidation const code = isValidation
? 'VALIDATION_ERROR' ? HttpErrorCode.ValidationError
: this.codeFor(status, payload); : this.codeFor(status, payload);
(response.locals ??= {}).publicErrorCode = code; response.locals ??= {};
if ( response.locals.publicErrorCode = code;
status >= Number(HttpStatus.INTERNAL_SERVER_ERROR) && if (isValidation) {
exception instanceof Error response.locals.invalidFields = [
) ...new Set(
response.locals.errorStack = exception.stack this.validationErrors(
?.split('\n') (payload as { message: ValidationError[] }).message,
.slice(1) ).map((error) => error.pointer),
.join('\n'); ),
].join(',');
}
if (!(exception instanceof HttpException)) {
this.logUnhandled(exception, code);
}
response response
.status(status) .status(status)
.type('application/problem+json') .type('application/problem+json')
@@ -51,30 +62,51 @@ export class HttpExceptionFilter implements ExceptionFilter {
}); });
} }
// ponytail: stack содержит текст message в первой строке; оставляем только кадры, message не пишем.
private logUnhandled(exception: unknown, code: string): void {
const error = exception instanceof Error ? exception : undefined;
const frames = (error?.stack ?? '')
.split('\n')
.filter((line) => line.startsWith(' at '))
.join('\n');
this.logger.event('error', 'http.request.unhandled', {
code,
errorName: error?.name ?? typeof exception,
errorStack: frames,
});
}
private codeFor(status: number, payload: unknown): string { private codeFor(status: number, payload: unknown): string {
if (typeof payload === 'object' && payload !== null && 'code' in payload) { if (typeof payload === 'object' && payload !== null && 'code' in payload) {
const code = (payload as { code?: unknown }).code; const code = (payload as { code?: unknown }).code;
if (typeof code === 'string') return code; if (typeof code === 'string') return code;
} }
return ( return (
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
{ {
400: 'BAD_REQUEST', 400: HttpErrorCode.BadRequest,
401: 'UNAUTHORIZED', 401: HttpErrorCode.Unauthorized,
404: 'NOT_FOUND', 404: HttpErrorCode.NotFound,
415: 'UNSUPPORTED_MEDIA_TYPE', 415: HttpErrorCode.UnsupportedMediaType,
500: 'INTERNAL_SERVER_ERROR', 500: HttpErrorCode.InternalServerError,
}[status] ?? 'INTERNAL_SERVER_ERROR' }[status] ?? HttpErrorCode.InternalServerError
); );
} }
private validationErrors( private validationErrors(
errors: ValidationError[], errors: ValidationError[],
): { pointer: string; detail: string }[] { prefix = '#',
return errors.flatMap((error) => ): { detail: string; pointer: string }[] {
Object.values(error.constraints ?? {}).map((detail) => ({ return errors.flatMap((error) => {
pointer: `#/${error.property}`, if (!error || typeof error.property !== 'string') return [];
detail, const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`;
})), return [
); ...Object.values(error.constraints ?? {}).map((detail) => ({
pointer,
detail,
})),
...this.validationErrors(error.children ?? [], pointer),
];
});
} }
} }
+64 -8
View File
@@ -1,15 +1,71 @@
import type { Request, Response } from 'express';
import type { Options } from 'pino-http';
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { RequestContextService } from './request-context.service'; import { ConfigService } from '@nestjs/config';
import { RequestLoggingMiddleware } from './request-logging.middleware'; import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { constants as httpStatus } from 'node:http2';
import { Environment } from '../../config/env.validation';
import { StructuredLogger } from './structured-logger.service'; import { StructuredLogger } from './structured-logger.service';
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function completion(request: Request, response: Response, value: { responseTime: number }) {
const route: unknown = request.route;
const path = typeof route === 'object' && route !== null && 'path' in route &&
typeof route.path === 'string'
? `${request.baseUrl}${route.path}`
: '<unmatched>';
return {
method: request.method,
route: path,
status: response.statusCode,
durationMs: Math.round(value.responseTime),
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
...(typeof response.locals.invalidFields === 'string' ? { invalidFields: response.locals.invalidFields } : {}),
};
}
export function loggingOptions(config: ConfigService<Environment, true>): Options<Request, Response> {
return {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
// ponytail: development-only pretty output; production never loads pino-pretty.
...(process.env['NODE_ENV'] === 'development'
? { transport: { target: 'pino-pretty', options: { colorize: true, singleLine: true, messageKey: 'message' } } }
: {}),
genReqId: (_request, response) => {
const id = randomUUID();
response.setHeader('X-Request-ID', id);
return id;
},
customProps: (request) => {
const incoming = request.header('x-request-id');
return { requestId: request.id, ...(incoming && uuid.test(incoming) ? { clientRequestId: incoming } : {}) };
},
customLogLevel: (_request, response) => {
if (response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR) return 'error';
if ([httpStatus.HTTP_STATUS_FORBIDDEN, httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS, httpStatus.HTTP_STATUS_UNAUTHORIZED].includes(response.statusCode)) return 'warn';
return 'info';
},
customSuccessMessage: () => 'http.request.completed',
customErrorMessage: () => 'http.request.completed',
customSuccessObject: completion,
customErrorObject: (request, response, _error, value: { responseTime: number }) => completion(request, response, value),
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.body'],
serializers: { req: () => undefined },
};
}
@Global() @Global()
@Module({ @Module({
providers: [ imports: [LoggerModule.forRootAsync<Request, Response>({
RequestContextService, inject: [ConfigService],
RequestLoggingMiddleware, useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
StructuredLogger, })],
], providers: [StructuredLogger],
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger], exports: [StructuredLogger],
}) })
export class LoggingModule {} export class LoggingModule {}
@@ -1,27 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export type RequestLogContext = {
requestId: string;
clientRequestId?: string;
userId?: string;
brokerageAccountId?: string;
};
@Injectable()
export class RequestContextService {
private readonly storage = new AsyncLocalStorage<RequestLogContext>();
run<T>(context: RequestLogContext, callback: () => T): T {
return this.storage.run(context, callback);
}
get(): RequestLogContext | undefined {
return this.storage.getStore();
}
set(context: Partial<RequestLogContext>): void {
const current = this.storage.getStore();
if (current) Object.assign(current, context);
}
}
@@ -1,53 +0,0 @@
import { EventEmitter } from 'node:events';
import { RequestLoggingMiddleware } from './request-logging.middleware';
describe('RequestLoggingMiddleware', () => {
it('returns a server ID and emits one safe completion event', () => {
const requestContext = {
run: jest.fn((_: unknown, next: () => void) => next()),
};
const logger = { event: jest.fn() };
const middleware = new RequestLoggingMiddleware(
requestContext as never,
logger as never,
);
const response = Object.assign(new EventEmitter(), {
statusCode: 200,
locals: {},
setHeader: jest.fn(),
});
const next = jest.fn();
const request: {
method: string;
baseUrl: string;
route: { path: string };
header: jest.Mock<string>;
id?: string;
} = {
method: 'GET',
baseUrl: '/api/v1',
route: { path: '/accounts/:id' },
header: jest.fn(() => 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'),
};
middleware.use(request as never, response as never, next);
response.emit('finish');
expect(response.setHeader).toHaveBeenCalledWith(
'X-Request-ID',
expect.stringMatching(/^[0-9a-f-]{36}$/i),
);
expect(request.id).toMatch(/^[0-9a-f-]{36}$/i);
expect(requestContext.run).toHaveBeenCalledWith(
expect.objectContaining({
clientRequestId: 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43',
}),
next,
);
expect(logger.event).toHaveBeenCalledWith(
'info',
'http.request.completed',
expect.objectContaining({ route: '/api/v1/accounts/:id', status: 200 }),
);
});
});
@@ -1,68 +0,0 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { RequestContextService } from './request-context.service';
import { StructuredLogger } from './structured-logger.service';
const uuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@Injectable()
export class RequestLoggingMiddleware implements NestMiddleware {
constructor(
private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger,
) {}
use(request: Request, response: Response, next: NextFunction): void {
const incoming = request.header('x-request-id');
const clientRequestId =
incoming && uuid.test(incoming) ? incoming : undefined;
const existingRequestId = (request as Request & { id?: unknown }).id;
const requestId =
typeof existingRequestId === 'string' ? existingRequestId : randomUUID();
(request as unknown as { id: string }).id = requestId;
const startedAt = performance.now();
response.setHeader('X-Request-ID', requestId);
response.on('finish', () => {
const routeRequest: unknown = request;
const routePath = (routeRequest as { route?: { path?: unknown } }).route
?.path;
const route =
typeof routePath === 'string'
? `${request.baseUrl}${routePath}`
: '<unmatched>';
const context = {
method: request.method,
route,
status: response.statusCode,
durationMs: Math.round(performance.now() - startedAt),
...(response.locals.publicErrorCode
? { code: response.locals.publicErrorCode as string }
: {}),
...(response.locals.errorStack
? { stack: response.locals.errorStack as string }
: {}),
};
const level =
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
? 'error'
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
? 'warn'
: 'info';
this.logger.event(level, 'http.request.completed', context);
});
this.requestContext.run(
{
requestId,
...(clientRequestId ? { clientRequestId } : {}),
},
next,
);
}
}
@@ -1,7 +1,6 @@
import { StructuredLogger } from './structured-logger.service'; import { StructuredLogger } from './structured-logger.service';
describe('StructuredLogger', () => { describe('StructuredLogger', () => {
const context = { get: jest.fn() };
const pino = { const pino = {
debug: jest.fn(), debug: jest.fn(),
info: jest.fn(), info: jest.fn(),
@@ -12,10 +11,10 @@ describe('StructuredLogger', () => {
beforeEach(() => jest.clearAllMocks()); beforeEach(() => jest.clearAllMocks());
it('passes only allowlisted fields to Pino', () => { it('passes only allowlisted fields to Pino', () => {
context.get.mockReturnValue({ requestId: 'request', token: 'secret' }); const logger = new StructuredLogger(pino as never);
const logger = new StructuredLogger(context as never, pino as never);
logger.info('event', { logger.info('event', {
asOf: '2026-10-10T12:00:00.000Z',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
password: 'secret', password: 'secret',
@@ -23,7 +22,7 @@ describe('StructuredLogger', () => {
expect(pino.info).toHaveBeenCalledWith( expect(pino.info).toHaveBeenCalledWith(
{ {
requestId: 'request', asOf: '2026-10-10T12:00:00.000Z',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
}, },
@@ -32,8 +31,7 @@ describe('StructuredLogger', () => {
}); });
it('delegates event levels to Pino', () => { it('delegates event levels to Pino', () => {
context.get.mockReturnValue(undefined); const logger = new StructuredLogger(pino as never);
const logger = new StructuredLogger(context as never, pino as never);
logger.event('debug', 'debug event'); logger.event('debug', 'debug event');
logger.event('warn', 'warn event'); logger.event('warn', 'warn event');
+27 -22
View File
@@ -1,36 +1,40 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from './request-context.service';
type LogLevel = 'debug' | 'info' | 'warn' | 'error'; type LogLevel = 'debug' | 'error' | 'info' | 'warn';
type LogContext = Record<string, string | number | boolean | undefined>; type LogContext = Record<string, boolean | number | string | undefined>;
const allowedFields = new Set([ const allowedFields = new Set([
'service', 'asOf',
'environment',
'requestId',
'clientRequestId',
'userId',
'brokerageAccountId',
'method',
'route',
'status',
'durationMs',
'code',
'stack',
'operation',
'grpcCode',
'attempts', 'attempts',
'brokerageAccountId',
'channelState',
'clientRequestId',
'code',
'count', 'count',
'durationMs',
'environment',
'errorName',
'errorStack',
'found', 'found',
'grpcCode',
'headersReceived',
'invalidFields',
'method',
'networkMs',
'operation',
'queueWaitMs',
'requestId',
'route',
'service',
'status',
'trackingId',
'userId',
]); ]);
@Injectable() @Injectable()
export class StructuredLogger implements OnApplicationShutdown { export class StructuredLogger implements OnApplicationShutdown {
constructor( constructor(private readonly pino: PinoLogger) {}
private readonly requestContext: RequestContextService,
private readonly pino: PinoLogger,
) {}
info(message: string, context?: LogContext): void { info(message: string, context?: LogContext): void {
this.write('info', message, context); this.write('info', message, context);
@@ -49,8 +53,9 @@ export class StructuredLogger implements OnApplicationShutdown {
message: string, message: string,
context: LogContext = {}, context: LogContext = {},
): void { ): void {
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
this.pino[level]( this.pino[level](
this.safeContext({ ...this.requestContext.get(), ...context }), this.safeContext(context),
message, message,
); );
} }
+1
View File
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import type { Money } from './money.types'; import type { Money } from './money.types';
export class MoneyDto implements Money { export class MoneyDto implements Money {
+2
View File
@@ -1,2 +1,4 @@
export const ISO_DATE_LENGTH = 10;
export const ISO_DATE_START = 0;
export const MILLISECONDS_PER_SECOND = 1_000; export const MILLISECONDS_PER_SECOND = 1_000;
export const MILLISECONDS_PER_DAY = 86_400_000; export const MILLISECONDS_PER_DAY = 86_400_000;
+9 -6
View File
@@ -1,3 +1,6 @@
const MIN_PORT = 1;
const MIN_REQUESTS_PER_SECOND = 1;
const DEFAULT_PORT = 3000; const DEFAULT_PORT = 3000;
const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5; const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5;
const MAX_PORT = 65_535; const MAX_PORT = 65_535;
@@ -7,12 +10,12 @@ export interface Environment {
DATABASE_URL: string; DATABASE_URL: string;
FRONTEND_ORIGIN: string; FRONTEND_ORIGIN: string;
JWT_SECRET: string; JWT_SECRET: string;
LOG_LEVEL: 'debug' | 'error' | 'info' | 'warn';
PORT: number; PORT: number;
TOKEN_ENCRYPTION_KEY: string;
TBANK_CA_CERT_PATH?: string; TBANK_CA_CERT_PATH?: string;
TBANK_GRPC_ENDPOINT: string; TBANK_GRPC_ENDPOINT: string;
TBANK_RATE_LIMIT_PER_SECOND: number; TBANK_RATE_LIMIT_PER_SECOND: number;
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error'; TOKEN_ENCRYPTION_KEY: string;
} }
export function validateEnv(config: Record<string, unknown>): Environment { export function validateEnv(config: Record<string, unknown>): Environment {
@@ -56,7 +59,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
const parsedPort = Number(port); const parsedPort = Number(port);
if ( if (
!Number.isInteger(parsedPort) || !Number.isInteger(parsedPort) ||
parsedPort < 1 || parsedPort < MIN_PORT ||
parsedPort > MAX_PORT parsedPort > MAX_PORT
) { ) {
throw new Error('PORT must be a valid port number'); throw new Error('PORT must be a valid port number');
@@ -71,7 +74,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
if ( if (
typeof tokenEncryptionKey !== 'string' || typeof tokenEncryptionKey !== 'string' ||
!/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) || !/^[A-Z0-9+/]+={0,2}$/i.test(tokenEncryptionKey) ||
Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES
) { ) {
throw new Error( throw new Error(
@@ -84,7 +87,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
} }
const parsedTbankRateLimit = Number(tbankRateLimit); const parsedTbankRateLimit = Number(tbankRateLimit);
if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < 1) { if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < MIN_REQUESTS_PER_SECOND) {
throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer'); throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer');
} }
@@ -92,7 +95,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
throw new Error('TBANK_CA_CERT_PATH must be a file path'); throw new Error('TBANK_CA_CERT_PATH must be a file path');
} }
if (!['debug', 'info', 'warn', 'error'].includes(logLevel as string)) { if (!['debug', 'error', 'info', 'warn'].includes(logLevel as string)) {
throw new Error('LOG_LEVEL must be debug, info, warn, or error'); throw new Error('LOG_LEVEL must be debug, info, warn, or error');
} }
+179 -11
View File
@@ -1,5 +1,11 @@
import { MoexClient } from './moex.client'; import { MoexClient } from './moex.client';
const emptyEvents = {
coupons: { columns: ['coupondate', 'value', 'faceunit'], data: [] },
offers: { columns: ['offerdate'], data: [] },
description: { columns: ['name', 'value'], data: [] },
};
describe('MoexClient', () => { describe('MoexClient', () => {
afterEach(() => jest.restoreAllMocks()); afterEach(() => jest.restoreAllMocks());
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => { it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
@@ -92,17 +98,179 @@ describe('MoexClient', () => {
expect(result?.value ?? null).toBe(value === null ? null : '0'); expect(result?.value ?? null).toBe(value === null ? null : '0');
}, },
); );
it('treats a successful response without the requested block as no events', async () => { it('accepts confirmed empty blocks', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ jest
ok: true, .spyOn(global, 'fetch')
json: () => .mockImplementation(() => Promise.resolve(Response.json(emptyEvents)));
Promise.resolve({
description: { columns: ['name', 'value'], data: [] },
boards: { columns: ['secid'], data: [] },
}),
} as Response);
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]); await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]);
fetchMock.mockRestore();
}); });
it('preserves not-found semantics independently of malformed HTTP 200', async () => {
jest
.spyOn(global, 'fetch')
.mockImplementation(() =>
Promise.resolve(new Response(null, { status: 404 })),
);
const client = new MoexClient();
await expect(client.bondEvents('MISSING')).resolves.toEqual([]);
await expect(
client.bondYield('MISSING', 'ISIN', 'TQOB'),
).resolves.toBeNull();
});
it.each([
['missing block', { coupons: undefined }],
['missing columns', { coupons: { data: [] } }],
['invalid column', { coupons: { columns: [null], data: [] } }],
['invalid row', { coupons: { columns: ['coupondate'], data: [null] } }],
[
'short row',
{ coupons: { columns: ['coupondate', 'value'], data: [['2099-02-01']] } },
],
[
'long row',
{ coupons: { columns: ['coupondate'], data: [['2099-02-01', 2]] } },
],
[
'empty cursor',
{
'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [] },
},
],
[
'missing cursor field',
{ 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } },
],
[
'null cursor index',
{
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[null, 0, 1]],
},
},
],
[
'boolean cursor total',
{
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, false, 1]],
},
},
],
[
'empty cursor total',
{
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, '', 1]],
},
},
],
[
'short page',
{
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, 1, 1]],
},
},
],
])(
'rejects %s without returning a partial calendar',
async (_name, invalid) => {
jest
.spyOn(global, 'fetch')
.mockImplementation(() =>
Promise.resolve(Response.json({ ...emptyEvents, ...invalid })),
);
await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({
response: { code: 'MOEX_UNAVAILABLE' },
});
},
);
it.each([0, -1])(
'rejects nonpositive page size %s before repeating a fetch',
async (pageSize) => {
const starts: string[] = [];
jest.spyOn(global, 'fetch').mockImplementation((input) => {
const url = new URL(input instanceof Request ? input.url : input);
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
starts.push(url.searchParams.get('start') ?? '');
if (starts.length > 1) {
return Promise.reject(new Error('Repeated page'));
}
}
return Promise.resolve(
Response.json({
...emptyEvents,
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, 1, pageSize]],
},
}),
);
});
await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({
response: { code: 'MOEX_UNAVAILABLE' },
});
expect(starts).toEqual(['0']);
},
);
it.each([false, true])(
'reads all cursor pages and rejects a repeated index: %s',
async (repeatIndex) => {
const starts: string[] = [];
jest.spyOn(global, 'fetch').mockImplementation((input) => {
const url = new URL(input instanceof Request ? input.url : input);
const start = Number(url.searchParams.get('start'));
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
starts.push(String(start));
if (starts.length > 2) {
return Promise.reject(new Error('Repeated page'));
}
}
return Promise.resolve(
Response.json({
...emptyEvents,
'coupons': {
columns: ['coupondate', 'value', 'faceunit'],
data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']],
},
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[repeatIndex ? 0 : start, 2, 1]],
},
}),
);
});
const result = new MoexClient().bondEvents('BOND');
if (repeatIndex) {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
await expect(result).rejects.toMatchObject({
response: { code: 'MOEX_UNAVAILABLE' },
});
} else {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
await expect(result).resolves.toEqual([
{
type: 'coupon',
date: '2099-02-01',
value: '2.000000001',
currency: 'RUB',
},
{
type: 'coupon',
date: '2099-02-02',
value: '2.000000001',
currency: 'RUB',
},
]);
}
expect(starts).toEqual(['0', '1']);
},
);
}); });
+57 -34
View File
@@ -1,5 +1,13 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { constants as httpStatus } from 'node:http2';
export enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
const FIRST_PAGE_OFFSET = 0;
const EXPECTED_CURSOR_ROWS = 1;
const EMPTY_PAGE_SIZE = 0;
const MIN_CURSOR_VALUE = 0;
const FALLBACK_PAGE_SIZE = 100; const FALLBACK_PAGE_SIZE = 100;
@@ -7,18 +15,18 @@ const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000; const timeoutMs = 10_000;
export type MoexBondEvent = export type MoexBondEvent =
| { type: 'coupon'; date: string; value: string; currency: string } | { currency: string; date: string; type: 'coupon'; value: string } |
| { type: 'maturity'; date: string; value: string; currency: string } { currency: string; date: string; type: 'maturity'; value: string } |
| { type: 'offer'; date: string }; { date: string; type: 'offer' };
type IssBlock = { columns?: unknown; data?: unknown }; type IssBlock = { columns?: unknown; data?: unknown };
type IssResponse = Record<string, IssBlock | undefined>; type IssResponse = Record<string, IssBlock | undefined>;
export type MoexBondYield = { export type MoexBondYield = {
value: string;
pricePercent: string;
date: string; date: string;
dateType: string | null; dateType: null | string;
pricePercent: string;
tradeAt: string; tradeAt: string;
value: string;
}; };
@Injectable() @Injectable()
@@ -52,14 +60,15 @@ export class MoexClient {
if ( if (
value === null || value === null ||
pricePercent === null || pricePercent === null ||
Number(pricePercent) <= 0 || Number(pricePercent) <= MIN_PRICE_PERCENT_EXCLUSIVE ||
!date || !date ||
!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{4}-\d{2}-\d{2}$/.test(date) ||
!Number.isFinite(Date.parse(date)) || !Number.isFinite(Date.parse(date)) ||
!tradeAt || !tradeAt ||
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt) !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
) ) {
return null; return null;
}
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`; const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
if (!Number.isFinite(Date.parse(tradeIso))) return null; if (!Number.isFinite(Date.parse(tradeIso))) return null;
return { return {
@@ -70,6 +79,7 @@ export class MoexClient {
tradeAt: new Date(tradeIso).toISOString(), tradeAt: new Date(tradeIso).toISOString(),
}; };
} }
async bondEvents(secid: string): Promise<MoexBondEvent[]> { async bondEvents(secid: string): Promise<MoexBondEvent[]> {
const path = `securities/${encodeURIComponent(secid)}/bondization.json`; const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
const [coupons, offers, description] = await Promise.all([ const [coupons, offers, description] = await Promise.all([
@@ -86,8 +96,9 @@ export class MoexClient {
const date = text(row.coupondate); const date = text(row.coupondate);
const value = numberText(row.value); const value = numberText(row.value);
const currency = currencyText(row.faceunit); const currency = currencyText(row.faceunit);
if (date && value && currency) if (date && value && currency) {
events.push({ type: 'coupon', date, value, currency }); events.push({ type: 'coupon', date, value, currency });
}
} }
for (const row of offers) { for (const row of offers) {
const date = text(row.offerdate); const date = text(row.offerdate);
@@ -99,8 +110,9 @@ export class MoexClient {
const date = values.get('MATDATE'); const date = values.get('MATDATE');
const value = numberText(values.get('FACEVALUE')); const value = numberText(values.get('FACEVALUE'));
const currency = currencyText(values.get('FACEUNIT')); const currency = currencyText(values.get('FACEUNIT'));
if (date && value && currency) if (date && value && currency) {
events.push({ type: 'maturity', date, value, currency }); events.push({ type: 'maturity', date, value, currency });
}
return events; return events;
} }
@@ -110,35 +122,41 @@ export class MoexClient {
paginated = true, paginated = true,
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const rows: Record<string, unknown>[] = []; const rows: Record<string, unknown>[] = [];
let start = 0; let start = FIRST_PAGE_OFFSET;
for (;;) { for (;;) {
const query = new URLSearchParams({ const query = new URLSearchParams({
'iss.meta': 'off', 'iss.meta': 'off',
'iss.only': `${block},${block}.cursor`, 'iss.only': `${block},${block}.cursor`,
start: String(start), 'start': String(start),
}); });
const response = await this.request(`${baseUrl}/${path}?${query}`); const response = await this.request(`${baseUrl}/${path}?${query}`);
if (!response) return rows; if (!response) return rows;
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
const current = parseRows(response[block]); const current = parseRows(response[block]);
rows.push(...current); rows.push(...current);
if (!paginated) return rows; if (!paginated) return rows;
const cursorBlock = response[`${block}.cursor`]; const cursorBlock = response[`${block}.cursor`];
if (!cursorBlock) { if (cursorBlock === undefined) {
if (current.length < FALLBACK_PAGE_SIZE) return rows; if (current.length < FALLBACK_PAGE_SIZE) return rows;
start += FALLBACK_PAGE_SIZE; start += FALLBACK_PAGE_SIZE;
continue; continue;
} }
const cursor = parseRows(cursorBlock)[0]; const cursors = parseRows(cursorBlock);
const cursor = cursors[0];
const index = integer(cursor?.index); const index = integer(cursor?.index);
const total = integer(cursor?.total); const total = integer(cursor?.total);
const pageSize = integer(cursor?.pagesize); const pageSize = integer(cursor?.pagesize);
if ( if (
index === null || cursors.length !== EXPECTED_CURSOR_ROWS ||
index !== start ||
total === null || total === null ||
pageSize === null || pageSize === null ||
index + pageSize >= total pageSize === EMPTY_PAGE_SIZE ||
) current.length !== Math.min(pageSize, total - start)
return rows; ) {
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
if (index + pageSize >= total) return rows;
start = index + pageSize; start = index + pageSize;
} }
} }
@@ -151,46 +169,51 @@ export class MoexClient {
if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null; if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`); if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
const body: unknown = await response.json(); const body: unknown = await response.json();
if (!body || typeof body !== 'object') if (!body || typeof body !== 'object') {
throw new Error('MOEX malformed response'); throw new Error('MOEX malformed response');
}
return body as IssResponse; return body as IssResponse;
} catch { } catch {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
} }
} }
} }
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] { function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
if (!block) return []; if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
if (!Array.isArray(block.columns) || !Array.isArray(block.data)) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); }
const columns = block.columns; const columns = block.columns;
if (!columns.every((column) => typeof column === 'string')) if (!columns.every((column) => typeof column === 'string')) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
return block.data.map((values) => { return block.data.map((values) => {
if (!Array.isArray(values)) if (!Array.isArray(values) || values.length !== columns.length) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
return Object.fromEntries( return Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
columns.map((column, index) => [column.toLowerCase(), values[index]]), columns.map((column, index) => [column.toLowerCase(), values[index]]),
); );
}); });
} }
function text(value: unknown): string | null { function text(value: unknown): null | string {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
} }
function numberText(value: unknown): string | null { function numberText(value: unknown): null | string {
const result = typeof value === 'number' ? String(value) : text(value); const result = typeof value === 'number' ? String(value) : text(value);
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null; return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null;
} }
function currencyText(value: unknown): string | null { function currencyText(value: unknown): null | string {
const result = text(value)?.toUpperCase(); const result = text(value)?.toUpperCase();
return result === 'SUR' ? 'RUB' : (result ?? null); return result === 'SUR' ? 'RUB' : (result ?? null);
} }
function integer(value: unknown): number | null { function integer(value: unknown): null | number {
const result = Number(value); const result = Number(numberText(value) ?? NaN);
return Number.isSafeInteger(result) && result >= 0 ? result : null; return Number.isSafeInteger(result) && result >= MIN_CURSOR_VALUE ? result : null;
} }
+1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClient } from './moex.client'; import { MoexClient } from './moex.client';
@Module({ @Module({
@@ -1,4 +1,5 @@
import { ServiceUnavailableException } from '@nestjs/common'; import { ServiceUnavailableException } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client'; import { NcrRatingsClient } from './ncr-ratings.client';
const isin = 'RU000A10EW44'; const isin = 'RU000A10EW44';
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') => const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') =>
@@ -65,6 +66,18 @@ describe('public NCR issue ratings', () => {
); );
}, },
); );
it.each(['AA+.ru', 'B-.ru', 'BBB+.ru', 'CCC-.ru', 'CC.ru', 'D.ru'])(
'accepts the public rating grammar: %s', async (value) => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value))));
expect(await new NcrRatingsClient().rating(isin)).toMatchObject({ value });
},
);
it.each(['AAA+.ru', 'CC-.ru', 'BBBB.ru', 'BBB++.ru'])(
'rejects invalid rating grades: %s', async (value) => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value))));
await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(ServiceUnavailableException);
},
);
it('bounds outage retries then recovers without retaining a false missing rating', async () => { it('bounds outage retries then recovers without retaining a false missing rating', async () => {
const fetchMock = jest const fetchMock = jest
.spyOn(global, 'fetch') .spyOn(global, 'fetch')
+29 -14
View File
@@ -2,6 +2,13 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { load } from 'cheerio'; import { load } from 'cheerio';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
export enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
const EXPECTED_REGISTRY_TABLES = 1;
const RATING_COLUMN = 2;
const REGISTRY_CACHE_TTL_MS = 21_600_000; const REGISTRY_CACHE_TTL_MS = 21_600_000;
const REQUEST_TIMEOUT_MS = 10_000; const REQUEST_TIMEOUT_MS = 10_000;
const COLUMN_COUNT = 6; const COLUMN_COUNT = 6;
@@ -11,11 +18,11 @@ const DATE_COLUMN = 5;
const registryUrl = 'https://ratings.ru/ratings/issues/'; const registryUrl = 'https://ratings.ru/ratings/issues/';
export type NcrRating = { export type NcrRating = {
agency: 'NCR'; agency: 'NCR';
value: string | null;
status: 'active' | 'withdrawn';
date: string; date: string;
sourceUrl: string;
observedAt: string; observedAt: string;
sourceUrl: string;
status: 'active' | 'withdrawn';
value: null | string;
}; };
@Injectable() @Injectable()
@@ -33,9 +40,10 @@ export class NcrRatingsClient {
if (!pending) { if (!pending) {
pending = this.registry().catch(() => { pending = this.registry().catch(() => {
// Bound outage traffic too; never present a failed download as no rating. // Bound outage traffic too; never present a failed download as no rating.
if (this.cache.get('registry') === pending) if (this.cache.get('registry') === pending) {
this.cache.set('registry', pending, { ttl: 60_000 }); this.cache.set('registry', pending, { ttl: 60_000 });
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' }); }
throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable });
}); });
this.cache.set('registry', pending); this.cache.set('registry', pending);
} }
@@ -54,13 +62,16 @@ export class NcrRatingsClient {
.map((_, cell) => $(cell).text().trim()) .map((_, cell) => $(cell).text().trim())
.get(); .get();
if ( if (
table.length !== 1 || table.length !== EXPECTED_REGISTRY_TABLES ||
headings.length !== COLUMN_COUNT || headings.length !== COLUMN_COUNT ||
headings[2] !== 'Рейтинг' || headings[2] !== 'Рейтинг' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
headings[ISIN_COLUMN] !== 'ISIN' || headings[ISIN_COLUMN] !== 'ISIN' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
headings[DATE_COLUMN] !== 'Дата' headings[DATE_COLUMN] !== 'Дата'
) ) {
throw new Error('NCR registry changed'); throw new Error('NCR registry changed');
}
const observedAt = new Date().toISOString(); const observedAt = new Date().toISOString();
const result = new Map<string, NcrRating>(); const result = new Map<string, NcrRating>();
table.find('tbody tr').each((_, row) => { table.find('tbody tr').each((_, row) => {
@@ -69,27 +80,30 @@ export class NcrRatingsClient {
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase(); const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
// Unassigned issues cannot safely be matched to a held instrument. // Unassigned issues cannot safely be matched to a held instrument.
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return; if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim(); const raw = cells.eq(RATING_COLUMN).text().replace(/\s+/g, ' ').trim();
const withdrawn = raw.toLowerCase() === 'рейтинг отозван'; const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
if ( if (
!withdrawn && !withdrawn &&
!/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test( !/^(?:AAA|A{1,2}[+-]?|B{1,3}[+-]?|CCC[+-]?|C{1,2}|D)\.ru$/.test(
raw, raw,
) )
) ) {
throw new Error('NCR rating changed'); throw new Error('NCR rating changed');
}
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec( const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
cells.eq(DATE_COLUMN).text().trim(), cells.eq(DATE_COLUMN).text().trim(),
); );
const href = cells.eq(ISIN_COLUMN).find('a').attr('href'); const href = cells.eq(ISIN_COLUMN).find('a').attr('href');
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) {
throw new Error('NCR identity missing'); throw new Error('NCR identity missing');
}
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
if ( if (
!Number.isFinite(Date.parse(date)) || !Number.isFinite(Date.parse(date)) ||
new Date(date).toISOString().slice(0, 10) !== date new Date(date).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== date
) ) {
throw new Error('NCR date invalid'); throw new Error('NCR date invalid');
}
const rating: NcrRating = { const rating: NcrRating = {
agency: 'NCR', agency: 'NCR',
value: withdrawn ? null : raw, value: withdrawn ? null : raw,
@@ -103,8 +117,9 @@ export class NcrRatingsClient {
previous && previous &&
previous.date === date && previous.date === date &&
(previous.value !== rating.value || previous.status !== rating.status) (previous.value !== rating.value || previous.status !== rating.status)
) ) {
throw new Error('NCR rating ambiguous'); throw new Error('NCR rating ambiguous');
}
if (!previous || previous.date < date) result.set(isin, rating); if (!previous || previous.date < date) result.set(isin, rating);
}); });
if (!result.size) throw new Error('NCR registry empty'); if (!result.size) throw new Error('NCR registry empty');
+1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client'; import { NcrRatingsClient } from './ncr-ratings.client';
@Module({ @Module({
+15
View File
@@ -21,3 +21,18 @@ describe('decimal', () => {
).toBe('1'); ).toBe('1');
}); });
}); });
describe('decimal formatting', () => {
it.each([
[120000n, 4, '12'],
[-120030n, 4, '-12.003'],
[1n, 4, '0.0001'],
[0n, 4, '0'],
])('preserves value %s at scale %s while trimming only trailing zeros', (value, scale, expected) => {
expect(formatDecimal({ value, scale })).toBe(expected);
});
it('preserves a long fraction with a trailing zero run', () => {
const significant = '1'.repeat(10_000);
expect(formatDecimal({ value: BigInt(significant + '0'.repeat(10_000)), scale: 20_000 })).toBe('0.' + significant);
});
});
+4 -2
View File
@@ -1,12 +1,14 @@
import { Decimal } from '../../common/decimal'; import { Decimal } from '../../common/decimal';
const MISSING_PROTO_COMPONENT = 0;
const NANO = 1_000_000_000n; const NANO = 1_000_000_000n;
export type ProtoDecimal = { units?: string | number; nano?: string | number }; export type ProtoDecimal = { nano?: number | string; units?: number | string };
export function decimal(value: ProtoDecimal): Decimal { export function decimal(value: ProtoDecimal): Decimal {
return { return {
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0), value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
scale: 9, scale: 9,
}; };
} }
@@ -0,0 +1,331 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import type {
TbankInstrumentLookup as MetadataLookupRow,
TbankInstrument as MetadataRow,
Prisma,
} from '../../../generated/prisma/client';
import type { TbankSyncAttempt } from './tbank-access.service';
import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { TbankErrorCode } from './tbank-error-code';
const FIRST_BATCH_OFFSET = 0;
const UNCHANGED_REVISION_INCREMENT = 0;
const REVISION_INCREMENT = 1;
const MISSING_DATA_REVISION = 0;
export type MetadataIdentity = {
figi?: null | string;
instrumentUid?: null | string;
};
export type MetadataLookup = {
data: null | TbankInstrument;
id: string;
type: 'figi' | 'uid';
};
const provider = 'tbank';
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
const metadataReadBatchSize = 400;
export function metadataKey(identity: MetadataIdentity) {
return JSON.stringify([
identity.instrumentUid?.trim() || null,
identity.figi?.trim() || null,
]);
}
@Injectable()
export class InstrumentMetadataRepository {
constructor(private readonly prisma: PrismaService) {}
async readMany(identities: MetadataIdentity[]) {
const identifiers = identities.flatMap((identity) => [
...(identity.instrumentUid?.trim()
? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }]
: []),
...(identity.figi?.trim()
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
: []),
]);
if (!identifiers.length) {
return new Map<string, ReturnType<typeof selectMetadata>>();
}
return this.prisma.$transaction(async (tx) => {
const lookups: MetadataLookupRow[] = [];
for (const identifierType of ['uid', 'figi']) {
const values = [
...new Set(
identifiers
.filter((entry) => entry.identifierType === identifierType)
.map((entry) => entry.identifier),
),
];
for (
let offset = FIRST_BATCH_OFFSET;
offset < values.length;
offset += metadataReadBatchSize
) {
lookups.push(
...(await tx.tbankInstrumentLookup.findMany({
where: {
provider,
identifierType,
identifier: {
in: values.slice(offset, offset + metadataReadBatchSize),
},
},
})),
);
}
}
const uids = [
...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
];
const instruments: MetadataRow[] = [];
for (
let offset = FIRST_BATCH_OFFSET;
offset < uids.length;
offset += metadataReadBatchSize
) {
instruments.push(
...(await tx.tbankInstrument.findMany({
where: {
provider,
uid: { in: uids.slice(offset, offset + metadataReadBatchSize) },
},
})),
);
}
const byIdentifier = new Map(
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
);
const byUid = new Map(instruments.map((row) => [row.uid, row]));
return new Map(
identities.map((identity) => [
metadataKey(identity),
selectMetadata(identity, byIdentifier, byUid),
]),
);
});
}
async reserve(
attemptOwner: { revision: string; userId: string },
identity: MetadataIdentity,
runId: string,
active: ReadonlySet<string>,
) {
return this.prisma.$transaction(async (tx) => {
const valid = await tx.tbankConnection.updateMany({
where: { ...attemptOwner, invalidToken: false },
data: { membershipRevision: { increment: 0 } },
});
if (!valid.count) return null;
const scope = {
...attemptOwner,
scopeKey: `metadata:${metadataKey(identity)}`,
};
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope },
});
if (
previous?.state === 'running' &&
previous.processRunId === runId &&
active.has(previous.attemptId)
) {
return { work: previous, launch: false };
}
const data = {
attemptId: randomUUID(),
processRunId: runId,
state: 'running',
priority: 1,
intent: { source: 'metadata', ...identity },
progress: {},
errorCode: null,
};
const work = await tx.tbankSyncWork.upsert({
where: { userId_revision_scopeKey: scope },
create: { ...scope, ...data, id: randomUUID() },
update: data,
});
return { work, launch: true };
});
}
work(userId: string, revision: string, identity: MetadataIdentity) {
return this.prisma.tbankSyncWork.findUnique({
where: {
userId_revision_scopeKey: {
userId,
revision,
scopeKey: `metadata:${metadataKey(identity)}`,
},
},
select: { id: true, state: true, errorCode: true },
});
}
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'complete', progress: {} },
});
if (!claimed.count) return false;
const aliases = new Map(
results.map((result) => [`${result.type}:${result.id}`, result]),
);
for (const result of results) {
if (!result.data) continue;
const uid =
result.data.uid?.trim() || (result.type === 'uid' ? result.id : null);
const figi =
result.data.figi?.trim() ||
(result.type === 'figi' ? result.id : null);
for (const [type, id] of [
['uid', uid],
['figi', figi],
] as const) {
if (id && uid && !aliases.has(`${type}:${id}`)) {
aliases.set(`${type}:${id}`, {
type,
id,
data: { ...result.data, uid },
});
}
}
}
for (const result of aliases.values()) {
const instrument = result.data;
const uid =
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
// FIGI is an alias, never a fabricated provider UID.
if (instrument && !uid) {
throw new Error('Metadata response has no instrument UID');
}
if (instrument && uid) {
const key = { provider, uid };
const previous = await tx.tbankInstrument.findUnique({
where: { provider_uid: key },
});
const payload = JSON.parse(
JSON.stringify(instrument),
) as Prisma.InputJsonObject;
const data = {
metadata: payload,
figi:
instrument.figi ?? (result.type === 'figi' ? result.id : null),
ticker: instrument.ticker ?? null,
instrumentType: instrument.instrumentType ?? '',
fetchedAt: now,
};
await tx.tbankInstrument.upsert({
where: { provider_uid: key },
create: { ...key, ...data },
update: {
...data,
dataRevision: {
increment: isDeepStrictEqual(previous?.metadata, payload)
? UNCHANGED_REVISION_INCREMENT
: REVISION_INCREMENT,
},
},
});
}
const key = {
provider,
identifierType: result.type,
identifier: result.id,
};
const matchedUids = instrument && uid ? [uid] : [];
const previous = await tx.tbankInstrumentLookup.findUnique({
where: { provider_identifierType_identifier: key },
});
await tx.tbankInstrumentLookup.upsert({
where: { provider_identifierType_identifier: key },
create: { ...key, matchedUids, fetchedAt: now },
update: {
matchedUids,
fetchedAt: now,
dataRevision: {
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
? UNCHANGED_REVISION_INCREMENT
: REVISION_INCREMENT,
},
},
});
}
return true;
});
}
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
await this.prisma.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
...(success ? { connection: { invalidToken: false } } : {}),
},
data: {
state: success ? 'complete' : 'failed',
errorCode: success ? null : TbankErrorCode.Unavailable,
},
});
if (success) {
await this.prisma.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: true },
},
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
});
}
}
}
function selectMetadata(
identity: MetadataIdentity,
lookups: ReadonlyMap<string, MetadataLookupRow>,
instruments: ReadonlyMap<string, MetadataRow>,
) {
const resolve = (type: string, id: null | string | undefined) => {
const lookup = lookups.get(`${type}:${id?.trim()}`);
const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
const instrument = uid ? instruments.get(uid) : undefined;
const data = instrument ? (instrument.metadata as TbankInstrument) : null;
return { lookup, instrument, data };
};
const instrumentUid = identity.instrumentUid?.trim();
const instrumentFigi = identity.figi?.trim();
const uid = resolve('uid', instrumentUid);
const figi = resolve('figi', instrumentFigi);
const usefulUid = Boolean(uid.data?.name?.trim() || uid.data?.ticker?.trim());
const used =
usefulUid || !instrumentFigi ? [uid] : instrumentUid ? [uid, figi] : [figi];
const complete = used.every((row) => Boolean(row.lookup));
const fetchedAt =
used
.flatMap((row) => (row.lookup ? [row.lookup.fetchedAt] : []))
.sort((a, b) => a.valueOf() - b.valueOf())[0] ?? null;
return {
data: usefulUid ? uid.data : (figi.data ?? uid.data),
complete,
fresh: complete,
fetchedAt,
dataRevision: used
.map(
(row) =>
`${row.lookup?.dataRevision ?? MISSING_DATA_REVISION}:${row.instrument?.dataRevision ?? MISSING_DATA_REVISION}`,
)
.join('/'),
};
}
@@ -1,294 +0,0 @@
import { ServiceUnavailableException } from '@nestjs/common';
import type { LRUCache } from 'lru-cache';
import assert from 'node:assert/strict';
import { InstrumentMetadataService } from './instrument-metadata.service';
import type { TbankInstrument } from './tbank.client';
describe('InstrumentMetadataService', () => {
const tbank = {
getInstrumentByFigi: jest.fn(),
getInstrumentByUid: jest.fn(),
};
const logger = { event: jest.fn() };
let service: InstrumentMetadataService;
function rememberExpired(key: string, value: TbankInstrument): void {
const cache = (
service as unknown as { metadata: LRUCache<string, TbankInstrument> }
).metadata;
cache.set(key, value, { ttl: 1, start: performance.now() - 1000 });
}
beforeEach(() => {
jest.clearAllMocks();
tbank.getInstrumentByFigi.mockReset();
tbank.getInstrumentByUid.mockReset();
service = new InstrumentMetadataService(tbank as never, logger as never);
});
it('prefers the instrument UID when metadata is available', async () => {
tbank.getInstrumentByUid.mockResolvedValue({
name: 'Инструмент',
brand: { logoName: 'instrument.png' },
});
await expect(
service.resolve('token', 'uid-1', 'figi-1'),
).resolves.toMatchObject({
brand: { logoName: 'instrument.png' },
});
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('falls back to FIGI when the UID has no matching instrument', async () => {
tbank.getInstrumentByUid.mockResolvedValue(null);
tbank.getInstrumentByFigi.mockResolvedValue({
ticker: 'SBER',
brand: { logoName: 'sber.png' },
});
await expect(
service.resolve('token', 'uid-1', 'figi-1'),
).resolves.toMatchObject({
ticker: 'SBER',
});
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'figi-1');
});
it('returns a nullable fallback when instrument metadata is unavailable', async () => {
tbank.getInstrumentByUid.mockRejectedValue(new Error('unavailable'));
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
});
it('caches negative lookups and avoids repeated upstream requests', async () => {
tbank.getInstrumentByUid.mockResolvedValue(null);
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
});
it('tries FIGI when UID metadata has no usable name or ticker', async () => {
tbank.getInstrumentByUid.mockResolvedValue({ name: ' ', ticker: '' });
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Облигация' });
await expect(
service.resolve('token', 'uid-1', 'figi-1'),
).resolves.toMatchObject({ name: 'Облигация' });
});
it('retains expired successful metadata through temporary failures and refreshes it later', async () => {
const known = {
name: 'Облигация',
ticker: 'BOND',
brand: { logoName: 'bond.png' },
};
tbank.getInstrumentByUid.mockResolvedValueOnce(known);
await service.resolve('token', 'uid-1', null);
rememberExpired('uid:uid-1', known);
tbank.getInstrumentByUid.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
known,
);
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
known,
);
tbank.getInstrumentByUid.mockResolvedValue({
...known,
name: 'Новое название',
});
await expect(
service.resolve('token', 'uid-1', null),
).resolves.toMatchObject({ name: 'Новое название' });
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(4);
});
it('does not mask an invalid token as missing metadata or retry by FIGI', async () => {
const error = new Error('INVALID_TBANK_TOKEN');
tbank.getInstrumentByUid.mockRejectedValue(error);
await expect(service.resolve('token', 'uid-1', 'figi-1')).rejects.toBe(
error,
);
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('keeps known metadata through an incomplete refresh without renewing its freshness', async () => {
const known = { name: 'Облигация', ticker: 'BOND' };
rememberExpired('uid:uid-1', known);
tbank.getInstrumentByUid
.mockResolvedValueOnce({ name: ' ', ticker: '', isin: 'RU0000000001' })
.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
known,
);
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
known,
);
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('keeps a known FIGI name after partial UID metadata and does not cache the partial response', async () => {
const known = { name: 'Облигация', ticker: 'BOND' };
rememberExpired('figi:figi-1', known);
tbank.getInstrumentByUid
.mockResolvedValueOnce({ isin: 'RU0000000001' })
.mockResolvedValueOnce({ name: 'Новое название' });
tbank.getInstrumentByFigi.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
known,
);
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual({
name: 'Новое название',
});
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1);
});
it('returns cold partial fields without caching them', async () => {
const partial = { isin: 'RU0000000001' };
tbank.getInstrumentByUid.mockResolvedValue(partial);
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
partial,
);
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
partial,
);
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
});
it('replaces an expired name with authoritative confirmed absence', async () => {
rememberExpired('uid:uid-1', { name: 'Старое название' });
tbank.getInstrumentByUid.mockResolvedValue(null);
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
});
it('isolates in-flight credentials while sharing successful public metadata', async () => {
const error = new Error('INVALID_TBANK_TOKEN');
const known = { name: 'Облигация' };
let rejectInvalid: ((reason: Error) => void) | undefined;
tbank.getInstrumentByUid.mockImplementation((token: string) =>
token === 'invalid-token'
? new Promise((_resolve, reject) => {
rejectInvalid = reject;
})
: Promise.resolve(known),
);
const invalid = expect(
service.resolve('invalid-token', 'uid-1', null),
).rejects.toBe(error);
const valid = service.resolve('valid-token', 'uid-1', null);
assert.ok(rejectInvalid);
rejectInvalid(error);
await invalid;
await expect(valid).resolves.toEqual(known);
await expect(
service.resolve('another-token', 'uid-1', null),
).resolves.toEqual(known);
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
});
it('returns cached or nullable metadata without starting requests after abort', async () => {
const known = { name: 'Облигация' };
rememberExpired('figi:figi-1', known);
const controller = new AbortController();
controller.abort();
await expect(
service.resolve('token', 'uid-1', 'figi-1', controller.signal),
).resolves.toEqual(known);
await expect(
service.resolve('token', 'uid-2', 'figi-2', controller.signal),
).resolves.toBeNull();
expect(tbank.getInstrumentByUid).not.toHaveBeenCalled();
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('returns expired metadata promptly when the waiting caller aborts', async () => {
const known = { name: 'Облигация' };
rememberExpired('uid:uid-1', known);
tbank.getInstrumentByUid.mockReturnValue(new Promise(() => {}));
const controller = new AbortController();
const result = service.resolve(
'token',
'uid-1',
'figi-1',
controller.signal,
);
controller.abort();
await expect(result).resolves.toEqual(known);
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('keeps a shared fetch alive after one caller aborts and caches its result', async () => {
const known = { name: 'Облигация' };
let finish: ((value: TbankInstrument) => void) | undefined;
tbank.getInstrumentByUid.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
const controller = new AbortController();
const aborted = service.resolve(
'token',
'uid-1',
'figi-1',
controller.signal,
);
controller.abort();
await expect(aborted).resolves.toBeNull();
const waiting = service.resolve('token', 'uid-1', null);
assert.ok(finish);
finish(known);
await expect(waiting).resolves.toEqual(known);
await expect(
service.resolve('another-token', 'uid-1', null),
).resolves.toEqual(known);
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('does not start FIGI lookup after an aborted UID lookup fails later', async () => {
let fail: ((reason: Error) => void) | undefined;
tbank.getInstrumentByUid.mockReturnValue(
new Promise((_resolve, reject) => {
fail = reject;
}),
);
const controller = new AbortController();
const result = service.resolve(
'token',
'uid-1',
'figi-1',
controller.signal,
);
controller.abort();
await expect(result).resolves.toBeNull();
assert.ok(fail);
fail(new Error('INVALID_TBANK_TOKEN'));
await Promise.resolve();
await Promise.resolve();
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
});
@@ -1,118 +1,222 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { LRUCache } from 'lru-cache'; import { randomUUID } from 'node:crypto';
import { createHash } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../common/logging/structured-logger.service';
import {
InstrumentMetadataRepository,
type MetadataIdentity,
metadataKey,
type MetadataLookup,
} from './instrument-metadata.repository';
import {
TbankAccessService,
type TbankSyncAttempt,
} from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient, type TbankInstrument } from './tbank.client'; import { TbankClient, type TbankInstrument } from './tbank.client';
export { metadataKey } from './instrument-metadata.repository';
export type { MetadataIdentity } from './instrument-metadata.repository';
const metadataForMs = 86_400_000; type Producer = {
const maxCachedInstruments = 500; attempt: TbankSyncAttempt;
const missingInstrument = Symbol('missingInstrument'); identity: MetadataIdentity;
observers: TbankSyncAttempt[];
};
@Injectable() @Injectable()
export class InstrumentMetadataService { export class InstrumentMetadataService {
private readonly metadata = new LRUCache< private readonly runId = randomUUID();
string, private readonly active = new Set<string>();
TbankInstrument | typeof missingInstrument private readonly producers = new Map<string, Producer>();
>({ private readonly queue: Producer[] = [];
max: maxCachedInstruments, private reservations: Promise<unknown> = Promise.resolve();
ttl: metadataForMs, private running = false;
noDeleteOnStaleGet: true,
});
private readonly pending = new Map<string, Promise<TbankInstrument | null>>();
constructor( constructor(
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
private readonly repository: InstrumentMetadataRepository,
private readonly access: TbankAccessService,
) {} ) {}
async resolve( savedMany(identities: MetadataIdentity[]) {
token: string, return this.repository.readMany(identities);
instrumentUid: string | null | undefined, }
figi: string | null | undefined,
signal?: AbortSignal, async read(
): Promise<TbankInstrument | null> { userId: string,
const uid = instrumentUid?.trim(); identity: MetadataIdentity,
let byUid: TbankInstrument | null = null; observe = false,
if (uid) { revision?: string,
byUid = await this.lookup( ) {
token, const connection = await this.access.local(userId);
`uid:${uid}`, if (revision && connection.revision !== revision) return null;
() => this.tbank.getInstrumentByUid(token, uid), const data = (await this.savedMany([identity])).get(metadataKey(identity));
signal, if (!observe && !data?.fresh) {
await this.request(userId, connection.revision, identity);
}
return {
...data,
work: await this.repository.work(userId, connection.revision, identity),
};
}
async request(
userId: string,
revision: string,
identity: MetadataIdentity,
force = false,
) {
identity = {
instrumentUid: identity.instrumentUid?.trim() || null,
figi: identity.figi?.trim() || null,
};
if (!identity.instrumentUid && !identity.figi) return null;
const normalized = identity;
const reserved = this.reservations.then(async () => {
const snapshot = (await this.savedMany([normalized])).get(
metadataKey(normalized),
); );
if (byUid?.name?.trim() || byUid?.ticker?.trim()) return byUid; if (!force && snapshot?.fresh) return null;
} const result = await this.repository.reserve(
const instrumentFigi = figi?.trim(); { userId, revision },
const byFigi = instrumentFigi normalized,
? await this.lookup( this.runId,
token, this.active,
`figi:${instrumentFigi}`, );
() => this.tbank.getInstrumentByFigi(token, instrumentFigi), if (!result) return null;
signal, const { id, attemptId } = result.work;
) if (!result.launch) return id;
: null; const attempt = { userId, revision, id, attemptId };
return byFigi ?? byUid; this.active.add(attemptId);
} const key = metadataKey(normalized);
const producer = this.producers.get(key);
private async lookup( if (producer) producer.observers.push(attempt);
token: string, else {
key: string, const queued = { identity: normalized, attempt, observers: [] };
fetch: () => Promise<TbankInstrument | null>, this.producers.set(key, queued);
signal?: AbortSignal, this.queue.push(queued);
): Promise<TbankInstrument | null> { }
const cached = this.metadata.get(key); if (!this.running) {
if (cached !== undefined) this.running = true;
return cached === missingInstrument ? null : cached; setImmediate(() => {
if (signal?.aborted) return this.lastKnown(key); void this.drain();
const owner = createHash('sha256').update(token).digest('hex'); });
const requestKey = `${owner}:${key}`; }
let pending = this.pending.get(requestKey); return id;
if (!pending) {
pending = this.fetch(key, fetch, this.lastKnown(key)).finally(() => {
this.pending.delete(requestKey);
});
this.pending.set(requestKey, pending);
}
if (!signal) return pending;
return new Promise((resolve, reject) => {
const onAbort = () => resolve(this.lastKnown(key));
signal.addEventListener('abort', onAbort, { once: true });
void pending.then(resolve, reject).finally(() => {
signal.removeEventListener('abort', onAbort);
});
}); });
this.reservations = reserved.catch(() => undefined);
return reserved;
} }
private lastKnown(key: string): TbankInstrument | null { private async drain() {
const value = this.metadata.peek(key, { allowStale: true });
return value === missingInstrument ? null : (value ?? null);
}
private async fetch(
key: string,
request: () => Promise<TbankInstrument | null>,
previous: TbankInstrument | null,
): Promise<TbankInstrument | null> {
try { try {
const value = await request(); while (this.queue.length) {
const found = Boolean(value?.name?.trim() || value?.ticker?.trim()); const producer = this.queue.shift();
if (value === null || found) if (!producer) break;
this.metadata.set(key, value ?? missingInstrument); const { attempt, identity } = producer;
this.logger.event('debug', 'instrument-metadata.resolved', { let success = false;
found, try {
const token = await this.access.tokenForAttempt(attempt);
if (token) {
const results: MetadataLookup[] = [];
let useful = false;
let uidFailed = false;
if (identity.instrumentUid) {
let data: null | TbankInstrument | undefined;
try {
data = await this.tbank.getInstrumentByUid(
token,
identity.instrumentUid,
);
} catch (error) {
if (
!identity.figi ||
(error instanceof Error &&
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
) {
throw error;
}
uidFailed = true;
}
if (data?.uid && data.uid !== identity.instrumentUid) {
throw new Error('Metadata UID mismatch');
}
if (data !== undefined) {
results.push({ type: 'uid', id: identity.instrumentUid, data });
}
useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
if (!useful && data && !identity.figi) {
throw new Error('Incomplete metadata');
}
}
if (!useful && identity.figi) {
const data = await this.tbank.getInstrumentByFigi(
token,
identity.figi,
);
if (data?.figi && data.figi !== identity.figi) {
throw new Error('Metadata FIGI mismatch');
}
if (data && !data.name?.trim() && !data.ticker?.trim()) {
throw new Error('Incomplete metadata');
}
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation at an untrusted upstream boundary includes missing payloads.
if (data === undefined || (uidFailed && !data)) {
throw new Error('Metadata source unavailable');
}
results.push({ type: 'figi', id: identity.figi, data });
// A successful FIGI response identifies its canonical UID too;
// a failed UID RPC is never persisted as a negative lookup.
if (
uidFailed &&
data?.uid === identity.instrumentUid &&
identity.instrumentUid
) {
results.push({ type: 'uid', id: identity.instrumentUid, data });
}
}
if (
!results.length ||
(results.some((result) => result.data) &&
!results.some(
(result) =>
result.data?.name?.trim() || result.data?.ticker?.trim(),
))
) {
throw new Error('Incomplete metadata');
}
success = await this.repository.publish(
attempt,
results,
new Date(),
);
}
} catch (error) {
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(attempt);
} else {
this.logger.event('warn', 'instrument-metadata.failed', {
code: TbankErrorCode.Unavailable,
});
}
}
// Remove the producer before awaiting finalization: a new request cannot
// attach to a producer whose observer list has already been drained.
this.producers.delete(metadataKey(identity));
for (const observer of [attempt, ...producer.observers]) {
await this.repository.finishObserver(observer, success);
this.active.delete(observer.attemptId);
}
}
} catch {
this.logger.event('error', 'instrument-metadata.persistence-failed', {
code: TbankErrorCode.Unavailable,
}); });
return found || value === null ? value : (previous ?? value); this.active.clear();
} catch (error) { this.producers.clear();
this.logger.event('warn', 'instrument-metadata.failed', { this.queue.length = 0;
code: } finally {
error instanceof Error && 'response' in error this.running = false;
? ((error as { response?: { code?: string } }).response?.code ??
'UNKNOWN')
: 'UNKNOWN',
});
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
throw error;
return error instanceof ServiceUnavailableException ? previous : null;
} }
} }
} }
@@ -1,162 +0,0 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import type { TbankAccountContext } from './tbank-access.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankTokenService } from './tbank-token.service';
describe('TbankAccessService', () => {
const prisma = { tbankConnection: { findUnique: jest.fn() } };
const token = {
decrypt: jest.fn(),
translateInvalidToken: jest.fn((operation: () => Promise<unknown>) =>
new TbankTokenService({
getOrThrow: () => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
} as never).translateInvalidToken(operation),
),
};
const tbank = { getOpenAccounts: jest.fn() };
const service = new TbankAccessService(
prisma as never,
token as never,
tbank as never,
);
beforeEach(() => {
jest.clearAllMocks();
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: 'encrypted',
});
token.decrypt.mockReturnValue('plain-token');
tbank.getOpenAccounts.mockResolvedValue([
{
id: 'account',
type: 'ACCOUNT_TYPE_TINKOFF',
name: ' Основной ',
openedDate: { seconds: '1723507200' },
},
{ id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
]);
});
it('lists supported accounts through the saved decrypted token', async () => {
await expect(service.listSupportedAccounts('user')).resolves.toEqual([
{
id: 'account',
type: 'BROKERAGE',
name: 'Основной',
openedOn: '2024-08-13',
},
]);
expect(prisma.tbankConnection.findUnique).toHaveBeenCalledWith({
where: { userId: 'user' },
});
expect(token.decrypt).toHaveBeenCalledWith('encrypted');
expect(tbank.getOpenAccounts).toHaveBeenCalledWith('plain-token');
});
it('rejects missing connections before calling T-Bank', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue(null);
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
expect.any(ConflictException),
);
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
});
it('resolves the persisted connection generation without an upstream account call', async () => {
const context = await service.withConnection('user', (value) => value);
expect(context).toEqual({
token: 'plain-token',
generation: expect.any(String) as string,
});
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
});
it('translates invalid tokens and preserves unavailable upstream errors', async () => {
tbank.getOpenAccounts.mockRejectedValueOnce(
new Error('INVALID_TBANK_TOKEN'),
);
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
expect.any(BadRequestException),
);
const unavailable = new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
tbank.getOpenAccounts.mockRejectedValueOnce(unavailable);
await expect(service.listSupportedAccounts('user')).rejects.toBe(
unavailable,
);
});
it('wraps account-scoped handlers with resolved account access', async () => {
const handler = jest.fn((context: TbankAccountContext) =>
Promise.resolve({
resolvedToken: context.token,
generation: context.generation,
accountId: context.account.id,
}),
);
await expect(
service.withAccount('user', 'account', handler),
).resolves.toEqual({
resolvedToken: 'plain-token',
generation: expect.any(String) as string,
accountId: 'account',
});
});
it('derives a stable opaque generation from each persisted encrypted payload', async () => {
const generation = () =>
service.withAccount('user', 'account', ({ generation: value }) =>
Promise.resolve(value),
);
prisma.tbankConnection.findUnique
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
.mockResolvedValueOnce({ encryptedToken: 'encrypted:second-nonce' });
const first = await generation();
const sameRecord = await generation();
const replaced = await generation();
expect(sameRecord).toBe(first);
expect(replaced).not.toBe(first);
expect(first).not.toContain('plain-token');
expect(first).not.toContain('encrypted:first-nonce');
});
it('rejects inaccessible accounts before running the handler', async () => {
const handler = jest.fn();
await expect(
service.withAccount('user', 'missing', handler),
).rejects.toEqual(expect.any(NotFoundException));
expect(handler).not.toHaveBeenCalled();
});
it('translates invalid-token handler failures and preserves unrelated handler failures', async () => {
await expect(
service.withAccount('user', 'account', () =>
Promise.reject(new Error('INVALID_TBANK_TOKEN')),
),
).rejects.toEqual(expect.any(BadRequestException));
const failure = new Error('source failed');
await expect(
service.withAccount('user', 'account', () => Promise.reject(failure)),
).rejects.toBe(failure);
});
});
+255 -79
View File
@@ -1,25 +1,28 @@
import { import {
BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { TbankAccount } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { import { supportedTbankAccounts } from './tbank-account.policy';
SupportedTbankAccount, import { TbankErrorCode } from './tbank-error-code';
accessibleTbankAccount, import { tbankSourceNeedsRefresh } from './tbank-source.policy';
supportedTbankAccounts,
} from './tbank-account.policy';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
import { TbankAccount, TbankClient } from './tbank.client';
export type TbankConnectionContext = { const EXPECTED_UPDATED_CONNECTIONS = 1;
token: string; const MEMBERSHIP_REVISION_INCREMENT = 1;
generation: string; const UNCHANGED_MEMBERSHIP_REVISION_INCREMENT = 0;
};
export type TbankAccountContext = TbankConnectionContext & { export type TbankSyncAttempt = {
account: TbankAccount; attemptId: string;
id: string;
revision: string;
userId: string;
}; };
@Injectable() @Injectable()
@@ -27,81 +30,254 @@ export class TbankAccessService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly tokens: TbankTokenService, private readonly tokens: TbankTokenService,
private readonly tbank: TbankClient,
) {} ) {}
async listSupportedAccounts( async reserveMembership(
userId: string, userId: string,
): Promise<SupportedTbankAccount[]> { revision: string,
return this.withSupportedAccounts(userId, ({ accounts }) => accounts); processRunId: string,
} active: ReadonlySet<string>,
force: boolean,
async withSupportedAccounts<T>( ) {
userId: string, return this.prisma.$transaction(async (tx) => {
handler: (context: { const current = await tx.tbankConnection.updateMany({
token: string; where: { userId, revision, invalidToken: false },
generation: string; data: { membershipRevision: { increment: 0 } },
accounts: SupportedTbankAccount[]; });
}) => Promise<T> | T, if (!current.count) return null;
): Promise<T> { const connection = await tx.tbankConnection.findUniqueOrThrow({
return this.withConnection(userId, async ({ token, generation }) => where: { userId },
handler({ });
token, if (
generation, !force &&
accounts: supportedTbankAccounts( !tbankSourceNeedsRefresh(
await this.tbank.getOpenAccounts(token), 'membership',
), connection.membershipAt,
}), new Date(),
); )
} ) {
return null;
async withConnection<T>( }
userId: string, const scope = { userId, revision, scopeKey: 'membership' };
handler: (context: TbankConnectionContext) => Promise<T> | T, const previous = await tx.tbankSyncWork.findUnique({
): Promise<T> { where: { userId_revision_scopeKey: scope },
const connection = await this.connection(userId); });
return this.tokens.translateInvalidToken(async () => handler(connection)); if (
} previous?.state === 'running' &&
previous.processRunId === processRunId &&
async withAccount<T>( active.has(previous.attemptId)
userId: string, ) {
accountId: string, return { work: previous, launch: false };
handler: (context: TbankAccountContext) => Promise<T>, }
): Promise<T> { const data = {
return this.withConnection(userId, (connection) => attemptId: randomUUID(),
this.withAccountForConnection(connection, accountId, handler), processRunId,
); state: 'running',
} intent: { source: 'membership' },
progress: {},
async withAccountForConnection<T>( priority: 0,
connection: TbankConnectionContext, errorCode: null,
accountId: string, };
handler: (context: TbankAccountContext) => Promise<T>, const work = await tx.tbankSyncWork.upsert({
): Promise<T> { where: { userId_revision_scopeKey: scope },
const { token, generation } = connection; create: { ...scope, ...data, id: randomUUID() },
return this.tokens.translateInvalidToken(async () => { update: data,
const account = accessibleTbankAccount( });
await this.tbank.getOpenAccounts(token), return { work, launch: true };
accountId,
);
if (!account) throw new NotFoundException();
return handler({ token, generation, account });
}); });
} }
private async connection( membershipWork(userId: string, revision: string) {
return this.prisma.tbankSyncWork.findUnique({
where: {
userId_revision_scopeKey: { userId, revision, scopeKey: 'membership' },
},
select: { id: true, state: true, errorCode: true, updatedAt: true },
});
}
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
if (invalidToken) return this.rejectMembershipToken(attempt);
const result = await this.prisma.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
});
return result.count === EXPECTED_UPDATED_CONNECTIONS;
}
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
const work = await this.prisma.tbankSyncWork.findFirst({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
select: { connection: { select: { encryptedToken: true } } },
});
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
}
// Online reads: a permanent upstream auth refusal becomes 400/INVALID_TBANK_TOKEN here, once, for every caller.
async online<T>(
userId: string, userId: string,
): Promise<{ token: string; generation: string }> { run: (connection: { revision: string; token: string }) => Promise<T>,
): Promise<T> {
const connection = await this.prisma.tbankConnection.findUnique({ const connection = await this.prisma.tbankConnection.findUnique({
where: { userId }, where: { userId },
select: { encryptedToken: true, invalidToken: true, revision: true },
}); });
if (!connection) if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); if (connection.invalidToken) {
return { throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
token: this.tokens.decrypt(connection.encryptedToken), }
generation: createHash('sha256') const token = this.tokens.decrypt(connection.encryptedToken);
.update(connection.encryptedToken) return this.tokens.translateInvalidToken(() => run({ revision: connection.revision, token }));
.digest('base64url'), }
};
async publishMembership(
attempt: TbankSyncAttempt,
accounts: TbankAccount[],
fetchedAt: Date,
completeWork = false,
): Promise<boolean> {
const supported = supportedTbankAccounts(accounts);
if (new Set(supported.map(({ id }) => id)).size !== supported.length) {
throw new Error('Duplicate T-Bank account identity');
}
return this.prisma.$transaction(async (tx) => {
// Acquire the write fence before reading membership. Replacement and
// attempt supersession cannot interleave with the publication below.
const claimed = await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { updatedAt: new Date() },
});
if (!claimed.count) return false;
const { userId, revision } = attempt;
const previous = await tx.tbankAccount.findMany({
where: { userId, revision },
orderBy: { upstreamOrder: 'asc' },
select: { accountId: true, type: true, name: true, openedOn: true },
});
const next = supported.map(({ id, ...account }) => ({
accountId: id,
...account,
}));
const changed =
previous.length !== next.length ||
previous.some((account, index) => {
// eslint-disable-next-line security/detect-object-injection -- The index comes from the paired account array iteration.
const incoming = next[index];
return (
account.accountId !== incoming.accountId ||
account.type !== incoming.type ||
account.name !== incoming.name ||
account.openedOn !== incoming.openedOn
);
});
await tx.tbankAccount.deleteMany({
where: {
userId,
revision,
accountId: { notIn: supported.map(({ id }) => id) },
},
});
for (const [upstreamOrder, account] of next.entries()) {
await tx.tbankAccount.upsert({
where: {
userId_revision_accountId: {
userId,
revision,
accountId: account.accountId,
},
},
create: { userId, revision, ...account, upstreamOrder },
update: { ...account, upstreamOrder },
});
}
await tx.tbankConnection.update({
where: { userId_revision: { userId, revision } },
data: {
membershipAt: fetchedAt,
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
},
});
// Value history visibility follows the confirmed membership only; a
// failed fetch never reaches this transaction and changes nothing.
const supportedIds = supported.map(({ id }) => id);
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { in: supportedIds } },
data: { deletedAt: null },
});
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { notIn: supportedIds }, deletedAt: null },
data: { deletedAt: fetchedAt },
});
if (completeWork) {
await tx.tbankSyncWork.update({
where: { id: attempt.id },
data: { state: 'complete', progress: { accounts: supported.length } },
});
}
return true;
});
}
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({
where: { ...attempt, state: 'running' },
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
});
if (!claimed.count) return false;
await tx.tbankConnection.update({
where: {
userId_revision: {
userId: attempt.userId,
revision: attempt.revision,
},
},
data: { invalidToken: true },
});
return true;
});
}
// This boundary neither decrypts credentials nor starts discovery/sync.
// Missing membership is distinct from a successfully observed empty list.
async local(userId: string, accountId?: string) {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
select: {
userId: true,
revision: true,
invalidToken: true,
membershipAt: true,
membershipRevision: true,
accounts: { orderBy: { upstreamOrder: 'asc' } },
},
});
if (!connection) {
throw new ConflictException({ code: TbankErrorCode.NotConnected });
}
if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
if (
connection.membershipAt &&
accountId &&
accountId !== 'all' &&
!connection.accounts.some((account) => account.accountId === accountId)
) {
throw new NotFoundException();
}
return connection;
} }
} }
@@ -1,7 +1,4 @@
import { import { supportedTbankAccounts } from './tbank-account.policy';
accessibleTbankAccount,
supportedTbankAccounts,
} from './tbank-account.policy';
describe('tbank-account-policy', () => { describe('tbank-account-policy', () => {
it('normalizes supported account types and fallback names', () => { it('normalizes supported account types and fallback names', () => {
@@ -45,26 +42,4 @@ describe('tbank-account-policy', () => {
}, },
]); ]);
}); });
it('matches account-scoped access only for supported account types', () => {
expect(
accessibleTbankAccount(
[
{ id: 'account', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
{ id: 'other', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Other' },
],
'account',
),
).toBeNull();
expect(
accessibleTbankAccount(
[{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Brokerage' }],
'account',
),
).toEqual({
id: 'account',
type: 'ACCOUNT_TYPE_TINKOFF',
name: 'Brokerage',
});
});
}); });
+9 -19
View File
@@ -1,14 +1,15 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
import { TbankAccountType } from './tbank-protocol';
import { TbankAccount } from './tbank.client'; import { TbankAccount } from './tbank.client';
const accountTypes = { const accountTypes = {
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' }, [TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
ACCOUNT_TYPE_TINKOFF_IIS: { [TbankAccountType.TinkoffIis]: {
type: 'INDIVIDUAL_INVESTMENT', type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС', name: 'ИИС',
}, },
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' }, [TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
ACCOUNT_TYPE_INVEST_FUND: { [TbankAccountType.InvestFund]: {
type: 'MONEY_MARKET_FUND', type: 'MONEY_MARKET_FUND',
name: 'Фонд денежного рынка', name: 'Фонд денежного рынка',
}, },
@@ -18,8 +19,8 @@ type AccountType = keyof typeof accountTypes;
export type SupportedTbankAccount = { export type SupportedTbankAccount = {
id: string; id: string;
name: string; name: string;
openedOn: null | string;
type: (typeof accountTypes)[AccountType]['type']; type: (typeof accountTypes)[AccountType]['type'];
openedOn: string | null;
}; };
export function supportedTbankAccounts( export function supportedTbankAccounts(
@@ -39,20 +40,9 @@ export function supportedTbankAccounts(
}); });
} }
export function accessibleTbankAccount( function openedOn(seconds: number | string | undefined): null | string {
accounts: TbankAccount[],
accountId: string,
): TbankAccount | null {
return (
accounts.find(
(account) => account.id === accountId && account.type in accountTypes,
) ?? null
);
}
function openedOn(seconds: string | number | undefined): string | null {
if (seconds === undefined) return null; if (seconds === undefined) return null;
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
if (Number.isNaN(date.valueOf())) return null; if (Number.isNaN(date.valueOf())) return null;
return date.toISOString().slice(0, 10); return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
} }
@@ -0,0 +1,10 @@
export enum TbankErrorCode {
InvalidToken = 'INVALID_TBANK_TOKEN',
NotConnected = 'TBANK_NOT_CONNECTED',
Unavailable = 'TBANK_UNAVAILABLE',
}
// Error messages and persisted codes cross an open string boundary.
export function isTbankErrorCode(value: unknown, expected: TbankErrorCode): boolean {
return value === expected;
}
+26 -24
View File
@@ -6,23 +6,25 @@ import type {
} from './tbank.client'; } from './tbank.client';
export type TbankShare = TbankInstrument & { export type TbankShare = TbankInstrument & {
uid?: string;
figi?: string;
assetUid?: string;
classCode?: string;
realExchange?: string;
apiTradeAvailableFlag?: boolean; apiTradeAvailableFlag?: boolean;
assetUid?: string;
buyAvailableFlag?: boolean;
classCode?: string;
countryOfRisk?: string;
currency?: string; currency?: string;
shareType?: string; figi?: string;
instrumentKind?: string;
sector?: string;
lot?: number;
first_1dayCandleDate?: TbankTimestamp; first_1dayCandleDate?: TbankTimestamp;
instrumentKind?: string;
lot?: number;
realExchange?: string;
sector?: string;
shareType?: string;
uid?: string;
}; };
export type TbankCandle = { export type TbankCandle = {
time?: TbankTimestamp;
close?: TbankDecimal; close?: TbankDecimal;
isComplete?: boolean; isComplete?: boolean;
time?: TbankTimestamp;
}; };
export type TbankLastPrice = { export type TbankLastPrice = {
instrumentUid?: string; instrumentUid?: string;
@@ -32,38 +34,38 @@ export type TbankLastPrice = {
export type TbankFundamentals = { export type TbankFundamentals = {
assetUid?: string; assetUid?: string;
currency?: string; currency?: string;
ebitdaTtm?: number;
marketCapitalization?: number; marketCapitalization?: number;
netDebtToEbitda?: number;
netIncomeTtm?: number;
peRatioTtm?: number; peRatioTtm?: number;
priceToBookTtm?: number; priceToBookTtm?: number;
revenueTtm?: number; revenueTtm?: number;
netIncomeTtm?: number;
ebitdaTtm?: number;
roe?: number; roe?: number;
totalDebtMrq?: number; totalDebtMrq?: number;
netDebtToEbitda?: number;
}; };
export type TbankBond = Omit<TbankShare, 'shareType'> & { export type TbankBond = Omit<TbankShare, 'shareType'> & {
nominal?: TbankMoney;
initialNominal?: TbankMoney;
aciValue?: TbankMoney; aciValue?: TbankMoney;
maturityDate?: TbankTimestamp; amortizationFlag?: boolean;
bondType?: string;
callDate?: TbankTimestamp; callDate?: TbankTimestamp;
couponQuantityPerYear?: number; couponQuantityPerYear?: number;
floatingCouponFlag?: boolean; floatingCouponFlag?: boolean;
initialNominal?: TbankMoney;
maturityDate?: TbankTimestamp;
nominal?: TbankMoney;
perpetualFlag?: boolean; perpetualFlag?: boolean;
amortizationFlag?: boolean;
subordinatedFlag?: boolean;
bondType?: string;
riskLevel?: string; riskLevel?: string;
subordinatedFlag?: boolean;
}; };
export type TbankCoupon = { export type TbankCoupon = {
couponNumber?: string;
couponDate?: TbankTimestamp; couponDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponNumber?: string;
couponPeriod?: number;
couponStartDate?: TbankTimestamp;
couponType?: string;
fixDate?: TbankTimestamp; fixDate?: TbankTimestamp;
payOneBond?: TbankMoney; payOneBond?: TbankMoney;
couponType?: string;
couponStartDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponPeriod?: number;
}; };
@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import {
TbankAccessService,
type TbankSyncAttempt,
} from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient } from './tbank.client';
@Injectable()
export class TbankMembershipService {
private readonly processRunId = randomUUID();
private readonly active = new Map<string, Promise<void>>();
private reservations: Promise<unknown> = Promise.resolve();
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly logger: StructuredLogger,
) {}
request(userId: string, revision: string, force = false) {
const reservation = this.reservations.then(async () => {
const requested = await this.access.reserveMembership(
userId,
revision,
this.processRunId,
new Set(this.active.keys()),
force,
);
if (!requested) return null;
const { id, attemptId } = requested.work;
if (requested.launch) {
const done = new Promise<void>((resolve) => setImmediate(resolve))
.then(() => this.execute({ userId, revision, id, attemptId }))
.catch(() =>
this.logger.event('error', 'tbank.membership.persistence_failed', {
userId,
code: TbankErrorCode.Unavailable,
}),
)
.finally(() => this.active.delete(attemptId));
this.active.set(attemptId, done);
}
return { id, done: this.active.get(attemptId) ?? Promise.resolve() };
});
this.reservations = reservation.catch(() => undefined);
return reservation;
}
private async execute(attempt: TbankSyncAttempt) {
try {
const token = await this.access.tokenForAttempt(attempt);
if (!token) return;
await this.access.publishMembership(
attempt,
await this.tbank.getOpenAccounts(token),
new Date(),
true,
);
} catch (error) {
await this.access.failMembership(
attempt,
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
);
}
}
}
@@ -0,0 +1,42 @@
import { loadSync } from '@grpc/proto-loader';
import { join } from 'node:path';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import {
InstrumentIdType,
isTbankProtocolValue,
TbankAccountStatus,
TbankAccountType,
TbankCandleInterval,
TbankCouponType,
TbankInstrumentType,
TbankOperationState,
TbankOperationType,
} from './tbank-protocol';
it('uses the string enum values declared by the vendored protocol', () => {
const directory = join(__dirname, 'proto/contracts');
const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String });
for (const [name, values] of Object.entries({
InstrumentIdType,
InstrumentType: TbankInstrumentType,
AccountStatus: TbankAccountStatus,
AccountType: TbankAccountType,
CandleInterval: TbankCandleInterval,
CouponType: TbankCouponType,
OperationState: TbankOperationState,
OperationType: TbankOperationType,
})) {
const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`];
expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto');
const descriptor = definition as { type: { value: { name: string }[] } };
expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values)));
}
});
it('recognizes supported wire values without accepting unknown ones', () => {
expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true);
expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false);
expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true);
expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false);
});
+74
View File
@@ -0,0 +1,74 @@
// String wire values for the protocol subset used by this integration.
// proto-loader decodes enums as strings; vendor contracts remain the source.
export enum InstrumentIdType {
Figi = 'INSTRUMENT_ID_TYPE_FIGI',
Uid = 'INSTRUMENT_ID_TYPE_UID',
}
export enum TbankInstrumentStatus {
All = 'INSTRUMENT_STATUS_ALL',
}
export enum TbankInstrumentType {
Bond = 'INSTRUMENT_TYPE_BOND',
Share = 'INSTRUMENT_TYPE_SHARE',
}
export enum TbankAccountStatus {
Open = 'ACCOUNT_STATUS_OPEN',
}
export enum TbankAccountType {
InvestBox = 'ACCOUNT_TYPE_INVEST_BOX',
InvestFund = 'ACCOUNT_TYPE_INVEST_FUND',
Tinkoff = 'ACCOUNT_TYPE_TINKOFF',
TinkoffIis = 'ACCOUNT_TYPE_TINKOFF_IIS',
}
export enum TbankCandleInterval {
Day = 'CANDLE_INTERVAL_DAY',
}
export enum TbankOperationState {
Executed = 'OPERATION_STATE_EXECUTED',
}
export enum TbankCouponType {
Floating = 'COUPON_TYPE_FLOATING',
Unspecified = 'COUPON_TYPE_UNSPECIFIED',
}
export enum TbankOperationType {
BondTax = 'OPERATION_TYPE_BOND_TAX',
BrokerFee = 'OPERATION_TYPE_BROKER_FEE',
Buy = 'OPERATION_TYPE_BUY',
BuyCard = 'OPERATION_TYPE_BUY_CARD',
BuyMargin = 'OPERATION_TYPE_BUY_MARGIN',
Coupon = 'OPERATION_TYPE_COUPON',
DivExt = 'OPERATION_TYPE_DIV_EXT',
Dividend = 'OPERATION_TYPE_DIVIDEND',
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
Input = 'OPERATION_TYPE_INPUT',
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
Output = 'OPERATION_TYPE_OUTPUT',
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
Sell = 'OPERATION_TYPE_SELL',
SellCard = 'OPERATION_TYPE_SELL_CARD',
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
ServiceFee = 'OPERATION_TYPE_SERVICE_FEE',
SuccessFee = 'OPERATION_TYPE_SUCCESS_FEE',
Tax = 'OPERATION_TYPE_TAX',
TransBsBs = 'OPERATION_TYPE_TRANS_BS_BS',
TransIisBs = 'OPERATION_TYPE_TRANS_IIS_BS',
Unspecified = 'OPERATION_TYPE_UNSPECIFIED',
}
// Provider responses may contain values newer than our supported protocol subset.
export function isTbankProtocolValue(
value: unknown,
expected: TbankCouponType | TbankInstrumentType | TbankOperationState | TbankOperationType,
): boolean {
return value === expected;
}
@@ -0,0 +1,34 @@
import {
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
} from './tbank-source.policy';
describe('mutable source freshness', () => {
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
'%s expires at its boundary without changing source time',
(source) => {
const fetchedAt = new Date('2026-09-01Z');
// eslint-disable-next-line security/detect-object-injection -- The key comes from the local source-policy test table.
const ttl = tbankSourceTtlMs[source];
expect(
tbankSourceNeedsRefresh(
source,
fetchedAt,
new Date(fetchedAt.valueOf() + ttl - 1000),
),
).toBe(false);
expect(
tbankSourceNeedsRefresh(
source,
fetchedAt,
new Date(fetchedAt.valueOf() + ttl),
),
).toBe(true);
expect(tbankSourceNeedsRefresh(source, fetchedAt, fetchedAt, true)).toBe(
true,
);
expect(tbankSourceNeedsRefresh(source, null, fetchedAt)).toBe(true);
expect(fetchedAt.toISOString()).toBe('2026-09-01T00:00:00.000Z');
},
);
});
@@ -0,0 +1,22 @@
// Freshness never establishes completeness. Immutable ranges are planned by
// their coverage owners; these TTLs apply only to mutable source snapshots.
export const tbankSourceTtlMs = {
membership: 3_600_000,
portfolio: 3_600_000,
quote: 3_600_000,
candleTail: 3_600_000,
} as const;
export function tbankSourceNeedsRefresh(
source: keyof typeof tbankSourceTtlMs,
fetchedAt: Date | null,
now: Date,
force = false,
): boolean {
return (
force ||
!fetchedAt ||
// eslint-disable-next-line security/detect-object-injection -- The key is a member of the closed TbankSource union.
now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
);
}
@@ -1,6 +1,8 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankCrypto } from './tbank.crypto'; import { TbankCrypto } from './tbank.crypto';
@Injectable() @Injectable()
@@ -25,8 +27,9 @@ export class TbankTokenService {
try { try {
return await operation(); return await operation();
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
throw error; throw error;
} }
} }
+15 -1
View File
@@ -1,15 +1,28 @@
// Public cross-module surface. Nest providers are exported by the matching module. // Public cross-module surface. Nest providers are exported by the matching module.
export { InstrumentMetadataService } from './instrument-metadata.service'; export { InstrumentMetadataService } from './instrument-metadata.service';
export { metadataKey } from './instrument-metadata.service';
export type { MetadataIdentity } from './instrument-metadata.service';
export { export {
TbankAccessService, TbankAccessService,
type TbankConnectionContext, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
export { supportedTbankAccounts } from './tbank-account.policy';
export { export {
type TbankBond, type TbankBond,
type TbankCandle, type TbankCandle,
type TbankCoupon,
type TbankFundamentals, type TbankFundamentals,
type TbankLastPrice,
type TbankShare, type TbankShare,
} from './tbank-market.types'; } from './tbank-market.types';
export { TbankMembershipService } from './tbank-membership.service';
export {
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
} from './tbank-source.policy';
export { TbankTokenService } from './tbank-token.service'; export { TbankTokenService } from './tbank-token.service';
export { export {
TbankClient, TbankClient,
@@ -17,6 +30,7 @@ export {
type TbankInstrument, type TbankInstrument,
type TbankMoney, type TbankMoney,
type TbankOperationItem, type TbankOperationItem,
type TbankOperationsByCursorResponse,
type TbankPortfolio, type TbankPortfolio,
type TbankPositions, type TbankPositions,
type TbankTimestamp, type TbankTimestamp,
+245 -64
View File
@@ -1,5 +1,8 @@
import { status } from '@grpc/grpc-js'; import { Metadata, status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { EventEmitter } from 'node:events';
import { TbankOperationState } from './tbank-protocol';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => { describe('TbankClient operations', () => {
@@ -9,7 +12,7 @@ describe('TbankClient operations', () => {
to: { seconds: '2' }, to: { seconds: '2' },
cursor: 'cursor', cursor: 'cursor',
limit: 10, limit: 10,
state: 'OPERATION_STATE_EXECUTED' as const, state: TbankOperationState.Executed,
}; };
it('passes a cursor operations request to OperationsService', async () => { it('passes a cursor operations request to OperationsService', async () => {
@@ -46,11 +49,27 @@ describe('TbankClient operations', () => {
seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)), seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)),
}, },
to: { to: {
seconds: String(Math.floor(Date.parse('2026-09-18T00:00:00Z') / 1000)), seconds: String(Math.floor(Date.parse('2026-09-19T00:00:00Z') / 1000)),
}, },
}); });
}); });
it.each(['getDividends', 'getBondCoupons'] as const)(
'%s converts a leap-day calendar window to a full half-open UTC day',
async (method) => {
const client = Object.create(TbankClient.prototype) as TbankClient;
const call = jest.fn().mockResolvedValue({});
(client as unknown as { call: jest.Mock }).call = call;
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
await client[method]('token', 'uid', '2024-02-29', '2024-02-29');
expect(call).toHaveBeenCalledWith('token', undefined, method, {
instrumentId: 'uid',
from: { seconds: String(Date.parse('2024-02-29T00:00:00Z') / 1000) },
to: { seconds: String(Date.parse('2024-03-01T00:00:00Z') / 1000) },
});
},
);
it('propagates the normalized upstream error', async () => { it('propagates the normalized upstream error', async () => {
const client = Object.create(TbankClient.prototype) as TbankClient; const client = Object.create(TbankClient.prototype) as TbankClient;
const error = new ServiceUnavailableException({ const error = new ServiceUnavailableException({
@@ -96,11 +115,10 @@ describe('TbankClient operations', () => {
ok: true, ok: true,
}); });
const failed = invoke('token', {}, 'getPortfolio', {}); const failed = invoke('token', {}, 'getPortfolio', {});
const rejected = expect(failed).rejects.toEqual( await Promise.all([
expect.any(ServiceUnavailableException), expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)),
); jest.advanceTimersByTimeAsync(1000),
await jest.advanceTimersByTimeAsync(1000); ]);
await rejected;
expect(logger.event).toHaveBeenCalledWith( expect(logger.event).toHaveBeenCalledWith(
'debug', 'debug',
@@ -119,13 +137,77 @@ describe('TbankClient operations', () => {
); );
jest.useRealTimers(); jest.useRealTimers();
}); });
it('distinguishes a gRPC deadline from queue delay without logging upstream secrets', async () => {
const logger = { event: jest.fn() };
const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, {
logger,
queue: { size: 0, run: (operation: () => Promise<unknown>) => operation() },
});
const channel = { getConnectivityState: () => 1 };
const service = {
getChannel: () => channel,
getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: unknown) => void) => {
const call = new EventEmitter();
void Promise.resolve().then(() => callback({ code: status.DEADLINE_EXCEEDED, details: 'Bearer secret' }));
return call;
},
};
const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise<unknown> }).call.bind(client);
jest.useFakeTimers();
try {
const failed = invoke('secret', service, 'getAccounts', {});
await Promise.all([
expect(failed).rejects.toBeInstanceOf(ServiceUnavailableException),
jest.advanceTimersByTimeAsync(1000),
]);
expect(logger.event).toHaveBeenLastCalledWith('error', 'tbank.rpc.failed', expect.objectContaining({
operation: 'getAccounts', grpcCode: status.DEADLINE_EXCEEDED,
queueWaitMs: 0, channelState: 'CONNECTING', headersReceived: false,
}));
const [, , fields] = logger.event.mock.calls.at(-1) as [string, string, { networkMs: unknown }];
expect(typeof fields.networkMs).toBe('number');
expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret');
} finally {
jest.useRealTimers();
}
});
it('records response headers and a validated upstream tracking ID', async () => {
const logger = { event: jest.fn() };
const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, {
logger,
queue: { size: 0, run: (operation: () => Promise<unknown>) => operation() },
});
const headers = new Metadata();
headers.set('x-tracking-id', '123e4567-e89b-12d3-a456-426614174000');
headers.set('authorization', 'Bearer secret');
const service = {
getChannel: () => ({ getConnectivityState: () => 2 }),
getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: null, response: object) => void) => {
const call = new EventEmitter();
void Promise.resolve().then(() => {
call.emit('metadata', headers);
callback(null, { accounts: [] });
});
return call;
},
};
const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise<unknown> }).call.bind(client);
await expect(invoke('secret', service, 'getAccounts', {})).resolves.toEqual({ accounts: [] });
expect(logger.event).toHaveBeenLastCalledWith('debug', 'tbank.rpc.completed', expect.objectContaining({
channelState: 'READY', headersReceived: true,
trackingId: '123e4567-e89b-12d3-a456-426614174000',
}));
expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret');
});
}); });
describe('TbankClient metadata retries', () => { describe('TbankClient metadata retries', () => {
const instrument = { name: 'Облигация', ticker: 'BOND' }; const instrument = { name: 'Облигация', ticker: 'BOND' };
let client: TbankClient; let client: TbankClient;
let requestRpc: jest.Mock; let requestRpc: jest.Mock;
let queue: { size: number; run: jest.Mock }; let queue: { run: jest.Mock; size: number };
let logger: { let logger: {
event: jest.Mock<void, [string, string, Record<string, unknown>]>; event: jest.Mock<void, [string, string, Record<string, unknown>]>;
}; };
@@ -159,46 +241,30 @@ describe('TbankClient metadata retries', () => {
status.DEADLINE_EXCEEDED, status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED, status.RESOURCE_EXHAUSTED,
])( ])(
'recovers metadata after transient gRPC status %s with 2s/4s pauses', 'recovers metadata after one retry for transient gRPC status %s',
async (code) => { async (code) => {
requestRpc requestRpc
.mockRejectedValueOnce({ code })
.mockRejectedValueOnce({ code }) .mockRejectedValueOnce({ code })
.mockResolvedValueOnce({ instrument }); .mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid'); const result = client.getInstrumentByUid('token', 'uid');
await jest.advanceTimersByTimeAsync(1999); await jest.advanceTimersByTimeAsync(999);
expect(requestRpc).toHaveBeenCalledTimes(1); expect(requestRpc).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1); await jest.advanceTimersByTimeAsync(1);
expect(requestRpc).toHaveBeenCalledTimes(2); expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(3999);
expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument); await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(3); expect(queue.run).toHaveBeenCalledTimes(2);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}, },
); );
it('rechecks a full queue before admitting a metadata retry', async () => { it('rejects metadata immediately when the queue is full', async () => {
queue.size = 50; queue.size = 50;
requestRpc.mockResolvedValue({ instrument }); requestRpc.mockResolvedValue({ instrument });
const result = client.getInstrumentByFigi('token', 'figi'); await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(1999);
expect(queue.run).not.toHaveBeenCalled(); expect(queue.run).not.toHaveBeenCalled();
queue.size = 49; expect(jest.getTimerCount()).toBe(0);
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(1);
expect(logger.event).toHaveBeenCalledWith(
'warn',
'tbank.rpc.retry',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
);
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
}); });
it('does not bypass queue admission after an upstream failure', async () => { it('does not bypass queue admission after an upstream failure', async () => {
@@ -206,46 +272,22 @@ describe('TbankClient metadata retries', () => {
.mockRejectedValueOnce({ code: status.UNAVAILABLE }) .mockRejectedValueOnce({ code: status.UNAVAILABLE })
.mockResolvedValueOnce({ instrument }); .mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid'); const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
queue.size = 50; queue.size = 50;
await jest.advanceTimersByTimeAsync(2000); await jest.advanceTimersByTimeAsync(1000);
expect(queue.run).toHaveBeenCalledTimes(1); expect(queue.run).toHaveBeenCalledTimes(1);
queue.size = 0; expect(await result).toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(4000);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(2);
});
it('stops retrying a saturated queue after three admission attempts', async () => {
queue.size = 50;
const result = expect(
client.getInstrumentByUid('token', 'uid'),
).rejects.toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(6000);
await result;
expect(queue.run).not.toHaveBeenCalled();
expect(logger.event).toHaveBeenCalledTimes(3);
expect(logger.event).toHaveBeenLastCalledWith(
'error',
'tbank.rpc.failed',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}); });
it('stops retrying an unavailable upstream after three RPCs', async () => { it('stops retrying an unavailable upstream after two RPCs', async () => {
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE }); requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
const result = expect( await Promise.all([
client.getInstrumentByUid('token', 'uid'), expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
).rejects.toBeInstanceOf(ServiceUnavailableException); jest.advanceTimersByTimeAsync(1000),
]);
await jest.advanceTimersByTimeAsync(6000); expect(queue.run).toHaveBeenCalledTimes(2);
await result;
expect(queue.run).toHaveBeenCalledTimes(3);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}); });
@@ -267,6 +309,7 @@ describe('TbankClient metadata retries', () => {
async (method) => { async (method) => {
requestRpc.mockRejectedValue({ code: status.NOT_FOUND }); requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
await expect(client[method]('token', 'instrument')).resolves.toBeNull(); await expect(client[method]('token', 'instrument')).resolves.toBeNull();
expect(requestRpc).toHaveBeenCalledTimes(1); expect(requestRpc).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
@@ -304,3 +347,141 @@ describe('TbankClient metadata retries', () => {
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}); });
}); });
describe('TbankClient share catalog', () => {
const token = 'token';
const catalogSize = 40;
let client: TbankClient;
let call: jest.Mock;
let logger: {
event: jest.Mock<void, [string, string, Record<string, number | string>]>;
};
beforeEach(() => {
call = jest.fn();
logger = {
event: jest.fn<void, [string, string, Record<string, number | string>]>(),
};
client = Object.assign(
Object.create(TbankClient.prototype) as TbankClient,
{ call, logger },
);
});
it('fetches the full share catalog through Shares with INSTRUMENT_STATUS_ALL', async () => {
const instruments = [{ ticker: 'AFLT', uid: 'uid-aflt' }];
call.mockResolvedValue({ instruments });
await expect(client.getAllShares(token)).resolves.toEqual(instruments);
expect(call).toHaveBeenCalledWith('token', undefined, 'shares', {
instrumentStatus: 'INSTRUMENT_STATUS_ALL',
});
});
it('returns the whole catalog including a fitting instrument beyond the first 30 text matches', async () => {
const catalog = Array.from({ length: catalogSize }, (_, index) => {
const fitting = index === catalogSize - 1;
return {
apiTradeAvailableFlag: fitting,
buyAvailableFlag: fitting,
ticker: fitting ? 'FIT' : `T${index}`,
uid: fitting ? 'uid-fit' : `uid-${index}`,
};
});
call.mockResolvedValue({ instruments: catalog });
const shares = await client.getAllShares(token);
expect(shares).toEqual(catalog);
expect(shares).toHaveLength(catalogSize);
expect(shares).toContainEqual({
apiTradeAvailableFlag: true,
buyAvailableFlag: true,
ticker: 'FIT',
uid: 'uid-fit',
});
});
it('rejects when the source refuses instead of returning an empty success', async () => {
const error = new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
call.mockRejectedValue(error);
await expect(client.getAllShares(token)).rejects.toBe(error);
});
it('rejects when the response carries no instrument list', async () => {
call.mockResolvedValue({});
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('rejects an empty instrument list with the public unavailable code', async () => {
call.mockResolvedValue({ instruments: [] });
const failure = await client
.getAllShares(token)
.catch((error: unknown) => error);
expect(failure).toBeInstanceOf(ServiceUnavailableException);
expect((failure as ServiceUnavailableException).getResponse()).toEqual({
code: 'TBANK_UNAVAILABLE',
});
});
it('rejects a non-list instrument payload as uninterpretable', async () => {
call.mockResolvedValue({ instruments: 'corrupted' });
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('rejects an uninterpretable entry without instrument identity', async () => {
call.mockResolvedValue({
instruments: [
{ ticker: 'FIT', uid: 'uid-fit' },
{ ticker: 'BROKEN' },
],
});
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('registers asOf and source instrument count on a successful catalog fetch', async () => {
call.mockResolvedValue({
instruments: [{ uid: 'uid-a' }, { uid: 'uid-b' }],
});
await client.getAllShares(token);
expect(logger.event).toHaveBeenCalledWith(
'info',
'tbank.shares.catalog.fetched',
expect.objectContaining({ count: 2, operation: 'shares' }),
);
expect(logger.event.mock.calls[0][2].asOf).toEqual(expect.any(String));
});
it('logs a distinguishable failure without secrets when the catalog is unusable', async () => {
call.mockResolvedValue({ instruments: [] });
await expect(client.getAllShares('secret-token')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
expect(logger.event).toHaveBeenCalledWith(
'error',
'tbank.shares.catalog.unusable',
expect.objectContaining({ operation: 'shares' }),
);
expect(JSON.stringify(logger.event.mock.calls)).not.toContain(
'secret-token',
);
});
});
+190 -110
View File
@@ -1,8 +1,8 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { import {
ChannelCredentials, ChannelCredentials,
Client, Client,
ClientUnaryCall, ClientUnaryCall,
connectivityState,
loadPackageDefinition, loadPackageDefinition,
Metadata, Metadata,
ServiceError, ServiceError,
@@ -15,10 +15,8 @@ import {
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { existsSync, readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { Environment } from '../../config/env.validation';
import type { import type {
TbankBond, TbankBond,
@@ -29,11 +27,29 @@ import type {
TbankShare, TbankShare,
} from './tbank-market.types'; } from './tbank-market.types';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { Environment } from '../../config/env.validation';
import { TbankErrorCode } from './tbank-error-code';
import {
InstrumentIdType,
TbankAccountStatus,
TbankCandleInterval,
TbankInstrumentStatus,
TbankInstrumentType,
TbankOperationState,
} from './tbank-protocol';
const INITIAL_REQUEST_COUNT = 0;
const MIN_RATE_DELAY_MS = 1;
const FIRST_ATTEMPT = 1;
const ATTEMPT_INCREMENT = 1;
const NO_RPC_ATTEMPTS = 0;
const NEXT_DAY_OFFSET = 1;
const RATE_WINDOW_MS = 1_000; const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2; const RPC_MAX_ATTEMPTS = 2;
const MAX_QUEUE_SIZE = 50; const MAX_QUEUE_SIZE = 50;
const METADATA_RETRY_DELAY_MS = 2_000;
const RPC_RETRY_DELAY_MS = 1_000; const RPC_RETRY_DELAY_MS = 1_000;
const requestDeadlineMs = 15_000; const requestDeadlineMs = 15_000;
@@ -42,74 +58,85 @@ type Unary<Request, Response> = (
request: Request, request: Request,
metadata: Metadata, metadata: Metadata,
options: { deadline: Date }, options: { deadline: Date },
callback: (error: ServiceError | null, response: Response) => void, callback: (error: null | ServiceError, response: Response) => void,
) => ClientUnaryCall; ) => ClientUnaryCall;
type TbankClientService = Client & Record<string, Unary<never, never>>; type TbankClientService = Client & Record<string, Unary<never, never>>;
export type TbankDecimal = { units?: string | number; nano?: string | number }; type RpcDiagnostics = {
channelState?: string;
headersReceived?: boolean;
networkMs?: number;
queueWaitMs?: number;
trackingId?: string;
};
export type TbankDecimal = { nano?: number | string; units?: number | string };
export type TbankMoney = TbankDecimal & { currency?: string }; export type TbankMoney = TbankDecimal & { currency?: string };
export type TbankAccount = { export type TbankAccount = {
id: string; id: string;
type: string;
name: string; name: string;
openedDate?: { seconds?: string | number }; openedDate?: { seconds?: number | string };
type: string;
}; };
export type TbankPortfolio = { export type TbankPortfolio = {
positions?: { positions?: {
figi?: string;
instrumentUid?: string;
instrumentType?: string;
ticker?: string;
quantity?: TbankDecimal;
currentPrice?: TbankMoney;
averagePositionPrice?: TbankMoney; averagePositionPrice?: TbankMoney;
currentPrice?: TbankMoney;
figi?: string;
instrumentType?: string;
instrumentUid?: string;
quantity?: TbankDecimal;
ticker?: string;
}[]; }[];
}; };
export type TbankPositions = { money?: TbankMoney[] }; export type TbankPositions = { money?: TbankMoney[] };
export type TbankInstrument = { export type TbankInstrument = {
ticker?: string; brand?: { logoName?: null | string };
name?: string; figi?: string;
instrumentType?: string;
isin?: string; isin?: string;
brand?: { logoName?: string | null }; name?: string;
ticker?: string;
uid?: string;
}; };
export type TbankDividend = { export type TbankDividend = {
dividendNet?: TbankMoney; dividendNet?: TbankMoney;
recordDate?: TbankTimestamp;
paymentDate?: TbankTimestamp;
lastBuyDate?: TbankTimestamp;
dividendType?: string; dividendType?: string;
lastBuyDate?: TbankTimestamp;
paymentDate?: TbankTimestamp;
recordDate?: TbankTimestamp;
}; };
export type TbankTimestamp = { seconds?: string | number; nanos?: number }; export type TbankTimestamp = { nanos?: number; seconds?: number | string };
export type TbankOperationItem = { export type TbankOperationItem = {
accruedInt?: TbankMoney;
commission?: TbankMoney;
cursor?: string; cursor?: string;
id?: string;
name?: string;
date?: TbankTimestamp; date?: TbankTimestamp;
type?: string;
description?: string; description?: string;
figi?: string; figi?: string;
instrumentUid?: string; id?: string;
instrumentType?: string; instrumentType?: string;
ticker?: string; instrumentUid?: string;
name?: string;
payment?: TbankMoney; payment?: TbankMoney;
price?: TbankMoney; price?: TbankMoney;
commission?: TbankMoney; quantity?: number | string;
accruedInt?: TbankMoney; quantityDone?: number | string;
quantity?: string | number; state?: string;
quantityDone?: string | number; ticker?: string;
type?: string;
}; };
export type TbankOperationsByCursorRequest = { export type TbankOperationsByCursorRequest = {
accountId: string; accountId: string;
instrumentId?: string;
from?: TbankTimestamp;
to?: TbankTimestamp;
cursor?: string; cursor?: string;
from?: TbankTimestamp;
instrumentId?: string;
limit?: number; limit?: number;
state: 'OPERATION_STATE_EXECUTED'; state: TbankOperationState.Executed;
to?: TbankTimestamp;
}; };
export type TbankOperationsByCursorResponse = { export type TbankOperationsByCursorResponse = {
hasNext?: boolean; hasNext?: boolean;
nextCursor?: string;
items?: TbankOperationItem[]; items?: TbankOperationItem[];
nextCursor?: string;
}; };
type TbankNamespace = { type TbankNamespace = {
tinkoff: { tinkoff: {
@@ -119,7 +146,7 @@ type TbankNamespace = {
class RateLimiter { class RateLimiter {
private readonly waiting: (() => void)[] = []; private readonly waiting: (() => void)[] = [];
private starts = 0; private starts = INITIAL_REQUEST_COUNT;
private windowStartedAt = Date.now(); private windowStartedAt = Date.now();
constructor(private readonly perSecond: number) {} constructor(private readonly perSecond: number) {}
@@ -144,16 +171,16 @@ class RateLimiter {
const now = Date.now(); const now = Date.now();
if (now - this.windowStartedAt >= RATE_WINDOW_MS) { if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
this.windowStartedAt = now; this.windowStartedAt = now;
this.starts = 0; this.starts = INITIAL_REQUEST_COUNT;
} }
while (this.starts < this.perSecond && this.waiting.length) { while (this.starts < this.perSecond && this.waiting.length) {
this.starts += 1; this.starts++;
this.waiting.shift()?.(); this.waiting.shift()?.();
} }
if (this.waiting.length && this.starts === this.perSecond) { if (this.waiting.length && this.starts === this.perSecond) {
setTimeout( setTimeout(
() => this.drain(), () => this.drain(),
Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)), Math.max(MIN_RATE_DELAY_MS, RATE_WINDOW_MS - (now - this.windowStartedAt)),
); );
} }
} }
@@ -172,13 +199,7 @@ export class TbankClient {
config: ConfigService<Environment, true>, config: ConfigService<Environment, true>,
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
) { ) {
const sourceProtoRoot = join( const protoRoot = join(__dirname, 'proto/contracts');
process.cwd(),
'src/integrations/tbank/proto/contracts',
);
const protoRoot = existsSync(sourceProtoRoot)
? sourceProtoRoot
: join(process.cwd(), 'dist/integrations/tbank/proto/contracts');
const definition = loadSync( const definition = loadSync(
[ [
'users.proto', 'users.proto',
@@ -200,11 +221,13 @@ export class TbankClient {
).tinkoff.public.invest.api.contract.v1; ).tinkoff.public.invest.api.contract.v1;
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH'); const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
const credentials = caPath const credentials = caPath
// eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request.
? ChannelCredentials.createSsl(readFileSync(caPath)) ? ChannelCredentials.createSsl(readFileSync(caPath))
: ChannelCredentials.createSsl(); : ChannelCredentials.createSsl();
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT'); const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
const create = (name: string) => const create = (name: string) =>
new ( new (
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
services[name] as new ( services[name] as new (
address: string, address: string,
credentials: ChannelCredentials, credentials: ChannelCredentials,
@@ -239,8 +262,8 @@ export class TbankClient {
this.logger.info('tbank.client.configured'); this.logger.info('tbank.client.configured');
} }
async validateToken(token: string): Promise<void> { validateToken(token: string): Promise<TbankAccount[]> {
await this.getOpenAccounts(token); return this.getOpenAccounts(token);
} }
getOpenAccounts(token: string): Promise<TbankAccount[]> { getOpenAccounts(token: string): Promise<TbankAccount[]> {
@@ -248,7 +271,7 @@ export class TbankClient {
token, token,
this.users, this.users,
'getAccounts', 'getAccounts',
{ status: 'ACCOUNT_STATUS_OPEN' }, { status: TbankAccountStatus.Open },
).then((response) => response.accounts ?? []); ).then((response) => response.accounts ?? []);
} }
@@ -270,15 +293,15 @@ export class TbankClient {
getInstrumentByFigi( getInstrumentByFigi(
token: string, token: string,
figi: string, figi: string,
): Promise<TbankInstrument | null> { ): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi); return this.getInstrument(token, InstrumentIdType.Figi, figi);
} }
getInstrumentByUid( getInstrumentByUid(
token: string, token: string,
instrumentUid: string, instrumentUid: string,
): Promise<TbankInstrument | null> { ): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid); return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
} }
getDividends( getDividends(
@@ -294,7 +317,7 @@ export class TbankClient {
{ {
instrumentId, instrumentId,
from: { seconds: String(utcSeconds(from)) }, from: { seconds: String(utcSeconds(from)) },
to: { seconds: String(utcSeconds(to)) }, to: { seconds: String(utcSeconds(to, true)) },
}, },
).then((response) => response.dividends ?? []); ).then((response) => response.dividends ?? []);
} }
@@ -306,11 +329,40 @@ export class TbankClient {
'findInstrument', 'findInstrument',
{ {
query, query,
instrumentKind: 'INSTRUMENT_TYPE_SHARE', instrumentKind: TbankInstrumentType.Share,
}, },
).then((response) => response.instruments ?? []); ).then((response) => response.instruments ?? []);
} }
async getAllShares(token: string): Promise<TbankShare[]> {
const response = await this.call<{ instruments?: TbankShare[] }>(
token,
this.instruments,
'shares',
{ instrumentStatus: TbankInstrumentStatus.All },
);
const { instruments } = response;
if (
!Array.isArray(instruments) ||
!instruments.length ||
instruments.some((item) => !item?.uid)
) {
this.logger.event('error', 'tbank.shares.catalog.unusable', {
code: TbankErrorCode.Unavailable,
operation: 'shares',
});
throw new ServiceUnavailableException({
code: TbankErrorCode.Unavailable,
});
}
this.logger.event('info', 'tbank.shares.catalog.fetched', {
asOf: new Date().toISOString(),
count: instruments.length,
operation: 'shares',
});
return instruments;
}
findBonds(token: string, query: string): Promise<TbankBond[]> { findBonds(token: string, query: string): Promise<TbankBond[]> {
return this.call<{ instruments?: TbankBond[] }>( return this.call<{ instruments?: TbankBond[] }>(
token, token,
@@ -318,21 +370,23 @@ export class TbankClient {
'findInstrument', 'findInstrument',
{ {
query, query,
instrumentKind: 'INSTRUMENT_TYPE_BOND', instrumentKind: TbankInstrumentType.Bond,
}, },
).then((response) => response.instruments ?? []); ).then((response) => response.instruments ?? []);
} }
getBond(token: string, uid: string): Promise<TbankBond | null> {
getBond(token: string, uid: string): Promise<null | TbankBond> {
return this.call<{ instrument?: TbankBond }>( return this.call<{ instrument?: TbankBond }>(
token, token,
this.sparseInstruments, this.sparseInstruments,
'bondBy', 'bondBy',
{ {
idType: 'INSTRUMENT_ID_TYPE_UID', idType: InstrumentIdType.Uid,
id: uid, id: uid,
}, },
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
} }
getBondCoupons( getBondCoupons(
token: string, token: string,
uid: string, uid: string,
@@ -346,24 +400,24 @@ export class TbankClient {
{ {
instrumentId: uid, instrumentId: uid,
from: { seconds: String(utcSeconds(from)) }, from: { seconds: String(utcSeconds(from)) },
to: { seconds: String(utcSeconds(to)) }, to: { seconds: String(utcSeconds(to, true)) },
}, },
).then((response) => response.events ?? []); ).then((response) => response.events ?? []);
} }
getShare(token: string, uid: string): Promise<TbankShare | null> { getShare(token: string, uid: string): Promise<null | TbankShare> {
return this.call<{ instrument?: TbankShare }>( return this.call<{ instrument?: TbankShare }>(
token, token,
this.instruments, this.instruments,
'shareBy', 'shareBy',
{ {
idType: 'INSTRUMENT_ID_TYPE_UID', idType: InstrumentIdType.Uid,
id: uid, id: uid,
}, },
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
} }
getAssetDescription(token: string, assetUid: string): Promise<string | null> { getAssetDescription(token: string, assetUid: string): Promise<null | string> {
return this.call<{ asset?: { description?: string } }>( return this.call<{ asset?: { description?: string } }>(
token, token,
this.instruments, this.instruments,
@@ -377,7 +431,7 @@ export class TbankClient {
getFundamentals( getFundamentals(
token: string, token: string,
assetUid: string, assetUid: string,
): Promise<TbankFundamentals | null> { ): Promise<null | TbankFundamentals> {
return this.call<{ fundamentals?: TbankFundamentals[] }>( return this.call<{ fundamentals?: TbankFundamentals[] }>(
token, token,
this.sparseInstruments, this.sparseInstruments,
@@ -401,13 +455,7 @@ export class TbankClient {
).then((response) => response.lastPrices ?? []); ).then((response) => response.lastPrices ?? []);
} }
getLastPrice(token: string, uid: string): Promise<TbankLastPrice | null> { getTradingStatus(token: string, uid: string): Promise<null | string> {
return this.getLastPrices(token, [uid]).then(
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
);
}
getTradingStatus(token: string, uid: string): Promise<string | null> {
return this.call<{ tradingStatus?: string }>( return this.call<{ tradingStatus?: string }>(
token, token,
this.marketData, this.marketData,
@@ -436,7 +484,7 @@ export class TbankClient {
to: { to: {
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)), seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
}, },
interval: 'CANDLE_INTERVAL_DAY', interval: TbankCandleInterval.Day,
limit: 2400, limit: 2400,
}, },
).then((response) => response.candles ?? []); ).then((response) => response.candles ?? []);
@@ -444,9 +492,9 @@ export class TbankClient {
private async getInstrument( private async getInstrument(
token: string, token: string,
idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI', idType: InstrumentIdType,
id: string, id: string,
): Promise<TbankInstrument | null> { ): Promise<null | TbankInstrument> {
try { try {
const response = await this.call<{ instrument?: TbankInstrument }>( const response = await this.call<{ instrument?: TbankInstrument }>(
token, token,
@@ -468,18 +516,26 @@ export class TbankClient {
request: object, request: object,
): Promise<Response> { ): Promise<Response> {
const metadataCall = method === 'getInstrumentBy'; const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS; for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
const startedAt = performance.now(); const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE; const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
const diagnostics: RpcDiagnostics = {};
try { try {
if (queueFull) if (queueFull) {
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
const response = await this.queue.run(() => }
this.request<Response>(token, client, method, request), const response = await this.queue.run(() => {
); const sentAt = performance.now();
diagnostics.queueWaitMs = Math.round(sentAt - startedAt);
return this.request<Response>(
token, client, method, request, diagnostics,
).finally(() => {
diagnostics.networkMs = Math.round(performance.now() - sentAt);
});
});
this.logger.event('debug', 'tbank.rpc.completed', { this.logger.event('debug', 'tbank.rpc.completed', {
operation: method, operation: method,
...diagnostics,
attempts, attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
@@ -487,52 +543,47 @@ export class TbankClient {
} catch (error) { } catch (error) {
const grpcCode = (error as ServiceError).code; const grpcCode = (error as ServiceError).code;
const failureContext = queueFull const failureContext = queueFull
? { code: 'TBANK_UNAVAILABLE' } ? { code: TbankErrorCode.Unavailable }
: { grpcCode }; : { grpcCode };
const retryable = queueFull const retryable = !queueFull && [
? metadataCall status.DEADLINE_EXCEEDED,
: [ status.RESOURCE_EXHAUSTED,
status.UNAVAILABLE, status.UNAVAILABLE,
status.DEADLINE_EXCEEDED, ].includes(grpcCode);
status.RESOURCE_EXHAUSTED, if (retryable && attempts < RPC_MAX_ATTEMPTS) {
].includes(grpcCode);
if (retryable && attempts < maxAttempts) {
this.logger.event('warn', 'tbank.rpc.retry', { this.logger.event('warn', 'tbank.rpc.retry', {
operation: method, operation: method,
...diagnostics,
...failureContext, ...failureContext,
attempts, attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
await new Promise((resolve) => await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
setTimeout(
resolve,
metadataCall
? attempts * METADATA_RETRY_DELAY_MS
: RPC_RETRY_DELAY_MS,
),
);
continue; continue;
} }
this.logger.event('error', 'tbank.rpc.failed', { this.logger.event('error', 'tbank.rpc.failed', {
operation: method, operation: method,
...diagnostics,
...failureContext, ...failureContext,
attempts: queueFull && !metadataCall ? 0 : attempts, attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
if ( if (
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode) [status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
) ) {
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary. // eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
throw new Error('INVALID_TBANK_TOKEN'); throw new Error(TbankErrorCode.InvalidToken);
}
if ( if (
grpcCode === status.NOT_FOUND && grpcCode === status.NOT_FOUND &&
(method === 'shareBy' || metadataCall) (method === 'shareBy' || metadataCall)
) ) {
throw new NotFoundException(); throw new NotFoundException();
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); }
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
} }
} }
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
} }
private request<Response>( private request<Response>(
@@ -540,20 +591,49 @@ export class TbankClient {
client: TbankClientService, client: TbankClientService,
method: string, method: string,
request: object, request: object,
diagnostics?: RpcDiagnostics,
): Promise<Response> { ): Promise<Response> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const metadata = new Metadata(); const metadata = new Metadata();
metadata.set('Authorization', `Bearer ${token}`); metadata.set('Authorization', `Bearer ${token}`);
client[method]( // A channel state is a snapshot, not proof that this particular call reached the server.
const channel = diagnostics && client.getChannel();
if (diagnostics) diagnostics.headersReceived = false;
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
const call = client[method](
request as never, request as never,
metadata, metadata,
{ deadline: new Date(Date.now() + requestDeadlineMs) }, { deadline: new Date(Date.now() + requestDeadlineMs) },
(error, response) => (error ? reject(error) : resolve(response)), (error, response) => {
if (diagnostics && channel) {
diagnostics.channelState = connectivityState[
channel.getConnectivityState(false)
];
}
if (error) reject(error);
else resolve(response);
},
); );
if (diagnostics) {
call.on('metadata', (headers: Metadata) => {
diagnostics.headersReceived = true;
const trackingId = headers.get('x-tracking-id')[0];
// Only a UUID may enter logs; never forward arbitrary upstream metadata.
if (
typeof trackingId === 'string' &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trackingId)
) {
diagnostics.trackingId = trackingId;
}
});
}
}); });
} }
} }
function utcSeconds(date: string): number { // Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)).
return Math.floor(Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_SECOND); function utcSeconds(date: string, nextDay = false): number {
const instant = new Date(`${date}T00:00:00Z`);
if (nextDay) instant.setUTCDate(instant.getUTCDate() + NEXT_DAY_OFFSET);
return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND);
} }
+7 -4
View File
@@ -1,5 +1,7 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const VERSION_SIZE_BYTES = 1;
const KEY_SIZE_BYTES = 32; const KEY_SIZE_BYTES = 32;
const VERSION = 1; const VERSION = 1;
@@ -11,8 +13,9 @@ export class TbankCrypto {
static fromBase64(key: string): TbankCrypto { static fromBase64(key: string): TbankCrypto {
const decoded = Buffer.from(key, 'base64'); const decoded = Buffer.from(key, 'base64');
if (decoded.length !== KEY_SIZE_BYTES) if (decoded.length !== KEY_SIZE_BYTES) {
throw new Error('Invalid token encryption key'); throw new Error('Invalid token encryption key');
}
return new TbankCrypto(decoded); return new TbankCrypto(decoded);
} }
@@ -33,15 +36,15 @@ export class TbankCrypto {
decrypt(payload: string): string { decrypt(payload: string): string {
const bytes = Buffer.from(payload, 'base64'); const bytes = Buffer.from(payload, 'base64');
if (bytes[0] !== VERSION || bytes.length < 1 + NONCE_SIZE + TAG_SIZE) { if (bytes[0] !== VERSION || bytes.length < VERSION_SIZE_BYTES + NONCE_SIZE + TAG_SIZE) {
throw new Error('Invalid encrypted token'); throw new Error('Invalid encrypted token');
} }
const nonce = bytes.subarray(1, 1 + NONCE_SIZE); const nonce = bytes.subarray(VERSION_SIZE_BYTES, VERSION_SIZE_BYTES + NONCE_SIZE);
const tag = bytes.subarray(-TAG_SIZE); const tag = bytes.subarray(-TAG_SIZE);
const decipher = createDecipheriv('aes-256-gcm', this.key, nonce); const decipher = createDecipheriv('aes-256-gcm', this.key, nonce);
decipher.setAuthTag(tag); decipher.setAuthTag(tag);
return Buffer.concat([ return Buffer.concat([
decipher.update(bytes.subarray(1 + NONCE_SIZE, -TAG_SIZE)), decipher.update(bytes.subarray(VERSION_SIZE_BYTES + NONCE_SIZE, -TAG_SIZE)),
decipher.final(), decipher.final(),
]).toString('utf8'); ]).toString('utf8');
} }
+6
View File
@@ -1,6 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { InstrumentMetadataRepository } from './instrument-metadata.repository';
import { InstrumentMetadataService } from './instrument-metadata.service'; import { InstrumentMetadataService } from './instrument-metadata.service';
import { TbankAccessService } from './tbank-access.service'; import { TbankAccessService } from './tbank-access.service';
import { TbankMembershipService } from './tbank-membership.service';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
@@ -9,12 +12,15 @@ import { TbankClient } from './tbank.client';
TbankClient, TbankClient,
TbankTokenService, TbankTokenService,
TbankAccessService, TbankAccessService,
TbankMembershipService,
InstrumentMetadataService, InstrumentMetadataService,
InstrumentMetadataRepository,
], ],
exports: [ exports: [
TbankClient, TbankClient,
TbankTokenService, TbankTokenService,
TbankAccessService, TbankAccessService,
TbankMembershipService,
InstrumentMetadataService, InstrumentMetadataService,
], ],
}) })
+22 -17
View File
@@ -1,15 +1,20 @@
import { INestApplication, ValidationPipe } from '@nestjs/common'; import {
import { NestFactory } from '@nestjs/core'; BadRequestException,
import cookieParser from 'cookie-parser'; INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { NestFactory } from '@nestjs/core';
import { SwaggerModule } from '@nestjs/swagger';
import cookieParser from 'cookie-parser';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
import { StructuredLogger } from './common/logging/structured-logger.service'; import { StructuredLogger } from './common/logging/structured-logger.service';
import { Environment } from './config/env.validation'; import { Environment } from './config/env.validation';
import { shouldExposeSwagger } from './config/swagger.config'; import { shouldExposeSwagger } from './config/swagger.config';
import { API_PREFIX, createOpenApiDocument } from './openapi';
export function configureApp(app: INestApplication): void { export function configureApp(app: INestApplication): void {
const config = app.get<ConfigService<Environment, true>>(ConfigService); const config = app.get<ConfigService<Environment, true>>(ConfigService);
@@ -21,12 +26,18 @@ export function configureApp(app: INestApplication): void {
credentials: true, credentials: true,
exposedHeaders: ['X-Request-ID'], exposedHeaders: ['X-Request-ID'],
}); });
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix(API_PREFIX);
app.use(cookieParser()); app.use(cookieParser());
const requestLogging = app.get(RequestLoggingMiddleware); app.useGlobalPipes(
app.use(requestLogging.use.bind(requestLogging)); new ValidationPipe({
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); forbidUnknownValues: true,
app.useGlobalFilters(new HttpExceptionFilter()); transform: true,
whitelist: true,
validationError: { target: false, value: false },
exceptionFactory: (message) => new BadRequestException({ message }),
}),
);
app.useGlobalFilters(new HttpExceptionFilter(app.get(StructuredLogger)));
app.enableShutdownHooks(); app.enableShutdownHooks();
} }
@@ -36,13 +47,7 @@ async function bootstrap() {
const config = app.get<ConfigService<Environment, true>>(ConfigService); const config = app.get<ConfigService<Environment, true>>(ConfigService);
if (shouldExposeSwagger(process.env['NODE_ENV'])) { if (shouldExposeSwagger(process.env['NODE_ENV'])) {
const document = SwaggerModule.createDocument( const document = createOpenApiDocument(app);
app,
new DocumentBuilder()
.setTitle('WealthLedger API')
.setVersion('v1')
.build(),
);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);
} }
@@ -0,0 +1,4 @@
export enum AuthErrorCode {
EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED',
InvalidCredentials = 'INVALID_CREDENTIALS',
}
@@ -114,8 +114,8 @@ describe('AuthService', () => {
where, where,
data, data,
}: { }: {
where: { id?: string; refreshTokenHash?: string };
data: { refreshTokenHash: string }; data: { refreshTokenHash: string };
where: { id?: string; refreshTokenHash?: string };
}) => { }) => {
if (where.id === session.id && where.refreshTokenHash === currentHash) { if (where.id === session.id && where.refreshTokenHash === currentHash) {
currentHash = data.refreshTokenHash; currentHash = data.refreshTokenHash;
+58 -48
View File
@@ -6,16 +6,23 @@ import {
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../../../generated/prisma/client'; import { Session, User } from '../../../../generated/prisma/client';
import { HttpErrorCode } from '../../../common/error-code';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types'; import { AuthErrorCode } from './auth-error-code';
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
const EXPECTED_ROTATED_SESSIONS = 1;
const PASSWORD_HASH_ROUNDS = 12; const PASSWORD_HASH_ROUNDS = 12;
const REFRESH_TTL_MS = 604_800_000; export const REFRESH_TTL_MS = 604_800_000;
const ACCESS_TTL = '15m'; const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d'; const REFRESH_TTL = '7d';
const DUMMY_PASSWORD_HASH = const DUMMY_PASSWORD_HASH =
// eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Public dummy bcrypt hash preserves login timing for nonexistent users; it is not a credential.
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6'; '$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
@Injectable() @Injectable()
@@ -28,26 +35,27 @@ export class AuthService {
async register( async register(
email: string, email: string,
password: string, password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<AuthenticationResult> {
const normalizedEmail = this.normalizeEmail(email);
const existing = await this.prisma.user.findUnique({ const existing = await this.prisma.user.findUnique({
where: { email: normalizedEmail }, where: { email },
}); });
if (existing) if (existing) {
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
let user: User; let user: User;
try { try {
user = await this.prisma.user.create({ user = await this.prisma.user.create({
data: { data: {
id: randomUUID(), id: randomUUID(),
email: normalizedEmail, email,
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
}, },
}); });
} catch (error) { } catch (error) {
if (isUniqueConstraintError(error)) if (isUniqueConstraintError(error)) {
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
throw error; throw error;
} }
return this.createSession(user); return this.createSession(user);
@@ -56,32 +64,34 @@ export class AuthService {
async login( async login(
email: string, email: string,
password: string, password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<AuthenticationResult> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { email: this.normalizeEmail(email) }, where: { email },
}); });
const valid = await bcrypt.compare( const valid = await bcrypt.compare(
password, password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH, user?.passwordHash ?? DUMMY_PASSWORD_HASH,
); );
if (!user || !valid) if (!user || !valid) {
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
}
return this.createSession(user); return this.createSession(user);
} }
async refresh( async refresh(
token: string, token: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<AuthenticationResult> {
let payload: RefreshPayload; let payload: RefreshPayload;
try { try {
payload = await this.jwt.verifyAsync<RefreshPayload>(token, { payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'], algorithms: ['HS256'],
}); });
} catch { } catch {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (payload.type !== 'refresh') {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
if (payload.type !== 'refresh')
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
const session = await this.prisma.session.findUnique({ const session = await this.prisma.session.findUnique({
where: { id: payload.sid }, where: { id: payload.sid },
include: { user: true }, include: { user: true },
@@ -92,37 +102,40 @@ export class AuthService {
session.revokedAt || session.revokedAt ||
session.expiresAt <= new Date() session.expiresAt <= new Date()
) { ) {
if (session && !session.revokedAt) if (session && !session.revokedAt) {
await this.prisma.session.delete({ where: { id: session.id } }); await this.prisma.session.delete({ where: { id: session.id } });
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); }
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (session.refreshTokenHash !== this.hash(token)) {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
if (session.refreshTokenHash !== this.hash(token))
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return this.rotateSession(session, session.refreshTokenHash); return this.rotateSession(session, session.refreshTokenHash);
} }
async logout(token?: string): Promise<void> { async logout(token?: string): Promise<void> {
if (!token) return; if (!token) return;
let payload: RefreshPayload;
try { try {
const payload = await this.jwt.verifyAsync<RefreshPayload>(token, { payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'], algorithms: ['HS256'],
}); });
if (payload.type !== 'refresh') return;
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
});
if (
session &&
!session.revokedAt &&
session.refreshTokenHash === this.hash(token)
) {
await this.prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
}
} catch { } catch {
// Logout is intentionally idempotent. return;
}
if (payload.type !== 'refresh') return;
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
});
if (
session &&
!session.revokedAt &&
session.refreshTokenHash === this.hash(token)
) {
await this.prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
} }
} }
@@ -132,13 +145,13 @@ export class AuthService {
async userById(id: string): Promise<User> { async userById(id: string): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return user; return user;
} }
private async createSession( private async createSession(
user: User, user: User,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<AuthenticationResult> {
const session = await this.prisma.session.create({ const session = await this.prisma.session.create({
data: { data: {
id: randomUUID(), id: randomUUID(),
@@ -153,7 +166,7 @@ export class AuthService {
private async rotateSession( private async rotateSession(
session: Session & { user: User }, session: Session & { user: User },
expectedRefreshTokenHash?: string, expectedRefreshTokenHash?: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<AuthenticationResult> {
const refreshToken = await this.jwt.signAsync( const refreshToken = await this.jwt.signAsync(
{ {
sub: session.userId, sub: session.userId,
@@ -172,8 +185,9 @@ export class AuthService {
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
data, data,
}); });
if (updated.count !== 1) if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
} else { } else {
await this.prisma.session.update({ where: { id: session.id }, data }); await this.prisma.session.update({ where: { id: session.id }, data });
} }
@@ -187,10 +201,6 @@ export class AuthService {
}; };
} }
private normalizeEmail(email: string): string {
return email;
}
private hash(token: string): string { private hash(token: string): string {
return createHash('sha256').update(token).digest('hex'); return createHash('sha256').update(token).digest('hex');
} }
@@ -201,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean {
typeof error === 'object' && typeof error === 'object' &&
error !== null && error !== null &&
'code' in error && 'code' in error &&
(error as { code?: unknown }).code === 'P2002' isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
); );
} }
+11 -5
View File
@@ -1,17 +1,23 @@
export interface AuthUser { export interface AuthUser {
id: string;
email: string; email: string;
id: string;
} }
export type AuthenticationResult = {
accessToken: string;
refreshToken: string;
user: AuthUser;
};
export interface AccessPayload { export interface AccessPayload {
sub: string;
sid: string; sid: string;
sub: string;
type: 'access'; type: 'access';
} }
export interface RefreshPayload { export interface RefreshPayload {
sub: string;
sid: string;
type: 'refresh';
jti: string; jti: string;
sid: string;
sub: string;
type: 'refresh';
} }
+3 -2
View File
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { AuthController } from './presentation/auth.controller';
import { AuthService } from './application/auth.service'; import { AuthService } from './application/auth.service';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
import { JwtStrategy } from './infrastructure/jwt.strategy'; import { JwtStrategy } from './infrastructure/jwt.strategy';
import { AuthController } from './presentation/auth.controller';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
@Module({ @Module({
imports: [ imports: [
@@ -2,6 +2,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { HttpErrorCode } from '../../../common/error-code';
import { Environment } from '../../../config/env.validation'; import { Environment } from '../../../config/env.validation';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@@ -17,8 +19,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
} }
validate(payload: AccessPayload): AccessPayload { validate(payload: AccessPayload): AccessPayload {
if (payload.type !== 'access') if (payload.type !== 'access') {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
return payload; return payload;
} }
} }
@@ -1,21 +0,0 @@
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
jest.mock('../application/auth.service', () => ({
AuthService: class AuthService {},
}));
import { AuthController } from './auth.controller';
describe('AuthController response statuses', () => {
it.each(['register', 'login', 'refresh'] as const)(
'%s explicitly returns HTTP 200',
(handler) => {
// Nest stores @HttpCode metadata on the unbound handler function.
const handlerTarget = Object.getOwnPropertyDescriptor(
AuthController.prototype,
handler,
)?.value as object;
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handlerTarget)).toBe(200);
},
);
});
@@ -1,3 +1,5 @@
import type { Request, Response } from 'express';
import { import {
Body, Body,
Controller, Controller,
@@ -17,11 +19,12 @@ import {
ApiProperty, ApiProperty,
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { CredentialsDto } from './auth.dto'; import { HttpErrorCode } from '../../../common/error-code';
import { AuthService } from '../application/auth.service'; import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload, AuthenticationResult } from '../application/auth.types';
import { LoginDto, RegisterDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
const REFRESH_COOKIE = 'refresh_token'; const REFRESH_COOKIE = 'refresh_token';
@@ -53,7 +56,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async register( async register(
@Body() credentials: CredentialsDto, @Body() credentials: RegisterDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
return this.respond( return this.respond(
@@ -66,7 +69,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async login( async login(
@Body() credentials: CredentialsDto, @Body() credentials: LoginDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
return this.respond( return this.respond(
@@ -82,14 +85,9 @@ export class AuthController {
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
try { const token = this.refreshToken(request);
const token = this.refreshToken(request); if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); return this.respond(response, await this.auth.refresh(token));
return this.respond(response, await this.auth.refresh(token));
} catch (error) {
this.clearCookie(response);
throw error;
}
} }
@Post('logout') @Post('logout')
@@ -114,11 +112,7 @@ export class AuthController {
private respond( private respond(
response: Response, response: Response,
result: { result: AuthenticationResult,
user: { id: string; email: string };
accessToken: string;
refreshToken: string;
},
) { ) {
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions()); response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
return { user: result.user, accessToken: result.accessToken }; return { user: result.user, accessToken: result.accessToken };
@@ -129,6 +123,7 @@ export class AuthController {
} }
private refreshToken(request: Request): string | undefined { private refreshToken(request: Request): string | undefined {
// eslint-disable-next-line security/detect-object-injection -- The cookie name is a fixed application constant; its value is narrowed below.
const token: unknown = request.cookies?.[REFRESH_COOKIE]; const token: unknown = request.cookies?.[REFRESH_COOKIE];
return typeof token === 'string' ? token : undefined; return typeof token === 'string' ? token : undefined;
} }
@@ -139,6 +134,7 @@ export class AuthController {
sameSite: 'lax' as const, sameSite: 'lax' as const,
secure: process.env['NODE_ENV'] !== 'development', secure: process.env['NODE_ENV'] !== 'development',
path: COOKIE_PATH, path: COOKIE_PATH,
maxAge: REFRESH_TTL_MS,
}; };
} }
} }
+37 -14
View File
@@ -1,17 +1,23 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsNotEmpty,
IsString, IsString,
MinLength, Matches,
Validate, Validate,
ValidatorConstraint, ValidatorConstraint,
} from 'class-validator'; } from 'class-validator';
const EMAIL_PART_COUNT = 2;
const FIRST_CHARACTER_INDEX = 0;
const MAX_EMAIL_LENGTH = 254; const MAX_EMAIL_LENGTH = 254;
const ASCII_SPACE = 32; const ASCII_SPACE = 32;
const ASCII_DELETE = 127; const ASCII_DELETE = 127;
const MIN_PASSWORD_LENGTH = 12; // bcrypt reads at most 72 bytes; printable ASCII keeps characters == bytes.
const MIN_PASSWORD_LENGTH = 8;
const MAX_PASSWORD_BYTES = 72; const MAX_PASSWORD_BYTES = 72;
const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`;
@ValidatorConstraint({ name: 'email' }) @ValidatorConstraint({ name: 'email' })
class EmailConstraint { class EmailConstraint {
@@ -19,37 +25,40 @@ class EmailConstraint {
return ( return (
typeof value === 'string' && typeof value === 'string' &&
value.length <= MAX_EMAIL_LENGTH && value.length <= MAX_EMAIL_LENGTH &&
value.split('@').length === 2 && value.split('@').length === EMAIL_PART_COUNT &&
!value !value
.split('') .split('')
.some( .some(
(character) => (character) =>
/\s/.test(character) || /\s/.test(character) ||
character.charCodeAt(0) < ASCII_SPACE || character.charCodeAt(FIRST_CHARACTER_INDEX) < ASCII_SPACE ||
character.charCodeAt(0) === ASCII_DELETE, character.charCodeAt(FIRST_CHARACTER_INDEX) === ASCII_DELETE,
) && ) &&
value.split('@').every(Boolean) value.split('@').every(Boolean)
); );
} }
defaultMessage(): string { defaultMessage(): string {
return 'must be a valid email address'; return 'must be a valid email address';
} }
} }
@ValidatorConstraint({ name: 'passwordBytes' }) // Login stays lenient: accounts created under an older policy must still sign in.
class PasswordBytesConstraint { @ValidatorConstraint({ name: 'passwordMaxBytes' })
class PasswordMaxBytesConstraint {
validate(value: unknown): boolean { validate(value: unknown): boolean {
return ( return (
typeof value === 'string' && typeof value === 'string' &&
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
); );
} }
defaultMessage(): string { defaultMessage(): string {
return 'must not exceed 72 UTF-8 bytes'; return `must not exceed ${MAX_PASSWORD_BYTES} UTF-8 bytes`;
} }
} }
export class CredentialsDto { class EmailDto {
@ApiProperty({ example: 'user@example.com' }) @ApiProperty({ example: 'user@example.com' })
@IsString() @IsString()
@Transform(({ value }: { value: unknown }) => @Transform(({ value }: { value: unknown }) =>
@@ -57,14 +66,28 @@ export class CredentialsDto {
) )
@Validate(EmailConstraint) @Validate(EmailConstraint)
declare email: string; declare email: string;
}
export class RegisterDto extends EmailDto {
@ApiProperty({ @ApiProperty({
minLength: MIN_PASSWORD_LENGTH, description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`,
maxLength: MAX_PASSWORD_BYTES, pattern: PASSWORD_PATTERN,
example: 'a secure password', example: 'a secure password',
}) })
@IsString() @IsString()
@MinLength(MIN_PASSWORD_LENGTH) @Matches(new RegExp(PASSWORD_PATTERN), {
@Validate(PasswordBytesConstraint) message: `must be ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters`,
})
declare password: string;
}
export class LoginDto extends EmailDto {
@ApiProperty({
description: `Raw password, at most ${MAX_PASSWORD_BYTES} UTF-8 bytes. Not trimmed or normalized.`,
example: 'a secure password',
})
@IsString()
@IsNotEmpty()
@Validate(PasswordMaxBytesConstraint)
declare password: string; declare password: string;
} }
@@ -1,11 +1,12 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { RequestContextService } from '../../../common/logging/request-context.service'; import { PinoLogger } from 'nestjs-pino';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly requestContext: RequestContextService) { constructor(private readonly logger: PinoLogger) {
super(); super();
} }
@@ -14,8 +15,9 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
const request = context const request = context
.switchToHttp() .switchToHttp()
.getRequest<{ user?: AccessPayload }>(); .getRequest<{ user?: AccessPayload }>();
if (request.user?.sub) if (request.user?.sub) {
this.requestContext.set({ userId: request.user.sub }); this.logger.assign({ userId: request.user.sub });
}
return Boolean(allowed); return Boolean(allowed);
} }
} }
@@ -1,40 +1,44 @@
import {
MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
import {
MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
import { parseRubMoney } from '../domain/rub-money';
import { import {
bankPaymentSystems, bankPaymentSystems,
BankPeriodQueryDto, BankPeriodQueryDto,
CreateBankCardDto, CreateBankCardDto,
CreateBankOperationDto, CreateBankOperationDto,
} from '../presentation/bank-card.dto'; } from '../presentation/bank-card.dto';
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
import { parseRubMoney } from '../domain/rub-money';
export const BankErrorCode = { const NEXT_MONTH_OFFSET = 1;
InvalidInput: 'BANK_INVALID_INPUT', const PREVIOUS_MONTH_LAST_DAY = 0;
VersionConflict: 'BANK_VERSION_CONFLICT',
NotFound: 'BANK_NOT_FOUND', export enum BankErrorCode {
InvalidMoney: 'BANK_INVALID_MONEY', CardArchived = 'BANK_CARD_ARCHIVED',
InvalidDate: 'BANK_INVALID_DATE', DependentRefunds = 'BANK_DEPENDENT_REFUNDS',
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION', DuplicateCategory = 'BANK_DUPLICATE_CATEGORY',
InvalidRefund: 'BANK_INVALID_REFUND', ImmutableOperation = 'BANK_IMMUTABLE_OPERATION',
RefundLimit: 'BANK_REFUND_LIMIT', IntentConflict = 'BANK_INTENT_CONFLICT',
DependentRefunds: 'BANK_DEPENDENT_REFUNDS', InvalidDate = 'BANK_INVALID_DATE',
IntentConflict: 'BANK_INTENT_CONFLICT', InvalidInput = 'BANK_INVALID_INPUT',
OperationDeleted: 'BANK_OPERATION_DELETED', InvalidMoney = 'BANK_INVALID_MONEY',
CardArchived: 'BANK_CARD_ARCHIVED', InvalidRefund = 'BANK_INVALID_REFUND',
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT', NotFound = 'BANK_NOT_FOUND',
WriteConflict: 'BANK_WRITE_CONFLICT', OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT',
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY', OperationDeleted = 'BANK_OPERATION_DELETED',
} as const; RefundLimit = 'BANK_REFUND_LIMIT',
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode]; VersionConflict = 'BANK_VERSION_CONFLICT',
WriteConflict = 'BANK_WRITE_CONFLICT',
}
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never { export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
throw new BadRequestException({ code }); throw new BadRequestException({ code });
@@ -44,8 +48,8 @@ export function periodBounds(query: BankPeriodQueryDto) {
const from = `${query.month}-01`; const from = `${query.month}-01`;
if (!isCalendarDate(from)) return fail(); if (!isCalendarDate(from)) return fail();
const end = new Date(`${from}T00:00:00Z`); const end = new Date(`${from}T00:00:00Z`);
end.setUTCMonth(end.getUTCMonth() + 1, 0); end.setUTCMonth(end.getUTCMonth() + NEXT_MONTH_OFFSET, PREVIOUS_MONTH_LAST_DAY);
return { from, to: end.toISOString().slice(0, 10) }; return { from, to: end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) };
} }
if ( if (
query.month || query.month ||
@@ -54,8 +58,9 @@ export function periodBounds(query: BankPeriodQueryDto) {
!isCalendarDate(query.from) || !isCalendarDate(query.from) ||
!isCalendarDate(query.to) || !isCalendarDate(query.to) ||
query.from > query.to query.from > query.to
) ) {
return fail(); return fail();
}
return { from: query.from, to: query.to }; return { from: query.from, to: query.to };
} }
export function conflict( export function conflict(
@@ -93,8 +98,9 @@ export function currentDate(value: string) {
if (value > moscowToday()) fail(BankErrorCode.InvalidDate); if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
} }
export function name(value: string, max: number) { export function name(value: string, max: number) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > max) if (typeof value !== 'string' || !value.trim() || value.trim().length > max) {
fail(); fail();
}
return value.trim(); return value.trim();
} }
export function cardInput(dto: CreateBankCardDto) { export function cardInput(dto: CreateBankCardDto) {
@@ -104,8 +110,9 @@ export function cardInput(dto: CreateBankCardDto) {
if ( if (
dto.paymentSystem != null && dto.paymentSystem != null &&
!bankPaymentSystems.some((system) => system === dto.paymentSystem) !bankPaymentSystems.some((system) => system === dto.paymentSystem)
) ) {
fail(); fail();
}
return { return {
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }), ...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
name: name(dto.name, MAX_CARD_NAME_LENGTH), name: name(dto.name, MAX_CARD_NAME_LENGTH),
@@ -120,8 +127,9 @@ export function operationInput(dto: CreateBankOperationDto) {
dto.description != null && dto.description != null &&
(typeof dto.description !== 'string' || (typeof dto.description !== 'string' ||
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH) dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
) ) {
fail(); fail();
}
return { return {
kind: dto.kind, kind: dto.kind,
cardId: dto.cardId, cardId: dto.cardId,

Some files were not shown because too many files have changed in this diff Show More