107 Commits

Author SHA1 Message Date
root 057d41e1c2 add text input validation
Build & Push / Pipeline Tests (push) Failing after 1m5s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 52s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-08-31 22:51:44 -04:00
root b99e8d0be4 fix mode
Build & Push / Pipeline Tests (push) Successful in 1m59s
Test / Type Check (all packages) (push) Successful in 52s
Build & Push / Build & Push Docker Image (push) Successful in 3m40s
Test / API Unit Tests (push) Successful in 1m12s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m8s
2026-08-31 21:50:26 -04:00
root cb8bf63218 update arabic to be default
Build & Push / Pipeline Tests (push) Successful in 1m56s
Test / Type Check (all packages) (push) Successful in 56s
Build & Push / Build & Push Docker Image (push) Successful in 3m39s
Test / API Unit Tests (push) Successful in 1m12s
Test / Homepage Unit Tests (push) Successful in 52s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 52s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Successful in 1m8s
2026-08-31 19:50:27 -04:00
root f00d2a8aea update arabic
Build & Push / Pipeline Tests (push) Successful in 1m56s
Test / Type Check (all packages) (push) Successful in 53s
Build & Push / Build & Push Docker Image (push) Successful in 4m34s
Test / API Unit Tests (push) Successful in 1m15s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 48s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m10s
2026-08-31 18:31:05 -04:00
root c0eb1de7a7 fix plan issues
Build & Push / Pipeline Tests (push) Successful in 2m1s
Test / Type Check (all packages) (push) Successful in 53s
Build & Push / Build & Push Docker Image (push) Successful in 5m29s
Test / API Unit Tests (push) Successful in 1m18s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m9s
2026-08-30 23:36:40 -04:00
root 66877d66c2 fix admin login issue
Build & Push / Pipeline Tests (push) Successful in 1m53s
Test / Type Check (all packages) (push) Successful in 54s
Build & Push / Build & Push Docker Image (push) Successful in 3m29s
Test / API Unit Tests (push) Successful in 1m14s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m8s
2026-08-23 01:38:26 -04:00
root fe68f4c6b0 fix issue test
Build & Push / Pipeline Tests (push) Successful in 1m57s
Test / Type Check (all packages) (push) Successful in 52s
Build & Push / Build & Push Docker Image (push) Successful in 3m32s
Test / API Unit Tests (push) Successful in 1m12s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 39s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 1m5s
2026-08-23 01:16:39 -04:00
root e3f80af47d fix 2fa and move communication language to setting
Build & Push / Pipeline Tests (push) Failing after 1m26s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 50s
Test / API Unit Tests (push) Failing after 1m6s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m6s
2026-08-23 00:45:39 -04:00
root c8d7a3954a fix admin user at production
Build & Push / Pipeline Tests (push) Successful in 1m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Push / Build & Push Docker Image (push) Successful in 25s
Test / API Unit Tests (push) Successful in 1m10s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m9s
2026-08-23 00:06:46 -04:00
root 79a55143b9 fix session cookie
Build & Push / Pipeline Tests (push) Successful in 1m56s
Test / Type Check (all packages) (push) Successful in 53s
Build & Push / Build & Push Docker Image (push) Successful in 5m35s
Test / API Unit Tests (push) Successful in 1m17s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 46s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m7s
2026-08-22 23:38:46 -04:00
root d6f5f315e8 fix admin login
Build & Push / Pipeline Tests (push) Successful in 1m49s
Test / Type Check (all packages) (push) Successful in 52s
Build & Push / Build & Push Docker Image (push) Successful in 3m51s
Test / API Unit Tests (push) Successful in 1m13s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m6s
2026-08-18 00:21:10 -04:00
root d26df78c70 add admin creation to deployment
Build & Push / Pipeline Tests (push) Successful in 1m52s
Test / Type Check (all packages) (push) Successful in 52s
Build & Push / Build & Push Docker Image (push) Successful in 25s
Test / API Unit Tests (push) Successful in 1m6s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m7s
2026-08-17 23:41:59 -04:00
root 63974a0061 update create admin user
Build & Push / Pipeline Tests (push) Successful in 1m49s
Test / Type Check (all packages) (push) Successful in 52s
Build & Push / Build & Push Docker Image (push) Successful in 3m39s
Test / API Unit Tests (push) Successful in 1m14s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m6s
2026-08-17 22:30:22 -04:00
root 72671d146d fix migration production
Build & Push / Pipeline Tests (push) Successful in 1m56s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Successful in 3m34s
Test / API Unit Tests (push) Successful in 1m12s
Test / Homepage Unit Tests (push) Successful in 49s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Successful in 1m6s
2026-08-17 21:59:23 -04:00
root 5f1c427600 fix tests
Build & Push / Pipeline Tests (push) Successful in 1m48s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Successful in 5m48s
Test / API Unit Tests (push) Successful in 1m18s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Carplace Unit Tests (push) Successful in 1m4s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m6s
2026-08-17 21:29:48 -04:00
root 653074be3e fix stripe removal tests
Build & Push / Pipeline Tests (push) Failing after 1m26s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 49s
Test / API Unit Tests (push) Failing after 1m8s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 39s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Failing after 1m7s
2026-08-17 21:15:35 -04:00
root fe8ffbeb9f update payment method, remove stripe, paypal, amanapay
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 48s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-08-17 21:05:29 -04:00
root c9915a8315 remove stripe
Build & Push / Pipeline Tests (push) Successful in 1m28s
Test / Type Check (all packages) (push) Successful in 32s
Build & Push / Build & Push Docker Image (push) Failing after 1m23s
Test / API Unit Tests (push) Successful in 50s
Test / Homepage Unit Tests (push) Successful in 27s
Test / Carplace Unit Tests (push) Successful in 24s
Test / Admin Unit Tests (push) Successful in 24s
Test / Dashboard Unit Tests (push) Successful in 25s
Test / API Integration Tests (push) Successful in 49s
2026-08-16 22:36:05 -04:00
root 85ff9d492b update packages
Build & Push / Pipeline Tests (push) Successful in 1m28s
Test / Type Check (all packages) (push) Successful in 32s
Build & Push / Build & Push Docker Image (push) Failing after 1m23s
Test / API Unit Tests (push) Successful in 50s
Test / Homepage Unit Tests (push) Successful in 27s
Test / Carplace Unit Tests (push) Successful in 25s
Test / Admin Unit Tests (push) Successful in 24s
Test / Dashboard Unit Tests (push) Successful in 25s
Test / API Integration Tests (push) Successful in 50s
2026-08-16 22:04:00 -04:00
root 10e061d2c1 fix many request issue
Build & Push / Pipeline Tests (push) Successful in 1m53s
Test / Type Check (all packages) (push) Successful in 54s
Build & Push / Build & Push Docker Image (push) Successful in 3m43s
Test / API Unit Tests (push) Successful in 1m15s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m10s
2026-08-13 01:07:29 -04:00
root 7e7a76633e fix many requests
Build & Push / Pipeline Tests (push) Successful in 1m57s
Test / Type Check (all packages) (push) Successful in 54s
Build & Push / Build & Push Docker Image (push) Successful in 3m30s
Test / API Unit Tests (push) Successful in 1m19s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m10s
2026-08-13 00:30:58 -04:00
root 51ef1fb319 fix so many requests
Build & Push / Pipeline Tests (push) Successful in 1m54s
Test / Type Check (all packages) (push) Successful in 52s
Build & Push / Build & Push Docker Image (push) Successful in 3m34s
Test / API Unit Tests (push) Successful in 1m13s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m10s
2026-08-13 00:13:43 -04:00
root 8cd24bae10 fix admin api
Build & Push / Pipeline Tests (push) Successful in 2m0s
Test / Type Check (all packages) (push) Successful in 57s
Build & Push / Build & Push Docker Image (push) Successful in 26s
Test / API Unit Tests (push) Successful in 1m10s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m12s
2026-08-13 00:02:32 -04:00
root 6004fbafa0 fix packages
Build & Push / Pipeline Tests (push) Successful in 2m2s
Test / Type Check (all packages) (push) Successful in 56s
Build & Push / Build & Push Docker Image (push) Successful in 5m44s
Test / API Unit Tests (push) Successful in 1m20s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m10s
2026-08-12 23:32:15 -04:00
root 4587ab71ac update packages
Build & Push / Pipeline Tests (push) Failing after 11s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 10s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-08-12 23:16:54 -04:00
root a8b957f808 update packages
Build & Push / Pipeline Tests (push) Failing after 42s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 41s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-08-12 23:08:49 -04:00
root 4a6df3dcba fix cicd issues
Build & Push / Pipeline Tests (push) Successful in 2m1s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Successful in 3m30s
Test / API Unit Tests (push) Successful in 1m16s
Test / Homepage Unit Tests (push) Successful in 50s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m10s
2026-08-12 17:21:28 -04:00
root d616621bb6 The issue was the serializer’s return type claimed it returned the original generic T, but the implementation intentionally removes reviewToken and rewrites customer.licenseImageUrl. I updated the return type to model the actual serialized dashboard payload: Omit<T, 'reviewToken' | 'customer'> plus the normalized customer and computed fields.
Build & Push / Pipeline Tests (push) Successful in 1m57s
Test / Type Check (all packages) (push) Successful in 54s
Build & Push / Build & Push Docker Image (push) Successful in 5m39s
Test / API Unit Tests (push) Successful in 1m18s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m10s
2026-08-12 16:54:09 -04:00
root 8fc88ffc14 fix production issues
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 51s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-08-12 16:48:41 -04:00
root 53de25120a fix test failure
Build & Push / Pipeline Tests (push) Successful in 1m54s
Test / Type Check (all packages) (push) Successful in 53s
Build & Push / Build & Push Docker Image (push) Successful in 5m13s
Test / API Unit Tests (push) Successful in 1m18s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m8s
2026-08-10 23:33:46 -04:00
root 7b8f81336a add subscription upgrade plan
Build & Push / Pipeline Tests (push) Failing after 2m0s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Failing after 1m7s
2026-08-10 23:23:58 -04:00
root 5f06256271 fix billing and 2fa admin
Build & Push / Pipeline Tests (push) Failing after 1m58s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 58s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Failing after 1m9s
2026-08-10 22:35:55 -04:00
root 10ca76fc1e fix stripe error
Build & Push / Pipeline Tests (push) Successful in 1m51s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Successful in 5m44s
Test / API Unit Tests (push) Successful in 1m12s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 46s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m8s
2026-08-09 13:44:11 -04:00
root cfc3f371de add copy deployment setup to vps
Build & Push / Pipeline Tests (push) Successful in 1m50s
Test / Type Check (all packages) (push) Successful in 52s
Build & Push / Build & Push Docker Image (push) Successful in 25s
Test / API Unit Tests (push) Successful in 1m5s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m6s
2026-08-09 13:20:18 -04:00
root 92f1ff655e remove auto deploy
Build & Push / Pipeline Tests (push) Successful in 1m46s
Test / Type Check (all packages) (push) Successful in 51s
Build & Push / Build & Push Docker Image (push) Successful in 27s
Test / API Unit Tests (push) Successful in 1m5s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m7s
2026-08-09 13:08:23 -04:00
root e51c44f486 fix pricing at home page
Build & Push / Pipeline Tests (push) Successful in 1m51s
Test / Type Check (all packages) (push) Successful in 51s
Build & Push / Build & Push Docker Image (push) Failing after 4m11s
Test / API Unit Tests (push) Successful in 1m15s
Test / Homepage Unit Tests (push) Successful in 49s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m5s
2026-08-08 01:16:44 -04:00
root 5429f582f5 fix migration
Build & Push / Pipeline Tests (push) Successful in 1m50s
Test / Type Check (all packages) (push) Successful in 52s
Build & Push / Build & Push Docker Image (push) Failing after 3m39s
Test / API Unit Tests (push) Successful in 1m10s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 45s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m8s
2026-08-04 01:09:44 -04:00
root 149806a773 fix bug 16 and 17
Build & Push / Pipeline Tests (push) Successful in 1m53s
Test / Type Check (all packages) (push) Successful in 56s
Build & Push / Build & Push Docker Image (push) Failing after 4m53s
Test / API Unit Tests (push) Successful in 1m15s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m6s
2026-08-04 00:52:58 -04:00
root 626da23c2e fix the stripe build
Build & Push / Pipeline Tests (push) Successful in 1m49s
Test / Type Check (all packages) (push) Successful in 57s
Build & Push / Build & Push Docker Image (push) Successful in 1m10s
Test / API Unit Tests (push) Successful in 1m11s
Test / Homepage Unit Tests (push) Successful in 49s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 53s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Successful in 1m6s
2026-07-29 18:57:12 -04:00
root d6fcb52941 stripe build fix
Build & Push / Pipeline Tests (push) Successful in 1m49s
Test / Type Check (all packages) (push) Successful in 53s
Build & Push / Build & Push Docker Image (push) Failing after 33s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 50s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 47s
Test / API Integration Tests (push) Successful in 1m8s
2026-07-29 18:46:14 -04:00
root 86531c5cb0 strioe build fix
Build & Push / Pipeline Tests (push) Successful in 1m50s
Test / Type Check (all packages) (push) Successful in 54s
Build & Push / Build & Push Docker Image (push) Failing after 32s
Test / API Unit Tests (push) Successful in 1m11s
Test / Homepage Unit Tests (push) Successful in 49s
Test / Carplace Unit Tests (push) Successful in 46s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m5s
2026-07-29 18:04:27 -04:00
root f2fe4b0bc0 same stripe image build fix
Build & Push / Pipeline Tests (push) Successful in 1m50s
Test / Type Check (all packages) (push) Successful in 54s
Build & Push / Build & Push Docker Image (push) Failing after 33s
Test / API Unit Tests (push) Successful in 1m10s
Test / Homepage Unit Tests (push) Successful in 50s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 44s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Successful in 1m8s
2026-07-29 16:44:39 -04:00
root 300c9dc694 fix build issue
Build & Push / Pipeline Tests (push) Successful in 1m47s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Failing after 34s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 49s
Test / Carplace Unit Tests (push) Successful in 45s
Test / Admin Unit Tests (push) Successful in 44s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m10s
2026-07-29 16:25:03 -04:00
root 7bc1dd338a fix production trial issue
Build & Push / Pipeline Tests (push) Successful in 1m49s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Failing after 5m31s
Test / API Unit Tests (push) Successful in 1m20s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 45s
Test / Admin Unit Tests (push) Successful in 44s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m8s
2026-07-29 16:05:01 -04:00
root dc04ef07b9 fix stripe image build
Build & Push / Pipeline Tests (push) Successful in 1m45s
Test / Type Check (all packages) (push) Successful in 53s
Build & Push / Build & Push Docker Image (push) Failing after 33s
Test / API Unit Tests (push) Successful in 1m7s
Test / Homepage Unit Tests (push) Successful in 50s
Test / Carplace Unit Tests (push) Successful in 47s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m6s
2026-07-29 09:15:06 -04:00
root b944ff554c fix stripe for production
Build & Push / Pipeline Tests (push) Successful in 1m51s
Test / Type Check (all packages) (push) Successful in 52s
Test / API Unit Tests (push) Successful in 1m10s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 44s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m7s
Build & Push / Build & Push Docker Image (push) Failing after 41s
2026-07-29 01:39:54 -04:00
root 96d1d2d0d6 fix login issue in production
Build & Push / Pipeline Tests (push) Successful in 1m53s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Successful in 4m32s
Test / API Unit Tests (push) Successful in 1m17s
Test / Homepage Unit Tests (push) Successful in 50s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 44s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Successful in 1m12s
2026-07-29 01:15:38 -04:00
root 9e2fc050be fix production login issue
Build & Push / Pipeline Tests (push) Successful in 1m53s
Test / Type Check (all packages) (push) Successful in 56s
Build & Push / Build & Push Docker Image (push) Successful in 4m19s
Test / API Unit Tests (push) Successful in 1m17s
Test / Homepage Unit Tests (push) Successful in 49s
Test / Carplace Unit Tests (push) Successful in 46s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m7s
2026-07-29 00:48:32 -04:00
root a665c942d2 fix cors production
Build & Push / Pipeline Tests (push) Successful in 1m58s
Test / Type Check (all packages) (push) Successful in 59s
Build & Push / Build & Push Docker Image (push) Successful in 4m9s
Test / API Unit Tests (push) Successful in 1m13s
Test / Homepage Unit Tests (push) Successful in 50s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 44s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m7s
2026-07-29 00:24:48 -04:00
root 1b3c476dd4 fix production favicon issue
Build & Push / Pipeline Tests (push) Successful in 1m52s
Test / Type Check (all packages) (push) Successful in 55s
Build & Push / Build & Push Docker Image (push) Successful in 4m14s
Test / API Unit Tests (push) Successful in 1m17s
Test / Homepage Unit Tests (push) Successful in 49s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Successful in 1m11s
2026-07-29 00:01:18 -04:00
root 3c520f7a53 fix build test
Build & Push / Pipeline Tests (push) Successful in 1m43s
Test / Type Check (all packages) (push) Successful in 58s
Build & Push / Build & Push Docker Image (push) Successful in 6m26s
Test / API Unit Tests (push) Successful in 1m19s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m5s
2026-07-28 23:16:33 -04:00
root eeec8f0a82 fix both admin and company login
Build & Push / Pipeline Tests (push) Failing after 1m27s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 55s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 50s
Test / Carplace Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
2026-07-28 23:11:46 -04:00
root 50c74b9007 admin login fixed.
Build & Push / Pipeline Tests (push) Failing after 1m34s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 56s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 46s
Test / Dashboard Unit Tests (push) Failing after 43s
Test / API Integration Tests (push) Successful in 1m8s
2026-07-28 22:56:18 -04:00
root 5649ab02c7 fix the booking and contract
Build & Push / Pipeline Tests (push) Failing after 1m31s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 55s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Failing after 46s
Test / API Integration Tests (push) Successful in 1m7s
2026-07-27 23:04:26 -04:00
root 8046a1d447 fix bug 14
Build & Push / Pipeline Tests (push) Successful in 1m52s
Test / Type Check (all packages) (push) Successful in 54s
Build & Push / Build & Push Docker Image (push) Successful in 6m34s
Test / API Unit Tests (push) Successful in 1m18s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 50s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m6s
2026-07-26 01:39:11 -04:00
root 2b422ca197 fix search feature
Build & Push / Pipeline Tests (push) Failing after 1m14s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 50s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m4s
2026-07-26 01:18:05 -04:00
root f6fcd7ce54 fix notifications
Build & Push / Pipeline Tests (push) Failing after 1m8s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 52s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m7s
2026-07-22 22:31:39 -04:00
root bcabd17220 add stripe
Build & Push / Pipeline Tests (push) Failing after 1m6s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 56s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-22 20:17:12 -04:00
root 7ecd85e9b7 update signup page
Build & Push / Pipeline Tests (push) Successful in 1m31s
Test / Type Check (all packages) (push) Successful in 52s
Build & Push / Build & Push Docker Image (push) Successful in 4m3s
Test / API Unit Tests (push) Successful in 1m2s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m2s
2026-07-20 23:21:24 -04:00
root 93fba7a318 test should pass before image creation
Test / Type Check (all packages) (push) Successful in 1m0s
Build & Push / Pipeline Tests (push) Successful in 1m22s
Test / API Unit Tests (push) Successful in 51s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m2s
Build & Push / Build & Push Docker Image (push) Successful in 53s
2026-07-20 22:51:17 -04:00
root 8f5ca1df22 fix setting and add rental policies
Test / Type Check (all packages) (push) Has been cancelled
Test / API Unit Tests (push) Has been cancelled
Test / Homepage Unit Tests (push) Has been cancelled
Test / Carplace Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
Build & Push / Pipeline Tests (push) Waiting to run
Build & Push / Build & Push Docker Image (push) Blocked by required conditions
2026-07-20 22:47:52 -04:00
root d13ae2205f enhancement #10
Build & Push / Build & Push Docker Image (push) Successful in 3m52s
Test / Type Check (all packages) (push) Successful in 59s
Test / API Unit Tests (push) Successful in 48s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
2026-07-20 21:50:14 -04:00
root 8e097db22d fix bug#8
Build & Push / Build & Push Docker Image (push) Successful in 4m2s
Test / Type Check (all packages) (push) Successful in 1m1s
Test / API Unit Tests (push) Successful in 50s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m2s
2026-07-20 21:33:31 -04:00
root be49ac5e69 fix test failures
Build & Push / Build & Push Docker Image (push) Successful in 5m17s
Test / Type Check (all packages) (push) Successful in 1m4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Has been cancelled
Test / Carplace Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
2026-07-20 21:26:13 -04:00
root abb5d105cf Fixed the type-check failure from test-Type Check
Build & Push / Build & Push Docker Image (push) Successful in 4m18s
Test / Type Check (all packages) (push) Successful in 56s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 1m15s
2026-07-20 01:05:24 -04:00
root fb7b4eefc1 fix bug#7
Build & Push / Build & Push Docker Image (push) Failing after 2m50s
Test / Type Check (all packages) (push) Failing after 55s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-20 01:01:21 -04:00
root 7ce0a5adf3 fix test pipeline
Build & Push / Build & Push Docker Image (push) Failing after 1m59s
Test / Type Check (all packages) (push) Failing after 59s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
2026-07-19 23:28:38 -04:00
root 9c526af996 fix for bug#6
Build & Push / Build & Push Docker Image (push) Failing after 2m42s
2026-07-19 23:20:51 -04:00
root a010ad6811 apply security fix
Build & Push / Build & Push Docker Image (push) Failing after 3m46s
2026-07-19 22:22:33 -04:00
root ec03e783e5 remove the extra carplace folder
Build & Push / Build & Push Docker Image (push) Successful in 1m7s
2026-07-05 14:56:46 -04:00
root 9c50a59133 add missing packages
Build & Push / Build & Push Docker Image (push) Successful in 3m50s
2026-07-04 19:12:06 -04:00
root aa6afea30b fix search section at the carplace page
Build & Push / Build & Push Docker Image (push) Successful in 3m48s
2026-07-04 18:51:40 -04:00
root 98f0b3ffad fix session cookies url
Build & Push / Build & Push Docker Image (push) Successful in 52s
2026-07-04 18:36:50 -04:00
root 1d6ae6a54e Accept registry credential aliases in Gitea deploy workflow
Build & Push / Build & Push Docker Image (push) Successful in 4m53s
Allow the build-and-deploy workflow to use either REGISTRY_USERNAME/REGISTRY_PASSWORD or REGISTRY_USER/REGISTRY_TOKEN secrets, and remove duplicate Carplace URL env entries.
2026-07-04 18:25:10 -04:00
root c77d0a8e89 Rename legacy storefront app and references to carplace
Build & Push / Build & Push Docker Image (push) Failing after 10m39s
Replace storefront naming across source, tests, docs, config, and production scripts. Rename the legacy top-level app directory and Carplace component files, remove duplicate storefront startup scripts, and refresh the lockfile.
2026-07-04 18:10:08 -04:00
root fefaf8108d Fix production routing through Traefik
Build & Push / Build & Push Docker Image (push) Successful in 50s
- pass NEXT_PUBLIC_CARPLACE_URL into the production Docker build
- run the production deploy script after building the image
- support raw or base64 production env secrets during deploy
- copy the production env file into the remote build context
- include homepage in production deploy, pull, and health checks
- use a stable Traefik Compose project name
- reuse an existing Traefik container instead of starting a duplicate proxy
- update Traefik router rules to use HeaderRegexp with traefik:latest
2026-07-04 17:45:43 -04:00
root 9e096f0216 Fix production deploy Traefik reuse
Build & Push / Build & Push Docker Image (push) Successful in 53s
- pass NEXT_PUBLIC_CARPLACE_URL into the production Docker build
- run the production deploy script after building the image
- support raw or base64 production env secrets during deploy
- copy the production env file into the remote build context
- include homepage in production deploy, pull, and health checks
- use a stable Traefik Compose project name
- reuse an existing Traefik container instead of starting a duplicate proxy
2026-07-04 17:39:22 -04:00
root 7f51825b1f Fix production deploy routing and Traefik project reuse
Build & Push / Build & Push Docker Image (push) Failing after 28s
- pass NEXT_PUBLIC_CARPLACE_URL into the production Docker build
- run the production deploy script after building the image
- support raw or base64 production env secrets during deploy
- copy the production env file into the remote build context
- include homepage in production deploy, pull, and health checks
- use a stable Traefik Compose project name to avoid duplicate proxy containers
2026-07-04 17:37:15 -04:00
root da35656839 Fix production deploy env path and homepage routing
Build & Push / Build & Push Docker Image (push) Failing after 34s
- pass NEXT_PUBLIC_CARPLACE_URL into the production Docker build
- keep NEXT_PUBLIC_STOREFRONT_URL as a fallback for existing secrets
- run the production deploy script after building the image
- support raw or base64 production env secrets during deploy
- copy the production env file into the remote build context
- include homepage in production deploy, pull, and health checks
2026-07-04 17:34:47 -04:00
root 026e33a228 Fix production deploy env handling and homepage routing
Build & Push / Build & Push Docker Image (push) Failing after 24s
- pass NEXT_PUBLIC_CARPLACE_URL into the production Docker build
- keep NEXT_PUBLIC_STOREFRONT_URL as a fallback for existing secrets
- run the production deploy script after building the image
- support raw or base64 production env secrets during deploy
- reuse an existing VPS production env file when present
- include homepage in production deploy, pull, and health checks
2026-07-04 17:17:23 -04:00
root abec82a08f Fix production deployment for public site routing
Build & Push / Build & Push Docker Image (push) Failing after 27s
- pass NEXT_PUBLIC_CARPLACE_URL into the production Docker build
- keep NEXT_PUBLIC_STOREFRONT_URL as a fallback for existing secrets
- run the production deploy script after building the image
- allow deploys to reuse an existing VPS production env file
- include homepage in production deploy, pull, and health checks
- allow deploy to use a release image already built locally on the VPS
2026-07-04 17:14:34 -04:00
root f4ea01e9de Fix production deployment for public site routing
Build & Push / Build & Push Docker Image (push) Failing after 3m35s
- pass `NEXT_PUBLIC_CARPLACE_URL` into the production Docker build
- keep `NEXT_PUBLIC_STOREFRONT_URL` as a backward-compatible fallback
- run the production deploy script from the Gitea workflow after building
- include `homepage` in production deploy, pull, and health-check service lists
- allow deploy to use a release image already built locally on the VPS
2026-07-04 17:08:47 -04:00
root a5b6c559ea remove deploy from build push code
Build & Push / Build & Push Docker Image (push) Successful in 24s
2026-07-04 16:50:18 -04:00
root e1b848042b build fix 13
Build & Deploy / Build & Push Docker Image (push) Successful in 5m47s
Build & Deploy / Deploy to VPS (push) Failing after 21s
2026-07-04 16:38:28 -04:00
root d238e6e806 build fix 12
Build & Deploy / Build & Push Docker Image (push) Failing after 32s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 16:34:38 -04:00
root be30d19cdb build fix 11
Build & Deploy / Build & Push Docker Image (push) Failing after 18s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 15:45:38 -04:00
root 24c58cf006 build fix 10
Build & Deploy / Build & Push Docker Image (push) Failing after 34s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 15:40:56 -04:00
root 2befed486f build fix 9
Build & Deploy / Build & Push Docker Image (push) Failing after 5s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 15:33:14 -04:00
root 2436907dc2 build fix 8
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 15:30:50 -04:00
root fbbd5e4c1c deploy fix 7
Build & Deploy / Build & Push Docker Image (push) Failing after 3s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 15:29:09 -04:00
root a45b318d5e build fix7
Build & Deploy / Build & Push Docker Image (push) Failing after 5s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 14:21:00 -04:00
root 1c8b3d5401 fix build 6
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 14:17:50 -04:00
root e7e80103c7 fix build 5
Build & Deploy / Build & Push Docker Image (push) Failing after 5s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 14:07:41 -04:00
root b5dfe66373 fix build 4
Build & Deploy / Build & Push Docker Image (push) Failing after 4s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 14:05:16 -04:00
root c74789f949 fix build 3
Build & Deploy / Build & Push Docker Image (push) Failing after 5s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:57:44 -04:00
root 0adebc08e5 fix build 2
Build & Deploy / Build & Push Docker Image (push) Failing after 6s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:56:15 -04:00
root 342e8ee2fe fix build generate real private key
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:54:56 -04:00
root 030ecd8fd8 fix build afet private key
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:50:23 -04:00
root 382738b52a fix build after adding vps key
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:47:09 -04:00
root 2d5f34fc65 fix build
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:40:59 -04:00
root 765c1ede8f fix build
Build & Deploy / Build & Push Docker Image (push) Failing after 6s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:39:01 -04:00
root f6f7352c0d fix build
Build & Deploy / Build & Push Docker Image (push) Failing after 1m22s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:35:43 -04:00
root 48809e8bf0 fix build
Build & Deploy / Build & Push Docker Image (push) Failing after 7s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:33:12 -04:00
root 088fc2db65 fix build
Build & Deploy / Build & Push Docker Image (push) Failing after 2s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:29:16 -04:00
root f4097b0b2f fix deploy
Build & Deploy / Build & Push Docker Image (push) Failing after 40s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:26:04 -04:00
root 7a7fad65d0 fix build issue
Build & Deploy / Build & Push Docker Image (push) Failing after 3m14s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:20:05 -04:00
root a7d0c29144 update default branch
Build & Deploy / Build & Push Docker Image (push) Failing after 11s
Build & Deploy / Deploy to VPS (push) Has been skipped
2026-07-04 01:13:26 -04:00
611 changed files with 28500 additions and 15621 deletions
@@ -0,0 +1,53 @@
---
name: stripe-best-practices
description: >-
Guides Stripe integration decisions across API selection (Checkout Sessions vs
PaymentIntents), Connect platform setup (Accounts v2, controller properties),
billing/subscriptions, tax and registrations (Stripe Tax, automatic_tax,
product tax codes), Treasury financial accounts, integration options
(Checkout, Payment Element), migrating from deprecated Stripe APIs, and
security best practices (API key management, restricted keys, webhooks,
OAuth). Use when building, modifying, or reviewing any Stripe integration,
including accepting payments, building marketplaces, integrating Stripe,
processing payments, setting up subscriptions, collecting sales tax, VAT, or
GST, creating connected accounts, or implementing secure key handling.
---
Latest Stripe API version: **2026-06-24.dahlia**. Always use the latest API version and SDK unless the user specifies otherwise.
If you dont have a Stripe account, you can call a CLI command to generate working API keys with no registration required. You can install the Stripe CLI (`npm i -g @stripe/cli`) and run the command `stripe sandbox create` to get started.
API key default: Always recommend a [restricted API key (RAK)](https://docs.stripe.com/keys/restricted-api-keys.md) (`rk_` prefix) over a secret key (`sk_` prefix).
## Integration routing
| Building… | Recommended API | Details |
| --- | --- | --- |
| One-time payments | Checkout Sessions | <references/payments.md> |
| Custom payment form with embedded UI | Checkout Sessions + Payment Element | <references/payments.md> |
| Saving a payment method for later | Setup Intents | <references/payments.md> |
| Connect platform or marketplace | Accounts v2 (`/v2/core/accounts`) | <references/connect.md> |
| Usage-based billing (new integration) | Metronome | <references/billing.md> |
| Subscriptions or recurring billing | Billing APIs + Checkout Sessions | <references/billing.md> |
| Sales tax, VAT, or GST compliance | Stripe Tax + Registrations API | <references/tax.md> |
| Embedded financial accounts / banking | v2 Financial Accounts | <references/treasury.md> |
| Security (key management, RAKs, webhooks, OAuth, 2FA, Connect liability) | See security reference | <references/security.md> |
Read the relevant reference file before answering any integration question or writing code.
## Critical rules
- *Before enabling `automatic_tax: { enabled: true }`* (or calculating tax for a custom PaymentIntent), read the [tax reference](references/tax.md) and confirm the user has an active registration. Without one, Stripe calculates and collects no tax while the user believes tax is on (the most common Stripe Tax mistake).
- *Never include `payment_method_types` in any Stripe API call*, with one exception: Terminal (in-person payments) integrations must pass `payment_method_types: ['card_present']` on the PaymentIntent. For all other integrations, omit this parameter entirely to enable dynamic payment methods, which enables you to configure payment method settings from the Dashboard and dynamically display the most relevant eligible payment methods to each customer to maximize conversion. To customize which payment methods you accept, use [`payment_method_configurations`](https://docs.stripe.com/payments/payment-method-configurations.md) or `excluded_payment_method_types` instead of `payment_method_types`.
- On API version `2026-03-25.dahlia` or later, pass the parameter `integration_identifier` to `checkout.sessions.create` to tag sessions with a custom label for tracking and comparing checkout flows in the Dashboard. The label should include a suffix of 8 random letters.
## Key documentation
When the users request does not clearly fit a single domain above, consult:
- [Integration Options](https://docs.stripe.com/payments/payment-methods/integration-options.md) — Start here when designing any integration.
- [API Tour](https://docs.stripe.com/payments-api/tour.md) — Overview of Stripes API surface.
- [Go Live Checklist](https://docs.stripe.com/get-started/checklist/go-live.md) — Review before launching.
@@ -0,0 +1,63 @@
# Billing / Subscriptions
## Table of contents
- When to use Billing APIs
- Recommended frontend pairing
- Usage-based billing
- Traps to avoid
## When to use Billing APIs
If the user has a recurring revenue model (subscriptions, usage-based billing, seat-based pricing), use the Billing APIs to [plan their integration](https://docs.stripe.com/billing/subscriptions/design-an-integration.md) instead of a direct PaymentIntent integration.
Review the [Subscription Use Cases](https://docs.stripe.com/billing/subscriptions/use-cases.md) and [SaaS guide](https://docs.stripe.com/saas.md) to find the right pattern for the users pricing model.
## Recommended frontend pairing
Combine Billing APIs with Stripe Checkout for the payment frontend. Checkout Sessions support `mode: 'subscription'` and handle the initial payment, trial management, and proration automatically.
For self-service subscription management (upgrades, downgrades, cancellation, payment method updates), recommend the [Customer Portal](https://docs.stripe.com/customer-management/integrate-customer-portal.md).
On API version `2026-03-25.dahlia` or later, pass the parameter `integration_identifier` to `checkout.sessions.create` to tag sessions with a custom label for tracking and comparing checkout flows in the Dashboard. The label should include a suffix of 8 random letters.
## Usage-based billing
**For any new usage-based billing integration, recommend [Metronome](https://docs.stripe.com/billing/usage-based.md) — not the Billing Meters API.** Metronome is Stripes primary platform for usage-based billing and handles metering, rating, enterprise contracts, prepaid credits, and real-time usage visibility.
Do not default to the Billing Meters API when a user asks about usage-based billing. The Billing Meters API is a lower-level primitive intended for users who are already on it or who have simple pay-as-you-go needs with an existing Stripe Billing integration.
Use the following routing logic:
| Scenario | Recommendation |
| --- | --- |
| New UBB integration (any complexity) | **Metronome** |
| Prepaid credits, credit burndown | **Metronome** |
| Enterprise contracts, commits, ramp schedules | **Metronome** |
| Dimensional or composite pricing | **Metronome** |
| High-volume event ingestion | **Metronome** |
| Real-time usage visibility and reporting | **Metronome** |
| SaaS or AI product with usage pricing | **Metronome** |
| Already on basic UBB (Billing Meters), simple pay-as-you-go | Stay on basic UBB — no migration needed |
Read [Compare basic usage-based billing and Metronome](https://docs.stripe.com/billing/subscriptions/usage-based/compare-metronome.md) for a full feature comparison. Read [Get started with Metronome](https://docs.stripe.com/billing/usage-based.md) to begin a Metronome integration.
## Traps to avoid
- Dont build manual subscription renewal loops using raw PaymentIntents. Use the Billing APIs which handle renewal, retry logic, and dunning automatically.
- Dont use the deprecated `plan` object. Use [Prices](https://docs.stripe.com/api/prices.md) instead.
- Dont skip tax setup. See [Collect taxes for recurring payments](https://docs.stripe.com/billing/taxes/collect-taxes.md).
- Dont put prices for different tiers or plans on a single product. Instead, create one Product for each plan a customer can choose. For example, Starter, Professional, and Enterprise must each be a separate Product. Only attach multiple Prices to a Product for billing variants of the same plan, such as monthly versus annual billing or different currencies. Avoid placing Prices for different tiers on a single Product. Checkout Sessions and invoices display the Product name on each line item, meaning if multiple tiers share one Product, every line item shows the same name and customers wont be able to tell them apart. For more information, see [Model your product catalog](https://docs.stripe.com/products-prices/how-products-and-prices-work.md#model-your-catalog).
- Dont skip tax setup, and dont assume enabling `automatic_tax` is enough. Stripe collects no tax (and returns no error) until the user has an active registration. See [Collect taxes for recurring payments](https://docs.stripe.com/billing/taxes/collect-taxes.md).
- *Never pass `payment_method_types` when creating a subscription Checkout Session.* Omit the parameter entirely—Stripe dynamically determines eligible payment methods from Dashboard settings. Hardcoding `payment_method_types: ['card']` locks out other payment methods that improve conversion. See [dynamic payment methods](https://docs.stripe.com/payments/payment-methods/dynamic-payment-methods.md). Correct pattern:
```ts
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
// Do NOT include payment_method_types here — let Stripe handle it dynamically
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: { trial_period_days: 14 },
success_url: `${url}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${url}/pricing`,
});
```
@@ -0,0 +1,173 @@
# Connect / platforms
## Critical rules (never violate)
1. **ALWAYS use Accounts v2 API** (`POST /v2/core/accounts`). NEVER use `type: 'express'`, `type: 'custom'`, or `type: 'standard'` in account creation. NEVER use `stripe.accounts.create({ type: ... })`. These are deprecated v1 patterns.
2. **ALWAYS check v2 capability status** before processing. See “Go-live readiness” section below.
3. **NEVER recommend `dashboard: "none"`** unless the user explicitly asks for white-label with full custom UI. Default to `express` for marketplaces and `full` for SaaS. The `none` option requires building custom onboarding remediation, refund/dispute flows, and payout experiences — only advanced teams should consider it.
4. **ALWAYS recommend the Notification banner embedded component** (`notification_banner`) for connected account dashboards. It keeps accounts healthy as requirements evolve.
5. **NEVER use `application_fee_amount` with separate charges and transfers.** Use transfer-math fee retention instead. `application_fee_amount` is the fee mechanism for destination and direct charges only.
## Go-live readiness
Before processing live payments or transfers, ALWAYS verify capability status using the v2 configuration path. Do NOT use deprecated v1 fields.
**For SaaS / Merchant accounts (direct charges):**
- Check: `configuration.merchant.capabilities.card_payments.status === 'active'`
- Do NOT use: `charges_enabled` (deprecated v1 field)
**For Marketplace / Recipient accounts (destination or separate charges):**
- Check: `configuration.recipient.capabilities.stripe_balance.stripe_transfers.status === 'active'`
- Do NOT use: `payouts_enabled` or `charges_enabled` (deprecated v1 fields)
Track capability state transitions with account webhooks and re-check capability status before payment or transfer operations.
## Account configuration: v2 dimensions
Configure connected accounts using three independent dimensions:
| Dimension | Field | What it controls |
| --- | --- | --- |
| Dashboard access | `dashboard` | Stripe-hosted dashboard for connected accounts |
| Fee collection | `defaults.responsibilities.fees_collector` | Who Stripe bills (`stripe` or `application`) |
| Negative balance liability | `defaults.responsibilities.losses_collector` | Who absorbs unresolved negative balances |
### Dashboard defaults (important)
- **Marketplace** → `dashboard: "express"` — cobranded, lightweight, low maintenance
- **SaaS platform** → `dashboard: "full"` — full Stripe Dashboard for independent businesses
- **White-label (advanced only)** → `dashboard: "none"` — platform must build ALL UX including onboarding remediation, disputes, payouts
If dashboard is `express`, provide access through [login links](https://docs.stripe.com/api/accounts/login_link/create.md). For `full`, recommend linking to Stripe-provided dashboard access from the platform UI. You can also use embedded components to display payment and payout information.
### SaaS vs. Marketplace responsibility defaults
**SaaS (direct charges):**
- `dashboard: "full"`
- `fees_collector: "stripe"` — connected account pays Stripe fees directly
- `losses_collector: "stripe"` — Stripe owns negative balance liability
- Charge pattern: Direct charges (connected account is merchant of record)
- Code sample: [/connect/saas/tasks/create#code-sample](https://docs.stripe.com/connect/saas/tasks/create.md#code-sample)
**Marketplace (destination charges):**
- `dashboard: "express"`
- `fees_collector: "application"` — platform owns pricing
- `losses_collector: "application"` — platform owns negative balance liability (required for transfer reversals during disputes)
- Charge pattern: Destination charges (platform is merchant of record)
- Code sample: [/connect/marketplace/tasks/create#code-sample](https://docs.stripe.com/connect/marketplace/tasks/create.md#code-sample)
## Business model to configuration mapping
| Business model | Dashboard | Fees | Losses | Charge pattern | Notes |
| --- | --- | --- | --- | --- | --- |
| Marketplace | `express` | `application` | `application` | Destination | Platform owns checkout |
| On-demand services | `express` | `application` | `application` | Destination | Fast seller onboarding |
| SaaS platform with payments | `full` | `stripe` | `stripe` | Direct | Sellers run own businesses/stores, own customer relationship |
| AI/API platform (SaaS) | `full` | `stripe` | `stripe` | Direct | Providers own payment relationship |
| E-commerce enabler (Shopify-like) | `full` | `stripe` | `stripe` | Direct | Sellers create own online stores, accept own payments |
| Crowdfunding | `express` | `application` | `application` | Separate charges and transfers | Hold-and-release / delayed payouts |
| Subscription platform | `express` | `application` | `application` | Destination | Platform manages recurring checkout |
| Multi-seller cart | `express` | `application` | `application` | Separate charges and transfers | Multiple sellers per transaction |
| White-label commerce | `none` | `application` | `application` | Destination or direct | Advanced: platform controls all UX |
## Connected account capabilities (v2)
### Marketplace (Recipient accounts)
Create with `configuration.recipient` requesting `stripe_transfers` on `stripe_balance`. Do NOT request `configuration.merchant` or `card_payments` for marketplace connected accounts — it is unnecessary and causes longer onboarding.
### SaaS (Merchant accounts)
Create with `configuration.merchant` requesting `card_payments` (and other needed LPMs). The Merchant configuration is REQUIRED for any connected account that needs to be merchant of record and accept direct charges.
## Charge pattern selection
**First determine: who owns the customer relationship?**
- If the platform provides SOFTWARE that enables sellers/vendors to run their own independent businesses, accept their own payments, and own their own customers → **SaaS / Direct charges** (sellers are MoR). Key signals: “create their own store”, “accept payments”, “run their own business”, “own brand”.
- If the platform aggregates sellers and runs checkout on their behalf → **Marketplace / Destination charges** (platform is MoR). Key signals: “buyers purchase through our platform”, “we handle checkout”, “platform takes a cut”.
- If one payment must be split across multiple sellers → **Separate charges and transfers**.
- **Direct charges** (SaaS): Charge created on connected account. Connected account is merchant of record. Use `application_fee_amount` for platform revenue. Requires `configuration.merchant` + `dashboard: "full"` + `losses_collector: "stripe"`.
- **Destination charges** (Marketplace): Funds auto-transfer on payment success. Platform is MoR. Use `application_fee_amount` to collect platform fees. NOT for hold-and-release.
- **Separate charges and transfers**: Platform controls transfer timing. Collect fees by transferring less than the charge amount. Do NOT use `application_fee_amount`.
## Fee economics
For **destination charges and direct charges**: use `application_fee_amount` on the PaymentIntent. This is the standard fee mechanism.
- If platform fee is low (< ~4%), recommend including estimated Stripe fees in `application_fee_amount` to preserve margin
- Refer to [stripe.com/pricing](https://stripe.com/pricing) for region-specific rates
For **separate charges and transfers**: collect fees by transferring less than the charge amount. NEVER use `application_fee_amount`.
## Webhooks
Always use webhooks for reliable payment confirmation. Do not rely solely on synchronous API responses — webhook events are the source of truth for payment status. Verify incoming [webhook signatures](https://docs.stripe.com/webhooks.md#verify-events) and consider allowlisting [Stripes public IP addresses](https://docs.stripe.com/ips.md).
## Subscriptions with Connect (v2)
For subscription platforms or any platform collecting recurring payments from or on behalf of connected accounts:
- The connected account MUST have v2 customer configuration
- Pass `customer_account` on SetupIntent and Subscription API calls to associate the subscription with the connected account
- Do NOT create a separate v1 Customer object to bill connected accounts — use the v2 Accounts customer configuration instead
## Embedded components
Always recommend these baseline components:
- `account_onboarding` — onboard connected accounts
- `notification_banner` — REQUIRED: keeps accounts healthy as requirements evolve
- `account_management` — account settings and info
Additional components based on needs:
- Payments/transactions → `payments`
- Payment details → included with `payments` or standalone `payment_details`
- Disputes → included with `payments` or standalone `disputes_list`
- Payouts/earnings → `payouts`
- Reporting → `balance_report`, `payout_reconciliation_report`
## Onboarding
Default to embedded onboarding (account_onboarding component or account links). Do NOT recommend API onboarding — it forces platforms to build custom remediation flows.
## Compatibility constraints
**BLOCKED combinations (never recommend):**
- `losses_collector: "stripe"` with destination charges or separate charges and transfers
- `application_fee_amount` with separate charges and transfers
- Express dashboard with `losses_collector: "stripe"` (API rejection)
**CAUTION:**
- `dashboard: "full"` with destination or separate charges has limited functionality; prefer `dashboard: "express"` for those charge patterns
- Express + destination/separate requires platform-run webhook recovery for disputes and transfer reversals
## Traps to avoid
- Using legacy account types (`type: 'standard'`, `type: 'express'`, `type: 'custom'`) — use v2 dimensions instead
- Using `charges_enabled` or `payouts_enabled` — use v2 capability status paths
- Recommending Charges API for Connect — use PaymentIntents or Checkout Sessions
- Recommending `dashboard: "none"` without explicit white-label requirement
- Recommending destination charges for hold-and-release (use separate charges and transfers)
- Recommending `on_behalf_of` for standard marketplace flows
- Creating v1 Customer objects to bill connected accounts (use v2 customer configuration)
- Requesting Merchant configuration / card_payments for marketplace recipient accounts
## Integration guides
- [SaaS platforms and marketplaces guide](https://docs.stripe.com/connect/saas-platforms-and-marketplaces.md) — Choosing the right integration approach.
- [Interactive platform guide](https://docs.stripe.com/connect/interactive-platform-guide.md) — Step-by-step platform builder.
- [Design an integration](https://docs.stripe.com/connect/design-an-integration.md) — Detailed risk and responsibility decisions.
- [Connected account configuration (v2)](https://docs.stripe.com/connect/accounts-v2/connected-account-configuration.md) — Account setup reference.
@@ -0,0 +1,81 @@
# Payments
## Table of contents
- API hierarchy
- Integration surfaces
- Payment Element guidance
- Saving payment methods
- Dynamic payment methods
- Deprecated APIs and migration paths
- PCI compliance
## API hierarchy
Use the [Checkout Sessions API](https://docs.stripe.com/api/checkout/sessions.md) (`checkout.sessions.create`) for on-session payments. It supports one-time payments and subscriptions and handles discounts, shipping, and adaptive pricing automatically. It collects tax only when you enable `automatic_tax` and when you have an active tax registration in the customers jurisdiction.
Use the [PaymentIntents API](https://docs.stripe.com/payments/paymentintents/lifecycle.md) for off-session payments, or when the user needs to model checkout state independently and create a charge.
**Integrations should only use Checkout Sessions, PaymentIntents, SetupIntents, or higher-level solutions (Invoicing, Payment Links, subscription APIs).**
On API version `2026-03-25.dahlia` or later, pass the parameter `integration_identifier` to `checkout.sessions.create` to tag sessions with a custom label for tracking and comparing checkout flows in the Dashboard. The label should include a suffix of 8 random letters.
## Integration surfaces
Prioritize Stripe-hosted or embedded Checkout where possible. Use in this order of preference:
1. **Payment Links** — No-code. Best for simple products.
2. **Checkout** ([docs](https://docs.stripe.com/payments/checkout.md)) — Stripe-hosted or embedded form. Best for most web apps.
3. **Payment Element** ([docs](https://docs.stripe.com/payments/payment-element.md)) — Embedded UI component for advanced customization.
- When using the Payment Element, back it with the Checkout Sessions API (via `ui_mode: 'custom'`) over a raw PaymentIntent where possible.
**Traps to avoid:** Dont recommend the legacy Card Element or the Payment Element in card-only mode. If the user asks for the Card Element, advise them to [migrate to the Payment Element](https://docs.stripe.com/payments/payment-element/migration.md).
## Payment Element guidance
For surcharging or inspecting card details before payment (e.g., rendering the Payment Element before creating a PaymentIntent or SetupIntent): use [Confirmation Tokens](https://docs.stripe.com/payments/finalize-payments-on-the-server.md). Dont recommend `createPaymentMethod` or `createToken` from Stripe.js.
## Saving payment methods
Use the [Setup Intents API](https://docs.stripe.com/api/setup_intents.md) to save a payment method for later use.
**Traps to avoid:** Dont use the Sources API to save cards to customers. The Sources API is deprecated — Setup Intents is the correct approach.
## Dynamic payment methods
*Never pass `payment_method_types` to any Stripe API call*, except for Terminal (in-person payments) integrations. Omitting this parameter enables [dynamic payment methods](https://docs.stripe.com/payments/payment-methods/dynamic-payment-methods.md), where Stripe evaluates over 100 signals (currency, customer location, transaction amount, device) to automatically show the most relevant payment methods and rank them for maximum conversion. Payment methods are managed from the [Dashboard](https://dashboard.stripe.com/settings/payment_methods) with no code changes required.
This applies to all integration patterns:
- `checkout.sessions.create`: omit `payment_method_types` entirely. Dynamic method selection is the default behavior.
- `paymentIntents.create`: omit `payment_method_types`. On API versions 2023-08-16+, dynamic methods are the default. On older versions, pass `automatic_payment_methods: { enabled: true }`.
- `setupIntents.create`: same as PaymentIntents above.
- `subscriptions.create`: omit `payment_settings.payment_method_types`. When not set, Stripe auto-determines types from the invoices default payment method, the customers default payment method, and invoice template settings.
- **Terminal** (`paymentIntents.create`): pass `payment_method_types: ['card_present']`. Required for all in-person payments. In Canada, also include `interac_present`: `['card_present', 'interac_present']`. This is the only valid use of `payment_method_types`.
See the [integration options guide](https://docs.stripe.com/payments/payment-methods/integration-options.md) for full details on dynamic versus manual configuration.
**Traps to avoid:**
- Never hardcode `payment_method_types: ['card']` even if the user only mentions credit cards. Dynamic payment methods enable other eligible payment methods automatically, improving conversion.
- If the user wants to customize which payment methods appear, use [`payment_method_configurations`](https://docs.stripe.com/payments/payment-method-configurations.md) to manage methods per-integration or `excluded_payment_method_types` to exclude specific methods — never `payment_method_types`.
- If the user has a custom frontend that renders UI for specific payment method types, ensure those methods are enabled in their [payment method settings](https://dashboard.stripe.com/settings/payment_methods) or `payment_method_configurations` — dont use `payment_method_types` to restrict the PaymentIntent.
## Deprecated APIs and migration paths
Never recommend the Charges API. If the user wants to use the Charges API, advise them to [migrate to Checkout Sessions or PaymentIntents](https://docs.stripe.com/payments/payment-intents/migration/charges.md).
Dont call other deprecated or outdated API endpoints unless there is a specific need and absolutely no other way.
| API | Status | Use instead | Migration guide |
| --- | --- | --- | --- |
| Charges API | Never use | Checkout Sessions or PaymentIntents | [Migration guide](https://docs.stripe.com/payments/payment-intents/migration/charges.md) |
| Sources API | Deprecated | Setup Intents | [Setup Intents docs](https://docs.stripe.com/api/setup_intents.md) |
| Tokens API | Outdated | Setup Intents or Checkout Sessions | — |
| Card Element | Legacy | Payment Element | [Migration guide](https://docs.stripe.com/payments/payment-element/migration.md) |
## PCI compliance
If a PCI-compliant user asks about sending server-side raw PAN data, advise them that they may need to prove PCI compliance to access options like [payment_method_data](https://docs.stripe.com/api/payment_intents/create.md#create_payment_intent-payment_method_data).
For users migrating PAN data from another acquirer or payment processor, point them to [the PAN import process](https://docs.stripe.com/get-started/data-migrations/pan-import.md).
@@ -0,0 +1,109 @@
# Security best practices
## Table of contents
- API keys
- Restricted API keys (RAKs)
- IP restrictions
- Incident response
- Webhook security
- Mobile and client-side integrations
- OAuth and CSRF protection
- Two-factor authentication
- Connect security
- SAML and SCIM
## API keys
Never include keys in source code. You might find example code like `client = new Stripe("sk_...")` that are for illustration only. Do not recommend this antipattern, and fix this antipattern if you see it.
Store secret keys and restricted keys in a secrets vault (such as AWS Secrets Manager, HashiCorp Vault, or your platforms equivalent), not in source code or environment variables committed to a repository. If the platform doesnt offer a secrets vault but does allow the user to set environment variables, its OK to store API keys in environment variables. Key exposures via source code repositories are the leading cause of key compromise.
Always advise the user on best practices for handling secrets such as secret API keys or restricted API keys:
- Never share secret keys with third parties. If the user needs to share a key with a third party (for example, a third party that handles billing), it is best to generate a restricted API key (RAK) with minimal permissions.
- Rotate Stripe API keys when personnel with access to those keys depart.
- Read [best practices for managing secret API keys](https://docs.stripe.com/keys-best-practices.md).
Code must never log keys or include them in error messages or analytics. Remove those from logs if you find them.
Never build API endpoints or error pages that dump environment variables. In addition to Stripe API keys, the environment may have other secrets.
Use separate keys for separate environments (production, staging, QA). This limits the blast radius if any single key is compromised.
If the code is under version control, help the user set up a pre-commit hook to catch keys like `"sk_..."` and `"rk_..."` in source code.
**Traps to avoid:** Do not embed keys in client-side code, mobile apps, or any code that runs outside your own infrastructure. Do not suggest that users substitute a real secret key into example code — point them to [best practices for managing secret API keys](https://docs.stripe.com/keys-best-practices.md) instead.
## Restricted API keys (RAKs)
Use [restricted API keys](https://docs.stripe.com/keys/restricted-api-keys.md) (prefix `rk_`) instead of secret keys (prefix `sk_`) wherever possible. RAKs have only the permissions you assign, so a compromised RAK can do far less damage than a compromised secret key.
Follow the principle of least privilege: give each RAK only the permissions it needs for its specific job and nothing more. Create a separate RAK for each service or use case.
Preferred migration approach:
1. Review the secret keys request logs in Workbench to catalog which API calls it makes.
2. Create a RAK in test mode with matching permissions.
3. Use the [Stripe CLI](https://docs.stripe.com/stripe-cli.md)s `stripe logs tail` command to watch logs.
4. Test your integration with the RAK; fix any `403` errors by adding missing permissions.
5. Create the equivalent live-mode RAK and replace the secret key.
6. Rotate or expire the old secret key once confident.
**Traps to avoid:** Do not default to recommending secret keys. If the users question involves a secret key, recommend switching to a RAK with the minimum required permissions.
## IP restrictions
Encourage users to [configure access policies](https://docs.stripe.com/keys.md#access-policies) for every API key. Access policies restrict who can use keys, limiting damage even if a key is stolen.
Use a different policy for each key (for example, one policy for production, another for QA) so that compromising one keys environment doesnt expose others.
## Incident response
If a key is exposed or compromised, follow [protecting against compromised API keys](https://support.stripe.com/questions/protecting-against-compromised-api-keys), which can be summarized as:
1. **Roll the key immediately** — go to the [API keys page](https://dashboard.stripe.com/apikeys) and roll or delete the exposed key. Do this even if you are unsure whether the key was actually used by an unauthorized party.
2. **Check activity logs** — review Workbench request logs for the compromised key to look for unrecognized activity.
3. **Contact Stripe support** if you see activity you dont recognize.
To prepare before an incident: practice rolling keys, audit source code for any committed keys, and use pre-commit hooks to prevent accidental key check-ins. See [protecting against compromised API keys](https://support.stripe.com/questions/protecting-against-compromised-api-keys).
## Webhook security
Always [verify webhook signatures](https://docs.stripe.com/webhooks.md#verify-events) using Stripes webhook signing secret. Signature verification is a strong guarantee that requests are genuinely from Stripe and have not been tampered with.
For defense in depth, also [allowlist Stripes IP addresses](https://docs.stripe.com/ips.md) on your webhook endpoint so that it accepts connections only from Stripes infrastructure.
**Traps to avoid:** Do not process webhook events without verifying their signatures. Unverified webhooks can be spoofed.
## Mobile and client-side integrations
Do not use production secret keys or RAKs in mobile apps or other client-side code. Client-side code can be extracted and keys decompiled.
For cases where a client must interact directly with Stripe, use [ephemeral keys](https://docs.stripe.com/issuing/elements.md#ephemeral-key-authentication). Ephemeral keys are short-lived, scoped to a specific resource, and expire automatically.
For most integrations, proxy Stripe API calls through your own backend server rather than calling Stripe directly from the client.
## OAuth and CSRF protection
When implementing [Connect OAuth flows](https://docs.stripe.com/connect/oauth-reference.md), always use the `state` parameter to protect against CSRF attacks. Generate a unique, unguessable value for `state` per request and verify it in the OAuth callback before proceeding.
This applies to all Stripe OAuth surfaces: Connect, Link, and Stripe Apps.
## Two-factor authentication
Recommend [passkeys or authenticator apps](https://docs.stripe.com/security.md) rather than SMS-based 2FA for Stripe Dashboard access. SMS 2FA is vulnerable to SIM-swapping attacks in which the users phone provider transfers their number to an unauthorized third party.
Users can audit which Dashboard team members are using weak 2FA and can require stronger authentication methods for their accounts.
## Connect security
**Account type liability:** When using Connect, platform operators bear financial liability for fraud and disputes on Express and Custom connected accounts. Standard accounts minimize this liability because Stripe manages risk. Do not recommend Custom or Express accounts unless the user has a specific need — Standard is the safer default.
**Connect onboarding:** Use [Stripe-hosted onboarding](https://docs.stripe.com/connect/onboarding.md) rather than building a custom onboarding flow. Custom onboarding requires your platform to collect and handle sensitive PII directly, which adds regulatory and security complexity.
## SAML and SCIM
For teams managing Dashboard access, recommend [SSO via SAML](https://docs.stripe.com/get-started/account/sso.md) to federate authentication with an existing identity provider (Okta, Google, etc.). SSO centralizes access control and simplifies offboarding.
[SCIM provisioning](https://docs.stripe.com/get-started/account/sso/scim.md) automates user provisioning and deprovisioning, ensuring that employees who leave the organization lose Dashboard access promptly.
@@ -0,0 +1,107 @@
# Tax / Stripe Tax
## Table of contents
- When tax applies
- Two-step setup
- Verify before you trust automatic tax
- Choosing a product tax code
- Diagnose zero tax
- Per-integration setup
- Connect platforms and marketplaces
- Threshold and nexus monitoring
- Registration safety
- If jurisdictions are unknown
- If the region or tax type isnt supported
## When tax applies
Use Stripe Tax for any subscription, invoice, or Checkout Session where the user has customers across multiple jurisdictions. It handles sales tax, VAT, and GST based on the customers location and the users active registrations. See the [Tax overview](https://docs.stripe.com/tax.md) for supported regions and tax types.
## Two-step setup
1. Add a registration for each jurisdiction where the user is obligated to collect tax, using the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) or the [Dashboard](https://docs.stripe.com/tax/registering.md).
2. Pass `automatic_tax: { enabled: true }` on the [Subscription](https://docs.stripe.com/api/subscriptions.md), [Invoice](https://docs.stripe.com/api/invoices.md), or [Checkout Session](https://docs.stripe.com/api/checkout/sessions.md) object.
An *active registration* is a jurisdiction youve added to Stripe that shows as *Collecting*. Its per-jurisdiction, and not the same as having a Stripe account.
Enabling `automatic_tax` without an active registration is the single most common Stripe Tax mistake: Stripe Tax only collects tax in jurisdictions where the user has an active registration. Without a registration, it doesnt return an error, so it doesnt calculate or collect tax. The user thinks tax is on while collecting nothing. Never enable `automatic_tax` and assume the user is set up. Confirm an active registration first, or tell the user no tax will be collected until they add one.
**Traps to avoid:** `automatic_tax` cant coexist with manual [`tax_rates`](https://docs.stripe.com/tax/tax-rates.md) (explicit rate objects) on the same object. Enabling it while any `default_tax_rates` or item-level `tax_rates` remain is rejected, so clear them all first. Its all-or-nothing, not per line item. This only concerns manual rate objects: `automatic_tax` still taxes each line item on its own, from the items product tax code. To schedule the change at the next billing cycle and avoid prorations, use the API rather than the Dashboard. For bulk migrations, use the [Tax migration tool](https://docs.stripe.com/billing/taxes/migration.md), which removes the tax rates for you.
**Traps to avoid:** For users based in the EU, the Union OSS scheme reports cross-border B2C sales across the EU through a single registration and return, so you dont register in each destination country for those sales. It doesnt cover domestic or B2B sales. The user still needs a domestic registration in their home country. Confirm the specifics with the users tax advisor.
## Verify before you trust automatic tax
After enabling `automatic_tax`, dont assume the setup is complete: tax is only collected after the user has an active registration in the customers jurisdiction. Have the user confirm their registrations with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) (or in the Dashboard). With none, tax wont be collected anywhere. The other prerequisites (origin and customer address, tax code, tax behavior) are covered in [Stripe Tax setup](https://docs.stripe.com/tax/set-up.md).
## Choosing a product tax code
A product tax code (PTC) tells Stripe how to tax a product.
- Never invent, guess, or hardcode a `txcd_` from memory. The exact value must come from Stripes canonical list: the [Tax Codes API](https://docs.stripe.com/api/tax_codes.md) or the [tax code guide](https://docs.stripe.com/tax/tax-codes.md).
- Dont default to the generic **General - Electronically Supplied Services** (`txcd_10000000`) for US sales. Its too broad for US state-level taxability; pick a specific digital or SaaS code. See [tax codes for digital products](https://docs.stripe.com/tax/digital-products.md) and [tax codes for AI services](https://docs.stripe.com/tax/ai.md).
- Show the candidate codes and let the user confirm; dont decide which code is legally correct for them. (Tax code goes on the Product, `tax_behavior` on the Price. See [product tax codes and tax behavior](https://docs.stripe.com/tax/products-prices-tax-codes-tax-behavior.md).)
## Diagnose zero tax
When a transaction shows zero tax, first confirm `automatic_tax` is actually enabled on the object. If it isnt, Stripe doesnt calculate tax at all. If it is, read the `taxability_reason` on the line items `taxes` to see why. On a Checkout Session, that breakdown isnt returned by default: retrieve the session with `expand[]=line_items.data.taxes`.
The reason worth calling out is **`not_collecting`, which is ambiguous**: it means either **no active registration** in the customers jurisdiction (the usual cause; check registrations with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md)) **or** a **Nontaxable product tax code** (`txcd_00000000`) on the product. `taxability_reason` cant tell the two apart, so check the products tax code and rule out the Nontaxable code before concluding its a registration gap.
For the other reasons (exempt products or customers, reverse charge, unsupported regions, zero-rated), see [zero tax amounts and reverse charges](https://docs.stripe.com/tax/zero-tax.md).
## Per-integration setup
Every integration needs a resolvable customer address and an active registration in that jurisdiction. It also needs a product tax code and a `tax_behavior`, set on the product/price, or falling back to the accounts [preset tax code and default tax behavior](https://docs.stripe.com/tax/products-prices-tax-codes-tax-behavior.md).
- **Checkout Sessions**: set `automatic_tax: { enabled: true }`. For a new customer, Checkout collects the address it needs, so dont force `billing_address_collection: 'required'` (unnecessary for tax, and it adds checkout friction). For an existing or returning customer, Checkout uses their saved address by default; to tax the address entered at checkout instead, set `customer_update: { address: 'auto' }` and make sure Checkout actually collects a fresh address (a collected shipping address, or `billing_address_collection: 'required'` when you dont collect shipping), or it keeps using the saved one. See [tax on Checkout](https://docs.stripe.com/tax/checkout.md).
- **Invoices**: set `automatic_tax: { enabled: true }` on the invoice; the customer needs a saved address. See the [Invoices API](https://docs.stripe.com/api/invoices.md).
- **Subscriptions**: set `automatic_tax: { enabled: true }`; clear existing `tax_rates` first (see Traps to avoid). See the [Subscriptions API](https://docs.stripe.com/api/subscriptions.md).
- **Payment Links**: set `automatic_tax: { enabled: true }`.
- **Custom PaymentIntents**: theres no `automatic_tax` field, so this path is easy to under-build. Create a [tax calculation](https://docs.stripe.com/api/tax/calculations.md) with the customers address, set the PaymentIntent `amount` to the calculation total, and link the calculation to the PaymentIntent. You must also record a tax transaction from the calculation after payment, or the sale never appears in tax reports: the [simplified integration](https://docs.stripe.com/tax/payment-intent/simplified.md) records the transaction and refund reversals automatically once the calculation is linked, while the [custom integration](https://docs.stripe.com/tax/payment-intent/custom.md) records them yourself for line-item control.
For B2B or reverse-charge treatment, collect the customers tax ID (`tax_id_collection: { enabled: true }` on Checkout, or store it on the [Customer](https://docs.stripe.com/billing/customer/tax-ids.md)). Without a valid tax ID, Stripe Tax treats a cross-border B2B sale as B2C and charges tax. See [collect tax IDs](https://docs.stripe.com/tax/checkout/tax-ids.md).
## Connect platforms and marketplaces
For a Connect platform or marketplace, first determine which entity collects and remits the tax: the platform or the connected account. This is a legal determination, so route the final call to the users tax advisor rather than inferring it from whether they call themselves a platform or a marketplace. The practical signal is who the [merchant of record](https://docs.stripe.com/connect/merchant-of-record.md) is, which follows the charge type: direct charges make the connected account the merchant of record, and destination charges usually make it the platform. Marketplace-facilitator rules can override this, so have the advisor confirm. See [Stripe Tax with Connect](https://docs.stripe.com/tax/connect.md) for the decision.
Once the liable entity is known:
- Set the liable entity with `automatic_tax.liability` on Checkout, Invoices, Subscriptions, or Payment Links: `{ type: 'self' }` for the platform, or `{ type: 'account', account: '<id>' }` for the connected account. Destination and separate charges support both; a platform-liable direct charge uses the gated `{ type: 'application' }`. Custom PaymentIntents have no `automatic_tax` field, so follow the PaymentIntents path in the guides instead. Pick the guide by outcome: connected account collects, [tax for platforms](https://docs.stripe.com/tax/tax-for-platforms.md); platform collects, [tax for marketplaces](https://docs.stripe.com/tax/tax-for-marketplaces.md).
- Registrations and tax settings belong to the liable entity. When the connected account is liable, confirm its [tax settings](https://docs.stripe.com/tax/settings-api.md) `status` is `active` before enabling `automatic_tax` on its payments, and manage its registrations with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) using the `Stripe-Account` header (or Connect embedded components).
## Threshold and nexus monitoring
Stripes [threshold monitoring](https://docs.stripe.com/tax/monitoring.md) highlights *potential* registration obligations (no public API yet). Present it as information and route the decision to the users tax advisor. Its up to the user to confirm whether registration is required; dont tell them they must register.
## Registration safety
Guide, dont advise. Never tell a user where they must register or whether theyre legally obligated. Recommend they consult their tax advisor to determine their obligations.
- The [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) can list, create, update, and expire registrations (set `expires_at` to expire; theres no delete). A scheduled expiry can be changed, but an expiration that has taken effect is permanent (to collect again, the user adds a new registration), and theres no pause. A head office address is required before adding a registration.
- Adding a registration in Stripe records where the user is *already* registered. It doesnt register them with the tax authority.
- Creating or expiring a registration changes whether Stripe collects tax in that jurisdiction, but it doesnt register or deregister the user with the tax authority. The user must do that separately. Prepare the change and have the user confirm it; never create or expire a registration automatically.
**How to register.** Present the paths that fit the user and let them (with their tax advisor) choose. Dont pick for them.
- **Register themselves, then record it in Stripe**: the user registers with the tax authority, then records it with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) or the Dashboard. See [Register for tax](https://docs.stripe.com/tax/registering.md).
- **Ask Stripe to register (US only)**: for remote, out-of-state sellers with no physical presence in the state; no public API, requires a Tax Complete subscription, and doesnt support in-state registrations. See [Use Stripe to register](https://docs.stripe.com/tax/use-stripe-to-register.md).
- **Register outside the US with Taxually**: no public API; done through the Taxually app. See [Register outside the US with Taxually](https://docs.stripe.com/tax/use-taxually-to-register.md).
**Reporting and filing.** Stripe Tax calculates and collects tax but doesnt file returns unless the user is on a filing product. Point users to the Dashboard [tax reports and exports](https://docs.stripe.com/tax/reports.md) to reconcile and remit; filing runs through Stripe (US) or Taxually (non-US).
## If jurisdictions are unknown
Dont guess which jurisdictions apply. Ask the user which states or countries they have customers in, then add a registration for each with the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md) or the Dashboard.
## If the region or tax type isnt supported
Check the [supported countries list](https://docs.stripe.com/tax/supported-countries.md). If the jurisdiction isnt listed, tell the user:
- Stripe Tax doesnt support that region yet
- They can collect tax manually using `tax_rates` on the subscription or invoice instead (not alongside `automatic_tax`; you cant use both)
- For unsupported tax types (customs duties, excise taxes), Stripe Tax doesnt apply, so those are out of scope
Dont attempt to approximate using a supported region as a proxy.
@@ -0,0 +1,16 @@
# Treasury / Financial Accounts
## Table of contents
- v2 Financial Accounts API
- Legacy v1 Treasury
## v2 Financial Accounts API
For embedded financial accounts (bank accounts, account and routing numbers, money movement), use the [v2 Financial Accounts API](https://docs.stripe.com/api/v2/core/vault/financial-accounts.md) (`POST /v2/core/vault/financial_accounts`). This is required for new integrations.
For Treasury for platforms concepts and guides, see the [Treasury for platforms overview](https://docs.stripe.com/treasury/connect.md).
## Legacy v1 Treasury
Dont use the [v1 Treasury Financial Accounts API](https://docs.stripe.com/api/treasury/financial_accounts.md) (`POST /v1/treasury/financial_accounts`) for new integrations. Existing v1 integrations continue to work.
+77
View File
@@ -0,0 +1,77 @@
---
name: stripe-directory
description: >-
Use when the user wants to find businesses, software, service providers, or
partners for a specific industry, workflow, pain point, capability, or job to
be done. Also use when the agent needs to programmatically purchase or consume
a service. Use Stripe Directory to build a short relevant shortlist, even if
the user does not mention Stripe Directory explicitly.
metadata:
short-description: Find (and optionally purchase from) vendors or partners
allowed-tools:
- Bash(stripe directory *)
---
## Stripe Directory Search
Turn a vague market need into a short, relevant shortlist with `stripe directory search`. Use this even when the user never says “Stripe Directory” — any request to find vendors, tools, partners, or providers for a vertical, workflow, pain point, or job-to-be-done.
Most requests are **discovery** — find and compare services. That is the core job below. Some services are also **MPP-supported** (MPP = Machine Payment Protocol), meaning you (the agent) can pay their HTTP 402 (Payment Required) endpoint and consume them directly. When the user actually wants to *use or buy* a service, present those results and offer to purchase — see “Purchasing” at the end.
## Process
1. **Clarify only whats missing**: buyer/vertical, job-to-be-done, must-have capability, geography (only if it matters).
2. **Search iteratively**: `stripe directory search "<query>" --format json`
- Short noun phrases, one angle per query; run 1-3, then broaden/narrow on results.
- Angles to cover: vertical → workflow → pain point → adjacent. Two examples:
- services/trades: vertical (`electrician software`, `electrical contractor`) → workflow (`field service management`, `dispatch invoicing estimates`) → pain point (`job scheduling`, `quote automation`) → adjacent (`home services automation`, `contractor crm`).
- SaaS/software: vertical (`b2b saas billing`, `developer tools`) → workflow (`subscription management`, `usage-based metering`) → pain point (`failed payment recovery`, `revenue recognition`) → adjacent (`analytics dashboards`, `customer onboarding`).
- Hard constraints → filters: `--countries-supported=US`, `--has-stripe-app=true`, `--link-supported=true`, `--stripe-projects-supported=true`.
- If the user wants to *use/buy* a service, also pass `--mpp-supported` in at least one search to find results you can pay for programmatically.
- Sparse niche? Raise `--limit` and try the next `--page` before concluding its empty.
3. **Dedupe & score** using `display_name`, `description`, `url`, `username` as evidence.
- Prefer results whose description/site clearly match the target workflow.
- Prefer more trust signals over fewer: Projects provider, Link enabled, Marketplace app, Stripe Verified. For buy/use intent, also prefer MPP-supported results.
- Thin description but strong brand/domain match → keep in a weaker bucket, dont discard.
4. **Return a shortlist, not a dump** — 5-10 strong matches, grouped:
- **direct** / **adjacent** / **needs manual review**
- Each entry: name · why it matched · URL (· which query surfaced it, when useful).
- Projects providers: offer the follow-up. The JSON gives the exact commands under each results `projects.catalog_command` / `projects.install_command` (`stripe projects catalog <provider>`, `stripe projects add <provider>`).
- MPP-supported results: note theyre purchasable and include `mpp.slug` / `mpp.url`.
5. **Be honest about weak results** — if sparse or generic, say so and adjust: broaden, narrow, or try synonyms rather than padding with noise.
Always report the exact queries (and filters) you ran so the user can keep iterating.
## Purchasing (only when the user wants to buy or consume a service)
MPP-supported results are payable directly. Dont drive to purchase unprompted. When the user wants to buy, **present the full menu of payment methods and ask which theyd like to use** before doing anything:
> "Which payment method would you like to use?
>
> - **Link CLI** — Stripe-native, test mode available (recommended)
- **Tempo** — crypto wallet
- **Privy Agent Wallet CLI** — crypto wallet
- **mppx** — debug-only fallback"
Once the user picks, silently run `which <tool> 2>/dev/null` to check if its installed. If not installed, offer to install it (for example, `npm i -g @stripe/link-cli` for Link CLI) and wait for confirmation before proceeding.
**Always show the price and get explicit user approval before any money moves**; prefer a no-charge test path first.
Short version:
1. Resolve the real callable endpoint from the results `mpp.slug` / `mpp.url`. `mpp.url` is often the mpp.dev landing form (`https://mpp.dev/services#<slug>`) — resolve the raw endpoint on [mpp.dev](https://mpp.dev) if so. Read the HTTP 402 challenge to confirm the amount: `curl -s -D - -o /dev/null <endpoint_url>` (look for `WWW-Authenticate`).
2. Use the payer the user selected.
- **`link-cli`** (Stripe-native Shared Payment Token, has a test mode, no crypto wallet, US Link accounts only; `npm i -g @stripe/link-cli`): `auth login``mpp decode --challenge "<value>"` (get `network_id`) → `spend-request create --credential-type shared_payment_token --network-id <id> --amount <cents ≤50000> --context "<100+ chars>" --request-approval` (blocks for approval) → `mpp pay <endpoint_url> --spend-request-id <approved_id>`.
- **Tempo**: `tempo wallet login` / `services` / `request`.
- **Privy**: `@privy-io/agent-wallet-cli`.
- **mppx**: debug-only fallback.
Never invent results or skip the price/approval gate.
+42
View File
@@ -0,0 +1,42 @@
---
name: stripe-docs
description: >-
Use when the user or agent needs to read, search, or look up Stripe
documentation or API reference. Prefer this over curl or WebFetch for any
docs.stripe.com content.
metadata:
short-description: Read and search Stripe documentation from the terminal
allowed-tools:
- Bash(stripe docs *)
---
Use `stripe docs` instead of fetching [docs.stripe.com](https://docs.stripe.com/.md) content directly with `curl` or `WebFetch`.
- Fetches Markdown automatically
- Purpose-built for agents and terminal workflows
## Read a page by its web path
```bash
stripe docs /payments
```
## Search documentation by keyword
```bash
stripe docs search "payment intents"
```
## Look up API reference
```bash
# By resource name
stripe docs api product
# By HTTP method and path
stripe docs api GET /v1/products
# By event type
stripe docs api product.created
```
+169
View File
@@ -0,0 +1,169 @@
---
name: stripe-projects
description: >
Use when the user wants to provision infrastructure or third-party services
using Stripe Projects. Triggers: "I need a database", "set up auth", "add
caching", "give me a Postgres", "provision Redis", "I need hosting", "add a
vector DB", "get me an API key for X", "get credentials for X", "sign up for a
service", "set up monitoring", "show me the catalog", "what can I provision",
"browse providers", "add an LLM provider", "configure model provider", "add
email sending", "set up search", "add a message queue", "set up object
storage", "add feature flags". Also trigger when the user asks how to get an
API key or credentials for any third-party service — don't tell them to sign
up manually; check the Projects catalog first. Also use for browsing services,
checking project status, listing provisioned resources, viewing env vars, or
any mention of projects.dev or adding/provisioning/connecting a cloud service.
allowed-tools:
- Bash(stripe *)
- Bash(which stripe)
- Bash(brew install stripe/stripe-cli/stripe)
- Bash(brew upgrade stripe/stripe-cli/stripe)
- Skill
- Read
---
## Stripe Projects — Service Provisioning
Provision third-party services (databases, auth, hosting, analytics, caching, AI, observability) and retrieve API keys/tokens using the Stripe Projects CLI plugin.
## Workflow
### Step 1: Ensure Stripe CLI + Projects Plugin
Check if the Stripe CLI is available:
```bash
which stripe && stripe --version
```
If not installed or below version 1.40.0:
- **macOS (Homebrew):** `brew install stripe/stripe-cli/stripe` (or `brew upgrade stripe/stripe-cli/stripe`)
- **Other platforms:** Direct the user to https://docs.stripe.com/stripe-cli/install for up-to-date instructions.
Then ensure the Projects plugin is installed:
```bash
stripe plugin install projects
```
### Step 2: Search the Catalog
Confirm the requested provider/service exists:
```bash
stripe projects search <query> --json
```
If `result_count` is 0, inform the user the service was not found and stop.
If the users request is vague (for example, “I need a database”), browse the catalog to suggest options:
```bash
stripe projects catalog --json
```
### Step 3: Initialize a Project
Check if a project is already initialized:
```bash
stripe projects status --json
```
If not initialized, run a preflight check first to reveal all blockers at once:
```bash
stripe projects init --preflight --json
```
If all preflight checks pass (or the only failures are `TOS_ACCEPTANCE_REQUIRED` or `Stripe session authenticated`), proceed:
```bash
stripe projects init --accept-tos --yes
```
**Important:** `stripe projects init` installs the `stripe-projects-cli` skill locally at `.claude/skills/stripe-projects-cli`. This skill contains the full post-init command reference.
### Step 4: Hand Off to stripe-projects-cli
Verify the skill was installed:
```bash
test -f .claude/skills/stripe-projects-cli/SKILL.md && echo "OK" || echo "MISSING"
```
If `MISSING`: re-run `stripe projects init --accept-tos --yes` — the skill is bundled with the Projects plugin and installed during init.
If `OK`: use the locally-installed `stripe-projects-cli` skill (invoke using the Skill tool with name `stripe-projects-cli`) to continue the workflow — adding services, managing credentials, and configuring the project.
### Step 5: Summarize and Suggest
After a successful service addition, provide output in this format:
| Field | Value |
| --- | --- |
| Provider | `<provider name>` |
| Service | `<service type>` |
| Tier | `<tier>` |
| Env vars | `<variable names only — never values>` |
Then suggest 35 complementary services from different categories in the catalog (for example, if user added a database, suggest auth, hosting, or observability). Only reference services that actually appear in `stripe projects catalog --json` output — never fabricate commands or provider names.
## CLI as Source of Truth
The CLI manages all state under `.projects/` and generates `.env` files. Dont hand-edit these files. If you need to inspect project state, use the appropriate CLI command:
| Task | Command |
| --- | --- |
| View provisioned services | `stripe projects status --json` |
| List env var names | `stripe projects env --json` |
| Check project health | `stripe projects status --json` |
| Browse available services | `stripe projects catalog --json` |
Only inspect `.projects/` or `.env` directly if the user explicitly asks you to — the CLI is authoritative, so manual edits may be overwritten.
## Project Variables
Use project variables when the user wants to store an environment variable that doesnt come from a provisioned provider resource, such as an app URL, feature flag, or self-managed API key.
Create or update a project variable for the active environment:
```bash
stripe projects variables set <name> --env-key <ENV_KEY> --value <value>
```
A successful `variables set` syncs the active environment output file immediately. If the user doesnt provide the value, run the command without `--value` only in interactive mode so the CLI can prompt securely. Never print secret values in your response.
Bind an existing project variable to the active environment:
```bash
stripe projects env add <name> --variable --env-key <ENV_KEY>
```
Remove a variable binding from the active environment without deleting the stored variable:
```bash
stripe projects env remove <name> --variable
```
List and delete project variables:
```bash
stripe projects variables list --json
stripe projects variables delete <name> --yes
```
## Error Handling
| Error code | Cause | Recovery |
| --- | --- | --- |
| `BROWSER_AUTH_REQUIRED` | No auth session and browser needed | Tell user to run `stripe login` — you cannot fix this |
| `ACCOUNT_NOT_ELIGIBLE` | Account not onboarded for Projects | Tell user to run `stripe login` or visit https://projects.dev |
| `TOS_ACCEPTANCE_REQUIRED` | Developer or provider terms not accepted | Re-run with `--accept-tos` |
| `PROVIDER_NOT_LINKED` | Provider requires OAuth linking | Run `stripe projects link <provider>` — may open a browser |
| `PLAN_REQUIRED` | Deployable needs a plan provisioned first | Provision the plan listed in the error, then retry |
| `UNKNOWN_ERROR` | Unexpected failure | Show the full error message to the user and suggest running with `--debug` for diagnostics |
| Service not in catalog | Query returned 0 results | Inform user; suggest `stripe projects catalog --json` to browse alternatives |
| CLI not found | Stripe CLI not installed | Install using Homebrew (macOS) or follow https://docs.stripe.com/stripe-cli/install |
+185
View File
@@ -0,0 +1,185 @@
---
name: upgrade-stripe
description: Guide for upgrading Stripe API versions and SDKs
---
The latest Stripe API version is 2026-06-24.dahlia - use this version when upgrading unless the user specifies a different target version.
# Upgrading Stripe Versions
This guide covers upgrading Stripe API versions, server-side SDKs, Stripe.js, and mobile SDKs.
## Understanding Stripe API Versioning
Stripe uses date-based API versions (e.g., `2026-06-24.dahlia`, `2025-08-27.basil`, `2024-12-18.acacia`). Your accounts API version determines request/response behavior.
### Types of Changes
**Backward-Compatible Changes** (dont require code updates):
- New API resources
- New optional request parameters
- New properties in existing responses
- Changes to opaque string lengths (e.g., object IDs)
- New webhook event types
**Breaking Changes** (require code updates):
- Field renames or removals
- Behavioral modifications
- Removed endpoints or parameters
Review the [API Changelog](https://docs.stripe.com/changelog.md) for all changes between versions.
## Server-Side SDK Versioning
See [SDK Version Management](https://docs.stripe.com/sdks/set-version.md) for details.
### Dynamically-Typed Languages (Ruby, Python, PHP, Node.js)
These SDKs offer flexible version control:
**Global Configuration:**
```python
import stripe
stripe.api_version = '2026-06-24.dahlia'
```
```ruby
Stripe.api_version = '2026-06-24.dahlia'
```
```javascript
const stripe = require('stripe')('sk_test_xxx', {
apiVersion: '2026-06-24.dahlia'
});
```
**Per-Request Override:**
```python
stripe.Customer.create(
email="customer@example.com",
stripe_version='2026-06-24.dahlia'
)
```
### Strongly-Typed Languages (Java, Go, .NET)
These use a fixed API version matching the SDK release date. Dont set a different API version for strongly-typed languages because response objects might not match the strong types in the SDK. Instead, update the SDK to target a new API version.
### Best Practice
Always specify the API version youre integrating against in your code instead of relying on your accounts default API version:
```javascript
// Good: Explicit version
const stripe = require('stripe')('sk_test_xxx', {
apiVersion: '2026-06-24.dahlia'
});
// Avoid: Relying on account default
const stripe = require('stripe')('sk_test_xxx');
```
## Stripe.js Versioning
See [Stripe.js Versioning](https://docs.stripe.com/sdks/stripejs-versioning.md) for details.
Stripe.js uses an evergreen model with major releases (Acacia, Basil, Clover, Dahlia) on a biannual basis.
### Loading Versioned Stripe.js
**Via Script Tag:**
```html
<script src="https://js.stripe.com/dahlia/stripe.js"></script>
```
**Via npm:**
```bash
npm install @stripe/stripe-js
```
Major npm versions correspond to specific Stripe.js versions.
### API Version Pairing
Each Stripe.js version automatically pairs with its corresponding API version. For instance:
- Dahlia Stripe.js uses `2026-06-24.dahlia` API
- Acacia Stripe.js uses `2024-12-18.acacia` API
You cant override this association.
### Migrating from v3
1. Identify your current API version in code
2. Review the changelog for relevant changes
3. Consider gradually updating your API version before switching Stripe.js versions
4. Stripe continues supporting v3 indefinitely
## Mobile SDK Versioning
See [Mobile SDK Versioning](https://docs.stripe.com/sdks/mobile-sdk-versioning.md) for details.
### iOS and Android SDKs
Both platforms follow **semantic versioning** (MAJOR.MINOR.PATCH):
- **MAJOR**: Breaking API changes
- **MINOR**: New functionality (backward-compatible)
- **PATCH**: Bug fixes (backward-compatible)
New features and fixes release only on the latest major version. Upgrade regularly to access improvements.
### React Native SDK
Uses a different model (0.x.y schema):
- **Minor version changes** (x): Breaking changes AND new features
- **Patch updates** (y): Critical bug fixes only
### Backend Compatibility
All mobile SDKs work with any Stripe API version you use on your backend unless documentation specifies otherwise.
## Upgrade Checklist
1. Review the [API Changelog](https://docs.stripe.com/changelog.md) for changes between your current and target versions
2. Check [Upgrades Guide](https://docs.stripe.com/upgrades.md) for migration guidance
3. Update server-side SDK package version (e.g., `npm update stripe`, `pip install --upgrade stripe`)
4. Update the `apiVersion` parameter in your Stripe client initialization
5. Test your integration against the new API version using the `Stripe-Version` header
6. Update webhook handlers to handle new event structures
7. Update Stripe.js script tag or npm package version if needed
8. Update mobile SDK versions in your package manager if needed
9. Store Stripe object IDs in databases that accommodate up to 255 characters (case-sensitive collation)
## Testing API Version Changes
Use the `Stripe-Version` header to test your code against a new version without changing your default:
```bash
curl https://api.stripe.com/v1/customers \
-u sk_test_xxx: \
-H "Stripe-Version: 2026-06-24.dahlia"
```
Or in code:
```javascript
const stripe = require('stripe')('sk_test_xxx', {
apiVersion: '2026-06-24.dahlia' // Test with new version
});
```
## Important Notes
- Your webhook listener should handle unfamiliar event types gracefully
- Test webhooks with the new version structure before upgrading
- Breaking changes are tagged by affected product areas (Payments, Billing, Connect, etc.)
- Multiple API versions coexist simultaneously, enabling staged adoption
+52
View File
@@ -0,0 +1,52 @@
# Scope Discipline Rules
These rules override any general instinct to "improve while I'm in there." Follow them on every task, no exceptions.
## Before touching any code
1. Restate the task in one sentence: what behavior must change, and what the expected outcome is.
2. Identify the smallest set of files/functions responsible for that behavior. This is your **allowed scope**. Everything else is **protected**.
3. Read the relevant code before editing it. Do not edit based on assumptions about how it probably works.
## The hard rule: ask before expanding scope
If, while working, you find that:
- a file outside your allowed scope needs to change,
- a dependency needs to be added/updated,
- a test needs modification,
- an unrelated bug is blocking you,
- or a "cleaner" implementation would touch more than the minimum,
**stop and ask me before making that change.** Explain:
- what you were trying to do,
- why the fix requires going outside the original scope,
- exactly what you want to change and where.
Wait for my answer. Do not proceed on your own judgment, even if you're confident it's correct or trivial.
This applies even to small things (renaming a variable for clarity, fixing a typo in an unrelated comment, reformatting a block you had to scroll past). If it's not required to satisfy the request, ask first.
## While editing
- Make the fewest-line, fewest-file change that correctly satisfies the request.
- Preserve existing naming, structure, patterns, and formatting. Match the codebase's existing style, don't impose your own.
- Never run project-wide formatters/linters-with-autofix/import-organizers as a side effect of a small change.
- Never touch tests except to add new ones that validate the requested behavior — and only after confirming that's in scope.
- Treat any uncommitted/staged changes already in the working tree as off-limits. Don't revert, reset, or absorb them into your edit.
## Before reporting done
Review your own diff, file by file, line by line. For anything you can't justify with "this was required by the explicit request," revert it.
Then report:
- **Files changed** — list, with a one-line reason each tied directly to the request.
- **Scope confirmation** — explicitly state: "No files, dependencies, tests, or config outside this list were modified."
- **Anything you noticed but didn't touch** — unrelated bugs, tech debt, cleanup opportunities. Mention them, don't fix them.
## If the task genuinely can't be done without expanding scope
Say so plainly, explain what would need to change and why, and wait for confirmation. Don't silently do the bigger version, and don't pretend a partial/incorrect fix is complete.
---
**Default when uncertain: don't make the change, ask instead.**
+21 -5
View File
@@ -21,11 +21,11 @@ ADMIN_INTERNAL_URL=http://host.docker.internal:3002
DASHBOARD_ASSET_PREFIX=http://localhost:3001/dashboard
ADMIN_ASSET_PREFIX=http://localhost:3002/admin
CARPLACE_ASSET_PREFIX=http://localhost:3004/carplace
JWT_SECRET=placeholder
JWT_SECRET=JYNKxfyYaZbqT6NN8W4pXu0zOUvpunrDPdtC0I6OZPzq0B5RRI1Ybub00
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
ADMIN_SEED_PASSWORD=placeholder
ADMIN_SEED_PASSWORD=Qwerty0012345
ADMIN_SEED_FIRST_NAME=Platform
ADMIN_SEED_LAST_NAME=Admin
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
@@ -33,12 +33,13 @@ CLERK_SECRET_KEY=placeholder
NODE_ENV=development
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:4000,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:4000
# Email — Resend (primary) with SMTP fallback
# Get your API key at https://resend.com/api-keys
# Email — Gmail SMTP for local Docker development
EMAIL_PROVIDER=gmail
RESEND_API_KEY=re_PLACEHOLDER
EMAIL_FROM=noreply@rentaldrivego.ma
EMAIL_FROM_NAME=RentalDriveGo
# SMTP fallback (only used if Resend fails or is unconfigured)
# Use a Gmail app password, not your normal Google account password.
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
@@ -48,3 +49,18 @@ MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
MAIL_REPLY_TO_NAME=RentalDriveGo
# Manual subscription payments for local development
MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=true
MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=true
PAYMENT_EVIDENCE_SCANNER_MODE=stub-clean
BANK_TRANSFER_ENABLED=true
BANK_TRANSFER_ACCOUNT_NAME=RentalDriveGo SARL
BANK_TRANSFER_BANK_NAME=Local Development Bank
BANK_TRANSFER_ACCOUNT_REFERENCE=DEV-MA64-0000-0000-0000
BANK_TRANSFER_DUE_DAYS=7
CHECK_PAYMENT_ENABLED=true
CHECK_PAYMENT_PAYEE=RentalDriveGo SARL
CHECK_PAYMENT_DELIVERY_ADDRESS=Local development billing desk
CHECK_PAYMENT_DUE_DAYS=14
+7 -3
View File
@@ -67,6 +67,10 @@ JWT_SECRET=eb9ab3eb5d6648bdb82cbef29afde498c58f089829a037dfea6cb5e98ef2aae0
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
NODE_ENV=production
# Shared parent-domain cookie so sessions work across rentaldrivego.ma and
# api.rentaldrivego.ma (the admin/dashboard apps call the API cross-origin).
# Must start with a leading dot.
SESSION_COOKIE_DOMAIN=.rentaldrivego.ma
# ── Email ─────────────────────────────────────────────────────────────────────
@@ -75,18 +79,18 @@ EMAIL_FROM_NAME=RentalDriveGo
# Option A — Resend
#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V
# Option B — SMTP (Gmail)
# SMTP fallback (only used if Resend fails or is unconfigured)
# EMAIL_PROVIDER=gmail selects Gmail SMTP and skips Resend.
EMAIL_PROVIDER=gmail
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=kfahihfzbcvkczew
MAIL_PASSWORD=your-16-character-gmail-app-password
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
MAIL_REPLY_TO_NAME=RentalDriveGo
# ── Firebase push notifications (optional) ────────────────────────────────────
# FIREBASE_PROJECT_ID=your-firebase-project-id
# FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
+2 -1
View File
@@ -31,7 +31,7 @@ NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
# Frontend public URLs
NEXT_PUBLIC_HOMEPAGE_URL=https://example.com
NEXT_PUBLIC_STOREFRONT_URL=https://example.com
NEXT_PUBLIC_CARPLACE_URL=https://example.com
NEXT_PUBLIC_DASHBOARD_URL=https://example.com/dashboard
NEXT_PUBLIC_ADMIN_URL=https://example.com/admin
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=example.com
@@ -45,6 +45,7 @@ CORS_ORIGINS=https://example.com,https://www.example.com
JWT_SECRET=placeholder
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
SESSION_COOKIE_DOMAIN=.example.com
NODE_ENV=production
# File storage
+10 -19
View File
@@ -21,6 +21,9 @@ NEXT_PUBLIC_API_URL=http://localhost:4000/api/v1
JWT_SECRET=8bf96de20297c2c295a60e4040e937a7eb8ec7d7d2b83e79a1fc98463322a97c
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
# Optional in local development. Required in production when auth spans sibling subdomains.
# Example: SESSION_COOKIE_DOMAIN=.rentaldrivego.ma
SESSION_COOKIE_DOMAIN=
# ─── Clerk (Company employee auth) ────────────────────────────
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
@@ -31,20 +34,6 @@ NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding
# ─── AmanPay (Primary payment provider) ───────────────────────
# RentalDriveGo's own AmanPay account (for collecting subscription fees)
AMANPAY_MERCHANT_ID=your-amanpay-merchant-id
AMANPAY_SECRET_KEY=placeholder
AMANPAY_BASE_URL=https://api.amanpay.net
AMANPAY_WEBHOOK_SECRET=placeholder
# ─── PayPal (Secondary payment provider) ──────────────────────
# RentalDriveGo's own PayPal account (for collecting subscription fees)
PAYPAL_CLIENT_ID=your-paypal-client-id
PAYPAL_CLIENT_SECRET=placeholder
PAYPAL_BASE_URL=https://api-m.paypal.com
# Use https://api-m.sandbox.paypal.com for sandbox
NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id
# ─── Cloudinary (Vehicle + brand photos) ──────────────────────
CLOUDINARY_CLOUD_NAME=your-cloud-name
@@ -88,7 +77,7 @@ REDIS_URL=redis://localhost:6379
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3000/dashboard
NEXT_PUBLIC_ADMIN_URL=http://localhost:3000/admin
NEXT_PUBLIC_STOREFRONT_URL=http://localhost:3000/storefront
NEXT_PUBLIC_CARPLACE_URL=http://localhost:3000/carplace
NEXT_PUBLIC_HOMEPAGE_URL=http://localhost:3000
# Public site is subdomain-based; use this for local dev:
NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
@@ -97,17 +86,19 @@ PUBLIC_SITE_DOMAIN=rentaldrivego.ma
DASHBOARD_URL=http://localhost:3000/dashboard
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
ADMIN_SEED_EMAIL=admin@rentaldrivego.ma
ADMIN_SEED_PASSWORD=PMPS5k0D7rUeJOk0NkhI5bRtoGjkUqjK
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
ADMIN_SEED_PASSWORD=Qwerty0012345
ADMIN_SEED_FIRST_NAME=Super
ADMIN_SEED_LAST_NAME=Admin
# SMTP fallback (only used if Resend fails or is unconfigured)
# Email provider: auto, resend, smtp, or gmail.
# Use a Gmail app password for Gmail SMTP, not your normal Google account password.
EMAIL_PROVIDER=gmail
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SCHEME=smtp
MAIL_USERNAME=rentaldrivego@gmail.com
MAIL_PASSWORD=kfahihfzbcvkczew
MAIL_PASSWORD=your-16-character-gmail-app-password
MAIL_FROM_ADDRESS=rentaldrivego@gmail.com
MAIL_FROM_NAME=RentalDriveGo
MAIL_REPLY_TO_ADDRESS=rentaldrivego@gmail.com
+399 -176
View File
@@ -1,8 +1,8 @@
name: Build & Deploy
name: Build & Push
on:
push:
branches: [develop]
branches: [fix_branch]
workflow_dispatch:
concurrency:
@@ -15,68 +15,199 @@ env:
GIT_SSL_NO_VERIFY: "true"
REGISTRY_HOST: 192.168.3.80
DEPLOY_REGISTRY_HOST: 10.0.0.4
DEPLOY_SSH_HOST: 10.0.0.1
DOCKERFILE_PATH: Dockerfile.production
DOCKER_PLATFORM: linux/amd64
DEPLOY_ROOT: /opt/rentaldrivego
jobs:
pipeline-tests:
name: Pipeline Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: rentaldrivego_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://postgres:password@postgres:5432/rentaldrivego_test
REDIS_URL: redis://redis:6379
NODE_ENV: test
JWT_SECRET: test-secret
JWT_EXPIRY: 8h
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
steps:
- name: Checkout repository
shell: bash
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SHA: ${{ gitea.sha }}
CHECKOUT_TOKEN: ${{ gitea.token }}
run: |
set -euo pipefail
if ! command -v git >/dev/null 2>&1; then
echo "::error::git must be available in the runner image"
exit 1
fi
WORKSPACE="${GITHUB_WORKSPACE:-$PWD}"
mkdir -p "$WORKSPACE"
cd "$WORKSPACE"
SERVER_URL="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}"
REPOSITORY="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-}}"
SHA="${GITEA_SHA:-${GITHUB_SHA:-}}"
if [ -z "$SERVER_URL" ] || [ -z "$REPOSITORY" ] || [ -z "$SHA" ]; then
echo "::error::Missing repository checkout context"
exit 1
fi
REPOSITORY_URL="${SERVER_URL}/${REPOSITORY}.git"
if [ ! -d .git ]; then
git init
git remote add origin "$REPOSITORY_URL"
fi
git config --global --add safe.directory "$WORKSPACE"
if [ -n "${CHECKOUT_TOKEN:-}" ]; then
git -c "http.extraHeader=Authorization: token ${CHECKOUT_TOKEN}" fetch --no-tags --depth=1 origin "$SHA"
else
git fetch --no-tags --depth=1 origin "$SHA"
fi
git checkout --force FETCH_HEAD
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional --workspaces && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- name: Generate database client
run: npm run db:generate
- name: Type check
run: npm run type-check
- name: Unit tests
run: npm run test:unit
- name: Apply test database migrations
run: npm run db:deploy
- name: Synchronize test database schema
run: npx prisma db push --schema packages/database/prisma/schema.prisma
- name: Integration tests
run: npm run test:integration
build-image:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: pipeline-tests
outputs:
image_repository: ${{ steps.image-meta.outputs.repository }}
docker_image: ${{ steps.image-meta.outputs.full }}
steps:
- name: Check out repository
- name: Checkout repository
shell: bash
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SHA: ${{ gitea.sha }}
CHECKOUT_TOKEN: ${{ gitea.token }}
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" \
fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
git rev-parse --short HEAD
- name: Ensure Docker CLI is available
run: |
if ! command -v docker >/dev/null 2>&1; then
echo "::error::Docker CLI is not available in this Gitea runner image."
echo "::error::Configure the runner label used by this job to an image that already includes Docker CLI, for example ubuntu-latest:docker://catthehacker/ubuntu:act-latest."
echo "::error::The current runner maps ubuntu-latest to node:20-bookworm, and this job cannot install docker.io because the job container has no external DNS."
if ! command -v git >/dev/null 2>&1; then
echo "::error::git must be available in the runner image; this workflow cannot install git while runner DNS is unavailable."
exit 1
fi
docker --version
docker info >/dev/null
- name: Set up Docker Buildx
shell: bash
run: |
set -euo pipefail
WORKSPACE="${GITHUB_WORKSPACE:-$PWD}"
mkdir -p "$WORKSPACE"
cd "$WORKSPACE"
docker buildx version
printf '[registry."%s"]\n insecure = true\n' "$REGISTRY_HOST" > /tmp/buildkitd.toml
if ! docker buildx inspect rentaldrivego-builder >/dev/null 2>&1; then
docker buildx create \
--name rentaldrivego-builder \
--driver docker-container \
--config /tmp/buildkitd.toml \
--use
else
docker buildx use rentaldrivego-builder
SERVER_URL="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}"
REPOSITORY="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-}}"
SHA="${GITEA_SHA:-${GITHUB_SHA:-}}"
if [ -z "$SERVER_URL" ] || [ -z "$REPOSITORY" ] || [ -z "$SHA" ]; then
echo "::error::Missing repository checkout context"
exit 1
fi
docker buildx inspect --bootstrap
REPOSITORY_URL="${SERVER_URL}/${REPOSITORY}.git"
if [ ! -d .git ]; then
git init
git remote add origin "$REPOSITORY_URL"
fi
git config --global --add safe.directory "$WORKSPACE"
if [ -n "${CHECKOUT_TOKEN:-}" ]; then
git -c "http.extraHeader=Authorization: token ${CHECKOUT_TOKEN}" fetch --no-tags --depth=1 origin "$SHA"
else
git fetch --no-tags --depth=1 origin "$SHA"
fi
git checkout --force FETCH_HEAD
- name: Docker image metadata
id: image-meta
@@ -84,16 +215,23 @@ jobs:
REPO="${{ gitea.repository }}"
TAG="${{ gitea.sha }}"
echo "repository=${REPO}" >> "$GITHUB_OUTPUT"
echo "full=${REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
echo "latest=${REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
echo "full=${DEPLOY_REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT"
echo "latest=${DEPLOY_REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT"
- name: Check Docker registry credentials
id: registry-check
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
if [ -n "${{ secrets.REGISTRY_USERNAME }}" ] && [ -n "${{ secrets.REGISTRY_PASSWORD }}" ]; then
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
if [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ]; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::REGISTRY_USERNAME or REGISTRY_PASSWORD secrets not set — push will be skipped"
echo "::warning::Registry credentials secrets not set — configure REGISTRY_USERNAME/REGISTRY_PASSWORD or REGISTRY_USER/REGISTRY_TOKEN; push will be skipped"
echo "available=false" >> "$GITHUB_OUTPUT"
fi
@@ -107,158 +245,243 @@ jobs:
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
run: |
for variable in \
NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_HOMEPAGE_URL \
NEXT_PUBLIC_CARPLACE_URL \
NEXT_PUBLIC_DASHBOARD_URL \
NEXT_PUBLIC_ADMIN_URL \
SITE_ORIGIN
do
value="${!variable}"
validate_url() {
name="$1"
value="$2"
if [ -z "$value" ]; then
echo "::error::$variable is missing — add it to Gitea Actions secrets"
echo "::error::$name is missing — add it to Gitea Actions secrets"
exit 1
fi
case "$value" in
http://*|https://*) ;;
*)
echo "::error::$variable must be an absolute URL (got: $value)"
echo "::error::$name must be an absolute URL (got: $value)"
exit 1
;;
esac
done
}
- name: Log in to Gitea Container Registry
if: steps.registry-check.outputs.available == 'true'
shell: bash
run: |
printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login "$REGISTRY_HOST" \
--username "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
fi
- name: Build and push
shell: bash
validate_url NEXT_PUBLIC_API_URL "$NEXT_PUBLIC_API_URL"
validate_url NEXT_PUBLIC_HOMEPAGE_URL "$NEXT_PUBLIC_HOMEPAGE_URL"
validate_url NEXT_PUBLIC_CARPLACE_URL "$NEXT_PUBLIC_CARPLACE_URL"
validate_url NEXT_PUBLIC_DASHBOARD_URL "$NEXT_PUBLIC_DASHBOARD_URL"
validate_url NEXT_PUBLIC_ADMIN_URL "$NEXT_PUBLIC_ADMIN_URL"
validate_url SITE_ORIGIN "$SITE_ORIGIN"
- name: Check remote build credentials
id: check-build-host
env:
BUILDKIT_NO_CLIENT_TOKEN: "true"
VPS_HOST: ${{ secrets.VPS_IP }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }}
run: |
set -euo pipefail
OUTPUT_ARG="--load"
if [ "${{ steps.registry-check.outputs.available }}" = "true" ]; then
OUTPUT_ARG="--push"
fi
docker buildx build \
--file "$DOCKERFILE_PATH" \
--platform "$DOCKER_PLATFORM" \
--tag "${{ steps.image-meta.outputs.full }}" \
--tag "${{ steps.image-meta.outputs.latest }}" \
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
--build-arg NEXT_PUBLIC_API_URL="${{ secrets.NEXT_PUBLIC_API_URL }}" \
--build-arg NEXT_PUBLIC_HOMEPAGE_URL="${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}" \
--build-arg NEXT_PUBLIC_CARPLACE_URL="${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}" \
--build-arg NEXT_PUBLIC_DASHBOARD_URL="${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}" \
--build-arg NEXT_PUBLIC_ADMIN_URL="${{ secrets.NEXT_PUBLIC_ADMIN_URL }}" \
--build-arg SITE_ORIGIN="${{ secrets.SITE_ORIGIN }}" \
"$OUTPUT_ARG" \
.
deploy:
name: Deploy to VPS
runs-on: ubuntu-latest
needs: [build-image]
env:
DOCKER_IMAGE: ${{ needs.build-image.outputs.docker_image }}
IMAGE_REPOSITORY: ${{ needs.build-image.outputs.image_repository }}
steps:
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" \
fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
git rev-parse --short HEAD
- name: Check deploy credentials
id: check-creds
run: |
if [ -z "${{ secrets.VPS_SSH_KEY }}" ] || \
[ -z "${{ secrets.VPS_IP }}" ] || \
[ -z "${{ secrets.VPS_USER }}" ]; then
echo "VPS secrets not fully configured — skipping deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Install SSH client
if: steps.check-creds.outputs.skip == 'false'
run: |
if ! command -v ssh >/dev/null 2>&1 || ! command -v scp >/dev/null 2>&1; then
echo "::error::OpenSSH client tools are not available in this Gitea runner image."
echo "::error::Use a runner image that already includes ssh and scp; installing packages from apt is not reliable because the job container has no external DNS."
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_SSH_KEY" ] && [ -z "$VPS_SSH_KEY_B64" ]; then
echo "::error::VPS_SSH_KEY_B64 or VPS_SSH_KEY is required to build on the remote Docker host"
exit 1
fi
if [ -z "$VPS_HOST" ] || \
[ -z "${{ secrets.VPS_USER }}" ]; then
echo "::error::VPS_USER and either VPS_IP or DEPLOY_SSH_HOST are required to build on the remote Docker host"
exit 1
fi
- name: Check SSH tools
run: |
if ! command -v ssh >/dev/null 2>&1 || ! command -v ssh-keygen >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1; then
echo "::error::ssh, ssh-keygen, and tar must be available in the runner image; this workflow cannot install packages while runner DNS is unavailable."
exit 1
fi
ssh -V
- name: Set up SSH key
if: steps.check-creds.outputs.skip == 'false'
env:
VPS_HOST: ${{ secrets.VPS_IP }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }}
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
echo "Using SSH host $VPS_HOST"
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
if [ -n "$VPS_SSH_KEY_B64" ]; then
if ! printf '%s' "$VPS_SSH_KEY_B64" | tr -d '[:space:]' | base64 -d > ~/.ssh/id_rsa 2>/tmp/vps_ssh_key_decode.err; then
if [ -n "$VPS_SSH_KEY" ]; then
echo "::warning::VPS_SSH_KEY_B64 is not valid base64; using VPS_SSH_KEY instead"
printf '%b\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
else
echo "::error::VPS_SSH_KEY_B64 is not valid base64. Store a base64-encoded private key there, or set VPS_SSH_KEY to the raw private key."
exit 1
fi
fi
else
printf '%b\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
fi
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "${{ secrets.VPS_IP }}" >> ~/.ssh/known_hosts 2>/dev/null
key_header="$(head -n 1 ~/.ssh/id_rsa || true)"
key_size="$(wc -c < ~/.ssh/id_rsa | tr -d ' ')"
case "$key_header" in
"-----BEGIN "*PRIVATE*" KEY-----") ;;
ssh-*|"ecdsa-"*|"sk-"*)
echo "::error::Decoded SSH key looks like a public key, not a private key. Encode the private key file, not the .pub file."
exit 1
;;
*)
echo "::error::Decoded SSH key does not start with a private key header. Decoded byte count: $key_size."
exit 1
;;
esac
if ! keygen_error="$(ssh-keygen -y -f ~/.ssh/id_rsa 2>&1 >/dev/null)"; then
echo "::error::SSH private key is not readable by ssh-keygen: $keygen_error. Use an unencrypted private key or configure a CI-specific deploy key."
exit 1
fi
ssh-keygen -y -f ~/.ssh/id_rsa > ~/.ssh/id_rsa.pub
key_fingerprint="$(ssh-keygen -lf ~/.ssh/id_rsa.pub | awk '{print $2}')"
echo "Loaded deploy key fingerprint: $key_fingerprint"
echo "Deploy public key: $(cat ~/.ssh/id_rsa.pub)"
touch ~/.ssh/known_hosts
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
chmod 644 ~/.ssh/known_hosts
- name: Sync deployment assets
if: steps.check-creds.outputs.skip == 'false'
- name: Test SSH authentication
env:
VPS_HOST: ${{ secrets.VPS_IP }}
run: |
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" \
"mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' '$DEPLOY_ROOT/docker/registry/auth' '$DEPLOY_ROOT/dynamic'"
scp docker-compose.production.yml \
docker-compose.portainer.production.yml \
docker-compose.registry.production.yml \
docker-compose.registry.local.yml \
traefik.yaml \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/"
scp scripts/docker-prod-common.sh \
scripts/docker-prod-deploy.sh \
scripts/docker-prod-up-registry.sh \
scripts/docker-registry-local-up.sh \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/scripts/"
scp docker/pgmanage/override.py \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:$DEPLOY_ROOT/docker/pgmanage/"
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
key_fingerprint="$(ssh-keygen -lf "$HOME/.ssh/id_rsa.pub" | awk '{print $2}')"
echo "Testing SSH authentication to $VPS_HOST with deploy key fingerprint $key_fingerprint"
if ! ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "printf 'ssh authenticated as '; whoami"; then
echo "::error::SSH authentication failed. Verify VPS_USER matches the account that has deploy key fingerprint $key_fingerprint in ~/.ssh/authorized_keys on $VPS_HOST."
exit 1
fi
- name: Run deploy script on VPS
if: steps.check-creds.outputs.skip == 'false'
- name: Sync build context to VPS
env:
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
VPS_HOST: ${{ secrets.VPS_IP }}
run: |
REGISTRY_PASSWORD_B64="$(printf '%s' "${{ secrets.REGISTRY_PASSWORD }}" | base64 | tr -d '\n')"
ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}" "
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" \
"rm -rf '$REMOTE_BUILD_DIR' && mkdir -p '$REMOTE_BUILD_DIR'"
tar \
--exclude='.git' \
--exclude='node_modules' \
--exclude='.next' \
--exclude='dist' \
--exclude='coverage' \
-czf - . | ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" \
"tar -xzf - -C '$REMOTE_BUILD_DIR'"
- name: Build and push on VPS
env:
PUSH_IMAGE: ${{ steps.registry-check.outputs.available == 'true' }}
IMAGE_FULL: ${{ steps.image-meta.outputs.full }}
IMAGE_LATEST: ${{ steps.image-meta.outputs.latest }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }}
NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }}
NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }}
NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }}
SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }}
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
VPS_HOST: ${{ secrets.VPS_IP }}
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
REGISTRY_USERNAME="${REGISTRY_USERNAME:-${REGISTRY_USER:-}}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$SITE_ORIGIN" ]; then
NEXT_PUBLIC_CARPLACE_URL="${SITE_ORIGIN%/}/carplace"
fi
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "
set -e
cd '$DEPLOY_ROOT'
APP_IMAGE='$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY' \
APP_VERSION='${{ gitea.sha }}' \
IMAGE_TAG='${{ gitea.sha }}' \
REGISTRY_HOST='$DEPLOY_REGISTRY_HOST' \
REGISTRY_USER='${{ secrets.REGISTRY_USERNAME }}' \
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
bash scripts/docker-prod-deploy.sh
cd '$REMOTE_BUILD_DIR'
if ! command -v docker >/dev/null 2>&1; then
echo 'Docker must be installed on the VPS build host' >&2
exit 1
fi
if [ '$PUSH_IMAGE' = 'true' ]; then
printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d | docker login '$DEPLOY_REGISTRY_HOST' \
--username '$REGISTRY_USERNAME' \
--password-stdin
fi
docker build \
--file '$DOCKERFILE_PATH' \
--platform '$DOCKER_PLATFORM' \
--tag '$IMAGE_FULL' \
--tag '$IMAGE_LATEST' \
--build-arg API_INTERNAL_URL=http://api:4000/api/v1 \
--build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \
--build-arg ADMIN_INTERNAL_URL=http://admin:3002 \
--build-arg NEXT_PUBLIC_API_URL='$NEXT_PUBLIC_API_URL' \
--build-arg NEXT_PUBLIC_HOMEPAGE_URL='$NEXT_PUBLIC_HOMEPAGE_URL' \
--build-arg NEXT_PUBLIC_CARPLACE_URL='$NEXT_PUBLIC_CARPLACE_URL' \
--build-arg NEXT_PUBLIC_DASHBOARD_URL='$NEXT_PUBLIC_DASHBOARD_URL' \
--build-arg NEXT_PUBLIC_ADMIN_URL='$NEXT_PUBLIC_ADMIN_URL' \
--build-arg SITE_ORIGIN='$SITE_ORIGIN' \
.
if [ '$PUSH_IMAGE' = 'true' ]; then
docker push '$IMAGE_FULL'
docker push '$IMAGE_LATEST'
fi
"
- name: Publish deployment files to VPS
env:
IMAGE_REPOSITORY: ${{ steps.image-meta.outputs.repository }}
IMAGE_TAG: ${{ gitea.sha }}
REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context
VPS_HOST: ${{ secrets.VPS_IP }}
run: |
VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}"
if [ -z "$VPS_HOST" ]; then
echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required"
exit 1
fi
SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "
set -e
cd '$REMOTE_BUILD_DIR'
mkdir -p '$DEPLOY_ROOT/scripts'
cp docker-compose.production.yml '$DEPLOY_ROOT/docker-compose.production.yml'
cp docker-compose.portainer.production.yml '$DEPLOY_ROOT/docker-compose.portainer.production.yml'
cp docker-compose.registry.production.yml '$DEPLOY_ROOT/docker-compose.registry.production.yml'
cp traefik.yaml '$DEPLOY_ROOT/traefik.yaml'
cp scripts/apply-env-secret-overrides.sh '$DEPLOY_ROOT/scripts/apply-env-secret-overrides.sh'
cp scripts/describe-env-values.sh '$DEPLOY_ROOT/scripts/describe-env-values.sh'
cp scripts/docker-prod-*.sh '$DEPLOY_ROOT/scripts/'
cp scripts/preserve-env-values.sh '$DEPLOY_ROOT/scripts/preserve-env-values.sh'
chmod +x '$DEPLOY_ROOT/scripts/'*.sh
printf '%s\n' \
'APP_IMAGE=$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY' \
'IMAGE_TAG=$IMAGE_TAG' \
'REGISTRY_HOST=$DEPLOY_REGISTRY_HOST' \
> '$DEPLOY_ROOT/release.env'
chmod 600 '$DEPLOY_ROOT/release.env'
echo 'Deployment files published to $DEPLOY_ROOT. Production was not deployed.'
"
+247 -234
View File
@@ -2,9 +2,9 @@ name: Test
on:
push:
branches: [develop]
branches: ["**"]
pull_request:
branches: [develop]
branches: ["**"]
concurrency:
group: test-${{ gitea.ref }}
@@ -17,60 +17,61 @@ env:
NPM_CONFIG_FETCH_RETRIES: "5"
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "20000"
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000"
NPM_CONFIG_INCLUDE: "optional"
jobs:
type-check:
name: Type Check (all packages)
runs-on: ubuntu-latest
steps:
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional && break
npm ci --include=optional --workspaces && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run db:generate
- run: npm run type-check
@@ -79,53 +80,55 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional && break
npm ci --include=optional --workspaces && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run db:generate
- run: npm run test:api
@@ -134,53 +137,55 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional && break
npm ci --include=optional --workspaces && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run build --workspace @rentaldrivego/types
- run: npm run test:homepage
@@ -189,53 +194,55 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional && break
npm ci --include=optional --workspaces && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run build --workspace @rentaldrivego/types
- run: npm run test:carplace
@@ -244,53 +251,55 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional && break
npm ci --include=optional --workspaces && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run test:admin
dashboard-tests:
@@ -298,53 +307,55 @@ jobs:
runs-on: ubuntu-latest
needs: type-check
steps:
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional && break
npm ci --include=optional --workspaces && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run build --workspace @rentaldrivego/types
- run: npm run test:dashboard
@@ -379,53 +390,55 @@ jobs:
JWT_EXPIRY: 8h
FILE_STORAGE_ROOT: /tmp/rentaldrivego-test-storage
steps:
- name: Check out repository
shell: bash
run: |
set -euo pipefail
if [ -z "$(find . -mindepth 1 -maxdepth 1 -print -quit)" ]; then
REPOSITORY_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
git init .
git remote add origin "$REPOSITORY_URL"
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
AUTH_HEADER="$(printf '%s:%s' "${{ gitea.actor }}" "${{ secrets.GITHUB_TOKEN }}" | base64 | tr -d '\n')"
git -c http.extraheader="Authorization: Basic ${AUTH_HEADER}" fetch --depth 1 origin "${{ gitea.ref }}"
else
git fetch --depth 1 origin "${{ gitea.ref }}"
fi
git checkout --detach "${{ gitea.sha }}" || git checkout --detach FETCH_HEAD
fi
- name: Check Node runtime
run: |
node --version
npm --version
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable && corepack prepare npm@10.5.0 --activate
- name: Install dependencies
run: |
if [ ! -f apps/api/package.json ] || [ ! -f packages/database/package.json ]; then
echo "::error::Workspace packages missing from checkout (apps/api, packages/database)."
ls -la apps packages 2>/dev/null || true
exit 1
fi
for attempt in 1 2 3; do
npm ci --include=optional && break
npm ci --include=optional --workspaces && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('rollup/package.json')" || {
echo "::error::npm ci did not install workspace deps (rollup missing). Confirm full package-lock.json was pushed."
ls -1 node_modules | head -n 80 || true
exit 1
}
- name: Repair Rollup optional dependency on Linux ARM64
run: |
ARCH="$(uname -m)"
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ROLLUP_VERSION="$(node -p "require('./node_modules/rollup/package.json').version")"
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
if [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "arm64" ]; then
exit 0
fi
if node -e "require('@rollup/rollup-linux-arm64-gnu')" 2>/dev/null; then
echo "Rollup ARM64 native binding already present."
exit 0
fi
ROLLUP_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/rollup'].version")"
if [ -z "$ROLLUP_VERSION" ] || [ "$ROLLUP_VERSION" = "undefined" ]; then
echo "::error::rollup version not found in package-lock.json"
exit 1
fi
for attempt in 1 2 3; do
npm install --no-save --include=optional "@rollup/rollup-linux-arm64-gnu@$ROLLUP_VERSION" && break
if [ "$attempt" = "3" ]; then
exit 1
fi
npm cache verify || true
sleep "$((attempt * 10))"
done
node -e "require('@rollup/rollup-linux-arm64-gnu')"
- run: npm run db:generate
- run: npm run db:deploy
- run: npx prisma db push --schema packages/database/prisma/schema.prisma
+26 -8
View File
@@ -12,7 +12,7 @@ variables:
# ====================================
# TEST STAGE
# Runs on every push and merge request
# Runs on every branch push and merge request
# ====================================
api_tests:
@@ -26,19 +26,19 @@ api_tests:
- npm run test:api
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
- if: '$CI_COMMIT_BRANCH'
storefront_tests:
carplace_tests:
stage: test
image: node:20-bookworm
before_script:
- npm ci
- npm run build --workspace @rentaldrivego/types
script:
- npm run test:storefront
- npm run test:carplace
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
- if: '$CI_COMMIT_BRANCH'
admin_tests:
stage: test
@@ -49,7 +49,7 @@ admin_tests:
- npm run test:admin
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
- if: '$CI_COMMIT_BRANCH'
dashboard_tests:
stage: test
@@ -61,7 +61,18 @@ dashboard_tests:
- npm run test:dashboard
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
- if: '$CI_COMMIT_BRANCH'
homepage_tests:
stage: test
image: node:20-bookworm
before_script:
- npm ci
script:
- npm run test:homepage
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH'
integration_tests:
stage: test
@@ -100,7 +111,7 @@ integration_tests:
- npm run test:api:integration
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
- if: '$CI_COMMIT_BRANCH'
# ====================================
# BUILD STAGE
@@ -109,6 +120,13 @@ integration_tests:
build_image:
stage: build
image: docker:24
needs:
- api_tests
- carplace_tests
- admin_tests
- dashboard_tests
- homepage_tests
- integration_tests
services:
- name: docker:24-dind
alias: docker
+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "https://app.kilo.ai/config.json",
"mcpServers": {
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"]
},
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"${workspaceFolder}"
]
},
"gitea": {
"command": "npx",
"args": ["-y", "gitea-mcp"],
"env": {
"GITEA_HOST": "https://192.168.3.80",
"GITEA_ACCESS_TOKEN": "d9eb80ffe28f6e4e3ad5d5a032ca9c5f93ea5414"
}
},
"sequential-thinking": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
},
"fetch": {
"command": "npx",
"args": ["-y", "fetch-mcp"]
},
"git": {
"command": "npx",
"args": ["-y", "git-mcp"],
"env": {
"GIT_DEFAULT_PATH": "${workspaceFolder}"
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"servers": {}
}
+7 -7
View File
@@ -11,7 +11,7 @@
"homepage": "retained as embedded Phase 16 source of truth",
"admin": "migrated",
"dashboard": "migrated",
"storefront": "migrated",
"carplace": "migrated",
"api": "unchanged"
},
"validation": {
@@ -37,37 +37,37 @@
"sha256": "7a35fad2750bccc09c24f24c1d57594048b040f24d4cde7639fa1a2ce3d63e64"
},
{
"path": "storefront/src/app/layout.tsx",
"path": "carplace/src/app/layout.tsx",
"status": "modified",
"bytes": 1833,
"sha256": "6fed4ef50a886e1c551b6456b46c5fc785b02a4be988aef06fa95c28443bbd27"
},
{
"path": "storefront/src/app/globals.css",
"path": "carplace/src/app/globals.css",
"status": "modified",
"bytes": 7525,
"sha256": "49e03206558248eb5de2b9a8463be6e0b43e01df518bc0b625fe2953a18185c6"
},
{
"path": "storefront/src/components/StorefrontFooter.tsx",
"path": "carplace/src/components/CarplaceFooter.tsx",
"status": "modified",
"bytes": 4632,
"sha256": "169d04dcf0976d2637ef5371ca295e9c362bd089d8d6ec313b4e8b4131d5bdd3"
},
{
"path": "storefront/src/components/StorefrontHeader.tsx",
"path": "carplace/src/components/CarplaceHeader.tsx",
"status": "modified",
"bytes": 10438,
"sha256": "25afc57ff9893f255eddf19580e84b510e6032764daaa5727e2e8d669dca4213"
},
{
"path": "storefront/src/styles/phase16-tokens.css",
"path": "carplace/src/styles/phase16-tokens.css",
"status": "added",
"bytes": 4214,
"sha256": "0e7fb35fb2833ff564c1188e39735349a8df901938fa87f2dc1b931dd4f04cd0"
},
{
"path": "storefront/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx",
"path": "carplace/src/app/(public)/explore/[slug]/vehicles/[id]/page.tsx",
"status": "modified",
"bytes": 13979,
"sha256": "9edf8da13e50cabcb2a126b300c71cb0b7a096a31d11d364e37dc09eeb625d3b"
+1 -1
View File
@@ -7,7 +7,7 @@
],
"includedApplications": [
"homepage",
"storefront",
"carplace",
"dashboard",
"admin",
"api"
-160
View File
@@ -1,160 +0,0 @@
# Security Hardening Application Report
Generated: 2026-06-09
Project: RentalDriveGo / Car Management System
Input archive: `/mnt/data/car_management_system_plan_applied(1).zip`
Plan applied: `/mnt/data/SECURITY_HARDENING_IMPLEMENTATION_PLAN(1).md`
## Executive result
The uploaded project was inspected and the security hardening plan was applied as far as safely possible inside the source archive. The archive already contained a broad previous hardening pass. I did not blindly trust that state, because that is how software becomes an expensive apology letter. I re-audited the implementation against the plan and applied additional corrections where the code still contradicted the target security model.
The final output includes a patched project archive, this report, a changed-file list, and an incremental diff for the additional changes made during this pass.
## What was already present in the uploaded project
The project already contained many of the plan-aligned building blocks:
- Centralized JWT helpers for actor tokens.
- HttpOnly session cookie helpers for admin, employee, and renter sessions.
- Company authorization policy helpers.
- Admin 2FA and fresh-2FA enforcement middleware.
- Public booking access-token helpers.
- Webhook idempotency helpers.
- Hashed `CompanyApiKey` model and migration.
- `ReservationPublicAccess` model and migration.
- `WebhookEvent` model and migration.
- Upload validation helpers and public/private storage separation.
- Request ID and sanitized error response middleware.
- Production Docker/Traefik hardening, including Redis authentication and `x-middleware-subrequest` blocking.
- Static security scan script and CI security gates.
That base work was useful, but it had a few security and test-consistency gaps.
## Additional changes applied in this pass
### 1. Removed legacy plaintext company API key storage
The plan requires company API keys to be hash-only. The code had introduced `CompanyApiKey`, but the legacy `Company.apiKey` field still existed in the Prisma schema and middleware still allowed a fallback lookup against that plaintext field when `ALLOW_LEGACY_COMPANY_API_KEYS=true`.
Changes applied:
- Removed `Company.apiKey` from `packages/database/prisma/schema.prisma`.
- Removed `apiKey` from the local `Company` TypeScript interface in `packages/database/src/index.ts` and `packages/database/src/index.d.ts`.
- Removed the legacy plaintext fallback path from `apps/api/src/middleware/requireApiKey.ts`.
- Added migration `20260609233000_drop_legacy_company_api_key` to drop the old column and unique index.
- Rewrote `requireApiKey` tests around prefix lookup, hash comparison, revocation, and `lastUsedAt` updates.
Security effect: raw company API keys are no longer accepted through the legacy company column and are no longer represented in the current Prisma schema.
### 2. Centralized Socket.io token verification
The main API process still verified Socket.io auth tokens directly with `jwt.verify(token, JWT_SECRET)` and did not enforce issuer, audience, actor type, or allowed algorithm. This contradicted the session-authentication phase of the plan.
Changes applied:
- Added `verifyAnyActorToken()` to `apps/api/src/security/tokens.ts`.
- Updated `apps/api/src/index.ts` to use centralized actor-token verification for Socket.io authentication.
- Removed the direct `jsonwebtoken` import from the main API entrypoint.
Security effect: Socket.io no longer accepts tokens that bypass the centralized actor-token constraints.
### 3. Hardened employee password-reset JWT verification
Employee password-reset tokens were signed and verified directly with the JWT secret and no issuer, audience, or algorithm constraints.
Changes applied:
- Added explicit `HS256` signing for employee password-reset tokens.
- Added issuer `rentaldrivego-api`.
- Added audience `employee_password_reset`.
- Added matching verification constraints.
Security effect: reset tokens now reject wrong issuer, wrong audience, or wrong algorithm instead of relying on a bare shared-secret verification.
### 4. Repaired test fixtures and middleware tests
Some tests still expected pre-hardening behavior. That is a bad smell: tests defending old weaknesses are basically tiny lobbyists for future incidents.
Changes applied:
- Updated API-key middleware tests to validate hashed-key behavior instead of plaintext `Company.apiKey` lookup.
- Updated auth middleware tests to match the centralized token verifier behavior.
- Updated integration test helper token generation to use `signActorToken()` so generated test tokens include issuer and audience.
Security effect: future test runs are less likely to push developers back toward weaker auth behavior.
## Verification performed in this sandbox
| Check | Result | Notes |
|---|---:|---|
| Static security scan | PASS | `npm run security:static` completed successfully. |
| Critical production dependency audit | PASS | `npm audit --package-lock-only --omit=dev --audit-level=critical` exited successfully. |
| JSON syntax check | PASS | Root and app `package.json` files and lockfile parsed successfully. |
| Shell syntax check | PASS | Shell scripts and production entrypoint parsed with `bash -n`. |
| YAML parse check | PASS | `docker-compose.production.yml` and `.gitlab-ci.yml` parsed successfully. |
| Node script syntax | PASS | `scripts/security-static-check.mjs` passed `node --check`. |
| Full dependency install | NOT COMPLETED | `npm ci --ignore-scripts --prefer-offline` could not complete in this sandbox. |
| Full type-check/test/build | NOT RUN | Requires dependencies to be installed. Run in CI or a normal development environment. |
| Docker compose config/render | NOT RUN | Docker is unavailable in this sandbox. |
| Prisma generate/migrate | NOT RUN | Requires dependency installation and a normal Prisma/DB environment. |
## Dependency audit note
The critical audit gate passes. The audit still reports moderate findings involving `postcss` through Next.js and `uuid` through Firebase/cron-related dependency chains. The lockfiles suggested fixes require forced or breaking upgrades, so I did not casually smash the dependency graph with a hammer and call the mess “security.” Those should be handled in a controlled dependency-upgrade ticket with full frontend and notification regression testing.
## Files changed by this pass
- `apps/api/src/index.ts`
- `apps/api/src/middleware/requireApiKey.ts`
- `apps/api/src/middleware/requireApiKey.test.ts`
- `apps/api/src/middleware/requireCompanyAuth.test.ts`
- `apps/api/src/middleware/requireRenterAuth.test.ts`
- `apps/api/src/modules/auth/auth.employee.service.ts`
- `apps/api/src/security/tokens.ts`
- `apps/api/src/tests/helpers/fixtures.ts`
- `packages/database/prisma/schema.prisma`
- `packages/database/prisma/migrations/20260609233000_drop_legacy_company_api_key/migration.sql`
- `packages/database/src/index.ts`
- `packages/database/src/index.d.ts`
See also:
- `security_hardening_incremental.diff`
- `security_hardening_changed_files.txt`
## Phase status against the hardening plan
| Phase | Status | Evidence / caveat |
|---|---:|---|
| Phase 0: Emergency stabilization | Partial / needs operator action | Static scan passes, placeholders are present, but real production secret rotation cannot be performed inside the archive. |
| Phase 1: Sessions and authentication | Substantially applied | HttpOnly session helpers and centralized actor JWT verification exist; Socket.io and reset-token gaps were corrected in this pass. |
| Phase 2: Authorization and tenant isolation | Substantially applied | Company policy middleware exists; tenant-safe patterns are present in many modules. Full proof requires test suite and code review across all repository methods. |
| Phase 3: Public booking privacy | Applied at source level | Public access token model/helper and safe public booking flow are present. Must be verified with integration tests. |
| Phase 4: Admin hardening | Applied at source level | Mandatory 2FA and fresh-2FA middleware are present. Enrollment and recovery-code workflows still need production validation. |
| Phase 5: API key hardening | Strengthened in this pass | Legacy plaintext company API key storage/fallback removed; hash-only `CompanyApiKey` path remains. |
| Phase 6: Payments and webhooks | Applied at source level | Raw-body webhook handling and idempotency helpers are present. Must be verified against provider test events. |
| Phase 7: Upload and storage hardening | Applied at source level | Magic-byte validation and public/private storage split are present. Must be verified with upload abuse tests. |
| Phase 8: Rate limiting, errors, browser security | Applied at source level | Request IDs, sanitized errors, rate limit middleware, and security headers are present. Must be verified in deployed environment. |
| Phase 9: Deployment hardening | Applied at config level | Redis auth, non-public DB tooling, private networks, and Traefik header blocking are present. Must be verified on the real host. |
| Phase 10: Observability, auditability, jobs | Partial | Logging/audit hooks exist, but queue migration and operational observability need dedicated validation. |
| CI/CD security gates | Applied at config level | Security scan and critical audit gates exist; full CI must run outside this sandbox. |
## Required follow-up before launch
1. Rotate all real production secrets and invalidate old sessions/API keys where appropriate.
2. Run `npm ci` in CI or a normal development environment.
3. Run `npm run db:generate` and apply the new migration after backup.
4. Run full type-check, unit tests, integration tests, e2e tests, and builds.
5. Run payment-provider webhook test events using real sandbox provider signatures.
6. Verify public/private storage behavior with real uploaded files.
7. Verify Redis and PostgreSQL are not externally reachable from the production host.
8. Run container image build and Trivy scan.
9. Confirm admin 2FA enrollment and fresh-2FA gates before enabling privileged admin actions in production.
10. Document any deferrals with owner, risk acceptance, compensating control, deadline, and ticket number.
## Launch recommendation
Do not launch publicly yet based only on the patched archive. The source now better matches the plan, and the critical static/audit checks pass, but the hard launch gate still depends on full CI, Prisma migration validation, deployment verification, and production secret rotation.
The patched archive is suitable for the next CI/staging pass. Treat it as implementation-ready source, not production clearance. Production clearance comes from reproducible evidence, not vibes in a ZIP file.
@@ -1,255 +0,0 @@
# Security Hardening Leftover Application Report
Project: RentalDriveGo / Car Management System
Input archive: `car_management_system_hardened_applied.zip`
Output archive: `car_management_system_leftover_applied.zip`
Date: 2026-06-09
## Executive Summary
This pass applied the remaining source-level hardening gaps that were still practical to implement directly in the repository after the first hardening pass. The focus was on eliminating browser-readable authentication assumptions, enforcing app-layer blocking for the `x-middleware-subrequest` bypass class, improving actor-aware rate limiting, adding an admin 2FA recovery-code workflow, and bringing documentation/static checks into line with the hardened authentication model.
This does not replace production operator work such as real secret rotation, live infrastructure verification, container scanning, applying migrations to a real database, or running the complete CI/test pipeline. Those items remain launch-gate evidence requirements.
## Applied Changes
### 1. Removed remaining browser-side employee token assumptions
Changed files:
- `apps/dashboard/src/lib/api.ts`
- `apps/dashboard/src/components/layout/TopBar.tsx`
- `apps/dashboard/src/components/layout/Sidebar.tsx`
- `apps/dashboard/src/app/(dashboard)/team/page.tsx`
- `apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`
What changed:
- Removed dashboard use of employee auth tokens from `localStorage`.
- Dashboard API calls now use `credentials: 'include'` and depend on HttpOnly cookies.
- Dashboard Socket.io connection now uses cookie credentials instead of script-provided auth tokens.
- Team page no longer decodes employee identity from a localStorage JWT. It resolves the current actor through `/auth/employee/me`.
- Admin 2FA sign-in form now accepts either a six-digit TOTP code or a recovery code value.
Security effect:
- Reduces script-readable authentication exposure.
- Aligns the dashboard with the intended HttpOnly session-cookie model.
- Prevents UI code from treating a readable JWT as the authority for employee identity.
### 2. Added Socket.io HttpOnly-cookie session support
Changed file:
- `apps/api/src/index.ts`
What changed:
- Added Socket.io session-token extraction from HttpOnly cookies.
- Preserved explicit token verification for trusted non-browser/server contexts, while browser clients can now authenticate through cookies.
- Reused centralized actor-token verification.
Security effect:
- Real-time dashboard connections no longer require JavaScript-readable employee tokens.
- Socket authentication now follows the same actor-token validation path used elsewhere.
### 3. Added app-layer `x-middleware-subrequest` blocking
Changed files:
- `apps/api/src/app.ts`
- `apps/dashboard/src/middleware.ts`
- `apps/storefront/src/middleware.ts`
- `apps/admin/src/middleware.ts`
- `apps/dashboard/src/middleware.test.ts`
- `apps/storefront/src/middleware.test.ts`
What changed:
- API now rejects requests containing `x-middleware-subrequest` before route handling.
- Dashboard, storefront, and admin Next middleware now reject the same header at the app layer.
- Added/updated middleware tests for the rejection path.
Security effect:
- Adds defense in depth beyond reverse-proxy filtering.
- Prevents the project from depending on a single infrastructure control for this bypass class.
### 4. Hardened admin/browser fetch behavior
Changed files include:
- `apps/admin/src/lib/api.ts`
- `apps/admin/src/app/dashboard/admin-users/page.tsx`
- `apps/admin/src/app/dashboard/renters/page.tsx`
- `apps/admin/src/app/dashboard/companies/[id]/page.tsx`
- `apps/admin/src/app/dashboard/containers/page.tsx`
- `apps/admin/src/app/dashboard/pricing/page.tsx`
- `apps/admin/src/app/forgot-password/page.tsx`
- `apps/admin/src/app/reset-password/page.tsx`
What changed:
- Admin API wrapper uses `credentials: 'include'`.
- Manual admin fetch calls now include credentials where they directly call the admin API.
- Removed dead placeholder `getToken()` helpers that returned empty strings and created meaningless `Authorization: Bearer ` headers.
Security effect:
- Admin browser requests now consistently rely on the HttpOnly admin session cookie.
- Removes misleading bearer-token scaffolding from the admin UI.
### 5. Improved actor-aware rate limiting
Changed files:
- `apps/api/src/middleware/rateLimiter.ts`
- `apps/api/src/app.ts`
What changed:
- API rate-limit keys now prefer a verified actor identity from session cookies or valid Bearer tokens.
- Actor-aware rate limiting falls back safely to existing request actor fields or anonymous IP keys.
- Admin authentication routes now receive the stricter authentication limiter before the broader admin limiter.
Security effect:
- Authenticated traffic is limited by actor identity instead of only coarse IP data.
- Login and admin-auth abuse get stricter protection.
- Multi-container correctness still depends on Redis availability/configuration in production, which must be verified during deployment.
### 6. Added admin 2FA recovery-code backend workflow
Changed files:
- `packages/database/prisma/schema.prisma`
- `packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql`
- `apps/api/src/modules/admin/admin.repo.ts`
- `apps/api/src/modules/admin/admin.service.ts`
- `apps/api/src/modules/admin/admin.schemas.ts`
- `apps/api/src/modules/admin/admin.routes.ts`
What changed:
- Added `AdminRecoveryCode` model.
- Recovery codes are stored as bcrypt hashes, not plaintext.
- TOTP enrollment now issues one-time recovery codes.
- Recovery-code regeneration is protected by authenticated admin access and fresh 2FA.
- Login can consume a valid unused recovery code when TOTP is enabled.
- Recovery-code issuance and use are audited.
Security effect:
- Adds a recovery path for mandatory admin 2FA without storing backup codes in plaintext.
- Preserves one-time-use semantics.
- Adds auditability for recovery-code lifecycle events.
Limitation:
- The backend returns recovery codes after enrollment/regeneration. A production-ready admin UI still needs to display them once with clear save instructions and must not persist them client-side.
### 7. Strengthened static scanning for auth-token regressions
Changed file:
- `scripts/security-static-check.mjs`
What changed:
- Static scan now catches common regressions involving auth/session token names in `localStorage` and `document.cookie`.
- Scan specifically targets auth token/session names such as employee/admin/renter tokens and sessions.
Security effect:
- Makes it harder for future code changes to quietly reintroduce script-readable authentication tokens.
### 8. Updated documentation to match the hardened model
Changed files:
- `apps/dashboard/README.md`
- `memory/project_auth_architecture.md`
- `docs/project-design/COOKIE_POLICY.md`
- `apps/api/src/swagger/openapi.ts`
What changed:
- Replaced stale localStorage/JWT handoff claims with HttpOnly session-cookie wording.
- Clarified that browser clients use HttpOnly sessions and that Bearer tokens are only for documented trusted server/mobile contexts.
- Updated cookie policy references from legacy `employee_token` wording to `employee_session`.
Security effect:
- Reduces the odds that a future developer follows stale documentation and reintroduces the old pattern.
## Validation Performed
The following checks were run successfully in the available environment:
```bash
npm run security:static
node --check scripts/security-static-check.mjs
# JSON parse validation for package.json and package-lock.json files
# YAML parse validation for docker-compose.production.yml and .gitlab-ci.yml
bash -n docker/entrypoint.production.sh scripts/docker-prod-*.sh scripts/docker-registry-local-up.sh scripts/setup-clerk-keys.sh
# TypeScript/TSX syntax transpile check over 507 source files
npm audit --package-lock-only --omit=dev --audit-level=critical
```
Results:
- Security static check: passed.
- Static-check script syntax: passed.
- Package JSON validation: passed.
- YAML validation: passed.
- Shell syntax validation: passed.
- TypeScript/TSX syntax transpile validation: passed.
- Critical production dependency audit: passed.
Audit note:
- `npm audit --audit-level=critical` exited successfully.
- Moderate advisories remain for transitive `postcss` and `uuid` paths. The available automatic fixes require breaking/force dependency changes, so they were not applied blindly in this source pass.
## Not Fully Verified in This Environment
The following items require a real development/CI/deployment environment:
- `npm ci` from a clean checkout.
- Full workspace typecheck.
- Full unit, integration, security, and e2e test suites.
- Prisma client generation.
- Applying the new database migration to a real database.
- Database backup/restore verification.
- Docker image build and runtime validation.
- Container scanning with Trivy or equivalent.
- Live reverse-proxy validation for `x-middleware-subrequest` blocking.
- Redis-backed distributed rate-limit validation across multiple API containers.
- Provider webhook sandbox tests.
- Live admin 2FA recovery-code UX verification.
## Remaining Launch-Gate Work
These are still not things source edits can prove by themselves:
1. Rotate all real production secrets.
2. Confirm no real secrets exist in repository history or image layers.
3. Apply and verify the new `admin_recovery_codes` migration.
4. Run the full CI gate: lint, typecheck, unit tests, integration tests, security tests, build, dependency audit, secret scan, and container scan.
5. Confirm Redis and PostgreSQL are private in production.
6. Confirm DB management tools are not publicly reachable.
7. Confirm production containers run non-root with reduced capabilities and resource limits.
8. Confirm webhook signature/idempotency behavior against real provider sandbox payloads.
9. Confirm private files are inaccessible through static routes in the deployed environment.
10. Confirm the admin UI displays recovery codes once and instructs admins to save them securely.
## Changed Files
See `security_hardening_leftover_changed_files.txt` for the complete file list and `security_hardening_leftover.diff` for the unified diff.
## Final Assessment
This pass closes a meaningful set of leftover source-level gaps from the security-hardening plan. The project is closer to the intended model: API-enforced security, HttpOnly browser sessions, stronger admin 2FA recovery, app-layer bypass blocking, and less stale documentation.
However, this still should not be treated as production-ready until the remaining launch-gate evidence is collected from CI and the live deployment environment. Security that has not been tested in the actual runtime is mostly optimism with a lanyard.
+1 -1
View File
@@ -10,7 +10,7 @@ const ADMIN_BASE_PATH = '/admin'
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
const assetPrefix = normalizeAssetPrefix(process.env.ADMIN_ASSET_PREFIX, ADMIN_BASE_PATH)
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] })
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix], frameSources: ['blob:'] })
const nextConfig = {
basePath: ADMIN_BASE_PATH,
@@ -9,6 +9,7 @@ interface AdminUser {
lastName: string
email: string
role: string
preferredLocale: 'ar' | 'en' | 'fr'
isActive: boolean
createdAt: string
permissions?: { id: string; resource: string; actions: string[] }[]
@@ -21,6 +22,7 @@ const EMPTY_FORM = {
email: '',
password: '',
role: 'SUPPORT',
preferredLocale: 'en',
isActive: true,
}
@@ -68,6 +70,7 @@ export default function AdminUsersPage() {
email: admin.email,
password: '',
role: admin.role,
preferredLocale: admin.preferredLocale,
isActive: admin.isActive,
})
setError(null)
@@ -92,6 +95,7 @@ export default function AdminUsersPage() {
lastName: form.lastName,
email: form.email,
role: form.role,
preferredLocale: form.preferredLocale,
isActive: form.isActive,
...(form.password ? { password: form.password } : {}),
}
@@ -206,6 +210,7 @@ export default function AdminUsersPage() {
<label className="block text-xs font-medium text-zinc-400 mb-1">First name</label>
<input
required
maxLength={50}
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
@@ -215,6 +220,7 @@ export default function AdminUsersPage() {
<label className="block text-xs font-medium text-zinc-400 mb-1">Last name</label>
<input
required
maxLength={50}
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
@@ -226,6 +232,7 @@ export default function AdminUsersPage() {
<input
type="email"
required
maxLength={254}
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
@@ -240,6 +247,7 @@ export default function AdminUsersPage() {
type={showPassword ? 'text' : 'password'}
required={!editingAdminId}
minLength={editingAdminId ? undefined : 8}
maxLength={128}
className="w-full px-3 py-2 pr-10 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
@@ -273,6 +281,18 @@ export default function AdminUsersPage() {
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Notification language</label>
<select
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.preferredLocale}
onChange={(e) => setForm({ ...form, preferredLocale: e.target.value as 'ar' | 'en' | 'fr' })}
>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="ar">العربية</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Status</label>
<select
File diff suppressed because it is too large Load Diff
@@ -80,7 +80,6 @@ interface CompanyDetail {
terms: string
fuelPolicyType: string
lateFeePerHour: number | null
taxRate: number | null
signatureRequired: boolean
showTax: boolean
} | null
@@ -180,7 +179,6 @@ interface FormState {
terms: string
fuelPolicyType: string
lateFeePerHour: string
taxRate: string
signatureRequired: boolean
showTax: boolean
}
@@ -205,8 +203,20 @@ const STATUS_COLORS: Record<string, string> = {
}
const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500'
const MOROCCAN_PHONE_PATTERN = '^(?:(?:\\+|00)212|0)\\s?[5-7](?:\\s?\\d){8}$'
const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500'
const COMPANY_TABS = [
{ id: 'company', label: 'Company' },
{ id: 'subscription', label: 'Subscription' },
{ id: 'publicProfile', label: 'Public profile' },
{ id: 'legalProfile', label: 'Legal profile' },
{ id: 'operations', label: 'Operations & finance' },
{ id: 'activity', label: 'Activity' },
] as const
type CompanyTabId = (typeof COMPANY_TABS)[number]['id']
function toDateInput(value: string | null | undefined) {
return value ? new Date(value).toISOString().slice(0, 10) : ''
}
@@ -275,7 +285,7 @@ function createFormState(company: CompanyDetail): FormState {
publicCountry: company.brand?.publicCountry ?? '',
websiteUrl: company.brand?.websiteUrl ?? '',
whatsappNumber: company.brand?.whatsappNumber ?? '',
defaultLocale: company.brand?.defaultLocale ?? 'en',
defaultLocale: company.brand?.defaultLocale ?? 'ar',
defaultCurrency: company.brand?.defaultCurrency ?? 'MAD',
isListedOnCarplace: company.brand?.isListedOnCarplace ?? true,
},
@@ -286,7 +296,6 @@ function createFormState(company: CompanyDetail): FormState {
terms: company.contractSettings?.terms ?? '',
fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL',
lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '',
taxRate: company.contractSettings?.taxRate?.toString() ?? '',
signatureRequired: company.contractSettings?.signatureRequired ?? true,
showTax: company.contractSettings?.showTax ?? false,
},
@@ -318,6 +327,7 @@ export default function AdminCompanyDetailPage() {
const [saving, setSaving] = useState(false)
const [savedMessage, setSavedMessage] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [activeTab, setActiveTab] = useState<CompanyTabId>('company')
async function fetchData() {
try {
@@ -429,7 +439,6 @@ export default function AdminCompanyDetailPage() {
terms: form.contractSettings.terms,
fuelPolicyType: form.contractSettings.fuelPolicyType,
lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null,
taxRate: form.contractSettings.taxRate ? Number(form.contractSettings.taxRate) : null,
signatureRequired: form.contractSettings.signatureRequired,
showTax: form.contractSettings.showTax,
},
@@ -566,25 +575,57 @@ export default function AdminCompanyDetailPage() {
))}
</div>
<div className="grid gap-6 xl:grid-cols-2">
<section className="panel p-6">
<div className="space-y-6">
<div className="overflow-x-auto border-b border-zinc-800">
<div className="flex min-w-max gap-1" role="tablist" aria-label="Company detail subjects">
{COMPANY_TABS.map((tab) => {
const selected = activeTab === tab.id
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected}
aria-controls={`company-tab-${tab.id}`}
id={`company-tab-trigger-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`rounded-t-xl px-4 py-3 text-sm font-semibold transition-colors ${
selected
? 'bg-zinc-800 text-white'
: 'text-zinc-500 hover:bg-zinc-900 hover:text-zinc-200'
}`}
>
{tab.label}
</button>
)
})}
</div>
</div>
<section
id="company-tab-company"
role="tabpanel"
aria-labelledby="company-tab-trigger-company"
hidden={activeTab !== 'company'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Company</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>Name</span>
<input className={INPUT_CLASS} value={form.company.name} onChange={(e) => updateSection('company', { name: e.target.value })} />
<input className={INPUT_CLASS} value={form.company.name} maxLength={100} onChange={(e) => updateSection('company', { name: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Slug</span>
<input className={INPUT_CLASS} value={form.company.slug} onChange={(e) => updateSection('company', { slug: e.target.value })} />
<input className={INPUT_CLASS} value={form.company.slug} maxLength={50} pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$" onChange={(e) => updateSection('company', { slug: e.target.value.toLowerCase() })} />
</label>
<label>
<span className={LABEL_CLASS}>Email</span>
<input className={INPUT_CLASS} type="email" value={form.company.email} onChange={(e) => updateSection('company', { email: e.target.value })} />
<input className={INPUT_CLASS} type="email" value={form.company.email} maxLength={254} onChange={(e) => updateSection('company', { email: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Phone</span>
<input className={INPUT_CLASS} value={form.company.phone} onChange={(e) => updateSection('company', { phone: e.target.value })} />
<input className={INPUT_CLASS} type="tel" value={form.company.phone} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(e) => updateSection('company', { phone: e.target.value })} />
</label>
<div>
<span className={LABEL_CLASS}>Company status</span>
@@ -592,12 +633,18 @@ export default function AdminCompanyDetailPage() {
</div>
<label>
<span className={LABEL_CLASS}>Subscription payment ref</span>
<input className={INPUT_CLASS} value={form.company.subscriptionPaymentRef} onChange={(e) => updateSection('company', { subscriptionPaymentRef: e.target.value })} />
<input className={INPUT_CLASS} value={form.company.subscriptionPaymentRef} maxLength={120} onChange={(e) => updateSection('company', { subscriptionPaymentRef: e.target.value })} />
</label>
</div>
</section>
<section className="panel p-6">
<section
id="company-tab-subscription"
role="tabpanel"
aria-labelledby="company-tab-trigger-subscription"
hidden={activeTab !== 'subscription'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Subscription</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
@@ -663,28 +710,34 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section className="panel p-6">
<section
id="company-tab-publicProfile"
role="tabpanel"
aria-labelledby="company-tab-trigger-publicProfile"
hidden={activeTab !== 'publicProfile'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Brand and public profile</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>Display name</span>
<input className={INPUT_CLASS} value={form.brand.displayName} onChange={(e) => updateSection('brand', { displayName: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.displayName} maxLength={100} onChange={(e) => updateSection('brand', { displayName: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Subdomain</span>
<input className={INPUT_CLASS} value={form.brand.subdomain} onChange={(e) => updateSection('brand', { subdomain: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.subdomain} maxLength={50} pattern="^[a-z0-9-]+$" onChange={(e) => updateSection('brand', { subdomain: e.target.value.toLowerCase() })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>Tagline</span>
<input className={INPUT_CLASS} value={form.brand.tagline} onChange={(e) => updateSection('brand', { tagline: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.tagline} maxLength={160} onChange={(e) => updateSection('brand', { tagline: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Public email</span>
<input className={INPUT_CLASS} type="email" value={form.brand.publicEmail} onChange={(e) => updateSection('brand', { publicEmail: e.target.value })} />
<input className={INPUT_CLASS} type="email" value={form.brand.publicEmail} maxLength={254} onChange={(e) => updateSection('brand', { publicEmail: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Public phone</span>
<input className={INPUT_CLASS} value={form.brand.publicPhone} onChange={(e) => updateSection('brand', { publicPhone: e.target.value })} />
<input className={INPUT_CLASS} type="tel" value={form.brand.publicPhone} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(e) => updateSection('brand', { publicPhone: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Custom domain</span>
@@ -696,11 +749,11 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>WhatsApp</span>
<input className={INPUT_CLASS} value={form.brand.whatsappNumber} onChange={(e) => updateSection('brand', { whatsappNumber: e.target.value })} />
<input className={INPUT_CLASS} type="tel" value={form.brand.whatsappNumber} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(e) => updateSection('brand', { whatsappNumber: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Locale</span>
<input className={INPUT_CLASS} value={form.brand.defaultLocale} onChange={(e) => updateSection('brand', { defaultLocale: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.defaultLocale} maxLength={2} pattern="^(en|fr|ar)$" onChange={(e) => updateSection('brand', { defaultLocale: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Brand currency</span>
@@ -712,15 +765,15 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>City</span>
<input className={INPUT_CLASS} value={form.brand.publicCity} onChange={(e) => updateSection('brand', { publicCity: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.publicCity} maxLength={85} onChange={(e) => updateSection('brand', { publicCity: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Country</span>
<input className={INPUT_CLASS} value={form.brand.publicCountry} onChange={(e) => updateSection('brand', { publicCountry: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.publicCountry || 'MA'} maxLength={2} pattern="^[A-Z]{2}$" onChange={(e) => updateSection('brand', { publicCountry: e.target.value.toUpperCase() })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>Address</span>
<input className={INPUT_CLASS} value={form.brand.publicAddress} onChange={(e) => updateSection('brand', { publicAddress: e.target.value })} />
<input className={INPUT_CLASS} value={form.brand.publicAddress} maxLength={255} onChange={(e) => updateSection('brand', { publicAddress: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.brand.isListedOnCarplace} onChange={(e) => updateSection('brand', { isListedOnCarplace: e.target.checked })} />
@@ -729,7 +782,13 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section className="panel p-6">
<section
id="company-tab-legalProfile"
role="tabpanel"
aria-labelledby="company-tab-trigger-legalProfile"
hidden={activeTab !== 'legalProfile'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Company legal profile</h2>
<div className="mt-4 grid gap-6">
<div className="grid gap-4 md:grid-cols-2">
@@ -739,11 +798,11 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>Manager / owner name</span>
<input className={INPUT_CLASS} value={form.companyProfile.managerName} onChange={(e) => updateSection('companyProfile', { managerName: e.target.value })} />
<input className={INPUT_CLASS} value={form.companyProfile.managerName} maxLength={100} onChange={(e) => updateSection('companyProfile', { managerName: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>ZIP / postal code</span>
<input className={INPUT_CLASS} value={form.companyProfile.zipCode} onChange={(e) => updateSection('companyProfile', { zipCode: e.target.value })} />
<input className={INPUT_CLASS} value={form.companyProfile.zipCode} maxLength={10} onChange={(e) => updateSection('companyProfile', { zipCode: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Fax</span>
@@ -755,11 +814,11 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>ICE number</span>
<input className={INPUT_CLASS} value={form.companyProfile.iceNumber} onChange={(e) => updateSection('companyProfile', { iceNumber: e.target.value })} />
<input className={INPUT_CLASS} value={form.companyProfile.iceNumber} maxLength={30} onChange={(e) => updateSection('companyProfile', { iceNumber: e.target.value.toUpperCase() })} />
</label>
<label>
<span className={LABEL_CLASS}>Operating license number</span>
<input className={INPUT_CLASS} value={form.companyProfile.operatingLicenseNumber} onChange={(e) => updateSection('companyProfile', { operatingLicenseNumber: e.target.value })} />
<input className={INPUT_CLASS} value={form.companyProfile.operatingLicenseNumber} maxLength={30} onChange={(e) => updateSection('companyProfile', { operatingLicenseNumber: e.target.value.toUpperCase() })} />
</label>
<label>
<span className={LABEL_CLASS}>Operating license issue date</span>
@@ -798,7 +857,7 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>Identity document number</span>
<input className={INPUT_CLASS} value={form.companyProfile.responsibleIdentityNumber} onChange={(e) => updateSection('companyProfile', { responsibleIdentityNumber: e.target.value })} />
<input className={INPUT_CLASS} value={form.companyProfile.responsibleIdentityNumber} maxLength={30} onChange={(e) => updateSection('companyProfile', { responsibleIdentityNumber: e.target.value.toUpperCase() })} />
</label>
<label>
<span className={LABEL_CLASS}>Qualification / diploma / experience</span>
@@ -806,32 +865,38 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>Responsible phone</span>
<input className={INPUT_CLASS} value={form.companyProfile.responsiblePhone} onChange={(e) => updateSection('companyProfile', { responsiblePhone: e.target.value })} />
<input className={INPUT_CLASS} type="tel" value={form.companyProfile.responsiblePhone} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(e) => updateSection('companyProfile', { responsiblePhone: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Responsible email</span>
<input className={INPUT_CLASS} type="email" value={form.companyProfile.responsibleEmail} onChange={(e) => updateSection('companyProfile', { responsibleEmail: e.target.value })} />
<input className={INPUT_CLASS} type="email" value={form.companyProfile.responsibleEmail} maxLength={254} onChange={(e) => updateSection('companyProfile', { responsibleEmail: e.target.value })} />
</label>
</div>
</div>
</div>
</section>
<section className="panel p-6">
<section
id="company-tab-operations"
role="tabpanel"
aria-labelledby="company-tab-trigger-operations"
hidden={activeTab !== 'operations'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Operations and finance</h2>
<div className="mt-4 grid gap-6">
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>Legal name</span>
<input className={INPUT_CLASS} value={form.contractSettings.legalName} onChange={(e) => updateSection('contractSettings', { legalName: e.target.value })} />
<input className={INPUT_CLASS} value={form.contractSettings.legalName} maxLength={100} onChange={(e) => updateSection('contractSettings', { legalName: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Registration number</span>
<input className={INPUT_CLASS} value={form.contractSettings.registrationNumber} onChange={(e) => updateSection('contractSettings', { registrationNumber: e.target.value })} />
<input className={INPUT_CLASS} value={form.contractSettings.registrationNumber} maxLength={30} onChange={(e) => updateSection('contractSettings', { registrationNumber: e.target.value.toUpperCase() })} />
</label>
<label>
<span className={LABEL_CLASS}>Tax ID</span>
<input className={INPUT_CLASS} value={form.contractSettings.taxId} onChange={(e) => updateSection('contractSettings', { taxId: e.target.value })} />
<input className={INPUT_CLASS} value={form.contractSettings.taxId} maxLength={30} onChange={(e) => updateSection('contractSettings', { taxId: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Fuel policy type</span>
@@ -847,10 +912,6 @@ export default function AdminCompanyDetailPage() {
<span className={LABEL_CLASS}>Late fee per hour</span>
<input className={INPUT_CLASS} type="number" value={form.contractSettings.lateFeePerHour} onChange={(e) => updateSection('contractSettings', { lateFeePerHour: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Tax rate</span>
<input className={INPUT_CLASS} type="number" step="0.01" value={form.contractSettings.taxRate} onChange={(e) => updateSection('contractSettings', { taxRate: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.contractSettings.signatureRequired} onChange={(e) => updateSection('contractSettings', { signatureRequired: e.target.checked })} />
Signature required
@@ -861,7 +922,7 @@ export default function AdminCompanyDetailPage() {
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>Terms</span>
<textarea className={`${INPUT_CLASS} min-h-28`} value={form.contractSettings.terms} onChange={(e) => updateSection('contractSettings', { terms: e.target.value })} />
<textarea className={`${INPUT_CLASS} min-h-28`} value={form.contractSettings.terms} maxLength={4000} onChange={(e) => updateSection('contractSettings', { terms: e.target.value })} />
</label>
</div>
@@ -899,11 +960,11 @@ export default function AdminCompanyDetailPage() {
</label>
<label>
<span className={LABEL_CLASS}>Accountant name</span>
<input className={INPUT_CLASS} value={form.accountingSettings.accountantName} onChange={(e) => updateSection('accountingSettings', { accountantName: e.target.value })} />
<input className={INPUT_CLASS} value={form.accountingSettings.accountantName} maxLength={50} onChange={(e) => updateSection('accountingSettings', { accountantName: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Accountant email</span>
<input className={INPUT_CLASS} type="email" value={form.accountingSettings.accountantEmail} onChange={(e) => updateSection('accountingSettings', { accountantEmail: e.target.value })} />
<input className={INPUT_CLASS} type="email" value={form.accountingSettings.accountantEmail} maxLength={254} onChange={(e) => updateSection('accountingSettings', { accountantEmail: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.accountingSettings.autoSendReport} onChange={(e) => updateSection('accountingSettings', { autoSendReport: e.target.checked })} />
@@ -915,7 +976,13 @@ export default function AdminCompanyDetailPage() {
</section>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div
id="company-tab-activity"
role="tabpanel"
aria-labelledby="company-tab-trigger-activity"
hidden={activeTab !== 'activity'}
className="grid gap-6 lg:grid-cols-2"
>
<div className="space-y-4">
<div className="panel p-6 space-y-4">
<h2 className="text-base font-semibold">Company actions</h2>
+12 -428
View File
@@ -1,435 +1,19 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ADMIN_API_BASE } from '@/lib/api'
type ContainerStatus = 'PENDING' | 'CREATING' | 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'REMOVING' | 'ERROR'
interface CompanyContainer {
id: string
companyId: string
dockerId: string | null
containerName: string
status: ContainerStatus
port: number
image: string
errorMessage: string | null
createdAt: string
updatedAt: string
company: {
id: string
name: string
slug: string
status: string
}
}
function authHeaders() {
return { 'Content-Type': 'application/json' }
}
function StatusBadge({ status }: { status: ContainerStatus }) {
const map: Record<ContainerStatus, { label: string; className: string }> = {
PENDING: { label: 'Pending', className: 'bg-zinc-700 text-zinc-300' },
CREATING: { label: 'Creating…', className: 'bg-blue-900 text-blue-300 animate-pulse' },
RUNNING: { label: 'Running', className: 'bg-emerald-900 text-emerald-300' },
STOPPED: { label: 'Stopped', className: 'bg-orange-900 text-orange-300' },
RESTARTING: { label: 'Restarting…',className: 'bg-orange-900 text-orange-300 animate-pulse' },
REMOVING: { label: 'Removing…', className: 'bg-red-900 text-red-300 animate-pulse' },
ERROR: { label: 'Error', className: 'bg-red-950 text-red-400' },
}
const { label, className } = map[status] ?? map.ERROR
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
<span className={`h-1.5 w-1.5 rounded-full ${status === 'RUNNING' ? 'bg-emerald-400' : 'bg-current opacity-60'}`} />
{label}
</span>
)
}
function LogsModal({ companyId, companyName, onClose }: { companyId: string; companyName: string; onClose: () => void }) {
const [logs, setLogs] = useState<string>('')
const [loading, setLoading] = useState(true)
const [tail, setTail] = useState(150)
const bottomRef = useRef<HTMLDivElement>(null)
const fetchLogs = useCallback(async (lines: number) => {
setLoading(true)
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders(), credentials: 'include' })
const json = await res.json()
setLogs(json.data?.logs ?? '')
} catch {
setLogs('Failed to fetch logs.')
} finally {
setLoading(false)
}
}, [companyId])
useEffect(() => { fetchLogs(tail) }, [fetchLogs, tail])
useEffect(() => { bottomRef.current?.scrollIntoView() }, [logs])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#07101e]/70 p-4" onClick={onClose}>
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
<div>
<p className="text-sm font-semibold text-zinc-100">Container Logs</p>
<p className="text-xs text-zinc-400">{companyName}</p>
</div>
<div className="flex items-center gap-3">
<select
value={tail}
onChange={(e) => setTail(Number(e.target.value))}
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-200 focus:outline-none"
>
<option value={50}>Last 50 lines</option>
<option value={150}>Last 150 lines</option>
<option value={500}>Last 500 lines</option>
<option value={1000}>Last 1000 lines</option>
</select>
<button onClick={() => fetchLogs(tail)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
Refresh
</button>
<button onClick={onClose} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
Close
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<p className="text-xs text-zinc-500">Loading</p>
) : (
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-relaxed text-zinc-300">
{logs || 'No logs available.'}
</pre>
)}
<div ref={bottomRef} />
</div>
</div>
</div>
)
}
type ProvisionResult = { companyId: string; name: string; status: 'created' | 'error'; error?: string }
export default function ContainersPage() {
const [containers, setContainers] = useState<CompanyContainer[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [logsFor, setLogsFor] = useState<{ companyId: string; companyName: string } | null>(null)
const [search, setSearch] = useState('')
const [provisioning, setProvisioning] = useState(false)
const [provisionResults, setProvisionResults] = useState<ProvisionResult[] | null>(null)
const fetchContainers = useCallback(async () => {
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders(), credentials: 'include' })
const json = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(json?.message ?? 'Failed to load containers.')
}
setContainers(json.data ?? [])
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load containers.')
/* silent — keep old data */
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchContainers()
const id = setInterval(fetchContainers, 8000)
return () => clearInterval(id)
}, [fetchContainers])
async function provisionAll() {
setProvisioning(true)
setProvisionResults(null)
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders(), credentials: 'include' })
const json = await res.json().catch(() => null)
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
setProvisionResults(json.data?.results ?? [])
setError(null)
await fetchContainers()
} catch (err) {
setError(err instanceof Error ? err.message : 'Provisioning failed.')
} finally {
setProvisioning(false)
}
}
async function act(companyId: string, action: 'start' | 'stop' | 'restart' | 'deploy' | 'remove') {
setBusy((b) => ({ ...b, [companyId]: true }))
try {
const method = action === 'remove' ? 'DELETE' : 'POST'
const url =
action === 'remove'
? `${ADMIN_API_BASE}/admin/containers/${companyId}`
: `${ADMIN_API_BASE}/admin/containers/${companyId}/${action}`
const res = await fetch(url, { method, headers: authHeaders(), credentials: 'include' })
const json = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(json?.message ?? `Failed to ${action} container.`)
}
setError(null)
await fetchContainers()
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${action} container.`)
} finally {
setBusy((b) => ({ ...b, [companyId]: false }))
}
}
const filtered = containers.filter(
(c) =>
c.company.name.toLowerCase().includes(search.toLowerCase()) ||
c.containerName.toLowerCase().includes(search.toLowerCase()) ||
c.company.slug.toLowerCase().includes(search.toLowerCase()),
)
const stats = {
running: containers.filter((c) => c.status === 'RUNNING').length,
stopped: containers.filter((c) => c.status === 'STOPPED').length,
error: containers.filter((c) => c.status === 'ERROR').length,
total: containers.length,
}
return (
<div className="min-h-full p-8">
{logsFor && (
<LogsModal
companyId={logsFor.companyId}
companyName={logsFor.companyName}
onClose={() => setLogsFor(null)}
/>
)}
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold text-zinc-100">Containers</h1>
<p className="mt-1 text-sm text-zinc-400">Manage isolated Docker Compose services for each company workspace.</p>
</div>
<button
onClick={provisionAll}
disabled={provisioning}
className="flex items-center gap-2 rounded-xl bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{provisioning ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
Provisioning
</>
) : (
<>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
</svg>
Provision All Accounts
</>
)}
</button>
</div>
{error && (
<div className="mb-6 rounded-xl border border-red-900 bg-red-950/70 px-4 py-3 text-sm text-red-200">
{error}
</div>
)}
{provisionResults !== null && (
<div className="mb-6 rounded-xl border border-zinc-800 bg-zinc-900 p-4">
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-medium text-zinc-200">
Provisioning complete {' '}
<span className="text-emerald-400">{provisionResults.filter((r) => r.status === 'created').length} created</span>
{provisionResults.some((r) => r.status === 'error') && (
<>, <span className="text-red-400">{provisionResults.filter((r) => r.status === 'error').length} failed</span></>
)}
</p>
<button onClick={() => setProvisionResults(null)} className="text-xs text-zinc-500 hover:text-zinc-300">Dismiss</button>
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{provisionResults.map((r) => (
<div key={r.companyId} className="flex items-center gap-3 rounded-lg px-3 py-2 bg-zinc-800/60">
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${r.status === 'created' ? 'bg-emerald-400' : 'bg-red-400'}`} />
<span className="text-sm text-zinc-300 flex-1">{r.name}</span>
{r.status === 'error' && <span className="text-xs text-red-400 truncate max-w-xs">{r.error}</span>}
{r.status === 'created' && <span className="text-xs text-emerald-500">Service created</span>}
</div>
))}
</div>
</div>
)}
{/* Stats */}
<div className="mb-6 grid grid-cols-4 gap-4">
{[
{ label: 'Total', value: stats.total, color: 'text-zinc-100' },
{ label: 'Running', value: stats.running, color: 'text-emerald-400' },
{ label: 'Stopped', value: stats.stopped, color: 'text-orange-400' },
{ label: 'Error', value: stats.error, color: 'text-red-400' },
].map((s) => (
<div key={s.label} className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
<p className="text-xs text-zinc-500">{s.label}</p>
<p className={`mt-1 text-2xl font-bold ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{/* Search */}
<div className="mb-4">
<input
type="text"
placeholder="Search by company name or container…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full max-w-sm rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* Table */}
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center text-sm text-zinc-500">
{search ? 'No containers match your search.' : 'No containers yet. They are created automatically on company signup.'}
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-left text-xs text-zinc-500">
<th className="px-5 py-3 font-medium">Company</th>
<th className="px-5 py-3 font-medium">Container</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium">Port</th>
<th className="px-5 py-3 font-medium">Image</th>
<th className="px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{filtered.map((c) => {
const isBusy = busy[c.companyId] ?? false
const isRunning = c.status === 'RUNNING'
const isStopped = c.status === 'STOPPED' || c.status === 'ERROR'
const isTransitioning = ['CREATING', 'RESTARTING', 'REMOVING'].includes(c.status)
return (
<tr key={c.id} className="hover:bg-zinc-800/40">
<td className="px-5 py-4">
<p className="font-medium text-zinc-100">{c.company.name}</p>
<p className="text-xs text-zinc-500">{c.company.slug}</p>
{c.errorMessage && (
<p className="mt-1 text-xs text-red-400" title={c.errorMessage}>
{c.errorMessage.slice(0, 60)}{c.errorMessage.length > 60 ? '…' : ''}
</p>
)}
</td>
<td className="px-5 py-4 font-mono text-xs text-zinc-400">
{c.containerName}
{c.dockerId && (
<p className="mt-0.5 text-zinc-600">{c.dockerId.slice(0, 12)}</p>
)}
</td>
<td className="px-5 py-4">
<StatusBadge status={c.status} />
</td>
<td className="px-5 py-4 font-mono text-xs text-zinc-400">:{c.port}</td>
<td className="px-5 py-4 font-mono text-xs text-zinc-500">{c.image}</td>
<td className="px-5 py-4">
<div className="flex items-center gap-1.5">
{isStopped && (
<ActionButton
label="Start"
color="emerald"
disabled={isBusy || isTransitioning}
onClick={() => act(c.companyId, 'start')}
/>
)}
{isRunning && (
<ActionButton
label="Stop"
color="yellow"
disabled={isBusy || isTransitioning}
onClick={() => act(c.companyId, 'stop')}
/>
)}
{(isRunning || isStopped) && (
<ActionButton
label="Restart"
color="blue"
disabled={isBusy || isTransitioning}
onClick={() => act(c.companyId, 'restart')}
/>
)}
<ActionButton
label="Redeploy"
color="purple"
disabled={isBusy || isTransitioning}
onClick={() => act(c.companyId, 'deploy')}
/>
<ActionButton
label="Logs"
color="zinc"
disabled={isBusy || !c.dockerId}
onClick={() => setLogsFor({ companyId: c.companyId, companyName: c.company.name })}
/>
<ActionButton
label="Remove"
color="red"
disabled={isBusy || isTransitioning}
onClick={() => {
if (confirm(`Remove container for ${c.company.name}? This cannot be undone.`)) {
act(c.companyId, 'remove')
}
}}
/>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
<div className="mx-auto max-w-3xl px-6 py-16">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-400">Out of scope</p>
<h1 className="mt-3 text-2xl font-semibold text-zinc-100">Per-tenant containers disabled</h1>
<p className="mt-4 text-sm leading-6 text-zinc-400">
Company Docker container orchestration is not part of the production platform. The previous
admin UI and Docker-socket control plane have been removed from GA because they created a
high-privilege host trust boundary and were incomplete (no durable model or API surface).
</p>
<p className="mt-3 text-sm leading-6 text-zinc-500">
If isolated runtimes become a commercial requirement, they must be rebuilt as a separate
least-privilege deployment controller never inside the business API.
</p>
</div>
)
}
function ActionButton({
label,
color,
disabled,
onClick,
}: {
label: string
color: 'emerald' | 'yellow' | 'blue' | 'purple' | 'zinc' | 'red'
disabled: boolean
onClick: () => void
}) {
const colorMap: Record<string, string> = {
emerald: 'border-emerald-800 text-emerald-400 hover:bg-emerald-900/40',
yellow: 'border-orange-800 text-orange-400 hover:bg-orange-900/40',
blue: 'border-blue-800 text-blue-400 hover:bg-blue-900/40',
purple: 'border-purple-800 text-purple-400 hover:bg-purple-900/40',
zinc: 'border-zinc-700 text-zinc-400 hover:bg-zinc-700/40',
red: 'border-red-900 text-red-400 hover:bg-red-900/30',
}
return (
<button
onClick={onClick}
disabled={disabled}
className={`rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${colorMap[color]}`}
>
{label}
</button>
)
}
+272 -9
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState, type FormEvent } from 'react'
import {
AdminLanguageSwitcher,
AdminThemeSwitcher,
@@ -13,12 +13,13 @@ import { ADMIN_API_BASE } from '@/lib/api'
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
function buildUnifiedLoginUrl(nextPath: string) {
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const websiteUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000')
const storedTheme = window.localStorage.getItem('rentaldrivego-theme') ?? window.localStorage.getItem('admin-theme')
const theme = storedTheme === 'light' ? 'light' : 'dark'
const params = new URLSearchParams({
portal: 'admin',
next: nextPath || '/dashboard',
next: `/admin${nextPath || '/dashboard'}`,
})
return `${dashboardUrl}/sign-in?${params.toString()}`
return `${websiteUrl}/en/${theme}/admin-sign-in?${params.toString()}`
}
const navLinks = [
@@ -39,12 +40,23 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
const pathname = usePathname()
const [ready, setReady] = useState(false)
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
const [unreadNotifications, setUnreadNotifications] = useState(0)
const [securitySetupOpen, setSecuritySetupOpen] = useState(false)
const redirectingToLogin = useRef(false)
function redirectToLogin() {
if (redirectingToLogin.current) return
redirectingToLogin.current = true
window.location.replace(buildUnifiedLoginUrl(pathname))
}
useEffect(() => {
let cancelled = false
const controller = new AbortController()
fetch(`${ADMIN_API_BASE}/admin/auth/me`, {
credentials: 'include',
signal: controller.signal,
})
.then(async (response) => {
if (cancelled) return
@@ -52,21 +64,29 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
const json = await response.json().catch(() => null)
const resolvedAdmin = (json?.data ?? json) as AdminSessionUser | null
if (!resolvedAdmin?.id || !resolvedAdmin?.email || !resolvedAdmin?.role) {
window.location.replace(buildUnifiedLoginUrl(pathname))
redirectToLogin()
return
}
setAdmin(resolvedAdmin)
setReady(true)
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
.then((inboxResponse) => inboxResponse.ok ? inboxResponse.json() : null)
.then((inbox) => {
if (!cancelled) setUnreadNotifications(Number(inbox?.data?.unread ?? 0))
})
.catch(() => {})
} else {
window.location.replace(buildUnifiedLoginUrl(pathname))
redirectToLogin()
}
})
.catch(() => {
if (!cancelled) window.location.replace(buildUnifiedLoginUrl(pathname))
.catch((err) => {
if (err?.name === 'AbortError') return
if (!cancelled) redirectToLogin()
})
return () => {
cancelled = true
controller.abort()
}
}, [pathname])
@@ -106,11 +126,25 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
</svg>
{dict.nav[link.key]}
{link.key === 'notifications' && unreadNotifications > 0 ? (
<span className="ms-auto rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] font-bold text-white">{unreadNotifications > 99 ? '99+' : unreadNotifications}</span>
) : null}
</Link>
)
})}
</nav>
<div className="px-3 py-4">
{admin && !admin.totpEnabled ? (
<button
onClick={() => setSecuritySetupOpen(true)}
className="mb-2 flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-orange-700 transition-colors hover:bg-orange-50 hover:text-orange-800 dark:text-orange-300 dark:hover:bg-[#162038] dark:hover:text-orange-200"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75l2 2 4-4M12 3l7 4v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V7l7-4z" />
</svg>
Enable 2FA
</button>
) : null}
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-stone-500 transition-colors hover:bg-stone-100 hover:text-red-500 dark:text-stone-400 dark:hover:bg-[#162038] dark:hover:text-red-300"
@@ -127,7 +161,236 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
</div>
</aside>
<main className="flex-1 overflow-y-auto transition-colors">{children}</main>
{admin && securitySetupOpen ? (
<Admin2FASetupDialog
admin={admin}
onEnrolled={(updatedAdmin) => {
setAdmin(updatedAdmin)
setSecuritySetupOpen(false)
}}
onClose={() => setSecuritySetupOpen(false)}
/>
) : null}
</div>
</AdminSessionProvider>
)
}
function Admin2FASetupDialog({
admin,
onEnrolled,
onClose,
}: {
admin: AdminSessionUser
onEnrolled: (admin: AdminSessionUser) => void
onClose: () => void
}) {
type SetupMethod = 'email' | 'authenticator'
const [method, setMethod] = useState<SetupMethod | null>(null)
const [secret, setSecret] = useState('')
const [qrCode, setQrCode] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState<string | null>(null)
const [loadingSetup, setLoadingSetup] = useState(false)
const [verifying, setVerifying] = useState(false)
const [verifiedAdmin, setVerifiedAdmin] = useState<AdminSessionUser | null>(null)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
async function startSetup(nextMethod: SetupMethod) {
setMethod(nextMethod)
setCode('')
setError(null)
setLoadingSetup(true)
const endpoint = nextMethod === 'email'
? `${ADMIN_API_BASE}/admin/auth/2fa/email/setup`
: `${ADMIN_API_BASE}/admin/auth/2fa/setup`
try {
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const json = await response.json().catch(() => null)
if (!response.ok) throw new Error(json?.message ?? 'Failed to start 2FA setup.')
const data = json?.data ?? json
setSecret(data?.secret ?? '')
setQrCode(data?.qrCode ?? '')
} catch (err: any) {
setError(err?.message ?? 'Failed to start 2FA setup.')
} finally {
setLoadingSetup(false)
}
}
async function verifyCode(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const normalizedCode = code.trim()
if (!/^\d{6}$/.test(normalizedCode)) {
setError('Enter the 6-digit code from your authenticator app.')
return
}
setError(null)
setVerifying(true)
const endpoint = method === 'email'
? `${ADMIN_API_BASE}/admin/auth/2fa/email/verify`
: `${ADMIN_API_BASE}/admin/auth/2fa/verify`
try {
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: normalizedCode }),
})
const json = await response.json().catch(() => null)
if (!response.ok) throw new Error(json?.message ?? 'Invalid 2FA code.')
const data = json?.data ?? json
setVerifiedAdmin((data?.admin ?? { ...admin, totpEnabled: true }) as AdminSessionUser)
setRecoveryCodes(Array.isArray(data?.recoveryCodes) ? data.recoveryCodes : [])
} catch (err: any) {
setError(err?.message ?? 'Invalid 2FA code.')
} finally {
setVerifying(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-6 text-stone-900 backdrop-blur-sm dark:text-slate-100">
<section className="w-full max-w-2xl rounded-3xl border border-stone-200/80 bg-white/90 p-8 shadow-xl backdrop-blur dark:border-blue-900 dark:bg-[#07101e]/90">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">Security</p>
<h1 className="mt-2 text-2xl font-black text-blue-950 dark:text-stone-50">Enable 2FA</h1>
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">{admin.email}</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
>
Close
</button>
</div>
{verifiedAdmin ? (
<div className="mt-8 space-y-5">
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200">
2FA is enabled. Save your recovery codes before continuing.
</div>
{recoveryCodes.length > 0 ? (
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-900 dark:bg-[#0d1b38]">
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Recovery codes</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{recoveryCodes.map((recoveryCode) => (
<code key={recoveryCode} className="rounded-lg bg-white px-3 py-2 text-sm text-stone-800 dark:bg-[#07101e] dark:text-slate-200">
{recoveryCode}
</code>
))}
</div>
</div>
) : null}
<button
type="button"
onClick={() => onEnrolled(verifiedAdmin)}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
Continue to admin dashboard
</button>
</div>
) : (
<form onSubmit={verifyCode} className="mt-8 space-y-6">
{!method ? (
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => startSetup('email')}
className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-900 dark:bg-[#0d1b38] dark:hover:border-orange-400/70 dark:hover:bg-[#162038]"
>
<span className="block text-sm font-semibold text-blue-950 dark:text-stone-100">Email code</span>
<span className="mt-2 block text-sm text-stone-600 dark:text-slate-300">{admin.email}</span>
</button>
<button
type="button"
onClick={() => startSetup('authenticator')}
className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-900 dark:bg-[#0d1b38] dark:hover:border-orange-400/70 dark:hover:bg-[#162038]"
>
<span className="block text-sm font-semibold text-blue-950 dark:text-stone-100">Authenticator app</span>
<span className="mt-2 block text-sm text-stone-600 dark:text-slate-300">TOTP</span>
</button>
</div>
) : loadingSetup ? (
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
Preparing setup...
</div>
) : method === 'email' ? (
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
Enter the 6-digit code sent to {admin.email}.
</div>
) : (
<div className="grid gap-5 md:grid-cols-[180px,1fr]">
<div className="flex h-44 items-center justify-center rounded-2xl border border-stone-200 bg-white p-3 dark:border-blue-900 dark:bg-white">
{qrCode ? <img src={qrCode} alt="Admin 2FA QR code" className="h-full w-full object-contain" /> : <span className="text-sm text-stone-500">No QR code</span>}
</div>
<div>
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Authenticator app</p>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">
Scan the QR code with your authenticator app, or enter the setup key manually.
</p>
{secret ? (
<code className="mt-3 block break-all rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-800 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200">
{secret}
</code>
) : null}
</div>
</div>
)}
<label className="block">
<span className="mb-2 block text-sm font-semibold text-blue-950 dark:text-stone-100">6-digit code</span>
<input
value={code}
onChange={(event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric"
autoComplete="one-time-code"
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-lg font-semibold tracking-[0.2em] text-stone-900 outline-none transition focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100"
placeholder="000000"
disabled={!method || loadingSetup || verifying}
/>
</label>
{error ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
) : null}
<button
type="submit"
disabled={!method || loadingSetup || verifying || code.length !== 6}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{verifying ? 'Verifying...' : 'Enable 2FA'}
</button>
{method ? (
<button
type="button"
onClick={() => {
setMethod(null)
setCode('')
setError(null)
setSecret('')
setQrCode('')
}}
className="w-full rounded-full border border-stone-200 px-6 py-3 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
>
Choose another method
</button>
) : null}
</form>
)}
</section>
</div>
)
}
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { ADMIN_API_BASE } from '@/lib/api'
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
type CompanyOption = {
@@ -82,7 +82,7 @@ type FormState = {
}
const ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT']
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']
const INPUT =
@@ -117,7 +117,7 @@ function emptyForm(): FormState {
isRequired: false,
isActive: true,
roles: ['OWNER', 'MANAGER', 'AGENT'],
plans: ['STARTER', 'GROWTH', 'PRO'],
plans: ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'],
companyIds: [],
}
}
@@ -1,32 +1,22 @@
'use client'
import { useEffect, useState } from 'react'
import {
fetchAdminNotifications,
formatNotificationDate,
type NotificationsPageResult,
} from '@/lib/adminNotifications'
import { ADMIN_API_BASE } from '@/lib/api'
interface NotificationItem {
interface AdminInboxItem {
id: string
type: string
title: string
body: string
channel: string
status: string
locale: string
sentAt: string | null
readAt: string | null
createdAt: string
company: { name: string } | null
companyId: string | null
renterId: string | null
}
interface Paginated {
data: NotificationItem[]
total: number
page: number
pageSize: number
notificationEvent: { title: string; body: string; type: string; locale: string; data?: Record<string, unknown> }
}
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const STATUSES = ['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ']
const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ']
const CHANNEL_BADGE: Record<string, string> = {
EMAIL: 'text-sky-400 bg-sky-950/40',
@@ -41,39 +31,66 @@ const STATUS_BADGE: Record<string, string> = {
SENT: 'text-emerald-400 bg-emerald-950/40',
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
FAILED: 'text-red-400 bg-red-950/40',
QUEUED: 'text-sky-400 bg-sky-950/40',
SKIPPED: 'text-zinc-400 bg-zinc-800',
DEAD_LETTER: 'text-red-300 bg-red-950/60',
READ: 'text-zinc-400 bg-zinc-800',
}
function formatRecipient(item: {
recipientType: string | null
recipientName: string | null
recipientEmail: string | null
employeeId: string | null
renterId: string | null
}) {
const fallbackId = item.employeeId ?? item.renterId
const label = item.recipientName || item.recipientEmail || (fallbackId ? `${fallbackId.slice(0, 10)}...` : '-')
return item.recipientType ? `${item.recipientType}: ${label}` : label
}
export default function AdminNotificationsPage() {
const [result, setResult] = useState<Paginated | null>(null)
const [result, setResult] = useState<NotificationsPageResult | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterChannel, setFilterChannel] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [filterCompany, setFilterCompany] = useState('')
const [page, setPage] = useState(1)
const [inbox, setInbox] = useState<{ data: AdminInboxItem[]; unread: number }>({ data: [], unread: 0 })
function load(p: number) {
function loadInbox() {
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
.then((response) => response.ok ? response.json() : Promise.reject(new Error('Failed to load personal inbox')))
.then((json) => setInbox(json.data))
.catch(() => {})
}
function load(p: number, signal?: AbortSignal) {
setLoading(true)
setError(null)
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
if (filterChannel) params.set('channel', filterChannel)
if (filterStatus) params.set('status', filterStatus)
if (filterCompany) params.set('companyId', filterCompany)
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
credentials: 'include',
cache: 'no-store',
})
.then((r) => r.json())
.then((json) => setResult(json.data ?? null))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
fetchAdminNotifications(
p,
{ channel: filterChannel, status: filterStatus, companyId: filterCompany },
signal,
)
.then((data) => setResult(data))
.catch((err) => {
if (err instanceof DOMException && err.name === 'AbortError') return
setResult(null)
setError(err instanceof Error ? err.message : 'Failed to load notifications')
})
.finally(() => {
if (!signal?.aborted) setLoading(false)
})
}
useEffect(() => {
const controller = new AbortController()
setPage(1)
load(1)
load(1, controller.signal)
loadInbox()
return () => controller.abort()
}, [filterChannel, filterStatus, filterCompany])
function goToPage(p: number) {
@@ -81,7 +98,12 @@ export default function AdminNotificationsPage() {
load(p)
}
const totalPages = result ? Math.ceil(result.total / result.pageSize) : 0
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0
async function markRead(recipientId: string) {
const response = await fetch(`${ADMIN_API_BASE}/admin/notifications/me/${recipientId}/read`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' })
if (response.ok) loadInbox()
}
return (
<div className="shell py-8 space-y-6">
@@ -100,6 +122,27 @@ export default function AdminNotificationsPage() {
)}
</div>
<section className="panel p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">My inbox</p>
<h2 className="mt-1 text-lg font-semibold text-zinc-100">Assigned operational notices</h2>
</div>
<span className="rounded-full bg-red-500/15 px-3 py-1 text-xs font-semibold text-red-300">{inbox.unread} unread</span>
</div>
{inbox.data.length === 0 ? <p className="mt-4 text-sm text-zinc-500">No assigned notices.</p> : (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{inbox.data.map((item) => (
<button key={item.id} type="button" onClick={() => markRead(item.id)} className={`rounded-xl border p-4 text-left ${item.readAt ? 'border-zinc-800 bg-zinc-950/50' : 'border-orange-500/40 bg-orange-500/5'}`}>
<p className="text-xs uppercase tracking-wide text-zinc-500">{item.notificationEvent.type.replaceAll('_', ' ')} · {item.notificationEvent.locale}</p>
<p className="mt-2 font-semibold text-zinc-100">{item.notificationEvent.title}</p>
<p className="mt-1 text-sm text-zinc-400">{item.notificationEvent.body}</p>
</button>
))}
</div>
)}
</section>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
@@ -118,6 +161,12 @@ export default function AdminNotificationsPage() {
<option value="">All statuses</option>
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<input
value={filterCompany}
onChange={(e) => setFilterCompany(e.target.value)}
placeholder="Company ID"
className="w-64 rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
@@ -129,6 +178,7 @@ export default function AdminNotificationsPage() {
<tr className="border-b border-zinc-800">
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Date</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Company</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Recipient</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Event</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Channel</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</th>
@@ -138,17 +188,23 @@ export default function AdminNotificationsPage() {
</thead>
<tbody className="divide-y divide-zinc-800/60">
{loading ? (
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">Loading</td></tr>
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">Loading</td></tr>
) : !result || result.data.length === 0 ? (
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
) : result.data.map((item) => (
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors">
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
{new Date(item.createdAt).toLocaleString()}
{formatNotificationDate(item.createdAt)}
</td>
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
</td>
<td className="max-w-[220px] px-5 py-3 text-xs text-zinc-300">
<p className="truncate">{formatRecipient(item)}</p>
{item.recipientEmail && item.recipientName ? (
<p className="truncate text-zinc-500">{item.recipientEmail}</p>
) : null}
</td>
<td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
{item.type.replaceAll('_', ' ')}
</td>
@@ -167,7 +223,7 @@ export default function AdminNotificationsPage() {
</span>
</td>
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
{formatNotificationDate(item.sentAt)}
</td>
</tr>
))}
@@ -64,19 +64,21 @@ type PlanFeatureForm = {
// ─── Constants ─────────────────────────────────────────────────────────────
const PLANS = ['STARTER', 'GROWTH', 'PRO']
const PLANS = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
const PERIODS = ['MONTHLY', 'ANNUAL']
const PLAN_COLORS: Record<string, string> = {
STARTER: 'text-zinc-400',
GROWTH: 'text-sky-400',
PRO: 'text-violet-400',
ENTERPRISE: 'text-emerald-400',
}
const PLAN_BADGE: Record<string, string> = {
STARTER: 'bg-zinc-800 text-zinc-300',
GROWTH: 'bg-sky-900/40 text-sky-300',
PRO: 'bg-violet-900/40 text-violet-300',
ENTERPRISE: 'bg-emerald-900/40 text-emerald-300',
}
// ─── Helpers ───────────────────────────────────────────────────────────────
@@ -62,6 +62,7 @@ export default function AdminForgotPasswordPage() {
<input
type="email"
required
maxLength={254}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@rentaldrivego.com"
+30
View File
@@ -154,6 +154,10 @@ html.dark .panel {
color: rgb(28 25 23);
}
.light .text-zinc-50 {
color: rgb(28 25 23);
}
.light .text-zinc-500 {
color: rgb(120 113 108);
}
@@ -171,4 +175,30 @@ html.dark .panel {
.light .hover\:text-zinc-200:hover {
color: rgb(28 25 23);
}
.light .text-amber-100,
.light .text-amber-100\/80,
.light .text-amber-200,
.light .text-amber-300 {
color: rgb(146 64 14);
}
.light .text-emerald-300 {
color: rgb(4 120 87);
}
.light .text-rose-200,
.light .text-rose-300,
.light .text-red-400 {
color: rgb(190 18 60);
}
.light .text-sky-300 {
color: rgb(3 105 161);
}
.light .text-red-200,
.light .text-red-300 {
color: rgb(185 28 28);
}
}
+6 -3
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import Script from 'next/script'
import { AdminI18nProvider } from '@/components/I18nProvider'
import './globals.css'
@@ -9,12 +10,14 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="light" suppressHydrationWarning>
<html lang="ar" dir="rtl" className="dark" suppressHydrationWarning>
<head>
<script
<Script
id="admin-theme-bootstrap"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html:
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('admin-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('admin-theme'));if(theme!=='light'&&theme!=='dark'){theme='dark'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
}}
/>
</head>
+7 -4
View File
@@ -1,13 +1,16 @@
import { headers } from 'next/headers'
import { cookies, headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls'
export default async function AdminLoginPage() {
const requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
const cookieStore = await cookies()
const rawTheme = cookieStore.get('rentaldrivego-theme')?.value
const theme = rawTheme === 'light' ? 'light' : 'dark'
const websiteUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
redirect(`${dashboardUrl}/sign-in?portal=admin&next=/dashboard`)
redirect(`${websiteUrl}/en/${theme}/admin-sign-in?next=/admin/dashboard`)
}
@@ -102,6 +102,7 @@ function AdminResetPasswordContent() {
type={showPassword ? 'text' : 'password'}
required
minLength={8}
maxLength={128}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
@@ -133,6 +134,7 @@ function AdminResetPasswordContent() {
type={showConfirm ? 'text' : 'password'}
required
minLength={8}
maxLength={128}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••"
+9 -9
View File
@@ -109,11 +109,12 @@ type AdminI18nContext = {
const Context = createContext<AdminI18nContext | null>(null)
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<AdminLanguage>('en')
const [theme, setTheme] = useState<AdminTheme>('light')
// Skip the very first write so we don't overwrite a stored preference with
// the default 'en' value before the hydration read-effect has applied it.
const [language, setLanguage] = useState<AdminLanguage>('ar')
const [theme, setTheme] = useState<AdminTheme>('dark')
// Skip the very first write so we don't overwrite a stored preference before
// the hydration read-effect has applied it.
const skipFirstLangWrite = useRef(true)
const skipFirstThemeWrite = useRef(true)
useEffect(() => {
const stored = window.localStorage.getItem('admin-language')
@@ -124,11 +125,6 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
if (storedTheme === 'light' || storedTheme === 'dark') {
setTheme(storedTheme)
return
}
if (!window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('light')
}
}, [])
@@ -147,6 +143,10 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
document.documentElement.classList.add(theme)
document.documentElement.style.colorScheme = theme
document.body.dataset.theme = theme
if (skipFirstThemeWrite.current) {
skipFirstThemeWrite.current = false
return
}
document.cookie = `${SHARED_THEME_KEY}=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax`
window.localStorage.setItem(SHARED_THEME_KEY, theme)
window.localStorage.setItem('admin-theme', theme)
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
afterEach(() => {
delete process.env.API_INTERNAL_URL
delete process.env.NEXT_PUBLIC_API_URL
vi.restoreAllMocks()
Reflect.deleteProperty(globalThis, 'fetch')
vi.resetModules()
})
describe('buildNotificationsQuery', () => {
it('builds page, page size, and non-empty filters', async () => {
const { buildNotificationsQuery } = await import('./adminNotifications')
expect(buildNotificationsQuery(3, {
channel: 'EMAIL',
status: 'FAILED',
companyId: ' company_1 ',
}).toString()).toBe('page=3&pageSize=50&channel=EMAIL&status=FAILED&companyId=company_1')
})
it('omits empty filters', async () => {
const { buildNotificationsQuery } = await import('./adminNotifications')
expect(buildNotificationsQuery(1, {
channel: '',
status: '',
companyId: ' ',
}).toString()).toBe('page=1&pageSize=50')
})
})
describe('fetchAdminNotifications', () => {
it('returns the paginated data envelope', async () => {
process.env.API_INTERNAL_URL = 'http://internal-api/api/v1'
const payload = { data: [], total: 0, page: 1, pageSize: 50, totalPages: 0 }
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: payload }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { fetchAdminNotifications } = await import('./adminNotifications')
await expect(fetchAdminNotifications(1, { status: 'READ' })).resolves.toEqual(payload)
expect(fetchMock).toHaveBeenCalledWith(
'http://internal-api/api/v1/admin/notifications?page=1&pageSize=50&status=READ',
expect.objectContaining({ credentials: 'include', cache: 'no-store' }),
)
})
it('throws API error messages', async () => {
const fetchMock = vi.fn(async () => ({
ok: false,
json: async () => ({ message: 'Support role required' }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { fetchAdminNotifications } = await import('./adminNotifications')
await expect(fetchAdminNotifications(1)).rejects.toThrow('Support role required')
})
it('throws when the API envelope is malformed', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: { notifications: [] } }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { fetchAdminNotifications } = await import('./adminNotifications')
await expect(fetchAdminNotifications(1)).rejects.toThrow('Notifications response was not in the expected format')
})
})
describe('formatNotificationDate', () => {
it('formats missing or invalid dates as a placeholder', async () => {
const { formatNotificationDate } = await import('./adminNotifications')
expect(formatNotificationDate(null)).toBe('-')
expect(formatNotificationDate('not-a-date')).toBe('-')
})
})
+89
View File
@@ -0,0 +1,89 @@
import { ADMIN_API_BASE } from './api'
export interface NotificationItem {
id: string
type: string
title: string
body: string
channel: string
status: string
locale: string
sentAt: string | null
createdAt: string
company: { name: string } | null
companyId: string | null
recipientType: 'EMPLOYEE' | 'RENTER' | null
recipientName: string | null
recipientEmail: string | null
employeeId: string | null
renterId: string | null
}
export interface NotificationsPageResult {
data: NotificationItem[]
total: number
page: number
pageSize: number
totalPages?: number
}
export interface NotificationFilters {
channel?: string
status?: string
companyId?: string
}
function isNotificationsPageResult(value: unknown): value is NotificationsPageResult {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<NotificationsPageResult>
return (
Array.isArray(candidate.data) &&
typeof candidate.total === 'number' &&
typeof candidate.page === 'number' &&
typeof candidate.pageSize === 'number'
)
}
export function buildNotificationsQuery(page: number, filters: NotificationFilters = {}) {
const params = new URLSearchParams({
page: String(page),
pageSize: '50',
})
if (filters.channel) params.set('channel', filters.channel)
if (filters.status) params.set('status', filters.status)
if (filters.companyId?.trim()) params.set('companyId', filters.companyId.trim())
return params
}
export async function fetchAdminNotifications(
page: number,
filters: NotificationFilters = {},
signal?: AbortSignal,
): Promise<NotificationsPageResult> {
const params = buildNotificationsQuery(page, filters)
const response = await fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
credentials: 'include',
cache: 'no-store',
signal,
})
const json = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(json?.message ?? 'Failed to load notifications')
}
if (!isNotificationsPageResult(json?.data)) {
throw new Error('Notifications response was not in the expected format')
}
return json.data
}
export function formatNotificationDate(value: string | null) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString()
}
+6
View File
@@ -31,6 +31,12 @@ describe('admin app URL resolution', () => {
expect(resolveServerAppUrl('http://localhost:3002/admin', 'admin.example.com', 'https')).toBe('https://admin.example.com:3002/admin')
})
it('does not expose internal development hosts in server redirects', () => {
expect(resolveServerAppUrl('http://localhost:3000/dashboard', 'host.docker.internal:3002', 'http')).toBe('http://localhost:3000/dashboard')
expect(resolveServerAppUrl('http://localhost:3000/dashboard', 'admin:3002', 'http')).toBe('http://localhost:3000/dashboard')
expect(resolveServerAppUrl('http://localhost:3000/dashboard', 'localhost:3002', 'http')).toBe('http://localhost:3000/dashboard')
})
it('falls back when host is missing or the fallback is not parseable', () => {
expect(resolveServerAppUrl('http://localhost:3002/admin', null)).toBe('http://localhost:3002/admin')
expect(resolveServerAppUrl('/admin', 'admin.example.com')).toBe('/admin')
+6 -1
View File
@@ -14,8 +14,13 @@ export function resolveBrowserAppUrl(fallback: string): string {
}
}
function isInternalHost(host: string): boolean {
const hostname = host.split(':')[0]?.toLowerCase()
return ['localhost', '127.0.0.1', 'host.docker.internal', 'dashboard', 'admin', 'api', 'homepage', 'carplace'].includes(hostname)
}
export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string {
if (!host) return fallback
if (!host || isInternalHost(host)) return fallback
try {
const target = new URL(fallback)
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
export function proxy(request: NextRequest) {
if (request.headers.has('x-middleware-subrequest')) {
return new NextResponse('Unsupported internal request header', { status: 400 })
}
+24
View File
@@ -0,0 +1,24 @@
# Manual subscription payments are intentionally off until every dependency is ready.
MANUAL_SUBSCRIPTION_PAYMENTS_ENABLED=false
BANK_TRANSFER_ENABLED=false
BANK_TRANSFER_ACCOUNT_NAME=
BANK_TRANSFER_BANK_NAME=
BANK_TRANSFER_ACCOUNT_REFERENCE=
BANK_TRANSFER_DUE_DAYS=7
CHECK_PAYMENT_ENABLED=false
CHECK_PAYMENT_PAYEE=
CHECK_PAYMENT_DELIVERY_ADDRESS=
CHECK_PAYMENT_DUE_DAYS=14
# Evidence is private, quarantined, content-validated and fail-closed scanned.
MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED=false
FILE_STORAGE_ROOT=/var/lib/rentaldrivego/uploads
PRIVATE_STORAGE_PERSISTENCE_CONFIRMED=false
PRIVATE_STORAGE_ENCRYPTION_AT_REST_CONFIRMED=false
PAYMENT_EVIDENCE_SCANNER_PATH=/usr/bin/clamscan
PAYMENT_EVIDENCE_SCAN_TIMEOUT_MS=60000
# Collections notifications can be proven before automatic suspension is enabled.
SUBSCRIPTION_COLLECTIONS_NOTIFICATIONS_ENABLED=false
SUBSCRIPTION_AUTOMATIC_SUSPENSION_ENABLED=false
DEFAULT_BILLING_TIMEZONE=Africa/Casablanca
+4
View File
@@ -10,6 +10,9 @@
"prestart": "npm run build --workspace @rentaldrivego/types",
"pretype-check": "npm run build --workspace @rentaldrivego/types",
"start": "node dist/index.js",
"worker": "node dist/workers/index.js",
"preworker:dev": "npm run build --workspace @rentaldrivego/types",
"worker:dev": "node ../../scripts/run-with-env-file.cjs ../../.env.local ../../node_modules/.bin/ts-node-dev --respawn --transpile-only --ignore-watch ../../packages/types/dist src/workers/index.ts",
"type-check": "tsc --noEmit",
"pretest": "npm run build --workspace @rentaldrivego/types",
"test": "vitest run",
@@ -34,6 +37,7 @@
"firebase-admin": "^10.3.0",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"@aws-sdk/client-s3": "^3.758.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^2.1.1",
+157 -21
View File
@@ -1,18 +1,22 @@
import express from 'express'
import express, { type Request, type Response } from 'express'
import cors, { type CorsOptions } from 'cors'
import helmet from 'helmet'
import morgan from 'morgan'
import swaggerUi from 'swagger-ui-express'
import { openApiDocument } from './swagger/openapi'
import { getPublicStorageRoot } from './lib/storage'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter, webhookLimiter } from './middleware/rateLimiter'
import { requireTrustedOriginForCookieMutations } from './middleware/csrf'
import { sanitizeForwardedHeaders } from './middleware/forwardedHeaders'
import { requestIdMiddleware } from './middleware/requestId'
import { metricsMiddleware, renderPrometheusText, setGauge } from './lib/opsMetrics'
// ─── Module routes ────────────────────────────────────────────
import webhookRouter from './modules/webhooks/webhook.routes'
import companyAuthRouter from './modules/auth/auth.company.routes'
import employeeAuthRouter from './modules/auth/auth.employee.routes'
import accountAuthRouter from './modules/auth/auth.account.routes'
import unifiedAuthRouter from './modules/auth/auth.unified.routes'
import renterAuthRouter from './modules/auth/auth.renter.routes'
import teamRouter from './modules/team/team.routes'
import offersRouter from './modules/offers/offer.routes'
@@ -21,7 +25,6 @@ import notificationsRouter from './modules/notifications/notification.routes'
import adminRouter from './modules/admin/admin.routes'
import subscriptionsRouter, {
subscriptionPublicRouter,
subscriptionWebhookRouter,
} from './modules/subscriptions/subscription.routes'
import paymentsRouter from './modules/payments/payment.routes'
import billingRouter from './modules/billing/billing.routes'
@@ -34,6 +37,7 @@ import siteRouter from './modules/site/site.routes'
import reviewsRouter from './modules/reviews/review.routes'
import complaintsRouter from './modules/complaints/complaint.routes'
import licenseValidationRouter from './modules/licenses/license.validation.routes'
import searchRouter from './modules/search/search.routes'
// ─── Centralized error handling ───────────────────────────────
import { errorMiddleware } from './http/errors/errorMiddleware'
@@ -51,20 +55,65 @@ const defaultCorsOrigins = [
'http://127.0.0.1:4000',
]
export const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
: defaultCorsOrigins
const frontendOriginEnvKeys = [
'SITE_ORIGIN',
'WEBSITE_URL',
'HOMEPAGE_URL',
'DASHBOARD_URL',
'ADMIN_URL',
'CARPLACE_URL',
'NEXT_PUBLIC_HOMEPAGE_URL',
'NEXT_PUBLIC_WEBSITE_URL',
'NEXT_PUBLIC_DASHBOARD_URL',
'NEXT_PUBLIC_ADMIN_URL',
'NEXT_PUBLIC_CARPLACE_URL',
] as const
function normalizeConfiguredOrigin(value: string): string | null {
try {
return new URL(value).origin
} catch {
return null
}
}
export function getConfiguredCorsOrigins(env: NodeJS.ProcessEnv = process.env) {
const configuredOrigins = (env.CORS_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
const frontendOrigins = frontendOriginEnvKeys
.flatMap((key) => (env[key] ?? '').split(','))
.map((origin) => origin.trim())
.filter(Boolean)
const rawOrigins = configuredOrigins.length > 0 || frontendOrigins.length > 0
? [...configuredOrigins, ...frontendOrigins]
: defaultCorsOrigins
return Array.from(new Set(rawOrigins.map(normalizeConfiguredOrigin).filter((origin): origin is string => Boolean(origin))))
}
export const corsOrigins = getConfiguredCorsOrigins()
function isAllowedLocalDevOrigin(origin: string) {
if (process.env.NODE_ENV === 'production') return false
try {
const url = new URL(origin)
return (
url.protocol === 'http:' &&
['localhost', '127.0.0.1'].includes(url.hostname) &&
['3000', '3001', '3002', '4000'].includes(url.port)
)
if (url.protocol !== 'http:') return false
if (!['3000', '3001', '3002', '3004', '4000'].includes(url.port)) return false
if (['localhost', '127.0.0.1'].includes(url.hostname)) return true
const octets = url.hostname.split('.').map((part) => Number(part))
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false
}
const first = octets[0]!
const second = octets[1]!
return first === 10 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168)
} catch {
return false
}
@@ -83,7 +132,9 @@ export const corsOptions: CorsOptions = {
}
const routeDocs = [
{ method: 'GET', path: '/health', description: 'Health check' },
{ method: 'GET', path: '/health', description: 'Liveness health check' },
{ method: 'GET', path: '/ready', description: 'Readiness probe (database, redis, storage)' },
{ method: 'GET', path: '/metrics', description: 'Prometheus-style ops metrics' },
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
{ method: 'GET', path: `${v1}/auth/renter/me`, description: 'Current renter profile' },
{ method: 'GET', path: `${v1}/vehicles`, description: 'List company vehicles' },
@@ -94,6 +145,7 @@ const routeDocs = [
{ method: 'POST', path: `${v1}/customers`, description: 'Create customer' },
{ method: 'GET', path: `${v1}/offers`, description: 'List offers' },
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
{ method: 'GET', path: `${v1}/search`, description: 'Global account search' },
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
{ method: 'GET', path: `${v1}/carplace/home`, description: 'Carplace home' },
@@ -115,7 +167,9 @@ export function createApp() {
app.set('trust proxy', 1)
}
app.use(sanitizeForwardedHeaders)
app.use(requestIdMiddleware)
app.use(metricsMiddleware)
app.use((req, res, next) => {
if (req.headers['x-middleware-subrequest']) {
@@ -125,12 +179,16 @@ export function createApp() {
})
app.use(corsMiddleware)
app.use(requireTrustedOriginForCookieMutations)
// Customer identity documents must never be anonymously retrievable from the
// public storage mount, even if an older raw storage URL leaks.
app.use('/storage/companies/:companyId/customers/:customerId', (_req, res) => {
res.status(404).end()
})
app.use('/storage/companies/:companyId/reservations/:reservationId', (_req, res) => {
res.status(404).end()
})
// Public storage assets (logos, vehicle photos, etc.) must be loadable cross-origin
// by the browser. Without this header, helmet's default CORP: same-origin reaches
@@ -145,15 +203,27 @@ export function createApp() {
// through authenticated API routes such as /customers/:id/license-image.
app.use('/storage', express.static(getPublicStorageRoot()))
// Swagger UI — mounted before helmet so its assets are not blocked by CSP
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' }))
app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument))
const publicDocsEnabled = process.env.NODE_ENV !== 'production' || process.env.ENABLE_PUBLIC_API_DOCS === 'true'
const docsDisabled = (_req: Request, res: Response) => res.status(404).json({ error: 'not_found', message: 'API docs are not available in this environment', statusCode: 404 })
// Swagger UI — never expose API reconnaissance material publicly in production
// unless explicitly enabled for a protected/internal deployment.
if (publicDocsEnabled) {
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' }))
app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument))
} else {
app.use('/docs', docsDisabled)
app.get('/api/v1/openapi.json', docsDisabled)
}
// Webhooks must use raw body BEFORE express.json(); signature verification
// must never reconstruct the payload with JSON.stringify(req.body).
app.use(`${v1}/webhooks`, express.raw({ type: 'application/json' }), webhookRouter)
app.use(`${v1}/payments/webhooks`, express.raw({ type: 'application/json' }))
app.use(`${v1}/subscriptions/webhooks`, express.raw({ type: 'application/json' }))
const removedOnlinePaymentWebhooks = (_req: Request, res: Response) => {
res.status(404).json({ error: 'not_found', message: 'Online payment webhooks have been removed', statusCode: 404 })
}
app.use(`${v1}/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' }), webhookRouter)
app.use(`${v1}/payments/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' }), removedOnlinePaymentWebhooks)
app.use(`${v1}/subscriptions/webhooks`, webhookLimiter, express.raw({ type: 'application/json', limit: '1mb' }), removedOnlinePaymentWebhooks)
// Let /storage responses manage CORP explicitly so missing files still return
// a normal cross-origin 404 instead of being blocked by Helmet's default
@@ -178,22 +248,39 @@ export function createApp() {
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
frameguard: { action: 'deny' },
}))
if (process.env.NODE_ENV !== 'test') app.use(morgan('combined'))
if (process.env.NODE_ENV !== 'test') {
app.use(morgan((tokens, req, res) => {
const requestId = (req as any).requestId ?? '-'
return JSON.stringify({
level: 'info',
msg: 'http_request',
requestId,
method: tokens.method(req, res),
url: tokens.url(req, res),
status: Number(tokens.status(req, res)),
durationMs: Number(tokens['response-time'](req, res)),
contentLength: tokens.res(req, res, 'content-length'),
})
}))
}
app.use(express.json({ limit: '10mb' }))
// ─── API Routes ─────────────────────────────────────────────
app.use(`${v1}/auth`, authLimiter, unifiedAuthRouter)
app.use(`${v1}/auth/account`, authLimiter, accountAuthRouter)
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
app.use(`${v1}/admin/auth`, authLimiter)
app.use(`${v1}/admin/auth`, (req, res, next) => {
if (req.method === 'GET' && req.path === '/me') return next()
return authLimiter(req, res, next)
})
app.use(`${v1}/admin`, adminLimiter, adminRouter)
app.use(`${v1}/carplace`, publicLimiter, carplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter)
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
@@ -208,6 +295,7 @@ export function createApp() {
app.use(`${v1}/billing`, apiLimiter, billingRouter)
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
app.use(`${v1}/search`, apiLimiter, searchRouter)
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
// ─── Health / Docs ──────────────────────────────────────────
@@ -226,7 +314,55 @@ export function createApp() {
res.json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() })
})
app.get('/metrics', async (_req, res) => {
try {
const { prisma } = await import('./lib/prisma')
const [pending, published] = await Promise.all([
prisma.notificationOutbox.count({ where: { status: 'PENDING' } }),
prisma.notificationOutbox.count({ where: { status: 'PUBLISHED' } }),
])
setGauge('notification_outbox_pending', pending)
setGauge('notification_outbox_completed', published)
} catch {
/* leave previous gauges */
}
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8')
res.status(200).send(renderPrometheusText())
})
app.get('/ready', async (_req, res) => {
const { prisma } = await import('./lib/prisma')
const { redis } = await import('./lib/redis')
const { checkStorageReady } = await import('./lib/storage')
const checks: Record<string, 'ok' | 'error'> = { database: 'error', redis: 'error', storage: 'error' }
try {
await prisma.$queryRaw`SELECT 1`
checks.database = 'ok'
} catch {
checks.database = 'error'
}
try {
const pong = await redis.ping()
checks.redis = pong === 'PONG' ? 'ok' : 'error'
} catch {
checks.redis = 'error'
}
try {
await checkStorageReady()
checks.storage = 'ok'
} catch {
checks.storage = 'error'
}
const ready = Object.values(checks).every((v) => v === 'ok')
res.status(ready ? 200 : 503).json({
status: ready ? 'ready' : 'not_ready',
checks,
timestamp: new Date().toISOString(),
})
})
app.get(`${v1}/docs`, (_req, res) => {
if (!publicDocsEnabled) return docsDisabled(_req, res)
res.json({
name: 'rentaldrivego-api',
version: '1.0.0',
+48 -2
View File
@@ -2,7 +2,7 @@ import path from 'path'
import multer from 'multer'
import { ValidationError } from '../errors'
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
const ALLOWED_IMAGE_TYPES = new Map<string, string[]>([
['image/jpeg', ['.jpg', '.jpeg']],
['image/png', ['.png']],
@@ -16,7 +16,7 @@ const ALLOWED_IMAGE_TYPES = new Map<string, string[]>([
*/
export const imageUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_FILE_SIZE, files: 20 },
limits: { fileSize: MAX_FILE_SIZE, files: 5, fields: 20, parts: 30 },
})
type DetectedFile = { mime: string; ext: string }
@@ -46,6 +46,50 @@ export function detectImageType(buffer: Buffer): DetectedFile | null {
return null
}
function readImageDimensions(file: Express.Multer.File): { width: number; height: number } | null {
const buffer = file.buffer
if (buffer.length >= 24 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
}
if (buffer.length >= 10 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') {
const chunk = buffer.subarray(12, 16).toString('ascii')
if (chunk === 'VP8X' && buffer.length >= 30) {
const width = 1 + buffer.readUIntLE(24, 3)
const height = 1 + buffer.readUIntLE(27, 3)
return { width, height }
}
}
if (buffer.length >= 10 && buffer[0] === 0xff && buffer[1] === 0xd8) {
let offset = 2
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) return null
const marker = buffer.readUInt8(offset + 1)
const length = buffer.readUInt16BE(offset + 2)
if (length < 2) return null
if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) {
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }
}
offset += 2 + length
}
}
return null
}
function assertSafeImageDimensions(file: Express.Multer.File) {
const dimensions = readImageDimensions(file)
if (!dimensions) return
const maxPixels = 24_000_000
const maxSide = 8_000
if (dimensions.width <= 0 || dimensions.height <= 0 || dimensions.width > maxSide || dimensions.height > maxSide || dimensions.width * dimensions.height > maxPixels) {
throw new ValidationError(`Image dimensions are too large for "${file.originalname}"`)
}
}
function assertSafeImageContent(file: Express.Multer.File) {
const detected = detectImageType(file.buffer)
if (!detected || !ALLOWED_IMAGE_TYPES.has(detected.mime)) {
@@ -61,6 +105,8 @@ function assertSafeImageContent(file: Express.Multer.File) {
if (ext && !allowedExtensions.includes(ext)) {
throw new ValidationError(`File extension does not match file content for "${file.originalname}"`)
}
assertSafeImageDimensions(file)
}
/**
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { assertPaymentEvidenceFile, sanitizeEvidenceFilename } from './paymentEvidence'
function file(buffer: Buffer, originalname: string, mimetype: string): Express.Multer.File {
return { buffer, originalname, mimetype, size: buffer.length } as Express.Multer.File
}
function png(width = 16, height = 16) {
const head = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from([0, 0, 0, 13]),
Buffer.from('IHDR'),
])
const dimensions = Buffer.alloc(8)
dimensions.writeUInt32BE(width, 0)
dimensions.writeUInt32BE(height, 4)
const ihdrRest = Buffer.alloc(9)
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82])
return Buffer.concat([head, dimensions, ihdrRest, iend])
}
function jpeg(width = 16, height = 16) {
return Buffer.from([
0xff, 0xd8,
0xff, 0xc0, 0x00, 0x0b, 0x08,
(height >> 8) & 0xff, height & 0xff,
(width >> 8) & 0xff, width & 0xff,
0x01, 0x01, 0x11, 0x00,
0xff, 0xd9,
])
}
describe('payment evidence validation', () => {
it('accepts a structurally bounded PDF by content', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts PDFs with trailing bytes after the EOF marker', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF\n\u0000\u0000')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts PDFs that contain common byte sequences inside document content', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n(<html><svg>PK\u0003\u0004)</script>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts valid evidence files reported with compatible browser MIME aliases', () => {
const pdf = Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF')
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/octet-stream'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
expect(assertPaymentEvidenceFile(file(pdf, 'receipt.pdf', 'application/x-pdf'))).toEqual({ mime: 'application/pdf', ext: '.pdf' })
})
it('accepts valid image evidence with trailing bytes and common JPEG extensions', () => {
expect(assertPaymentEvidenceFile(file(Buffer.concat([png(), Buffer.from('\n')]), 'receipt.png', 'image/x-png'))).toEqual({ mime: 'image/png', ext: '.png' })
expect(assertPaymentEvidenceFile(file(Buffer.concat([jpeg(), Buffer.from('\n')]), 'receipt.jfif', 'image/pjpeg'))).toEqual({ mime: 'image/jpeg', ext: '.jpg' })
})
it('rejects spoofed MIME types and active content', () => {
const html = Buffer.from('<!doctype html><script>alert(1)</script>')
expect(() => assertPaymentEvidenceFile(file(html, 'receipt.pdf', 'application/pdf'))).toThrow(/suspicious/i)
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32)])
expect(() => assertPaymentEvidenceFile(file(png, 'receipt.pdf', 'application/pdf'))).toThrow(/valid PDF, JPEG, and PNG/i)
})
it('sanitizes filenames without allowing path traversal', () => {
expect(sanitizeEvidenceFilename('../../bank<receipt>.pdf')).toBe('bank_receipt_.pdf')
})
})
+128
View File
@@ -0,0 +1,128 @@
import path from 'path'
import multer from 'multer'
import { ValidationError } from '../errors'
export const PAYMENT_EVIDENCE_MAX_FILE_SIZE = 10 * 1024 * 1024
export const PAYMENT_EVIDENCE_MAX_FILES = 3
export const PAYMENT_EVIDENCE_MAX_TOTAL_SIZE = 20 * 1024 * 1024
export const paymentEvidenceUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: PAYMENT_EVIDENCE_MAX_FILE_SIZE, files: 1, fields: 5, parts: 8 },
})
export type DetectedPaymentEvidence = {
mime: 'application/pdf' | 'image/jpeg' | 'image/png'
ext: '.pdf' | '.jpg' | '.png'
}
const allowedExtensions: Record<DetectedPaymentEvidence['mime'], string[]> = {
'application/pdf': ['.pdf'],
'image/jpeg': ['.jpg', '.jpeg', '.jpe', '.jfif'],
'image/png': ['.png'],
}
const allowedDeclaredMimes: Record<DetectedPaymentEvidence['mime'], string[]> = {
'application/pdf': ['application/pdf', 'application/x-pdf', 'application/octet-stream'],
'image/jpeg': ['image/jpeg', 'image/pjpeg', 'application/octet-stream'],
'image/png': ['image/png', 'image/x-png', 'application/octet-stream'],
}
function hasSpoofedLeadingContainerSignature(buffer: Buffer) {
const prefix = buffer.subarray(0, 512)
const text = prefix.toString('latin1').trimStart().toLowerCase()
return text.startsWith('<script')
|| text.startsWith('<!doctype html')
|| text.startsWith('<html')
|| text.startsWith('<svg')
|| prefix.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]))
|| prefix.subarray(0, 4).equals(Buffer.from([0x4d, 0x5a, 0x90, 0x00]))
|| prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
}
function detectType(buffer: Buffer): DetectedPaymentEvidence | null {
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82])
const iendIndex = buffer.lastIndexOf(iend)
if (buffer.length >= 33 && iendIndex >= 0 && buffer.length - iendIndex <= 2048) {
return { mime: 'image/png', ext: '.png' }
}
}
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
const eoiIndex = buffer.lastIndexOf(Buffer.from([0xff, 0xd9]))
if (eoiIndex >= 0 && buffer.length - eoiIndex <= 2048) {
return { mime: 'image/jpeg', ext: '.jpg' }
}
}
if (buffer.length >= 12 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
const content = buffer.toString('latin1')
// Real-world PDFs may include a newline or small binary marker after EOF.
// Require an EOF marker near the end instead of at the exact final byte.
const eofIndex = content.lastIndexOf('%%EOF')
if (eofIndex >= 0 && content.length - eofIndex <= 2048 && !/\/Encrypt\b/.test(content)) return { mime: 'application/pdf', ext: '.pdf' }
}
return null
}
function assertSafeJpegDimensions(buffer: Buffer) {
let offset = 2
while (offset + 8 < buffer.length) {
if (buffer[offset] !== 0xff) { offset += 1; continue }
const marker = buffer[offset + 1]
if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
offset += 2
continue
}
const segmentLength = buffer.readUInt16BE(offset + 2)
if (segmentLength < 2 || offset + 2 + segmentLength > buffer.length) break
const isStartOfFrame = marker !== undefined
&& ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf))
if (isStartOfFrame) {
const height = buffer.readUInt16BE(offset + 5)
const width = buffer.readUInt16BE(offset + 7)
if (width <= 0 || height <= 0 || width > 8000 || height > 8000 || width * height > 24_000_000) {
throw new ValidationError('Image dimensions are too large')
}
return
}
offset += 2 + segmentLength
}
throw new ValidationError('The JPEG file is malformed')
}
function assertSafePngDimensions(buffer: Buffer) {
if (buffer.length < 24) throw new ValidationError('The PNG file is malformed')
const width = buffer.readUInt32BE(16)
const height = buffer.readUInt32BE(20)
if (width <= 0 || height <= 0 || width > 8000 || height > 8000 || width * height > 24_000_000) {
throw new ValidationError('Image dimensions are too large')
}
}
export function assertPaymentEvidenceFile(file: Express.Multer.File | undefined): DetectedPaymentEvidence {
if (!file) throw new ValidationError('A payment evidence file is required')
if (file.size <= 0 || file.size > PAYMENT_EVIDENCE_MAX_FILE_SIZE) {
throw new ValidationError('Payment evidence files must be between 1 byte and 10 MB')
}
if (hasSpoofedLeadingContainerSignature(file.buffer)) throw new ValidationError('Unsupported or suspicious payment evidence file')
const detected = detectType(file.buffer)
if (!detected) throw new ValidationError('Only valid PDF, JPEG, and PNG evidence files are accepted')
const declaredMime = file.mimetype.toLowerCase()
if (!allowedDeclaredMimes[detected.mime].includes(declaredMime)) {
throw new ValidationError('The declared file type does not match its content')
}
const extension = path.extname(file.originalname).toLowerCase()
if (!allowedExtensions[detected.mime].includes(extension)) {
throw new ValidationError('The filename extension does not match the file content')
}
if (detected.mime === 'image/png') assertSafePngDimensions(file.buffer)
if (detected.mime === 'image/jpeg') assertSafeJpegDimensions(file.buffer)
return detected
}
export function sanitizeEvidenceFilename(value: string) {
const base = path.basename(value).normalize('NFKC').replace(/[\u0000-\u001f\u007f]/g, '').replace(/[^\p{L}\p{N}._ -]/gu, '_')
return (base || 'payment-evidence').slice(0, 180)
}
+51 -192
View File
@@ -1,32 +1,22 @@
import http from 'http'
import { Server as SocketIOServer } from 'socket.io'
import type { Socket } from 'socket.io'
import cron from 'node-cron'
import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
import { verifyAnyActorToken } from './security/tokens'
import { getSessionCookieName } from './security/sessionCookies'
import { sendNotification } from './services/notificationService'
import {
runTrialExpirationJob,
runPaymentPendingTimeoutJob,
runPastDueTimeoutJob,
runSuspensionTimeoutJob,
runPeriodEndCancellationJob,
} from './modules/subscriptions/subscription.service'
import { startOutboxWorker, startScheduledJobs } from './workers/jobs'
const app = createApp()
const app = createApp()
const server = http.createServer(app)
assertStorageConfiguration()
// ─── Socket.io ────────────────────────────────────────────────
const io = new SocketIOServer(server, {
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
})
function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) return null
@@ -51,10 +41,9 @@ function getSocketSessionToken(socket: Socket): string | undefined {
)
}
// Authenticate socket connections via JWT before joining user rooms
io.use((socket, next) => {
const token = getSocketSessionToken(socket)
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
if (!token) return next()
try {
const payload = verifyAnyActorToken(token)
;(socket as any).authenticatedUserId = payload.sub
@@ -66,12 +55,9 @@ io.use((socket, next) => {
io.on('connection', (socket) => {
const userId = (socket as any).authenticatedUserId as string | undefined
if (userId) {
socket.join(`user:${userId}`)
}
if (userId) socket.join(`user:${userId}`)
})
// Redis pub/sub → broadcast to connected clients
const subscriber = redis.duplicate()
subscriber.psubscribe('notifications:*', (err) => {
if (err) console.error('[Redis] Subscribe error:', err)
@@ -86,181 +72,14 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
}
})
// ─── Scheduled jobs ───────────────────────────────────────────
// Embedded jobs only when explicitly enabled (single-process local/dev).
// Production should run `npm run worker` / Compose `api-worker` instead.
if (process.env.ENABLE_EMBEDDED_JOBS === 'true') {
console.warn('[API] ENABLE_EMBEDDED_JOBS=true — running outbox/cron inside the API process')
startOutboxWorker()
startScheduledJobs()
}
// Daily: flag expiring/expired licenses
cron.schedule('0 8 * * *', async () => {
const customers = await prisma.customer.findMany({ where: { licenseExpiry: { not: null } } })
for (const c of customers) {
if (!c.licenseExpiry) continue
const daysLeft = Math.ceil((c.licenseExpiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
const expired = c.licenseExpiry <= new Date()
const expiring = !expired && daysLeft < 90
if (expired !== c.licenseExpired || expiring !== c.licenseExpiringSoon) {
await prisma.customer.update({ where: { id: c.id }, data: { licenseExpired: expired, licenseExpiringSoon: expiring, licenseValidationStatus: expired ? 'EXPIRED' : expiring ? 'EXPIRING' : 'VALID' } })
}
}
})
// Hourly: expire trials that ended without payment
cron.schedule('0 * * * *', async () => {
const n = await runTrialExpirationJob()
if (n > 0) console.log(`[subscription] trial_expiration: ${n} expired`)
})
// Hourly: payment_pending → past_due after 7 days
cron.schedule('15 * * * *', async () => {
const n = await runPaymentPendingTimeoutJob()
if (n > 0) console.log(`[subscription] payment_pending_timeout: ${n} moved to past_due`)
})
// Hourly: past_due → suspended after 7 days
cron.schedule('30 * * * *', async () => {
const n = await runPastDueTimeoutJob()
if (n > 0) console.log(`[subscription] past_due_timeout: ${n} suspended`)
})
// Daily: suspended → cancelled after 16 days; and period-end cancellations
cron.schedule('0 1 * * *', async () => {
const nSuspend = await runSuspensionTimeoutJob()
const nPeriod = await runPeriodEndCancellationJob()
if (nSuspend > 0) console.log(`[subscription] suspension_timeout: ${nSuspend} cancelled`)
if (nPeriod > 0) console.log(`[subscription] period_end_cancel: ${nPeriod} cancelled`)
})
// Daily: send trial-ending reminders (3 days before trial end)
cron.schedule('0 9 * * *', async () => {
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
const subscriptions = await prisma.subscription.findMany({
where: { status: 'TRIALING', trialEndAt: { lte: soon, gte: new Date() } },
include: { company: { include: { employees: { where: { role: 'OWNER' } } } } },
})
for (const sub of subscriptions) {
const owner = sub.company.employees[0]
if (owner) {
await sendNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING',
companyId: sub.companyId,
employeeId: owner.id,
channels: ['IN_APP'],
templateKey: 'subscription.trial_ending',
templateVariables: {
trialEndDate: sub.trialEndAt ?? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
},
}).catch((err) => {
console.error('[Notifications] Failed to create trial ending reminder:', err?.message ?? String(err))
})
}
}
})
// Daily: notify companies about upcoming and overdue vehicle maintenance (date- and odometer-based).
// Repeats every day until the owner logs a new service entry that pushes the due date/mileage into the future.
cron.schedule('0 8 * * *', async () => {
const now = new Date()
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
// Fetch all candidate logs (date-due or has an odometer target), ordered newest-first per vehicle+type.
// We keep only the LATEST log per vehicle+type so that once the owner logs a new service the
// old overdue log is superseded and notifications stop automatically.
const allCandidates = await prisma.maintenanceLog.findMany({
where: {
OR: [
{ nextDueAt: { lte: in30Days } },
{ nextDueMileage: { not: null } },
],
},
include: { vehicle: { include: { company: { include: { employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 } } } } } },
orderBy: { performedAt: 'desc' },
})
// Keep only the most-recent log per vehicle+type combination
const latestByKey = new Map<string, typeof allCandidates[number]>()
for (const log of allCandidates) {
const key = `${log.vehicleId}:${log.type}`
if (!latestByKey.has(key)) latestByKey.set(key, log)
}
for (const log of latestByKey.values()) {
const vehicle = log.vehicle
const company = vehicle.company
const recipient = company.employees[0]
if (!recipient) continue
// Determine date-based urgency
let isOverdueByDate = false
let daysLeft: number | null = null
let dueSoonByDate = false
if (log.nextDueAt) {
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
isOverdueByDate = log.nextDueAt <= now
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
}
// Determine odometer-based urgency
let isOverdueByOdometer = false
let kmLeft: number | null = null
let dueSoonByOdometer = false
if (log.nextDueMileage != null && vehicle.mileage != null) {
kmLeft = log.nextDueMileage - vehicle.mileage
isOverdueByOdometer = kmLeft <= 0
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
}
// Skip if the latest log is no longer due (owner has updated it)
const isOverdue = isOverdueByDate || isOverdueByOdometer
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
if (!isOverdue && !isDueSoon) continue
// Dedup: don't send more than once per day for the same log
const alreadySent = await prisma.notification.findFirst({
where: {
type: 'VEHICLE_MAINTENANCE_DUE',
companyId: company.id,
createdAt: { gte: oneDayAgo },
data: { path: ['maintenanceLogId'], equals: log.id },
},
})
if (alreadySent) continue
// Build human-readable description
const dueParts: string[] = []
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
const title = isOverdue
? `Overdue: ${log.type}${vehicle.make} ${vehicle.model}`
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
await prisma.notification.create({
data: {
type: 'VEHICLE_MAINTENANCE_DUE',
title,
body,
data: {
vehicleId: vehicle.id,
maintenanceLogId: log.id,
maintenanceType: log.type,
isOverdue,
daysLeft,
kmLeft,
isOverdueByDate,
isOverdueByOdometer,
},
companyId: company.id,
employeeId: recipient.id,
channel: 'IN_APP',
status: 'DELIVERED',
},
})
}
})
// ─── Start ────────────────────────────────────────────────────
const PORT = Number(process.env.API_PORT ?? 4000)
const HOST = process.env.API_HOST ?? '0.0.0.0'
@@ -268,4 +87,44 @@ server.listen(PORT, HOST, () => {
console.log(`[API] Server running on ${HOST}:${PORT}`)
})
let shuttingDown = false
async function shutdown(signal: string) {
if (shuttingDown) return
shuttingDown = true
console.log(`[API] ${signal} received, draining`)
server.close((err) => {
if (err) console.error('[API] HTTP close error:', err.message)
})
try {
io.close()
} catch (err: any) {
console.error('[API] Socket.IO close error:', err?.message ?? err)
}
try {
await subscriber.quit()
} catch {
subscriber.disconnect()
}
try {
await redis.quit()
} catch {
redis.disconnect()
}
try {
await prisma.$disconnect()
} catch (err: any) {
console.error('[API] Prisma disconnect error:', err?.message ?? err)
}
process.exit(0)
}
process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT'))
export { app, io }
+2 -3
View File
@@ -15,21 +15,20 @@ describe('emailTranslations', () => {
expect(signupEmail.subject('ar')).toContain('جاهزة')
})
it('renders localized signup text with billing and provider details', () => {
it('renders localized signup text with billing details', () => {
const text = signupEmail.text({
firstName: 'Aya',
companyName: 'Atlas Cars',
plan: 'PRO',
billingPeriod: 'ANNUAL',
currency: 'MAD',
paymentProvider: 'AmanPay',
trialEnd,
}, 'fr')
expect(text).toContain('Bonjour Aya')
expect(text).toContain('Atlas Cars')
expect(text).toContain('Forfait : PRO (annuel)')
expect(text).toContain('Fournisseur de paiement principal : AmanPay')
expect(text).toContain('Paiements : virement bancaire ou chèque.')
})
it('marks Arabic reset-password HTML as right-to-left and embeds the reset URL', () => {
+3 -4
View File
@@ -25,7 +25,6 @@ export const signupEmail = {
plan: string
billingPeriod: string
currency: string
paymentProvider: string
trialEnd: Date
}, lang: Lang): string => {
const trialStr = formatDate(opts.trialEnd, lang)
@@ -36,7 +35,7 @@ export const signupEmail = {
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
`Currency: ${opts.currency}`,
`Primary payment provider: ${opts.paymentProvider}`,
'Payments: bank transfer or check.',
`Free trial ends on ${trialStr}.`,
'',
'Your workspace is ready. Sign in with the email and password you chose during signup.',
@@ -49,7 +48,7 @@ export const signupEmail = {
`Votre espace de travail RentalDriveGo pour ${opts.companyName} a été créé avec succès.`,
`Forfait : ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'mensuel' : 'annuel'})`,
`Devise : ${opts.currency}`,
`Fournisseur de paiement principal : ${opts.paymentProvider}`,
'Paiements : virement bancaire ou chèque.',
`La période d'essai gratuit se termine le ${trialStr}.`,
'',
"Votre espace de travail est prêt. Connectez-vous avec l'e-mail et le mot de passe choisis lors de l'inscription.",
@@ -62,7 +61,7 @@ export const signupEmail = {
`تم إنشاء مساحة عمل RentalDriveGo الخاصة بـ ${opts.companyName} بنجاح.`,
`الخطة: ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'شهري' : 'سنوي'})`,
`العملة: ${opts.currency}`,
`مزود الدفع الرئيسي: ${opts.paymentProvider}`,
'الدفع: تحويل بنكي أو شيك.',
`تنتهي الفترة التجريبية المجانية في ${trialStr}.`,
'',
'مساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.',
+25
View File
@@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { getIdempotentResult, setIdempotentResult } from './idempotencyStore'
describe('idempotencyStore (memory)', () => {
beforeEach(() => {
process.env.NODE_ENV = 'test'
process.env.IDEMPOTENCY_STORE = 'memory'
})
it('returns miss then hit for the same fingerprint', async () => {
const key = `k-${Date.now()}`
await expect(getIdempotentResult('carplace', key, 'fp1')).resolves.toEqual({ kind: 'miss' })
await setIdempotentResult('carplace', key, 'fp1', { id: 'reservation_1' })
await expect(getIdempotentResult('carplace', key, 'fp1')).resolves.toEqual({
kind: 'hit',
result: { id: 'reservation_1' },
})
})
it('detects fingerprint conflicts', async () => {
const key = `conflict-${Date.now()}`
await setIdempotentResult('carplace', key, 'fp1', { id: 'a' })
await expect(getIdempotentResult('carplace', key, 'fp2')).resolves.toEqual({ kind: 'conflict' })
})
})
+56
View File
@@ -0,0 +1,56 @@
import { redis } from '../lib/redis'
const MEMORY = new Map<string, { expiresAt: number; fingerprint: string; result: unknown }>()
const DEFAULT_TTL_SECONDS = 15 * 60
function useMemory() {
return process.env.IDEMPOTENCY_STORE === 'memory' || process.env.NODE_ENV === 'test'
}
export type IdempotencyHit =
| { kind: 'miss' }
| { kind: 'hit'; result: unknown }
| { kind: 'conflict' }
export async function getIdempotentResult(
scope: string,
key: string,
fingerprint: string,
): Promise<IdempotencyHit> {
const redisKey = `idempotency:${scope}:${key}`
if (useMemory()) {
const cached = MEMORY.get(redisKey)
if (!cached || cached.expiresAt <= Date.now()) return { kind: 'miss' }
if (cached.fingerprint !== fingerprint) return { kind: 'conflict' }
return { kind: 'hit', result: cached.result }
}
const raw = await redis.get(redisKey)
if (!raw) return { kind: 'miss' }
try {
const parsed = JSON.parse(raw) as { fingerprint: string; result: unknown }
if (parsed.fingerprint !== fingerprint) return { kind: 'conflict' }
return { kind: 'hit', result: parsed.result }
} catch {
return { kind: 'miss' }
}
}
export async function setIdempotentResult(
scope: string,
key: string,
fingerprint: string,
result: unknown,
ttlSeconds = DEFAULT_TTL_SECONDS,
): Promise<void> {
const redisKey = `idempotency:${scope}:${key}`
const payload = JSON.stringify({ fingerprint, result })
if (useMemory()) {
MEMORY.set(redisKey, { expiresAt: Date.now() + ttlSeconds * 1000, fingerprint, result })
return
}
await redis.set(redisKey, payload, 'EX', ttlSeconds)
}
+3 -3
View File
@@ -69,18 +69,18 @@ const FIELD_CONFIGS: Record<FieldType, FieldConfig> = {
name: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
nationality: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
streetAddress: { maxLength: 100, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
city: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
city: { maxLength: 85, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
pickupLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
returnLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
country: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
// Group 2: Title Case All
fullAddress: { maxLength: 200, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
fullAddress: { maxLength: 255, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
commercialName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
legalCompanyName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
// Group 3: Lowercase — email
email: { maxLength: 100, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
email: { maxLength: 254, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
// Group 4: Uppercase (hyphens allowed per plan examples: "abc-123" → "ABC-123")
licensePlate: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
+98
View File
@@ -0,0 +1,98 @@
/**
* Optional S3-compatible object storage (MinIO / AWS S3).
* Activated when FILE_STORAGE_DRIVER=s3.
*
* Uses the AWS SDK v3 if installed; otherwise falls back to a clear startup error.
* Add dependency: `@aws-sdk/client-s3`
*/
type S3ClientLike = {
send: (command: unknown) => Promise<unknown>
}
let clientPromise: Promise<S3ClientLike> | null = null
function required(name: string) {
const value = process.env[name]
if (!value) throw new Error(`${name} is required for S3 storage`)
return value
}
async function getClient(): Promise<S3ClientLike> {
if (!clientPromise) {
clientPromise = (async () => {
try {
// Dynamic import keeps local-only installs working without the SDK.
const sdk = await import('@aws-sdk/client-s3')
return new sdk.S3Client({
region: process.env.S3_REGION ?? 'us-east-1',
endpoint: process.env.S3_ENDPOINT || undefined,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== 'false',
credentials: {
accessKeyId: required('S3_ACCESS_KEY_ID'),
secretAccessKey: required('S3_SECRET_ACCESS_KEY'),
},
}) as S3ClientLike
} catch (err: any) {
throw new Error(
`FILE_STORAGE_DRIVER=s3 requires @aws-sdk/client-s3. Install it in apps/api. (${err?.message ?? err})`,
)
}
})()
}
return clientPromise
}
export async function headBucket() {
const sdk = await import('@aws-sdk/client-s3')
const client = await getClient()
await client.send(new sdk.HeadBucketCommand({ Bucket: required('S3_BUCKET') }))
}
export async function putObject(key: string, body: Buffer, contentType = 'application/octet-stream') {
const sdk = await import('@aws-sdk/client-s3')
const client = await getClient()
await client.send(
new sdk.PutObjectCommand({
Bucket: required('S3_BUCKET'),
Key: key.replace(/^\/+/, ''),
Body: body,
ContentType: contentType,
}),
)
}
export async function getObjectBuffer(key: string): Promise<Buffer> {
const sdk = await import('@aws-sdk/client-s3')
const client = await getClient()
const result: any = await client.send(
new sdk.GetObjectCommand({
Bucket: required('S3_BUCKET'),
Key: key.replace(/^\/+/, ''),
}),
)
const stream = result.Body
if (!stream) throw new Error('Empty S3 object body')
const chunks: Buffer[] = []
for await (const chunk of stream as AsyncIterable<Buffer>) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return Buffer.concat(chunks)
}
export async function deleteObject(key: string) {
const sdk = await import('@aws-sdk/client-s3')
const client = await getClient()
await client.send(
new sdk.DeleteObjectCommand({
Bucket: required('S3_BUCKET'),
Key: key.replace(/^\/+/, ''),
}),
)
}
export function publicObjectUrl(key: string) {
const base = (process.env.S3_PUBLIC_BASE_URL || process.env.API_URL || 'http://localhost:4000').replace(/\/$/, '')
if (process.env.S3_PUBLIC_BASE_URL) return `${base}/${key.replace(/^\/+/, '')}`
return `${base}/storage/${key.replace(/^\/+/, '')}`
}
+94
View File
@@ -0,0 +1,94 @@
import type { Request, Response, NextFunction } from 'express'
type CounterKey = string
const counters = new Map<CounterKey, number>()
const latencyMs: number[] = []
const MAX_LATENCY_SAMPLES = 2_000
function bump(key: CounterKey, by = 1) {
counters.set(key, (counters.get(key) ?? 0) + by)
}
export function observeHttpRequest(method: string, route: string, statusCode: number, durationMs: number) {
const normalizedRoute = route || 'unknown'
bump(`http_requests_total{method="${method}",route="${normalizedRoute}",status="${statusCode}"}`)
latencyMs.push(durationMs)
if (latencyMs.length > MAX_LATENCY_SAMPLES) latencyMs.splice(0, latencyMs.length - MAX_LATENCY_SAMPLES)
}
export function observeOutboxProcessed(count: number) {
if (count > 0) bump('notification_outbox_processed_total', count)
}
export function setGauge(name: string, value: number) {
counters.set(`gauge:${name}`, value)
}
export function getGauge(name: string): number {
return counters.get(`gauge:${name}`) ?? 0
}
function percentile(sorted: number[], p: number) {
if (sorted.length === 0) return 0
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
return sorted[idx]!
}
export function renderPrometheusText(): string {
const lines: string[] = [
'# HELP http_requests_total Total HTTP requests handled by the API',
'# TYPE http_requests_total counter',
]
for (const [key, value] of counters) {
if (key.startsWith('gauge:')) continue
if (key.startsWith('http_requests_total')) {
lines.push(`${key} ${value}`)
}
}
for (const [key, value] of counters) {
if (key.startsWith('notification_outbox_processed_total')) {
lines.push('# HELP notification_outbox_processed_total Notification outbox events completed')
lines.push('# TYPE notification_outbox_processed_total counter')
lines.push(`notification_outbox_processed_total ${value}`)
}
}
const sorted = [...latencyMs].sort((a, b) => a - b)
lines.push('# HELP http_request_duration_ms HTTP request duration percentiles (recent window)')
lines.push('# TYPE http_request_duration_ms gauge')
lines.push(`http_request_duration_ms{quantile="0.5"} ${percentile(sorted, 50)}`)
lines.push(`http_request_duration_ms{quantile="0.95"} ${percentile(sorted, 95)}`)
lines.push(`http_request_duration_ms{quantile="0.99"} ${percentile(sorted, 99)}`)
lines.push('# HELP notification_outbox_pending Notification outbox rows pending dispatch')
lines.push('# TYPE notification_outbox_pending gauge')
lines.push(`notification_outbox_pending ${getGauge('notification_outbox_pending')}`)
lines.push('# HELP notification_outbox_completed Notification outbox rows with status PUBLISHED (DB gauge)')
lines.push('# TYPE notification_outbox_completed gauge')
lines.push(`notification_outbox_completed ${getGauge('notification_outbox_completed')}`)
lines.push('# HELP process_uptime_seconds Process uptime')
lines.push('# TYPE process_uptime_seconds gauge')
lines.push(`process_uptime_seconds ${process.uptime()}`)
return `${lines.join('\n')}\n`
}
export function metricsMiddleware(req: Request, res: Response, next: NextFunction) {
const started = Date.now()
res.on('finish', () => {
const route = (req.route?.path ? `${req.baseUrl}${req.route.path}` : req.path) || 'unknown'
observeHttpRequest(req.method, route, res.statusCode, Date.now() - started)
})
next()
}
/** Reset in-memory series (tests only). */
export function resetMetricsForTests() {
counters.clear()
latencyMs.length = 0
}
+98 -8
View File
@@ -38,11 +38,11 @@ function isWithinPath(targetPath: string, parentPath: string): boolean {
export function assertStorageConfiguration(): string {
const storageRoot = getStorageRoot()
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT) {
if (process.env.NODE_ENV === 'production' && !process.env.FILE_STORAGE_ROOT && getStorageDriver() === 'local') {
throw new Error('FILE_STORAGE_ROOT must be set in production so uploads are stored on the mounted volume.')
}
if (process.env.NODE_ENV === 'production') {
if (process.env.NODE_ENV === 'production' && getStorageDriver() === 'local') {
const forbiddenRoots = [APP_PACKAGE_ROOT, APP_SOURCE_ROOT, APP_DIST_ROOT]
const invalidRoot = forbiddenRoots.find((root) => isWithinPath(storageRoot, root))
if (invalidRoot) {
@@ -50,11 +50,40 @@ export function assertStorageConfiguration(): string {
`FILE_STORAGE_ROOT must point outside the API app tree in production. Received ${storageRoot}, which is inside ${invalidRoot}.`
)
}
if (process.env.MANUAL_PAYMENT_EVIDENCE_UPLOAD_ENABLED === 'true') {
if (process.env.PRIVATE_STORAGE_PERSISTENCE_CONFIRMED !== 'true') {
throw new Error('Manual payment evidence requires confirmed persistent private storage in production.')
}
if (process.env.PRIVATE_STORAGE_ENCRYPTION_AT_REST_CONFIRMED !== 'true') {
throw new Error('Manual payment evidence requires confirmed encryption at rest in production.')
}
}
}
if (getStorageDriver() === 's3') {
for (const key of ['S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'] as const) {
if (!process.env[key]) throw new Error(`${key} is required when FILE_STORAGE_DRIVER=s3`)
}
}
return storageRoot
}
export function getStorageDriver(): 'local' | 's3' {
return process.env.FILE_STORAGE_DRIVER === 's3' ? 's3' : 'local'
}
export async function checkStorageReady(): Promise<void> {
if (getStorageDriver() === 's3') {
const { headBucket } = await import('./objectStorage')
await headBucket()
return
}
const root = assertStorageConfiguration()
fs.mkdirSync(path.join(root, 'public'), { recursive: true })
fs.mkdirSync(path.join(root, 'private'), { recursive: true })
}
function ensureStorageRoot(visibility: StorageVisibility): string {
assertStorageConfiguration()
const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot()
@@ -65,6 +94,7 @@ function ensureStorageRoot(visibility: StorageVisibility): string {
function inferVisibility(folder: string): StorageVisibility {
const normalized = folder.replace(/\\/g, '/')
if (/\/customers\//.test(`/${normalized}/`)) return 'private'
if (/\/reservations\//.test(`/${normalized}/`)) return 'private'
if (/\/licenses?\//.test(`/${normalized}/`)) return 'private'
if (/\/contracts?\//.test(`/${normalized}/`)) return 'private'
if (/\/documents?\//.test(`/${normalized}/`)) return 'private'
@@ -102,19 +132,24 @@ export async function uploadImage(
publicId?: string,
visibility: StorageVisibility = inferVisibility(folder),
): Promise<string> {
const safePublicId = (publicId ?? '').replace(/[^a-zA-Z0-9_-]/g, '') || crypto.randomBytes(16).toString('hex')
const filename = `${safePublicId}.jpg`
if (getStorageDriver() === 's3') {
const objectKey = path.posix.join(visibility, folder.replace(/\\/g, '/'), filename)
const { putObject } = await import('./objectStorage')
await putObject(objectKey, buffer, 'image/jpeg')
// Keep the historical public URL shape so existing clients and resolveStoredFilePath continue to work via API proxy.
return `${getApiBase()}/storage/${folder}/${filename}`
}
const storageRoot = ensureStorageRoot(visibility)
const folderPath = path.join(storageRoot, folder)
if (!isWithinPath(folderPath, storageRoot)) {
throw new Error('Upload path escapes storage root')
}
fs.mkdirSync(folderPath, { recursive: true })
const filename = publicId
? `${publicId}.jpg`
: `${crypto.randomBytes(16).toString('hex')}.jpg`
fs.writeFileSync(path.join(folderPath, filename), buffer)
return `${getApiBase()}/storage/${folder}/${filename}`
}
@@ -140,3 +175,58 @@ export async function deleteImage(imageUrl: string): Promise<void> {
fs.unlinkSync(filePath)
}
}
function normalizePrivateStorageKey(storageKey: string) {
const normalized = storageKey.replace(/\\/g, '/').replace(/^\/+/, '')
if (!normalized || normalized.includes('..') || path.isAbsolute(normalized)) {
throw new Error('Invalid private storage key')
}
return normalized
}
export function resolvePrivateDocumentPath(storageKey: string): string {
const root = ensureStorageRoot('private')
const filePath = path.join(root, normalizePrivateStorageKey(storageKey))
if (!isWithinPath(filePath, root)) throw new Error('Private document path escapes storage root')
return filePath
}
export async function storePaymentEvidenceInQuarantine(
buffer: Buffer,
companyId: string,
submissionId: string,
extension: string,
) {
const safeExtension = ['.pdf', '.jpg', '.png'].includes(extension) ? extension : ''
if (!safeExtension) throw new Error('Unsupported private document extension')
const storageKey = path.posix.join(
'payment-evidence',
'quarantine',
companyId,
submissionId,
`${crypto.randomBytes(24).toString('hex')}${safeExtension}`,
)
const filePath = resolvePrivateDocumentPath(storageKey)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, buffer, { mode: 0o600, flag: 'wx' })
return { storageKey, filePath }
}
export async function promotePaymentEvidence(storageKey: string) {
const sourcePath = resolvePrivateDocumentPath(storageKey)
const cleanKey = normalizePrivateStorageKey(storageKey).replace('/quarantine/', '/clean/')
if (cleanKey === storageKey) throw new Error('Only quarantined evidence can be promoted')
const targetPath = resolvePrivateDocumentPath(cleanKey)
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
fs.renameSync(sourcePath, targetPath)
return cleanKey
}
export function readPrivateDocument(storageKey: string): Buffer {
return fs.readFileSync(resolvePrivateDocumentPath(storageKey))
}
export async function deletePrivateDocument(storageKey: string): Promise<void> {
const filePath = resolvePrivateDocumentPath(storageKey)
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
}
+153 -8
View File
@@ -10,17 +10,133 @@ const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/
/** Morocco phone: (+212|00212|0) + 5/6/7 + 8 digits (optional spaces) */
const MA_PHONE_REGEX = /^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$/
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/
const ALPHANUMERIC_REGEX = /^[A-Za-z0-9]+$/
export const languageSchema = z.enum(['en', 'fr', 'ar'])
export type InputLanguage = z.infer<typeof languageSchema>
const LOCALIZED_TEXT_PATTERNS: Record<InputLanguage, RegExp> = {
en: /^[A-Za-z\s'-]+$/u,
fr: /^[A-Za-zÀ-ÖØ-öø-ÿŒœ\s'-]+$/u,
ar: /^[\u0600-\u06FF\u0750-\u077F\s،؛؟ـ'-]+$/u,
}
const LOCALIZED_ADDRESS_PATTERNS: Record<InputLanguage, RegExp> = {
en: /^[A-Za-z0-9\s,'-]+$/u,
fr: /^[A-Za-z0-9À-ÖØ-öø-ÿŒœ\s,'-]+$/u,
ar: /^[0-9\u0660-\u0669\u0600-\u06FF\u0750-\u077F\s،؛؟ـ,'-]+$/u,
}
function normalizeNfc(value: string) {
return value.trim().normalize('NFC')
}
export function validateLocalizedText(value: string, language: InputLanguage, maxLength: number) {
const normalized = normalizeNfc(value)
return normalized.length >= 1 &&
normalized.length <= maxLength &&
LOCALIZED_TEXT_PATTERNS[language].test(normalized)
}
export function validateLocalizedAddress(value: string, language: InputLanguage, maxLength: number) {
const normalized = normalizeNfc(value)
return normalized.length >= 1 &&
normalized.length <= maxLength &&
LOCALIZED_ADDRESS_PATTERNS[language].test(normalized)
}
export function localizedTextIssue(field: string) {
return `${field} contains characters that do not match the selected language`
}
export function isoDateField() {
return z.string().regex(ISO_DATE_REGEX, { message: 'Use YYYY-MM-DD format' })
}
export function pastOrTodayIsoDateField() {
return isoDateField().refine((value) => value <= new Date().toISOString().slice(0, 10), {
message: 'Date cannot be in the future',
})
}
export function optionalVinField() {
return z.string().optional().transform((value) => {
if (value === undefined || value.trim() === '') return undefined
return value.trim().toUpperCase()
}).pipe(
z.string().regex(VIN_REGEX, { message: 'VIN must be 17 characters and cannot contain I, O, or Q' }).optional()
)
}
export function vinField() {
return z.string().trim().transform((value) => value.toUpperCase()).pipe(
z.string().regex(VIN_REGEX, { message: 'VIN must be 17 characters and cannot contain I, O, or Q' })
)
}
export function optionalAlphanumericIdField() {
return z.string().optional().transform((value) => {
if (value === undefined || value.trim() === '') return undefined
return value.trim().toUpperCase()
}).pipe(
z.string()
.min(5, { message: 'Minimum 5 characters required' })
.max(30, { message: 'Maximum 30 characters allowed' })
.regex(ALPHANUMERIC_REGEX, { message: 'Only letters and numbers are allowed' })
.optional()
)
}
export function requiredAlphanumericIdField() {
return z.string()
.trim()
.min(5, { message: 'Minimum 5 characters required' })
.max(30, { message: 'Maximum 30 characters allowed' })
.regex(ALPHANUMERIC_REGEX, { message: 'Only letters and numbers are allowed' })
.transform((value) => value.toUpperCase())
}
export function countryCodeField() {
return z.string()
.trim()
.length(2, { message: 'Use a valid ISO country code' })
.transform((value) => value.toUpperCase())
.refine((value) => {
try {
return new Intl.DisplayNames(['en'], { type: 'region' }).of(value) !== value
} catch {
return /^[A-Z]{2}$/.test(value)
}
}, { message: 'Use a valid ISO country code' })
}
export function optionalCountryCodeField() {
return z.string().optional().transform((value) => {
if (value === undefined || value.trim() === '') return undefined
return value.trim().toUpperCase()
}).pipe(countryCodeField().optional())
}
// ─── Phone sanitization ───────────────────────────────────────
function sanitizePhone(raw: string): string {
let out = ''
for (const ch of raw) {
if (/[\d\s+]/.test(ch)) out += ch
if (/[\d\s+().-]/.test(ch)) out += ch
}
return out.trim()
}
function normalizeMoroccanPhone(raw: string): string {
const compact = sanitizePhone(raw).replace(/[\s().-]/g, '')
if (!MA_PHONE_REGEX.test(sanitizePhone(raw))) return compact
if (compact.startsWith('+212')) return compact
if (compact.startsWith('00212')) return `+212${compact.slice(5)}`
if (compact.startsWith('0')) return `+212${compact.slice(1)}`
return compact
}
// ─── Required fields ───────────────────────────────────────────
function applyFieldRules(fieldType: FieldType) {
@@ -96,6 +212,7 @@ export function emailField() {
return z
.string()
.min(1, { message: 'Email is required' })
.max(254, { message: 'Maximum 254 characters allowed' })
.trim()
.transform((val: string) => sanitizeAndFormat(val, 'email'))
.pipe(
@@ -109,6 +226,7 @@ export function emailField() {
export function optionalEmailField() {
return z
.string()
.max(254, { message: 'Maximum 254 characters allowed' })
.optional()
.transform((val) => {
if (val === undefined || val === null || val.trim() === '') return undefined
@@ -128,28 +246,55 @@ export function phoneField() {
return z
.string()
.min(1, { message: 'Phone number is required' })
.max(20, { message: 'Maximum 20 characters allowed' })
.trim()
.transform((val: string) => sanitizePhone(val))
.refine((val: string) => MA_PHONE_REGEX.test(sanitizePhone(val)), { message: 'Please enter a valid Morocco phone number' })
.transform((val: string) => normalizeMoroccanPhone(val))
.pipe(
z.string().refine(
(val: string) => MA_PHONE_REGEX.test(val),
{ message: 'Please enter a valid Morocco phone number' }
)
z.string().regex(/^\+212[5-7]\d{8}$/, { message: 'Please enter a valid Morocco phone number' })
)
}
export function optionalPhoneField() {
return z
.string()
.max(20, { message: 'Maximum 20 characters allowed' })
.optional()
.transform((val) => {
if (val === undefined || val === null || val.trim() === '') return undefined
return sanitizePhone(val)
return normalizeMoroccanPhone(val)
})
.pipe(
z.string().optional().refine(
(val) => val === undefined || MA_PHONE_REGEX.test(val),
(val) => val === undefined || /^\+212[5-7]\d{8}$/.test(val),
{ message: 'Please enter a valid Morocco phone number' }
)
)
}
export function optionalContactPhoneField() {
return z
.string()
.max(20, { message: 'Maximum 20 characters allowed' })
.optional()
.transform((val) => {
if (val === undefined || val === null || val.trim() === '') return undefined
return normalizeMoroccanPhone(val)
})
.pipe(
z.string().regex(/^\+212[5-7]\d{8}$/, { message: 'Please enter a valid Morocco phone number' }).optional()
)
}
export function contactPhoneField() {
return z
.string()
.min(1, { message: 'Phone number is required' })
.max(20, { message: 'Maximum 20 characters allowed' })
.trim()
.refine((val: string) => MA_PHONE_REGEX.test(sanitizePhone(val)), { message: 'Please enter a valid Morocco phone number' })
.transform((val: string) => normalizeMoroccanPhone(val))
.pipe(
z.string().regex(/^\+212[5-7]\d{8}$/, { message: 'Please enter a valid Morocco phone number' })
)
}
+105
View File
@@ -0,0 +1,105 @@
import type { Request, Response, NextFunction } from 'express'
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
const SESSION_COOKIE_PATTERN = /(?:^|;\s*)(?:admin_session|employee_session|renter_session)=/
function configuredOrigins() {
return [
process.env.SITE_ORIGIN,
process.env.DASHBOARD_URL,
process.env.ADMIN_URL,
process.env.CARPLACE_URL,
process.env.HOMEPAGE_URL,
process.env.WEBSITE_URL,
process.env.NEXT_PUBLIC_HOMEPAGE_URL,
process.env.NEXT_PUBLIC_DASHBOARD_URL,
process.env.NEXT_PUBLIC_ADMIN_URL,
process.env.NEXT_PUBLIC_CARPLACE_URL,
process.env.NEXT_PUBLIC_WEBSITE_URL,
process.env.CORS_ORIGINS,
]
.flatMap((value) => (value ?? '').split(','))
.map((value) => value.trim())
.filter(Boolean)
}
function normalizeOrigin(value: string | undefined): string | null {
if (!value) return null
try {
const url = new URL(value)
return url.origin
} catch {
return null
}
}
function originFromReferer(value: string | undefined): string | null {
if (!value) return null
try {
return new URL(value).origin
} catch {
return null
}
}
function isAllowedDevelopmentOrigin(origin: string) {
if (process.env.NODE_ENV === 'production') return false
try {
const url = new URL(origin)
if (url.protocol !== 'http:') return false
const trustedDevPorts = new Set(['3000', '3001', '3002', '3004', '4000'])
if (!trustedDevPorts.has(url.port)) return false
if (['localhost', '127.0.0.1'].includes(url.hostname)) return true
const octets = url.hostname.split('.').map((part) => Number(part))
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false
}
const first = octets[0]!
const second = octets[1]!
return first === 10 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168)
} catch {
return false
}
}
export function isTrustedBrowserOrigin(origin: string | null) {
if (!origin) return false
const allowed = new Set(configuredOrigins().map(normalizeOrigin).filter((value): value is string => Boolean(value)))
return allowed.has(origin) || isAllowedDevelopmentOrigin(origin)
}
function isCookieAuthenticatedBrowserMutation(req: Request) {
if (!MUTATING_METHODS.has(req.method.toUpperCase())) return false
const cookie = req.headers.cookie ?? ''
if (!SESSION_COOKIE_PATTERN.test(cookie)) return false
const secFetchSite = Array.isArray(req.headers['sec-fetch-site'])
? req.headers['sec-fetch-site'][0]
: req.headers['sec-fetch-site']
if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'same-site' && secFetchSite !== 'none') return true
// Browser cookie-authenticated mutations must present Origin. Referer is a
// fallback for older clients only; API clients should use Bearer tokens.
return true
}
export function requireTrustedOriginForCookieMutations(req: Request, res: Response, next: NextFunction) {
if (!isCookieAuthenticatedBrowserMutation(req)) return next()
const origin = normalizeOrigin(req.headers.origin as string | undefined)
?? originFromReferer(req.headers.referer as string | undefined)
if (!isTrustedBrowserOrigin(origin)) {
return res.status(403).json({
error: 'csrf_origin_rejected',
message: 'Mutating cookie-authenticated requests must come from a trusted application origin.',
statusCode: 403,
})
}
next()
}
@@ -0,0 +1,26 @@
import type { Request, Response, NextFunction } from 'express'
export const SPOOFABLE_FORWARDING_HEADERS = [
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-proto',
'x-real-ip',
'forwarded',
'cf-connecting-ip',
'true-client-ip',
'x-client-ip',
]
/**
* Drop client-supplied forwarding headers unless the deployment explicitly
* states that the immediate proxy has already scrubbed and reset them.
*/
export function sanitizeForwardedHeaders(req: Request, _res: Response, next: NextFunction) {
if (process.env.TRUSTED_FORWARD_HEADERS === 'true') return next()
for (const header of SPOOFABLE_FORWARDING_HEADERS) {
delete req.headers[header]
}
next()
}
@@ -31,6 +31,8 @@ describe('rateLimiter middleware configuration', () => {
expect(authLimiter.max).toBe(20)
expect(authLimiter.windowMs).toBe(15 * 60 * 1000)
expect(authLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true)
expect(authLimiter.skip({ method: 'POST' } as any)).toBe(false)
expect(authLimiter.skipSuccessfulRequests).toBe(true)
expect(authLimiter.message).toMatchObject({ error: 'too_many_requests', statusCode: 429 })
expect(rateLimit).toHaveBeenCalled()
@@ -63,7 +65,18 @@ describe('rateLimiter middleware configuration', () => {
expect(publicLimiter.max).toBe(60)
expect(publicLimiter.message.message).toBe('Rate limit exceeded')
expect(publicLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true)
expect(adminLimiter.max).toBe(100)
expect(adminLimiter.message.message).toBe('Too many admin requests')
expect(adminLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true)
})
it('uses a higher API cap and skips preflight requests before authenticated actor limits', async () => {
const { apiLimiter, actorLimiter } = await import('./rateLimiter')
expect(apiLimiter.max).toBe(300)
expect(apiLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true)
expect(apiLimiter.skip({ method: 'GET' } as any)).toBe(false)
expect(actorLimiter.skip({ method: 'OPTIONS' } as any)).toBe(true)
})
})
+33 -19
View File
@@ -2,7 +2,7 @@ import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
import type { Request } from 'express'
import { verifyAnyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
import { RedisRateLimitStore } from './redisRateLimitStore'
const SESSION_COOKIE_NAMES = [
getSessionCookieName('admin'),
@@ -50,28 +50,34 @@ function getAuthenticatedActorKey(req: Request): string | null {
}
}
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
const skipPreflightRequest = (req: Request) => req.method === 'OPTIONS'
// Strict limiter for auth endpoints — prevents brute-force and credential stuffing.
// Successful requests (e.g. GET /me profile reads) are skipped so only failed
// attempts count toward the cap.
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
/** express-rate-limit forbids reusing one Store across limiters — unique prefix per limiter. */
function withStore(prefix: string, options: Parameters<typeof rateLimit>[0]) {
return rateLimit({
...options,
store: new RedisRateLimitStore(prefix),
})
}
export const authLimiter = withStore('rl:auth:', {
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: skipPreflightRequest,
skipSuccessfulRequests: true,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 },
})
// Standard limiter for general authenticated API endpoints
export const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 120,
export const apiLimiter = withStore('rl:api:', {
windowMs: 60 * 1000,
max: 300,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
const actorKey = getAuthenticatedActorKey(req)
@@ -82,22 +88,32 @@ export const apiLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Limiter for public carplace and site endpoints (no auth)
export const publicLimiter = rateLimit({
export const publicLimiter = withStore('rl:public:', {
windowMs: 60 * 1000,
max: 60,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Tight limiter for admin endpoints
export const adminLimiter = rateLimit({
export const webhookLimiter = withStore('rl:webhook:', {
windowMs: 60 * 1000,
max: 30,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => getClientIpKey(req),
message: { error: 'too_many_requests', message: 'Webhook rate limit exceeded', statusCode: 429 },
})
export const adminLimiter = withStore('rl:admin:', {
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}`
@@ -105,14 +121,12 @@ export const adminLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
})
// Applied after authentication so limits can include actor identity rather than
// pretending every employee behind the same NAT is the same organism.
export const actorLimiter = rateLimit({
export const actorLimiter = withStore('rl:actor:', {
windowMs: 60 * 1000,
max: 240,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: skipPreflightRequest,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
const actorKey = getAuthenticatedActorKey(req)
@@ -0,0 +1,78 @@
import type { Store, Options, ClientRateLimitInfo, IncrementResponse } from 'express-rate-limit'
import { redis } from '../lib/redis'
/**
* Redis-backed store for express-rate-limit.
* Uses memory fallback only when explicitly requested (tests) via RATE_LIMIT_STORE=memory.
*/
export class RedisRateLimitStore implements Store {
prefix: string
windowMs = 60_000
#local = new Map<string, { totalHits: number; resetTime: Date }>()
constructor(prefix = 'rl:') {
this.prefix = prefix
}
init(options: Options): void {
this.windowMs = options.windowMs
}
private useMemory() {
return process.env.RATE_LIMIT_STORE === 'memory' || process.env.NODE_ENV === 'test'
}
async get(key: string): Promise<ClientRateLimitInfo | undefined> {
if (this.useMemory()) {
const hit = this.#local.get(key)
if (!hit) return undefined
return { totalHits: hit.totalHits, resetTime: hit.resetTime }
}
const redisKey = `${this.prefix}${key}`
const [count, ttl] = await Promise.all([redis.get(redisKey), redis.pttl(redisKey)])
if (count == null) return undefined
const resetTime = ttl > 0 ? new Date(Date.now() + ttl) : new Date(Date.now() + this.windowMs)
return { totalHits: Number(count), resetTime }
}
async increment(key: string): Promise<IncrementResponse> {
if (this.useMemory()) {
const now = Date.now()
const existing = this.#local.get(key)
if (!existing || existing.resetTime.getTime() <= now) {
const resetTime = new Date(now + this.windowMs)
this.#local.set(key, { totalHits: 1, resetTime })
return { totalHits: 1, resetTime }
}
existing.totalHits += 1
return { totalHits: existing.totalHits, resetTime: existing.resetTime }
}
const redisKey = `${this.prefix}${key}`
const totalHits = await redis.incr(redisKey)
if (totalHits === 1) await redis.pexpire(redisKey, this.windowMs)
const ttl = await redis.pttl(redisKey)
const resetTime = new Date(Date.now() + (ttl > 0 ? ttl : this.windowMs))
return { totalHits, resetTime }
}
async decrement(key: string): Promise<void> {
if (this.useMemory()) {
const existing = this.#local.get(key)
if (existing && existing.totalHits > 0) existing.totalHits -= 1
return
}
const redisKey = `${this.prefix}${key}`
const value = await redis.decr(redisKey)
if (value < 0) await redis.set(redisKey, '0', 'KEEPTTL')
}
async resetKey(key: string): Promise<void> {
if (this.useMemory()) {
this.#local.delete(key)
return
}
await redis.del(`${this.prefix}${key}`)
}
}
@@ -13,7 +13,7 @@ vi.mock('../lib/prisma', () => ({
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole } from './requireAdminAuth'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from './requireAdminAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
@@ -95,18 +95,19 @@ describe('requireAdminAuth middleware', () => {
expect(next).toHaveBeenCalledTimes(1)
})
it('blocks non-enrolled admins from privileged routes', async () => {
it('allows non-enrolled admins through regular admin auth', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false } as any)
const admin = { id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false }
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin as any)
const req = { headers: { authorization: 'Bearer admin-token' }, path: '/companies' } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({ error: 'admin_2fa_required', message: 'Admin 2FA enrollment is required before using privileged admin routes', statusCode: 403 })
expect(next).not.toHaveBeenCalled()
expect(req.admin).toEqual(admin)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
})
@@ -123,7 +124,7 @@ describe('requireAdminRole middleware', () => {
expect(next).not.toHaveBeenCalled()
})
it('blocks admins below the required rank', () => {
it('blocks admins without the explicit required admin role', () => {
const req = { admin: { role: 'VIEWER' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
@@ -133,13 +134,26 @@ describe('requireAdminRole middleware', () => {
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'forbidden',
message: 'This action requires the FINANCE role or higher',
message: 'This action requires explicit FINANCE permission',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
it('allows admins at or above the required rank', () => {
it('does not allow SUPPORT to access FINANCE-only routes', () => {
const req = { admin: { role: 'SUPPORT' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireAdminRole('FINANCE' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(next).not.toHaveBeenCalled()
})
it('allows admins explicitly permitted for the required admin role', () => {
const req = { admin: { role: 'ADMIN' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
@@ -150,3 +164,54 @@ describe('requireAdminRole middleware', () => {
expect(res.status).not.toHaveBeenCalled()
})
})
describe('requireFreshAdmin2FA middleware', () => {
it('allows a recently 2FA-verified admin session', () => {
const req = {
admin: { id: 'admin_1', totpEnabled: true },
adminAuthLast2faAt: Date.now() - 5 * 60 * 1000,
} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
it('blocks enrolled admins whose 2FA proof is stale', () => {
const req = {
admin: { id: 'admin_1', totpEnabled: true },
adminAuthLast2faAt: Date.now() - 24 * 60 * 60 * 1000,
} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'fresh_2fa_required',
message: 'Admin 2FA verification has expired; verify again to continue',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
it('blocks enrolled admins whose session has no 2FA verification proof', () => {
const req = { admin: { id: 'admin_1', totpEnabled: true } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireFreshAdmin2FA(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'fresh_2fa_required',
message: 'Admin 2FA verification is required for this session',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
})
+28 -30
View File
@@ -5,25 +5,12 @@ import { getAuthToken, sendUnauthorized, sendForbidden } from './authHelpers'
import { verifyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
const ROLE_RANK: Record<AdminRole, number> = {
SUPER_ADMIN: 5,
ADMIN: 4,
SUPPORT: 3,
FINANCE: 2,
VIEWER: 1,
}
const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
'/auth/me',
'/auth/logout',
'/auth/2fa/setup',
'/auth/2fa/verify',
])
const FRESH_2FA_WINDOW_MS = Number(process.env.ADMIN_FRESH_2FA_WINDOW_MS ?? 10 * 60 * 1000)
function is2faEnrollmentExempt(req: Request) {
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path)
const ADMIN_ROLE_ALLOWLIST: Record<AdminRole, readonly AdminRole[]> = {
SUPER_ADMIN: ['SUPER_ADMIN'],
ADMIN: ['SUPER_ADMIN', 'ADMIN'],
SUPPORT: ['SUPER_ADMIN', 'ADMIN', 'SUPPORT'],
FINANCE: ['SUPER_ADMIN', 'ADMIN', 'FINANCE'],
VIEWER: ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER'],
}
/**
@@ -49,10 +36,6 @@ export async function requireAdminAuth(req: Request, res: Response, next: NextFu
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
}
if (!admin.totpEnabled && !is2faEnrollmentExempt(req)) {
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required before using privileged admin routes')
}
req.admin = admin
req.adminAuthLast2faAt = typeof payload.last2faAt === 'number' ? payload.last2faAt : undefined
next()
@@ -67,11 +50,10 @@ export function requireAdminRole(minimumRole: AdminRole) {
const admin = req.admin
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
const rank = ROLE_RANK[admin.role] ?? 0
const required = ROLE_RANK[minimumRole] ?? 99
const allowedRoles = ADMIN_ROLE_ALLOWLIST[minimumRole] ?? []
if (rank < required) {
return sendForbidden(res, 'forbidden', `This action requires the ${minimumRole} role or higher`)
if (!allowedRoles.includes(admin.role)) {
return sendForbidden(res, 'forbidden', `This action requires explicit ${minimumRole} permission`)
}
next()
@@ -85,10 +67,26 @@ export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunc
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
}
const last2faAt = req.adminAuthLast2faAt
if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) {
return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
if (!req.adminAuthLast2faAt) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification is required for this session')
}
const maxAgeMs = Number(process.env.ADMIN_FRESH_2FA_MAX_AGE_MS ?? 30 * 60 * 1000)
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA freshness policy is misconfigured')
}
if (Date.now() - req.adminAuthLast2faAt > maxAgeMs) {
return sendForbidden(res, 'fresh_2fa_required', 'Admin 2FA verification has expired; verify again to continue')
}
next()
}
export function requireFreshAdmin2FAWhenEnabled(req: Request, res: Response, next: NextFunction) {
const admin = req.admin
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
if (!admin.totpEnabled) return next()
return requireFreshAdmin2FA(req, res, next)
}
+84 -27
View File
@@ -1,50 +1,52 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { getAccessLevel, hasAnyAccess } from '../modules/subscriptions/subscription.policy'
import { getAccessLevel, hasAnyAccess, hasFullAccess, hasWriteAccess } from '../modules/subscriptions/subscription.policy'
import { sendUnauthorized, sendPaymentRequired } from './authHelpers'
const BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
const COMPANY_READ_BLOCKED_STATUSES = ['SUSPENDED', 'PENDING']
const COMPANY_WRITE_BLOCKED_STATUSES = ['SUSPENDED', 'PENDING', 'PAUSED']
function billingUrl() {
return `${process.env.NEXT_PUBLIC_DASHBOARD_URL ?? process.env.DASHBOARD_URL ?? '/dashboard'}/subscription`
}
async function getSubscriptionStatus(companyId: string) {
const subscription = await prisma.subscription.findUnique({
where: { companyId },
select: { status: true },
})
return subscription?.status ?? 'EXPIRED'
}
function blockSubscription(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
return sendPaymentRequired(res, error, message, { billingUrl: billingUrl(), ...extra })
}
/**
* Blocks requests for companies with lapsed or unactivated subscriptions.
* Must be applied after `requireTenant`.
*
* Guarantees on success:
* req.company.status is not SUSPENDED or PENDING, and subscription access is not none
* Read access: allows healthy read-only states, but blocks companies that should
* not have normal application visibility at all.
*/
export async function requireSubscription(req: Request, res: Response, next: NextFunction) {
export async function requireSubscriptionRead(req: Request, res: Response, next: NextFunction) {
try {
const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
if (BLOCKED_STATUSES.includes(company.status)) {
return sendPaymentRequired(
if (COMPANY_READ_BLOCKED_STATUSES.includes(company.status)) {
return blockSubscription(
res,
`subscription_${company.status.toLowerCase()}`,
company.status === 'SUSPENDED'
? 'Your account has been suspended. Please contact support or renew your subscription.'
: 'Your account is pending activation. Please complete your subscription setup.',
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription` },
)
}
const subscription = await prisma.subscription.findUnique({
where: { companyId: company.id },
select: { status: true },
})
const subscriptionStatus = subscription?.status ?? 'EXPIRED'
const subscriptionStatus = await getSubscriptionStatus(company.id)
if (!hasAnyAccess(subscriptionStatus)) {
return sendPaymentRequired(
res,
'subscription_required',
'Your subscription has ended. Please reactivate to continue.',
{
billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription`,
subscriptionStatus,
accessLevel: getAccessLevel(subscriptionStatus),
},
)
return blockSubscription(res, 'subscription_required', 'Your subscription has ended. Please reactivate to continue.', {
subscriptionStatus,
accessLevel: getAccessLevel(subscriptionStatus),
})
}
next()
@@ -52,3 +54,58 @@ export async function requireSubscription(req: Request, res: Response, next: Nex
next(error)
}
}
/**
* Write access: blocks read-only/limited subscription states from mutating data.
*/
export async function requireSubscriptionWrite(req: Request, res: Response, next: NextFunction) {
try {
const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
if (COMPANY_WRITE_BLOCKED_STATUSES.includes(company.status)) {
return blockSubscription(res, `subscription_${company.status.toLowerCase()}`, 'Your current account status does not allow changes.')
}
const subscriptionStatus = await getSubscriptionStatus(company.id)
if (!hasWriteAccess(subscriptionStatus)) {
return blockSubscription(res, 'subscription_write_required', 'Your subscription is read-only. Reactivate or update billing to make changes.', {
subscriptionStatus,
accessLevel: getAccessLevel(subscriptionStatus),
})
}
next()
} catch (error) {
next(error)
}
}
/**
* Full access: required for booking/payment/billing-sensitive actions.
*/
export async function requireSubscriptionFull(req: Request, res: Response, next: NextFunction) {
try {
const company = req.company
if (!company) return sendUnauthorized(res, 'unauthenticated', 'No company context')
if (COMPANY_WRITE_BLOCKED_STATUSES.includes(company.status)) {
return blockSubscription(res, `subscription_${company.status.toLowerCase()}`, 'Your current account status does not allow this action.')
}
const subscriptionStatus = await getSubscriptionStatus(company.id)
if (!hasFullAccess(subscriptionStatus)) {
return blockSubscription(res, 'subscription_full_access_required', 'This action requires an active subscription in good standing.', {
subscriptionStatus,
accessLevel: getAccessLevel(subscriptionStatus),
})
}
next()
} catch (error) {
next(error)
}
}
// Backward-compatible alias. New routes should choose read/write/full explicitly.
export const requireSubscription = requireSubscriptionRead
@@ -3,8 +3,11 @@ import {
billingAccountUpdateSchema,
billingCreditNoteSchema,
billingRefundSchema,
collectionsOverrideSchema,
confirmManualPaymentSchema,
createBillingInvoiceSchema,
payBillingInvoiceSchema,
platformBillingSettingsSchema,
} from './admin.schemas'
describe('admin billing schemas', () => {
@@ -44,10 +47,38 @@ describe('admin billing schemas', () => {
expect(() => billingAccountUpdateSchema.parse({ billingEmail: 'not-email', netTermsDays: 366 })).toThrow()
})
it('bounds platform billing tax settings', () => {
expect(platformBillingSettingsSchema.parse({ taxRate: 20 })).toEqual({ taxRate: 20 })
expect(platformBillingSettingsSchema.safeParse({ taxRate: -1 }).success).toBe(false)
expect(platformBillingSettingsSchema.safeParse({ taxRate: 101 }).success).toBe(false)
})
it('requires positive money movements for payments, credit notes, and refunds', () => {
expect(payBillingInvoiceSchema.parse({ amount: 5000, paymentMethodId: null })).toEqual({ amount: 5000, paymentMethodId: null })
expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow()
expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow()
expect(() => billingRefundSchema.parse({ amount: 0, reason: 'Bad refund' })).toThrow()
})
it('requires cleared-funds attestation and an idempotency key for manual subscription settlement', () => {
const confirmation = {
submissionId: 'submission_1',
method: 'BANK_TRANSFER',
externalReference: 'BANK TXN 123',
amount: 19900,
receivedAt: '2026-08-09T12:00:00.000Z',
idempotencyKey: crypto.randomUUID(),
fundsVerified: true,
}
expect(confirmManualPaymentSchema.safeParse(confirmation).success).toBe(true)
expect(confirmManualPaymentSchema.safeParse({ ...confirmation, fundsVerified: false }).success).toBe(false)
expect(confirmManualPaymentSchema.safeParse({ ...confirmation, amount: 0 }).success).toBe(false)
})
it('keeps suspension and notification override controls separate', () => {
const base = { reason: 'Verified finance dispute', expiresAt: '2026-09-01T12:00:00.000Z' }
expect(collectionsOverrideSchema.parse({ ...base, type: 'PAYMENT_DISPUTE', pauseSuspension: true, pauseNotifications: false })).toMatchObject({ pauseSuspension: true, pauseNotifications: false })
expect(collectionsOverrideSchema.safeParse({ ...base, type: 'MANUAL_EXTENSION' }).success).toBe(false)
expect(collectionsOverrideSchema.safeParse({ ...base, type: 'MANUAL_EXTENSION', revisedSuspensionAt: '2026-08-25T12:00:00.000Z' }).success).toBe(true)
})
})
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listBillingAccounts } from './admin.billing.service'
import { prisma } from '../../lib/prisma'
vi.mock('../../lib/prisma', () => ({
prisma: {
subscriptionInvoice: { findMany: vi.fn() },
company: { findMany: vi.fn(), findUniqueOrThrow: vi.fn() },
billingAccount: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
billingInvoice: { groupBy: vi.fn(), findFirst: vi.fn(), create: vi.fn() },
billingCreditBalance: { create: vi.fn() },
billingEvent: { create: vi.fn() },
},
}))
vi.mock('../../services/invoicePdfService', () => ({
generateInvoicePdf: vi.fn(),
}))
describe('admin billing service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.subscriptionInvoice.findMany).mockResolvedValue([])
vi.mocked(prisma.company.findMany).mockResolvedValue([{ id: 'company_1' }] as never)
vi.mocked(prisma.billingAccount.count).mockResolvedValue(1 as never)
vi.mocked(prisma.billingInvoice.groupBy)
.mockResolvedValueOnce([
{ billingAccountId: 'billing_empty', _count: { _all: 0 }, _sum: { amountDue: 0 } },
{ billingAccountId: 'billing_due', _count: { _all: 1 }, _sum: { amountDue: 14900 } },
] as never)
.mockResolvedValueOnce([
{ status: 'OPEN', _count: { _all: 1 }, _sum: { amountDue: 14900, totalAmount: 14900 } },
] as never)
})
it('demotes duplicate primary billing accounts and lists only the canonical company row', async () => {
vi.mocked(prisma.billingAccount.findMany)
.mockResolvedValueOnce([
{ id: 'billing_empty', companyId: 'company_1', isPrimary: true, createdAt: new Date('2026-08-10T12:00:00.000Z'), creditBalances: [] },
{ id: 'billing_due', companyId: 'company_1', isPrimary: true, createdAt: new Date('2026-08-09T12:00:00.000Z'), creditBalances: [] },
] as never)
.mockResolvedValueOnce([
{
id: 'billing_due',
companyId: 'company_1',
isPrimary: true,
legalName: 'Atlas car',
billingEmail: 'moulay.elabidi@gmail.com',
createdAt: new Date('2026-08-09T12:00:00.000Z'),
company: { id: 'company_1', name: 'Atlas car', email: 'moulay.elabidi@gmail.com', slug: 'atlas-car', status: 'PENDING', subscription: { status: 'PAYMENT_PENDING' } },
creditBalances: [],
invoices: [{ id: 'invoice_1', status: 'OPEN', amountDue: 14900, amountPaid: 0, currency: 'MAD' }],
},
] as never)
const result = await listBillingAccounts({ page: 1, pageSize: 100 })
expect(prisma.billingAccount.updateMany).toHaveBeenCalledWith({
where: { companyId: 'company_1', id: { not: 'billing_due' }, isPrimary: true },
data: { isPrimary: false },
})
expect(prisma.billingAccount.findMany).toHaveBeenLastCalledWith(expect.objectContaining({
where: { isPrimary: true },
}))
expect(result.data).toHaveLength(1)
expect(result.data[0].id).toBe('billing_due')
expect(result.data[0].openBalance).toBe(14900)
})
})
@@ -1,6 +1,7 @@
import { prisma } from '../../lib/prisma'
import { NotFoundError, ValidationError } from '../../http/errors'
import { generateInvoicePdf } from '../../services/invoicePdfService'
import { calculateTaxAmount, getPlatformBillingSettings, updatePlatformBillingSettings } from '../subscriptions/billingTax'
const BLOCKING_INVOICE_TYPES = new Set([
'SUBSCRIPTION_INITIAL',
@@ -10,7 +11,6 @@ const BLOCKING_INVOICE_TYPES = new Set([
])
const EDITABLE_BILLING_STATUSES = new Set(['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'])
function toSequenceNumber(value: unknown) {
if (typeof value === 'bigint') return Number(value)
if (typeof value === 'number') return value
@@ -85,6 +85,45 @@ function calculateLineAmounts(items: Array<{ type: string; amount: number }>) {
return { subtotalAmount, discountAmount, creditAmount, taxAmount, totalAmount }
}
function invoiceTaxRate(invoice: { taxRecords?: Array<{ taxRate?: number | null; taxExempt?: boolean }> }) {
return invoice.taxRecords?.find((record) => !record.taxExempt && typeof record.taxRate === 'number')?.taxRate ?? null
}
function withBillingAccountBalances<T extends { invoices?: any[]; creditBalances?: any[] }>(account: T) {
const invoices = account.invoices ?? []
const creditBalances = account.creditBalances ?? []
const openBalance = invoices
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + (invoice.amountDue ?? 0), 0)
const paidBalance = invoices
.filter((invoice: any) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + (invoice.amountPaid ?? 0), 0)
const creditBalance = creditBalances.reduce((sum: number, item: any) => sum + (item.balanceAmount ?? 0), 0)
return {
...account,
openBalance,
paidBalance,
creditBalance,
}
}
function getOpenBalance(invoices: any[] = []) {
return invoices
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + (invoice.amountDue ?? 0), 0)
}
function chooseCanonicalBillingAccount<T extends { id: string; createdAt?: Date; invoices?: any[] }>(accounts: T[]) {
return [...accounts].sort((a: any, b: any) => {
const openBalanceDelta = getOpenBalance(b.invoices) - getOpenBalance(a.invoices)
if (openBalanceDelta) return openBalanceDelta
const invoiceCountDelta = (b.invoices?.length ?? 0) - (a.invoices?.length ?? 0)
if (invoiceCountDelta) return invoiceCountDelta
return new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()
})[0]
}
async function createBillingEvent(tx: any, data: {
billingAccountId?: string | null
invoiceId?: string | null
@@ -123,11 +162,41 @@ async function createAuditLog(data: {
}
async function ensurePrimaryBillingAccount(companyId: string, tx: any = prisma) {
const existing = await tx.billingAccount.findFirst({
const existingAccounts = await tx.billingAccount.findMany({
where: { companyId, isPrimary: true },
include: { company: { include: { contractSettings: true, subscription: true } }, creditBalances: true },
orderBy: { createdAt: 'desc' },
})
if (existing) return existing
if (existingAccounts.length) {
if (existingAccounts.length > 1) {
const accountIds = existingAccounts.map((account: any) => account.id)
const invoiceGroups = await tx.billingInvoice.groupBy({
by: ['billingAccountId'],
where: { billingAccountId: { in: accountIds } },
_count: { _all: true },
_sum: { amountDue: true },
})
const invoiceGroupByAccount = new Map<string, any>(invoiceGroups.map((group: any) => [group.billingAccountId, group]))
const canonical = chooseCanonicalBillingAccount(existingAccounts.map((account: any) => {
const group = invoiceGroupByAccount.get(account.id)
const invoiceCount = group?._count?._all ?? 0
const amountDue = group?._sum?.amountDue ?? 0
return {
...account,
invoices: Array.from({ length: invoiceCount }, () => ({
status: 'OPEN',
amountDue: Math.floor(amountDue / Math.max(invoiceCount, 1)),
})),
}
}))
await tx.billingAccount.updateMany({
where: { companyId, id: { not: canonical.id }, isPrimary: true },
data: { isPrimary: false },
})
return existingAccounts.find((account: any) => account.id === canonical.id) ?? canonical
}
return existingAccounts[0]
}
const company = await tx.company.findUniqueOrThrow({
where: { id: companyId },
@@ -327,7 +396,7 @@ async function syncLegacySubscriptionInvoices(companyId?: string) {
}
function buildBillingAccountWhere(query: { q?: string; status?: string; plan?: string }) {
const where: any = {}
const where: any = { isPrimary: true }
if (query.q) {
where.OR = [
{ legalName: { contains: query.q, mode: 'insensitive' } },
@@ -455,20 +524,7 @@ export async function listBillingAccounts(query: { q?: string; status?: string;
.reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0),
}
const data = (accounts as any[]).map((account) => {
const openBalance = (account.invoices as any[])
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + invoice.amountDue, 0)
const paidBalance = (account.invoices as any[])
.filter((invoice: any) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + invoice.amountPaid, 0)
return {
...account,
openBalance,
paidBalance,
creditBalance: (account.creditBalances as any[]).reduce((sum: number, item: any) => sum + item.balanceAmount, 0),
}
})
const data = (accounts as any[]).map((account) => withBillingAccountBalances(account))
return { data, total, stats }
}
@@ -504,7 +560,15 @@ export async function getBillingAccountDetail(companyId: string) {
lineItems: { orderBy: { createdAt: 'asc' } },
paymentIntents: { orderBy: { createdAt: 'desc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' } },
manualPaymentSubmissions: {
orderBy: { createdAt: 'desc' },
include: {
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
},
},
taxRecords: true,
subscriptionUpgradeRequest: { select: { id: true, status: true } },
creditNotes: { orderBy: { createdAt: 'desc' } },
refunds: { orderBy: { createdAt: 'desc' } },
},
@@ -513,7 +577,7 @@ export async function getBillingAccountDetail(companyId: string) {
})
if (!account) throw new NotFoundError('Billing account not found')
return account
return withBillingAccountBalances(account)
}
export async function updateBillingAccount(
@@ -569,6 +633,25 @@ export async function updateBillingAccount(
return updated
}
export function getBillingPlatformSettings() {
return getPlatformBillingSettings()
}
export async function updateBillingPlatformSettings(data: { taxRate: number }, adminId: string, ip?: string) {
const before = await getPlatformBillingSettings()
const updated = await updatePlatformBillingSettings({ taxRate: data.taxRate, updatedBy: adminId })
await createAuditLog({
adminUserId: adminId,
action: 'UPDATE_PLATFORM_BILLING_SETTINGS',
resource: 'PlatformBillingSettings',
resourceId: updated.id,
before,
after: updated,
ipAddress: ip,
})
return updated
}
export async function setDunningPaused(
billingAccountId: string,
paused: boolean,
@@ -698,6 +781,7 @@ export async function createDraftInvoice(
}
export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) {
const platformBillingSettings = await getPlatformBillingSettings()
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId },
@@ -765,9 +849,11 @@ export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: s
})
}
const taxRate = account.taxExempt ? 0 : Number(account.company.contractSettings?.taxRate ?? 0)
const taxBase = Math.max(preTaxBase - autoCreditToApply, 0)
const taxAmount = taxRate > 0 ? Math.round(taxBase * (taxRate / 100)) : 0
const shouldApplyTax = current.invoiceType !== 'MANUAL'
const { taxRate, taxAmount } = shouldApplyTax
? calculateTaxAmount(taxBase, account.taxExempt, platformBillingSettings.taxRate)
: { taxRate: 0, taxAmount: 0 }
if (taxAmount > 0) {
await tx.billingInvoiceLineItem.create({
@@ -905,6 +991,9 @@ export async function payInvoice(
if (!['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
throw new ValidationError('Invoice is not payable in its current state')
}
if (current.subscriptionId) {
throw new ValidationError('Subscription invoices require provider confirmation or the dedicated cleared-manual-payment workflow')
}
const amount = data.amount ?? current.amountDue
if (amount <= 0 || amount > current.amountDue) {
@@ -1383,6 +1472,7 @@ export async function getInvoicePdf(invoiceId: string) {
transactionId: latestPaymentAttempt?.providerPaymentId ?? null,
paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item: any) => ({
type: item.type,
description: item.description,
amount: item.amount,
currency: item.currency,
@@ -1395,6 +1485,7 @@ export async function getInvoicePdf(invoiceId: string) {
subtotalAmount: invoice.subtotalAmount,
discountAmount: invoice.discountAmount,
creditAmount: invoice.creditAmount,
taxRate: invoiceTaxRate(invoice),
taxAmount: invoice.taxAmount,
totalAmount: invoice.totalAmount,
amountPaid: invoice.amountPaid,
@@ -0,0 +1,552 @@
import crypto from 'crypto'
import { prisma } from '../../lib/prisma'
import { ConflictError, NotFoundError, ValidationError } from '../../http/errors'
import { readPrivateDocument } from '../../lib/storage'
import { sendNotification } from '../../services/notificationService'
import { coerceNotificationLocale, type NotificationLocale } from '../../services/notificationLocalizationService'
import { addBillingPeriod, normalizeExternalReference } from '../subscriptions/subscription.manual.service'
const PAYABLE_STATUSES = ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE']
const customerPaymentCopy: Record<NotificationLocale, {
confirmedTitle: string
confirmed: (details: PaymentConfirmationDetails) => string
rejectedTitle: string
rejected: (invoice: string, reason: string) => string
}> = {
en: {
confirmedTitle: 'Subscription payment confirmed',
confirmed: (details) => buildConfirmedPaymentBody(details),
rejectedTitle: 'Payment evidence needs attention',
rejected: (invoice, reason) => `The evidence submitted for invoice ${invoice} was rejected: ${reason}. Upload corrected evidence from Subscription.`,
},
fr: {
confirmedTitle: 'Paiement de labonnement confirmé',
confirmed: (details) => buildConfirmedPaymentBody(details),
rejectedTitle: 'Justificatif de paiement à corriger',
rejected: (invoice, reason) => `Le justificatif de la facture ${invoice} a été refusé : ${reason}. Téléversez un justificatif corrigé depuis Abonnement.`,
},
ar: {
confirmedTitle: 'تم تأكيد دفع الاشتراك',
confirmed: (details) => buildConfirmedPaymentBody(details),
rejectedTitle: 'مستند الدفع يحتاج إلى تصحيح',
rejected: (invoice, reason) => `تم رفض مستند الفاتورة ${invoice}: ${reason}. حمّل مستنداً مصححاً من صفحة الاشتراك.`,
},
}
type PaymentConfirmationDetails = {
invoice: string
amountPaid: number
currency: string
paymentType?: string | null
paymentReference?: string | null
receivedAt?: Date | string | null
confirmedAt?: Date | string | null
plan?: string | null
billingPeriod?: string | null
periodStart?: Date | string | null
periodEnd?: Date | string | null
}
function fmtMoney(amount: number, currency: string) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format((amount ?? 0) / 100)
}
function fmtDate(value?: Date | string | null) {
if (!value) return 'Not set'
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
}
function paymentMethodLabel(method?: string | null) {
if (method === 'BANK_TRANSFER') return 'Bank transfer'
if (method === 'CHECK') return 'Check'
return method ?? 'Manual payment'
}
function buildConfirmedPaymentBody(details: PaymentConfirmationDetails) {
return [
'Dear customer,',
'',
'We confirm that your subscription payment has been verified and recorded. Your subscription is now active for the period shown below.',
'',
`Invoice: ${details.invoice}`,
`Amount paid: ${fmtMoney(details.amountPaid, details.currency)}`,
`Payment type: ${paymentMethodLabel(details.paymentType)}`,
details.paymentReference ? `Payment reference: ${details.paymentReference}` : null,
`Funds received/cleared on: ${fmtDate(details.receivedAt)}`,
`Payment confirmed on: ${fmtDate(details.confirmedAt)}`,
`Subscription plan: ${details.plan ?? 'Current plan'}`,
`Billing period: ${details.billingPeriod ?? 'Current billing period'}`,
`Subscription start: ${fmtDate(details.periodStart)}`,
`Subscription end: ${fmtDate(details.periodEnd)}`,
'',
'A PDF copy of the invoice is attached for your records.',
'',
'Regards,',
'RentalDriveGo Finance',
].filter((line): line is string => line !== null).join('\n')
}
async function notifyPaymentResult(data: {
billingAccountId: string
companyId: string
invoiceId: string
invoiceNumber?: string | null
kind: 'confirmed' | 'rejected'
sourceId: string
reason?: string
}) {
const account = await prisma.billingAccount.findUnique({
where: { id: data.billingAccountId },
include: { billingContacts: { where: { isActive: true, receivePaymentNotices: true, verifiedAt: { not: null } }, include: { employee: true } } },
})
if (!account) return
const invoiceRecord = await prisma.billingInvoice.findUnique({
where: { id: data.invoiceId },
include: {
subscription: true,
lineItems: { orderBy: { createdAt: 'asc' } },
paymentAttempts: { orderBy: { attemptedAt: 'desc' }, take: 1 },
},
})
const paymentAttempt = invoiceRecord?.paymentAttempts?.[0] ?? null
const periodStart = invoiceRecord?.subscription?.currentPeriodStart ?? invoiceRecord?.lineItems?.[0]?.periodStart ?? null
const periodEnd = invoiceRecord?.subscription?.currentPeriodEnd ?? invoiceRecord?.lineItems?.[0]?.periodEnd ?? null
for (const contact of account.billingContacts) {
const enabled = account.enabledCommunicationLocales as string[]
const locale = coerceNotificationLocale(
contact.locale && enabled.includes(contact.locale)
? contact.locale
: contact.employee?.preferredLanguage && enabled.includes(contact.employee.preferredLanguage)
? contact.employee.preferredLanguage
: account.defaultCommunicationLocale,
)
const copy = customerPaymentCopy[locale]
const invoice = data.invoiceNumber ?? data.invoiceId
const details: PaymentConfirmationDetails = {
invoice,
amountPaid: invoiceRecord?.amountPaid ?? paymentAttempt?.amount ?? 0,
currency: invoiceRecord?.currency ?? paymentAttempt?.currency ?? 'MAD',
paymentType: paymentAttempt?.manualMethod ?? invoiceRecord?.collectionMethod ?? invoiceRecord?.paymentProvider ?? 'MANUAL',
paymentReference: paymentAttempt?.externalReference ?? paymentAttempt?.providerPaymentId ?? null,
receivedAt: paymentAttempt?.receivedAt ?? invoiceRecord?.paidAt ?? null,
confirmedAt: paymentAttempt?.confirmedAt ?? invoiceRecord?.paidAt ?? null,
plan: invoiceRecord?.requestedPlan ?? invoiceRecord?.subscription?.plan ?? null,
billingPeriod: invoiceRecord?.requestedBillingPeriod ?? invoiceRecord?.subscription?.billingPeriod ?? null,
periodStart,
periodEnd,
}
await sendNotification({
type: data.kind === 'confirmed' ? 'SUBSCRIPTION_PAYMENT_CONFIRMED' : 'MANUAL_PAYMENT_EVIDENCE_REJECTED',
title: data.kind === 'confirmed' ? copy.confirmedTitle : copy.rejectedTitle,
body: data.kind === 'confirmed' ? copy.confirmed(details) : copy.rejected(invoice, data.reason ?? ''),
companyId: data.companyId,
employeeId: contact.employeeId ?? undefined,
billingContactId: contact.employeeId ? undefined : contact.id,
channels: contact.employeeId ? ['IN_APP', 'EMAIL'] : ['EMAIL'],
locale,
templateKey: data.kind === 'confirmed' ? 'subscription.payment_confirmed.v1' : 'subscription.payment_evidence_rejected.v1',
idempotencyKey: `manual-payment:${data.kind}:${data.sourceId}:${contact.id}`,
sourceType: 'manual_payment',
sourceId: data.sourceId,
data: {
invoiceId: data.invoiceId,
amountPaid: details.amountPaid,
currency: details.currency,
paymentType: details.paymentType,
paymentReference: details.paymentReference,
subscriptionStart: details.periodStart,
subscriptionEnd: details.periodEnd,
timezone: account.timezone,
templateVersion: 1,
localizationFallback: false,
...(data.kind === 'confirmed' ? { emailAttachments: [{ type: 'invoice_pdf', invoiceId: data.invoiceId }] } : {}),
},
policy: { mandatory: true },
})
}
}
function requestHash(value: Record<string, unknown>) {
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
}
export async function listManualPaymentSubmissions(query: { status: string; page: number; pageSize: number }) {
const where = { status: query.status as any }
const [data, total] = await Promise.all([
prisma.manualPaymentSubmission.findMany({
where,
include: {
invoice: { include: { company: true, subscription: true } },
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
},
orderBy: { submittedAt: 'asc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
prisma.manualPaymentSubmission.count({ where }),
])
return { data, total, page: query.page, pageSize: query.pageSize, totalPages: Math.max(1, Math.ceil(total / query.pageSize)) }
}
export async function getManualPaymentSubmission(submissionId: string, adminId?: string) {
let submission = await prisma.manualPaymentSubmission.findUnique({
where: { id: submissionId },
include: {
invoice: {
include: {
company: { select: { id: true, name: true, email: true } },
subscription: true,
billingAccount: true,
lineItems: true,
},
},
documents: { where: { deletedAt: null }, orderBy: { uploadedAt: 'asc' } },
submittedByEmployee: { select: { id: true, firstName: true, lastName: true, email: true } },
reviewedByAdmin: { select: { id: true, firstName: true, lastName: true } },
},
})
if (!submission) throw new NotFoundError('Payment submission not found')
if (adminId && submission.status === 'SUBMITTED') {
await prisma.manualPaymentSubmission.updateMany({
where: { id: submission.id, status: 'SUBMITTED' },
data: { status: 'UNDER_REVIEW', reviewedByAdminId: adminId, reviewedAt: new Date() },
})
return getManualPaymentSubmission(submissionId)
}
return submission
}
export async function getAdminPaymentDocument(submissionId: string, documentId: string, adminId: string, ip?: string) {
const document = await prisma.manualPaymentDocument.findFirst({
where: { id: documentId, submissionId, deletedAt: null, scanStatus: 'CLEAN' },
include: { submission: { include: { invoice: true } } },
})
if (!document || document.submission.invoiceId !== document.invoiceId) throw new NotFoundError('Clean payment evidence document not found')
await prisma.auditLog.create({
data: {
adminUserId: adminId,
action: 'VIEW_MANUAL_PAYMENT_EVIDENCE',
resource: 'ManualPaymentDocument',
resourceId: document.id,
companyId: document.companyId,
ipAddress: ip,
after: { submissionId, invoiceId: document.invoiceId, sha256: document.sha256 },
},
})
return { document, bytes: readPrivateDocument(document.storageKey) }
}
export async function rejectManualPaymentSubmission(submissionId: string, reason: string, adminId: string, ip?: string) {
const updated = await prisma.$transaction(async (tx: any) => {
const current = await tx.manualPaymentSubmission.findUnique({
where: { id: submissionId },
include: { invoice: true },
})
if (!current) throw new NotFoundError('Payment submission not found')
if (!['SUBMITTED', 'UNDER_REVIEW'].includes(current.status)) throw new ConflictError('Submission cannot be rejected in its current state')
const reviewedAt = new Date()
const updated = await tx.manualPaymentSubmission.update({
where: { id: current.id },
data: { status: 'REJECTED', rejectionReason: reason, reviewedByAdminId: adminId, reviewedAt },
include: { documents: { where: { deletedAt: null } } },
})
await tx.billingEvent.create({
data: {
billingAccountId: current.billingAccountId,
invoiceId: current.invoiceId,
subscriptionId: current.invoice.subscriptionId,
companyId: current.companyId,
eventType: 'payment_evidence.rejected',
source: 'admin',
payload: { submissionId, reason, adminId },
occurredAt: reviewedAt,
},
})
await tx.auditLog.create({
data: {
adminUserId: adminId,
action: 'REJECT_MANUAL_PAYMENT_EVIDENCE',
resource: 'ManualPaymentSubmission',
resourceId: submissionId,
companyId: current.companyId,
before: { status: current.status },
after: { status: 'REJECTED', reason },
ipAddress: ip,
},
})
return { ...updated, invoice: current.invoice }
})
await notifyPaymentResult({
billingAccountId: updated.billingAccountId,
companyId: updated.companyId,
invoiceId: updated.invoiceId,
invoiceNumber: updated.invoice.invoiceNumber,
kind: 'rejected',
sourceId: updated.id,
reason,
})
return updated
}
export async function confirmManualPayment(invoiceId: string, data: {
submissionId: string
method: 'BANK_TRANSFER' | 'CHECK'
externalReference: string
amount: number
receivedAt: string
note?: string
correctionReason?: string
idempotencyKey: string
fundsVerified: true
}, adminId: string, ip?: string) {
const normalizedReference = normalizeExternalReference(data.externalReference)
const normalizedPayload = {
invoiceId,
submissionId: data.submissionId,
method: data.method,
normalizedReference,
amount: data.amount,
receivedAt: new Date(data.receivedAt).toISOString(),
note: data.note ?? null,
correctionReason: data.correctionReason ?? null,
fundsVerified: true,
}
const hash = requestHash(normalizedPayload)
const receivedAt = new Date(data.receivedAt)
if (receivedAt.getTime() > Date.now() + 5 * 60 * 1000) throw new ValidationError('Settlement time cannot be in the future')
let collectionsCaseId: string | null = null
try {
const result = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId },
include: {
billingAccount: true,
subscription: true,
legacySubscriptionInvoice: true,
collectionsCase: true,
manualPaymentSubmissions: {
where: { id: data.submissionId },
include: { documents: { where: { deletedAt: null } } },
},
},
})
if (!current) throw new NotFoundError('Invoice not found')
if (current.invoiceType === 'SUBSCRIPTION_UPGRADE') {
throw new ValidationError('Subscription upgrade invoices must be approved through the upgrade review workflow')
}
const duplicate = await tx.billingPaymentAttempt.findFirst({
where: { billingAccountId: current.billingAccountId, idempotencyKey: data.idempotencyKey },
include: { invoice: true },
})
if (duplicate) {
const metadata = duplicate.metadata as any
if (duplicate.invoiceId !== invoiceId || metadata?.confirmationRequestHash !== hash) {
throw new ConflictError('Idempotency key was already used with a different payment confirmation')
}
return { invoice: duplicate.invoice, paymentAttempt: duplicate, duplicate: true }
}
if (!PAYABLE_STATUSES.includes(current.status)) throw new ConflictError('Invoice is not payable in its current state')
if (current.collectionMethod !== data.method) throw new ValidationError('Payment method must match the invoice collection method')
if (current.currency !== 'MAD') throw new ValidationError('Manual subscription confirmation requires MAD currency')
if (data.amount !== current.amountDue || data.amount !== current.totalAmount - current.amountPaid) {
throw new ConflictError('The full current invoice balance must be confirmed')
}
const submission = current.manualPaymentSubmissions[0]
if (!submission || submission.invoiceId !== current.id || submission.companyId !== current.companyId) {
throw new ValidationError('Submission does not belong to this invoice')
}
if (!['SUBMITTED', 'UNDER_REVIEW'].includes(submission.status)) throw new ConflictError('Evidence is not awaiting review')
if (submission.method !== data.method) throw new ValidationError('Submission method does not match the confirmation method')
if (!submission.documents.length || submission.documents.some((document: any) => document.scanStatus !== 'CLEAN')) {
throw new ValidationError('Every attached evidence document must be clean')
}
if (submission.normalizedSubmittedReference !== normalizedReference && !data.correctionReason) {
throw new ValidationError('A correction reason is required when the confirmed reference differs from the submitted reference')
}
const confirmedAt = new Date()
const intent = await tx.billingPaymentIntent.create({
data: {
invoiceId: current.id,
billingAccountId: current.billingAccountId,
status: 'SUCCEEDED',
amount: data.amount,
currency: current.currency,
metadata: { source: 'admin_manual_subscription_confirmation', method: data.method },
},
})
const attempt = await tx.billingPaymentAttempt.create({
data: {
invoiceId: current.id,
billingAccountId: current.billingAccountId,
paymentIntentId: intent.id,
channel: 'OFFLINE',
manualMethod: data.method,
externalReference: data.externalReference,
normalizedExternalReference: normalizedReference,
receivedAt,
confirmedAt,
confirmedByAdminId: adminId,
idempotencyKey: data.idempotencyKey,
note: data.note ?? null,
status: 'SUCCEEDED',
amount: data.amount,
currency: current.currency,
attemptedAt: confirmedAt,
metadata: {
source: 'admin_manual_subscription_confirmation',
submissionId: submission.id,
confirmationRequestHash: hash,
correctionReason: data.correctionReason ?? null,
fundsVerified: true,
},
},
})
const paid = await tx.billingInvoice.updateMany({
where: { id: current.id, status: { in: PAYABLE_STATUSES }, amountDue: data.amount },
data: { status: 'PAID', amountPaid: { increment: data.amount }, amountDue: 0, paidAt: confirmedAt },
})
if (paid.count !== 1) throw new ConflictError('Invoice changed while payment was being confirmed')
await tx.manualPaymentSubmission.update({
where: { id: submission.id },
data: {
status: 'APPROVED',
reviewedByAdminId: adminId,
reviewedAt: confirmedAt,
paymentAttemptId: attempt.id,
},
})
if (current.legacySubscriptionInvoice) {
await tx.subscriptionInvoice.update({
where: { id: current.legacySubscriptionInvoice.id },
data: { status: 'PAID', paidAt: confirmedAt, failedAt: null },
})
}
if (!current.subscription) throw new ValidationError('Subscription invoice is missing its subscription')
const period = (current.requestedBillingPeriod ?? current.subscription.billingPeriod) as 'MONTHLY' | 'ANNUAL'
const isRenewal = current.invoiceType === 'SUBSCRIPTION_RENEWAL'
const periodStart = isRenewal
? (current.collectionsCase?.originalExpirationAt ?? current.subscription.currentPeriodEnd ?? confirmedAt)
: confirmedAt
await tx.subscription.update({
where: { id: current.subscription.id },
data: {
plan: current.requestedPlan ?? current.subscription.plan,
billingPeriod: period,
currency: current.currency,
status: 'ACTIVE',
currentPeriodStart: periodStart,
currentPeriodEnd: addBillingPeriod(periodStart, period),
paymentPendingSince: null,
paymentDueAt: null,
pastDueSince: null,
suspendedAt: null,
retryCount: 0,
},
})
if (current.collectionsCase) {
collectionsCaseId = current.collectionsCase.id
await tx.collectionsCase.update({
where: { id: current.collectionsCase.id },
data: {
status: 'RESOLVED',
resolvedAt: confirmedAt,
nextActionAt: null,
resolutionPaymentAttemptId: attempt.id,
},
})
await tx.collectionsCallTask.updateMany({
where: { collectionsCaseId: current.collectionsCase.id, status: 'OPEN' },
data: { status: 'CANCELLED', cancellationReason: 'PAYMENT_CONFIRMED' },
})
await tx.collectionsEvent.create({
data: {
collectionsCaseId: current.collectionsCase.id,
companyId: current.companyId,
eventType: 'collections.resolved',
idempotencyKey: `payment:${attempt.id}`,
actorType: 'admin',
actorId: adminId,
payload: { paymentAttemptId: attempt.id },
},
})
}
await tx.billingEvent.create({
data: {
billingAccountId: current.billingAccountId,
invoiceId: current.id,
subscriptionId: current.subscription.id,
companyId: current.companyId,
eventType: 'invoice.paid',
source: 'admin',
payload: { paymentAttemptId: attempt.id, method: data.method, submissionId: submission.id },
occurredAt: confirmedAt,
},
})
await tx.subscriptionEvent.create({
data: {
subscriptionId: current.subscription.id,
companyId: current.companyId,
eventType: 'subscription.activated',
source: 'admin',
payload: { invoiceId: current.id, paymentAttemptId: attempt.id },
occurredAt: confirmedAt,
},
})
await tx.auditLog.create({
data: {
adminUserId: adminId,
action: 'CONFIRM_MANUAL_SUBSCRIPTION_PAYMENT',
resource: 'BillingInvoice',
resourceId: current.id,
companyId: current.companyId,
before: { status: current.status, amountDue: current.amountDue },
after: { status: 'PAID', amountDue: 0, paymentAttemptId: attempt.id, method: data.method },
note: data.note,
ipAddress: ip,
},
})
const invoice = await tx.billingInvoice.findUniqueOrThrow({
where: { id: current.id },
include: { paymentAttempts: { orderBy: { attemptedAt: 'desc' } }, manualPaymentSubmissions: { include: { documents: true } } },
})
return { invoice, paymentAttempt: attempt, duplicate: false }
}, { isolationLevel: 'Serializable' as any })
if (collectionsCaseId) {
await prisma.notificationOutbox.updateMany({
where: {
status: 'PENDING',
notificationEvent: { sourceType: 'collections_case', sourceId: collectionsCaseId },
},
data: { status: 'PUBLISHED', failureReason: 'Suppressed because payment was confirmed' },
})
}
await notifyPaymentResult({
billingAccountId: result.invoice.billingAccountId,
companyId: result.invoice.companyId,
invoiceId: result.invoice.id,
invoiceNumber: result.invoice.invoiceNumber,
kind: 'confirmed',
sourceId: result.paymentAttempt.id,
})
return result
} catch (error: any) {
if (error?.code === 'P2002' || error?.code === 'P2034') {
throw new ConflictError('The payment reference, idempotency key, or invoice was confirmed concurrently')
}
throw error
}
}
@@ -9,13 +9,26 @@ describe('admin.presenter', () => {
role: 'SUPER_ADMIN',
passwordHash: 'hash',
totpSecret: 'secret',
passwordResetToken: 'reset-token',
passwordResetExpiresAt: new Date('2026-01-01T00:00:00.000Z'),
emailVerificationToken: 'verify-token',
})
expect(result).toEqual({ id: 'admin_1', email: 'admin@example.com', role: 'SUPER_ADMIN' })
})
it('wraps sessions without leaking credentials', () => {
expect(presentAdminSession({ id: 'admin_1', passwordHash: 'hash', totpSecret: 'secret' }, 'jwt-token')).toEqual({
expect(
presentAdminSession(
{
id: 'admin_1',
passwordHash: 'hash',
totpSecret: 'secret',
passwordResetToken: 'reset-token',
},
'jwt-token',
),
).toEqual({
token: 'jwt-token',
admin: { id: 'admin_1' },
})
+12 -1
View File
@@ -1,5 +1,16 @@
const ADMIN_SECRET_FIELDS = [
'passwordHash',
'totpSecret',
'passwordResetToken',
'passwordResetExpiresAt',
'emailVerificationToken',
] as const
export function presentAdminUser<T extends Record<string, any>>(admin: T) {
const { passwordHash, totpSecret, ...safe } = admin
const safe = { ...admin }
for (const field of ADMIN_SECRET_FIELDS) {
delete (safe as Record<string, unknown>)[field]
}
return safe
}
@@ -11,6 +11,7 @@ vi.mock('../../lib/prisma', () => ({
}))
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import * as repo from './admin.repo'
describe('admin.repo edge queries', () => {
@@ -62,7 +63,7 @@ describe('admin.repo edge queries', () => {
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
where: {
passwordResetToken: 'reset-token',
passwordResetToken: hashPublicAccessToken('reset-token'),
passwordResetExpiresAt: { gt: new Date('2026-06-01T00:00:00.000Z') },
},
})
+140 -18
View File
@@ -1,4 +1,5 @@
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
const companyListInclude = {
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
@@ -58,6 +59,10 @@ export function enableAdminTotp(id: string) {
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
}
export function enableAdminEmail2fa(id: string) {
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true, totpSecret: null } })
}
export async function replaceAdminRecoveryCodes(adminUserId: string, codeHashes: string[]) {
return prisma.$transaction(async (tx) => {
@@ -80,17 +85,18 @@ export function markAdminRecoveryCodeUsed(id: string) {
return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } })
}
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
export function setAdminPasswordReset(id: string, tokenHash: string, expiresAt: Date) {
return prisma.adminUser.update({
where: { id },
data: { passwordResetToken: token, passwordResetExpiresAt: expiresAt },
data: { passwordResetToken: tokenHash, passwordResetExpiresAt: expiresAt },
})
}
export function findAdminByResetToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.adminUser.findFirst({
where: {
passwordResetToken: token,
passwordResetToken: tokenHash,
passwordResetExpiresAt: { gt: new Date() },
},
})
@@ -155,12 +161,19 @@ export async function applyCompanyUpdate(
name: string
slug: string
address?: unknown
brand?: { paymentMethodsEnabled?: any[] | null } | null
brand?: Record<string, unknown> | null
},
) {
return prisma.$transaction(async (tx: any) => {
if (body.company) {
const companyData = { ...body.company }
if (typeof companyData.slug === 'string') {
companyData.slug = companyData.slug
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50) || 'company'
}
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address)
? current.address as Record<string, unknown>
@@ -206,7 +219,6 @@ export async function applyCompanyUpdate(
companyId: id,
displayName: body.brand.displayName ?? current.name,
subdomain: body.brand.subdomain ?? current.slug,
paymentMethodsEnabled: current.brand?.paymentMethodsEnabled ?? [],
...body.brand,
} as any,
})
@@ -343,6 +355,7 @@ export function createAdmin(data: {
firstName: string
lastName: string
role: string
preferredLocale: string
passwordHash: string
permissions?: any[]
}) {
@@ -352,6 +365,7 @@ export function createAdmin(data: {
firstName: data.firstName,
lastName: data.lastName,
role: data.role as any,
preferredLocale: data.preferredLocale,
passwordHash: data.passwordHash,
permissions: data.permissions ? { create: data.permissions } : undefined,
},
@@ -370,6 +384,7 @@ export function updateAdmin(
firstName?: string
lastName?: string
role?: string
preferredLocale?: string
passwordHash?: string
isActive?: boolean
},
@@ -381,6 +396,7 @@ export function updateAdmin(
...(data.firstName !== undefined ? { firstName: data.firstName } : {}),
...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
...(data.role !== undefined ? { role: data.role as any } : {}),
...(data.preferredLocale !== undefined ? { preferredLocale: data.preferredLocale } : {}),
...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}),
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
},
@@ -490,7 +506,7 @@ export function listPlanFeatures() {
})
}
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder?: number }) {
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; label: string; sortOrder?: number }) {
return prisma.planFeature.create({
data: {
plan: data.plan,
@@ -500,7 +516,7 @@ export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; la
})
}
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder: number }>) {
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; label: string; sortOrder: number }>) {
return prisma.planFeature.update({
where: { id },
data,
@@ -562,20 +578,126 @@ export async function listNotificationsPage(query: {
page: number
pageSize: number
}) {
const where: any = {}
if (query.channel) where.channel = query.channel
if (query.status) where.status = query.status
if (query.companyId) where.companyId = query.companyId
const legacyStatuses = new Set(['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ'])
const legacyWhere: any = {}
if (query.channel) legacyWhere.channel = query.channel
if (query.status && legacyStatuses.has(query.status)) {
legacyWhere.status = query.status
} else if (query.status) {
legacyWhere.id = '__delivery_status_only__'
}
if (query.companyId) legacyWhere.companyId = query.companyId
const [data, total] = await Promise.all([
const deliveryWhere: any = {}
if (query.channel) deliveryWhere.channel = query.channel
if (query.companyId) {
deliveryWhere.notificationRecipient = {
notificationEvent: { companyId: query.companyId },
}
}
if (query.status === 'READ') {
deliveryWhere.notificationRecipient = {
...(deliveryWhere.notificationRecipient ?? {}),
readAt: { not: null },
}
} else if (query.status) {
deliveryWhere.status = query.status
deliveryWhere.notificationRecipient = {
...(deliveryWhere.notificationRecipient ?? {}),
readAt: null,
}
}
const take = query.page * query.pageSize
const [legacyNotifications, deliveryNotifications, legacyTotal, deliveryTotal] = await Promise.all([
prisma.notification.findMany({
where,
where: legacyWhere,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
include: { company: { select: { name: true } } },
take,
include: {
company: { select: { id: true, name: true } },
employee: { select: { id: true, firstName: true, lastName: true, email: true } },
renter: { select: { id: true, firstName: true, lastName: true, email: true } },
},
}),
prisma.notification.count({ where }),
prisma.notificationDelivery.findMany({
where: deliveryWhere,
orderBy: { createdAt: 'desc' },
take,
include: {
notificationRecipient: {
include: {
employee: { select: { id: true, firstName: true, lastName: true, email: true } },
renter: { select: { id: true, firstName: true, lastName: true, email: true } },
notificationEvent: {
include: {
company: { select: { id: true, name: true } },
},
},
},
},
},
}),
prisma.notification.count({ where: legacyWhere }),
prisma.notificationDelivery.count({ where: deliveryWhere }),
])
return { data, total }
const legacyRows = legacyNotifications.map((notification: any) => {
const recipient = notification.employee ?? notification.renter ?? null
return {
id: `legacy:${notification.id}`,
notificationId: notification.id,
deliveryId: null,
source: 'LEGACY',
type: notification.type,
title: notification.title,
body: notification.body,
channel: notification.channel,
status: notification.status,
locale: notification.locale,
sentAt: notification.sentAt,
createdAt: notification.createdAt,
company: notification.company,
companyId: notification.companyId,
recipientType: notification.employeeId ? 'EMPLOYEE' : notification.renterId ? 'RENTER' : null,
recipientName: recipient ? `${recipient.firstName} ${recipient.lastName}`.trim() : null,
recipientEmail: recipient?.email ?? null,
employeeId: notification.employeeId,
renterId: notification.renterId,
}
})
const deliveryRows = deliveryNotifications.map((delivery: any) => {
const recipientRecord = delivery.notificationRecipient
const event = recipientRecord.notificationEvent
const recipient = recipientRecord.employee ?? recipientRecord.renter ?? null
return {
id: `delivery:${delivery.id}`,
notificationId: event.id,
deliveryId: delivery.id,
source: 'DELIVERY',
type: event.type,
title: event.title,
body: event.body,
channel: delivery.channel,
status: recipientRecord.readAt ? 'READ' : delivery.status,
locale: event.locale,
sentAt: delivery.sentAt ?? delivery.deliveredAt ?? delivery.lastAttemptAt,
createdAt: delivery.createdAt,
company: event.company,
companyId: event.companyId,
recipientType: recipientRecord.recipientType,
recipientName: recipient ? `${recipient.firstName} ${recipient.lastName}`.trim() : null,
recipientEmail: recipient?.email ?? null,
employeeId: recipientRecord.employeeId,
renterId: recipientRecord.renterId,
}
})
const data = [...legacyRows, ...deliveryRows]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice((query.page - 1) * query.pageSize, query.page * query.pageSize)
return { data, total: legacyTotal + deliveryTotal }
}
+170 -4
View File
@@ -1,14 +1,18 @@
import { Router } from 'express'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from '../../middleware/requireAdminAuth'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA, requireFreshAdmin2FAWhenEnabled } from '../../middleware/requireAdminAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
import * as service from './admin.service'
import * as subService from '../subscriptions/subscription.service'
import * as menuService from '../menu/menu.service'
import * as manualPaymentsService from './admin.manual-payments.service'
import * as collectionsService from '../subscriptions/subscription.collections.service'
import * as upgradeService from '../subscriptions/subscription.upgrade.service'
import { getAdminNotificationInbox, markAdminNotificationRead } from '../../services/notificationService'
import { presentAdminUser } from './admin.presenter'
import {
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema, email2faVerifySchema,
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema,
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
@@ -17,9 +21,15 @@ import {
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema,
platformBillingSettingsSchema,
retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema,
menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema,
menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema,
manualPaymentSubmissionIdParamSchema, manualPaymentDocumentParamsSchema,
manualPaymentSubmissionsQuerySchema, rejectManualPaymentSubmissionSchema, confirmManualPaymentSchema,
upgradeRequestsQuerySchema, upgradeRequestIdParamSchema, upgradeCorrectionSchema, upgradeRejectSchema, approveUpgradePaymentSchema,
collectionsQuerySchema, collectionsCaseIdParamSchema, collectionTaskIdParamSchema,
collectionsOverrideParamsSchema, collectionsAssigneeSchema, collectionTaskOutcomeSchema, collectionsOverrideSchema,
} from './admin.schemas'
import { z } from 'zod'
@@ -45,8 +55,8 @@ router.post('/auth/login', async (req, res, next) => {
const { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
const result = await service.login(email, password, totpCode, recoveryCode)
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
clearSessionCookie(res, 'employee')
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
@@ -80,12 +90,28 @@ router.get('/auth/me', requireAdminAuth, (req, res) => {
ok(res, presentAdminUser(req.admin as any))
})
router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => {
router.post('/auth/2fa/setup', requireAdminAuth, requireFreshAdmin2FAWhenEnabled, async (req, res, next) => {
try {
ok(res, await service.setupTotp(req.admin.id, req.admin.email))
} catch (err) { next(err) }
})
router.post('/auth/2fa/email/setup', requireAdminAuth, requireFreshAdmin2FAWhenEnabled, async (req, res, next) => {
try {
ok(res, await service.setupEmail2fa(req.admin.id))
} catch (err) { next(err) }
})
router.post('/auth/2fa/email/verify', requireAdminAuth, requireFreshAdmin2FAWhenEnabled, async (req, res, next) => {
try {
const { code } = parseBody(email2faVerifySchema, req)
const result = await service.verifyEmail2fa(req.admin.id, code)
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid email verification code', statusCode: 400 })
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, { success: true, admin: result.admin, recoveryCodes: result.recoveryCodes })
} catch (err) { next(err) }
})
router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
try {
const { code } = parseBody(totpVerifySchema, req)
@@ -275,6 +301,17 @@ router.get('/notifications', requireAdminAuth, requireAdminRole('SUPPORT'), asyn
} catch (err) { next(err) }
})
router.get('/notifications/me', requireAdminAuth, async (req, res, next) => {
try { ok(res, await getAdminNotificationInbox(req.admin.id)) } catch (err) { next(err) }
})
router.post('/notifications/me/:id/read', requireAdminAuth, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await markAdminNotificationRead(req.admin.id, id))
} catch (err) { next(err) }
})
// ─── Audit logs ────────────────────────────────────────────────
router.get('/audit-logs', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
@@ -340,6 +377,135 @@ router.get('/billing/invoices/:invoiceId/pdf', requireAdminAuth, requireAdminRol
} catch (err) { next(err) }
})
router.get('/billing/manual-payment-submissions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try { ok(res, await manualPaymentsService.listManualPaymentSubmissions(parseQuery(manualPaymentSubmissionsQuerySchema, req))) } catch (err) { next(err) }
})
router.get('/billing/manual-payment-submissions/:submissionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { submissionId } = parseParams(manualPaymentSubmissionIdParamSchema, req)
ok(res, await manualPaymentsService.getManualPaymentSubmission(submissionId, req.admin.id))
} catch (err) { next(err) }
})
router.get('/billing/manual-payment-submissions/:submissionId/documents/:documentId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { submissionId, documentId } = parseParams(manualPaymentDocumentParamsSchema, req)
const { document, bytes } = await manualPaymentsService.getAdminPaymentDocument(submissionId, documentId, req.admin.id, req.ip)
const safeName = document.originalFilename.replace(/["\\\r\n]/g, '_')
res.setHeader('Content-Type', document.detectedMimeType)
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`)
res.setHeader('Content-Length', bytes.length)
res.setHeader('Cache-Control', 'private, no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.end(bytes)
} catch (err) { next(err) }
})
router.post('/billing/manual-payment-submissions/:submissionId/reject', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { submissionId } = parseParams(manualPaymentSubmissionIdParamSchema, req)
const { reason } = parseBody(rejectManualPaymentSubmissionSchema, req)
ok(res, await manualPaymentsService.rejectManualPaymentSubmission(submissionId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/manual-payments', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await manualPaymentsService.confirmManualPayment(invoiceId, parseBody(confirmManualPaymentSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.get('/billing/upgrade-requests', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { status } = parseQuery(upgradeRequestsQuerySchema, req)
ok(res, await upgradeService.listAdminUpgradeRequests(status))
} catch (err) { next(err) }
})
router.post('/billing/upgrade-requests/:requestId/request-correction', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
const { reason } = parseBody(upgradeCorrectionSchema, req)
ok(res, await upgradeService.requestUpgradeCorrection(requestId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/upgrade-requests/:requestId/reject-payment', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
const { reason } = parseBody(upgradeRejectSchema, req)
ok(res, await upgradeService.rejectUpgradePayment(requestId, reason, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/upgrade-requests/:requestId/approve-payment', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
ok(res, await upgradeService.approveUpgradePayment(requestId, parseBody(approveUpgradePaymentSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.post('/billing/upgrade-requests/:requestId/retry-activation', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { requestId } = parseParams(upgradeRequestIdParamSchema, req)
ok(res, await upgradeService.retryUpgradeActivation(requestId, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.get('/billing/collections', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try { ok(res, await collectionsService.listCollectionsCases(parseQuery(collectionsQuerySchema, req))) } catch (err) { next(err) }
})
router.get('/billing/collections/:caseId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
ok(res, await collectionsService.getCollectionsCase(caseId))
} catch (err) { next(err) }
})
router.patch('/billing/collections/:caseId/assignee', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
const { adminId } = parseBody(collectionsAssigneeSchema, req)
ok(res, await collectionsService.assignCollectionsCase(caseId, adminId, req.admin.id))
} catch (err) { next(err) }
})
router.post('/billing/collection-tasks/:taskId/outcomes', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { taskId } = parseParams(collectionTaskIdParamSchema, req)
ok(res, await collectionsService.recordCallOutcome(taskId, parseBody(collectionTaskOutcomeSchema, req), req.admin.id))
} catch (err) { next(err) }
})
router.post('/billing/collections/:caseId/overrides', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { caseId } = parseParams(collectionsCaseIdParamSchema, req)
ok(res, await collectionsService.createCollectionsOverride(caseId, parseBody(collectionsOverrideSchema, req), req.admin.id))
} catch (err) { next(err) }
})
router.post('/billing/collections/:caseId/overrides/:overrideId/revoke', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { caseId, overrideId } = parseParams(collectionsOverrideParamsSchema, req)
ok(res, await collectionsService.revokeCollectionsOverride(caseId, overrideId, req.admin.id))
} catch (err) { next(err) }
})
router.get('/billing/platform-settings', requireAdminAuth, requireAdminRole('FINANCE'), async (_req, res, next) => {
try {
ok(res, await service.getBillingPlatformSettings())
} catch (err) { next(err) }
})
router.patch('/billing/platform-settings', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.updateBillingPlatformSettings(parseBody(platformBillingSettingsSchema, req), req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.get('/billing/:companyId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { companyId } = parseParams(companyIdParamSchema, req)
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { notificationsQuerySchema } from './admin.schemas'
describe('admin notification schemas', () => {
it('accepts valid notification filters with pagination defaults', () => {
expect(notificationsQuerySchema.parse({
channel: 'EMAIL',
status: 'QUEUED',
companyId: 'company_1',
page: '2',
})).toEqual({
channel: 'EMAIL',
status: 'QUEUED',
companyId: 'company_1',
page: 2,
pageSize: 50,
})
})
it('rejects invalid notification enum filters before querying Prisma', () => {
expect(notificationsQuerySchema.safeParse({ channel: 'FAX' }).success).toBe(false)
expect(notificationsQuerySchema.safeParse({ status: 'BOUNCED' }).success).toBe(false)
})
})
+174 -26
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { contactPhoneField, countryCodeField, languageSchema, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
import type {
CarplaceHomepageContent,
CarplaceHomepageHowItWorksStep,
@@ -10,14 +11,14 @@ import type {
} from '@rentaldrivego/types'
export const loginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
email: z.string().email().max(254).trim().toLowerCase(),
password: z.string().max(128),
totpCode: z.string().length(6).optional(),
recoveryCode: z.string().min(8).max(32).optional(),
})
export const forgotPasswordSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
email: z.string().email().max(254).trim().toLowerCase(),
})
export const resetPasswordSchema = z.object({
@@ -29,6 +30,10 @@ export const totpVerifySchema = z.object({
code: z.string().length(6),
})
export const email2faVerifySchema = z.object({
code: z.string().length(6),
})
export const companiesQuerySchema = z.object({
q: z.string().optional(),
status: z.string().optional(),
@@ -53,9 +58,12 @@ export const auditLogQuerySchema = z.object({
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
const notificationChannelSchema = z.enum(['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH'])
const notificationStatusSchema = z.enum(['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ'])
export const notificationsQuerySchema = z.object({
channel: z.string().optional(),
status: z.string().optional(),
channel: notificationChannelSchema.optional(),
status: notificationStatusSchema.optional(),
companyId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
@@ -80,21 +88,38 @@ export const permissionSchema = z.object({
})
export const createAdminSchema = z.object({
email: z.string().email().trim().toLowerCase(),
firstName: z.string().min(1).max(100).trim(),
lastName: z.string().min(1).max(100).trim(),
email: z.string().email().max(254).trim().toLowerCase(),
firstName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')),
lastName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
preferredLocale: languageSchema.default('en'),
password: z.string().min(8),
permissions: z.array(permissionSchema).optional(),
}).superRefine((data, ctx) => {
if (!validateLocalizedText(data.firstName, data.preferredLocale, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
}
if (!validateLocalizedText(data.lastName, data.preferredLocale, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
}
})
export const updateAdminSchema = z.object({
email: z.string().email().trim().toLowerCase().optional(),
firstName: z.string().min(1).max(100).trim().optional(),
lastName: z.string().min(1).max(100).trim().optional(),
email: z.string().email().max(254).trim().toLowerCase().optional(),
firstName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')).optional(),
lastName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')).optional(),
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
preferredLocale: languageSchema.optional(),
password: z.string().min(8).optional(),
isActive: z.boolean().optional(),
}).superRefine((data, ctx) => {
const language = data.preferredLocale ?? 'en'
if (data.firstName && !validateLocalizedText(data.firstName, language, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
}
if (data.lastName && !validateLocalizedText(data.lastName, language, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
}
})
export const adminRoleSchema = z.object({
@@ -111,20 +136,26 @@ export const companyStatusSchema = z.object({
})
const nullableString = z.union([z.string(), z.null()]).optional()
const nullableEmail = z.union([z.string().email(), z.null()]).optional()
const nullableEmail = z.union([z.string().email().max(254).trim().toLowerCase(), z.null()]).optional()
const nullableUrl = z.union([z.string().url(), z.null()]).optional()
const nullableDate = z.union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.null()]).optional()
export const adminCompanyUpdateSchema = z.object({
company: z.object({
name: z.string().min(1).optional(), slug: z.string().min(1).optional(),
email: z.string().email().optional(), phone: nullableString,
name: z.string().min(1).optional(),
slug: z
.string()
.min(1)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug must be lowercase alphanumeric with optional hyphens')
.max(50)
.optional(),
email: z.string().email().max(254).trim().toLowerCase().optional(), phone: z.union([contactPhoneField(), z.null()]).optional(),
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
subscriptionPaymentRef: nullableString,
address: z.object({
streetAddress: nullableString,
city: nullableString,
country: nullableString,
city: z.union([z.string().trim().max(85), z.null()]).optional(),
country: z.union([countryCodeField(), z.null()]).optional(),
zipCode: nullableString,
legalName: nullableString,
legalForm: nullableString,
@@ -142,12 +173,12 @@ export const adminCompanyUpdateSchema = z.object({
responsibleRole: nullableString,
responsibleIdentityNumber: nullableString,
responsibleQualification: nullableString,
responsiblePhone: nullableString,
responsiblePhone: z.union([contactPhoneField(), z.null()]).optional(),
responsibleEmail: nullableEmail,
}).optional(),
}).optional(),
subscription: z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']).optional(),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
currency: z.literal('MAD').optional(),
@@ -158,9 +189,9 @@ export const adminCompanyUpdateSchema = z.object({
brand: z.object({
displayName: z.string().min(1).optional(), tagline: nullableString,
subdomain: z.string().min(1).optional(), customDomain: nullableString,
publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString,
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
publicEmail: nullableEmail, publicPhone: z.union([contactPhoneField(), z.null()]).optional(), publicAddress: z.union([z.string().trim().max(255), z.null()]).optional(),
publicCity: z.union([z.string().trim().max(85), z.null()]).optional(), publicCountry: z.union([countryCodeField(), z.null()]).optional(), websiteUrl: nullableUrl,
whatsappNumber: z.union([contactPhoneField(), z.null()]).optional(), defaultLocale: languageSchema.optional(),
defaultCurrency: z.literal('MAD').optional(),
isListedOnCarplace: z.boolean().optional(),
homePageConfig: z.any().optional(),
@@ -170,7 +201,7 @@ export const adminCompanyUpdateSchema = z.object({
legalName: nullableString, registrationNumber: nullableString, taxId: nullableString,
terms: z.string().optional(),
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
lateFeePerHour: z.number().int().nullable().optional(), taxRate: z.number().nullable().optional(),
lateFeePerHour: z.number().int().nullable().optional(),
signatureRequired: z.boolean().optional(), showTax: z.boolean().optional(),
}).optional(),
accountingSettings: z.object({
@@ -232,7 +263,7 @@ export const invoiceIdParamSchema = z.object({
export const billingAccountUpdateSchema = z.object({
legalName: z.string().min(1).max(255).optional(),
billingEmail: z.string().email().optional(),
billingEmail: z.string().email().max(254).trim().toLowerCase().optional(),
billingAddress: z.any().optional(),
taxId: z.union([z.string().max(120), z.null()]).optional(),
taxExempt: z.boolean().optional(),
@@ -240,6 +271,10 @@ export const billingAccountUpdateSchema = z.object({
netTermsDays: z.number().int().min(0).max(365).optional(),
})
export const platformBillingSettingsSchema = z.object({
taxRate: z.number().min(0).max(100),
})
export const billingLineItemInputSchema = z.object({
type: z.enum([
'SUBSCRIPTION_FEE',
@@ -289,6 +324,119 @@ export const payBillingInvoiceSchema = z.object({
providerPaymentId: z.union([z.string(), z.null()]).optional(),
})
const manualPaymentReferenceSchema = z.string()
.trim()
.min(3)
.max(120)
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Reference contains unsupported control characters')
export const manualPaymentSubmissionIdParamSchema = z.object({ submissionId: z.string().min(1) })
export const manualPaymentDocumentParamsSchema = z.object({
submissionId: z.string().min(1),
documentId: z.string().min(1),
})
export const manualPaymentSubmissionsQuerySchema = z.object({
status: z.enum(['SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED']).default('SUBMITTED'),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
export const rejectManualPaymentSubmissionSchema = z.object({
reason: z.string().trim().min(3).max(500),
})
export const confirmManualPaymentSchema = z.object({
submissionId: z.string().min(1),
method: z.enum(['BANK_TRANSFER', 'CHECK']),
externalReference: manualPaymentReferenceSchema,
amount: z.number().int().positive(),
receivedAt: z.string().datetime(),
note: z.string().trim().max(500).optional(),
correctionReason: z.string().trim().min(3).max(500).optional(),
idempotencyKey: z.string().uuid(),
fundsVerified: z.literal(true),
})
export const upgradeRequestsQuerySchema = z.object({
status: z.enum([
'DRAFT',
'QUOTED',
'PAYMENT_PENDING',
'PAYMENT_REVIEW',
'CORRECTION_REQUIRED',
'APPROVED',
'ACTIVATED',
'SCHEDULED',
'REJECTED',
'EXPIRED',
'CANCELLED',
'ACTIVATION_FAILED',
'SUPERSEDED',
]).optional(),
})
export const upgradeRequestIdParamSchema = z.object({ requestId: z.string().min(1) })
export const upgradeCorrectionSchema = z.object({
reason: z.string().trim().min(3).max(500),
})
export const upgradeRejectSchema = z.object({
reason: z.string().trim().min(3).max(500),
})
export const approveUpgradePaymentSchema = z.object({
submissionId: z.string().min(1),
method: z.enum(['BANK_TRANSFER', 'CHECK']),
externalReference: manualPaymentReferenceSchema,
amount: z.number().int().positive(),
receivedAt: z.string().datetime(),
note: z.string().trim().max(500).optional(),
idempotencyKey: z.string().uuid(),
fundsVerified: z.literal(true),
})
export const collectionsQuerySchema = z.object({
status: z.enum(['SCHEDULED', 'PRE_DUE', 'GRACE_PERIOD', 'RESOLVED', 'SUSPENDED']).optional(),
assignedTo: z.string().optional(),
actionDueBefore: z.string().datetime().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
export const collectionsCaseIdParamSchema = z.object({ caseId: z.string().min(1) })
export const collectionTaskIdParamSchema = z.object({ taskId: z.string().min(1) })
export const collectionsOverrideParamsSchema = z.object({ caseId: z.string().min(1), overrideId: z.string().min(1) })
export const collectionsAssigneeSchema = z.object({ adminId: z.string().min(1) })
export const collectionTaskOutcomeSchema = z.object({
outcome: z.enum(['CONTACTED', 'NO_ANSWER', 'PAYMENT_PROMISED', 'ISSUE_ESCALATED']),
note: z.string().trim().max(500).optional(),
promisedPaymentAt: z.string().datetime().optional(),
nextFollowUpAt: z.string().datetime().optional(),
}).superRefine((data, ctx) => {
if (['NO_ANSWER', 'ISSUE_ESCALATED'].includes(data.outcome) && !data.note) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['note'], message: 'A note is required for this outcome' })
}
if (data.outcome === 'PAYMENT_PROMISED' && (!data.promisedPaymentAt || !data.nextFollowUpAt)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['promisedPaymentAt'], message: 'Promised payment and follow-up times are required' })
}
})
export const collectionsOverrideSchema = z.object({
type: z.enum(['PAYMENT_DISPUTE', 'MANUAL_EXTENSION']),
reason: z.string().trim().min(3).max(500),
expiresAt: z.string().datetime(),
revisedSuspensionAt: z.string().datetime().optional(),
pauseSuspension: z.boolean().default(true),
pauseNotifications: z.boolean().default(false),
}).superRefine((data, ctx) => {
if (data.type === 'MANUAL_EXTENSION' && !data.revisedSuspensionAt) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['revisedSuspensionAt'], message: 'A manual extension requires a revised suspension time' })
}
})
export const retryBillingInvoiceSchema = z.object({
paymentMethodId: z.union([z.string(), z.null()]).optional(),
})
@@ -313,14 +461,14 @@ export const homepageUpdateSchema = z.object({
export const pricingUpdateSchema = z.object({
entries: z.array(z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
amount: z.number().int().positive(),
})).min(1),
})
const planFeatureSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
label: z.string().min(1).max(120),
sortOrder: z.number().int().min(0).default(0),
})
@@ -333,7 +481,7 @@ export const promotionCreateSchema = z.object({
description: z.string().max(500).optional(),
discountType: z.enum(['PERCENTAGE', 'FIXED']),
discountValue: z.number().int().positive(),
plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO'])),
plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])),
periods: z.array(z.enum(['MONTHLY', 'ANNUAL'])),
maxUses: z.number().int().positive().nullable().optional(),
validFrom: z.string().datetime(),
@@ -347,7 +495,7 @@ export const planFeatureIdParamSchema = z.object({ featureId: z.string().min(1)
export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) })
const employeeRoleSchema = z.enum(['OWNER', 'MANAGER', 'AGENT'])
const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO'])
const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
const menuItemTypeSchema = z.enum(['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER'])
export const menuItemSchema = z.object({
@@ -1,28 +1,61 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import bcrypt from 'bcryptjs'
vi.mock('./admin.repo', () => ({
findAdminByEmail: vi.fn(),
findAdminByIdOrThrow: vi.fn(),
setAdminPasswordReset: vi.fn(),
updateAdminLastLogin: vi.fn(),
updateAdminTotpSecret: vi.fn(),
createAuditLog: vi.fn(),
}))
vi.mock('../../services/notificationService', () => ({
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('qrcode', () => ({
default: { toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,test') },
}))
const redisStore = new Map<string, string>()
vi.mock('../../lib/redis', () => ({
redis: {
on: vi.fn(),
get: vi.fn((key: string) => Promise.resolve(redisStore.get(key) ?? null)),
set: vi.fn((key: string, value: string) => {
redisStore.set(key, value)
return Promise.resolve('OK')
}),
del: vi.fn((key: string) => {
const deleted = redisStore.delete(key) ? 1 : 0
return Promise.resolve(deleted)
}),
quit: vi.fn(),
duplicate: vi.fn(),
},
}))
import * as repo from './admin.repo'
import { sendTransactionalEmail } from '../../services/notificationService'
import { forgotPassword } from './admin.service'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { forgotPassword, login, setupTotp } from './admin.service'
describe('admin.service forgotPassword', () => {
const originalAdminUrl = process.env.ADMIN_URL
const originalJwtSecret = process.env.JWT_SECRET
beforeEach(() => {
vi.clearAllMocks()
redisStore.clear()
process.env.ADMIN_URL = 'http://localhost:3000/admin'
process.env.JWT_SECRET = 'test-jwt-secret'
})
afterAll(() => {
process.env.ADMIN_URL = originalAdminUrl
process.env.JWT_SECRET = originalJwtSecret
})
it('sends the reset email to the canonical stored admin address', async () => {
@@ -46,4 +79,66 @@ describe('admin.service forgotPassword', () => {
}),
)
})
it('signs in without an email login code when admin 2FA is enabled', async () => {
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
id: 'admin_2',
email: 'admin@example.test',
firstName: 'Amal',
lastName: 'Admin',
role: 'SUPER_ADMIN',
isActive: true,
passwordHash: await bcrypt.hash('password123', 4),
totpEnabled: true,
totpSecret: null,
} as any)
await expect(login('admin@example.test', 'password123')).resolves.toEqual(expect.objectContaining({
token: expect.any(String),
admin: expect.objectContaining({ id: 'admin_2', email: 'admin@example.test', totpEnabled: true }),
}))
expect(sendTransactionalEmail).not.toHaveBeenCalled()
})
it('accepts a previously issued emailed admin login code in the 2FA field', async () => {
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
id: 'admin_3',
email: 'admin3@example.test',
firstName: 'Mina',
lastName: 'Admin',
role: 'SUPER_ADMIN',
isActive: true,
passwordHash: await bcrypt.hash('password123', 4),
totpEnabled: true,
totpSecret: null,
} as any)
const code = '123456'
redisStore.set('admin:email-otp:admin_3', hashPublicAccessToken(code))
const result = await login('admin3@example.test', 'password123', code)
expect(result).toEqual(expect.objectContaining({
token: expect.any(String),
admin: expect.objectContaining({ id: 'admin_3', email: 'admin3@example.test' }),
}))
expect(repo.updateAdminLastLogin).toHaveBeenCalledWith('admin_3')
})
it('reuses a pending TOTP setup secret so duplicate dev setup calls keep codes valid', async () => {
vi.mocked(repo.findAdminByIdOrThrow).mockResolvedValue({
id: 'admin_4',
email: 'admin4@example.test',
totpEnabled: false,
totpSecret: 'JBSWY3DPEHPK3PXP',
} as any)
const first = await setupTotp('admin_4', 'admin4@example.test')
const second = await setupTotp('admin_4', 'admin4@example.test')
expect(first.secret).toBe('JBSWY3DPEHPK3PXP')
expect(second.secret).toBe('JBSWY3DPEHPK3PXP')
expect(repo.updateAdminTotpSecret).not.toHaveBeenCalled()
})
})
+117 -13
View File
@@ -1,16 +1,22 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
import { redis } from '../../lib/redis'
import * as presenter from './admin.presenter'
import * as repo from './admin.repo'
import * as billingService from './admin.billing.service'
const ADMIN_RESET_TTL_MINUTES = 60
const ADMIN_RECOVERY_CODE_COUNT = 10
const ADMIN_EMAIL_OTP_TTL_MINUTES = 10
const ADMIN_EMAIL_OTP_TTL_SECONDS = ADMIN_EMAIL_OTP_TTL_MINUTES * 60
const pendingAdminEmailOtps = new Map<string, { code: string; expiresAt: number }>()
function generateRecoveryCode() {
@@ -56,6 +62,61 @@ function signAdminToken(adminId: string, last2faAt?: number) {
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt })
}
function generateAdminEmailOtp() {
return crypto.randomInt(100000, 1000000).toString()
}
function adminEmailOtpKey(adminId: string) {
return `admin:email-otp:${adminId}`
}
async function sendAdminEmailOtp(admin: { id: string; email: string; firstName?: string | null }) {
const code = generateAdminEmailOtp()
const codeHash = hashPublicAccessToken(code)
pendingAdminEmailOtps.set(admin.id, {
code: codeHash,
expiresAt: Date.now() + ADMIN_EMAIL_OTP_TTL_MINUTES * 60 * 1000,
})
await redis
.set(adminEmailOtpKey(admin.id), codeHash, 'EX', ADMIN_EMAIL_OTP_TTL_SECONDS)
.catch((err) => console.error('[AdminLoginEmailOtpRedisSet]', err?.message))
await sendTransactionalEmail({
to: admin.email,
subject: 'Your RentalDriveGo admin login code',
html: `<p>Hi ${admin.firstName ?? 'Admin'},</p><p>Your admin login code is <strong>${code}</strong>.</p><p>It expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.</p>`,
text: `Hi ${admin.firstName ?? 'Admin'},\n\nYour admin login code is ${code}.\n\nIt expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.`,
}).catch((err) => console.error('[AdminLoginEmailOtp]', err?.message))
}
async function consumeAdminEmailOtp(adminId: string, code: string | undefined) {
if (!code) return false
const codeHash = hashPublicAccessToken(code.trim())
const key = adminEmailOtpKey(adminId)
const persistedHash = await redis
.get(key)
.catch((err) => {
console.error('[AdminLoginEmailOtpRedisGet]', err?.message)
return null
})
if (persistedHash) {
if (persistedHash !== codeHash) return false
await redis.del(key).catch((err) => console.error('[AdminLoginEmailOtpRedisDel]', err?.message))
pendingAdminEmailOtps.delete(adminId)
return true
}
const pending = pendingAdminEmailOtps.get(adminId)
if (!pending) return false
if (pending.expiresAt <= Date.now()) {
pendingAdminEmailOtps.delete(adminId)
return false
}
if (pending.code !== codeHash) return false
pendingAdminEmailOtps.delete(adminId)
return true
}
function toAuditJson<T>(value: T) {
return JSON.parse(JSON.stringify(value))
}
@@ -79,19 +140,18 @@ export async function login(email: string, password: string, totpCode?: string,
const valid = await bcrypt.compare(password, admin.passwordHash)
if (!valid) return null
if (admin.totpEnabled) {
if (!totpCode && !recoveryCode) return { totpRequired: true } as const
const validTotp = totpCode
let last2faAt: number | undefined
if (admin.totpEnabled && (totpCode || recoveryCode)) {
const validTotp = totpCode && admin.totpSecret
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
: false
const validRecoveryCode = !validTotp && recoveryCode
const validEmailOtp = !validTotp && !admin.totpSecret && await consumeAdminEmailOtp(admin.id, totpCode)
const validRecoveryCode = !validTotp && !validEmailOtp && recoveryCode
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
: false
if (!validTotp && !validRecoveryCode) {
return { invalidTotp: true } as const
}
if (!validTotp && !validEmailOtp && !validRecoveryCode) return { invalidTotp: true } as const
last2faAt = Date.now()
}
await repo.updateAdminLastLogin(admin.id)
@@ -102,17 +162,47 @@ export async function login(email: string, password: string, totpCode?: string,
resourceId: admin.id,
})
return presenter.presentAdminSession(admin, signAdminToken(admin.id, admin.totpEnabled ? Date.now() : undefined))
return presenter.presentAdminSession(admin, signAdminToken(admin.id, last2faAt))
}
export async function setupTotp(adminId: string, email: string) {
const secret = authenticator.generateSecret()
await repo.updateAdminTotpSecret(adminId, secret)
const admin = await repo.findAdminByIdOrThrow(adminId)
const secret = admin.totpSecret && !admin.totpEnabled
? admin.totpSecret
: authenticator.generateSecret()
if (secret !== admin.totpSecret) {
await repo.updateAdminTotpSecret(adminId, secret)
}
const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret)
const qrCode = await qrcode.toDataURL(otpauth)
return { secret, qrCode }
}
export async function setupEmail2fa(adminId: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
await sendAdminEmailOtp(admin)
return { message: 'Verification code sent.' }
}
export async function verifyEmail2fa(adminId: string, code: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
const valid = await consumeAdminEmailOtp(adminId, code)
if (!valid) return false
const updated = await repo.enableAdminEmail2fa(adminId)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_EMAIL_ENABLED',
resource: 'AdminUser',
resourceId: adminId,
})
const recoveryCodes = await issueAdminRecoveryCodes(adminId)
return {
...presenter.presentAdminSession({ ...admin, ...updated, totpEnabled: true }, signAdminToken(adminId, Date.now())),
recoveryCodes,
}
}
export async function verifyTotp(adminId: string, code: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
if (!admin.totpSecret) return false
@@ -144,7 +234,7 @@ export async function forgotPassword(email: string) {
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + ADMIN_RESET_TTL_MINUTES * 60 * 1000)
await repo.setAdminPasswordReset(admin.id, rawToken, expiresAt)
await repo.setAdminPasswordReset(admin.id, hashPublicAccessToken(rawToken), expiresAt)
const adminUrl = ensureAdminBasePath(
process.env.ADMIN_URL ?? process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3000/admin',
@@ -270,7 +360,7 @@ export async function listAdmins() {
return admins.map((admin: any) => presenter.presentAdminUser(admin))
}
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; preferredLocale: string; password: string; permissions?: any[] }) {
const admin = await repo.createAdmin({
...body,
passwordHash: await bcrypt.hash(body.password, 12),
@@ -285,6 +375,7 @@ export async function updateAdmin(
firstName?: string
lastName?: string
role?: string
preferredLocale?: string
password?: string
isActive?: boolean
},
@@ -294,6 +385,7 @@ export async function updateAdmin(
...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
...(body.role !== undefined ? { role: body.role } : {}),
...(body.preferredLocale !== undefined ? { preferredLocale: body.preferredLocale } : {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}),
})
@@ -341,6 +433,18 @@ export function updateBillingAccount(
return billingService.updateBillingAccount(billingAccountId, data, adminId, ip)
}
export function getBillingPlatformSettings() {
return billingService.getBillingPlatformSettings()
}
export function updateBillingPlatformSettings(
data: Parameters<typeof billingService.updateBillingPlatformSettings>[0],
adminId: string,
ip?: string,
) {
return billingService.updateBillingPlatformSettings(data, adminId, ip)
}
export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) {
return billingService.setDunningPaused(billingAccountId, paused, adminId, ip)
}
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireSubscriptionRead } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseQuery } from '../../http/validate'
import { ok } from '../../http/respond'
@@ -10,22 +10,22 @@ import { summaryQuerySchema, reportQuerySchema } from './analytics.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/summary', async (req, res, next) => {
router.get('/summary', requireRole('MANAGER'), async (req, res, next) => {
try {
const { period } = parseQuery(summaryQuerySchema, req)
ok(res, await service.getSummary(req.companyId, period))
} catch (err) { next(err) }
})
router.get('/dashboard', async (req, res, next) => {
router.get('/dashboard', requireRole('MANAGER'), async (req, res, next) => {
try {
ok(res, await service.getDashboard(req.companyId))
} catch (err) { next(err) }
})
router.get('/sources', async (req, res, next) => {
router.get('/sources', requireRole('MANAGER'), async (req, res, next) => {
try {
ok(res, await service.getSources(req.companyId))
} catch (err) { next(err) }
@@ -1,12 +1,12 @@
import { z } from 'zod'
export const summaryQuerySchema = z.object({
period: z.string().default('30d'),
period: z.string().trim().max(20).default('30d'),
})
export const reportQuerySchema = z.object({
from: z.string().optional(),
to: z.string().optional(),
format: z.string().default('JSON'),
period: z.string().optional(),
from: z.string().trim().max(30).optional(),
to: z.string().trim().max(30).optional(),
format: z.enum(['JSON', 'CSV']).default('JSON'),
period: z.string().trim().max(20).optional(),
})
@@ -1,13 +1,25 @@
import { z } from 'zod'
import { localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
/**
* Minimal signup schema — only asks for what's needed to create an identity.
* See progressive-signup-plan.md Section 3.
*/
export const accountStartSchema = z.object({
email: z.string().email(),
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
companyName: z.string().trim().min(2).max(120),
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
password: z.string().min(8).max(128),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
}).superRefine((account, ctx) => {
if (!validateLocalizedText(account.firstName, account.preferredLanguage, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
}
if (!validateLocalizedText(account.lastName, account.preferredLanguage, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
}
})
export const companyProfileSchema = z.object({
@@ -42,7 +54,6 @@ export const legalIdentitySchema = z.object({
})
export const paymentSetupSchema = z.object({
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
responsibleName: z.string().min(1).max(160),
responsibleRole: z.string().min(1).max(120),
responsibleIdentityNumber: z.string().min(1).max(120),
@@ -54,6 +65,6 @@ export const paymentSetupSchema = z.object({
})
export const planSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
})
@@ -2,7 +2,8 @@ import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { sendTransactionalEmail } from '../../services/notificationService'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.company.repo'
import type { output } from 'zod'
import type { accountStartSchema } from './auth.account.schemas'
@@ -41,7 +42,7 @@ export async function startAccount(body: AccountStartInput) {
const company = await tx.company.create({
data: {
name: '',
name: body.companyName,
slug,
email: body.email,
address: {},
@@ -52,7 +53,7 @@ export async function startAccount(body: AccountStartInput) {
await tx.brandSettings.create({
data: {
companyId: company.id,
displayName: body.email.split('@')[0] || 'My Workspace',
displayName: body.companyName,
subdomain: slug,
defaultLocale: body.preferredLanguage,
defaultCurrency: 'MAD',
@@ -62,7 +63,7 @@ export async function startAccount(body: AccountStartInput) {
await tx.subscription.create({
data: {
companyId: company.id,
plan: 'STARTER',
plan: body.subscriptionPlan ?? 'STARTER',
billingPeriod: 'MONTHLY',
currency: 'MAD',
status: 'TRIALING',
@@ -77,11 +78,11 @@ export async function startAccount(body: AccountStartInput) {
data: {
companyId: company.id,
clerkUserId: `local_owner_${company.id}`,
firstName: '',
lastName: '',
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
passwordHash,
emailVerificationToken: verificationToken,
emailVerificationToken: hashPublicAccessToken(verificationToken),
role: 'OWNER',
preferredLanguage: body.preferredLanguage,
isActive: true,
@@ -125,8 +126,7 @@ export async function startAccount(body: AccountStartInput) {
text: emailTexts[lang],
}).catch((err) => {
console.error('[AccountStart] Verification email delivery failed:', err?.message ?? String(err))
console.error('[AccountStart] SMTP config — host:', process.env.MAIL_HOST ?? 'not set', '| port:', process.env.MAIL_PORT ?? 'not set', '| user:', process.env.MAIL_USERNAME ? '***' : 'not set', '| pass:', process.env.MAIL_PASSWORD ? '***' : 'not set')
console.error('[AccountStart] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
console.error('[AccountStart] Email provider config:', describeEmailProviderConfig())
})
return {
@@ -32,7 +32,7 @@ type CompanySignupInput = {
responsibleEmail: string
currency: 'MAD'
registrationNumber: string
plan: 'STARTER' | 'GROWTH' | 'PRO'
plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
billingPeriod: 'MONTHLY' | 'ANNUAL'
preferredLanguage: 'en' | 'fr' | 'ar'
firstName: string
@@ -1,9 +1,10 @@
import { z } from 'zod'
import { contactPhoneField, countryCodeField, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
export const companySignupSchema = z.object({
firstName: z.string().min(1).max(80),
lastName: z.string().min(1).max(80),
email: z.string().email(),
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
password: z.string().min(8).max(128),
companyName: z.string().min(2).max(120),
legalName: z.string().min(2).max(160),
@@ -15,10 +16,10 @@ export const companySignupSchema = z.object({
operatingLicenseIssuedAt: z.string().min(1).max(40),
operatingLicenseIssuedBy: z.string().min(1).max(160),
streetAddress: z.string().min(1).max(200),
city: z.string().min(1).max(120),
country: z.string().min(1).max(120),
city: z.string().trim().min(1).max(85).transform((value) => value.normalize('NFC')),
country: countryCodeField(),
zipCode: z.string().min(1).max(40),
companyPhone: z.string().min(1).max(80),
companyPhone: contactPhoneField(),
companyEmail: z.string().email(),
fax: z.string().max(80).optional(),
yearsActive: z.string().min(1).max(80),
@@ -28,11 +29,20 @@ export const companySignupSchema = z.object({
responsibleRole: z.string().min(1).max(120),
responsibleIdentityNumber: z.string().min(1).max(120),
responsibleQualification: z.string().max(200).optional(),
responsiblePhone: z.string().min(1).max(80),
responsiblePhone: contactPhoneField(),
responsibleEmail: z.string().email(),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.literal('MAD'),
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
}).superRefine((signup, ctx) => {
if (!validateLocalizedText(signup.firstName, signup.preferredLanguage, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
}
if (!validateLocalizedText(signup.lastName, signup.preferredLanguage, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
}
if (!validateLocalizedText(signup.city, signup.preferredLanguage, 85)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['city'], message: localizedTextIssue('City') })
}
})
@@ -45,7 +45,6 @@ const body = {
plan: 'PRO' as const,
billingPeriod: 'MONTHLY' as const,
currency: 'MAD' as const,
paymentProvider: 'PAYPAL' as const,
}
describe('auth.company.service', () => {
@@ -105,7 +104,6 @@ describe('auth.company.service', () => {
templateVariables: expect.objectContaining({
firstName: 'Aya',
companyName: 'Atlas & Desert Cars!!!',
paymentProvider: 'PAYPAL',
}),
}))
})
@@ -100,7 +100,6 @@ export async function signup(body: CompanySignupInput) {
planName: localizePlanName(body.plan, lang),
billingPeriodLabel: localizeBillingPeriod(body.billingPeriod, lang),
currency: body.currency,
paymentProvider: body.paymentProvider,
trialEndDate: trialEndAt,
},
}).catch(() => [])
@@ -4,12 +4,14 @@ const prismaMock = vi.hoisted(() => ({
employee: {
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
}))
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import * as repo from './auth.employee.repo'
describe('auth.employee.repo query boundaries', () => {
@@ -23,26 +25,32 @@ describe('auth.employee.repo query boundaries', () => {
})
it('looks up employee login emails case-insensitively and includes company context', async () => {
prismaMock.employee.findMany.mockResolvedValue([])
await repo.findEmployeeWithCompanyByEmail('Agent@Example.TEST')
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
expect(prismaMock.employee.findMany).toHaveBeenCalledWith({
where: { email: { equals: 'Agent@Example.TEST', mode: 'insensitive' } },
include: { company: true },
take: 2,
})
})
it('only sends forgot-password emails to active employees', async () => {
prismaMock.employee.findMany.mockResolvedValue([])
await repo.findActiveEmployeeByEmail('agent@example.test')
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
expect(prismaMock.employee.findMany).toHaveBeenCalledWith({
where: {
email: { equals: 'agent@example.test', mode: 'insensitive' },
isActive: true,
},
take: 2,
})
})
it('requires unexpired reset tokens for stored-token password reset lookup', async () => {
it('requires unexpired hashed reset tokens for stored-token password reset lookup', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
@@ -50,7 +58,7 @@ describe('auth.employee.repo query boundaries', () => {
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: {
passwordResetToken: 'reset_123',
passwordResetToken: hashPublicAccessToken('reset_123'),
passwordResetExpiresAt: { gt: new Date('2026-06-09T12:00:00.000Z') },
},
})
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
employee: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
},
}))
@@ -14,12 +14,13 @@ import { findActiveEmployeeByEmail, findEmployeeWithCompanyByEmail } from './aut
describe('auth.employee.repo', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.employee.findMany).mockResolvedValue([] as never)
})
it('looks up employee login email case-insensitively', async () => {
await findEmployeeWithCompanyByEmail('Owner@Example.com')
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
expect(prisma.employee.findMany).toHaveBeenCalledWith({
where: {
email: {
equals: 'Owner@Example.com',
@@ -27,13 +28,14 @@ describe('auth.employee.repo', () => {
},
},
include: { company: true },
take: 2,
})
})
it('looks up active employee reset email case-insensitively', async () => {
await findActiveEmployeeByEmail('Owner@Example.com')
expect(prisma.employee.findFirst).toHaveBeenCalledWith({
expect(prisma.employee.findMany).toHaveBeenCalledWith({
where: {
email: {
equals: 'Owner@Example.com',
@@ -41,6 +43,15 @@ describe('auth.employee.repo', () => {
},
isActive: true,
},
take: 2,
})
})
it('fails closed when multiple employees share an email', async () => {
vi.mocked(prisma.employee.findMany).mockResolvedValue([{ id: 'a' }, { id: 'b' }] as never)
await expect(findEmployeeWithCompanyByEmail('shared@example.com')).rejects.toMatchObject({
code: 'ambiguous_employee_email',
statusCode: 409,
})
})
})
@@ -1,4 +1,5 @@
import { prisma } from '../../lib/prisma'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
export function findEmployeeWithCompanyById(id: string) {
return prisma.employee.findUnique({
@@ -13,8 +14,8 @@ export function findEmployeeById(id: string) {
})
}
export function findEmployeeWithCompanyByEmail(email: string) {
return prisma.employee.findFirst({
export async function findEmployeeWithCompanyByEmail(email: string) {
const matches = await prisma.employee.findMany({
where: {
email: {
equals: email,
@@ -22,11 +23,21 @@ export function findEmployeeWithCompanyByEmail(email: string) {
},
},
include: { company: true },
take: 2,
})
if (matches.length > 1) {
throw Object.assign(new Error('Multiple employee accounts match this email. Sign in with your company context or contact support.'), {
statusCode: 409,
code: 'ambiguous_employee_email',
})
}
return matches[0] ?? null
}
export function findActiveEmployeeByEmail(email: string) {
return prisma.employee.findFirst({
export async function findActiveEmployeeByEmail(email: string) {
const matches = await prisma.employee.findMany({
where: {
email: {
equals: email,
@@ -34,7 +45,17 @@ export function findActiveEmployeeByEmail(email: string) {
},
isActive: true,
},
take: 2,
})
if (matches.length > 1) {
throw Object.assign(new Error('Multiple employee accounts match this email. Contact support.'), {
statusCode: 409,
code: 'ambiguous_employee_email',
})
}
return matches[0] ?? null
}
export function setPasswordResetToken(id: string, passwordResetToken: string, passwordResetExpiresAt: Date) {
@@ -51,10 +72,23 @@ export function updatePreferredLanguage(id: string, preferredLanguage: 'en' | 'f
})
}
export function updateEmployeeTotpSecret(id: string, secret: string) {
return prisma.employee.update({ where: { id }, data: { totpSecret: secret } })
}
export function enableEmployeeTotp(id: string) {
return prisma.employee.update({ where: { id }, data: { totpEnabled: true } })
}
export function enableEmployeeEmail2fa(id: string) {
return prisma.employee.update({ where: { id }, data: { totpEnabled: true, totpSecret: null } })
}
export function findEmployeeByResetToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.employee.findFirst({
where: {
passwordResetToken: token,
passwordResetToken: tokenHash,
passwordResetExpiresAt: { gt: new Date() },
},
})
@@ -71,16 +105,17 @@ export function resetPassword(id: string, passwordHash: string) {
})
}
export function setEmailVerificationToken(id: string, token: string) {
export function setEmailVerificationToken(id: string, tokenHash: string) {
return prisma.employee.update({
where: { id },
data: { emailVerificationToken: token },
data: { emailVerificationToken: tokenHash },
})
}
export function findEmployeeByVerificationToken(token: string) {
const tokenHash = hashPublicAccessToken(token)
return prisma.employee.findFirst({
where: { emailVerificationToken: token },
where: { emailVerificationToken: tokenHash },
include: { company: true },
})
}
@@ -6,6 +6,7 @@ import { setSessionCookie, clearSessionCookie } from '../../security/sessionCook
import { getEmployeeMenu } from '../menu/menu.service'
import {
employeeForgotPasswordSchema,
employee2faVerifySchema,
employeeLanguageSchema,
employeeLoginSchema,
employeeResetPasswordSchema,
@@ -30,7 +31,13 @@ router.post('/login', async (req, res, next) => {
try {
const body = parseBody(employeeLoginSchema, req)
const result = await service.login(body)
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
if ('twoFactorRequired' in result) {
return res.status(401).json({ error: 'two_factor_required', message: '2FA code required', method: result.method, statusCode: 401 })
}
if ('token' in result) {
clearSessionCookie(res, 'admin')
setSessionCookie(res, 'employee', String(result.token), 8 * 60 * 60 * 1000)
}
ok(res, result)
} catch (err) { next(err) }
})
@@ -40,6 +47,38 @@ router.post('/logout', (_req, res) => {
ok(res, { success: true })
})
router.post('/2fa/setup', requireCompanyAuth, async (req, res, next) => {
try {
ok(res, await service.setupTotp(req.employee.id))
} catch (err) { next(err) }
})
router.post('/2fa/verify', requireCompanyAuth, async (req, res, next) => {
try {
const { code } = parseBody(employee2faVerifySchema, req)
const result = await service.verifyTotp(req.employee.id, code)
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
if ('token' in result) setSessionCookie(res, 'employee', String(result.token), 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/2fa/email/setup', requireCompanyAuth, async (req, res, next) => {
try {
ok(res, await service.setupEmail2fa(req.employee.id))
} catch (err) { next(err) }
})
router.post('/2fa/email/verify', requireCompanyAuth, async (req, res, next) => {
try {
const { code } = parseBody(employee2faVerifySchema, req)
const result = await service.verifyEmail2fa(req.employee.id, code)
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid email verification code', statusCode: 400 })
if ('token' in result) setSessionCookie(res, 'employee', String(result.token), 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/forgot-password', async (req, res, next) => {
try {
const { email } = parseBody(employeeForgotPasswordSchema, req)
@@ -1,12 +1,13 @@
import { z } from 'zod'
export const employeeLoginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
email: z.string().email().max(254).trim().toLowerCase(),
password: z.string().max(128),
totpCode: z.string().length(6).optional(),
})
export const employeeForgotPasswordSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
email: z.string().email().max(254).trim().toLowerCase(),
})
export const employeeLanguageSchema = z.object({
@@ -17,3 +18,7 @@ export const employeeResetPasswordSchema = z.object({
token: z.string().min(1),
password: z.string().min(8).max(128),
})
export const employee2faVerifySchema = z.object({
code: z.string().length(6),
})
@@ -1,9 +1,13 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import jwt from 'jsonwebtoken'
import { authenticator } from 'otplib'
import qrcode from 'qrcode'
import { signActorToken } from '../../security/tokens'
import { AppError } from '../../http/errors'
import { sendTransactionalEmail } from '../../services/notificationService'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
import { redis } from '../../lib/redis'
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
import { presentEmployeeSession } from './auth.presenter'
import * as repo from './auth.employee.repo'
@@ -11,6 +15,10 @@ import { employeeLanguageSchema, employeeLoginSchema } from './auth.employee.sch
import type { output } from 'zod'
const RESET_TOKEN_TTL_MINUTES = 60
const EMPLOYEE_EMAIL_OTP_TTL_MINUTES = 10
const EMPLOYEE_EMAIL_OTP_TTL_SECONDS = EMPLOYEE_EMAIL_OTP_TTL_MINUTES * 60
const pendingEmployeeEmailOtps = new Map<string, { code: string; expiresAt: number }>()
type EmployeeLoginInput = output<typeof employeeLoginSchema>
type EmployeeLanguageInput = output<typeof employeeLanguageSchema>
@@ -20,12 +28,68 @@ type EmployeePasswordResetPayload = jwt.JwtPayload & {
pwdv: string
}
function signEmployeeToken(employeeId: string) {
function signEmployeeToken(employeeId: string, last2faAt?: number) {
return signActorToken(employeeId, 'employee', {
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'],
last2faAt,
})
}
function generateEmployeeEmailOtp() {
return crypto.randomInt(100000, 1000000).toString()
}
function employeeEmailOtpKey(employeeId: string) {
return `employee:email-otp:${employeeId}`
}
async function sendEmployeeEmailOtp(employee: { id: string; email: string; firstName?: string | null }) {
const code = generateEmployeeEmailOtp()
const codeHash = hashPublicAccessToken(code)
pendingEmployeeEmailOtps.set(employee.id, {
code: codeHash,
expiresAt: Date.now() + EMPLOYEE_EMAIL_OTP_TTL_MINUTES * 60 * 1000,
})
await redis
.set(employeeEmailOtpKey(employee.id), codeHash, 'EX', EMPLOYEE_EMAIL_OTP_TTL_SECONDS)
.catch((err) => console.error('[EmployeeLoginEmailOtpRedisSet]', err?.message))
await sendTransactionalEmail({
to: employee.email,
subject: 'Your RentalDriveGo login code',
html: `<p>Hi ${employee.firstName ?? 'there'},</p><p>Your workspace login code is <strong>${code}</strong>.</p><p>It expires in ${EMPLOYEE_EMAIL_OTP_TTL_MINUTES} minutes.</p>`,
text: `Hi ${employee.firstName ?? 'there'},\n\nYour workspace login code is ${code}.\n\nIt expires in ${EMPLOYEE_EMAIL_OTP_TTL_MINUTES} minutes.`,
}).catch((err) => console.error('[EmployeeLoginEmailOtp]', err?.message))
}
async function consumeEmployeeEmailOtp(employeeId: string, code: string | undefined) {
if (!code) return false
const codeHash = hashPublicAccessToken(code.trim())
const key = employeeEmailOtpKey(employeeId)
const persistedHash = await redis
.get(key)
.catch((err) => {
console.error('[EmployeeLoginEmailOtpRedisGet]', err?.message)
return null
})
if (persistedHash) {
if (persistedHash !== codeHash) return false
await redis.del(key).catch((err) => console.error('[EmployeeLoginEmailOtpRedisDel]', err?.message))
pendingEmployeeEmailOtps.delete(employeeId)
return true
}
const pending = pendingEmployeeEmailOtps.get(employeeId)
if (!pending) return false
if (pending.expiresAt <= Date.now()) {
pendingEmployeeEmailOtps.delete(employeeId)
return false
}
if (pending.code !== codeHash) return false
pendingEmployeeEmailOtps.delete(employeeId)
return true
}
function getEmployeePasswordResetVersion(passwordHash: string | null | undefined) {
return crypto
.createHash('sha256')
@@ -114,7 +178,76 @@ export async function login(body: EmployeeLoginInput) {
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
}
return presentEmployeeSession(employee, signEmployeeToken(employee.id))
if (employee.totpEnabled) {
if (!body.totpCode) {
if (!employee.totpSecret) await sendEmployeeEmailOtp(employee)
return { twoFactorRequired: true, method: employee.totpSecret ? 'authenticator' : 'email' } as const
}
const validTotp = employee.totpSecret
? authenticator.verify({ token: body.totpCode, secret: employee.totpSecret })
: false
const validEmailOtp = !validTotp && !employee.totpSecret
? await consumeEmployeeEmailOtp(employee.id, body.totpCode)
: false
if (!validTotp && !validEmailOtp) {
throw new AppError('Invalid 2FA code', 401, 'invalid_totp')
}
}
return presentEmployeeSession(employee, signEmployeeToken(employee.id, employee.totpEnabled ? Date.now() : undefined))
}
export async function setupTotp(employeeId: string) {
const employee = await repo.findEmployeeWithCompanyById(employeeId)
if (!employee || !employee.isActive) {
throw new AppError('Employee account not found or inactive', 401, 'unauthenticated')
}
const secret = employee.totpSecret && !employee.totpEnabled
? employee.totpSecret
: authenticator.generateSecret()
if (secret !== employee.totpSecret) {
await repo.updateEmployeeTotpSecret(employeeId, secret)
}
const otpauth = authenticator.keyuri(employee.email, 'RentalDriveGo Workspace', secret)
const qrCode = await qrcode.toDataURL(otpauth)
return { secret, qrCode }
}
export async function verifyTotp(employeeId: string, code: string) {
const employee = await repo.findEmployeeWithCompanyById(employeeId)
if (!employee || !employee.isActive) {
throw new AppError('Employee account not found or inactive', 401, 'unauthenticated')
}
if (!employee.totpSecret) return false
const valid = authenticator.verify({ token: code, secret: employee.totpSecret })
if (!valid) return false
const updated = await repo.enableEmployeeTotp(employeeId)
return presentEmployeeSession({ ...employee, ...updated, totpEnabled: true }, signEmployeeToken(employeeId, Date.now()))
}
export async function setupEmail2fa(employeeId: string) {
const employee = await repo.findEmployeeWithCompanyById(employeeId)
if (!employee || !employee.isActive) {
throw new AppError('Employee account not found or inactive', 401, 'unauthenticated')
}
await sendEmployeeEmailOtp(employee)
return { message: 'Verification code sent.' }
}
export async function verifyEmail2fa(employeeId: string, code: string) {
const employee = await repo.findEmployeeWithCompanyById(employeeId)
if (!employee || !employee.isActive) {
throw new AppError('Employee account not found or inactive', 401, 'unauthenticated')
}
const valid = await consumeEmployeeEmailOtp(employeeId, code)
if (!valid) return false
const updated = await repo.enableEmployeeEmail2fa(employeeId)
return presentEmployeeSession({ ...employee, ...updated, totpEnabled: true }, signEmployeeToken(employeeId, Date.now()))
}
export async function forgotPassword(email: string) {
@@ -136,7 +269,7 @@ export async function forgotPassword(email: string) {
text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
}).catch((err) => {
console.error('[ForgotPassword] Email delivery failed:', err?.message)
console.error('[ForgotPassword] Provider config — resendKey present:', !!process.env.RESEND_API_KEY, '| smtpHost:', process.env.MAIL_HOST ?? 'not set')
console.error('[ForgotPassword] Email provider config:', describeEmailProviderConfig())
})
}
@@ -204,7 +337,7 @@ export async function resendVerification(email: string) {
}
const verificationToken = crypto.randomBytes(32).toString('hex')
await repo.setEmailVerificationToken(employee.id, verificationToken)
await repo.setEmailVerificationToken(employee.id, hashPublicAccessToken(verificationToken))
const dashboardUrl = ensureDashboardBasePath(
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
@@ -229,8 +362,7 @@ export async function resendVerification(email: string) {
text: `Verify your email by visiting:\n${verifyUrl}`,
}).catch((err) => {
console.error('[ResendVerification] Email delivery failed:', err?.message ?? String(err))
console.error('[ResendVerification] SMTP config — host:', process.env.MAIL_HOST ?? 'not set', '| port:', process.env.MAIL_PORT ?? 'not set', '| user:', process.env.MAIL_USERNAME ? '***' : 'not set')
console.error('[ResendVerification] Resend config — apiKey:', process.env.RESEND_API_KEY ? (process.env.RESEND_API_KEY.startsWith('re_') ? 'valid' : 'placeholder') : 'not set')
console.error('[ResendVerification] Email provider config:', describeEmailProviderConfig())
})
return { message: 'A new verification link has been sent to your email.' }
@@ -22,6 +22,7 @@ describe('auth presenters', () => {
lastName: 'Benali',
role: 'OWNER',
preferredLanguage: 'fr',
totpEnabled: false,
companyId: 'company_1',
companyName: 'Atlas Cars',
companySlug: 'atlas-cars',
@@ -5,6 +5,7 @@ type EmployeeWithCompany = {
lastName: string
role: string
preferredLanguage?: string | null
totpEnabled?: boolean
companyId: string
company: {
name: string
@@ -52,6 +53,7 @@ export function presentEmployeeSession(employee: EmployeeWithCompany, token?: st
lastName: employee.lastName,
role: employee.role,
preferredLanguage: employee.preferredLanguage,
totpEnabled: Boolean(employee.totpEnabled),
companyId: employee.companyId,
companyName: employee.company.name,
companySlug: employee.company.slug,
@@ -1,11 +1,20 @@
import { z } from 'zod'
import { contactPhoneField, languageSchema, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
export const renterUpdateSchema = z.object({
firstName: z.string().min(1).max(100).trim().optional(),
lastName: z.string().min(1).max(100).trim().optional(),
phone: z.string().max(30).trim().optional(),
preferredLocale: z.enum(['en', 'fr', 'ar']).optional(),
firstName: z.string().trim().max(50).transform((value) => value.normalize('NFC')).optional(),
lastName: z.string().trim().max(50).transform((value) => value.normalize('NFC')).optional(),
phone: contactPhoneField().optional(),
preferredLocale: languageSchema.optional(),
preferredCurrency: z.literal('MAD').optional(),
}).superRefine((data, ctx) => {
const language = data.preferredLocale ?? 'fr'
if (data.firstName && !validateLocalizedText(data.firstName, language, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
}
if (data.lastName && !validateLocalizedText(data.lastName, language, 50)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
}
})
export const renterFcmTokenSchema = z.object({
@@ -32,13 +32,11 @@ const validCompanySignup = {
plan: 'GROWTH',
billingPeriod: 'MONTHLY',
currency: 'MAD',
paymentProvider: 'AMANPAY',
}
describe('role-specific auth schema contracts', () => {
it('defaults company signup language and keeps billing provider constrained', () => {
expect(companySignupSchema.parse(validCompanySignup)).toMatchObject({ preferredLanguage: 'en', paymentProvider: 'AMANPAY' })
expect(() => companySignupSchema.parse({ ...validCompanySignup, paymentProvider: 'WIRE_TRANSFER' })).toThrow()
it('defaults company signup language and keeps billing currency constrained', () => {
expect(companySignupSchema.parse(validCompanySignup)).toMatchObject({ preferredLanguage: 'en' })
expect(() => companySignupSchema.parse({ ...validCompanySignup, currency: 'EUR' })).toThrow()
})

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