Compare commits
647 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62278d110f | |||
| 71348f45b2 | |||
| 664b7d52af | |||
| fb6f9c91c6 | |||
| 051c873fdf | |||
| 12224458e7 | |||
| b6719d2a3e | |||
| 7843b56f39 | |||
| 19be6c4b3b | |||
| 4a283710ad | |||
| 1af7c5ee73 | |||
| 77dd47fa8a | |||
| ccfe91f08d | |||
| 624fb8322f | |||
| 6bc3242a29 | |||
| 33f528ef79 | |||
| 6c1afc4453 | |||
| 2a14447f8c | |||
| ad50f7473a | |||
| 5f1218f7b1 | |||
| e377afe9f6 | |||
| 21eb4d2876 | |||
| bb824a7070 | |||
| f98de98288 | |||
| 350de6ad2e | |||
| dc8e724482 | |||
| fb5b564c00 | |||
| 18270381c1 | |||
| b32dc30050 | |||
| 667804043c | |||
| 3fd3d90c10 | |||
| 6e3619fb1a | |||
| 987c0e992b | |||
| bdd7f40688 | |||
| 809bc662e0 | |||
| a74e1e765a | |||
| c356af3eda | |||
| a4c7121b93 | |||
| 2a3c00045f | |||
| 8e938a2723 | |||
| 197f8f7ba5 | |||
| 10b528642d | |||
| 60d6fff085 | |||
| c664b3da91 | |||
| 492282e758 | |||
| 47f4aefc25 | |||
| 7c0ff9ede7 | |||
| 44e81ea979 | |||
| 56dc10330a | |||
| 6c6e5c0fdf | |||
| 4be73558d5 | |||
| 87ff7650a1 | |||
| ae5af013b4 | |||
| 5adfe65aba | |||
| d3ae06deeb | |||
| ee4a72bcb7 | |||
| f941c23927 | |||
| 3ae6a08b71 | |||
| 620eeaed4c | |||
| 9b08228398 | |||
| eb102a0d23 | |||
| 18317e0028 | |||
| 2ec93c6710 | |||
| 5933eeb05f | |||
| 3fae2d687f | |||
| 285d7c6030 | |||
| 0fd57f5843 | |||
| a296460124 | |||
| 35104f7b29 | |||
| 51375ed8b7 | |||
| 1bb4f604e9 | |||
| 8fd990df93 | |||
| 1f390cabc6 | |||
| 7f1f8c54c1 | |||
| b1515db229 | |||
| d78c226734 | |||
| 52865bc1f5 | |||
| efbb6c9814 | |||
| 1e3dea3dad | |||
| 25f84482ba | |||
| 3f2a116fdb | |||
| dce002c6cc | |||
| dcfbbdc79d | |||
| f8cac37eef | |||
| cb37713c4c | |||
| 7ae4082d7e | |||
| aa8d339d35 | |||
| 7a61b182e0 | |||
| 91b697b80b | |||
| 7d10b63dc5 | |||
| 92916cd9db | |||
| 075d112861 | |||
| 7e9407fbc7 | |||
| 313f777e44 | |||
| 3ba9eb7ad7 | |||
| 328a2d52da | |||
| 697bea814e | |||
| 83cda218b7 | |||
| 76435440af | |||
| 0a8b5e46ca | |||
| a74b0934fc | |||
| c05d2aeafb | |||
| 87a3e5ceea | |||
| 555b36e13e | |||
| 021866011f | |||
| 749abdcb56 | |||
| 7d6f3a2925 | |||
| af515f1072 | |||
| c90c67eab3 | |||
| 51c9507d08 | |||
| 11daf4f927 | |||
| 58195b5959 | |||
| b51aecf45a | |||
| eb8ad6a181 | |||
| 4edd2e4d32 | |||
| 813c3abe54 | |||
| ca7ea72ff1 | |||
| 53b1d8a9f3 | |||
| a3dfb30a42 | |||
| 111d4b1b8c | |||
| 32d76ee2ac | |||
| 98ad6fb31b | |||
| 3a18a3994f | |||
| 6d35b20880 | |||
| 173d4a536f | |||
| ea740e12d1 | |||
| d7bff686c0 | |||
| 193f6da8d1 | |||
| 954dc8d1d9 | |||
| 234819f8f2 | |||
| b34a8d116b | |||
| 7375b9efae | |||
| c8f170d197 | |||
| 4989d1e31a | |||
| c568251d8d | |||
| db2de4af89 | |||
| 574ae8f5f9 | |||
| 125091d719 | |||
| 5d37f44241 | |||
| f0f5e9219b | |||
| ee75a09f8c | |||
| c49c6f5837 | |||
| 03118f1ede | |||
| 0ff5ea4f6a | |||
| 978fa8bfc6 | |||
| 36c7c5dc34 | |||
| 8f3628d4ec | |||
| fcd3c72cac | |||
| 3f0be4a39d | |||
| f9ff3a5715 | |||
| ed71c90d73 | |||
| e580c7db7c | |||
| 4a9b0117a2 | |||
| 9b10292e02 | |||
| d9a9ae14fd | |||
| db716f781e | |||
| 46f341f7fd | |||
| 9bb413bb1e | |||
| 2878d5690c | |||
| 1143404a65 | |||
| e92bb9043b | |||
| a2f5d830d6 | |||
| 77e3422e88 | |||
| 9a551c91e5 | |||
| c35ac493ed | |||
| e100645a00 | |||
| 96bfc66a94 | |||
| e21c9eec31 | |||
| a9f32102b5 | |||
| 7291628f86 | |||
| 5fbb205044 | |||
| 02ab24d01e | |||
| 3cfbd66a80 | |||
| 048e4fc73c | |||
| 860fa47b7c | |||
| 71d9d8f010 | |||
| cc58e28e54 | |||
| 653dd920ad | |||
| 49b4fcf063 | |||
| dd78c2cc08 | |||
| 47ae017f94 | |||
| 4b61e12ca6 | |||
| c6bd657ee6 | |||
| c33fa8782b | |||
| 93cba1d098 | |||
| d3a1f9798d | |||
| 115c3cbf07 | |||
| bed5817da3 | |||
| 83035753af | |||
| 379b53bcca | |||
| 355294a2d4 | |||
| 833b27ab4a | |||
| 8a04f13dd4 | |||
| 49020fa6ea | |||
| ac3402a8b3 | |||
| 9369e60695 | |||
| 295e38d2af | |||
| 923371e5b4 | |||
| 81430ba3d3 | |||
| 11595f5f96 | |||
| b08e875b37 | |||
| 0016b8fce7 | |||
| f5b10df7cf | |||
| 1782f31075 | |||
| 21032bc22b | |||
| e4aaadc9f9 | |||
| f7aca85417 | |||
| c42df737b0 | |||
| 75c6af3b11 | |||
| e734cc93a1 | |||
| e3ccc4f8d4 | |||
| 896c362dce | |||
| 864ab97293 | |||
| 899c47e9a3 | |||
| bc2f291352 | |||
| 98f5e39a7f | |||
| e0a66e79bb | |||
| c781be4e70 | |||
| 48c4b44f72 | |||
| 68a84181d9 | |||
| b60390d805 | |||
| b33e776072 | |||
| 2187f23588 | |||
| 732fd4eba1 | |||
| 495c772d6b | |||
| 02e4fe8b48 | |||
| 6d45486bb5 | |||
| 4c1f7f6243 | |||
| 1e3a9fb921 | |||
| 8b8e7913dc | |||
| 88515a7dad | |||
| e012063f5b | |||
| e76377d3c7 | |||
| a357f42c48 | |||
| 2becb15916 | |||
| 186eeed784 | |||
| 216c932cb9 | |||
| 52de5eb539 | |||
| eec3119297 | |||
| f2e7f83b3d | |||
| 93dea0525e | |||
| 7e6e0a8a9c | |||
| 211d3a53f5 | |||
| 187936e5dd | |||
| b180d064d1 | |||
| ed46afd81a | |||
| 0050fad70d | |||
| 3dc9c100e9 | |||
| 969e7c9296 | |||
| 591caab45a | |||
| 02e97d6ae4 | |||
| 996160fadc | |||
| dc1923ab1f | |||
| fe5831eb48 | |||
| ca79abc9d4 | |||
| 9eafa067b9 | |||
| 403b8a12e4 | |||
| 3f305e4d5c | |||
| 5e5e172d39 | |||
| bc7a883f6c | |||
| 12056aebc6 | |||
| 6689a8d93e | |||
| 297cb9c8f2 | |||
| d699455b38 | |||
| 9e7b4afaec | |||
| e91c344e64 | |||
| 07f628b928 | |||
| 184e1425a4 | |||
| 22f2990f56 | |||
| 6c28c5f383 | |||
| e9f9cf54f4 | |||
| 751d1b083d | |||
| a4d77a4fd3 | |||
| f72252552f | |||
| c5d68675c9 | |||
| d04740fede | |||
| 36b8ef5fba | |||
| 6cca5e0472 | |||
| 4048fa274a | |||
| b4d2974e19 | |||
| fb896d6c0f | |||
| 82b86263dc | |||
| 6564bfe8ae | |||
| 75ce7e91d7 | |||
| 656eea5bb1 | |||
| aba27a7bbf | |||
| 3d7e322bf9 | |||
| 1f3fff4a9d | |||
| 39e06f2b6d | |||
| b775c5b674 | |||
| 796bf37141 | |||
| c5b0e1e876 | |||
| 2e8bd7a8c7 | |||
| 790daba796 | |||
| bcf6b86b84 | |||
| 2e9bd50172 | |||
| 4677a0d501 | |||
| c673dce484 | |||
| e1dd8965dc | |||
| 346a0bdc98 | |||
| 8c1169738e | |||
| bc80f91a45 | |||
| 3d062389e9 | |||
| 7afddb5eb5 | |||
| 425a99e5ee | |||
| a5e9339af9 | |||
| b4463f0e1a | |||
| b7c0669c38 | |||
| 835a30cffe | |||
| 83e20c2dfa | |||
| f85d41945a | |||
| 149eb17b27 | |||
| 18b90da6ab | |||
| 72d469b19c | |||
| 6526d7f225 | |||
| 36c0edc62e | |||
| aad26b9ae4 | |||
| 80dcdfe251 | |||
| 1696fc37a8 | |||
| 7759d87835 | |||
| c085de1e9e | |||
| e99cd52e99 | |||
| e38359c74f | |||
| 2cfd7e867b | |||
| ec5a2b0cbe | |||
| 2bf426bc22 | |||
| ed8545f8a2 | |||
| 9811492a0b | |||
| 973925b35d | |||
| 0b30cfc341 | |||
| a7e44944fb | |||
| 9b25ebd6cd | |||
| 33b775e263 | |||
| b0edc6302e | |||
| 1a644b131d | |||
| f7689b7a5a | |||
| 1073011a2a | |||
| 6fa0009ebf | |||
| 42c0abedb7 | |||
| 56e59a93f3 | |||
| 9d581f3897 | |||
| e5ac6ec7cd | |||
| 694fe98131 | |||
| 730078597e | |||
| 32ac8db803 | |||
| aad2ed2719 | |||
| a790fed297 | |||
| deb2d5ce2a | |||
| 093097c619 | |||
| 2cdeb7c104 | |||
| 4dbb04bfb8 | |||
| fa8b921635 | |||
| 33dd10c670 | |||
| ba24e1c4f5 | |||
| f079714caf | |||
| 152d2fb1d6 | |||
| d374b4f66e | |||
| efd2792197 | |||
| efe22c889c | |||
| 7f2b3eb481 | |||
| 81be4a35d9 | |||
| e84da6a8df | |||
| 3ef3ede7df | |||
| 1e521b0b54 | |||
| 59ea701304 | |||
| 7d7c54107d | |||
| f0f6673d69 | |||
| 71561d0e65 | |||
| af87edf3a6 | |||
| 13caad18c7 | |||
| 47522b7e3a | |||
| c8c8d74452 | |||
| e7098963d6 | |||
| f015fb592b | |||
| c099167905 | |||
| b0e274f5a9 | |||
| 146c287aba | |||
| 95ee56217c | |||
| 7625cc208d | |||
| f2dfd939bc | |||
| 699bc8ddb4 | |||
| e658e007e1 | |||
| 3103265450 | |||
| 97789d9e2e | |||
| a1aa3d96d4 | |||
| ae39bc0ecd | |||
| e4d81aa610 | |||
| f59b524266 | |||
| 58c499acae | |||
| f22301a1eb | |||
| d6264fb39a | |||
| 08b5a9b34b | |||
| 263976bf41 | |||
| 0e4cd3a5ba | |||
| 192542629f | |||
| aeed271f3e | |||
| d41e9bbe68 | |||
| 904a5520b8 | |||
| 0f43ae4871 | |||
| bd0cf98319 | |||
| fc7d6bbaf9 | |||
| 1b89c328de | |||
| e7f147d4ca | |||
| 41dbc7d5ed | |||
| 9f297317f2 | |||
| e0a8721207 | |||
| a69b310109 | |||
| 864a6a39cd | |||
| 1882d4df88 | |||
| ef3f71e9b5 | |||
| 9981c4c035 | |||
| bc267b7107 | |||
| 5cbb767e5f | |||
| ac79621cae | |||
| f47c94d05b | |||
| 10773432bb | |||
| d5a56fb71d | |||
| b5518d029e | |||
| 9a9ae649ef | |||
| aa6dc67015 | |||
| 0da96b06ea | |||
| 6649f15498 | |||
| 49854f31a5 | |||
| 762c79511b | |||
| 6e29ebd649 | |||
| e128b8c282 | |||
| 832e382888 | |||
| 8a68b899f5 | |||
| 71f45b8a80 | |||
| ecf008a8d9 | |||
| b3880e5c02 | |||
| 58004a8ec9 | |||
| b0ff7d2707 | |||
| 262f7bd090 | |||
| b1558b09b1 | |||
| a33de8268b | |||
| c92d5096c4 | |||
| 9867d3c876 | |||
| 70bddba55b | |||
| 23181f4019 | |||
| 19c1c2042b | |||
| 9cc3190e3a | |||
| 72f179578b | |||
| 4c873e7c48 | |||
| 56fcb80b23 | |||
| 41c68148a9 | |||
| 5d38059b95 | |||
| df0198df9c | |||
| 26713b53f6 | |||
| 2bc6b28978 | |||
| f1ed4da8a4 | |||
| adeefb9dbd | |||
| bf1cc705a5 | |||
| 0b82dae01e | |||
| 75afe6ece2 | |||
| 70e2fe1e4e | |||
| 17e03457e1 | |||
| 4d8cb7e231 | |||
| 9561d23f1e | |||
| 0275f44056 | |||
| a2e1c7b751 | |||
| 9e2ec72ced | |||
| 02fe1f3abd | |||
| c30fe5b574 | |||
| a0b9ea76a3 | |||
| eb3a3eac98 | |||
| 02d2e09709 | |||
| 6e8283957c | |||
| bb9b94a983 | |||
| 36f807ddbc | |||
| cb31546c6b | |||
| 2d48adc9f9 | |||
| 113b7a0b84 | |||
| ca279ee3e0 | |||
| a5808200f0 | |||
| aa9de4d2ac | |||
| 50821e972d | |||
| e5b9dfee66 | |||
| 8e09070bc1 | |||
| 3d9c413bbb | |||
| 86670c1d60 | |||
| 5f2ee77a73 | |||
| 714e3a8fb1 | |||
| 672555f051 | |||
| 7853f2b096 | |||
| 61a7dda29a | |||
| f9bcb25eaa | |||
| 6df804ab32 | |||
| 9e50735800 | |||
| a30119f5e8 | |||
| 3c9f0946d2 | |||
| 21861a6dcd | |||
| 6135f2b727 | |||
| 3a616cc804 | |||
| d5eb67a8fc | |||
| 3ac7ef23ae | |||
| 08c5ec2be7 | |||
| a88b79e066 | |||
| a48ef77ee5 | |||
| 55f3807491 | |||
| dbfb8e83e8 | |||
| 515afc14a5 | |||
| 903e8c0fa1 | |||
| 7f9c760380 | |||
| 530a1a5350 | |||
| 3fb36c1434 | |||
| 5d20956a0e | |||
| 29f91e8276 | |||
| 4d9e38d022 | |||
| d7d31b78f0 | |||
| e8ed8fc7e9 | |||
| 5ee406c92e | |||
| 89f4f4ba6f | |||
| 4e5fd2f2b0 | |||
| 22a5743817 | |||
| 40e05f903a | |||
| 6c1e1bc0ca | |||
| cebbf9ca65 | |||
| e6646e5833 | |||
| 00f7510195 | |||
| 8d1f5e1096 | |||
| e497fcc3b9 | |||
| a7010b2788 | |||
| abf58a70ee | |||
| d4c602cf91 | |||
| d1b0bbb6d9 | |||
| ae0be3a4bc | |||
| a8f3f71021 | |||
| 933ca71c16 | |||
| 591cb9cdc1 | |||
| 34b18bdb53 | |||
| 7d475f5e91 | |||
| 39c35fa539 | |||
| bada1fdb97 | |||
| d9303f87c8 | |||
| e1bc0b7efd | |||
| 5ef068c8dc | |||
| 14680df160 | |||
| 94c01a23a9 | |||
| 609fb357bb | |||
| cf9a17cc2e | |||
| 538b57941c | |||
| f4bee6406a | |||
| 0b2693a317 | |||
| 1580b7abff | |||
| 3be2d928f6 | |||
| e5398d441e | |||
| d38f9ac2bb | |||
| 8d018fe47d | |||
| 34d5c9535d | |||
| dc60ef712f | |||
| dc7eb630c3 | |||
| d4138e2141 | |||
| bbcd352817 | |||
| 21d9199978 | |||
| 3e7b9d7bff | |||
| 54acdbe282 | |||
| 11bbd028fc | |||
| 7ae99731f4 | |||
| 17b4fd9a44 | |||
| caa398ae54 | |||
| 035e324e5a | |||
| 272a5737b0 | |||
| be50fd4586 | |||
| 8301511efe | |||
| c527ce6562 | |||
| 80bee900aa | |||
| 54209c2390 | |||
| 3b0ee8d9d4 | |||
| 7a0a877dc0 | |||
| b6ed762724 | |||
| d1d58cf455 | |||
| e22f30c694 | |||
| d71c112959 | |||
| 199e960f6d | |||
| 843b9a0b16 | |||
| b9aedc65fe | |||
| 11f5cfd4f8 | |||
| 2ff2e32e9c | |||
| facb1e4cdf | |||
| 6d129c0738 | |||
| 67a3d226f5 | |||
| 19a3773880 | |||
| 09d5d9082e | |||
| bb5547a157 | |||
| 8eab3b1ee2 | |||
| 30b46ebb6e | |||
| 91d5f89ab9 | |||
| 703050f949 | |||
| 97af17a993 | |||
| 09a8c457ac | |||
| 80317aca83 | |||
| bd10519598 | |||
| 15c7c47b3c | |||
| 0a7e8c3cc2 | |||
| fc2668fca8 | |||
| 8f2c20a2bc | |||
| 462ae7ee8c | |||
| 808abd2038 | |||
| 3de25e51ff | |||
| ef25a9dbe0 | |||
| e21c3afb13 | |||
| cb7962c25d | |||
| f667ee4feb | |||
| 810119e4ee | |||
| d082de3d88 | |||
| 1e7863ce4f | |||
| a942871f3d | |||
| 2fe85ebda2 | |||
| da1205d38e | |||
| b1f7ee02b0 | |||
| affa3bbb6a | |||
| 5cb316f4e9 | |||
| 6b6c9cf4d8 | |||
| 05617c63c0 | |||
| 11c30b8b27 | |||
| 3d13e9105c | |||
| 289be30e6b | |||
| d4c52bbf2f | |||
| a74c0c227c | |||
| 05bf77da29 | |||
| bb4deb1ae9 | |||
| 87f50bf0cc | |||
| 2c66da1b19 | |||
| ab19955502 | |||
| 1db9dcec81 | |||
| 49c2d3163e | |||
| 45b9e13a13 | |||
| 8d29602929 | |||
| 6c2a8bf6de | |||
| 697be01411 | |||
| 1bd6f240cc | |||
| 278375f7be | |||
| 65c383520b | |||
| e54bd25516 | |||
| 8f0e17774f | |||
| 2ab5540085 | |||
| 4e7328a1cc | |||
| 6380079239 | |||
| b6aba13d3a | |||
| c0a9db92ef | |||
| 3bc5ab2136 | |||
| ce77268c82 | |||
| abc0a41d9e | |||
| a68b57067c | |||
| 444d293a29 | |||
| 35d16ac683 |
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: crud-endpoints
|
||||
description: Use whenever asked to add, create, or scaffold a CRUD endpoint, router, or entity in this repo's server (create/list/get/update/delete handlers, new `server/routers/<entity>/` or `server/private/routers/<entity>/` folder). Points to the established file layout, middleware, ActionsEnum, and route-registration conventions before writing any code.
|
||||
---
|
||||
|
||||
Before writing any router/handler/middleware code for a new entity, read
|
||||
`docs/crud-endpoints.md` in full. It documents, with real examples from
|
||||
`server/routers/aiProvider/` (public) and `server/private/routers/alertRule/`
|
||||
(enterprise-only), how this repo structures CRUD endpoints:
|
||||
|
||||
- Directory/file layout per entity (`index.ts`, `types.ts`, `validation.ts`,
|
||||
one file per operation).
|
||||
- The standard handler anatomy (zod parsing, OpenAPI registry, response
|
||||
envelope, error handling).
|
||||
- Where access-control middleware (`verify<Entity>Access`) lives and when
|
||||
it's needed vs. plain `verifyOrgAccess`.
|
||||
- How to wire up `ActionsEnum` entries, `verifyUserHasAction`, and
|
||||
`logActionAudit`.
|
||||
- Which of the four router files (`server/routers/external.ts`,
|
||||
`server/routers/internal.ts`, `server/private/routers/external.ts`,
|
||||
`server/private/routers/internal.ts`) to register routes in, and the
|
||||
middleware chain template per HTTP verb.
|
||||
- The repo's non-standard verb convention: **`PUT` = create, `POST` =
|
||||
update** (backwards from typical REST) — don't "fix" this to standard
|
||||
REST verbs, match the existing convention.
|
||||
- The `#dynamic` import alias, for the rare case of a hook needing different
|
||||
implementations in OSS vs. enterprise builds.
|
||||
|
||||
Follow that doc's checklist (§8) step by step rather than improvising a
|
||||
structure. If the doc and the actual code in `aiProvider`/`alertRule` ever
|
||||
disagree, trust the code and flag the doc as stale.
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Monitor storage space
|
||||
run: |
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Monitor storage space
|
||||
run: |
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -201,10 +201,10 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Extract tag name
|
||||
id: get-tag
|
||||
@@ -264,7 +264,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: 1.25
|
||||
|
||||
@@ -407,7 +407,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Login to GitHub Container Registry (for cosign)
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -21,10 +21,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
|
||||
@@ -14,10 +14,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Build Docker image sqlite
|
||||
run: make dev-build-sqlite
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Build Docker image pg
|
||||
run: make dev-build-pg
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# FROM node:24-slim AS base
|
||||
FROM public.ecr.aws/docker/library/node:24-slim AS base
|
||||
# FROM node:24.18.1-slim AS base
|
||||
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -32,8 +32,8 @@ FROM base AS builder
|
||||
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# FROM node:24-slim AS runner
|
||||
FROM public.ecr.aws/docker/library/node:24-slim AS runner
|
||||
# FROM node:24.18.1-slim AS runner
|
||||
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine
|
||||
FROM node:24.18.1-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
|
||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -63,11 +63,26 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
|
||||
|
||||
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
|
||||
|
||||
* Lightweight user-space connector runs anywhere
|
||||
* Punches through any firewall
|
||||
* Doesn't require open ports or a public IP
|
||||
* Strict network segmentation
|
||||
* WireGuard-based
|
||||
* Get alerts when a device or network resource goes down
|
||||
|
||||
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
||||
|
||||
### Browser-based reverse proxy access
|
||||
|
||||
Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
||||
Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the browser through identity and context-aware tunneled reverse proxies. Users access resources with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
||||
|
||||
* Expose a web panel anywhere
|
||||
* Access via any web browser
|
||||
* Single sign-on across all resources
|
||||
* HTTPS resources
|
||||
* Remote desktop in the browser with VNC and RDP
|
||||
* In-browser SSH terminal with privileged access management (PAM)
|
||||
* PIN codes, passcodes, email OTP, geoblocking, allow-lists, and more
|
||||
|
||||
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
||||
|
||||
@@ -75,14 +90,35 @@ Expose web applications through identity and context-aware tunneled reverse prox
|
||||
|
||||
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
|
||||
|
||||
* Peer-to-peer with intelligent NAT traversal
|
||||
* Hosts/IPs and port ranges
|
||||
* Network ranges/CIDRs
|
||||
* Friendly DNS aliases for network addresses
|
||||
* Privileged access management (PAM) with SSH resources
|
||||
* Private HTTPS resources only accessible on the private network
|
||||
|
||||
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
||||
|
||||
### Give users and roles access to resources
|
||||
|
||||
Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
||||
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
||||
|
||||
* Bring your existing identity provider (IdP) or use Pangolin identities
|
||||
* Sync users and roles from your IdP
|
||||
* User- and role-based access control
|
||||
* Full network audit and access logs
|
||||
|
||||
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
||||
|
||||
### Find and launch resources from a personalized home page
|
||||
|
||||
Give users a landing page to quickly find and open the resources they can access. Resources are grouped by site or label, searchable, and filterable, with grid or list views. Saved views capture filters, grouping, and layout as personal or organization-wide defaults.
|
||||
|
||||
* Single place for admins and non-admins to see accessible resources
|
||||
* Create reusable views for common access patterns
|
||||
|
||||
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
|
||||
|
||||
## Download Clients
|
||||
|
||||
Download the Pangolin client for your platform:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommandModule } from "yargs";
|
||||
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db";
|
||||
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders, virtualApiKeys } from "@server/db";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -132,12 +132,16 @@ export const rotateServerSecret: CommandModule<
|
||||
const certs = await db.select().from(certificates);
|
||||
const streamingDestinations = await db.select().from(eventStreamingDestinations);
|
||||
const webhookActions = await db.select().from(alertWebhookActions);
|
||||
const providers = await db.select().from(aiProviders);
|
||||
const virtualKeys = await db.select().from(virtualApiKeys);
|
||||
|
||||
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
|
||||
console.log(`Found ${licenseKeys.length} license key(s)`);
|
||||
console.log(`Found ${certs.length} certificate(s)`);
|
||||
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
|
||||
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
|
||||
console.log(`Found ${providers.length} AI provider(s)`);
|
||||
console.log(`Found ${virtualKeys.length} virtual API key(s)`);
|
||||
|
||||
// Prepare all decrypted and re-encrypted values
|
||||
console.log("\nDecrypting and re-encrypting values...");
|
||||
@@ -171,11 +175,24 @@ export const rotateServerSecret: CommandModule<
|
||||
encryptedConfig: string;
|
||||
};
|
||||
|
||||
type AiProviderUpdate = {
|
||||
providerId: number;
|
||||
encryptedApiKey: string | null;
|
||||
encryptedHeaders: string | null;
|
||||
};
|
||||
|
||||
type VirtualApiKeyUpdate = {
|
||||
virtualApiKeyId: string;
|
||||
encryptedToken: string;
|
||||
};
|
||||
|
||||
const idpUpdates: IdpUpdate[] = [];
|
||||
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
||||
const certUpdates: CertUpdate[] = [];
|
||||
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
|
||||
const webhookActionUpdates: WebhookActionUpdate[] = [];
|
||||
const aiProviderUpdates: AiProviderUpdate[] = [];
|
||||
const virtualApiKeyUpdates: VirtualApiKeyUpdate[] = [];
|
||||
|
||||
// Process idpOidcConfig entries
|
||||
for (const idpConfig of idpConfigs) {
|
||||
@@ -306,6 +323,60 @@ export const rotateServerSecret: CommandModule<
|
||||
}
|
||||
}
|
||||
|
||||
// Process aiProviders entries (apiKey + headers)
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
if (!provider.apiKey && !provider.headers) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedApiKey = provider.apiKey
|
||||
? encrypt(decrypt(provider.apiKey, oldSecret), newSecret)
|
||||
: null;
|
||||
const encryptedHeaders = provider.headers
|
||||
? encrypt(
|
||||
decrypt(provider.headers, oldSecret),
|
||||
newSecret
|
||||
)
|
||||
: null;
|
||||
|
||||
aiProviderUpdates.push({
|
||||
providerId: provider.providerId,
|
||||
encryptedApiKey,
|
||||
encryptedHeaders
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error processing AI provider ${provider.providerId}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Process virtualApiKeys entries (token)
|
||||
for (const key of virtualKeys) {
|
||||
try {
|
||||
if (!key.token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
virtualApiKeyUpdates.push({
|
||||
virtualApiKeyId: key.virtualApiKeyId,
|
||||
encryptedToken: encrypt(
|
||||
decrypt(key.token, oldSecret),
|
||||
newSecret
|
||||
)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error processing virtual API key ${key.virtualApiKeyId}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform all database updates in a single transaction
|
||||
console.log("\nUpdating database in transaction...");
|
||||
await db.transaction(async (trx) => {
|
||||
@@ -376,6 +447,32 @@ export const rotateServerSecret: CommandModule<
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Update AI provider entries
|
||||
for (const update of aiProviderUpdates) {
|
||||
await trx
|
||||
.update(aiProviders)
|
||||
.set({
|
||||
apiKey: update.encryptedApiKey,
|
||||
headers: update.encryptedHeaders
|
||||
})
|
||||
.where(eq(aiProviders.providerId, update.providerId));
|
||||
}
|
||||
|
||||
// Update virtual API key entries
|
||||
for (const update of virtualApiKeyUpdates) {
|
||||
await trx
|
||||
.update(virtualApiKeys)
|
||||
.set({
|
||||
token: update.encryptedToken
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
virtualApiKeys.virtualApiKeyId,
|
||||
update.virtualApiKeyId
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
|
||||
@@ -383,6 +480,8 @@ export const rotateServerSecret: CommandModule<
|
||||
console.log(`Rotated ${certUpdates.length} certificate(s)`);
|
||||
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
|
||||
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
|
||||
console.log(`Rotated ${aiProviderUpdates.length} AI provider(s)`);
|
||||
console.log(`Rotated ${virtualApiKeyUpdates.length} virtual API key(s)`);
|
||||
|
||||
// Update config file with new secret
|
||||
console.log("\nUpdating config file...");
|
||||
@@ -402,6 +501,7 @@ export const rotateServerSecret: CommandModule<
|
||||
console.log(` - Certificates: ${certUpdates.length}`);
|
||||
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
|
||||
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
|
||||
console.log(` - AI providers: ${aiProviderUpdates.length}`);
|
||||
console.log(
|
||||
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
- 80:80 # Port for traefik because of the network_mode
|
||||
|
||||
traefik:
|
||||
image: traefik:v3.6
|
||||
image: traefik:v3.7
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
POSTGRES_USER: postgres # Default user
|
||||
POSTGRES_PASSWORD: password # Default password (change for production!)
|
||||
volumes:
|
||||
- ./config/postgres:/var/lib/postgresql/data
|
||||
- ${HOME}/.local/share/pangolin-dev/postgres:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432" # Map host port 5432 to container port 5432
|
||||
restart: no
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
# AI Gateway Provider Selection
|
||||
|
||||
How the AI gateway picks which attached provider handles a request when an
|
||||
inference resource has more than one AI provider.
|
||||
|
||||
**Code:**
|
||||
|
||||
- Route → capability binding: `server/routers/aiGateway/createAiGatewayRouter.ts`
|
||||
- Request pipeline: `server/routers/aiGateway/pipeline.ts` (`selectProvider`)
|
||||
- Tie-break scoring: `server/lib/aiProviderSelection.ts`
|
||||
- Allow/block matching: `server/lib/aiModelKeyMatch.ts`
|
||||
- Model catalog: `server/lib/aiModelCatalog.ts`
|
||||
- Default capabilities per provider type: `server/lib/aiProviderDefaults.ts`
|
||||
|
||||
Overlapping model allows are permitted at save time. Selection happens at
|
||||
request time. If the algorithm cannot confidently pick one provider, the
|
||||
gateway returns `403` with an ambiguous-provider error.
|
||||
|
||||
## Selection Pipeline
|
||||
|
||||
Every gateway request runs through these steps in order. Each step narrows
|
||||
the candidate set. Later steps only run when more than one provider remains.
|
||||
|
||||
```
|
||||
1. Capability filter
|
||||
2. Allow / block lists
|
||||
3. Most specific allow pattern
|
||||
4. Catalog ownership
|
||||
5. Provider class preference
|
||||
6. Ambiguous → error
|
||||
```
|
||||
|
||||
### 1. Capability Filter
|
||||
|
||||
The incoming path selects a capability before any provider logic runs.
|
||||
|
||||
| Path | Capability |
|
||||
|------|------------|
|
||||
| `POST /v1/chat/completions` | `openai_chat` |
|
||||
| `POST /v1/responses` | `openai_responses` |
|
||||
| `POST /v1/messages` | `anthropic_messages` |
|
||||
| Gemini / Vertex / Bedrock routes | their respective capability ids |
|
||||
|
||||
Only attached providers that advertise that capability stay in the candidate
|
||||
set. Default capabilities do not overlap for native OpenAI vs Anthropic:
|
||||
|
||||
| Provider type | Default capabilities |
|
||||
|---------------|----------------------|
|
||||
| `openai` | `openai_chat`, `openai_responses` |
|
||||
| `anthropic` | `anthropic_messages` |
|
||||
| `openRouter` | `openai_chat` |
|
||||
| `vercelAiGateway` | `openai_chat`, `openai_responses` |
|
||||
| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages` |
|
||||
| `custom` | whatever was configured |
|
||||
|
||||
### 2. Allow / Block Lists
|
||||
|
||||
For each remaining provider, the gateway resolves the effective allow and
|
||||
block patterns:
|
||||
|
||||
- **`inherit`**: use the provider's own model lists
|
||||
- **`select`**: use the resource-selected subset of those lists
|
||||
|
||||
A candidate is kept only if `isAllowedByLists(requestedModel, allows, blocks)`
|
||||
passes:
|
||||
|
||||
1. At least one allow pattern must match
|
||||
2. No block pattern may match
|
||||
|
||||
Patterns support `*` and `?` globs (`gpt-*`, `claude-3-5-sonnet-?`).
|
||||
|
||||
### 3. Most Specific Allow Pattern
|
||||
|
||||
Among providers that allow the model, keep those whose matching allow
|
||||
pattern is most specific:
|
||||
|
||||
1. Exact keys beat patterns
|
||||
2. Fewer wildcard characters win
|
||||
3. Longer literal length wins
|
||||
|
||||
Example: `gpt-4o` beats `gpt-*` beats `*`.
|
||||
|
||||
### 4. Catalog Ownership
|
||||
|
||||
When specificity is tied (common with multiple `*` allows), score each
|
||||
provider against the known model catalog:
|
||||
|
||||
| Score | Meaning |
|
||||
|------:|---------|
|
||||
| 2 | Typed provider whose catalog contains the model (`openai` → openai catalog, `anthropic` → anthropic, etc.) |
|
||||
| 1 | Aggregator or custom (`openRouter`, `vercelAiGateway`, `custom`) and the model exists somewhere in the catalog |
|
||||
| 0 | No ownership signal (typed catalog miss, or unknown model on aggregator/custom) |
|
||||
|
||||
Model id lookup tries the raw id, then a stripped `vendor/model` form
|
||||
(e.g. `openai/gpt-4o` → also try `gpt-4o`).
|
||||
|
||||
Typed providers map to catalog providers as:
|
||||
|
||||
| Provider type | Catalog |
|
||||
|---------------|---------|
|
||||
| `openai` | `openai` |
|
||||
| `anthropic` | `anthropic` |
|
||||
| `googleGemini` | `gemini` |
|
||||
| `vertexAi` | `vertex` |
|
||||
| `bedrock` | `bedrock` |
|
||||
| `microsoftFoundry` | `azure` |
|
||||
| `openRouter` / `vercelAiGateway` / `custom` | none (aggregator/custom path) |
|
||||
|
||||
### 5. Provider Class Preference
|
||||
|
||||
If catalog ownership is still tied, prefer:
|
||||
|
||||
| Rank | Class |
|
||||
|-----:|-------|
|
||||
| 2 | Native typed provider (`openai`, `anthropic`, `googleGemini`, …) |
|
||||
| 1 | Aggregator (`openRouter`, `vercelAiGateway`) |
|
||||
| 0 | `custom` |
|
||||
|
||||
### 6. Ambiguous Error
|
||||
|
||||
If more than one distinct provider remains after all steps, the gateway
|
||||
rejects the request:
|
||||
|
||||
```
|
||||
Model "<id>" is ambiguous across multiple AI providers on this resource
|
||||
```
|
||||
|
||||
Typical remaining ties: two OpenAI-type providers both with `*`, or two
|
||||
customs advertising the same capability for an unknown model.
|
||||
|
||||
## Examples
|
||||
|
||||
Assume each provider below is attached and enabled on the same inference
|
||||
resource.
|
||||
|
||||
### Example A: OpenAI + Anthropic, Both `*`
|
||||
|
||||
| Provider | Allow | Capabilities |
|
||||
|----------|-------|--------------|
|
||||
| OpenAI | `*` | `openai_chat`, `openai_responses` |
|
||||
| Anthropic | `*` | `anthropic_messages` |
|
||||
|
||||
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
|
||||
|
||||
1. Capability → only OpenAI remains
|
||||
2. Allow → OpenAI matches `*`
|
||||
3. Result → **OpenAI**
|
||||
|
||||
Anthropic never reaches pattern or catalog scoring. Capability alone decides.
|
||||
|
||||
**Request:** `POST /v1/messages` with `model: "claude-3-5-sonnet-latest"`
|
||||
|
||||
1. Capability → only Anthropic remains
|
||||
2. Result → **Anthropic**
|
||||
|
||||
### Example B: OpenAI + OpenRouter, Both `*`
|
||||
|
||||
| Provider | Allow | Capabilities |
|
||||
|----------|-------|--------------|
|
||||
| OpenAI | `*` | `openai_chat`, … |
|
||||
| OpenRouter | `*` | `openai_chat` |
|
||||
|
||||
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
|
||||
|
||||
1. Capability → both remain (`openai_chat`)
|
||||
2. Allow → both match `*`
|
||||
3. Specificity → tie (`*` vs `*`)
|
||||
4. Catalog → OpenAI scores `2` (owns `gpt-4o`); OpenRouter scores `1`
|
||||
5. Result → **OpenAI**
|
||||
|
||||
### Example C: OpenRouter Only Serving a Claude Model Over OpenAI Chat
|
||||
|
||||
| Provider | Allow | Capabilities |
|
||||
|----------|-------|--------------|
|
||||
| OpenRouter | `*` | `openai_chat` |
|
||||
|
||||
**Request:** `POST /v1/chat/completions` with `model: "anthropic/claude-3.5-sonnet"`
|
||||
|
||||
1. Capability → OpenRouter remains
|
||||
2. Only one candidate → **OpenRouter**
|
||||
|
||||
No tie-breaking needed.
|
||||
|
||||
### Example D: OpenAI (`gpt-*`) + OpenRouter (`*`)
|
||||
|
||||
| Provider | Allow |
|
||||
|----------|-------|
|
||||
| OpenAI | `gpt-*` |
|
||||
| OpenRouter | `*` |
|
||||
|
||||
**Request:** `model: "gpt-4o"` on `openai_chat`
|
||||
|
||||
1. Capability → both
|
||||
2. Allow → both match
|
||||
3. Specificity → OpenAI's `gpt-*` beats OpenRouter's `*`
|
||||
4. Result → **OpenAI**
|
||||
|
||||
Catalog scoring is not needed because specificity already unique'd the set.
|
||||
|
||||
### Example E: OpenAI + Anthropic With Overlapping Custom Capabilities
|
||||
|
||||
Someone grants Anthropic `openai_chat` as well (non-default).
|
||||
|
||||
| Provider | Allow | Capabilities |
|
||||
|----------|-------|--------------|
|
||||
| OpenAI | `*` | `openai_chat`, … |
|
||||
| Anthropic | `*` | `anthropic_messages`, `openai_chat` |
|
||||
|
||||
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
|
||||
|
||||
1. Capability → both remain
|
||||
2. Allow → both match `*`
|
||||
3. Specificity → tie
|
||||
4. Catalog → OpenAI `2`, Anthropic `0` (`gpt-4o` is not in the anthropic catalog)
|
||||
5. Result → **OpenAI**
|
||||
|
||||
### Example F: Two Aggregators, Known Model
|
||||
|
||||
| Provider | Allow |
|
||||
|----------|-------|
|
||||
| OpenRouter | `*` |
|
||||
| Vercel AI Gateway | `*` |
|
||||
|
||||
**Request:** `model: "gpt-4o"` on `openai_chat`
|
||||
|
||||
1. Capability → both
|
||||
2. Allow / specificity → tie
|
||||
3. Catalog → both score `1` (known model, no typed owner in the set)
|
||||
4. Class → both aggregators (rank `1`) → still tied
|
||||
5. Result → **ambiguous error**
|
||||
|
||||
Attach a native OpenAI provider (or narrow one aggregator's allow list) to
|
||||
make this determinable.
|
||||
|
||||
### Example G: Two OpenAI Providers, Both `*`
|
||||
|
||||
| Provider | Type | Allow |
|
||||
|----------|------|-------|
|
||||
| OpenAI Prod | `openai` | `*` |
|
||||
| OpenAI Staging | `openai` | `*` |
|
||||
|
||||
**Request:** `model: "gpt-4o"`
|
||||
|
||||
1–5 all leave both candidates (same capability, same specificity, same
|
||||
catalog ownership, same class).
|
||||
|
||||
Result → **ambiguous error**
|
||||
|
||||
Disambiguate with different allow patterns, disable one attachment, or
|
||||
split across resources.
|
||||
|
||||
### Example H: Unknown Model Across Native + Aggregator
|
||||
|
||||
| Provider | Allow |
|
||||
|----------|-------|
|
||||
| OpenAI | `*` |
|
||||
| OpenRouter | `*` |
|
||||
|
||||
**Request:** `model: "my-fine-tune-v3"` (not in catalog)
|
||||
|
||||
1. Capability → both
|
||||
2. Allow / specificity → tie
|
||||
3. Catalog → both score `0` (typed miss + unknown aggregator model)
|
||||
4. Class → OpenAI (`2`) beats OpenRouter (`1`)
|
||||
5. Result → **OpenAI**
|
||||
|
||||
## Practical Guidance
|
||||
|
||||
- Native OpenAI + Anthropic with `*` is safe. Different default APIs never
|
||||
collide.
|
||||
- OpenAI + OpenRouter with `*` is usually fine for catalog-known OpenAI
|
||||
models. Native wins.
|
||||
- Prefer specific allow patterns (`gpt-4o`, `gpt-*`) when two providers share
|
||||
a capability.
|
||||
- Two providers of the same type both using `*` will stay ambiguous. Narrow
|
||||
at least one allow list.
|
||||
- Custom providers only win ties when no stronger native/aggregator signal
|
||||
remains.
|
||||
|
||||
## Related Behavior
|
||||
|
||||
- **Saving providers on a resource does not reject overlapping allows.**
|
||||
Collisions are resolved (or rejected) per request.
|
||||
- Budgets, auth, and upstream URL / target routing run after a single
|
||||
provider has been selected.
|
||||
@@ -0,0 +1,347 @@
|
||||
# How to build a CRUD endpoint in this repo
|
||||
|
||||
Reference for adding a new CRUD entity to the server. Based on two real
|
||||
examples already in the codebase — read them side by side with this doc:
|
||||
|
||||
- **Public / open-source (Community Edition) pattern**: `server/routers/aiProvider/`
|
||||
- **Enterprise-only pattern**: `server/private/routers/alertRule/`
|
||||
|
||||
The two are structurally identical. The only difference is *where the files
|
||||
live* and *which router they get wired into*.
|
||||
|
||||
## 1. Decide: public or private?
|
||||
|
||||
- `server/routers/<entity>/` — ships in the open-source Community Edition.
|
||||
Anyone running Pangolin gets this.
|
||||
- `server/private/routers/<entity>/` — Enterprise/SaaS only. Gated behind
|
||||
`verifyValidLicense` (and often `verifyValidSubscription(tierMatrix.x)`).
|
||||
Every file here starts with the Fossorial Commercial License header block
|
||||
(copy it verbatim from an existing private file).
|
||||
|
||||
Everything below applies to both — swap `@server/...` for `#private/...`
|
||||
import paths and add license headers when building the private version.
|
||||
|
||||
## 2. Directory layout
|
||||
|
||||
One folder per entity, one file per operation, a barrel `index.ts`:
|
||||
|
||||
```
|
||||
server/routers/<entity>/
|
||||
index.ts # export * from each operation file + ./types
|
||||
types.ts # response payload types + row->public mapper
|
||||
validation.ts # zod schemas/refinements shared by create + update (optional)
|
||||
create<Entity>.ts
|
||||
list<Entities>.ts
|
||||
get<Entity>.ts
|
||||
update<Entity>.ts
|
||||
delete<Entity>.ts
|
||||
```
|
||||
|
||||
`index.ts` is a flat barrel:
|
||||
|
||||
```ts
|
||||
export * from "./createAiProvider";
|
||||
export * from "./listAiProviders";
|
||||
export * from "./getAiProvider";
|
||||
export * from "./updateAiProvider";
|
||||
export * from "./deleteAiProvider";
|
||||
export * from "./types";
|
||||
```
|
||||
|
||||
## 3. Anatomy of a single handler
|
||||
|
||||
Every handler file (`create<Entity>.ts`, etc.) follows the same shape:
|
||||
|
||||
```ts
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { <table>, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { GetXResponse } from "@server/routers/<entity>/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty() // or entityId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({ /* ... */ }); // create/update only
|
||||
|
||||
registry.registerPath({
|
||||
method: "get", // put | post | delete
|
||||
path: "/org/{orgId}/x",
|
||||
description: "...",
|
||||
tags: [OpenAPITags.<Entity>],
|
||||
request: { params: paramsSchema, /* body: {...} for write ops, query: for list */ },
|
||||
responses: { 200: { description: "Successful response" } }
|
||||
});
|
||||
|
||||
export async function getX(req: Request, res: Response, next: NextFunction): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString()));
|
||||
}
|
||||
// parse body too, if present, same pattern
|
||||
|
||||
// ...business logic against db...
|
||||
|
||||
if (!row) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, `X with ID ${id} not found`));
|
||||
}
|
||||
|
||||
return response<GetXResponse>(res, {
|
||||
data: { /* ... */ },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "X retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules to keep consistent with the rest of the codebase:
|
||||
|
||||
- `z.strictObject` for params/body — rejects unknown keys.
|
||||
- Params parsed first, then body; each on its own `safeParse` + early
|
||||
`next(createHttpError(...))` — never throw raw errors.
|
||||
- Every handler registers itself with the OpenAPI `registry` even if nobody
|
||||
reads the spec directly — it's how `/api/v1/docs` stays accurate.
|
||||
- Catch-all `try/catch` at the bottom: `logger.error(error)` +
|
||||
generic `500` message. Never leak internal error details to the client.
|
||||
- Use `response<T>(res, { data, success, error, message, status })` from
|
||||
`@server/lib/response` for every response, success or otherwise (errors go
|
||||
through `next(createHttpError(...))` instead, not through `response`).
|
||||
- If the route already ran an access-control middleware that fetched the row
|
||||
(see §5), reuse it instead of re-querying:
|
||||
`req.aiProvider && req.aiProvider.providerId === providerId ? [req.aiProvider] : await db.select()...`
|
||||
|
||||
### List handler specifics
|
||||
|
||||
Pagination is a fixed shape (`page`, `pageSize`, optional `query` for
|
||||
search). See `listAiProviders.ts`:
|
||||
|
||||
```ts
|
||||
const listSchema = z.object({
|
||||
pageSize: z.coerce.number<string>().int().positive().optional().catch(20).default(20),
|
||||
page: z.coerce.number<string>().int().min(0).optional().catch(1).default(1),
|
||||
query: z.string().optional()
|
||||
});
|
||||
```
|
||||
|
||||
Run the count query and the page query in `Promise.all`, and return
|
||||
`PaginatedResponse<{ items: T[] }>` (`@server/types/Pagination`) with
|
||||
`{ total, pageSize, page }`.
|
||||
|
||||
### types.ts specifics
|
||||
|
||||
- Define one response type per operation: `List<Entities>Response`,
|
||||
`Get<Entity>Response`, `CreateOrEdit<Entity>Response` (create and update
|
||||
commonly share a response shape).
|
||||
- If the raw DB row needs to be shaped for clients (decrypting secrets,
|
||||
parsing a serialized column, hiding a column), put a `toPublic<Entity>()`
|
||||
mapper here — see `toPublicAiProvider` for the pattern of stripping
|
||||
`apiKey`/serialized columns and re-adding decrypted/parsed versions.
|
||||
|
||||
### validation.ts specifics
|
||||
|
||||
Only needed when create and update share non-trivial zod pieces (enums,
|
||||
`superRefine` cross-field rules). Export the raw schemas (`z.enum([...])`)
|
||||
and refinement functions, and import them into both `createX.ts` and
|
||||
`updateX.ts` — see `aiProvider/validation.ts`'s
|
||||
`refineProviderUpstreamFields`.
|
||||
|
||||
## 4. Wire up an access-control middleware (for id-scoped routes)
|
||||
|
||||
For routes scoped to a single row (`/x/:xId`, as opposed to
|
||||
`/org/:orgId/x` create/list), add a `verify<Entity>Access` middleware in
|
||||
`server/middlewares/` (or `server/private/middlewares/` for enterprise-only
|
||||
entities) and export it from that directory's `index.ts`.
|
||||
|
||||
Pattern (`verifyAiProviderAccess.ts`):
|
||||
|
||||
1. Read the id param, `Number.parseInt`/validate it.
|
||||
2. Load the row by id.
|
||||
3. `404` if it doesn't exist.
|
||||
4. Resolve the row's `orgId`, then check/attach `req.userOrg` (query
|
||||
`userOrgs` if not already on the request), `403` if the user isn't in
|
||||
that org.
|
||||
5. Run `checkOrgAccessPolicy` if `req.orgPolicyAllowed` hasn't been resolved
|
||||
yet.
|
||||
6. Set `req.userOrgId`, `req.userOrgRoleIds`, and stash the row on the
|
||||
request (e.g. `req.aiProvider = provider`) so downstream handlers and
|
||||
`verifyUserHasAction` don't have to refetch it.
|
||||
|
||||
Org-scoped create/list routes (`/org/:orgId/x`) don't need a bespoke
|
||||
middleware — they use the existing generic `verifyOrgAccess` from
|
||||
`@server/middlewares`.
|
||||
|
||||
## 5. Register an action + permission check
|
||||
|
||||
Add one `ActionsEnum` entry per operation in `server/auth/actions.ts`,
|
||||
grouped near the entity's other actions, named `create<Entity>`,
|
||||
`get<Entity>`, `update<Entity>`, `delete<Entity>`, `list<Entities>`:
|
||||
|
||||
```ts
|
||||
createAiProvider = "createAiProvider",
|
||||
deleteAiProvider = "deleteAiProvider",
|
||||
getAiProvider = "getAiProvider",
|
||||
listAiProviders = "listAiProviders",
|
||||
updateAiProvider = "updateAiProvider",
|
||||
```
|
||||
|
||||
Every route uses `verifyUserHasAction(ActionsEnum.x)` to check the caller's
|
||||
role/permissions for that action, and mutating routes (create/update/delete)
|
||||
follow it with `logActionAudit(ActionsEnum.x)` to record the action in the
|
||||
audit log.
|
||||
|
||||
## 6. Register the routes
|
||||
|
||||
There are four router files; which one(s) you touch depends on public vs.
|
||||
private and user-facing vs. service-to-service:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `server/routers/external.ts` | Public, user-facing API. Exports `authenticated`, `unauthenticated`, `authRouter` Express routers. |
|
||||
| `server/routers/internal.ts` | Public, internal service-to-service API (gerbil, badger, traefik-config) — no user auth, exports `internalRouter`. |
|
||||
| `server/private/routers/external.ts` | Enterprise-only, user-facing. Imports `authenticated`/`unauthenticated`/`authRouter` **from the public `external.ts`** and re-exports them, then adds more routes on top. |
|
||||
| `server/private/routers/internal.ts` | Enterprise-only, service-to-service. Same re-export trick with `internalRouter`. |
|
||||
|
||||
Private router files always start:
|
||||
|
||||
```ts
|
||||
import {
|
||||
unauthenticated as ua,
|
||||
authenticated as a,
|
||||
authRouter as aa
|
||||
} from "@server/routers/external";
|
||||
|
||||
export const authenticated = a;
|
||||
export const unauthenticated = ua;
|
||||
export const authRouter = aa;
|
||||
```
|
||||
|
||||
...and then call `authenticated.get/put/post/delete(...)` to bolt on
|
||||
additional, enterprise-only routes on the *same* router instances the public
|
||||
build uses. This is why the private build has strictly more routes than the
|
||||
public build, not a divergent copy.
|
||||
|
||||
### Route registration order (mutating vs read)
|
||||
|
||||
Standard middleware chain per verb, using `alertRule`'s registrations as the
|
||||
template:
|
||||
|
||||
```ts
|
||||
// Create — org-scoped, no row exists yet
|
||||
authenticated.put(
|
||||
"/org/:orgId/x",
|
||||
verifyValidLicense, // private/enterprise routes only
|
||||
verifyOrgAccess,
|
||||
verifyLimits, // if the entity counts against a plan limit
|
||||
verifyUserHasAction(ActionsEnum.createX),
|
||||
logActionAudit(ActionsEnum.createX),
|
||||
x.createX
|
||||
);
|
||||
|
||||
// Update — row-scoped
|
||||
authenticated.post(
|
||||
"/org/:orgId/x/:xId", // or "/x/:xId" if id is globally unique
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess, // or verifyXAccess if globally-keyed
|
||||
verifyUserHasAction(ActionsEnum.updateX),
|
||||
logActionAudit(ActionsEnum.updateX),
|
||||
x.updateX
|
||||
);
|
||||
|
||||
// Delete — row-scoped
|
||||
authenticated.delete(
|
||||
"/org/:orgId/x/:xId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteX),
|
||||
logActionAudit(ActionsEnum.deleteX),
|
||||
x.deleteX
|
||||
);
|
||||
|
||||
// List — org-scoped, read-only, no audit log
|
||||
authenticated.get(
|
||||
"/org/:orgId/xs",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listXs),
|
||||
x.listXs
|
||||
);
|
||||
|
||||
// Get one — row-scoped, read-only, no audit log
|
||||
authenticated.get(
|
||||
"/org/:orgId/x/:xId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getX),
|
||||
x.getX
|
||||
);
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- HTTP verbs: `PUT` = create, `POST` = update, `GET` = read, `DELETE` =
|
||||
delete. This repo does not use `PATCH` for entity updates (site
|
||||
provisioning keys are the one exception, using `PATCH`).
|
||||
- `verifyValidLicense` is only needed on private/enterprise routes; public
|
||||
OSS routes skip it.
|
||||
- Use `verifyValidSubscription(tierMatrix.someFeature)` right after
|
||||
`verifyValidLicense` when a feature is gated to specific SaaS tiers (see
|
||||
`tierMatrix` usages in `server/private/routers/external.ts`).
|
||||
- `verifyLimits` goes on create routes for entities that count against a
|
||||
plan/seat limit.
|
||||
- For entities keyed by a globally-unique id (not nested under `/org/:orgId`),
|
||||
use the dedicated `verify<Entity>Access` middleware from §4 instead of
|
||||
`verifyOrgAccess` on the row-scoped routes (see how `/ai-provider/:providerId`
|
||||
uses `verifyAiProviderAccess`, while `/org/:orgId/ai-provider` create/list
|
||||
use plain `verifyOrgAccess`).
|
||||
- Read-only routes (`get`, `list`) skip `logActionAudit` — only mutations are
|
||||
audited.
|
||||
- `internal*.ts` routes are for trusted internal callers (gerbil/badger
|
||||
sidecars) and generally skip user-facing auth entirely, using
|
||||
`verifySessionUserMiddleware` / `verifyUserFromResourceSessionMiddleware`
|
||||
instead of `verifyOrgAccess`/`verifyUserHasAction`. CRUD entities almost
|
||||
never need internal router entries — only add one if a sidecar process
|
||||
needs direct access to the resource.
|
||||
|
||||
## 7. The `#dynamic` alias (advanced — most CRUD work can ignore this)
|
||||
|
||||
Some middleware (e.g. `logActionAudit`) needs a real implementation in the
|
||||
enterprise/SaaS build but a no-op stub in the open-source build, while
|
||||
being imported by identical code in `server/routers/external.ts` in both
|
||||
builds. That's done via the `#dynamic/*` import alias, which
|
||||
`tsconfig.oss.json` points at `./server/*` and `tsconfig.enterprise.json` /
|
||||
`tsconfig.saas.json` point at `./server/private/*`. You only need this
|
||||
pattern if you're adding a genuinely dual-implementation hook; a normal
|
||||
private-only CRUD entity (like `alertRule`) never touches `#dynamic` — it
|
||||
just lives entirely under `server/private/` and is imported with `#private/*`
|
||||
directly from `server/private/routers/external.ts`.
|
||||
|
||||
## 8. Checklist for a new entity
|
||||
|
||||
1. Add the DB table to `server/db/pg/schema/schema.ts` (and sqlite schema if
|
||||
applicable).
|
||||
2. Add `ActionsEnum` entries in `server/auth/actions.ts`.
|
||||
3. Create `server/routers/<entity>/` (or `server/private/routers/<entity>/`):
|
||||
`types.ts`, optional `validation.ts`, one file per operation, `index.ts`
|
||||
barrel.
|
||||
4. If routes are row-scoped by a global id, add
|
||||
`verify<Entity>Access.ts` to `server/middlewares/` or
|
||||
`server/private/middlewares/`, and export it from that directory's
|
||||
`index.ts`.
|
||||
5. Wire routes into `external.ts` (public or private) following the verb/
|
||||
middleware table in §6. Add to `internal.ts` only if a sidecar needs
|
||||
direct access.
|
||||
6. Add license header block to every new file if it's under `server/private/`.
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
- 80:80{{end}}
|
||||
|
||||
traefik:
|
||||
image: docker.io/traefik:v3.6
|
||||
image: docker.io/traefik:v3.7
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
||||
|
||||
@@ -5,7 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/term v0.45.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -33,6 +33,6 @@ require (
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
)
|
||||
|
||||
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
@@ -76,7 +76,7 @@ var redisFlag *bool
|
||||
func main() {
|
||||
|
||||
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
||||
redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.")
|
||||
redisFlag = flag.Bool("redis", false, "Install Redis as caching solution. Required for HA. Not required for the Enterprise version.")
|
||||
flag.Parse()
|
||||
|
||||
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"inviteLoginUser": "Please make sure you're logged in as the correct user.",
|
||||
"inviteErrorNoUser": "We're sorry, but it looks like the invite you're trying to access is not for a user that exists.",
|
||||
"inviteCreateUser": "Please create an account first.",
|
||||
"inviteErrorOidcNotAllowed": "Invites can only be accepted by internal accounts. Sign out and log in with your password for this email.",
|
||||
"inviteLoginInternalOnly": "Invites require an internal account with a password. Create an account or sign in with your password.",
|
||||
"goHome": "Go Home",
|
||||
"inviteLogInOtherUser": "Log In as a Different User",
|
||||
"createAnAccount": "Create an Account",
|
||||
@@ -178,7 +180,7 @@
|
||||
"shareDeleteConfirm": "Confirm Delete Shareable Link",
|
||||
"shareQuestionRemove": "Are you sure you want to delete this share link?",
|
||||
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
|
||||
"shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.",
|
||||
"shareTokenDescription": "The access token can be passed as a query parameter or in request headers. By default it must be sent on every request. If session persistence is enabled, the first request exchanges it for a session cookie.",
|
||||
"accessToken": "Access Token",
|
||||
"usageExamples": "Usage Examples",
|
||||
"tokenId": "Token ID",
|
||||
@@ -196,8 +198,14 @@
|
||||
"shareTitleOptional": "Title (optional)",
|
||||
"sharePathOptional": "Path (optional)",
|
||||
"sharePathDescription": "The link will redirect users to this path after authentication.",
|
||||
"shareAssociateUserOptional": "Associate User (optional)",
|
||||
"shareAssociateUserDescription": "When set, requests using this link are attributed to the user in access logs and identity headers. The link is removed if the user leaves the organization.",
|
||||
"userSelect": "Select user",
|
||||
"usersNotFound": "No users found",
|
||||
"expireIn": "Expire In",
|
||||
"neverExpire": "Never expire",
|
||||
"sharePersistSession": "Persist session after first use",
|
||||
"sharePersistSessionDescription": "When enabled, the first request with this token via a query param or header sets a session cookie so later requests do not need the token. Leave off for API clients that should send the token on every request.",
|
||||
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
|
||||
"shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.",
|
||||
"shareAccessHint": "Anyone with this link can access the resource. Share it with care.",
|
||||
@@ -280,7 +288,21 @@
|
||||
"noCountryFound": "No country found.",
|
||||
"siteSelectionDescription": "This site will provide connectivity to the target.",
|
||||
"resourceType": "Resource Type",
|
||||
"resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This can’t be changed later.",
|
||||
"resourceTypeDescription": "This controls the resource protocol and can’t be changed later.",
|
||||
"resourceTypeSearch": "Search resource types...",
|
||||
"resourceTypeNotFound": "No resource type found",
|
||||
"resourceTypeHttpDescription": "Proxy web traffic over HTTPS using a domain name",
|
||||
"resourceTypeInferenceDescription": "Route AI requests through attached providers",
|
||||
"resourceTypeSshDescription": "Access an SSH server from the browser",
|
||||
"resourceTypeRdpDescription": "Access a remote desktop from the browser",
|
||||
"resourceTypeVncDescription": "Access a VNC desktop from the browser",
|
||||
"resourceTypeTcpDescription": "Proxy raw TCP traffic using a port number",
|
||||
"resourceTypeUdpDescription": "Proxy raw UDP traffic using a port number",
|
||||
"privateResourceTypeDescription": "This controls how clients reach the resource. This can’t be changed later.",
|
||||
"privateResourceTypeHostDescription": "Expose a single host on the site network to connected clients",
|
||||
"privateResourceTypeCidrDescription": "Expose a CIDR range on the site network to connected clients",
|
||||
"privateResourceTypeHttpDescription": "Access an HTTP or HTTPS service through a domain",
|
||||
"privateResourceTypeSshDescription": "Access an SSH server from connected clients",
|
||||
"resourceDomainDescription": "The resource will be served at this fully qualified domain name.",
|
||||
"resourceHTTPSSettings": "HTTPS Settings",
|
||||
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
|
||||
@@ -449,8 +471,14 @@
|
||||
"provisioningManage": "Provisioning",
|
||||
"provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.",
|
||||
"pendingSites": "Pending Sites",
|
||||
"siteApproveSuccess": "Site approved successfully",
|
||||
"siteApproveSuccess": "Site and associated resources approved successfully",
|
||||
"siteApproveError": "Error approving site",
|
||||
"siteReject": "Reject Site",
|
||||
"siteQuestionReject": "Are you sure you want to reject this site?",
|
||||
"siteMessageReject": "This will permanently delete the site and any associated resources that are still pending.",
|
||||
"siteConfirmReject": "Confirm Reject Site",
|
||||
"siteRejectSuccess": "Site rejected successfully",
|
||||
"siteRejectError": "Error rejecting site",
|
||||
"provisioningKeys": "Provisioning Keys",
|
||||
"searchProvisioningKeys": "Search provisioning keys...",
|
||||
"provisioningKeysAdd": "Generate Provisioning Key",
|
||||
@@ -466,12 +494,12 @@
|
||||
"provisioningKeysSave": "Save the provisioning key",
|
||||
"provisioningKeysSaveDescription": "You will only be able to see this once. Copy it to a secure place.",
|
||||
"provisioningKeysErrorCreate": "Error creating provisioning key",
|
||||
"provisioningKeysList": "New provisioning key",
|
||||
"provisioningKeysMaxBatchSize": "Max batch size",
|
||||
"provisioningKeysList": "New Provisioning Key",
|
||||
"provisioningKeysMaxBatchSize": "Max Batch Size",
|
||||
"provisioningKeysUnlimitedBatchSize": "Unlimited batch size (no limit)",
|
||||
"provisioningKeysMaxBatchUnlimited": "Unlimited",
|
||||
"provisioningKeysMaxBatchSizeInvalid": "Enter a valid max batch size (1–1,000,000).",
|
||||
"provisioningKeysValidUntil": "Valid until",
|
||||
"provisioningKeysValidUntil": "Valid Until",
|
||||
"provisioningKeysValidUntilHint": "Leave empty for no expiration.",
|
||||
"provisioningKeysValidUntilInvalid": "Enter a valid date and time.",
|
||||
"provisioningKeysNumUsed": "Times used",
|
||||
@@ -480,7 +508,7 @@
|
||||
"provisioningKeysNeverUsed": "Never",
|
||||
"provisioningKeysEdit": "Edit Provisioning Key",
|
||||
"provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.",
|
||||
"provisioningKeysApproveNewSites": "Approve new sites",
|
||||
"provisioningKeysApproveNewSites": "Approve New Sites",
|
||||
"provisioningKeysApproveNewSitesDescription": "Automatically approve sites that register with this key.",
|
||||
"provisioningKeysUpdateError": "Error updating provisioning key",
|
||||
"provisioningKeysUpdated": "Provisioning key updated",
|
||||
@@ -843,12 +871,16 @@
|
||||
"authMethodsSave": "Save Settings",
|
||||
"policyAuthStackTitle": "Authentication",
|
||||
"policyAuthStackDescription": "Control which authentication methods are required to access this resource",
|
||||
"policyAuthInferenceStackDescription": "Choose which users and roles can authenticate to this AI gateway",
|
||||
"policyAuthOrLogicTitle": "Multiple authentication methods active",
|
||||
"policyAuthOrLogicBanner": "Visitors may authenticate using any one of the active methods below. They do not need to complete all of them.",
|
||||
"policyAuthMethodActive": "Active",
|
||||
"policyAuthMethodOff": "Off",
|
||||
"policyAuthSsoTitle": "Platform SSO",
|
||||
"policyAuthSsoDescription": "Require sign-in through your organization's identity provider",
|
||||
"policyAuthInferenceIdentityKeySignInUrl": "Sign-in URL",
|
||||
"policyAuthInferenceIdentityKeyHelpNoUrl": "Every user already has an identity API key, so you only need to create virtual API keys for non-user clients or shared access. Users can retrieve their key by signing in with their identity provider at this resource's URL, where it will be shown after login.",
|
||||
"policyAuthInferenceIdentityKeyHelp": "Every user already has an identity API key, so you only need to create virtual API keys for non-user clients or shared access. Users retrieve their key here after signing in with their identity provider.",
|
||||
"policyAuthSsoSummary": "{idp} · {users} users, {roles} roles",
|
||||
"policyAuthSsoDefaultIdp": "Default provider",
|
||||
"policyAuthAddDefaultIdentityProvider": "Add Default Identity Provider",
|
||||
@@ -922,7 +954,7 @@
|
||||
"newtVersion": "Version",
|
||||
"architecture": "Architecture",
|
||||
"sites": "Sites",
|
||||
"siteWgAnyClients": "Use any WireGuard client to connect. You will have to address internal resources using the peer IP.",
|
||||
"siteWgAnyClients": "Use any WireGuard client to connect. You will have to address private resources using the peer IP.",
|
||||
"siteWgCompatibleAllClients": "Compatible with all WireGuard clients",
|
||||
"siteWgManualConfigurationRequired": "Manual configuration required",
|
||||
"userErrorNotAdminOrOwner": "User is not an admin or owner",
|
||||
@@ -1067,6 +1099,8 @@
|
||||
"accessRoleErrorNewRequired": "New role is required",
|
||||
"accessRoleErrorRemove": "Failed to remove role",
|
||||
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
|
||||
"accessRoleInferenceBudget": "AI Budget",
|
||||
"accessRoleInferenceBudgetDescription": "Configure how members of this role restrict AI usage based on spending or token limits",
|
||||
"accessRoleName": "Role Name",
|
||||
"accessRoleQuestionRemove": "You're about to delete the `{name}` role. You cannot undo this action.",
|
||||
"accessRoleRemove": "Remove Role",
|
||||
@@ -1397,6 +1431,7 @@
|
||||
"createOrgUser": "Create Org User",
|
||||
"actionUpdateOrg": "Update Organization",
|
||||
"actionRemoveInvitation": "Remove Invitation",
|
||||
"actionRemoveUserRole": "Remove User Role",
|
||||
"actionUpdateUser": "Update User",
|
||||
"actionGetUser": "Get User",
|
||||
"actionGetOrgUser": "Get Organization User",
|
||||
@@ -1411,6 +1446,28 @@
|
||||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
"actionListSites": "List Sites",
|
||||
"actionCreateAiProvider": "Create AI Provider",
|
||||
"actionDeleteAiProvider": "Delete AI Provider",
|
||||
"actionGetAiProvider": "Get AI Provider",
|
||||
"actionListAiProviders": "List AI Providers",
|
||||
"actionUpdateAiProvider": "Update AI Provider",
|
||||
"actionCreateAiModel": "Create AI Model",
|
||||
"actionDeleteAiModel": "Delete AI Model",
|
||||
"actionGetAiModel": "Get AI Model",
|
||||
"actionListAiModels": "List AI Models",
|
||||
"actionUpdateAiModel": "Update AI Model",
|
||||
"actionCreateAiBudget": "Create AI Budget",
|
||||
"actionDeleteAiBudget": "Delete AI Budget",
|
||||
"actionGetAiBudget": "Get AI Budget",
|
||||
"actionListAiBudgets": "List AI Budgets",
|
||||
"actionUpdateAiBudget": "Update AI Budget",
|
||||
"actionListResourceAiModels": "List Resource AI Models",
|
||||
"actionSetResourceAiModels": "Set Resource AI Models",
|
||||
"actionCreateVirtualApiKey": "Create Virtual API Key",
|
||||
"actionDeleteVirtualApiKey": "Delete Virtual API Key",
|
||||
"actionGetVirtualApiKey": "Get Virtual API Key",
|
||||
"actionListVirtualApiKeys": "List Virtual API Keys",
|
||||
"actionUpdateVirtualApiKey": "Update Virtual API Key",
|
||||
"actionApplyBlueprint": "Apply Blueprint",
|
||||
"actionListBlueprints": "List Blueprints",
|
||||
"actionGetBlueprint": "Get Blueprint",
|
||||
@@ -1419,6 +1476,8 @@
|
||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Update Site",
|
||||
"actionApproveSite": "Approve Site",
|
||||
"actionRejectSite": "Reject Site",
|
||||
"actionResetSiteBandwidth": "Reset Organization Bandwidth",
|
||||
"actionListSiteRoles": "List Allowed Site Roles",
|
||||
"actionCreateResource": "Create Resource",
|
||||
@@ -1434,8 +1493,11 @@
|
||||
"actionSetResourcePincode": "Set Resource Pincode",
|
||||
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
|
||||
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
|
||||
"actionListResourcePolicies": "List Resource Policies",
|
||||
"actionCreateResourcePolicy": "Create Resource Policy",
|
||||
"actionGetResourcePolicy": "Get Resource Policy",
|
||||
"actionUpdateResourcePolicy": "Update Resource Policy",
|
||||
"actionDeleteResourcePolicy": "Delete Resource Policy",
|
||||
"actionSetResourcePolicyUsers": "Set Resource Policy Users",
|
||||
"actionSetResourcePolicyRoles": "Set Resource Policy Roles",
|
||||
"actionSetResourcePolicyPassword": "Set Resource Policy Password",
|
||||
@@ -1462,6 +1524,7 @@
|
||||
"actionGenerateAccessToken": "Generate Access Token",
|
||||
"actionDeleteAccessToken": "Delete Access Token",
|
||||
"actionListAccessTokens": "List Access Tokens",
|
||||
"actionCreateResourceSessionToken": "Create Resource Session Token",
|
||||
"actionCreateResourceRule": "Create Resource Rule",
|
||||
"actionDeleteResourceRule": "Delete Resource Rule",
|
||||
"actionListResourceRules": "List Resource Rules",
|
||||
@@ -1501,6 +1564,10 @@
|
||||
"actionListInvitations": "List Invitations",
|
||||
"actionExportLogs": "Export Logs",
|
||||
"actionViewLogs": "View Logs",
|
||||
"actionCreateSiteProvisioningKey": "Create Site Provisioning Key",
|
||||
"actionListSiteProvisioningKeys": "List Site Provisioning Keys",
|
||||
"actionUpdateSiteProvisioningKey": "Update Site Provisioning Key",
|
||||
"actionDeleteSiteProvisioningKey": "Delete Site Provisioning Key",
|
||||
"noneSelected": "None selected",
|
||||
"orgNotFound2": "No organizations found.",
|
||||
"search": "Search…",
|
||||
@@ -1515,10 +1582,37 @@
|
||||
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
||||
"otpAuthSubmit": "Submit Code",
|
||||
"idpContinue": "Or continue with",
|
||||
"idpLastUsed": "Last Used",
|
||||
"otpAuthBack": "Back to Password",
|
||||
"navbar": "Navigation Menu",
|
||||
"navbarDescription": "Main navigation menu for the application",
|
||||
"navbarDocsLink": "Documentation",
|
||||
"commandPaletteTitle": "Command Palette",
|
||||
"commandPaletteDescription": "Search for pages, organizations, resources, and actions",
|
||||
"commandPaletteSearchPlaceholder": "Search pages, resources, actions...",
|
||||
"commandPaletteNoResults": "No results found.",
|
||||
"commandPaletteSearching": "Searching...",
|
||||
"commandPaletteNavigation": "Navigation",
|
||||
"commandPaletteOrganizations": "Organizations",
|
||||
"commandPaletteSites": "Sites",
|
||||
"commandPaletteResources": "Resources",
|
||||
"commandPaletteUsers": "Users",
|
||||
"commandPaletteClients": "Machine Clients",
|
||||
"commandPaletteActions": "Actions",
|
||||
"commandPaletteCreateSite": "Create Site",
|
||||
"commandPaletteCreateProxyResource": "Create Public Resource",
|
||||
"commandPaletteCreatePrivateResource": "Create Private Resource",
|
||||
"commandPaletteCreateUser": "Create User",
|
||||
"commandPaletteCreateApiKey": "Create API Key",
|
||||
"commandPaletteCreateMachineClient": "Create Machine Client",
|
||||
"commandPaletteCreateAlertRule": "Create Alert Rule",
|
||||
"commandPaletteCreateIdentityProvider": "Create Identity Provider",
|
||||
"commandPaletteCreateAiProvider": "Create AI Provider",
|
||||
"commandPaletteCreateVirtualApiKey": "Create Virtual API Key",
|
||||
"commandPaletteToggleTheme": "Toggle Theme",
|
||||
"commandPaletteChooseOrganization": "Choose Organization",
|
||||
"commandPaletteShortcutMac": "⌘K",
|
||||
"commandPaletteShortcutWindows": "Ctrl K",
|
||||
"otpErrorEnable": "Unable to enable 2FA",
|
||||
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
||||
"otpSetupCheckCode": "Please enter a 6-digit code",
|
||||
@@ -1577,6 +1671,420 @@
|
||||
"sidebarInvitations": "Invitations",
|
||||
"sidebarRoles": "Roles",
|
||||
"sidebarShareableLinks": "Shareable Links",
|
||||
"sidebarAiGateway": "AI Gateway",
|
||||
"sidebarAiProviders": "Providers",
|
||||
"commandAiProviders": "AI Providers",
|
||||
"sidebarVirtualApiKeys": "Virtual API Keys",
|
||||
"sidebarMyApiKeys": "Your API Keys",
|
||||
"sidebarAccount": "Launcher",
|
||||
"commandVirtualApiKeys": "Virtual API Keys",
|
||||
"virtualApiKeysTitle": "Manage Virtual API Keys",
|
||||
"virtualApiKeysDescription": "Create and manage manual API keys for AI Gateway access to public AI gateways",
|
||||
"virtualApiKeysTabIdentity": "Identity Keys",
|
||||
"virtualApiKeysTabVirtual": "Virtual Keys",
|
||||
"virtualApiKeysIdentitySplashTitle": "Identity Keys for Every User",
|
||||
"virtualApiKeysIdentitySplashDescription": "Every user already has a Pangolin identity key for this organization. It is unique to their account and authenticates them to AI gateways they can access.",
|
||||
"virtualApiKeysIdentitySplashExample": "Example",
|
||||
"virtualApiKeysIdentitySplashRetrieveTitle": "How Users Get Their Key",
|
||||
"virtualApiKeysIdentitySplashRetrieveResource": "Visit a public AI gateway URL in the browser and log in with their account.",
|
||||
"virtualApiKeysIdentitySplashRetrievePage": "Or go to <url></url> while signed in.",
|
||||
"virtualApiKeysIdentitySplashManual": "You can create additional virtual API keys on the Virtual Keys tab. Those keys can be scoped to specific public AI gateways and optionally associated with a user. Creating a key immediately grants access to the selected public AI gateways.",
|
||||
"virtualApiKeysIdentitySplashGoToVirtual": "Manually Create a Key",
|
||||
"virtualApiKeysEmailIdentity": "Email Identity Keys",
|
||||
"virtualApiKeysEmailIdentityDescription": "Send each selected user or role their Pangolin identity key.",
|
||||
"virtualApiKeysEmailIdentitySendAll": "Send to all users",
|
||||
"virtualApiKeysEmailIdentitySendAllDescription": "Email every organization member who has an account email.",
|
||||
"virtualApiKeysEmailIdentitySelectUsers": "Users",
|
||||
"virtualApiKeysEmailIdentitySelectRoles": "Roles",
|
||||
"virtualApiKeysEmailIdentitySubmit": "Send Emails",
|
||||
"virtualApiKeysEmailIdentitySuccess": "Identity keys emailed",
|
||||
"virtualApiKeysEmailIdentitySuccessDescription": "Sent {sent} emails.",
|
||||
"virtualApiKeysEmailIdentitySkipped": "{skipped} users were skipped because they do not have an email address.",
|
||||
"virtualApiKeysEmailIdentityRecipientsRequired": "Select at least one user or role, or send to all users.",
|
||||
"virtualApiKeysEmailIdentityError": "Error sending identity keys",
|
||||
"virtualApiKeysEmailIdentityErrorDescription": "Failed to email identity keys",
|
||||
"virtualApiKeysBannerTitle": "Identity Keys for Every User",
|
||||
"virtualApiKeysBannerDescription": "Every user already has an identity key available at {keysUrl}. You can also manually generate keys here that grant direct access to public AI gateways.",
|
||||
"virtualApiKeysBannerButtonText": "View Identity Keys",
|
||||
"virtualApiKeys": "Virtual API Keys",
|
||||
"virtualApiKeysSearch": "Search keys...",
|
||||
"virtualApiKeysCreate": "Create Virtual API Key",
|
||||
"virtualApiKeysCreateDescription": "Mint a manual key that can call public AI gateways in this organization",
|
||||
"virtualApiKeysCreateButton": "Create Key",
|
||||
"virtualApiKeysEmpty": "No virtual API keys yet",
|
||||
"virtualApiKeysName": "Name",
|
||||
"virtualApiKeysDescriptionOptional": "Description (optional)",
|
||||
"virtualApiKeysAssociateUserOptional": "Associate User (optional)",
|
||||
"virtualApiKeysAssociateUserDescription": "Associate this key with a user to track usage. Once created, this key immediately grants access to the selected public AI gateways. You do not need to associate a user to manually to the resource. The key will also show up in the user's profile.",
|
||||
"virtualApiKeysAllResources": "All public AI gateways",
|
||||
"virtualApiKeysAllResourcesDescription": "Allow this key to access every public AI gateway in the organization",
|
||||
"virtualApiKeysSelectResources": "Public AI Gateways",
|
||||
"virtualApiKeysSelectResourcesPlaceholder": "Select resources",
|
||||
"virtualApiKeysSelectResourcesDescription": "Choose which public AI gateways this key can access",
|
||||
"virtualApiKeysNoResources": "No resources",
|
||||
"virtualApiKeysSecret": "Key",
|
||||
"virtualApiKeysCopyKey": "Copy this key. You can view it again later from the table or when editing.",
|
||||
"virtualApiKeysViewSecret": "View Secret",
|
||||
"virtualApiKeysViewSecretTitle": "Virtual API Key Secret",
|
||||
"virtualApiKeysViewSecretDescription": "This secret grants access to the public AI gateways assigned to this key",
|
||||
"virtualApiKeysEdit": "Edit Virtual API Key",
|
||||
"virtualApiKeysEditDescription": "Update the associated user and public AI gateway access for this key",
|
||||
"virtualApiKeysSaveButton": "Save Changes",
|
||||
"virtualApiKeysSelectResourcesRequired": "Select at least one public AI gateway, or enable all public AI gateways",
|
||||
"virtualApiKeysUpdated": "Virtual API key updated",
|
||||
"virtualApiKeysUpdatedDescription": "The virtual API key has been updated",
|
||||
"virtualApiKeysErrorUpdate": "Error updating virtual API key",
|
||||
"virtualApiKeysErrorUpdateDescription": "Failed to update virtual API key",
|
||||
"virtualApiKeysErrorCreate": "Error creating virtual API key",
|
||||
"virtualApiKeysErrorCreateDescription": "Failed to create virtual API key",
|
||||
"virtualApiKeysErrorDelete": "Error deleting virtual API key",
|
||||
"virtualApiKeysErrorDeleteMessage": "Failed to delete virtual API key",
|
||||
"virtualApiKeysDeleted": "Virtual API key deleted",
|
||||
"virtualApiKeysDeletedDescription": "The virtual API key has been deleted",
|
||||
"virtualApiKeysDelete": "Delete Virtual API Key",
|
||||
"virtualApiKeysDeleteConfirm": "Delete Key",
|
||||
"virtualApiKeysQuestionRemove": "Are you sure you want to delete this virtual API key?",
|
||||
"virtualApiKeysMessageRemove": "Clients using this key will lose access immediately.",
|
||||
"virtualApiKeysErrorFetchSecret": "Error loading secret",
|
||||
"virtualApiKeysErrorFetchSecretDescription": "Failed to load the virtual API key secret",
|
||||
"virtualApiKeysFilterUnassigned": "Unassigned",
|
||||
"virtualApiKeysInferenceBudget": "Budget",
|
||||
"virtualApiKeysInferenceBudgetDescription": "Configure how this key restricts AI usage based on spending or token limits",
|
||||
"virtualApiKeysEmailOnGenerate": "Email key upon generation",
|
||||
"virtualApiKeysEmailThisKey": "Email this key",
|
||||
"virtualApiKeysEmailOnGenerateDescription": "Send the key to the associated user and additional addresses after it is created",
|
||||
"virtualApiKeysEmailThisKeyDescription": "Send the current key to the associated user and additional addresses",
|
||||
"virtualApiKeysEmailSmtpRequired": "Email is not configured on this server",
|
||||
"virtualApiKeysEmailSmtpRequiredDescription": "Configure SMTP to email virtual API keys.",
|
||||
"virtualApiKeysEmailSendToUser": "Send to associated user",
|
||||
"virtualApiKeysEmailSendToUserDescription": "Email the key to the associated user's account email",
|
||||
"virtualApiKeysEmailSendToUserDisabled": "Associate a user to send the key to that user",
|
||||
"virtualApiKeysEmailAdditional": "Additional emails",
|
||||
"virtualApiKeysEmailAdditionalPlaceholder": "Add email and press Enter",
|
||||
"virtualApiKeysEmailRecipientsRequired": "Select the associated user or add at least one email address",
|
||||
"myVirtualApiKeysTitle": "Your API Keys",
|
||||
"myVirtualApiKeysDescription": "View your identity key and any virtual API keys attributed to you in this organization",
|
||||
"myVirtualApiKeysResourceTitle": "Your API Keys for {resourceName}",
|
||||
"myVirtualApiKeysResourceDescription": "View your identity key and virtual API keys attributed to you that can access {resourceName}",
|
||||
"myVirtualApiKeysIdentityTitle": "Identity Key",
|
||||
"myVirtualApiKeysIdentityHeadline": "Your Personal API Key",
|
||||
"myVirtualApiKeysIdentityDescription": "Your personal key for this organization. It is unique to your account and used to identify you when calling AI Gateway resources.",
|
||||
"myVirtualApiKeysIdentityResourceHeadline": "Your Personal API Key for {resourceName}",
|
||||
"myVirtualApiKeysIdentityResourceDescription": "Your personal key for this organization. Use it to call {resourceName}.",
|
||||
"myVirtualApiKeysManualTitle": "Attributed Keys",
|
||||
"myVirtualApiKeysManualDescription": "Manual virtual API keys an admin associated with your account",
|
||||
"myVirtualApiKeysManualResourceDescription": "Manual virtual API keys associated with your account that can access {resourceName}",
|
||||
"myVirtualApiKeysManualEmpty": "No attributed keys yet",
|
||||
"myVirtualApiKeysKindUser": "Identity",
|
||||
"myVirtualApiKeysKindManual": "Manual",
|
||||
"myVirtualApiKeysUnnamed": "Unnamed key",
|
||||
"myVirtualApiKeysRevealSecret": "Reveal Secret",
|
||||
"myVirtualApiKeysViewSecretDescription": "This secret authenticates you to AI Gateway resources",
|
||||
"aiClientConfigTitle": "Configure Coding Agents",
|
||||
"aiClientConfigDescription": "Copy configuration for popular coding agents, or let the Pangolin CLI set it up for you automatically.",
|
||||
"aiClientConfigDescriptionClaude": "Anthropic's agentic coding tool for the terminal.",
|
||||
"aiClientConfigDescriptionCodex": "OpenAI's agentic coding tool for the terminal.",
|
||||
"aiClientConfigDescriptionOpencode": "Open source terminal coding agent.",
|
||||
"aiClientConfigDescriptionCursor": "AI code editor built on VS Code.",
|
||||
"aiClientConfigSetup": "Setup",
|
||||
"aiClientConfigTabCli": "Automatic (CLI)",
|
||||
"aiClientConfigTabManual": "Manual Configuration",
|
||||
"aiClientConfigPreset": "Configuration",
|
||||
"aiClientConfigStep": "Step {number}",
|
||||
"aiClientConfigEndpointPlaceholder": "https://example.resource.url.com",
|
||||
"aiClientConfigPlaceholderWarning": "This snippet includes example values, such as a model name or resource URL. Replace them with your own before using it.",
|
||||
"aiClientConfigRevealError": "Could not load your API key.",
|
||||
"aiClientConfigRevealRetry": "Try again",
|
||||
"resourceGeneralAiClientConfigDescription": "This resource can be used from common clients like Claude Code and OpenCode. <configLink>Learn more</configLink>",
|
||||
"aiProvidersTitle": "AI Providers",
|
||||
"aiProvidersDescription": "Connect model providers for AI workloads in this organization",
|
||||
"aiProvidersBannerTitle": "Connect Model Providers",
|
||||
"aiProvidersBannerDescription": "Providers are the model backends Pangolin uses for AI workloads. Connect OpenAI, Anthropic, and other providers here, then attach them to public AI gateways so users can call models with identity-aware access, budgets, and logging.",
|
||||
"aiProvidersAdd": "Add Provider",
|
||||
"aiProvidersSearch": "Search providers...",
|
||||
"aiProvidersEmpty": "No AI providers yet",
|
||||
"aiProviderCreate": "Create AI Provider",
|
||||
"aiProviderCreateDescription": "Add a model provider for this organization",
|
||||
"aiProviderSeeAll": "See All Providers",
|
||||
"aiProviderSetting": "Provider Settings for {providerName}",
|
||||
"aiProviderSettingDescription": "Configure this AI provider",
|
||||
"aiProviderGeneral": "General",
|
||||
"aiProviderGeneralDescription": "Basic settings for this provider",
|
||||
"aiProviderConfiguration": "Configuration",
|
||||
"aiProviderConfigurationDescription": "Network routing and authentication for this provider",
|
||||
"aiProviderNetworkSettings": "Network Settings",
|
||||
"aiProviderNetworkSettingsDescription": "Choose how traffic reaches this provider",
|
||||
"aiProviderAuthSettings": "Authentication",
|
||||
"aiProviderAuthSettingsDescription": "Configure how this provider authenticates requests to its upstream URL",
|
||||
"aiProviderBudgetSettings": "Budget",
|
||||
"aiProviderBudgetSettingsDescription": "Configure how this provider restricts usage based on spending or token limits",
|
||||
"aiBudgetAdd": "Add Budget",
|
||||
"aiBudgetEmpty": "No budgets configured yet. Click Add Budget to set a spending or token limit.",
|
||||
"aiBudgetUnit": "Spend Type",
|
||||
"aiBudgetPeriod": "Reset Period",
|
||||
"aiBudgetAmount": "Maximum Spend",
|
||||
"aiBudgetAmountPlaceholder": "Maximum spend",
|
||||
"aiBudgetPeriodHourly": "Hourly",
|
||||
"aiBudgetPeriodDaily": "Daily",
|
||||
"aiBudgetPeriodWeekly": "Weekly",
|
||||
"aiBudgetPeriodMonthly": "Monthly",
|
||||
"aiBudgetPeriodYearly": "Yearly",
|
||||
"aiBudgetPeriodLifetime": "Lifetime",
|
||||
"aiBudgetUnitUsd": "USD",
|
||||
"aiBudgetUnitTokens": "Tokens",
|
||||
"aiBudgetConflictError": "A budget for this reset period and spend type already exists",
|
||||
"aiBudgetInvalidAmountError": "Enter a maximum spend greater than 0",
|
||||
"aiBudgetUpdated": "Budgets updated",
|
||||
"aiBudgetErrorSave": "Failed to update budgets",
|
||||
"aiProviderType": "Provider Type",
|
||||
"aiProviderTypeSearch": "Search providers...",
|
||||
"aiProviderTypeNotFound": "No provider type found",
|
||||
"aiProviderTypeOpenai": "OpenAI",
|
||||
"aiProviderTypeAnthropic": "Anthropic",
|
||||
"aiProviderTypeGoogleGemini": "Google Gemini",
|
||||
"aiProviderTypeVertexAi": "Vertex AI",
|
||||
"aiProviderTypeBedrock": "Amazon Bedrock",
|
||||
"aiProviderTypeMicrosoftFoundry": "Microsoft Foundry",
|
||||
"aiProviderTypeOpenRouter": "OpenRouter",
|
||||
"aiProviderTypeVercelAiGateway": "Vercel AI Gateway",
|
||||
"aiProviderTypeCustom": "Custom",
|
||||
"aiProviderTypeOpenaiDescription": "OpenAI API",
|
||||
"aiProviderTypeAnthropicDescription": "Anthropic API",
|
||||
"aiProviderTypeGoogleGeminiDescription": "Google Gemini API",
|
||||
"aiProviderTypeVertexAiDescription": "Google Vertex AI API",
|
||||
"aiProviderTypeBedrockDescription": "Amazon Bedrock Runtime API",
|
||||
"aiProviderTypeMicrosoftFoundryDescription": "Microsoft Foundry API",
|
||||
"aiProviderTypeOpenRouterDescription": "OpenRouter API",
|
||||
"aiProviderTypeVercelAiGatewayDescription": "Vercel AI Gateway API",
|
||||
"aiProviderTypeCustomDescription": "Bring your own endpoint or route via site targets",
|
||||
"aiProviderUpstreamUrl": "Upstream URL",
|
||||
"aiProviderUpstreamUrlDescription": "Base URL for the provider API",
|
||||
"aiProviderUpstreamUrlOptionalDescription": "Leave blank to use the default upstream URL for this provider",
|
||||
"aiProviderEffectiveUpstreamUrl": "Effective Upstream URL",
|
||||
"aiProviderApiKey": "API Key",
|
||||
"aiProviderApiKeyDescription": "API key used to authenticate requests to this provider",
|
||||
"aiProviderCustomHeadersDescription": "Headers sent on every request to this provider. Newline separated: Header-Name: value",
|
||||
"aiProviderApiKeyLastChars": "API Key",
|
||||
"aiProviderAuthType": "Auth Type",
|
||||
"aiProviderAuthTypeSearch": "Search auth types...",
|
||||
"aiProviderAuthTypeNotFound": "No auth type found",
|
||||
"aiProviderAuthTypeBearer": "Bearer",
|
||||
"aiProviderAuthTypeBearerDescription": "Authorization: Bearer key. Used by OpenAI and most providers",
|
||||
"aiProviderAuthTypeXApiKey": "x-api-key",
|
||||
"aiProviderAuthTypeXApiKeyDescription": "x-api-key header. Used by Anthropic",
|
||||
"aiProviderAuthTypeXGoogApiKey": "x-goog-api-key",
|
||||
"aiProviderAuthTypeXGoogApiKeyDescription": "x-goog-api-key header. Used by Google Gemini",
|
||||
"aiProviderAuthTypeHec": "Splunk HEC",
|
||||
"aiProviderAuthTypeHecDescription": "Authorization: Splunk key. Used by Splunk HTTP Event Collector",
|
||||
"aiProviderAuthTypeCfAigAuthorization": "Cloudflare AI Gateway",
|
||||
"aiProviderAuthTypeCfAigAuthorizationDescription": "cf-aig-authorization: Bearer key. Used by Cloudflare AI Gateway",
|
||||
"aiProviderAuthTypeNone": "No Auth",
|
||||
"aiProviderAuthTypePassthrough": "Passthrough",
|
||||
"aiProviderAuthTypeDescription": "How the upstream API authenticates requests",
|
||||
"aiProviderAuthTypePassthroughDescription": "Forward the caller's API key headers to the upstream",
|
||||
"aiProviderAuthTypeNoneDescription": "Do not send authentication headers to the upstream",
|
||||
"aiProviderRoutingMode": "Routing Mode",
|
||||
"aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to HTTP targets on your sites",
|
||||
"aiProviderRoutingModeUrl": "Upstream URL",
|
||||
"aiProviderRoutingModeUrlDescription": "Call a public or private API base URL",
|
||||
"aiProviderRoutingModeTarget": "Site Targets",
|
||||
"aiProviderRoutingModeTargetDescription": "Route through targets on your sites",
|
||||
"aiProviderRoutingModeTargetNote": "After creating this provider, configure site targets on the Network Settings tab.",
|
||||
"aiProviderTargetNoOne": "This provider doesn't have any targets. Add a target to route requests through your sites.",
|
||||
"aiProviderSkipTlsVerification": "Skip TLS Verification",
|
||||
"aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection",
|
||||
"aiProviderBudget": "Budget",
|
||||
"aiProviderBudgetDescription": "Optional spending or token budget for this provider",
|
||||
"aiProviderBudgetAmount": "Budget Amount",
|
||||
"aiProviderBudgetUnit": "Budget Unit",
|
||||
"aiProviderBudgetUnitUsd": "USD",
|
||||
"aiProviderBudgetUnitTokens": "Tokens",
|
||||
"aiProviderEnabled": "Enabled",
|
||||
"aiProviderEnabledDescription": "Fully disable this provider across all resources",
|
||||
"aiProviderErrorCreate": "Failed to create AI provider",
|
||||
"aiProviderErrorUpdate": "Failed to update AI provider",
|
||||
"aiProviderErrorDelete": "Failed to delete AI provider",
|
||||
"aiProviderErrorLoad": "Failed to load AI provider",
|
||||
"aiProviderErrorUpstreamUrlInvalid": "Enter a valid upstream URL",
|
||||
"aiProviderErrorUpstreamUrlRequired": "Upstream URL is required for this provider",
|
||||
"aiProviderErrorAuthTypeRequired": "Auth type is required",
|
||||
"aiProviderErrorApiKeyRequired": "API key is required",
|
||||
"aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers",
|
||||
"aiProviderErrorCapabilitiesRequired": "Select at least one API capability",
|
||||
"aiProviderCapabilities": "API Capabilities",
|
||||
"aiProviderCapabilitiesDescription": "Select which API formats this provider can handle. Known providers start with recommended defaults.",
|
||||
"aiProviderCapabilitiesCustomDescription": "Select which API formats this custom provider can handle",
|
||||
"aiProviderCapabilitiesSelect": "Select capabilities",
|
||||
"aiProviderCapabilitiesEmpty": "No capabilities found",
|
||||
"aiProviderCapabilitiesSearch": "Search capabilities...",
|
||||
"aiCapabilityOpenaiChat": "OpenAI Chat Completions",
|
||||
"aiCapabilityOpenaiChatDescription": "Supports /v1/chat/completions",
|
||||
"aiCapabilityOpenaiResponses": "OpenAI Responses",
|
||||
"aiCapabilityOpenaiResponsesDescription": "Supports /v1/responses",
|
||||
"aiCapabilityAnthropicMessages": "Anthropic Messages",
|
||||
"aiCapabilityAnthropicMessagesDescription": "Supports /v1/messages",
|
||||
"aiCapabilityGeminiGenerateContent": "Gemini Generate Content",
|
||||
"aiCapabilityGeminiGenerateContentDescription": "Supports the direct Gemini API",
|
||||
"aiCapabilityBedrockModelInvoke": "Bedrock Model Invoke",
|
||||
"aiCapabilityBedrockModelInvokeDescription": "Supports Amazon Bedrock InvokeModel",
|
||||
"aiCapabilityGoogleGenerateContent": "Vertex Generate Content",
|
||||
"aiCapabilityGoogleGenerateContentDescription": "Supports Vertex AI Gemini format",
|
||||
"aiCapabilityGoogleRawPredict": "Vertex Raw Predict",
|
||||
"aiCapabilityGoogleRawPredictDescription": "Supports Vertex AI rawPredict for Anthropic models",
|
||||
"aiCapabilityBedrockConverse": "Bedrock Converse",
|
||||
"aiCapabilityBedrockConverseDescription": "Supports Amazon Bedrock Converse API",
|
||||
"aiProviderCreated": "AI provider created",
|
||||
"aiProviderUpdated": "AI provider updated",
|
||||
"aiProviderDeleted": "AI provider deleted",
|
||||
"aiProviderDelete": "Delete Provider",
|
||||
"aiProviderDeleteConfirm": "Delete Provider",
|
||||
"aiProviderQuestionRemove": "Are you sure you want to delete this AI provider?",
|
||||
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
|
||||
"aiProviderErrorNoUpdate": "AI provider is not available to update",
|
||||
"aiProviderModels": "Models",
|
||||
"aiProviderModelsDescription": "Define allow and block lists for this provider. Requests must match an allow entry and must not match a block entry.",
|
||||
"aiProviderCreateModelsDescription": "Choose which models this provider can serve. An empty allow list denies all traffic. You can add or change models later.",
|
||||
"aiProviderModelsPlaceholder": "Search models or type a custom key",
|
||||
"aiProviderModelsAllow": "Allow List",
|
||||
"aiProviderModelsAllowDescription": "Models that may be used through this provider. Empty means deny all.",
|
||||
"aiProviderModelsAllowPlaceholder": "Enter model key",
|
||||
"aiProviderModelsAllowEmpty": "No models added yet.",
|
||||
"aiProviderModelsBlock": "Block List",
|
||||
"aiProviderModelsBlockDescription": "Models to deny even if they match an allow entry.",
|
||||
"aiProviderModelsBlockPlaceholder": "Enter model key",
|
||||
"aiProviderModelsBlockEmpty": "No models added yet.",
|
||||
"aiProviderModelsAdd": "Add Models",
|
||||
"aiProviderModelsClearAll": "Clear All",
|
||||
"aiProviderModelsAddCustom": "Add \"{key}\"",
|
||||
"aiProviderModelsAddCustomHint": "Press Enter to add this custom model key.",
|
||||
"aiProviderModelsAddBulk": "Add {count} custom keys",
|
||||
"aiProviderModelsAddBulkHint": "Press Enter to add {count} custom keys.",
|
||||
"aiProviderModelsAddOne": "Add {key} now",
|
||||
"aiProviderModelsAddSelected": "Add Selected",
|
||||
"aiProviderModelsSelectedCount": "{count} selected",
|
||||
"aiProviderModelsSelectAll": "Select all",
|
||||
"aiProviderModelsClearSelected": "Clear",
|
||||
"aiProviderModelsBulkHint": "Select known models or type a custom key.",
|
||||
"aiProviderModelsCatalogEmpty": "No matching catalog models.",
|
||||
"aiProviderModelsCatalogHeading": "Known Models",
|
||||
"aiProviderModelsAllLabel": "All models",
|
||||
"aiProviderModelsAllPatternHint": "Wildcard: *",
|
||||
"aiProviderModelsAddAllAllow": "Allow all models",
|
||||
"aiProviderModelsAddAllBlock": "Block all models",
|
||||
"aiProviderModelsAddAllDescription": "Uses the * wildcard so every model key matches.",
|
||||
"aiProviderModelsViewMore": "View more ({count})",
|
||||
"aiProviderModelsViewLess": "View less",
|
||||
"aiProviderModelsRemove": "Remove model",
|
||||
"aiProviderModelsEditHint": "Click to edit model settings",
|
||||
"aiProviderModelsSourceCatalog": "Known catalog model",
|
||||
"aiProviderModelsSourceCustom": "Custom model key",
|
||||
"aiProviderModelsSourcePattern": "Wildcard pattern",
|
||||
"aiProviderModelsSourceAll": "Matches every model key",
|
||||
"aiProviderModelsBudgetConfigured": "Budget configured",
|
||||
"aiProviderModelsEditTitle": "Edit Model",
|
||||
"aiProviderModelsEditDescription": "Update the model key or configure its budget.",
|
||||
"aiProviderModelsBudgetTab": "Budget",
|
||||
"aiProviderModelsKeyLabel": "Model Key",
|
||||
"aiProviderModelsKeyRequired": "Enter a model key",
|
||||
"aiProviderModelsKeyDuplicate": "This model key is already on a list",
|
||||
"aiProviderModelsOverlapError": "These patterns cannot be on both lists: {keys}",
|
||||
"aiProviderModelsUpdated": "Models updated",
|
||||
"aiProviderModelsErrorUpdate": "Failed to update models",
|
||||
"aiResourceProviders": "Providers",
|
||||
"aiResourceProvidersDescription": "Choose which AI providers this AI gateway can use",
|
||||
"aiResourceProvidersHelp": "Attach providers and choose inherit (use each provider's lists) or select (pick an allow list for this resource). Allow patterns that conflict across attached providers are not allowed.",
|
||||
"aiResourceProvidersSelect": "Select providers",
|
||||
"aiResourceProvidersEmpty": "No AI providers found",
|
||||
"aiResourceProvidersNoneAttached": "No providers attached yet.",
|
||||
"aiResourceProvidersAdd": "Add provider",
|
||||
"aiResourceProvidersRemove": "Remove provider",
|
||||
"aiResourceProviderToggleEnabled": "Enable or disable this provider on the resource",
|
||||
"aiResourceProviderDisabled": "Disabled",
|
||||
"aiResourceProvidersUpdated": "Providers updated",
|
||||
"aiResourceProvidersErrorUpdate": "Failed to update providers",
|
||||
"aiResourceProviderEditDescription": "Choose how this provider's models are exposed on this resource.",
|
||||
"viewProviderSettings": "View Provider Settings",
|
||||
"aiResourceProviderMode": "Access mode",
|
||||
"aiResourceProviderModeInherit": "Inherit",
|
||||
"aiResourceProviderModeSelect": "Select",
|
||||
"aiResourceProviderModeSelectSummary": "Select · {count} models",
|
||||
"aiResourceProviderModeInheritHelp": "Use this provider's allow and block lists as configured on the provider.",
|
||||
"aiResourceProviderModeSelectHelp": "Choose a subset of this provider's allow-list models for this resource.",
|
||||
"aiResourceProviderAllowModels": "Allow list",
|
||||
"aiResourceProviderAllowModelsSelect": "Select models",
|
||||
"aiResourceProviderAllowModelsSearch": "Search models...",
|
||||
"aiResourceProviderAllowModelsEmpty": "No models found",
|
||||
"aiResourceProviderAllowModelsHelp": "Only models from this provider's allow list can be selected.",
|
||||
"aiResourceAliasRequired": "Alias is required for AI gateways",
|
||||
"aiResourceDomainConfiguration": "Domain Configuration",
|
||||
"aiResourceDomainConfigurationDescription": "Choose the domain clients will use to reach this AI gateway.",
|
||||
"aiUsageAnalyticsTitle": "AI Usage Analytics",
|
||||
"aiUsageAnalyticsDescription": "Analyze AI gateway cost, token usage, and activity across providers, resources, roles, and users",
|
||||
"aiUsageTabOverview": "Overview",
|
||||
"aiUsageTabProviders": "Providers",
|
||||
"aiUsageTabResources": "Resources",
|
||||
"aiUsageFilterProvider": "Provider",
|
||||
"aiUsageFilterModel": "Model",
|
||||
"aiUsageFilterResource": "Resource",
|
||||
"aiUsageFilterRole": "Role",
|
||||
"aiUsageFilterUser": "User",
|
||||
"aiUsageFilterAllProviders": "All Providers",
|
||||
"aiUsageFilterAllModels": "All Models",
|
||||
"aiUsageFilterAllResources": "All Resources",
|
||||
"aiUsageFilterAllRoles": "All Roles",
|
||||
"aiUsageFilterAllUsers": "All Users",
|
||||
"aiUsageFilterSearch": "Search...",
|
||||
"aiUsageFilterNotFound": "No options found",
|
||||
"aiUsageResetFilters": "Reset Filters",
|
||||
"aiUsageRefresh": "Refresh",
|
||||
"aiUsageTokenTypePrompt": "Prompt",
|
||||
"aiUsageTokenTypeCacheRead": "Cache read",
|
||||
"aiUsageTokenTypeCacheWrite": "Cache write",
|
||||
"aiUsageTokenTypeCompletion": "Completion",
|
||||
"aiUsageTokenTypeReasoning": "Reasoning",
|
||||
"aiUsageRequests": "Requests",
|
||||
"aiUsageCost": "Cost",
|
||||
"aiUsageTokens": "Tokens",
|
||||
"aiUsageOther": "Other",
|
||||
"aiUsageTotalRequests": "Total Requests",
|
||||
"aiUsageTotalTokens": "Total Tokens",
|
||||
"aiUsageTotalCost": "Total Cost",
|
||||
"aiUsageEstimated": "Estimated",
|
||||
"aiUsageRequestVolume": "Request Volume",
|
||||
"aiUsageTokenUsage": "Token Usage",
|
||||
"aiUsageModelCost": "Model Cost",
|
||||
"aiUsageModelTokens": "Model Tokens",
|
||||
"aiUsageTopModels": "Top Models",
|
||||
"aiUsageTopProviders": "Top Providers",
|
||||
"aiUsageProviderCost": "Provider Cost",
|
||||
"aiUsageProviderTokenUsage": "Provider Token Usage",
|
||||
"aiUsageTopResources": "Top Resources",
|
||||
"aiUsageResourceCost": "Resource Cost",
|
||||
"aiUsageResourceTokenUsage": "Resource Token Usage",
|
||||
"aiUsageResourceTypePublic": "Public Resource",
|
||||
"aiUsageResourceTypeSite": "Private Resource",
|
||||
"aiUsageNoResource": "No resource",
|
||||
"aiUsageRolesTab": "Roles",
|
||||
"aiUsageUsersTab": "Users",
|
||||
"aiUsageTopRoles": "Top Roles",
|
||||
"aiUsageRoleCost": "Role Cost",
|
||||
"aiUsageRoleTokenUsage": "Role Token Usage",
|
||||
"aiUsageTopUsers": "Top Users",
|
||||
"aiUsageUserCost": "User Cost",
|
||||
"aiUsageUserTokenUsage": "User Token Usage",
|
||||
"aiUsageUnknownUser": "Unknown user",
|
||||
"aiUsageVirtualApiKeysTab": "Virtual API Keys",
|
||||
"aiUsageFilterVirtualApiKey": "Virtual API Key",
|
||||
"aiUsageFilterAllVirtualApiKeys": "All Virtual API Keys",
|
||||
"aiUsageTopVirtualApiKeys": "Top Virtual API Keys",
|
||||
"aiUsageVirtualApiKeyCost": "Virtual API Key Cost",
|
||||
"aiUsageVirtualApiKeyTokenUsage": "Virtual API Key Token Usage",
|
||||
"aiUsageUnknownVirtualApiKey": "No virtual API key",
|
||||
"aiUsageUnnamedVirtualApiKey": "Unnamed key",
|
||||
"aiUsageLoading": "Loading...",
|
||||
"aiUsageNoData": "No data",
|
||||
"resourceBudgetSettings": "Budget",
|
||||
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
|
||||
"sidebarApiKeys": "API Keys",
|
||||
"sidebarProvisioning": "Provisioning",
|
||||
"sidebarSettings": "Settings",
|
||||
@@ -1596,6 +2104,45 @@
|
||||
"sidebarManagement": "Management",
|
||||
"sidebarBillingAndLicenses": "Billing & Licenses",
|
||||
"sidebarLogsAnalytics": "Analytics",
|
||||
"commandSites": "Sites",
|
||||
"commandActionModeInfo": "Type \">\" To Open Action Mode",
|
||||
"commandResources": "Resources",
|
||||
"commandProxyResources": "Public Resources",
|
||||
"commandClientResources": "Private Resources",
|
||||
"commandClients": "Clients",
|
||||
"commandUserDevices": "User Devices",
|
||||
"commandMachineClients": "Machine Clients",
|
||||
"commandDomains": "Domains",
|
||||
"commandRemoteExitNodes": "Remote Nodes",
|
||||
"commandTeam": "Team",
|
||||
"commandUsers": "Users",
|
||||
"commandRoles": "Roles",
|
||||
"commandInvitations": "Invitations",
|
||||
"commandPolicies": "Shared Policies",
|
||||
"commandResourcePolicies": "Public Resources Policies",
|
||||
"commandIdentityProviders": "Identity Providers",
|
||||
"commandApprovals": "Approval Requests",
|
||||
"commandShareableLinks": "Shareable Links",
|
||||
"commandOrganization": "Organization",
|
||||
"commandLogsAndAnalytics": "Logs & Analytics",
|
||||
"commandLogsAnalytics": "Analytics",
|
||||
"commandLogsRequest": "HTTP Request Logs",
|
||||
"commandLogsAccess": "Authentication Logs",
|
||||
"commandLogsAction": "Admin Action Logs",
|
||||
"commandLogsConnection": "Network Logs",
|
||||
"commandLogsStreaming": "Event Streaming",
|
||||
"commandManagement": "Management",
|
||||
"commandAlerting": "Alerting",
|
||||
"commandProvisioning": "Provisioning",
|
||||
"commandBluePrints": "Blueprints",
|
||||
"commandApiKeys": "API Keys",
|
||||
"commandBillingAndLicenses": "Billing & Licenses",
|
||||
"commandBilling": "Billing",
|
||||
"commandEnterpriseLicenses": "Licenses",
|
||||
"commandSettings": "Settings",
|
||||
"commandLauncher": "Launcher",
|
||||
"commandResourceLauncher": "Resource Launcher",
|
||||
"commandSearchResults": "Search Results",
|
||||
"alertingTitle": "Alerting",
|
||||
"alertingDescription": "Define sources, triggers, and actions for notifications",
|
||||
"alertingRules": "Alert rules",
|
||||
@@ -1611,6 +2158,8 @@
|
||||
"alertingRuleSaved": "Alert rule saved",
|
||||
"alertingRuleSavedCreatedDescription": "Your new alert rule was created. You can keep editing it on this page.",
|
||||
"alertingRuleSavedUpdatedDescription": "Your changes to this alert rule were saved.",
|
||||
"alertingTestAlertSent": "Test alert sent",
|
||||
"alertingTestAlertSentDescription": "A test alert was sent to the actions configured on this rule.",
|
||||
"alertingEditRule": "Edit Alert Rule",
|
||||
"alertingCreateRule": "Create Alert Rule",
|
||||
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify",
|
||||
@@ -1720,6 +2269,12 @@
|
||||
"alertingRulesBannerDescription": "Each rule ties together what to watch (a site, health check, or resource), when to fire (for example offline or unhealthy), and how to notify your team via email, webhooks, or integrations. Use this list to create, enable, and manage those rules.",
|
||||
"alertingHealthChecksBannerTitle": "Monitor Health & Resources",
|
||||
"alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.",
|
||||
"alertingTestRule": "Test Alert Rule",
|
||||
"alertingAddActionHeading": "Add New Action",
|
||||
"alertingSelectActionType": "Select an Action",
|
||||
"alertingNoActionsTitle": "No actions configured",
|
||||
"alertingNoActionsSaveDescription": "Add at least one action so this rule can notify someone when it fires.",
|
||||
"alertingNoActionsTestDescription": "Add at least one action before you can test this rule.",
|
||||
"standaloneHcTableTitle": "Health Checks",
|
||||
"standaloneHcSearchPlaceholder": "Search health checks…",
|
||||
"standaloneHcAddButton": "Create Health Check",
|
||||
@@ -1911,6 +2466,9 @@
|
||||
"domainPickerSubdomain": "Subdomain: {subdomain}",
|
||||
"domainPickerNamespace": "Namespace: {namespace}",
|
||||
"domainPickerShowMore": "Show More",
|
||||
"domainPickerNoDomainsAvailableTitle": "No domains available",
|
||||
"domainPickerNoDomainsAvailableDescription": "You don't have any domains set up yet. Create a domain to continue.",
|
||||
"domainPickerNoDomainsAvailableAction": "Go to Domains",
|
||||
"regionSelectorTitle": "Select Region",
|
||||
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||
"regionSelectorInfo": "Selecting a region helps us provide better performance for your location. You do not have to be in the same region as your server.",
|
||||
@@ -2106,6 +2664,8 @@
|
||||
"subnetPlaceholder": "Subnet",
|
||||
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
|
||||
"selectSites": "Select sites",
|
||||
"selectResources": "Select resources",
|
||||
"multiResourcesSelectorResourcesCount": "{count, plural, one {# resource} other {# resources}}",
|
||||
"selectLabels": "Select labels",
|
||||
"sitesDescription": "The client will have connectivity to the selected sites",
|
||||
"clientInstallOlm": "Install Machine Client",
|
||||
@@ -2158,10 +2718,11 @@
|
||||
"timeIsInSeconds": "Time is in seconds",
|
||||
"requireDeviceApproval": "Require Device Approvals",
|
||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||
"sshSettings": "SSH Settings",
|
||||
"sshSettings": "SSH",
|
||||
"inferenceSettings": "AI Gateway",
|
||||
"sshAccess": "SSH Access",
|
||||
"rdpSettings": "RDP Settings",
|
||||
"vncSettings": "VNC Settings",
|
||||
"rdpSettings": "RDP",
|
||||
"vncSettings": "VNC",
|
||||
"sshServer": "SSH Server",
|
||||
"rdpServer": "RDP Server",
|
||||
"vncServer": "VNC Server",
|
||||
@@ -2274,7 +2835,7 @@
|
||||
"resourcesTableProxyResources": "Public",
|
||||
"resourcesTableClientResources": "Private",
|
||||
"resourcesTableNoProxyResourcesFound": "No proxy resources found.",
|
||||
"resourcesTableNoInternalResourcesFound": "No internal resources found.",
|
||||
"resourcesTableNoInternalResourcesFound": "No private resources found.",
|
||||
"resourcesTableDestination": "Destination",
|
||||
"resourcesTableAlias": "Alias",
|
||||
"resourcesTableAliasAddress": "Alias Address",
|
||||
@@ -2297,9 +2858,9 @@
|
||||
"editInternalResourceDialogCancel": "Cancel",
|
||||
"editInternalResourceDialogSaveResource": "Save Resource",
|
||||
"editInternalResourceDialogSuccess": "Success",
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Internal resource updated successfully",
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Private resource updated successfully",
|
||||
"editInternalResourceDialogError": "Error",
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource": "Failed to update internal resource",
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource": "Failed to update private resource",
|
||||
"editInternalResourceDialogNameRequired": "Name is required",
|
||||
"editInternalResourceDialogNameMaxLength": "Name must be less than 255 characters",
|
||||
"editInternalResourceDialogProxyPortMin": "Proxy port must be at least 1",
|
||||
@@ -2314,6 +2875,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeInference": "AI Gateway",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Scheme",
|
||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
||||
@@ -2325,16 +2887,17 @@
|
||||
"editInternalResourceDialogAlias": "Alias",
|
||||
"editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
|
||||
"createInternalResourceDialogNoSitesAvailable": "No Sites Available",
|
||||
"createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create internal resources.",
|
||||
"createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create private resources.",
|
||||
"createInternalResourceDialogClose": "Close",
|
||||
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
|
||||
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
|
||||
"privateResourceGeneralDescription": "Configure the name, identifier, and other general resource settings.",
|
||||
"privateResourceCreatePageSeeAll": "See All Private Resources",
|
||||
"privateResourceAllowIcmpPing": "Allow ICMP Ping",
|
||||
"privateResourceNetworkAccess": "Network Access",
|
||||
"privateResourceNetworkAccessDescription": "Control TCP/UDP port access and whether ICMP ping is allowed for this resource.",
|
||||
"hostSettings": "Host Settings",
|
||||
"cidrSettings": "CIDR Settings",
|
||||
"hostSettings": "Host",
|
||||
"cidrSettings": "CIDR",
|
||||
"createInternalResourceDialogResourceProperties": "Resource Properties",
|
||||
"createInternalResourceDialogName": "Name",
|
||||
"createInternalResourceDialogSite": "Site",
|
||||
@@ -2353,9 +2916,9 @@
|
||||
"createInternalResourceDialogCancel": "Cancel",
|
||||
"createInternalResourceDialogCreateResource": "Create Resource",
|
||||
"createInternalResourceDialogSuccess": "Success",
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Internal resource created successfully",
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Private resource created successfully",
|
||||
"createInternalResourceDialogError": "Error",
|
||||
"createInternalResourceDialogFailedToCreateInternalResource": "Failed to create internal resource",
|
||||
"createInternalResourceDialogFailedToCreateInternalResource": "Failed to create private resource",
|
||||
"createInternalResourceDialogNameRequired": "Name is required",
|
||||
"createInternalResourceDialogNameMaxLength": "Name must be less than 255 characters",
|
||||
"createInternalResourceDialogPleaseSelectSite": "Please select a site",
|
||||
@@ -2372,6 +2935,7 @@
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"createInternalResourceDialogModeInference": "AI Gateway",
|
||||
"scheme": "Scheme",
|
||||
"createInternalResourceDialogScheme": "Scheme",
|
||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
||||
@@ -2483,6 +3047,7 @@
|
||||
"noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.",
|
||||
"exitNode": "Exit Node",
|
||||
"country": "Country",
|
||||
"countryIsNot": "Country Is Not",
|
||||
"rulesMatchCountry": "Currently based on source IP",
|
||||
"region": "Region",
|
||||
"selectRegion": "Select region",
|
||||
@@ -2692,6 +3257,9 @@
|
||||
"twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.",
|
||||
"additionalSecurityRequired": "Additional Security Required",
|
||||
"organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.",
|
||||
"sessionExpired": "Session Expired",
|
||||
"sessionExpiredReauthRequired": "Your session has expired per your organization's security policy. Please re-authenticate to continue.",
|
||||
"reauthenticate": "Re-authenticate",
|
||||
"completeTheseSteps": "Complete these steps",
|
||||
"enableTwoFactorAuthentication": "Enable two-factor authentication",
|
||||
"completeSecuritySteps": "Complete Security Steps",
|
||||
@@ -2959,6 +3527,7 @@
|
||||
"validPassword": "Valid Password",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"validVirtualAPIKey": "Valid Virtual API Key",
|
||||
"view": "View",
|
||||
"configManaged": "Config Managed",
|
||||
"connectedClient": "Connected Client",
|
||||
@@ -2980,6 +3549,38 @@
|
||||
"logRetention": "Log Retention",
|
||||
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
|
||||
"requestLogsDescription": "View detailed request logs for HTTPS resources in this organization",
|
||||
"aiSessionLogs": "AI Gateway Session Logs",
|
||||
"aiSessionLogsDescription": "View prompt and response transcripts for AI gateway requests in this organization",
|
||||
"sidebarLogsAi": "Session Logs",
|
||||
"commandLogsAi": "Session Logs",
|
||||
"sidebarLogsAiUsage": "Usage Analytics",
|
||||
"commandLogsAiUsage": "Usage Analytics",
|
||||
"provider": "Provider",
|
||||
"capability": "Capability",
|
||||
"model": "Model",
|
||||
"virtualApiKey": "Virtual API Key",
|
||||
"noVirtualApiKey": "No virtual API key",
|
||||
"stream": "Stream",
|
||||
"streaming": "Streaming",
|
||||
"nonStreaming": "Non-streaming",
|
||||
"statusCode": "Status Code",
|
||||
"aiSessionId": "Session ID",
|
||||
"aiSessionRequest": "Request",
|
||||
"aiSessionResponse": "Response",
|
||||
"aiSessionNoData": "No data captured",
|
||||
"aiSessionCouldNotParse": "(raw, could not parse transcript)",
|
||||
"aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.",
|
||||
"aiSessionViewRaw": "View Raw JSON",
|
||||
"aiSessionViewChat": "View Chat",
|
||||
"cost": "Cost",
|
||||
"estimated": "Estimated",
|
||||
"tokenUsage": "Token Usage",
|
||||
"promptTokens": "Prompt Tokens",
|
||||
"cacheReadTokens": "Cache Read Tokens",
|
||||
"cacheWriteTokens": "Cache Write Tokens",
|
||||
"completionTokens": "Completion Tokens",
|
||||
"reasoningTokens": "Reasoning Tokens",
|
||||
"totalTokens": "Total Tokens",
|
||||
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
|
||||
"logRetentionRequestLabel": "HTTP Request Log Retention",
|
||||
"logRetentionRequestDescription": "How long to retain request logs",
|
||||
@@ -2989,6 +3590,8 @@
|
||||
"logRetentionActionDescription": "How long to retain action logs",
|
||||
"logRetentionConnectionLabel": "Network Log Retention",
|
||||
"logRetentionConnectionDescription": "How long to retain connection logs",
|
||||
"logRetentionAISessionsLabel": "AI Gateway Session Log Retention",
|
||||
"logRetentionAISessionsDescription": "How long to retain AI gateway prompt/response session logs",
|
||||
"logRetentionDisabled": "Disabled",
|
||||
"logRetention3Days": "3 days",
|
||||
"logRetention7Days": "7 days",
|
||||
@@ -3006,8 +3609,8 @@
|
||||
"sourceAddress": "Source Address",
|
||||
"destinationAddress": "Destination Address",
|
||||
"duration": "Duration",
|
||||
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a free demo or POC trial to learn more</bookADemoLink>.",
|
||||
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a free demo or POC trial to learn more</bookADemoLink>.",
|
||||
"licenseRequiredToUse": "<enterpriseEditionLink>Enterprise Edition license</enterpriseEditionLink> is required. <bookADemoLink>Book a demo or trial</bookADemoLink>",
|
||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition license</enterpriseEditionLink> is required. <bookADemoLink>Book a demo or trial</bookADemoLink>",
|
||||
"certResolver": "Certificate Resolver",
|
||||
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
||||
"selectCertResolver": "Select Certificate Resolver",
|
||||
@@ -3201,13 +3804,13 @@
|
||||
"editInternalResourceDialogAddUsers": "Add Users",
|
||||
"editInternalResourceDialogAddClients": "Add Clients",
|
||||
"editInternalResourceDialogDestinationLabel": "Destination",
|
||||
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.",
|
||||
"editInternalResourceDialogDestinationDescription": "Configure how clients reach this resource.",
|
||||
"internalResourceFormMultiSiteRoutingHelp": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
||||
"internalResourceFormMultiSiteRoutingHelpLearnMore": "Learn more",
|
||||
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
|
||||
"createInternalResourceDialogHttpConfiguration": "HTTP configuration",
|
||||
"createInternalResourceDialogHttpConfiguration": "Domain Configuration",
|
||||
"createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",
|
||||
"editInternalResourceDialogHttpConfiguration": "HTTP configuration",
|
||||
"editInternalResourceDialogHttpConfiguration": "Domain Configuration",
|
||||
"editInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",
|
||||
"editInternalResourceDialogTcp": "TCP",
|
||||
"editInternalResourceDialogUdp": "UDP",
|
||||
@@ -3643,7 +4246,18 @@
|
||||
"resourceLauncherTcp": "TCP",
|
||||
"resourceLauncherUdp": "UDP",
|
||||
"resourceLauncherUnlabeled": "Unlabeled",
|
||||
"resourceLauncherAiGateway": "AI Gateway",
|
||||
"resourceLauncherNoSite": "No Site",
|
||||
"resourceLauncherAvailableModels": "Available Models",
|
||||
"resourceLauncherAvailableModelsDescription": "Models you can use with this AI gateway.",
|
||||
"resourceLauncherAvailableModelsEmpty": "No models are available for this resource.",
|
||||
"resourceLauncherAvailableModelsError": "Could not load available models.",
|
||||
"resourceLauncherApiKeys": "API Keys",
|
||||
"resourceLauncherApiKeysDescription": "Use your identity key or an attributed key to authenticate with this resource.",
|
||||
"resourceLauncherApiKeysIdentity": "Identity Key",
|
||||
"resourceLauncherApiKeysManual": "Attributed Keys",
|
||||
"resourceLauncherApiKeysEmpty": "No API keys are available for this resource.",
|
||||
"resourceLauncherApiKeysError": "Could not load API keys.",
|
||||
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
||||
"resourceLauncherEmptyStateTitle": "No Resources Available",
|
||||
"resourceLauncherEmptyStateDescription": "You don't have access to any resources yet. Contact your administrator to request access.",
|
||||
@@ -3670,9 +4284,9 @@
|
||||
"resourceLauncherViewDeleteFailedDescription": "Could not delete the launcher view. Please try again.",
|
||||
"memberPortalPrevious": "Previous",
|
||||
"memberPortalNext": "Next",
|
||||
"httpSettings": "HTTP Settings",
|
||||
"tcpSettings": "TCP Settings",
|
||||
"udpSettings": "UDP Settings",
|
||||
"httpSettings": "HTTP",
|
||||
"tcpSettings": "TCP",
|
||||
"udpSettings": "UDP",
|
||||
"sshTitle": "SSH",
|
||||
"sshConnectingDescription": "Establishing a secure connection…",
|
||||
"sshConnecting": "Connecting…",
|
||||
@@ -3732,5 +4346,6 @@
|
||||
"rdpUploadFailed": "Upload failed",
|
||||
"rdpUnicodeKeyboardMode": "Unicode keyboard mode",
|
||||
"sessionToolbarShow": "Show toolbar",
|
||||
"sessionToolbarHide": "Hide toolbar"
|
||||
"sessionToolbarHide": "Hide toolbar",
|
||||
"actionUpdateSiteApprovals": "Update Site Approvals"
|
||||
}
|
||||
|
||||
@@ -1099,6 +1099,7 @@
|
||||
"actionGenerateAccessToken": "生成訪問令牌",
|
||||
"actionDeleteAccessToken": "刪除訪問令牌",
|
||||
"actionListAccessTokens": "訪問令牌",
|
||||
"actionCreateResourceSessionToken": "建立資源工作階段權杖",
|
||||
"actionCreateResourceRule": "創建資源規則",
|
||||
"actionDeleteResourceRule": "刪除資源規則",
|
||||
"actionListResourceRules": "列出資源規則",
|
||||
|
||||
@@ -32,49 +32,49 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||
"@asteasolutions/zod-to-openapi": "9.1.0",
|
||||
"@aws-sdk/client-s3": "3.1111.0",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
||||
"@aws-sdk/client-s3": "3.1056.0",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
"@hookform/resolvers": "5.9.0",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@node-rs/argon2": "2.0.2",
|
||||
"@node-rs/argon2": "2.1.0",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
"@radix-ui/react-checkbox": "1.3.11",
|
||||
"@radix-ui/react-collapsible": "1.1.20",
|
||||
"@radix-ui/react-dialog": "1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@radix-ui/react-label": "2.1.15",
|
||||
"@radix-ui/react-popover": "1.1.23",
|
||||
"@radix-ui/react-progress": "1.1.16",
|
||||
"@radix-ui/react-radio-group": "1.4.7",
|
||||
"@radix-ui/react-scroll-area": "1.2.18",
|
||||
"@radix-ui/react-select": "2.3.7",
|
||||
"@radix-ui/react-separator": "1.1.15",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-switch": "1.3.7",
|
||||
"@radix-ui/react-tabs": "1.1.21",
|
||||
"@radix-ui/react-toast": "1.2.23",
|
||||
"@radix-ui/react-tooltip": "1.2.16",
|
||||
"@react-email/body": "0.3.0",
|
||||
"@react-email/components": "1.0.12",
|
||||
"@react-email/render": "2.0.8",
|
||||
"@react-email/render": "2.1.0",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"@simplewebauthn/server": "13.3.2",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@tanstack/react-table": "9.1.2",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.16.1",
|
||||
"axios": "1.19.0",
|
||||
"better-sqlite3": "11.9.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -86,61 +86,62 @@
|
||||
"d3": "7.9.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.5.2",
|
||||
"express-rate-limit": "8.6.2",
|
||||
"glob": "13.0.6",
|
||||
"helmet": "8.2.0",
|
||||
"gpt-tokenizer": "^4.0.0",
|
||||
"helmet": "8.3.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.11.0",
|
||||
"ioredis": "6.0.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.2.0",
|
||||
"js-yaml": "5.3.0",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "1.17.0",
|
||||
"maxmind": "5.0.6",
|
||||
"lucide-react": "1.31.0",
|
||||
"maxmind": "5.0.7",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.6",
|
||||
"next-intl": "4.13.0",
|
||||
"next": "16.3.1",
|
||||
"next-intl": "4.13.6",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "9.0.1",
|
||||
"nodemailer": "9.0.5",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.21.0",
|
||||
"posthog-node": "5.35.6",
|
||||
"pg": "8.23.0",
|
||||
"posthog-node": "5.49.1",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.6",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.6",
|
||||
"react": "19.2.8",
|
||||
"react-day-picker": "10.0.1",
|
||||
"react-dom": "19.2.8",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.76.1",
|
||||
"react-icons": "5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"react-hook-form": "7.85.0",
|
||||
"react-icons": "5.7.0",
|
||||
"recharts": "3.10.1",
|
||||
"reodotdev": "1.1.0",
|
||||
"semver": "7.8.1",
|
||||
"semver": "7.8.5",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "22.2.0",
|
||||
"stripe": "22.5.0",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
"uuid": "14.0.0",
|
||||
"uuid": "14.0.1",
|
||||
"vaul": "1.1.2",
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.21.0",
|
||||
"ws": "8.21.3",
|
||||
"yaml": "2.9.0",
|
||||
"yargs": "18.0.0",
|
||||
"yargs": "18.1.0",
|
||||
"zod": "4.4.3",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.69.1",
|
||||
"@dotenvx/dotenvx": "2.21.0",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.5.0",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@react-email/ui": "^6.9.2",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tanstack/react-query-devtools": "5.101.4",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
@@ -151,36 +152,36 @@
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/nodemailer": "8.0.0",
|
||||
"@types/node": "26.2.0",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sshpk": "1.17.4",
|
||||
"@types/pg": "8.21.0",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@types/semver": "7.8.0",
|
||||
"@types/sshpk": "1.17.5",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.35",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.15",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsc-alias": "1.8.17",
|
||||
"tsx": "4.22.3",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.0"
|
||||
"esbuild": "0.28.2",
|
||||
"esbuild-node-externals": "2.0.0",
|
||||
"eslint": "10.8.1",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"postcss": "8.5.26",
|
||||
"prettier": "3.9.6",
|
||||
"react-email": "6.9.2",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsc-alias": "1.9.1",
|
||||
"tsx": "4.23.12",
|
||||
"typescript": "7.0.2",
|
||||
"typescript-eslint": "8.67.0"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild": "0.28.2",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.15"
|
||||
"postcss": "8.5.26"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 556 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg width="256" height="257" viewBox="0 0 256 257" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_29_2)">
|
||||
<path d="M50.2278 170.321L100.585 142.064L101.428 139.601L100.585 138.24H98.1225L89.6972 137.722L60.9215 136.944L35.9696 135.907L11.7954 134.611L5.70329 133.314L0 125.796L0.583291 122.037L5.70329 118.603L13.0268 119.251L29.2294 120.352L53.5332 122.037L71.1615 123.074L97.28 125.796H101.428L102.011 124.111L100.585 123.074L99.4835 122.037L74.3372 104.992L47.117 86.9752L32.8587 76.6056L25.1463 71.3559L21.2577 66.4304L19.5727 55.6719L26.5722 47.9595L35.9696 48.6076L38.3676 49.2557L47.8947 56.5792L68.2451 72.3281L94.8172 91.9008L98.7058 95.1413L100.261 94.0395L100.456 93.2618L98.7058 90.3453L84.2532 64.2268L68.8284 37.6547L61.9585 26.637L60.1438 20.0263C59.4957 17.3043 59.042 15.0359 59.042 12.2491L67.0137 1.42582L71.4208 0L82.0496 1.42582L86.5215 5.31443L93.1321 20.4152L103.826 44.2005L120.417 76.5408L125.278 86.1327L127.87 95.0116L128.843 97.7337H130.528V96.1782L131.889 77.9666L134.416 55.6071L136.879 26.8314L137.722 18.7301L141.74 9.00861L149.711 3.75899L155.933 6.74025L161.053 14.0638L160.34 18.7949L157.294 38.562L151.332 69.5413L147.443 90.2805H149.711L152.304 87.6881L162.803 73.7539L180.431 51.7185L188.209 42.9691L197.282 33.3124L203.115 28.7109H214.133L222.234 40.7656L218.605 53.2091L207.263 67.597L197.865 79.7813L184.385 97.9281L175.959 112.446L176.737 113.612L178.746 113.418L209.207 106.937L225.669 103.955L245.306 100.585L254.185 104.733L255.157 108.946L251.658 117.566L230.659 122.75L206.031 127.676L169.349 136.361L168.895 136.685L169.414 137.333L185.94 138.888L193.005 139.277H210.309L242.52 141.675L250.945 147.249L256 154.054L255.157 159.238L242.195 165.849L224.697 161.701L183.866 151.98L169.867 148.48H167.923V149.647L179.589 161.053L200.976 180.367L227.743 205.254L229.104 211.411L225.669 216.271L222.039 215.753L198.513 198.06L189.44 190.088L168.895 172.784H167.534V174.598L172.265 181.533L197.282 219.123L198.578 230.659L196.764 234.418L190.283 236.687L183.153 235.39L168.506 214.846L153.406 191.708L141.221 170.969L139.731 171.812L132.537 249.26L129.167 253.213L121.389 256.194L114.908 251.269L111.473 243.297L114.908 227.548L119.056 207.004L122.426 190.671L125.472 170.386L127.287 163.646L127.157 163.192L125.667 163.386L110.372 184.385L87.1048 215.818L68.6987 235.52L64.2916 237.27L56.6441 233.316L57.357 226.252L61.6344 219.966L87.1048 187.561L102.465 167.469L112.381 155.868L112.316 154.183H111.733L44.0709 198.125L32.0162 199.68L26.8314 194.819L27.4795 186.848L29.9423 184.255L50.2927 170.256L50.2278 170.321Z" fill="#0B0B0B"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_29_2">
|
||||
<rect width="256" height="257" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg width="256" height="257" viewBox="0 0 256 257" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_29_2)">
|
||||
<path d="M50.2278 170.321L100.585 142.064L101.428 139.601L100.585 138.24H98.1225L89.6972 137.722L60.9215 136.944L35.9696 135.907L11.7954 134.611L5.70329 133.314L0 125.796L0.583291 122.037L5.70329 118.603L13.0268 119.251L29.2294 120.352L53.5332 122.037L71.1615 123.074L97.28 125.796H101.428L102.011 124.111L100.585 123.074L99.4835 122.037L74.3372 104.992L47.117 86.9752L32.8587 76.6056L25.1463 71.3559L21.2577 66.4304L19.5727 55.6719L26.5722 47.9595L35.9696 48.6076L38.3676 49.2557L47.8947 56.5792L68.2451 72.3281L94.8172 91.9008L98.7058 95.1413L100.261 94.0395L100.456 93.2618L98.7058 90.3453L84.2532 64.2268L68.8284 37.6547L61.9585 26.637L60.1438 20.0263C59.4957 17.3043 59.042 15.0359 59.042 12.2491L67.0137 1.42582L71.4208 0L82.0496 1.42582L86.5215 5.31443L93.1321 20.4152L103.826 44.2005L120.417 76.5408L125.278 86.1327L127.87 95.0116L128.843 97.7337H130.528V96.1782L131.889 77.9666L134.416 55.6071L136.879 26.8314L137.722 18.7301L141.74 9.00861L149.711 3.75899L155.933 6.74025L161.053 14.0638L160.34 18.7949L157.294 38.562L151.332 69.5413L147.443 90.2805H149.711L152.304 87.6881L162.803 73.7539L180.431 51.7185L188.209 42.9691L197.282 33.3124L203.115 28.7109H214.133L222.234 40.7656L218.605 53.2091L207.263 67.597L197.865 79.7813L184.385 97.9281L175.959 112.446L176.737 113.612L178.746 113.418L209.207 106.937L225.669 103.955L245.306 100.585L254.185 104.733L255.157 108.946L251.658 117.566L230.659 122.75L206.031 127.676L169.349 136.361L168.895 136.685L169.414 137.333L185.94 138.888L193.005 139.277H210.309L242.52 141.675L250.945 147.249L256 154.054L255.157 159.238L242.195 165.849L224.697 161.701L183.866 151.98L169.867 148.48H167.923V149.647L179.589 161.053L200.976 180.367L227.743 205.254L229.104 211.411L225.669 216.271L222.039 215.753L198.513 198.06L189.44 190.088L168.895 172.784H167.534V174.598L172.265 181.533L197.282 219.123L198.578 230.659L196.764 234.418L190.283 236.687L183.153 235.39L168.506 214.846L153.406 191.708L141.221 170.969L139.731 171.812L132.537 249.26L129.167 253.213L121.389 256.194L114.908 251.269L111.473 243.297L114.908 227.548L119.056 207.004L122.426 190.671L125.472 170.386L127.287 163.646L127.157 163.192L125.667 163.386L110.372 184.385L87.1048 215.818L68.6987 235.52L64.2916 237.27L56.6441 233.316L57.357 226.252L61.6344 219.966L87.1048 187.561L102.465 167.469L112.381 155.868L112.316 154.183H111.733L44.0709 198.125L32.0162 199.68L26.8314 194.819L27.4795 186.848L29.9423 184.255L50.2927 170.256L50.2278 170.321Z" fill="#F0EFEC"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_29_2">
|
||||
<rect width="256" height="257" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
|
||||
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #26251e;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 793 B |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
|
||||
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #edecec;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 793 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="716" height="716" viewBox="157 157 402 402" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M508.749 317.399C516.777 287.314 508.991 253.884 485.389 230.282C461.788 206.681 428.36 198.895 398.273 206.923C376.231 184.928 343.39 174.956 311.148 183.596C278.906 192.234 255.45 217.292 247.36 247.361C217.291 255.451 192.233 278.91 183.595 311.149C174.957 343.391 184.927 376.232 206.924 398.274C198.896 428.359 206.683 461.789 230.284 485.391C253.885 508.992 287.313 516.779 317.401 508.75C339.442 530.745 372.286 540.717 404.525 532.079C436.767 523.441 460.223 498.384 468.313 468.315C498.383 460.224 523.44 436.766 532.078 404.526C540.716 372.285 530.747 339.443 508.749 317.402V317.399ZM470.899 244.776C486.892 260.77 493.488 282.601 490.687 303.412L415.577 260.046C412.411 258.218 408.509 258.218 405.345 260.046L317.401 310.82V277.526C317.401 275.191 318.652 273.005 320.676 271.837L387.644 233.174C414.178 218.353 448.346 222.223 470.901 244.776H470.899ZM357.837 311.144L398.275 334.491V381.185L357.837 404.532L317.398 381.185V334.491L357.837 311.144ZM264.776 269.693C265.207 239.305 285.644 211.649 316.453 203.393C338.3 197.54 360.505 202.744 377.127 215.573L302.014 258.937C298.848 260.764 296.898 264.144 296.898 267.798V369.346L268.065 352.699C266.043 351.531 264.776 349.353 264.776 347.017V269.691V269.693ZM203.391 316.454C209.244 294.608 224.854 277.978 244.276 269.999V356.73C244.276 360.384 246.226 363.763 249.392 365.591L337.337 416.365L308.503 433.013C306.481 434.181 303.961 434.188 301.939 433.02L234.971 394.357C208.868 378.789 195.138 347.261 203.391 316.454ZM244.775 470.9C228.781 454.906 222.186 433.075 224.986 412.264L300.096 455.63C303.263 457.457 307.164 457.457 310.328 455.63L398.273 404.856V438.149C398.273 440.485 397.022 442.671 394.997 443.839L328.029 482.502C301.495 497.322 267.327 493.452 244.772 470.9H244.775ZM450.897 445.982C450.466 476.371 430.029 504.027 399.22 512.283C377.373 518.136 355.168 512.932 338.547 500.102L413.659 456.738C416.826 454.911 418.775 451.532 418.775 447.877V346.329L447.609 362.977C449.631 364.145 450.897 366.323 450.897 368.659V445.985V445.982ZM512.282 399.221C506.429 421.068 490.819 437.697 471.397 445.676V358.946C471.397 355.292 469.448 351.912 466.281 350.085L378.336 299.311L407.17 282.663C409.192 281.495 411.712 281.487 413.734 282.655L480.702 321.318C506.805 336.887 520.536 368.415 512.282 399.221Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="716" height="716" viewBox="157 157 402 402" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M508.749 317.399C516.777 287.314 508.991 253.884 485.389 230.282C461.788 206.681 428.36 198.895 398.273 206.923C376.231 184.928 343.39 174.956 311.148 183.596C278.906 192.234 255.45 217.292 247.36 247.361C217.291 255.451 192.233 278.91 183.595 311.149C174.957 343.391 184.927 376.232 206.924 398.274C198.896 428.359 206.683 461.789 230.284 485.391C253.885 508.992 287.313 516.779 317.401 508.75C339.442 530.745 372.286 540.717 404.525 532.079C436.767 523.441 460.223 498.384 468.313 468.315C498.383 460.224 523.44 436.766 532.078 404.526C540.716 372.285 530.747 339.443 508.749 317.402V317.399ZM470.899 244.776C486.892 260.77 493.488 282.601 490.687 303.412L415.577 260.046C412.411 258.218 408.509 258.218 405.345 260.046L317.401 310.82V277.526C317.401 275.191 318.652 273.005 320.676 271.837L387.644 233.174C414.178 218.353 448.346 222.223 470.901 244.776H470.899ZM357.837 311.144L398.275 334.491V381.185L357.837 404.532L317.398 381.185V334.491L357.837 311.144ZM264.776 269.693C265.207 239.305 285.644 211.649 316.453 203.393C338.3 197.54 360.505 202.744 377.127 215.573L302.014 258.937C298.848 260.764 296.898 264.144 296.898 267.798V369.346L268.065 352.699C266.043 351.531 264.776 349.353 264.776 347.017V269.691V269.693ZM203.391 316.454C209.244 294.608 224.854 277.978 244.276 269.999V356.73C244.276 360.384 246.226 363.763 249.392 365.591L337.337 416.365L308.503 433.013C306.481 434.181 303.961 434.188 301.939 433.02L234.971 394.357C208.868 378.789 195.138 347.261 203.391 316.454ZM244.775 470.9C228.781 454.906 222.186 433.075 224.986 412.264L300.096 455.63C303.263 457.457 307.164 457.457 310.328 455.63L398.273 404.856V438.149C398.273 440.485 397.022 442.671 394.997 443.839L328.029 482.502C301.495 497.322 267.327 493.452 244.772 470.9H244.775ZM450.897 445.982C450.466 476.371 430.029 504.027 399.22 512.283C377.373 518.136 355.168 512.932 338.547 500.102L413.659 456.738C416.826 454.911 418.775 451.532 418.775 447.877V346.329L447.609 362.977C449.631 364.145 450.897 366.323 450.897 368.659V445.985V445.982ZM512.282 399.221C506.429 421.068 490.819 437.697 471.397 445.676V358.946C471.397 355.292 469.448 351.912 466.281 350.085L378.336 299.311L407.17 282.663C409.192 281.495 411.712 281.487 413.734 282.655L480.702 321.318C506.805 336.887 520.536 368.415 512.282 399.221Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86274)'><mask id='mask0_1401_86274' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86274)'><path d='M180 240H60V120H180V240Z' fill='#CFCECD'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#211E1E'/></g></g><defs><clipPath id='clip0_1401_86274'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 577 B |
@@ -0,0 +1 @@
|
||||
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86283)'><mask id='mask0_1401_86283' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86283)'><path d='M180 240H60V120H180V240Z' fill='#4B4646'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#F1ECEC'/></g></g><defs><clipPath id='clip0_1401_86283'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 577 B |
@@ -0,0 +1,39 @@
|
||||
import express from "express";
|
||||
import helmet from "helmet";
|
||||
import cors from "cors";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
errorHandlerMiddleware,
|
||||
notFoundMiddleware
|
||||
} from "@server/middlewares";
|
||||
import { createAiGatewayRouter } from "@server/routers/aiGateway";
|
||||
|
||||
const aiGatewayPort = config.getRawConfig().server.ai_gateway_port;
|
||||
|
||||
export function createAiGatewayServer() {
|
||||
const aiGatewayServer = express();
|
||||
|
||||
const trustProxy = config.getRawConfig().server.trust_proxy;
|
||||
if (trustProxy) {
|
||||
aiGatewayServer.set("trust proxy", trustProxy);
|
||||
}
|
||||
|
||||
aiGatewayServer.use(helmet());
|
||||
aiGatewayServer.use(cors());
|
||||
aiGatewayServer.use(express.json());
|
||||
|
||||
aiGatewayServer.use(createAiGatewayRouter());
|
||||
|
||||
aiGatewayServer.use(notFoundMiddleware);
|
||||
aiGatewayServer.use(errorHandlerMiddleware);
|
||||
|
||||
aiGatewayServer.listen(aiGatewayPort, (err?: any) => {
|
||||
if (err) throw err;
|
||||
logger.info(
|
||||
`AI gateway server is running on http://localhost:${aiGatewayPort}`
|
||||
);
|
||||
});
|
||||
|
||||
return aiGatewayServer;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export enum ActionsEnum {
|
||||
getSite = "getSite",
|
||||
listSites = "listSites",
|
||||
updateSite = "updateSite",
|
||||
updateSiteApprovals = "updateSiteApprovals",
|
||||
restartSite = "restartSite",
|
||||
resetSiteBandwidth = "resetSiteBandwidth",
|
||||
reGenerateSecret = "reGenerateSecret",
|
||||
@@ -49,6 +50,8 @@ export enum ActionsEnum {
|
||||
setResourceUsers = "setResourceUsers",
|
||||
setResourceRoles = "setResourceRoles",
|
||||
listResourceUsers = "listResourceUsers",
|
||||
listResourceAiModels = "listResourceAiModels",
|
||||
setResourceAiModels = "setResourceAiModels",
|
||||
// removeRoleSite = "removeRoleSite",
|
||||
// addRoleAction = "addRoleAction",
|
||||
// removeRoleAction = "removeRoleAction",
|
||||
@@ -71,6 +74,7 @@ export enum ActionsEnum {
|
||||
setResourceWhitelist = "setResourceWhitelist",
|
||||
getResourceWhitelist = "getResourceWhitelist",
|
||||
generateAccessToken = "generateAccessToken",
|
||||
createResourceSessionToken = "createResourceSessionToken",
|
||||
deleteAcessToken = "deleteAcessToken",
|
||||
listAccessTokens = "listAccessTokens",
|
||||
createResourceRule = "createResourceRule",
|
||||
@@ -149,6 +153,7 @@ export enum ActionsEnum {
|
||||
createAlertRule = "createAlertRule",
|
||||
updateAlertRule = "updateAlertRule",
|
||||
deleteAlertRule = "deleteAlertRule",
|
||||
testAlertRule = "testAlertRule",
|
||||
listAlertRules = "listAlertRules",
|
||||
listOrgLabels = "listOrgLabels",
|
||||
createOrgLabel = "createOrgLabel",
|
||||
@@ -180,7 +185,27 @@ export enum ActionsEnum {
|
||||
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
||||
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
||||
setResourcePolicyRules = "setResourcePolicyRules",
|
||||
createOrgWideLauncherView = "createOrgWideLauncherView"
|
||||
createOrgWideLauncherView = "createOrgWideLauncherView",
|
||||
createAiProvider = "createAiProvider",
|
||||
deleteAiProvider = "deleteAiProvider",
|
||||
getAiProvider = "getAiProvider",
|
||||
listAiProviders = "listAiProviders",
|
||||
updateAiProvider = "updateAiProvider",
|
||||
createAiModel = "createAiModel",
|
||||
deleteAiModel = "deleteAiModel",
|
||||
getAiModel = "getAiModel",
|
||||
listAiModels = "listAiModels",
|
||||
updateAiModel = "updateAiModel",
|
||||
createAiBudget = "createAiBudget",
|
||||
deleteAiBudget = "deleteAiBudget",
|
||||
getAiBudget = "getAiBudget",
|
||||
listAiBudgets = "listAiBudgets",
|
||||
updateAiBudget = "updateAiBudget",
|
||||
createVirtualApiKey = "createVirtualApiKey",
|
||||
deleteVirtualApiKey = "deleteVirtualApiKey",
|
||||
getVirtualApiKey = "getVirtualApiKey",
|
||||
listVirtualApiKeys = "listVirtualApiKeys",
|
||||
updateVirtualApiKey = "updateVirtualApiKey"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||
import {
|
||||
db,
|
||||
users,
|
||||
virtualApiKeyResources,
|
||||
virtualApiKeys,
|
||||
type VirtualApiKey
|
||||
} from "@server/db";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
decryptVirtualApiKeyToken,
|
||||
VIRTUAL_API_KEY_PREFIX,
|
||||
looksLikeVirtualApiKeyCredential
|
||||
} from "@server/lib/virtualApiKey";
|
||||
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { isWithinExpirationDate } from "oslo";
|
||||
|
||||
export type VirtualApiKeyCredential = {
|
||||
virtualApiKeyId: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type VirtualApiKeyUserData = {
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
role: string | null;
|
||||
};
|
||||
|
||||
function getHeader(
|
||||
headers: Record<string, string> | undefined,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
if (headers[name] !== undefined) {
|
||||
return headers[name];
|
||||
}
|
||||
const lower = name.toLowerCase();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === lower) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseVkCredential(
|
||||
raw: string | undefined
|
||||
): VirtualApiKeyCredential | null {
|
||||
if (!raw || !looksLikeVirtualApiKeyCredential(raw)) {
|
||||
return null;
|
||||
}
|
||||
const withoutPrefix = raw.trim().slice(VIRTUAL_API_KEY_PREFIX.length);
|
||||
const dot = withoutPrefix.indexOf(".");
|
||||
return {
|
||||
virtualApiKeyId: withoutPrefix.slice(0, dot),
|
||||
secret: withoutPrefix.slice(dot + 1)
|
||||
};
|
||||
}
|
||||
|
||||
export function extractVirtualApiKeyCredential(
|
||||
headers: Record<string, string> | undefined
|
||||
): VirtualApiKeyCredential | null {
|
||||
if (!headers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorization = getHeader(headers, "authorization");
|
||||
if (authorization) {
|
||||
const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i);
|
||||
if (bearerMatch) {
|
||||
const credential = parseVkCredential(bearerMatch[1]);
|
||||
if (credential) {
|
||||
return credential;
|
||||
}
|
||||
}
|
||||
const splunkMatch = authorization.match(/^Splunk\s+(.+)$/i);
|
||||
if (splunkMatch) {
|
||||
const credential = parseVkCredential(splunkMatch[1]);
|
||||
if (credential) {
|
||||
return credential;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cfAig = getHeader(headers, "cf-aig-authorization");
|
||||
if (cfAig) {
|
||||
const bearerMatch = cfAig.match(/^Bearer\s+(.+)$/i);
|
||||
const credential = parseVkCredential(
|
||||
bearerMatch ? bearerMatch[1] : cfAig
|
||||
);
|
||||
if (credential) {
|
||||
return credential;
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of ["x-api-key", "x-goog-api-key"] as const) {
|
||||
const credential = parseVkCredential(getHeader(headers, name));
|
||||
if (credential) {
|
||||
return credential;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function buildUserData(
|
||||
userId: string,
|
||||
orgId: string
|
||||
): Promise<VirtualApiKeyUserData | undefined> {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
config.getRawConfig().flags?.require_email_verification &&
|
||||
!user.emailVerified
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
|
||||
if (userOrgRoles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
|
||||
};
|
||||
}
|
||||
|
||||
async function userHasResourceAccess(
|
||||
userId: string,
|
||||
resourceId: number,
|
||||
orgId: string
|
||||
): Promise<{ allowed: boolean; userData?: VirtualApiKeyUserData }> {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
if (
|
||||
config.getRawConfig().flags?.require_email_verification &&
|
||||
!user.emailVerified
|
||||
) {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
|
||||
if (userOrgRoles.length === 0) {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
const allowed = await canUserAccessResource({
|
||||
userId,
|
||||
resourceId,
|
||||
roleIds: userOrgRoles.map((r) => r.roleId)
|
||||
});
|
||||
|
||||
if (!allowed) {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
userData: {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function manualKeyHasResourceAccess(
|
||||
key: VirtualApiKey,
|
||||
resourceId: number
|
||||
): Promise<boolean> {
|
||||
if (key.allResources) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({ resourceId: virtualApiKeyResources.resourceId })
|
||||
.from(virtualApiKeyResources)
|
||||
.where(
|
||||
and(
|
||||
eq(virtualApiKeyResources.virtualApiKeyId, key.virtualApiKeyId),
|
||||
eq(virtualApiKeyResources.resourceId, resourceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
async function touchLastUsedAt(virtualApiKeyId: string): Promise<void> {
|
||||
try {
|
||||
await db
|
||||
.update(virtualApiKeys)
|
||||
.set({ lastUsedAt: Date.now() })
|
||||
.where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId));
|
||||
} catch {
|
||||
// Best-effort; do not fail auth on audit timestamp updates.
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyVirtualApiKey({
|
||||
credential,
|
||||
resourceId,
|
||||
orgId
|
||||
}: {
|
||||
credential: VirtualApiKeyCredential;
|
||||
resourceId: number;
|
||||
orgId: string;
|
||||
}): Promise<{
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
key?: VirtualApiKey;
|
||||
userData?: VirtualApiKeyUserData;
|
||||
}> {
|
||||
const [key] = await db
|
||||
.select()
|
||||
.from(virtualApiKeys)
|
||||
.where(eq(virtualApiKeys.virtualApiKeyId, credential.virtualApiKeyId))
|
||||
.limit(1);
|
||||
|
||||
if (!key) {
|
||||
return { valid: false, error: "Virtual API key not found" };
|
||||
}
|
||||
|
||||
if (key.orgId !== orgId) {
|
||||
return { valid: false, error: "Virtual API key org mismatch" };
|
||||
}
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = decryptVirtualApiKeyToken(key.token);
|
||||
} catch {
|
||||
return { valid: false, error: "Virtual API key secret is invalid" };
|
||||
}
|
||||
|
||||
if (plaintext !== credential.secret) {
|
||||
return { valid: false, error: "Invalid virtual API key secret" };
|
||||
}
|
||||
|
||||
if (key.expiresAt && !isWithinExpirationDate(new Date(key.expiresAt))) {
|
||||
return { valid: false, error: "Virtual API key has expired" };
|
||||
}
|
||||
|
||||
if (key.kind === "manual") {
|
||||
const scoped = await manualKeyHasResourceAccess(key, resourceId);
|
||||
if (!scoped) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Virtual API key is not scoped to this resource"
|
||||
};
|
||||
}
|
||||
|
||||
let userData: VirtualApiKeyUserData | undefined;
|
||||
if (key.userId) {
|
||||
userData = await buildUserData(key.userId, orgId);
|
||||
}
|
||||
|
||||
await touchLastUsedAt(key.virtualApiKeyId);
|
||||
return { valid: true, key, userData };
|
||||
}
|
||||
|
||||
if (key.kind === "user") {
|
||||
if (!key.userId) {
|
||||
return { valid: false, error: "User virtual API key has no user" };
|
||||
}
|
||||
|
||||
const access = await userHasResourceAccess(
|
||||
key.userId,
|
||||
resourceId,
|
||||
orgId
|
||||
);
|
||||
if (!access.allowed || !access.userData) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "User is not allowed to access this resource"
|
||||
};
|
||||
}
|
||||
|
||||
await touchLastUsedAt(key.virtualApiKeyId);
|
||||
return { valid: true, key, userData: access.userData };
|
||||
}
|
||||
|
||||
return { valid: false, error: "Unknown virtual API key kind" };
|
||||
}
|
||||
@@ -3,12 +3,16 @@ import { flushConnectionLogToDb } from "#dynamic/routers/newt";
|
||||
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
||||
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
|
||||
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
|
||||
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
|
||||
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
|
||||
|
||||
async function cleanup() {
|
||||
await stopPingAccumulator();
|
||||
await flushBandwidthToDb();
|
||||
await flushConnectionLogToDb();
|
||||
await flushSiteBandwidthToDb();
|
||||
await shutdownUsageRecorder();
|
||||
await shutdownAiSessionLogger();
|
||||
await wsCleanup();
|
||||
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import {
|
||||
aiProviders,
|
||||
clients,
|
||||
db,
|
||||
resourcePolicies,
|
||||
@@ -113,6 +114,32 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUniqueProviderName(orgId: string): Promise<string> {
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error("Could not generate a unique name");
|
||||
}
|
||||
|
||||
const name = generateName();
|
||||
|
||||
const aiProviderCount = await db
|
||||
.select({
|
||||
niceId: aiProviders.niceId,
|
||||
orgId: aiProviders.orgId
|
||||
})
|
||||
.from(aiProviders)
|
||||
.where(
|
||||
and(eq(aiProviders.niceId, name), eq(aiProviders.orgId, orgId))
|
||||
);
|
||||
|
||||
if (aiProviderCount.length === 0) {
|
||||
return name;
|
||||
}
|
||||
loops++;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUniqueResourcePolicyName(
|
||||
orgId: string
|
||||
): Promise<string> {
|
||||
|
||||
@@ -29,25 +29,6 @@ import {
|
||||
labels
|
||||
} from "./schema";
|
||||
|
||||
export const certificates = pgTable("certificates", {
|
||||
certId: serial("certId").primaryKey(),
|
||||
domain: varchar("domain", { length: 255 }).notNull().unique(),
|
||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
wildcard: boolean("wildcard").default(false),
|
||||
status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, requested, valid, expired, failed
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }),
|
||||
lastRenewalAttempt: bigint("lastRenewalAttempt", { mode: "number" }),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull(),
|
||||
orderId: varchar("orderId", { length: 500 }),
|
||||
errorMessage: text("errorMessage"),
|
||||
renewalCount: integer("renewalCount").default(0),
|
||||
certFile: text("certFile"),
|
||||
keyFile: text("keyFile")
|
||||
});
|
||||
|
||||
export const dnsChallenge = pgTable("dnsChallenges", {
|
||||
dnsChallengeId: serial("dnsChallengeId").primaryKey(),
|
||||
domain: varchar("domain", { length: 255 }).notNull(),
|
||||
@@ -95,7 +76,8 @@ export const subscriptions = pgTable("subscriptions", {
|
||||
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }),
|
||||
trial: boolean("trial").default(false),
|
||||
type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
|
||||
type: varchar("type", { length: 50 }), // tier1, tier2, tier3, or license
|
||||
override: boolean("override").default(false)
|
||||
});
|
||||
|
||||
export const subscriptionItems = pgTable("subscriptionItems", {
|
||||
@@ -632,7 +614,6 @@ export const trialNotifications = pgTable("trialNotifications", {
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
|
||||
export type Customer = InferSelectModel<typeof customers>;
|
||||
export type Subscription = InferSelectModel<typeof subscriptions>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InferSelectModel, sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
serial,
|
||||
text,
|
||||
unique,
|
||||
uniqueIndex,
|
||||
varchar
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
@@ -18,7 +20,7 @@ export const domains = pgTable("domains", {
|
||||
domainId: varchar("domainId").primaryKey(),
|
||||
baseDomain: varchar("baseDomain").notNull(),
|
||||
configManaged: boolean("configManaged").notNull().default(false),
|
||||
type: varchar("type"), // "ns", "cname", "wildcard"
|
||||
type: varchar("type").$type<"ns" | "cname" | "wildcard">(),
|
||||
verified: boolean("verified").notNull().default(false),
|
||||
failed: boolean("failed").notNull().default(false),
|
||||
tries: integer("tries").notNull().default(0),
|
||||
@@ -63,6 +65,11 @@ export const orgs = pgTable("orgs", {
|
||||
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
settingsLogRetentionDaysAISessions: integer(
|
||||
"settingsLogRetentionDaysAISessions"
|
||||
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(7),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||
isBillingOrg: boolean("isBillingOrg"),
|
||||
@@ -98,7 +105,7 @@ export const sites = pgTable(
|
||||
}),
|
||||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
subnet: varchar("subnet"),
|
||||
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node and INCLUDES THE CIDR
|
||||
megabytesIn: real("bytesIn").default(0),
|
||||
megabytesOut: real("bytesOut").default(0),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
@@ -107,6 +114,7 @@ export const sites = pgTable(
|
||||
lastPing: integer("lastPing"),
|
||||
address: varchar("address"),
|
||||
endpoint: varchar("endpoint"),
|
||||
localEndpoints: varchar("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
|
||||
publicKey: varchar("publicKey"),
|
||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||
listenPort: integer("listenPort"),
|
||||
@@ -193,14 +201,22 @@ export const resources = pgTable(
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: boolean("wildcard").notNull().default(false),
|
||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||
mode: text("mode")
|
||||
.default("http")
|
||||
.$type<
|
||||
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
|
||||
>()
|
||||
.notNull(),
|
||||
pamMode: varchar("pamMode", { length: 32 })
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
status: varchar("status")
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
},
|
||||
(t) => [
|
||||
index("idx_resources_fulldomain")
|
||||
@@ -211,6 +227,41 @@ export const resources = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const resourceAiProviders = pgTable(
|
||||
"resourceAiProviders",
|
||||
{
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
providerId: integer("providerId")
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
accessMode: varchar("accessMode")
|
||||
.$type<"inherit" | "select">()
|
||||
.notNull()
|
||||
.default("inherit"),
|
||||
enabled: boolean("enabled").notNull().default(true)
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
||||
);
|
||||
|
||||
export const resourceAiModels = pgTable(
|
||||
"resourceAiModels",
|
||||
{
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
modelId: integer("modelId")
|
||||
.notNull()
|
||||
.references(() => aiModels.modelId, { onDelete: "cascade" }),
|
||||
listType: varchar("listType")
|
||||
.$type<"allow" | "block">()
|
||||
.notNull()
|
||||
.default("allow")
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
||||
);
|
||||
|
||||
export const labels = pgTable("labels", {
|
||||
labelId: serial("labelId").primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
@@ -313,11 +364,18 @@ export const targets = pgTable(
|
||||
"targets",
|
||||
{
|
||||
targetId: serial("targetId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
}
|
||||
),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{
|
||||
onDelete: "cascade"
|
||||
}
|
||||
),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
@@ -341,6 +399,7 @@ export const targets = pgTable(
|
||||
},
|
||||
(t) => [
|
||||
index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId),
|
||||
index("idx_targets_providerid_siteid").on(t.providerId, t.siteId),
|
||||
index("idx_targets_site_enabled_priority_target_resource")
|
||||
.on(t.siteId, t.priority.desc(), t.targetId, t.resourceId)
|
||||
.where(sql`${t.enabled} = true`)
|
||||
@@ -420,11 +479,14 @@ export const siteResources = pgTable(
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
requiresExitNodeConnection: boolean("requiresExitNodeConnection")
|
||||
.notNull()
|
||||
.default(false),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
mode: varchar("mode")
|
||||
.$type<"host" | "cidr" | "http" | "ssh">()
|
||||
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
|
||||
.notNull(), // "host" | "cidr" | "http"
|
||||
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
@@ -451,11 +513,53 @@ export const siteResources = pgTable(
|
||||
onDelete: "set null"
|
||||
}),
|
||||
subdomain: varchar("subdomain"),
|
||||
fullDomain: varchar("fullDomain")
|
||||
fullDomain: varchar("fullDomain"),
|
||||
status: varchar("status")
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
},
|
||||
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
|
||||
);
|
||||
|
||||
export const siteResourceAiProviders = pgTable(
|
||||
"siteResourceAiProviders",
|
||||
{
|
||||
siteResourceId: integer("siteResourceId")
|
||||
.notNull()
|
||||
.references(() => siteResources.siteResourceId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId")
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
accessMode: varchar("accessMode")
|
||||
.$type<"inherit" | "select">()
|
||||
.notNull()
|
||||
.default("inherit"),
|
||||
enabled: boolean("enabled").notNull().default(true)
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
||||
);
|
||||
|
||||
export const siteResourceAiModels = pgTable(
|
||||
"siteResourceAiModels",
|
||||
{
|
||||
siteResourceId: integer("siteResourceId")
|
||||
.notNull()
|
||||
.references(() => siteResources.siteResourceId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
modelId: integer("modelId")
|
||||
.notNull()
|
||||
.references(() => aiModels.modelId, { onDelete: "cascade" }),
|
||||
listType: varchar("listType")
|
||||
.$type<"allow" | "block">()
|
||||
.notNull()
|
||||
.default("allow")
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
|
||||
);
|
||||
|
||||
export const networks = pgTable(
|
||||
"networks",
|
||||
{
|
||||
@@ -658,7 +762,7 @@ export const roles = pgTable("roles", {
|
||||
name: varchar("name").notNull(),
|
||||
description: varchar("description"),
|
||||
requireDeviceApproval: boolean("requireDeviceApproval").default(false),
|
||||
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("none"), // "none" | "full" | "commands"
|
||||
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("full"), // "none" | "full" | "commands"
|
||||
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
||||
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
|
||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||
@@ -899,12 +1003,16 @@ export const resourceAccessToken = pgTable("resourceAccessToken", {
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
path: varchar("path"),
|
||||
tokenHash: varchar("tokenHash").notNull(),
|
||||
sessionLength: bigint("sessionLength", { mode: "number" }).notNull(),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }),
|
||||
title: varchar("title"),
|
||||
description: varchar("description"),
|
||||
persistSession: boolean("persistSession").notNull().default(false),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
@@ -999,7 +1107,17 @@ export const resourceRules = pgTable("resourceRules", {
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
action: varchar("action").notNull(), // ACCEPT, DROP, PASS
|
||||
match: varchar("match").notNull(), // CIDR, PATH, IP
|
||||
match: varchar("match")
|
||||
.$type<
|
||||
| "CIDR"
|
||||
| "PATH"
|
||||
| "IP"
|
||||
| "COUNTRY"
|
||||
| "COUNTRY_IS_NOT"
|
||||
| "ASN"
|
||||
| "REGION"
|
||||
>()
|
||||
.notNull(), // CIDR, PATH, IP
|
||||
value: varchar("value").notNull()
|
||||
});
|
||||
|
||||
@@ -1014,7 +1132,15 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
|
||||
priority: integer("priority").notNull(),
|
||||
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||
match: varchar("match")
|
||||
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
|
||||
.$type<
|
||||
| "CIDR"
|
||||
| "PATH"
|
||||
| "IP"
|
||||
| "COUNTRY"
|
||||
| "COUNTRY_IS_NOT"
|
||||
| "ASN"
|
||||
| "REGION"
|
||||
>()
|
||||
.notNull(),
|
||||
value: varchar("value").notNull()
|
||||
});
|
||||
@@ -1121,6 +1247,52 @@ export const apiKeyOrg = pgTable("apiKeyOrg", {
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const virtualApiKeys = pgTable(
|
||||
"virtualApiKeys",
|
||||
{
|
||||
virtualApiKeyId: varchar("virtualApiKeyId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
kind: varchar("kind").$type<"user" | "manual">().notNull(),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
name: varchar("name"),
|
||||
description: varchar("description"),
|
||||
token: varchar("token").notNull(),
|
||||
lastChars: varchar("lastChars").notNull(),
|
||||
allResources: boolean("allResources").notNull().default(false),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }),
|
||||
lastUsedAt: bigint("lastUsedAt", { mode: "number" }),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
createdByUserId: varchar("createdByUserId").references(
|
||||
() => users.userId,
|
||||
{ onDelete: "set null" }
|
||||
)
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("virtual_api_key_user_identity_uniq")
|
||||
.on(t.orgId, t.userId)
|
||||
.where(sql`${t.kind} = 'user'`)
|
||||
]
|
||||
);
|
||||
|
||||
export const virtualApiKeyResources = pgTable(
|
||||
"virtualApiKeyResources",
|
||||
{
|
||||
virtualApiKeyId: varchar("virtualApiKeyId")
|
||||
.notNull()
|
||||
.references(() => virtualApiKeys.virtualApiKeyId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.virtualApiKeyId, t.resourceId] })]
|
||||
);
|
||||
|
||||
export const idpOrg = pgTable("idpOrg", {
|
||||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
@@ -1153,6 +1325,7 @@ export const clients = pgTable(
|
||||
name: varchar("name").notNull(),
|
||||
pubKey: varchar("pubKey"),
|
||||
subnet: varchar("subnet").notNull(),
|
||||
exitNodeSubnet: varchar("exitNodeSubnet"), // INCLUDES THE CIDR
|
||||
megabytesIn: real("bytesIn"),
|
||||
megabytesOut: real("bytesOut"),
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
@@ -1511,6 +1684,358 @@ export const statusHistory = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const aiProviders = pgTable(
|
||||
"aiProviders",
|
||||
{
|
||||
providerId: serial("providerId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
name: varchar("name").notNull(),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
type: varchar("type")
|
||||
.$type<
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "googleGemini"
|
||||
| "vertexAi"
|
||||
| "bedrock"
|
||||
| "microsoftFoundry"
|
||||
| "openRouter"
|
||||
| "vercelAiGateway"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull(),
|
||||
upstreamUrl: text("upstreamUrl"),
|
||||
apiKey: text("apiKey"),
|
||||
apiKeyLastChars: varchar("apiKeyLastChars"),
|
||||
authType: varchar("authType")
|
||||
.$type<
|
||||
| "bearer"
|
||||
| "x-api-key"
|
||||
| "x-goog-api-key"
|
||||
| "hec"
|
||||
| "cf-aig-authorization"
|
||||
| "none"
|
||||
| "passthrough"
|
||||
>()
|
||||
.notNull(),
|
||||
routingMode: varchar("routingMode")
|
||||
.$type<"url" | "target">()
|
||||
.notNull()
|
||||
.default("url"),
|
||||
capabilities: text("capabilities").notNull().default("[]"),
|
||||
headers: text("headers"), // JSON array of { name, value }
|
||||
skipTlsVerification: boolean("skipTlsVerification")
|
||||
.notNull()
|
||||
.default(false),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||
},
|
||||
(t) => [index("idx_aiProviders_orgId_niceId").on(t.orgId, t.niceId)]
|
||||
);
|
||||
|
||||
export const aiModels = pgTable(
|
||||
"aiModels",
|
||||
{
|
||||
modelId: serial("modelId").primaryKey(),
|
||||
providerId: integer("providerId")
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
modelKey: varchar("modelKey").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
listType: varchar("listType")
|
||||
.$type<"allow" | "block">()
|
||||
.notNull()
|
||||
.default("allow"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||
},
|
||||
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
|
||||
);
|
||||
|
||||
export const aiBudgets = pgTable(
|
||||
"aiBudgets",
|
||||
{
|
||||
budgetId: serial("budgetId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
modelId: integer("modelId").references(() => aiModels.modelId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
roleId: integer("roleId").references(() => roles.roleId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
virtualApiKeyId: varchar("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
amount: real("amount").notNull(),
|
||||
unit: varchar("unit").$type<"usd" | "tokens">().notNull(),
|
||||
period: varchar("period")
|
||||
.$type<
|
||||
| "monthly"
|
||||
| "yearly"
|
||||
| "lifetime"
|
||||
| "daily"
|
||||
| "hourly"
|
||||
| "weekly"
|
||||
>()
|
||||
.notNull()
|
||||
.default("monthly"),
|
||||
enforcement: varchar("enforcement")
|
||||
.$type<"hard" | "soft">()
|
||||
.notNull()
|
||||
.default("hard"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||
},
|
||||
(t) => [
|
||||
unique("ai_budget_provider_uniq").on(t.providerId, t.unit, t.period),
|
||||
unique("ai_budget_model_uniq").on(t.modelId, t.unit, t.period),
|
||||
unique("ai_budget_resource_uniq").on(t.resourceId, t.unit, t.period),
|
||||
unique("ai_budget_site_resource_uniq").on(
|
||||
t.siteResourceId,
|
||||
t.unit,
|
||||
t.period
|
||||
),
|
||||
unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period),
|
||||
unique("ai_budget_virtual_api_key_uniq").on(
|
||||
t.virtualApiKeyId,
|
||||
t.unit,
|
||||
t.period
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const aiUsageRecords = pgTable(
|
||||
"aiUsageRecords",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
virtualApiKeyId: varchar("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
// Links this usage record back to the aiSessionLog row for the same
|
||||
// request (aiSessionLog.sessionId), so token/cost usage can be shown
|
||||
// alongside the session transcript. Not a DB-level FK - aiSessionLog
|
||||
// lives in the separate logs database. Nullable because the session
|
||||
// log may be disabled (retention set to 0) while usage tracking
|
||||
// stays on.
|
||||
sessionId: varchar("sessionId"),
|
||||
requestedModel: varchar("requestedModel").notNull(),
|
||||
promptTokens: integer("promptTokens").notNull().default(0),
|
||||
cacheReadTokens: integer("cacheReadTokens").notNull().default(0),
|
||||
cacheWriteTokens: integer("cacheWriteTokens").notNull().default(0),
|
||||
completionTokens: integer("completionTokens").notNull().default(0),
|
||||
reasoningTokens: integer("reasoningTokens").notNull().default(0),
|
||||
totalTokens: integer("totalTokens").notNull().default(0),
|
||||
costUsd: real("costUsd"),
|
||||
estimated: boolean("estimated").notNull().default(false),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull()
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_usage_records_org_provider_created").on(
|
||||
t.orgId,
|
||||
t.providerId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_usage_records_org_resource_created").on(
|
||||
t.orgId,
|
||||
t.resourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_usage_records_org_site_resource_created").on(
|
||||
t.orgId,
|
||||
t.siteResourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_usage_records_org_user_created").on(
|
||||
t.orgId,
|
||||
t.userId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_usage_records_org_virtual_api_key_created").on(
|
||||
t.orgId,
|
||||
t.virtualApiKeyId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_usage_records_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export const aiBudgetBreachEvents = pgTable(
|
||||
"aiBudgetBreachEvents",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
budgetId: integer("budgetId")
|
||||
.notNull()
|
||||
.references(() => aiBudgets.budgetId, { onDelete: "cascade" }),
|
||||
enforcement: varchar("enforcement").$type<"hard" | "soft">().notNull(),
|
||||
unit: varchar("unit").$type<"usd" | "tokens">().notNull(),
|
||||
period: varchar("period")
|
||||
.$type<
|
||||
| "monthly"
|
||||
| "yearly"
|
||||
| "lifetime"
|
||||
| "daily"
|
||||
| "hourly"
|
||||
| "weekly"
|
||||
>()
|
||||
.notNull(),
|
||||
amount: real("amount").notNull(),
|
||||
usageAmount: real("usageAmount").notNull(),
|
||||
blocked: boolean("blocked").notNull(),
|
||||
requestUserId: varchar("requestUserId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull()
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_budget_breach_events_budget_created").on(
|
||||
t.budgetId,
|
||||
t.createdAt
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
// Logs the aggregated prompt + response for a single AI gateway request, for
|
||||
// session replay. One row per request (not per streaming chunk). `sessionId`
|
||||
// is a fresh random id per row for now - no cross-request correlation yet,
|
||||
// but the column exists so a future pass can link multiple rows into a real
|
||||
// multi-turn session.
|
||||
export const aiSessionLog = pgTable(
|
||||
"aiSessionLog",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
sessionId: varchar("sessionId").notNull(),
|
||||
orgId: varchar("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
capability: varchar("capability").notNull(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
virtualApiKeyId: varchar("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
requestedModel: varchar("requestedModel"),
|
||||
isStream: boolean("isStream").notNull().default(false),
|
||||
requestBody: text("requestBody"),
|
||||
responseBody: text("responseBody"),
|
||||
// Capability-agnostic message transcript (JSON-encoded
|
||||
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
|
||||
// computed at write time so search/display never need per-capability
|
||||
// parsing logic. Null when normalization couldn't recognize the
|
||||
// shape - callers fall back to requestBody/responseBody.
|
||||
normalizedRequest: text("normalizedRequest"),
|
||||
normalizedResponse: text("normalizedResponse"),
|
||||
// True if any of the request/response (raw or normalized) fields
|
||||
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
||||
truncated: boolean("truncated").notNull().default(false),
|
||||
statusCode: integer("statusCode"),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch ms
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
|
||||
index("idx_ai_session_log_org_provider_created").on(
|
||||
t.orgId,
|
||||
t.providerId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_resource_created").on(
|
||||
t.orgId,
|
||||
t.resourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_site_resource_created").on(
|
||||
t.orgId,
|
||||
t.siteResourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_user_created").on(
|
||||
t.orgId,
|
||||
t.userId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_virtual_api_key_created").on(
|
||||
t.orgId,
|
||||
t.virtualApiKeyId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export const certificates = pgTable("certificates", {
|
||||
certId: serial("certId").primaryKey(),
|
||||
domain: varchar("domain", { length: 255 }).notNull().unique(),
|
||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
wildcard: boolean("wildcard").default(false),
|
||||
status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, requested, valid, expired, failed
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }),
|
||||
lastRenewalAttempt: bigint("lastRenewalAttempt", { mode: "number" }),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull(),
|
||||
orderId: varchar("orderId", { length: 500 }),
|
||||
errorMessage: text("errorMessage"),
|
||||
renewalCount: integer("renewalCount").default(0),
|
||||
certFile: text("certFile"),
|
||||
keyFile: text("keyFile")
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -1566,6 +2091,10 @@ export type Idp = InferSelectModel<typeof idp>;
|
||||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||
export type VirtualApiKey = InferSelectModel<typeof virtualApiKeys>;
|
||||
export type VirtualApiKeyResource = InferSelectModel<
|
||||
typeof virtualApiKeyResources
|
||||
>;
|
||||
export type Client = InferSelectModel<typeof clients>;
|
||||
export type ClientSite = InferSelectModel<typeof clientSitesAssociationsCache>;
|
||||
export type Olm = InferSelectModel<typeof olms>;
|
||||
@@ -1594,3 +2123,17 @@ export type LauncherView = InferSelectModel<typeof launcherViews>;
|
||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
|
||||
export type AiProvider = InferSelectModel<typeof aiProviders>;
|
||||
export type AiModel = InferSelectModel<typeof aiModels>;
|
||||
export type AiBudget = InferSelectModel<typeof aiBudgets>;
|
||||
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
|
||||
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
|
||||
export type SiteResourceAiProvider = InferSelectModel<
|
||||
typeof siteResourceAiProviders
|
||||
>;
|
||||
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
|
||||
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
|
||||
@@ -33,7 +33,9 @@ import {
|
||||
resourcePolicyPassword,
|
||||
ResourcePolicyPassword,
|
||||
resourcePolicyHeaderAuth,
|
||||
ResourcePolicyHeaderAuth
|
||||
ResourcePolicyHeaderAuth,
|
||||
resourceWhitelist,
|
||||
resourcePolicyWhiteList
|
||||
} from "@server/db";
|
||||
import { alias } from "@server/db";
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
@@ -448,6 +450,36 @@ export async function getResourceRules(
|
||||
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the whitelisted email associated with a resource session's whitelist
|
||||
* match (either a direct resource whitelist entry or a resource policy
|
||||
* whitelist entry).
|
||||
*/
|
||||
export async function getWhitelistEmail(
|
||||
whitelistId?: number | null,
|
||||
policyWhitelistId?: number | null
|
||||
): Promise<string | null> {
|
||||
if (whitelistId) {
|
||||
const [row] = await db
|
||||
.select({ email: resourceWhitelist.email })
|
||||
.from(resourceWhitelist)
|
||||
.where(eq(resourceWhitelist.whitelistId, whitelistId))
|
||||
.limit(1);
|
||||
return row?.email ?? null;
|
||||
}
|
||||
|
||||
if (policyWhitelistId) {
|
||||
const [row] = await db
|
||||
.select({ email: resourcePolicyWhiteList.email })
|
||||
.from(resourcePolicyWhiteList)
|
||||
.where(eq(resourcePolicyWhiteList.whitelistId, policyWhitelistId))
|
||||
.limit(1);
|
||||
return row?.email ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization login page
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
export const exists = checkFileExists(location);
|
||||
@@ -12,7 +13,11 @@ export const exists = checkFileExists(location);
|
||||
bootstrapVolume();
|
||||
|
||||
function createDb() {
|
||||
const sqlite = new Database(location);
|
||||
const verbose =
|
||||
process.env.QUERY_LOGGING == "true"
|
||||
? (message: unknown) => logger.debug(String(message))
|
||||
: undefined;
|
||||
const sqlite = new Database(location, { verbose });
|
||||
|
||||
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
||||
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
||||
|
||||
@@ -23,25 +23,6 @@ import {
|
||||
users
|
||||
} from "./schema";
|
||||
|
||||
export const certificates = sqliteTable("certificates", {
|
||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||
domain: text("domain").notNull().unique(),
|
||||
domainId: text("domainId").references(() => domains.domainId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).default(false),
|
||||
status: text("status").notNull().default("pending"), // pending, requested, valid, expired, failed
|
||||
expiresAt: integer("expiresAt"),
|
||||
lastRenewalAttempt: integer("lastRenewalAttempt"),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt").notNull(),
|
||||
orderId: text("orderId"),
|
||||
errorMessage: text("errorMessage"),
|
||||
renewalCount: integer("renewalCount").default(0),
|
||||
certFile: text("certFile"),
|
||||
keyFile: text("keyFile")
|
||||
});
|
||||
|
||||
export const dnsChallenge = sqliteTable("dnsChallenges", {
|
||||
dnsChallengeId: integer("dnsChallengeId").primaryKey({
|
||||
autoIncrement: true
|
||||
@@ -89,7 +70,8 @@ export const subscriptions = sqliteTable("subscriptions", {
|
||||
expiresAt: integer("expiresAt"),
|
||||
trial: integer("trial", { mode: "boolean" }).default(false),
|
||||
billingCycleAnchor: integer("billingCycleAnchor"),
|
||||
type: text("type") // tier1, tier2, tier3, or license
|
||||
type: text("type"), // tier1, tier2, tier3, or license
|
||||
override: integer("override", { mode: "boolean" }).default(false)
|
||||
});
|
||||
|
||||
export const subscriptionItems = sqliteTable("subscriptionItems", {
|
||||
@@ -627,7 +609,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
|
||||
export type Customer = InferSelectModel<typeof customers>;
|
||||
export type Subscription = InferSelectModel<typeof subscriptions>;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { InferSelectModel, sql } from "drizzle-orm";
|
||||
import {
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
primaryKey,
|
||||
real,
|
||||
sqliteTable,
|
||||
text,
|
||||
unique
|
||||
unique,
|
||||
uniqueIndex
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const domains = sqliteTable("domains", {
|
||||
@@ -15,7 +18,7 @@ export const domains = sqliteTable("domains", {
|
||||
configManaged: integer("configManaged", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
type: text("type"), // "ns", "cname", "wildcard"
|
||||
type: text("type").$type<"ns" | "cname" | "wildcard">(),
|
||||
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
|
||||
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
||||
tries: integer("tries").notNull().default(0),
|
||||
@@ -61,6 +64,11 @@ export const orgs = sqliteTable("orgs", {
|
||||
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
settingsLogRetentionDaysAISessions: integer(
|
||||
"settingsLogRetentionDaysAISessions"
|
||||
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(7),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
||||
@@ -107,7 +115,7 @@ export const sites = sqliteTable("sites", {
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
pubKey: text("pubKey"),
|
||||
subnet: text("subnet"),
|
||||
exitNodeSubnet: text("exitNodeSubnet"),
|
||||
megabytesIn: integer("bytesIn").default(0),
|
||||
megabytesOut: integer("bytesOut").default(0),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
@@ -118,6 +126,7 @@ export const sites = sqliteTable("sites", {
|
||||
// exit node stuff that is how to connect to the site when it has a wg server
|
||||
address: text("address"), // this is the address of the wireguard interface in newt
|
||||
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
|
||||
localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
|
||||
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
listenPort: integer("listenPort"),
|
||||
@@ -202,16 +211,55 @@ export const resources = sqliteTable("resources", {
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||
mode: text("mode")
|
||||
.default("http")
|
||||
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
|
||||
.notNull(), // rdp, ssh, http, vnc, inference
|
||||
pamMode: text("pamMode")
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: text("authDaemonMode")
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||
});
|
||||
|
||||
export const resourceAiProviders = sqliteTable(
|
||||
"resourceAiProviders",
|
||||
{
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
providerId: integer("providerId")
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
accessMode: text("accessMode")
|
||||
.$type<"inherit" | "select">()
|
||||
.notNull()
|
||||
.default("inherit"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
|
||||
);
|
||||
|
||||
export const resourceAiModels = sqliteTable(
|
||||
"resourceAiModels",
|
||||
{
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
modelId: integer("modelId")
|
||||
.notNull()
|
||||
.references(() => aiModels.modelId, { onDelete: "cascade" }),
|
||||
listType: text("listType")
|
||||
.$type<"allow" | "block">()
|
||||
.notNull()
|
||||
.default("allow")
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
|
||||
);
|
||||
|
||||
export const labels = sqliteTable("labels", {
|
||||
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
@@ -320,11 +368,12 @@ export const clientLabels = sqliteTable(
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
resourceId: integer("resourceId").references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(() => aiProviders.providerId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
@@ -420,10 +469,17 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
() => networks.networkId,
|
||||
{ onDelete: "restrict" }
|
||||
),
|
||||
requiresExitNodeConnection: integer("requiresExitNodeConnection", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
|
||||
mode: text("mode")
|
||||
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
|
||||
.notNull(), // "host" | "cidr" | "http"
|
||||
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
@@ -447,9 +503,49 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
subdomain: text("subdomain"),
|
||||
fullDomain: text("fullDomain")
|
||||
fullDomain: text("fullDomain"),
|
||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||
});
|
||||
|
||||
export const siteResourceAiProviders = sqliteTable(
|
||||
"siteResourceAiProviders",
|
||||
{
|
||||
siteResourceId: integer("siteResourceId")
|
||||
.notNull()
|
||||
.references(() => siteResources.siteResourceId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId")
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
accessMode: text("accessMode")
|
||||
.$type<"inherit" | "select">()
|
||||
.notNull()
|
||||
.default("inherit"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
|
||||
);
|
||||
|
||||
export const siteResourceAiModels = sqliteTable(
|
||||
"siteResourceAiModels",
|
||||
{
|
||||
siteResourceId: integer("siteResourceId")
|
||||
.notNull()
|
||||
.references(() => siteResources.siteResourceId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
modelId: integer("modelId")
|
||||
.notNull()
|
||||
.references(() => aiModels.modelId, { onDelete: "cascade" }),
|
||||
listType: text("listType")
|
||||
.$type<"allow" | "block">()
|
||||
.notNull()
|
||||
.default("allow")
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
|
||||
);
|
||||
|
||||
export const networks = sqliteTable("networks", {
|
||||
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
|
||||
niceId: text("niceId"),
|
||||
@@ -596,6 +692,7 @@ export const clients = sqliteTable("clients", {
|
||||
pubKey: text("pubKey"),
|
||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||
subnet: text("subnet").notNull(),
|
||||
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
|
||||
megabytesIn: integer("bytesIn"),
|
||||
megabytesOut: integer("bytesOut"),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
@@ -898,7 +995,7 @@ export const roles = sqliteTable("roles", {
|
||||
requireDeviceApproval: integer("requireDeviceApproval", {
|
||||
mode: "boolean"
|
||||
}).default(false),
|
||||
sshSudoMode: text("sshSudoMode").default("none"), // "none" | "full" | "commands"
|
||||
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
|
||||
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
||||
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
|
||||
true
|
||||
@@ -1123,12 +1220,18 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
path: text("path"),
|
||||
tokenHash: text("tokenHash").notNull(),
|
||||
sessionLength: integer("sessionLength").notNull(),
|
||||
expiresAt: integer("expiresAt"),
|
||||
title: text("title"),
|
||||
description: text("description"),
|
||||
persistSession: integer("persistSession", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
createdAt: integer("createdAt").notNull()
|
||||
});
|
||||
|
||||
@@ -1224,7 +1327,17 @@ export const resourceRules = sqliteTable("resourceRules", {
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
||||
match: text("match").notNull(), // CIDR, PATH, IP
|
||||
match: text("match")
|
||||
.$type<
|
||||
| "CIDR"
|
||||
| "PATH"
|
||||
| "IP"
|
||||
| "COUNTRY"
|
||||
| "COUNTRY_IS_NOT"
|
||||
| "ASN"
|
||||
| "REGION"
|
||||
>()
|
||||
.notNull(), // CIDR, PATH, IP
|
||||
value: text("value").notNull()
|
||||
});
|
||||
|
||||
@@ -1271,7 +1384,15 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
|
||||
priority: integer("priority").notNull(),
|
||||
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||
match: text("match")
|
||||
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
|
||||
.$type<
|
||||
| "CIDR"
|
||||
| "PATH"
|
||||
| "IP"
|
||||
| "COUNTRY"
|
||||
| "COUNTRY_IS_NOT"
|
||||
| "ASN"
|
||||
| "REGION"
|
||||
>()
|
||||
.notNull(),
|
||||
value: text("value").notNull()
|
||||
});
|
||||
@@ -1384,6 +1505,54 @@ export const apiKeyOrg = sqliteTable("apiKeyOrg", {
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const virtualApiKeys = sqliteTable(
|
||||
"virtualApiKeys",
|
||||
{
|
||||
virtualApiKeyId: text("virtualApiKeyId").primaryKey(),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
kind: text("kind").$type<"user" | "manual">().notNull(),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
name: text("name"),
|
||||
description: text("description"),
|
||||
token: text("token").notNull(),
|
||||
lastChars: text("lastChars").notNull(),
|
||||
allResources: integer("allResources", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
expiresAt: integer("expiresAt"),
|
||||
lastUsedAt: integer("lastUsedAt"),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
createdByUserId: text("createdByUserId").references(
|
||||
() => users.userId,
|
||||
{ onDelete: "set null" }
|
||||
)
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("virtual_api_key_user_identity_uniq")
|
||||
.on(t.orgId, t.userId)
|
||||
.where(sql`${t.kind} = 'user'`)
|
||||
]
|
||||
);
|
||||
|
||||
export const virtualApiKeyResources = sqliteTable(
|
||||
"virtualApiKeyResources",
|
||||
{
|
||||
virtualApiKeyId: text("virtualApiKeyId")
|
||||
.notNull()
|
||||
.references(() => virtualApiKeys.virtualApiKeyId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.virtualApiKeyId, t.resourceId] })]
|
||||
);
|
||||
|
||||
export const idpOrg = sqliteTable("idpOrg", {
|
||||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
@@ -1499,6 +1668,370 @@ export const statusHistory = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const aiProviders = sqliteTable(
|
||||
"aiProviders",
|
||||
{
|
||||
providerId: integer("providerId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
niceId: text("niceId").notNull(),
|
||||
type: text("type")
|
||||
.$type<
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "googleGemini"
|
||||
| "vertexAi"
|
||||
| "bedrock"
|
||||
| "microsoftFoundry"
|
||||
| "openRouter"
|
||||
| "vercelAiGateway"
|
||||
| "custom"
|
||||
>()
|
||||
.notNull(),
|
||||
upstreamUrl: text("upstreamUrl"),
|
||||
apiKey: text("apiKey"),
|
||||
apiKeyLastChars: text("apiKeyLastChars"),
|
||||
authType: text("authType")
|
||||
.$type<
|
||||
| "bearer"
|
||||
| "x-api-key"
|
||||
| "x-goog-api-key"
|
||||
| "hec"
|
||||
| "cf-aig-authorization"
|
||||
| "none"
|
||||
| "passthrough"
|
||||
>()
|
||||
.notNull(),
|
||||
routingMode: text("routingMode")
|
||||
.$type<"url" | "target">()
|
||||
.notNull()
|
||||
.default("url"),
|
||||
capabilities: text("capabilities").notNull().default("[]"),
|
||||
headers: text("headers"), // JSON array of { name, value }
|
||||
skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
enabled: integer("enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt").notNull()
|
||||
},
|
||||
(t) => [index("idx_aiProviders_orgId_niceId").on(t.orgId, t.niceId)]
|
||||
);
|
||||
|
||||
export const aiModels = sqliteTable(
|
||||
"aiModels",
|
||||
{
|
||||
modelId: integer("modelId").primaryKey({ autoIncrement: true }),
|
||||
providerId: integer("providerId")
|
||||
.notNull()
|
||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
||||
modelKey: text("modelKey").notNull(),
|
||||
name: text("name").notNull(),
|
||||
listType: text("listType")
|
||||
.$type<"allow" | "block">()
|
||||
.notNull()
|
||||
.default("allow"),
|
||||
enabled: integer("enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt").notNull()
|
||||
},
|
||||
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
|
||||
);
|
||||
|
||||
export const aiBudgets = sqliteTable(
|
||||
"aiBudgets",
|
||||
{
|
||||
budgetId: integer("budgetId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
modelId: integer("modelId").references(() => aiModels.modelId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
roleId: integer("roleId").references(() => roles.roleId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
virtualApiKeyId: text("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
amount: real("amount").notNull(),
|
||||
unit: text("unit").$type<"usd" | "tokens">().notNull(),
|
||||
period: text("period")
|
||||
.$type<
|
||||
| "monthly"
|
||||
| "yearly"
|
||||
| "lifetime"
|
||||
| "daily"
|
||||
| "hourly"
|
||||
| "weekly"
|
||||
>()
|
||||
.notNull()
|
||||
.default("monthly"),
|
||||
enforcement: text("enforcement")
|
||||
.$type<"hard" | "soft">()
|
||||
.notNull()
|
||||
.default("hard"),
|
||||
enabled: integer("enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt").notNull()
|
||||
},
|
||||
(t) => [
|
||||
unique("ai_budget_provider_uniq").on(t.providerId, t.unit, t.period),
|
||||
unique("ai_budget_model_uniq").on(t.modelId, t.unit, t.period),
|
||||
unique("ai_budget_resource_uniq").on(t.resourceId, t.unit, t.period),
|
||||
unique("ai_budget_site_resource_uniq").on(
|
||||
t.siteResourceId,
|
||||
t.unit,
|
||||
t.period
|
||||
),
|
||||
unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period),
|
||||
unique("ai_budget_virtual_api_key_uniq").on(
|
||||
t.virtualApiKeyId,
|
||||
t.unit,
|
||||
t.period
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const aiUsageRecords = sqliteTable(
|
||||
"aiUsageRecords",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
virtualApiKeyId: text("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
// Links this usage record back to the aiSessionLog row for the same
|
||||
// request (aiSessionLog.sessionId), so token/cost usage can be shown
|
||||
// alongside the session transcript. Not a DB-level FK - aiSessionLog
|
||||
// lives in the separate logs database. Nullable because the session
|
||||
// log may be disabled (retention set to 0) while usage tracking
|
||||
// stays on.
|
||||
sessionId: text("sessionId"),
|
||||
requestedModel: text("requestedModel").notNull(),
|
||||
promptTokens: integer("promptTokens").notNull().default(0),
|
||||
cacheReadTokens: integer("cacheReadTokens").notNull().default(0),
|
||||
cacheWriteTokens: integer("cacheWriteTokens").notNull().default(0),
|
||||
completionTokens: integer("completionTokens").notNull().default(0),
|
||||
reasoningTokens: integer("reasoningTokens").notNull().default(0),
|
||||
totalTokens: integer("totalTokens").notNull().default(0),
|
||||
costUsd: real("costUsd"),
|
||||
estimated: integer("estimated", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
createdAt: integer("createdAt").notNull()
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_usage_records_org_provider_created").on(
|
||||
t.orgId,
|
||||
t.providerId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_usage_records_org_resource_created").on(
|
||||
t.orgId,
|
||||
t.resourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_usage_records_org_site_resource_created").on(
|
||||
t.orgId,
|
||||
t.siteResourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_usage_records_org_user_created").on(
|
||||
t.orgId,
|
||||
t.userId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_usage_records_org_virtual_api_key_created").on(
|
||||
t.orgId,
|
||||
t.virtualApiKeyId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_usage_records_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export const aiBudgetBreachEvents = sqliteTable(
|
||||
"aiBudgetBreachEvents",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
budgetId: integer("budgetId")
|
||||
.notNull()
|
||||
.references(() => aiBudgets.budgetId, { onDelete: "cascade" }),
|
||||
enforcement: text("enforcement").$type<"hard" | "soft">().notNull(),
|
||||
unit: text("unit").$type<"usd" | "tokens">().notNull(),
|
||||
period: text("period")
|
||||
.$type<
|
||||
| "monthly"
|
||||
| "yearly"
|
||||
| "lifetime"
|
||||
| "daily"
|
||||
| "hourly"
|
||||
| "weekly"
|
||||
>()
|
||||
.notNull(),
|
||||
amount: real("amount").notNull(),
|
||||
usageAmount: real("usageAmount").notNull(),
|
||||
blocked: integer("blocked", { mode: "boolean" }).notNull(),
|
||||
requestUserId: text("requestUserId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
createdAt: integer("createdAt").notNull()
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_budget_breach_events_budget_created").on(
|
||||
t.budgetId,
|
||||
t.createdAt
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
// Logs the aggregated prompt + response for a single AI gateway request, for
|
||||
// session replay. One row per request (not per streaming chunk). `sessionId`
|
||||
// is a fresh random id per row for now - no cross-request correlation yet,
|
||||
// but the column exists so a future pass can link multiple rows into a real
|
||||
// multi-turn session.
|
||||
export const aiSessionLog = sqliteTable(
|
||||
"aiSessionLog",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sessionId: text("sessionId").notNull(),
|
||||
orgId: text("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
providerId: integer("providerId").references(
|
||||
() => aiProviders.providerId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
capability: text("capability").notNull(),
|
||||
resourceId: integer("resourceId").references(
|
||||
() => resources.resourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
virtualApiKeyId: text("virtualApiKeyId").references(
|
||||
() => virtualApiKeys.virtualApiKeyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
requestedModel: text("requestedModel"),
|
||||
isStream: integer("isStream", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
requestBody: text("requestBody"),
|
||||
responseBody: text("responseBody"),
|
||||
// Capability-agnostic message transcript (JSON-encoded
|
||||
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
|
||||
// computed at write time so search/display never need per-capability
|
||||
// parsing logic. Null when normalization couldn't recognize the
|
||||
// shape - callers fall back to requestBody/responseBody.
|
||||
normalizedRequest: text("normalizedRequest"),
|
||||
normalizedResponse: text("normalizedResponse"),
|
||||
// True if any of the request/response (raw or normalized) fields
|
||||
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
||||
truncated: integer("truncated", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
statusCode: integer("statusCode"),
|
||||
createdAt: integer("createdAt").notNull() // epoch ms
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
|
||||
index("idx_ai_session_log_org_provider_created").on(
|
||||
t.orgId,
|
||||
t.providerId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_resource_created").on(
|
||||
t.orgId,
|
||||
t.resourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_site_resource_created").on(
|
||||
t.orgId,
|
||||
t.siteResourceId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_user_created").on(
|
||||
t.orgId,
|
||||
t.userId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_org_virtual_api_key_created").on(
|
||||
t.orgId,
|
||||
t.virtualApiKeyId,
|
||||
t.createdAt
|
||||
),
|
||||
index("idx_ai_session_log_session").on(t.sessionId)
|
||||
]
|
||||
);
|
||||
|
||||
export const certificates = sqliteTable("certificates", {
|
||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||
domain: text("domain").notNull().unique(),
|
||||
domainId: text("domainId").references(() => domains.domainId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).default(false),
|
||||
status: text("status").notNull().default("pending"), // pending, requested, valid, expired, failed
|
||||
expiresAt: integer("expiresAt"),
|
||||
lastRenewalAttempt: integer("lastRenewalAttempt"),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt").notNull(),
|
||||
orderId: text("orderId"),
|
||||
errorMessage: text("errorMessage"),
|
||||
renewalCount: integer("renewalCount").default(0),
|
||||
certFile: text("certFile"),
|
||||
keyFile: text("keyFile")
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -1550,6 +2083,10 @@ export type Idp = InferSelectModel<typeof idp>;
|
||||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||
export type VirtualApiKey = InferSelectModel<typeof virtualApiKeys>;
|
||||
export type VirtualApiKeyResource = InferSelectModel<
|
||||
typeof virtualApiKeyResources
|
||||
>;
|
||||
export type SiteResource = InferSelectModel<typeof siteResources>;
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
||||
@@ -1581,3 +2118,16 @@ export type ResourcePolicyHeaderAuth = InferSelectModel<
|
||||
>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
export type AiProvider = InferSelectModel<typeof aiProviders>;
|
||||
export type AiModel = InferSelectModel<typeof aiModels>;
|
||||
export type AiBudget = InferSelectModel<typeof aiBudgets>;
|
||||
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
|
||||
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
|
||||
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
|
||||
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
|
||||
export type SiteResourceAiProvider = InferSelectModel<
|
||||
typeof siteResourceAiProviders
|
||||
>;
|
||||
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
|
||||
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
|
||||
@@ -31,9 +31,24 @@ export type AlertNotificationProps = {
|
||||
orgId: string;
|
||||
data: Record<string, unknown>;
|
||||
dashboardLink: string;
|
||||
isTestAlert?: boolean;
|
||||
};
|
||||
|
||||
function getEventMeta(eventType: AlertEventType): {
|
||||
function getEventMeta(
|
||||
eventType: AlertEventType,
|
||||
isTestAlert: boolean = false
|
||||
): {
|
||||
heading: string;
|
||||
previewText: string;
|
||||
summary: string;
|
||||
statusLabel: string | null;
|
||||
statusColor: string | null;
|
||||
} {
|
||||
const meta = getBaseEventMeta(eventType);
|
||||
return isTestAlert ? { ...meta, heading: `[TEST] ${meta.heading}` } : meta;
|
||||
}
|
||||
|
||||
function getBaseEventMeta(eventType: AlertEventType): {
|
||||
heading: string;
|
||||
previewText: string;
|
||||
summary: string;
|
||||
@@ -180,8 +195,14 @@ function formatDataItems(
|
||||
}
|
||||
|
||||
export const AlertNotification = (props: AlertNotificationProps) => {
|
||||
const { eventType, orgId, data, dashboardLink } = props;
|
||||
const meta = getEventMeta(eventType);
|
||||
const {
|
||||
eventType,
|
||||
orgId,
|
||||
data,
|
||||
dashboardLink,
|
||||
isTestAlert = false
|
||||
} = props;
|
||||
const meta = getEventMeta(eventType, isTestAlert);
|
||||
const dataItems = formatDataItems(data);
|
||||
|
||||
const isToggle =
|
||||
@@ -242,6 +263,12 @@ export const AlertNotification = (props: AlertNotificationProps) => {
|
||||
Open your dashboard to view more details and manage
|
||||
your alert rules.
|
||||
</EmailText>
|
||||
{isTestAlert && (
|
||||
<EmailText>
|
||||
This is a test alert. No action is required,
|
||||
and no real event has occurred.
|
||||
</EmailText>
|
||||
)}
|
||||
|
||||
<EmailSection>
|
||||
<ButtonLink href={dashboardLink}>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailFooter,
|
||||
EmailGreeting,
|
||||
EmailHeading,
|
||||
EmailInfoSection,
|
||||
EmailLetterHead,
|
||||
EmailSection,
|
||||
EmailSignature,
|
||||
EmailText
|
||||
} from "./components/Email";
|
||||
|
||||
type IdentityApiKeyGeneratedProps = {
|
||||
orgName: string;
|
||||
accountLabel?: string | null;
|
||||
credential: string;
|
||||
resourceUrls: string[];
|
||||
hasMoreResources: boolean;
|
||||
};
|
||||
|
||||
export const IdentityApiKeyGenerated = ({
|
||||
orgName,
|
||||
accountLabel,
|
||||
credential,
|
||||
resourceUrls,
|
||||
hasMoreResources
|
||||
}: IdentityApiKeyGeneratedProps) => {
|
||||
const previewText = `Your personal identity key for ${orgName}`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
This is your personal identity key for{" "}
|
||||
<strong>{orgName}</strong>. It belongs to your
|
||||
account and identifies you when you use public AI
|
||||
gateways.
|
||||
</EmailText>
|
||||
|
||||
<EmailText>
|
||||
Use it with resources your administrator has granted
|
||||
you, or that your role has access to. Treat this key
|
||||
like a password and do not share it.
|
||||
</EmailText>
|
||||
|
||||
<EmailSection>
|
||||
<EmailText>Your identity key:</EmailText>
|
||||
<div className="inline-block max-w-full">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 mx-auto text-left">
|
||||
<span className="text-sm font-mono text-gray-900 break-all">
|
||||
{credential}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</EmailSection>
|
||||
|
||||
<EmailFooter>
|
||||
<EmailSignature />
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdentityApiKeyGenerated;
|
||||
@@ -30,14 +30,14 @@ export const NotifyTrialExpiring = ({
|
||||
const isLastDay = daysRemaining === 1;
|
||||
|
||||
const previewText = hasEnded
|
||||
? `Your trial for ${orgName} has ended.`
|
||||
? `Your cloud trial for ${orgName} has ended.`
|
||||
: isLastDay
|
||||
? `Your trial for ${orgName} ends tomorrow.`
|
||||
: `Your trial for ${orgName} ends in ${daysRemaining} days.`;
|
||||
? `Your cloud trial for ${orgName} ends tomorrow.`
|
||||
: `Your cloud trial for ${orgName} ends in ${daysRemaining} days.`;
|
||||
|
||||
const heading = hasEnded
|
||||
? "Your Trial Ended"
|
||||
: "Your Trial is Ending Soon";
|
||||
? "Your Cloud Trial Ended"
|
||||
: "Your Cloud Trial is Ending Soon";
|
||||
|
||||
return (
|
||||
<Html>
|
||||
@@ -55,7 +55,7 @@ export const NotifyTrialExpiring = ({
|
||||
{hasEnded ? (
|
||||
<>
|
||||
<EmailText>
|
||||
Your free trial for{" "}
|
||||
Your cloud free trial for{" "}
|
||||
<strong>{orgName}</strong> ended on{" "}
|
||||
<strong>{trialEndsAt}</strong>. Your account
|
||||
has been moved to the free plan, which
|
||||
@@ -64,10 +64,11 @@ export const NotifyTrialExpiring = ({
|
||||
|
||||
<EmailText>
|
||||
Some features and resources may now be
|
||||
restricted. To restore full
|
||||
access and continue using all the features
|
||||
you had during your trial, please upgrade to
|
||||
a paid plan.
|
||||
restricted. To restore full access and
|
||||
continue using all the features you had
|
||||
during your trial, please upgrade to a paid
|
||||
plan. This does not effect any self hosted
|
||||
licenses.
|
||||
</EmailText>
|
||||
|
||||
<EmailText>
|
||||
@@ -93,7 +94,8 @@ export const NotifyTrialExpiring = ({
|
||||
<EmailText>
|
||||
After your trial ends, your account will be
|
||||
moved to the free plan and some
|
||||
functionality may be restricted.
|
||||
functionality may be restricted. This does
|
||||
not effect any self hosted licenses.
|
||||
</EmailText>
|
||||
|
||||
<EmailText>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from "react";
|
||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailFooter,
|
||||
EmailGreeting,
|
||||
EmailInfoSection,
|
||||
EmailLetterHead,
|
||||
EmailSection,
|
||||
EmailSignature,
|
||||
EmailText
|
||||
} from "./components/Email";
|
||||
|
||||
type VirtualApiKeyGeneratedProps = {
|
||||
orgName: string;
|
||||
keyName: string | null;
|
||||
credential: string;
|
||||
resourceUrls: string[];
|
||||
hasMoreResources: boolean;
|
||||
};
|
||||
|
||||
export const VirtualApiKeyGenerated = ({
|
||||
orgName,
|
||||
keyName,
|
||||
credential,
|
||||
resourceUrls,
|
||||
hasMoreResources
|
||||
}: VirtualApiKeyGeneratedProps) => {
|
||||
const previewText = `A virtual API key for ${orgName} has been shared with you`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
<EmailText>
|
||||
A virtual API key for <strong>{orgName}</strong> has
|
||||
been shared with you. This key grants access to the
|
||||
public AI gateways it was created for. Treat this
|
||||
key like a password and do not share it.
|
||||
</EmailText>
|
||||
|
||||
<EmailSection>
|
||||
<EmailText>Your virtual API key:</EmailText>
|
||||
<div className="inline-block max-w-full">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 mx-auto text-left">
|
||||
<span className="text-sm font-mono text-gray-900 break-all">
|
||||
{credential}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</EmailSection>
|
||||
|
||||
<EmailInfoSection
|
||||
title="Key details"
|
||||
items={[
|
||||
{
|
||||
label: "Organization",
|
||||
value: orgName
|
||||
},
|
||||
...(keyName
|
||||
? [
|
||||
{
|
||||
label: "Name",
|
||||
value: keyName
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
|
||||
{resourceUrls.length > 0 && (
|
||||
<>
|
||||
<EmailText>
|
||||
This key can be used to authenticate to the
|
||||
following AI gateway resources:
|
||||
</EmailText>
|
||||
<div className="px-6 pb-2">
|
||||
{resourceUrls.map((url) => (
|
||||
<p
|
||||
key={url}
|
||||
className="text-base text-gray-700 leading-relaxed"
|
||||
>
|
||||
<a
|
||||
href={url}
|
||||
className="text-primary font-medium break-all"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{hasMoreResources && (
|
||||
<EmailText>
|
||||
Contact your administrator to get the
|
||||
full list.
|
||||
</EmailText>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<EmailFooter>
|
||||
<EmailSignature />
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualApiKeyGenerated;
|
||||
@@ -18,7 +18,7 @@ export function EmailLetterHead() {
|
||||
<Img
|
||||
src="https://fossorial-public-assets.s3.us-east-1.amazonaws.com/word_mark_black.png"
|
||||
alt="Pangolin Logo"
|
||||
width="180"
|
||||
width="135"
|
||||
height="auto"
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
@@ -5,26 +5,29 @@ import { runSetupFunctions } from "./setup";
|
||||
import { createApiServer } from "./apiServer";
|
||||
import { createNextServer } from "./nextServer";
|
||||
import { createInternalServer } from "./internalServer";
|
||||
import { createAiGatewayServer } from "./aiGatewayServer";
|
||||
import { createIntegrationApiServer } from "./integrationApiServer";
|
||||
import {
|
||||
ApiKey,
|
||||
ApiKeyOrg,
|
||||
AiBudget,
|
||||
AiModel,
|
||||
AiProvider,
|
||||
RemoteExitNode,
|
||||
Session,
|
||||
SiteResource,
|
||||
User,
|
||||
UserOrg
|
||||
UserOrg,
|
||||
VirtualApiKey
|
||||
} from "@server/db";
|
||||
import config from "@server/lib/config";
|
||||
import { setHostMeta } from "@server/lib/hostMeta";
|
||||
import { initTelemetryClient } from "@server/lib/telemetry";
|
||||
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
|
||||
import { initCleanup } from "#dynamic/cleanup";
|
||||
import { startSchedulers } from "#dynamic/startSchedulers";
|
||||
import license from "#dynamic/license/license";
|
||||
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
||||
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
|
||||
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
|
||||
|
||||
async function startServers() {
|
||||
await setHostMeta();
|
||||
@@ -38,15 +41,14 @@ async function startServers() {
|
||||
|
||||
await fetchServerIp();
|
||||
|
||||
initTelemetryClient();
|
||||
await initAiModelCatalog();
|
||||
|
||||
initLogCleanupInterval();
|
||||
initAcmeCertSync();
|
||||
startRebuildQueueProcessor();
|
||||
startSchedulers();
|
||||
|
||||
// Start all servers
|
||||
const apiServer = createApiServer();
|
||||
const internalServer = createInternalServer();
|
||||
const aiGatewayServer = createAiGatewayServer();
|
||||
|
||||
const nextServer = await createNextServer();
|
||||
if (config.getRawConfig().traefik.file_mode) {
|
||||
@@ -65,6 +67,7 @@ async function startServers() {
|
||||
apiServer,
|
||||
nextServer,
|
||||
internalServer,
|
||||
aiGatewayServer,
|
||||
integrationServer
|
||||
};
|
||||
}
|
||||
@@ -83,6 +86,10 @@ declare global {
|
||||
userOrgIds?: string[];
|
||||
remoteExitNode?: RemoteExitNode;
|
||||
siteResource?: SiteResource;
|
||||
aiProvider?: AiProvider;
|
||||
aiModel?: AiModel;
|
||||
aiBudget?: AiBudget;
|
||||
virtualApiKey?: VirtualApiKey;
|
||||
orgPolicyAllowed?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { logIncomingMiddleware } from "./middlewares/logIncoming";
|
||||
import helmet from "helmet";
|
||||
import swaggerUi from "swagger-ui-express";
|
||||
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
||||
import { registry } from "./openApi";
|
||||
import { registry, openApiTags } from "./openApi";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { APP_PATH } from "./lib/consts";
|
||||
@@ -181,7 +181,8 @@ function getOpenApiDocumentation() {
|
||||
version: "v1",
|
||||
title: "Pangolin Integration API"
|
||||
},
|
||||
servers: [{ url: "/v1" }]
|
||||
servers: [{ url: "/v1" }],
|
||||
tags: openApiTags
|
||||
});
|
||||
|
||||
if (!process.env.DISABLE_GEN_OPENAPI) {
|
||||
|
||||
@@ -1,3 +1,866 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
certificates,
|
||||
clients,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
db,
|
||||
domains,
|
||||
newts,
|
||||
siteNetworks,
|
||||
SiteResource,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
generateSubnetProxyTargetV2,
|
||||
SubnetProxyTargetV2
|
||||
} from "@server/lib/ip";
|
||||
import { updateTargets } from "@server/routers/client/targets";
|
||||
import cache from "#dynamic/lib/cache";
|
||||
import { build } from "@server/build";
|
||||
|
||||
interface AcmeCert {
|
||||
domain: { main: string; sans?: string[] };
|
||||
certificate: string;
|
||||
key: string;
|
||||
Store: string;
|
||||
}
|
||||
|
||||
interface AcmeJson {
|
||||
[resolver: string]: {
|
||||
Certificates: AcmeCert[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function pushCertUpdateToAffectedNewts(
|
||||
domain: string,
|
||||
domainId: string | null,
|
||||
oldCertPem: string | null,
|
||||
oldKeyPem: string | null
|
||||
): Promise<void> {
|
||||
// Find all SSL-enabled HTTP site resources that use this cert's domain
|
||||
let affectedResources: SiteResource[] = [];
|
||||
|
||||
if (domainId) {
|
||||
affectedResources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.domainId, domainId),
|
||||
eq(siteResources.ssl, true)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Fallback: match by exact fullDomain when no domainId is available
|
||||
affectedResources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.fullDomain, domain),
|
||||
eq(siteResources.ssl, true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (affectedResources.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no affected site resources for cert domain "${domain}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"`
|
||||
);
|
||||
|
||||
for (const resource of affectedResources) {
|
||||
try {
|
||||
// Get all sites for this resource via siteNetworks
|
||||
const resourceSiteRows = resource.networkId
|
||||
? await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, resource.networkId))
|
||||
: [];
|
||||
|
||||
if (resourceSiteRows.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get all clients with access to this resource
|
||||
const resourceClients = await db
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(
|
||||
clients.clientId,
|
||||
clientSiteResourcesAssociationsCache.clientId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
resource.siteResourceId
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceClients.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data
|
||||
if (resource.fullDomain) {
|
||||
await cache.del(`cert:${resource.fullDomain}`);
|
||||
}
|
||||
|
||||
// Generate target once - same cert applies to all sites for this resource
|
||||
const newTargets = await generateSubnetProxyTargetV2(
|
||||
resource,
|
||||
resourceClients
|
||||
);
|
||||
|
||||
if (!newTargets) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Construct the old targets - same routing shape but with the previous cert/key.
|
||||
// The newt only uses destPrefix/sourcePrefixes for removal, but we keep the
|
||||
// semantics correct so the update message accurately reflects what changed.
|
||||
const oldTargets: SubnetProxyTargetV2[] = newTargets.map((t) => ({
|
||||
...t,
|
||||
tlsCert: oldCertPem ?? undefined,
|
||||
tlsKey: oldKeyPem ?? undefined
|
||||
}));
|
||||
|
||||
// Push update to each site's newt
|
||||
for (const { siteId } of resourceSiteRows) {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await updateTargets(
|
||||
newt.newtId,
|
||||
{ oldTargets: oldTargets, newTargets: newTargets },
|
||||
newt.version
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findDomainId(certDomain: string): Promise<string | null> {
|
||||
// Strip wildcard prefix before lookup (*.example.com -> example.com)
|
||||
const lookupDomain = certDomain.startsWith("*.")
|
||||
? certDomain.slice(2)
|
||||
: certDomain;
|
||||
|
||||
// 1. Exact baseDomain match (any domain type)
|
||||
const exactMatch = await db
|
||||
.select({ domainId: domains.domainId })
|
||||
.from(domains)
|
||||
.where(eq(domains.baseDomain, lookupDomain))
|
||||
.limit(1);
|
||||
|
||||
if (exactMatch.length > 0) {
|
||||
return exactMatch[0].domainId;
|
||||
}
|
||||
|
||||
// 2. Walk up the domain hierarchy looking for a wildcard-type domain whose
|
||||
// baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com"
|
||||
// matches a wildcard domain with baseDomain "example.com".
|
||||
const parts = lookupDomain.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const candidate = parts.slice(i).join(".");
|
||||
if (!candidate) continue;
|
||||
|
||||
const wildcardMatch = await db
|
||||
.select({ domainId: domains.domainId })
|
||||
.from(domains)
|
||||
.where(
|
||||
and(
|
||||
eq(domains.baseDomain, candidate),
|
||||
eq(domains.type, "wildcard")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (wildcardMatch.length > 0) {
|
||||
return wildcardMatch[0].domainId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractFirstCert(pemBundle: string): string | null {
|
||||
const match = pemBundle.match(
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
|
||||
);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an ACME cert entry represents a wildcard cert by checking
|
||||
* both the primary domain (`main`) and the SANs. Some ACME clients (notably
|
||||
* Traefik) store the bare apex in `main` and only put the wildcard form in
|
||||
* `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]).
|
||||
*/
|
||||
function detectWildcard(
|
||||
main: string,
|
||||
sans: string[] | undefined
|
||||
): { wildcard: boolean; wildcardSan: string | null } {
|
||||
if (main.startsWith("*.")) {
|
||||
return { wildcard: true, wildcardSan: null };
|
||||
}
|
||||
if (Array.isArray(sans)) {
|
||||
for (const san of sans) {
|
||||
if (typeof san !== "string") continue;
|
||||
if (san === `*.${main}` || san.startsWith("*.")) {
|
||||
return { wildcard: true, wildcardSan: san };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { wildcard: false, wildcardSan: null };
|
||||
}
|
||||
|
||||
interface HttpCert {
|
||||
wildcard: boolean;
|
||||
altName: string;
|
||||
certName: string;
|
||||
commonName: string;
|
||||
certFile: string;
|
||||
keyFile: string;
|
||||
}
|
||||
|
||||
async function syncAcmeCertsFromHttp(endpoint: string): Promise<void> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(endpoint);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.debug(
|
||||
`acmeCertSync: HTTP endpoint returned status ${response.status}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let httpCerts: HttpCert[];
|
||||
try {
|
||||
httpCerts = await response.json();
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse JSON from HTTP endpoint: ${err}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(httpCerts) || httpCerts.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no certificates returned from HTTP endpoint`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cert of httpCerts) {
|
||||
const domain = cert?.certName;
|
||||
|
||||
if (!domain || typeof domain !== "string") {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping HTTP cert with missing certName`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const certPem = cert.certFile;
|
||||
const keyPem = cert.keyFile;
|
||||
|
||||
if (!certPem?.trim() || !keyPem?.trim()) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstCertPemForValidation = extractFirstCert(certPem);
|
||||
if (!firstCertPemForValidation) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let validatedX509: crypto.X509Certificate;
|
||||
try {
|
||||
validatedX509 = new crypto.X509Certificate(
|
||||
firstCertPemForValidation
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
crypto.createPrivateKey(keyPem);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const wildcard = cert.wildcard ?? false;
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.domain, domain))
|
||||
.limit(1);
|
||||
|
||||
let oldCertPem: string | null = null;
|
||||
let oldKeyPem: string | null = null;
|
||||
|
||||
if (existing.length > 0 && existing[0].certFile) {
|
||||
try {
|
||||
const storedCertPem = decrypt(
|
||||
existing[0].certFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||
continue;
|
||||
}
|
||||
oldCertPem = storedCertPem;
|
||||
if (existing[0].keyFile) {
|
||||
try {
|
||||
oldKeyPem = decrypt(
|
||||
existing[0].keyFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
} catch (keyErr) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let expiresAt: number | null = null;
|
||||
try {
|
||||
expiresAt = Math.floor(
|
||||
new Date(validatedX509.validTo).getTime() / 1000
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedCert = encrypt(
|
||||
certPem,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const encryptedKey = encrypt(
|
||||
keyPem,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const domainId = await findDomainId(domain);
|
||||
if (domainId) {
|
||||
logger.debug(
|
||||
`acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
wildcard,
|
||||
...(domainId !== null && { domainId })
|
||||
})
|
||||
.where(eq(certificates.domain, domain));
|
||||
|
||||
await pushCertUpdateToAffectedNewts(
|
||||
domain,
|
||||
domainId,
|
||||
oldCertPem,
|
||||
oldKeyPem
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
await db.insert(certificates).values({
|
||||
domain,
|
||||
domainId,
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
wildcard
|
||||
});
|
||||
|
||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function storeCertForDomain(
|
||||
domain: string,
|
||||
certPem: string,
|
||||
keyPem: string,
|
||||
validatedX509: crypto.X509Certificate
|
||||
): Promise<void> {
|
||||
const wildcard = domain.startsWith("*.");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.domain, domain))
|
||||
.limit(1);
|
||||
|
||||
let oldCertPem: string | null = null;
|
||||
let oldKeyPem: string | null = null;
|
||||
|
||||
if (existing.length > 0 && existing[0].certFile) {
|
||||
try {
|
||||
const storedCertPem = decrypt(
|
||||
existing[0].certFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||
return;
|
||||
}
|
||||
oldCertPem = storedCertPem;
|
||||
if (existing[0].keyFile) {
|
||||
try {
|
||||
oldKeyPem = decrypt(
|
||||
existing[0].keyFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
} catch (keyErr) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let expiresAt: number | null = null;
|
||||
try {
|
||||
expiresAt = Math.floor(
|
||||
new Date(validatedX509.validTo).getTime() / 1000
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedCert = encrypt(
|
||||
certPem,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const domainId = await findDomainId(domain);
|
||||
if (domainId) {
|
||||
logger.debug(
|
||||
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
wildcard,
|
||||
...(domainId !== null && { domainId })
|
||||
})
|
||||
.where(eq(certificates.domain, domain));
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
|
||||
await pushCertUpdateToAffectedNewts(
|
||||
domain,
|
||||
domainId,
|
||||
oldCertPem,
|
||||
oldKeyPem
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
await db.insert(certificates).values({
|
||||
domain,
|
||||
domainId,
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
wildcard
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
|
||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
function findAcmeJsonFiles(dirPath: string): string[] {
|
||||
const results: string[] = [];
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`acmeCertSync: could not read directory "${dirPath}": ${err}`
|
||||
);
|
||||
return results;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...findAcmeJsonFiles(fullPath));
|
||||
} else if (entry.isFile()) {
|
||||
// check if it is a json file
|
||||
if (entry.name.endsWith(".json")) {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(fullPath, "utf8");
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`acmeCertSync: could not read file "${fullPath}": ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`acmeCertSync: could not parse "${fullPath}" as JSON: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||
} catch (err) {
|
||||
logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let acmeJson: AcmeJson;
|
||||
try {
|
||||
acmeJson = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvers = Object.keys(acmeJson || {});
|
||||
if (resolvers.length === 0) {
|
||||
logger.debug(`acmeCertSync: no resolvers found in acme.json`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect certificates from every resolver. If the same domain appears in
|
||||
// multiple resolvers, the last one wins (resolvers iterated in object order).
|
||||
const allCerts: AcmeCert[] = [];
|
||||
for (const resolver of resolvers) {
|
||||
const resolverData = acmeJson[resolver];
|
||||
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// logger.debug(
|
||||
// `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
|
||||
// );
|
||||
for (const cert of resolverData.Certificates) {
|
||||
allCerts.push(cert);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cert of allCerts) {
|
||||
const mainDomain = cert?.domain?.main;
|
||||
|
||||
if (!mainDomain || typeof mainDomain !== "string") {
|
||||
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!cert.certificate || !cert.key) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - empty certificate or key field`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let certPem: string;
|
||||
let keyPem: string;
|
||||
try {
|
||||
certPem = Buffer.from(cert.certificate, "base64").toString("utf8");
|
||||
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - failed to base64-decode cert/key: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!certPem.trim() || !keyPem.trim()) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - blank PEM after base64 decode`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate that the decoded data actually parses as a real X.509 cert
|
||||
// before we touch the database. This prevents importing partially-written
|
||||
// or corrupted entries from acme.json.
|
||||
const firstCertPemForValidation = extractFirstCert(certPem);
|
||||
if (!firstCertPemForValidation) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - no PEM certificate block found`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let validatedX509: crypto.X509Certificate;
|
||||
try {
|
||||
validatedX509 = new crypto.X509Certificate(
|
||||
firstCertPemForValidation
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - invalid X.509 certificate: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sanity-check the private key parses too
|
||||
try {
|
||||
crypto.createPrivateKey(keyPem);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - invalid private key: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect all domains covered by this cert: main + every SAN.
|
||||
// Each domain gets its own row in the certificates table so that
|
||||
// lookups by any hostname on the cert succeed independently.
|
||||
const allDomains = new Set<string>([mainDomain]);
|
||||
if (Array.isArray(cert.domain?.sans)) {
|
||||
for (const san of cert.domain.sans) {
|
||||
if (typeof san === "string" && san.trim()) {
|
||||
allDomains.add(san.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logger.debug(
|
||||
// `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||
// );
|
||||
|
||||
for (const domain of allDomains) {
|
||||
try {
|
||||
await storeCertForDomain(
|
||||
domain,
|
||||
certPem,
|
||||
keyPem,
|
||||
validatedX509
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`acmeCertSync: error storing cert for domain "${domain}": ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initAcmeCertSync(): void {
|
||||
// stub
|
||||
}
|
||||
if (build == "saas") {
|
||||
logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`);
|
||||
return;
|
||||
}
|
||||
|
||||
const configData = config.getRawConfig();
|
||||
|
||||
if (!configData.flags?.enable_acme_cert_sync) {
|
||||
logger.debug(
|
||||
`acmeCertSync: ACME cert sync is disabled by config flag, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const acmeJsonPath =
|
||||
configData.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
|
||||
const intervalMs = configData.acme?.sync_interval_ms ?? 5000;
|
||||
const httpEndpoint = configData.acme?.acme_http_endpoint;
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms`
|
||||
);
|
||||
if (httpEndpoint) {
|
||||
logger.debug(
|
||||
`acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms`
|
||||
);
|
||||
}
|
||||
|
||||
const runSync = () => {
|
||||
if (httpEndpoint) {
|
||||
syncAcmeCertsFromHttp(httpEndpoint).catch((err) => {
|
||||
logger.error(`acmeCertSync: error during HTTP sync: ${err}`);
|
||||
});
|
||||
} else {
|
||||
// only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up
|
||||
let stat: fs.Stats | null = null;
|
||||
try {
|
||||
stat = fs.statSync(acmeJsonPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`acmeCertSync: cannot stat path "${acmeJsonPath}": ${err}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const files = findAcmeJsonFiles(acmeJsonPath);
|
||||
if (files.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no acme.json files found in directory "${acmeJsonPath}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// logger.debug(
|
||||
// `acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"`
|
||||
// );
|
||||
for (const file of files) {
|
||||
syncAcmeCerts(file).catch((err) => {
|
||||
logger.error(
|
||||
`acmeCertSync: error during sync of "${file}": ${err}`
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
syncAcmeCerts(acmeJsonPath).catch((err) => {
|
||||
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Run immediately on init, then on the configured interval
|
||||
runSync();
|
||||
|
||||
setInterval(runSync, intervalMs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNull,
|
||||
or,
|
||||
sql,
|
||||
SQL,
|
||||
type InferInsertModel
|
||||
} from "drizzle-orm";
|
||||
import {
|
||||
AiBudget,
|
||||
aiBudgetBreachEvents,
|
||||
aiBudgets,
|
||||
aiModels,
|
||||
aiUsageRecords,
|
||||
db,
|
||||
userOrgRoles
|
||||
} from "@server/db";
|
||||
import { modelKeyMatches } from "@server/lib/aiModelKeyMatch";
|
||||
import type { AiUsage } from "@server/lib/aiUsageExtraction";
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
import logger from "@server/logger";
|
||||
|
||||
type BudgetPeriod = AiBudget["period"];
|
||||
|
||||
const PERIOD_DURATIONS_MS: Record<Exclude<BudgetPeriod, "lifetime">, number> = {
|
||||
hourly: 60 * 60 * 1000,
|
||||
daily: 24 * 60 * 60 * 1000,
|
||||
weekly: 7 * 24 * 60 * 60 * 1000,
|
||||
monthly: 30 * 24 * 60 * 60 * 1000,
|
||||
yearly: 365 * 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
// Budgets are cheap to be a little stale about (enforcement is already
|
||||
// check-then-act, not transactional). Re-derive each budget's usage sum
|
||||
// from aiUsageRecords at most this often; in between, completed requests
|
||||
// just add their own contribution onto the cached sum instead of
|
||||
// re-querying/re-aggregating from scratch.
|
||||
const BUDGET_CACHE_REFRESH_MS = 8_000;
|
||||
// Redis-level TTL is only a safety net for eviction if a budget stops
|
||||
// seeing traffic - the actual staleness check is the computedAt timestamp
|
||||
// stored in the cached value, compared against BUDGET_CACHE_REFRESH_MS.
|
||||
const BUDGET_CACHE_SAFETY_TTL_SEC = 60;
|
||||
|
||||
function applicableBudgetsCacheKey(ctx: BudgetScopeContext): string {
|
||||
const roleKey = [...ctx.roleIds].sort((a, b) => a - b).join(",");
|
||||
return [
|
||||
"aiBudget:applicable",
|
||||
ctx.orgId,
|
||||
ctx.providerId,
|
||||
ctx.requestedModel,
|
||||
ctx.resourceId ?? "",
|
||||
ctx.siteResourceId ?? "",
|
||||
roleKey,
|
||||
ctx.virtualApiKeyId ?? ""
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function budgetUsageCacheKey(budgetId: number): string {
|
||||
return `aiBudget:usage:${budgetId}`;
|
||||
}
|
||||
|
||||
type CachedBudgetUsage = {
|
||||
sum: number;
|
||||
computedAt: number;
|
||||
};
|
||||
|
||||
// Budget periods are trailing windows from "now", not calendar-aligned
|
||||
// (e.g. "daily" = last 24h). "lifetime" has no lower bound.
|
||||
function windowStart(period: BudgetPeriod, now: number): number {
|
||||
if (period === "lifetime") {
|
||||
return 0;
|
||||
}
|
||||
return now - PERIOD_DURATIONS_MS[period];
|
||||
}
|
||||
|
||||
export type BudgetScopeContext = {
|
||||
orgId: string;
|
||||
providerId: number;
|
||||
requestedModel: string;
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
roleIds: number[];
|
||||
requestUserId: string | null;
|
||||
virtualApiKeyId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every budget that could apply to this request: the provider itself, any
|
||||
* model on that provider whose (possibly wildcarded) modelKey matches the
|
||||
* requested model, the target resource/site-resource, and any role the
|
||||
* requesting user holds in the org. Cached for BUDGET_CACHE_REFRESH_MS since
|
||||
* budget/model config changes are rare and a request-scoped org/provider/
|
||||
* model/resource/role combination repeats constantly under real traffic.
|
||||
*/
|
||||
export async function resolveApplicableBudgets(
|
||||
ctx: BudgetScopeContext
|
||||
): Promise<AiBudget[]> {
|
||||
const cacheKey = applicableBudgetsCacheKey(ctx);
|
||||
const cached = await cache.get<AiBudget[]>(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const budgets = await fetchApplicableBudgets(ctx);
|
||||
await cache.set(cacheKey, budgets, BUDGET_CACHE_REFRESH_MS / 1000);
|
||||
return budgets;
|
||||
}
|
||||
|
||||
async function fetchApplicableBudgets(
|
||||
ctx: BudgetScopeContext
|
||||
): Promise<AiBudget[]> {
|
||||
const providerModels = await db
|
||||
.select({ modelId: aiModels.modelId, modelKey: aiModels.modelKey })
|
||||
.from(aiModels)
|
||||
.where(
|
||||
and(
|
||||
eq(aiModels.providerId, ctx.providerId),
|
||||
eq(aiModels.enabled, true)
|
||||
)
|
||||
);
|
||||
|
||||
const matchingModelIds = providerModels
|
||||
.filter((m) => modelKeyMatches(m.modelKey, ctx.requestedModel))
|
||||
.map((m) => m.modelId);
|
||||
|
||||
const scopeConditions: SQL[] = [
|
||||
and(
|
||||
eq(aiBudgets.providerId, ctx.providerId),
|
||||
isNull(aiBudgets.modelId)
|
||||
)!
|
||||
];
|
||||
if (matchingModelIds.length > 0) {
|
||||
scopeConditions.push(inArray(aiBudgets.modelId, matchingModelIds));
|
||||
}
|
||||
if (ctx.resourceId != null) {
|
||||
scopeConditions.push(eq(aiBudgets.resourceId, ctx.resourceId));
|
||||
}
|
||||
if (ctx.siteResourceId != null) {
|
||||
scopeConditions.push(eq(aiBudgets.siteResourceId, ctx.siteResourceId));
|
||||
}
|
||||
if (ctx.roleIds.length > 0) {
|
||||
scopeConditions.push(inArray(aiBudgets.roleId, ctx.roleIds));
|
||||
}
|
||||
if (ctx.virtualApiKeyId != null) {
|
||||
scopeConditions.push(
|
||||
eq(aiBudgets.virtualApiKeyId, ctx.virtualApiKeyId)
|
||||
);
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(aiBudgets)
|
||||
.where(
|
||||
and(
|
||||
eq(aiBudgets.orgId, ctx.orgId),
|
||||
eq(aiBudgets.enabled, true),
|
||||
or(...scopeConditions)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function sumUsageAmount(
|
||||
where: SQL,
|
||||
unit: AiBudget["unit"]
|
||||
): Promise<number> {
|
||||
const column =
|
||||
unit === "usd" ? aiUsageRecords.costUsd : aiUsageRecords.totalTokens;
|
||||
const [row] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(${column}), 0)` })
|
||||
.from(aiUsageRecords)
|
||||
.where(where);
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sums recorded usage for a single budget's scope + rolling window. Model
|
||||
* budgets can't be pushed down to SQL because the model's key may itself be
|
||||
* a glob, so those rows are fetched for the provider+window and matched in
|
||||
* JS the same way access-control matching does.
|
||||
*/
|
||||
export async function sumUsageForBudget(
|
||||
budget: AiBudget,
|
||||
ctx: BudgetScopeContext,
|
||||
now: number
|
||||
): Promise<number> {
|
||||
const start = windowStart(budget.period, now);
|
||||
|
||||
if (budget.modelId != null) {
|
||||
const [model] = await db
|
||||
.select({
|
||||
providerId: aiModels.providerId,
|
||||
modelKey: aiModels.modelKey
|
||||
})
|
||||
.from(aiModels)
|
||||
.where(eq(aiModels.modelId, budget.modelId))
|
||||
.limit(1);
|
||||
if (!model) {
|
||||
return 0;
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
requestedModel: aiUsageRecords.requestedModel,
|
||||
costUsd: aiUsageRecords.costUsd,
|
||||
totalTokens: aiUsageRecords.totalTokens
|
||||
})
|
||||
.from(aiUsageRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(aiUsageRecords.orgId, ctx.orgId),
|
||||
eq(aiUsageRecords.providerId, model.providerId),
|
||||
gte(aiUsageRecords.createdAt, start)
|
||||
)
|
||||
);
|
||||
return rows
|
||||
.filter((r) => modelKeyMatches(model.modelKey, r.requestedModel))
|
||||
.reduce(
|
||||
(sum, r) =>
|
||||
sum +
|
||||
(budget.unit === "usd" ? (r.costUsd ?? 0) : r.totalTokens),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
if (budget.providerId != null) {
|
||||
return sumUsageAmount(
|
||||
and(
|
||||
eq(aiUsageRecords.orgId, ctx.orgId),
|
||||
eq(aiUsageRecords.providerId, budget.providerId),
|
||||
gte(aiUsageRecords.createdAt, start)
|
||||
)!,
|
||||
budget.unit
|
||||
);
|
||||
}
|
||||
|
||||
if (budget.resourceId != null) {
|
||||
return sumUsageAmount(
|
||||
and(
|
||||
eq(aiUsageRecords.orgId, ctx.orgId),
|
||||
eq(aiUsageRecords.resourceId, budget.resourceId),
|
||||
gte(aiUsageRecords.createdAt, start)
|
||||
)!,
|
||||
budget.unit
|
||||
);
|
||||
}
|
||||
|
||||
if (budget.siteResourceId != null) {
|
||||
return sumUsageAmount(
|
||||
and(
|
||||
eq(aiUsageRecords.orgId, ctx.orgId),
|
||||
eq(aiUsageRecords.siteResourceId, budget.siteResourceId),
|
||||
gte(aiUsageRecords.createdAt, start)
|
||||
)!,
|
||||
budget.unit
|
||||
);
|
||||
}
|
||||
|
||||
if (budget.roleId != null) {
|
||||
const members = await db
|
||||
.select({ userId: userOrgRoles.userId })
|
||||
.from(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.roleId, budget.roleId),
|
||||
eq(userOrgRoles.orgId, ctx.orgId)
|
||||
)
|
||||
);
|
||||
const userIds = members.map((m) => m.userId);
|
||||
if (userIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return sumUsageAmount(
|
||||
and(
|
||||
eq(aiUsageRecords.orgId, ctx.orgId),
|
||||
inArray(aiUsageRecords.userId, userIds),
|
||||
gte(aiUsageRecords.createdAt, start)
|
||||
)!,
|
||||
budget.unit
|
||||
);
|
||||
}
|
||||
|
||||
if (budget.virtualApiKeyId != null) {
|
||||
return sumUsageAmount(
|
||||
and(
|
||||
eq(aiUsageRecords.orgId, ctx.orgId),
|
||||
eq(aiUsageRecords.virtualApiKeyId, budget.virtualApiKeyId),
|
||||
gte(aiUsageRecords.createdAt, start)
|
||||
)!,
|
||||
budget.unit
|
||||
);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached wrapper around sumUsageForBudget. Reuses a per-budget cached sum
|
||||
* for up to BUDGET_CACHE_REFRESH_MS, and otherwise falls through to the DB
|
||||
* aggregation and reseeds the cache. Completed requests within that window
|
||||
* top the cached sum up via applyUsageToBudgetCache below rather than
|
||||
* forcing a re-aggregation on every request.
|
||||
*/
|
||||
async function getBudgetUsage(
|
||||
budget: AiBudget,
|
||||
ctx: BudgetScopeContext,
|
||||
now: number
|
||||
): Promise<number> {
|
||||
const cacheKey = budgetUsageCacheKey(budget.budgetId);
|
||||
const cached = await cache.get<CachedBudgetUsage>(cacheKey);
|
||||
if (cached && now - cached.computedAt < BUDGET_CACHE_REFRESH_MS) {
|
||||
return cached.sum;
|
||||
}
|
||||
|
||||
const sum = await sumUsageForBudget(budget, ctx, now);
|
||||
await cache.set(
|
||||
cacheKey,
|
||||
{ sum, computedAt: now } satisfies CachedBudgetUsage,
|
||||
BUDGET_CACHE_SAFETY_TTL_SEC
|
||||
);
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once a request's actual usage is known, for every budget that was
|
||||
* resolved as applicable to it (i.e. checkBudgets' returned `budgets`).
|
||||
* Adds this request's contribution directly onto each budget's cached sum
|
||||
* so the next request in the same refresh window doesn't need to re-query
|
||||
* or re-aggregate. If there's no warm cache entry, or it's already due for
|
||||
* a refresh, this is a no-op - the next reader re-derives from the DB,
|
||||
* which by then already includes this request's row via recordUsage.
|
||||
*/
|
||||
export async function applyUsageToBudgetCache(
|
||||
budgets: AiBudget[],
|
||||
usage: { usd: number; tokens: number }
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
budgets.map(async (budget) => {
|
||||
const delta = budget.unit === "usd" ? usage.usd : usage.tokens;
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = budgetUsageCacheKey(budget.budgetId);
|
||||
const cached = await cache.get<CachedBudgetUsage>(cacheKey);
|
||||
if (
|
||||
!cached ||
|
||||
Date.now() - cached.computedAt >= BUDGET_CACHE_REFRESH_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await cache.set(
|
||||
cacheKey,
|
||||
{
|
||||
sum: cached.sum + delta,
|
||||
computedAt: cached.computedAt
|
||||
} satisfies CachedBudgetUsage,
|
||||
BUDGET_CACHE_SAFETY_TTL_SEC
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Throttled to one durable event per budget per breach window, so a soft
|
||||
// budget being exceeded doesn't write a row on every subsequent request
|
||||
// while it stays over.
|
||||
async function recordBreachEventIfNew(
|
||||
budget: AiBudget,
|
||||
ctx: BudgetScopeContext,
|
||||
usageAmount: number,
|
||||
now: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const start = windowStart(budget.period, now);
|
||||
const [existing] = await db
|
||||
.select({ id: aiBudgetBreachEvents.id })
|
||||
.from(aiBudgetBreachEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(aiBudgetBreachEvents.budgetId, budget.budgetId),
|
||||
gte(aiBudgetBreachEvents.createdAt, start)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.insert(aiBudgetBreachEvents).values({
|
||||
orgId: ctx.orgId,
|
||||
budgetId: budget.budgetId,
|
||||
enforcement: budget.enforcement,
|
||||
unit: budget.unit,
|
||||
period: budget.period,
|
||||
amount: budget.amount,
|
||||
usageAmount,
|
||||
blocked: budget.enforcement === "hard",
|
||||
requestUserId: ctx.requestUserId,
|
||||
createdAt: now
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to record AI budget breach event", {
|
||||
error,
|
||||
budgetId: budget.budgetId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type BudgetCheckResult = {
|
||||
blocked: boolean;
|
||||
blockingBudget?: AiBudget;
|
||||
// Every budget resolved as applicable to this request, regardless of
|
||||
// whether it was breached - pass to applyUsageToBudgetCache once this
|
||||
// request's actual usage is known.
|
||||
budgets: AiBudget[];
|
||||
};
|
||||
|
||||
export async function checkBudgets(
|
||||
ctx: BudgetScopeContext
|
||||
): Promise<BudgetCheckResult> {
|
||||
const budgets = await resolveApplicableBudgets(ctx);
|
||||
if (budgets.length === 0) {
|
||||
return { blocked: false, budgets: [] };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let blockingBudget: AiBudget | undefined;
|
||||
|
||||
for (const budget of budgets) {
|
||||
const usage = await getBudgetUsage(budget, ctx, now);
|
||||
if (usage < budget.amount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await recordBreachEventIfNew(budget, ctx, usage, now);
|
||||
|
||||
if (budget.enforcement === "hard" && !blockingBudget) {
|
||||
blockingBudget = budget;
|
||||
}
|
||||
}
|
||||
|
||||
return blockingBudget
|
||||
? { blocked: true, blockingBudget, budgets }
|
||||
: { blocked: false, budgets };
|
||||
}
|
||||
|
||||
export type UsageRecordInput = {
|
||||
orgId: string;
|
||||
providerId: number;
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
userId: string | null;
|
||||
virtualApiKeyId: string | null;
|
||||
requestedModel: string;
|
||||
usage: AiUsage;
|
||||
costUsd: number | null;
|
||||
createdAt?: number;
|
||||
// Same id as the aiSessionLog row logged for this request, so the two
|
||||
// can be joined to show token/cost usage alongside the session
|
||||
// transcript. Undefined when the session wasn't logged (e.g. session
|
||||
// log retention disabled for the org).
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
type AiUsageRecordInsert = InferInsertModel<typeof aiUsageRecords>;
|
||||
|
||||
// In-memory buffer for batching AI usage record inserts, mirroring the
|
||||
// approach in server/routers/badger/logRequestAudit.ts. Usage rows are read
|
||||
// back on every budget-cache miss (see getBudgetUsage above), which happens
|
||||
// at least every BUDGET_CACHE_REFRESH_MS, so this buffer is flushed much
|
||||
// more aggressively than the request audit log to keep the table from
|
||||
// lagging behind what budget enforcement needs. Unlike the audit log, there
|
||||
// is no retention/cleanup job for this table - usage history is kept
|
||||
// indefinitely for billing and historical reporting.
|
||||
const usageRecordBuffer: AiUsageRecordInsert[] = [];
|
||||
|
||||
const USAGE_BATCH_SIZE = 20; // Write to DB every 20 records
|
||||
const USAGE_BATCH_INTERVAL_MS = 1000; // Or every 1 second, whichever comes first
|
||||
const USAGE_MAX_BUFFER_SIZE = 5000; // Prevent unbounded memory growth
|
||||
let usageFlushTimer: NodeJS.Timeout | null = null;
|
||||
let isUsageFlushInProgress = false;
|
||||
|
||||
async function flushUsageRecords() {
|
||||
if (usageRecordBuffer.length === 0 || isUsageFlushInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUsageFlushInProgress = true;
|
||||
|
||||
const recordsToWrite = usageRecordBuffer.splice(
|
||||
0,
|
||||
usageRecordBuffer.length
|
||||
);
|
||||
|
||||
try {
|
||||
// Use a transaction to ensure all inserts succeed or fail together
|
||||
await db.transaction(async (tx) => {
|
||||
// Batch insert in groups to avoid overwhelming the database
|
||||
const DB_BATCH_SIZE = 25;
|
||||
for (let i = 0; i < recordsToWrite.length; i += DB_BATCH_SIZE) {
|
||||
const batch = recordsToWrite.slice(i, i + DB_BATCH_SIZE);
|
||||
await tx.insert(aiUsageRecords).values(batch);
|
||||
}
|
||||
});
|
||||
logger.debug(
|
||||
`Flushed ${recordsToWrite.length} AI usage records to database`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error flushing AI usage records:", error);
|
||||
// On transaction error, put records back at the front of the buffer
|
||||
// to retry, but only if the buffer isn't too large
|
||||
if (
|
||||
usageRecordBuffer.length <
|
||||
USAGE_MAX_BUFFER_SIZE - recordsToWrite.length
|
||||
) {
|
||||
usageRecordBuffer.unshift(...recordsToWrite);
|
||||
logger.info(
|
||||
`Re-queued ${recordsToWrite.length} AI usage records for retry`
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`Buffer full, dropped ${recordsToWrite.length} AI usage records`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isUsageFlushInProgress = false;
|
||||
// If buffer filled up while we were flushing, flush again
|
||||
if (usageRecordBuffer.length >= USAGE_BATCH_SIZE) {
|
||||
flushUsageRecords().catch((err) =>
|
||||
logger.error("Error in follow-up AI usage flush:", err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleUsageFlush() {
|
||||
if (usageFlushTimer === null) {
|
||||
usageFlushTimer = setTimeout(() => {
|
||||
usageFlushTimer = null;
|
||||
flushUsageRecords().catch((err) =>
|
||||
logger.error("Error in scheduled AI usage flush:", err)
|
||||
);
|
||||
}, USAGE_BATCH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully flush all pending AI usage records (call this on shutdown).
|
||||
*/
|
||||
export async function shutdownUsageRecorder() {
|
||||
if (usageFlushTimer) {
|
||||
clearTimeout(usageFlushTimer);
|
||||
usageFlushTimer = null;
|
||||
}
|
||||
// Force flush even if one is in progress by waiting and retrying
|
||||
while (isUsageFlushInProgress) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
await flushUsageRecords();
|
||||
}
|
||||
|
||||
export async function recordUsage(input: UsageRecordInput): Promise<void> {
|
||||
try {
|
||||
const { usage } = input;
|
||||
const totalTokens =
|
||||
usage.promptTokens +
|
||||
usage.cacheReadTokens +
|
||||
usage.cacheWriteTokens +
|
||||
usage.completionTokens +
|
||||
usage.reasoningTokens;
|
||||
|
||||
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
|
||||
if (usageRecordBuffer.length >= USAGE_MAX_BUFFER_SIZE) {
|
||||
const dropped = usageRecordBuffer.splice(0, USAGE_BATCH_SIZE);
|
||||
logger.warn(
|
||||
`AI usage record buffer exceeded max size (${USAGE_MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
|
||||
);
|
||||
}
|
||||
|
||||
usageRecordBuffer.push({
|
||||
orgId: input.orgId,
|
||||
providerId: input.providerId,
|
||||
resourceId: input.resourceId,
|
||||
siteResourceId: input.siteResourceId,
|
||||
userId: input.userId,
|
||||
virtualApiKeyId: input.virtualApiKeyId,
|
||||
sessionId: input.sessionId,
|
||||
requestedModel: input.requestedModel,
|
||||
promptTokens: usage.promptTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens,
|
||||
cacheWriteTokens: usage.cacheWriteTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
reasoningTokens: usage.reasoningTokens,
|
||||
totalTokens,
|
||||
costUsd: input.costUsd,
|
||||
estimated: usage.estimated,
|
||||
createdAt: input.createdAt ?? Date.now()
|
||||
});
|
||||
|
||||
// Flush immediately if buffer is full, otherwise schedule a flush
|
||||
if (usageRecordBuffer.length >= USAGE_BATCH_SIZE) {
|
||||
flushUsageRecords().catch((err) =>
|
||||
logger.error("Error flushing AI usage records:", err)
|
||||
);
|
||||
} else {
|
||||
scheduleUsageFlush();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to record AI usage", { error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { Request } from "express";
|
||||
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||
|
||||
export { AI_CAPABILITIES, type AiCapability };
|
||||
|
||||
export type AiCapabilityRoute = {
|
||||
method: "POST";
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type AiProtocolFamily = "openai" | "anthropic" | "google" | "bedrock";
|
||||
|
||||
export type AiCapabilityDefinition = {
|
||||
id: AiCapability;
|
||||
protocolFamily: AiProtocolFamily;
|
||||
routes: AiCapabilityRoute[];
|
||||
extractModel: (req: Request) => string | undefined;
|
||||
resolveUpstreamUrl: (
|
||||
baseUrl: string,
|
||||
req: Request,
|
||||
model: string
|
||||
) => string;
|
||||
isStreaming: (req: Request, contentType: string) => boolean;
|
||||
};
|
||||
|
||||
function bodyModel(req: Request): string | undefined {
|
||||
return typeof req.body?.model === "string" ? req.body.model : undefined;
|
||||
}
|
||||
|
||||
function paramModel(req: Request): string | undefined {
|
||||
const model = req.params?.model;
|
||||
return typeof model === "string" && model.length > 0 ? model : undefined;
|
||||
}
|
||||
|
||||
export function joinUpstreamUrl(baseUrl: string, path: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
let suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
|
||||
let basePathname = "/";
|
||||
try {
|
||||
basePathname = new URL(base).pathname.replace(/\/+$/, "") || "/";
|
||||
} catch {
|
||||
// Fall through with "/" non-absolute bases are not expected in
|
||||
// production, but keep joining usable for malformed input.
|
||||
}
|
||||
|
||||
if (basePathname !== "/") {
|
||||
const baseSegs = basePathname.split("/").filter(Boolean);
|
||||
const pathSegs = suffix.split("/").filter(Boolean);
|
||||
const max = Math.min(baseSegs.length, pathSegs.length);
|
||||
let overlap = 0;
|
||||
for (let n = max; n >= 1; n--) {
|
||||
const baseSuffix = baseSegs.slice(-n);
|
||||
const pathPrefix = pathSegs.slice(0, n);
|
||||
if (baseSuffix.every((seg, i) => seg === pathPrefix[i])) {
|
||||
overlap = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (overlap > 0) {
|
||||
const remaining = pathSegs.slice(overlap);
|
||||
suffix = remaining.length > 0 ? `/${remaining.join("/")}` : "/";
|
||||
}
|
||||
}
|
||||
|
||||
if (suffix === "/") {
|
||||
return base;
|
||||
}
|
||||
|
||||
return `${base}${suffix}`;
|
||||
}
|
||||
|
||||
function pathFromRequest(req: Request): string {
|
||||
const raw = req.originalUrl || req.url || req.path;
|
||||
return raw.startsWith("/") ? raw : `/${raw}`;
|
||||
}
|
||||
|
||||
function bodyRequestsStream(req: Request): boolean {
|
||||
return req.body?.stream === true;
|
||||
}
|
||||
|
||||
function contentTypeIsSse(contentType: string): boolean {
|
||||
return contentType.includes("text/event-stream");
|
||||
}
|
||||
|
||||
function contentTypeIsAmazonEventStream(contentType: string): boolean {
|
||||
return contentType.includes("application/vnd.amazon.eventstream");
|
||||
}
|
||||
|
||||
function pathIncludes(req: Request, fragment: string): boolean {
|
||||
return pathFromRequest(req).includes(fragment);
|
||||
}
|
||||
|
||||
function isBodyOrSseStreaming(req: Request, contentType: string): boolean {
|
||||
return bodyRequestsStream(req) || contentTypeIsSse(contentType);
|
||||
}
|
||||
|
||||
function isGeminiStyleStreaming(req: Request, contentType: string): boolean {
|
||||
return (
|
||||
pathIncludes(req, "streamGenerateContent") ||
|
||||
pathIncludes(req, "alt=sse") ||
|
||||
contentTypeIsSse(contentType)
|
||||
);
|
||||
}
|
||||
|
||||
export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
||||
{
|
||||
openai_chat: {
|
||||
id: "openai_chat",
|
||||
protocolFamily: "openai",
|
||||
routes: [
|
||||
{ method: "POST", path: "/v1/chat/completions" },
|
||||
{ method: "POST", path: "/chat/completions" }
|
||||
],
|
||||
extractModel: bodyModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: isBodyOrSseStreaming
|
||||
},
|
||||
openai_responses: {
|
||||
id: "openai_responses",
|
||||
protocolFamily: "openai",
|
||||
routes: [{ method: "POST", path: "/v1/responses" }],
|
||||
extractModel: bodyModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: isBodyOrSseStreaming
|
||||
},
|
||||
anthropic_messages: {
|
||||
id: "anthropic_messages",
|
||||
protocolFamily: "anthropic",
|
||||
routes: [{ method: "POST", path: "/v1/messages" }],
|
||||
extractModel: bodyModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: isBodyOrSseStreaming
|
||||
},
|
||||
gemini_generate_content: {
|
||||
id: "gemini_generate_content",
|
||||
protocolFamily: "google",
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1beta/models/:model\\:generateContent"
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1beta/models/:model\\:streamGenerateContent"
|
||||
}
|
||||
],
|
||||
extractModel: paramModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: isGeminiStyleStreaming
|
||||
},
|
||||
google_generate_content: {
|
||||
id: "google_generate_content",
|
||||
protocolFamily: "google",
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
// Vertex publisher model generateContent
|
||||
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:generateContent"
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamGenerateContent"
|
||||
}
|
||||
],
|
||||
extractModel: paramModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: isGeminiStyleStreaming
|
||||
},
|
||||
google_raw_predict: {
|
||||
id: "google_raw_predict",
|
||||
protocolFamily: "google",
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:rawPredict"
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamRawPredict"
|
||||
}
|
||||
],
|
||||
extractModel: paramModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: (req, contentType) =>
|
||||
pathIncludes(req, "streamRawPredict") ||
|
||||
pathIncludes(req, "alt=sse") ||
|
||||
contentTypeIsSse(contentType)
|
||||
},
|
||||
bedrock_model_invoke: {
|
||||
id: "bedrock_model_invoke",
|
||||
protocolFamily: "bedrock",
|
||||
routes: [
|
||||
{ method: "POST", path: "/model/:model/invoke" },
|
||||
{
|
||||
method: "POST",
|
||||
path: "/model/:model/invoke-with-response-stream"
|
||||
}
|
||||
],
|
||||
extractModel: paramModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: (req, contentType) =>
|
||||
pathIncludes(req, "invoke-with-response-stream") ||
|
||||
contentTypeIsAmazonEventStream(contentType) ||
|
||||
contentTypeIsSse(contentType)
|
||||
},
|
||||
bedrock_converse: {
|
||||
id: "bedrock_converse",
|
||||
protocolFamily: "bedrock",
|
||||
routes: [
|
||||
{ method: "POST", path: "/model/:model/converse" },
|
||||
{ method: "POST", path: "/model/:model/converse-stream" }
|
||||
],
|
||||
extractModel: paramModel,
|
||||
resolveUpstreamUrl: (base, req) =>
|
||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||
isStreaming: (req, contentType) =>
|
||||
pathIncludes(req, "converse-stream") ||
|
||||
contentTypeIsAmazonEventStream(contentType) ||
|
||||
contentTypeIsSse(contentType)
|
||||
}
|
||||
};
|
||||
|
||||
export function isAiCapability(value: unknown): value is AiCapability {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(AI_CAPABILITIES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an Express-style route path from AI_CAPABILITY_DEFS into a RegExp.
|
||||
* Handles `:param` segments and escaped literal colons (`\:`).
|
||||
*/
|
||||
export function routePatternToRegExp(routePath: string): RegExp {
|
||||
let pattern = "";
|
||||
for (let i = 0; i < routePath.length; i++) {
|
||||
const ch = routePath[i];
|
||||
if (
|
||||
ch === "\\" &&
|
||||
i + 1 < routePath.length &&
|
||||
routePath[i + 1] === ":"
|
||||
) {
|
||||
pattern += ":";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ":") {
|
||||
// Named param: consume until next / or end
|
||||
i++;
|
||||
while (
|
||||
i < routePath.length &&
|
||||
routePath[i] !== "/" &&
|
||||
!(routePath[i] === "\\" && routePath[i + 1] === ":")
|
||||
) {
|
||||
i++;
|
||||
}
|
||||
i--; // loop will ++
|
||||
pattern += "[^/]+";
|
||||
continue;
|
||||
}
|
||||
// Escape regex special chars
|
||||
if (/[.*+?^${}()|[\]\\]/.test(ch)) {
|
||||
pattern += "\\" + ch;
|
||||
} else {
|
||||
pattern += ch;
|
||||
}
|
||||
}
|
||||
return new RegExp(`^${pattern}$`);
|
||||
}
|
||||
|
||||
export function resolveAiCapabilityFromPath(path: string): AiCapability | null {
|
||||
const pathname = path.split("?")[0] || "/";
|
||||
const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||
|
||||
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
|
||||
for (const route of def.routes) {
|
||||
if (routePatternToRegExp(route.path).test(normalized)) {
|
||||
return def.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseCapabilities(raw: unknown): AiCapability[] {
|
||||
if (raw == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: AiCapability[] = [];
|
||||
const seen = new Set<AiCapability>();
|
||||
for (const item of parsed) {
|
||||
if (isAiCapability(item) && !seen.has(item)) {
|
||||
seen.add(item);
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function serializeCapabilities(capabilities: AiCapability[]): string {
|
||||
return JSON.stringify(capabilities);
|
||||
}
|
||||
|
||||
export function providerHasCapability(
|
||||
capabilities: AiCapability[] | string | null | undefined,
|
||||
capability: AiCapability
|
||||
): boolean {
|
||||
const list =
|
||||
typeof capabilities === "string" || capabilities == null
|
||||
? parseCapabilities(capabilities)
|
||||
: capabilities;
|
||||
return list.includes(capability);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
AI_CAPABILITY_DEFS,
|
||||
type AiCapability,
|
||||
type AiProtocolFamily
|
||||
} from "@server/lib/aiCapabilities";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export type ClientErrorResponse = {
|
||||
statusCode?: number;
|
||||
contentType?: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type AiCapabilityErrorKind =
|
||||
| "authentication"
|
||||
| "invalid_request"
|
||||
| "not_found"
|
||||
| "permission"
|
||||
| "rate_limit"
|
||||
| "internal";
|
||||
|
||||
const AUTH_MESSAGE = "Invalid API key provided.";
|
||||
|
||||
type KindFields = {
|
||||
openaiType: string;
|
||||
openaiCode: string | null;
|
||||
anthropicType: string;
|
||||
googleStatus: string;
|
||||
};
|
||||
|
||||
const KIND_FIELDS: Record<AiCapabilityErrorKind, KindFields> = {
|
||||
authentication: {
|
||||
openaiType: "authentication_error",
|
||||
openaiCode: "invalid_api_key",
|
||||
anthropicType: "authentication_error",
|
||||
googleStatus: "UNAUTHENTICATED"
|
||||
},
|
||||
invalid_request: {
|
||||
openaiType: "invalid_request_error",
|
||||
openaiCode: null,
|
||||
anthropicType: "invalid_request_error",
|
||||
googleStatus: "INVALID_ARGUMENT"
|
||||
},
|
||||
not_found: {
|
||||
openaiType: "invalid_request_error",
|
||||
openaiCode: null,
|
||||
anthropicType: "not_found_error",
|
||||
googleStatus: "NOT_FOUND"
|
||||
},
|
||||
permission: {
|
||||
openaiType: "invalid_request_error",
|
||||
openaiCode: null,
|
||||
anthropicType: "permission_error",
|
||||
googleStatus: "PERMISSION_DENIED"
|
||||
},
|
||||
rate_limit: {
|
||||
openaiType: "rate_limit_error",
|
||||
openaiCode: "rate_limit_exceeded",
|
||||
anthropicType: "rate_limit_error",
|
||||
googleStatus: "RESOURCE_EXHAUSTED"
|
||||
},
|
||||
internal: {
|
||||
openaiType: "api_error",
|
||||
openaiCode: null,
|
||||
anthropicType: "api_error",
|
||||
googleStatus: "INTERNAL"
|
||||
}
|
||||
};
|
||||
|
||||
function resolveProtocolFamily(
|
||||
capability: AiCapability | null
|
||||
): AiProtocolFamily {
|
||||
if (capability == null) {
|
||||
return "openai";
|
||||
}
|
||||
return AI_CAPABILITY_DEFS[capability].protocolFamily;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a protocol-native error body for the given capability.
|
||||
* Message stays contextual; only the envelope/machine fields follow the
|
||||
* capability's native API shape.
|
||||
*/
|
||||
export function buildAiCapabilityErrorBody(
|
||||
capability: AiCapability | null,
|
||||
kind: AiCapabilityErrorKind,
|
||||
message: string,
|
||||
httpStatus?: number
|
||||
): Record<string, unknown> {
|
||||
const family = resolveProtocolFamily(capability);
|
||||
const fields = KIND_FIELDS[kind];
|
||||
|
||||
switch (family) {
|
||||
case "openai":
|
||||
return {
|
||||
error: {
|
||||
message,
|
||||
type: fields.openaiType,
|
||||
param: null,
|
||||
code: fields.openaiCode
|
||||
}
|
||||
};
|
||||
case "anthropic":
|
||||
return {
|
||||
type: "error",
|
||||
error: {
|
||||
type: fields.anthropicType,
|
||||
message
|
||||
}
|
||||
};
|
||||
case "google":
|
||||
return {
|
||||
error: {
|
||||
code: httpStatus ?? HttpCode.BAD_REQUEST,
|
||||
message,
|
||||
status: fields.googleStatus
|
||||
}
|
||||
};
|
||||
case "bedrock":
|
||||
return { message };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInferenceAuthClientError(
|
||||
capability: AiCapability | null
|
||||
): ClientErrorResponse {
|
||||
return {
|
||||
statusCode: HttpCode.UNAUTHORIZED,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
buildAiCapabilityErrorBody(
|
||||
capability,
|
||||
"authentication",
|
||||
AUTH_MESSAGE,
|
||||
HttpCode.UNAUTHORIZED
|
||||
)
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createHash } from "crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export const AI_GATEWAY_TRUST_HEADER = "X-Pangolin-Ai-Gateway-Auth";
|
||||
|
||||
// Injected by the same Traefik trust middleware as AI_GATEWAY_TRUST_HEADER,
|
||||
// but its value differs per router (public inference resource vs. private
|
||||
// siteResource) so the gateway can tell which kind of resource a trusted
|
||||
// request arrived on without re-deriving it from resourceId/siteResourceId.
|
||||
export const AI_GATEWAY_RESOURCE_TYPE_HEADER =
|
||||
"X-Pangolin-Ai-Gateway-Resource-Type";
|
||||
|
||||
export type AiGatewayResourceType = "resource" | "site-resource";
|
||||
|
||||
// Opt-in (server.enable_ai_gateway_client_ip_header): carries the client IP
|
||||
// that Badger resolved at the Traefik hop, so it survives an intermediary
|
||||
// proxy between Traefik and the AI gateway that overwrites
|
||||
// X-Forwarded-For/X-Real-Ip instead of appending to them. Set by a
|
||||
// disableForwardAuth Badger middleware instance (see getTraefikConfig.ts)
|
||||
// on the site-resource inference router only, since that's the sole path
|
||||
// that resolves request identity from the client IP.
|
||||
export const AI_GATEWAY_CLIENT_IP_HEADER = "X-Pangolin-Client-Ip";
|
||||
|
||||
/**
|
||||
* Derive a Traefik-injected trust token from the server secret.
|
||||
* Traefik overwrites this header on inference routes so the AI gateway can
|
||||
* trust Badger-injected Remote-* identity without re-validating credentials.
|
||||
*/
|
||||
export function deriveAiGatewayTrustToken(secret: string): string {
|
||||
return createHash("sha256")
|
||||
.update(`ai-gateway-trust:${secret}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function getAiGatewayTrustToken(): string {
|
||||
const secret = config.getRawConfig().server.secret;
|
||||
if (!secret) {
|
||||
throw new Error("Server secret is required for AI gateway trust token");
|
||||
}
|
||||
return deriveAiGatewayTrustToken(secret);
|
||||
}
|
||||
|
||||
export function isAiGatewayTrustHeaderValid(
|
||||
headers: Record<string, string | string[] | undefined> | undefined,
|
||||
expectedToken?: string
|
||||
): boolean {
|
||||
if (!headers) {
|
||||
return false;
|
||||
}
|
||||
const expected = expectedToken ?? getAiGatewayTrustToken();
|
||||
const raw =
|
||||
headers[AI_GATEWAY_TRUST_HEADER] ??
|
||||
headers[AI_GATEWAY_TRUST_HEADER.toLowerCase()];
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
return typeof value === "string" && value === expected;
|
||||
}
|
||||
|
||||
export function getAiGatewayResourceType(
|
||||
headers: Record<string, string | string[] | undefined> | undefined
|
||||
): AiGatewayResourceType | null {
|
||||
if (!headers) {
|
||||
return null;
|
||||
}
|
||||
const raw =
|
||||
headers[AI_GATEWAY_RESOURCE_TYPE_HEADER] ??
|
||||
headers[AI_GATEWAY_RESOURCE_TYPE_HEADER.toLowerCase()];
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
return value === "resource" || value === "site-resource" ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
type UpstreamFetchInit = {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
skipTlsVerification?: boolean;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
const insecureHttpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
keepAlive: true
|
||||
});
|
||||
|
||||
export function aiGatewayUpstreamFetch(
|
||||
url: string,
|
||||
init: UpstreamFetchInit
|
||||
): Promise<Response> {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === "https:";
|
||||
const lib = isHttps ? https : http;
|
||||
const agent =
|
||||
isHttps && init.skipTlsVerification ? insecureHttpsAgent : undefined;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (init.signal?.aborted) {
|
||||
reject(init.signal.reason ?? new Error("Request aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
const req = lib.request(
|
||||
url,
|
||||
{
|
||||
method: init.method,
|
||||
headers: init.headers,
|
||||
agent
|
||||
},
|
||||
(res) => {
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(res.headers)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
headers.append(key, entry);
|
||||
}
|
||||
} else {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const body = Readable.toWeb(res) as ReadableStream<Uint8Array>;
|
||||
resolve(
|
||||
new Response(body, {
|
||||
status: res.statusCode ?? 502,
|
||||
statusText: res.statusMessage,
|
||||
headers
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
req.on("error", reject);
|
||||
|
||||
if (init.signal) {
|
||||
const onAbort = () => req.destroy(init.signal!.reason);
|
||||
init.signal.addEventListener("abort", onAbort, { once: true });
|
||||
req.on("close", () =>
|
||||
init.signal!.removeEventListener("abort", onAbort)
|
||||
);
|
||||
}
|
||||
|
||||
if (init.body !== undefined) {
|
||||
req.write(init.body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
aiModels,
|
||||
aiProviders,
|
||||
db,
|
||||
resourceAiModels,
|
||||
resourceAiProviders,
|
||||
siteResourceAiModels,
|
||||
siteResourceAiProviders,
|
||||
type Transaction
|
||||
} from "@server/db";
|
||||
import { z } from "zod";
|
||||
|
||||
type DbOrTrx = Transaction | typeof db;
|
||||
|
||||
export const modelListTypeSchema = z.enum(["allow", "block"]);
|
||||
|
||||
export type ModelListType = z.infer<typeof modelListTypeSchema>;
|
||||
|
||||
export const accessModeSchema = z.enum(["inherit", "select"]);
|
||||
|
||||
export type AccessMode = z.infer<typeof accessModeSchema>;
|
||||
|
||||
export const resourceAiProviderAttachmentSchema = z.strictObject({
|
||||
providerId: z.number().int().positive(),
|
||||
accessMode: accessModeSchema.optional().default("inherit"),
|
||||
enabled: z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
export type ResourceAiProviderInput = z.infer<
|
||||
typeof resourceAiProviderAttachmentSchema
|
||||
>;
|
||||
|
||||
export type ResourceAiProviderAttachment = {
|
||||
providerId: number;
|
||||
accessMode: AccessMode;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export const resourceAiModelEntrySchema = z.strictObject({
|
||||
modelId: z.number().int().positive(),
|
||||
listType: modelListTypeSchema
|
||||
});
|
||||
|
||||
export type ResourceAiModelEntry = z.infer<typeof resourceAiModelEntrySchema>;
|
||||
|
||||
export type InferenceFieldsError = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
export function isInferenceFieldsError(
|
||||
value: { error: string } | object
|
||||
): value is InferenceFieldsError {
|
||||
return "error" in value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which allow/block patterns apply for an attachment.
|
||||
* inherit → provider lists; select → resource-selected lists (replace).
|
||||
*/
|
||||
export function resolveEffectiveLists(input: {
|
||||
accessMode: AccessMode;
|
||||
providerAllows: string[];
|
||||
providerBlocks: string[];
|
||||
resourceAllows: string[];
|
||||
resourceBlocks: string[];
|
||||
}): { allows: string[]; blocks: string[] } {
|
||||
if (input.accessMode === "select") {
|
||||
return {
|
||||
allows: input.resourceAllows,
|
||||
blocks: input.resourceBlocks
|
||||
};
|
||||
}
|
||||
return {
|
||||
allows: input.providerAllows,
|
||||
blocks: input.providerBlocks
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAttachments(
|
||||
inputs: ResourceAiProviderInput[]
|
||||
): ResourceAiProviderAttachment[] {
|
||||
const byProviderId = new Map<
|
||||
number,
|
||||
{ accessMode: AccessMode; enabled: boolean }
|
||||
>();
|
||||
for (const input of inputs) {
|
||||
byProviderId.set(input.providerId, {
|
||||
accessMode: input.accessMode ?? "inherit",
|
||||
enabled: input.enabled ?? true
|
||||
});
|
||||
}
|
||||
return [...byProviderId.entries()].map(
|
||||
([providerId, { accessMode, enabled }]) => ({
|
||||
providerId,
|
||||
accessMode,
|
||||
enabled
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate provider attachments for an org.
|
||||
*/
|
||||
export async function resolveProviderAttachments(input: {
|
||||
orgId: string;
|
||||
attachments: ResourceAiProviderInput[];
|
||||
requireAtLeastOne: boolean;
|
||||
}): Promise<ResourceAiProviderAttachment[] | InferenceFieldsError> {
|
||||
const attachments = normalizeAttachments(input.attachments);
|
||||
|
||||
if (input.requireAtLeastOne && attachments.length === 0) {
|
||||
return {
|
||||
error: "At least one AI provider is required for inference-mode resources"
|
||||
};
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const providerIds = attachments.map((a) => a.providerId);
|
||||
const providers = await db
|
||||
.select({
|
||||
providerId: aiProviders.providerId,
|
||||
orgId: aiProviders.orgId,
|
||||
enabled: aiProviders.enabled
|
||||
})
|
||||
.from(aiProviders)
|
||||
.where(
|
||||
and(
|
||||
inArray(aiProviders.providerId, providerIds),
|
||||
eq(aiProviders.orgId, input.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (providers.length !== providerIds.length) {
|
||||
return {
|
||||
error: "One or more AI providers were not found in this organization"
|
||||
};
|
||||
}
|
||||
|
||||
const disabled = providers.find((p) => !p.enabled);
|
||||
if (disabled) {
|
||||
return {
|
||||
error: `AI provider with ID ${disabled.providerId} is disabled`
|
||||
};
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
export async function assertInferenceModeAllowsProviderFields(input: {
|
||||
mode: string;
|
||||
hasProviderAttachments: boolean;
|
||||
}): Promise<InferenceFieldsError | null> {
|
||||
if (input.mode === "inference") {
|
||||
return null;
|
||||
}
|
||||
if (input.hasProviderAttachments) {
|
||||
return {
|
||||
error: "AI providers can only be attached to inference-mode resources"
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach providers to a resource. Inherit attachments use the provider lists
|
||||
* as-is (resource model rows for those providers are pruned). Select
|
||||
* attachments keep resource-selected allow/block subsets.
|
||||
*/
|
||||
export async function setPublicResourceAiProviders(
|
||||
resourceId: number,
|
||||
attachments: ResourceAiProviderAttachment[],
|
||||
trx: DbOrTrx = db
|
||||
): Promise<void> {
|
||||
await trx
|
||||
.delete(resourceAiProviders)
|
||||
.where(eq(resourceAiProviders.resourceId, resourceId));
|
||||
|
||||
if (attachments.length > 0) {
|
||||
await trx.insert(resourceAiProviders).values(
|
||||
attachments.map((a) => ({
|
||||
resourceId,
|
||||
providerId: a.providerId,
|
||||
accessMode: a.accessMode,
|
||||
enabled: a.enabled
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await prunePublicResourceModelsToSelectProviders(
|
||||
resourceId,
|
||||
attachments,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
export async function setSiteResourceAiProviders(
|
||||
siteResourceId: number,
|
||||
attachments: ResourceAiProviderAttachment[],
|
||||
trx: DbOrTrx = db
|
||||
): Promise<void> {
|
||||
await trx
|
||||
.delete(siteResourceAiProviders)
|
||||
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
|
||||
|
||||
if (attachments.length > 0) {
|
||||
await trx.insert(siteResourceAiProviders).values(
|
||||
attachments.map((a) => ({
|
||||
siteResourceId,
|
||||
providerId: a.providerId,
|
||||
accessMode: a.accessMode,
|
||||
enabled: a.enabled
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await pruneSiteResourceModelsToSelectProviders(
|
||||
siteResourceId,
|
||||
attachments,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep resource model rows only for providers in select mode.
|
||||
*/
|
||||
async function prunePublicResourceModelsToSelectProviders(
|
||||
resourceId: number,
|
||||
attachments: ResourceAiProviderAttachment[],
|
||||
trx: DbOrTrx
|
||||
): Promise<void> {
|
||||
const selectProviderIds = attachments
|
||||
.filter((a) => a.accessMode === "select")
|
||||
.map((a) => a.providerId);
|
||||
|
||||
if (selectProviderIds.length === 0) {
|
||||
await trx
|
||||
.delete(resourceAiModels)
|
||||
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await trx
|
||||
.select({
|
||||
modelId: resourceAiModels.modelId,
|
||||
providerId: aiModels.providerId
|
||||
})
|
||||
.from(resourceAiModels)
|
||||
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
|
||||
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||
|
||||
const allowed = new Set(selectProviderIds);
|
||||
const toRemove = existing
|
||||
.filter((row) => !allowed.has(row.providerId))
|
||||
.map((row) => row.modelId);
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
await trx
|
||||
.delete(resourceAiModels)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceAiModels.resourceId, resourceId),
|
||||
inArray(resourceAiModels.modelId, toRemove)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneSiteResourceModelsToSelectProviders(
|
||||
siteResourceId: number,
|
||||
attachments: ResourceAiProviderAttachment[],
|
||||
trx: DbOrTrx
|
||||
): Promise<void> {
|
||||
const selectProviderIds = attachments
|
||||
.filter((a) => a.accessMode === "select")
|
||||
.map((a) => a.providerId);
|
||||
|
||||
if (selectProviderIds.length === 0) {
|
||||
await trx
|
||||
.delete(siteResourceAiModels)
|
||||
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await trx
|
||||
.select({
|
||||
modelId: siteResourceAiModels.modelId,
|
||||
providerId: aiModels.providerId
|
||||
})
|
||||
.from(siteResourceAiModels)
|
||||
.innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId))
|
||||
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
|
||||
|
||||
const allowed = new Set(selectProviderIds);
|
||||
const toRemove = existing
|
||||
.filter((row) => !allowed.has(row.providerId))
|
||||
.map((row) => row.modelId);
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
await trx
|
||||
.delete(siteResourceAiModels)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResourceAiModels.siteResourceId, siteResourceId),
|
||||
inArray(siteResourceAiModels.modelId, toRemove)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearPublicResourceAiConfig(
|
||||
resourceId: number,
|
||||
trx: DbOrTrx = db
|
||||
): Promise<void> {
|
||||
await trx
|
||||
.delete(resourceAiModels)
|
||||
.where(eq(resourceAiModels.resourceId, resourceId));
|
||||
await trx
|
||||
.delete(resourceAiProviders)
|
||||
.where(eq(resourceAiProviders.resourceId, resourceId));
|
||||
}
|
||||
|
||||
export async function clearSiteResourceAiConfig(
|
||||
siteResourceId: number,
|
||||
trx: DbOrTrx = db
|
||||
): Promise<void> {
|
||||
await trx
|
||||
.delete(siteResourceAiModels)
|
||||
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
|
||||
await trx
|
||||
.delete(siteResourceAiProviders)
|
||||
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
|
||||
}
|
||||
|
||||
export async function listPublicResourceAiProviders(resourceId: number) {
|
||||
return db
|
||||
.select({
|
||||
providerId: resourceAiProviders.providerId,
|
||||
niceId: aiProviders.niceId,
|
||||
name: aiProviders.name,
|
||||
type: aiProviders.type,
|
||||
enabled: resourceAiProviders.enabled,
|
||||
providerEnabled: aiProviders.enabled,
|
||||
accessMode: resourceAiProviders.accessMode
|
||||
})
|
||||
.from(resourceAiProviders)
|
||||
.innerJoin(
|
||||
aiProviders,
|
||||
eq(resourceAiProviders.providerId, aiProviders.providerId)
|
||||
)
|
||||
.where(eq(resourceAiProviders.resourceId, resourceId));
|
||||
}
|
||||
|
||||
export async function listSiteResourceAiProviders(siteResourceId: number) {
|
||||
return db
|
||||
.select({
|
||||
providerId: siteResourceAiProviders.providerId,
|
||||
niceId: aiProviders.niceId,
|
||||
name: aiProviders.name,
|
||||
type: aiProviders.type,
|
||||
enabled: siteResourceAiProviders.enabled,
|
||||
providerEnabled: aiProviders.enabled,
|
||||
accessMode: siteResourceAiProviders.accessMode
|
||||
})
|
||||
.from(siteResourceAiProviders)
|
||||
.innerJoin(
|
||||
aiProviders,
|
||||
eq(siteResourceAiProviders.providerId, aiProviders.providerId)
|
||||
)
|
||||
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
|
||||
}
|
||||
|
||||
export type EffectiveAllowModel = {
|
||||
modelId: number;
|
||||
modelKey: string;
|
||||
name: string;
|
||||
providerId: number;
|
||||
providerName: string;
|
||||
};
|
||||
|
||||
export async function listEffectiveAllowModels(options: {
|
||||
resourceId?: number;
|
||||
siteResourceId?: number;
|
||||
}): Promise<EffectiveAllowModel[]> {
|
||||
if (
|
||||
options.resourceId === undefined &&
|
||||
options.siteResourceId === undefined
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attachments =
|
||||
options.resourceId !== undefined
|
||||
? await listPublicResourceAiProviders(options.resourceId)
|
||||
: await listSiteResourceAiProviders(options.siteResourceId!);
|
||||
|
||||
const activeAttachments = attachments.filter(
|
||||
(a) => a.enabled && a.providerEnabled
|
||||
);
|
||||
if (activeAttachments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const inheritProviderIds = activeAttachments
|
||||
.filter((a) => a.accessMode === "inherit")
|
||||
.map((a) => a.providerId);
|
||||
const selectProviderIds = activeAttachments
|
||||
.filter((a) => a.accessMode === "select")
|
||||
.map((a) => a.providerId);
|
||||
|
||||
const providerNameById = new Map(
|
||||
activeAttachments.map((a) => [a.providerId, a.name] as const)
|
||||
);
|
||||
|
||||
const models: EffectiveAllowModel[] = [];
|
||||
|
||||
if (inheritProviderIds.length > 0) {
|
||||
const rows = await db
|
||||
.select({
|
||||
modelId: aiModels.modelId,
|
||||
modelKey: aiModels.modelKey,
|
||||
name: aiModels.name,
|
||||
providerId: aiModels.providerId
|
||||
})
|
||||
.from(aiModels)
|
||||
.where(
|
||||
and(
|
||||
inArray(aiModels.providerId, inheritProviderIds),
|
||||
eq(aiModels.enabled, true),
|
||||
eq(aiModels.listType, "allow")
|
||||
)
|
||||
);
|
||||
for (const row of rows) {
|
||||
models.push({
|
||||
...row,
|
||||
providerName: providerNameById.get(row.providerId) ?? ""
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (selectProviderIds.length > 0) {
|
||||
if (options.resourceId !== undefined) {
|
||||
const rows = await db
|
||||
.select({
|
||||
modelId: aiModels.modelId,
|
||||
modelKey: aiModels.modelKey,
|
||||
name: aiModels.name,
|
||||
providerId: aiModels.providerId
|
||||
})
|
||||
.from(resourceAiModels)
|
||||
.innerJoin(
|
||||
aiModels,
|
||||
eq(resourceAiModels.modelId, aiModels.modelId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceAiModels.resourceId, options.resourceId),
|
||||
inArray(aiModels.providerId, selectProviderIds),
|
||||
eq(resourceAiModels.listType, "allow"),
|
||||
eq(aiModels.enabled, true)
|
||||
)
|
||||
);
|
||||
for (const row of rows) {
|
||||
models.push({
|
||||
...row,
|
||||
providerName: providerNameById.get(row.providerId) ?? ""
|
||||
});
|
||||
}
|
||||
} else if (options.siteResourceId !== undefined) {
|
||||
const rows = await db
|
||||
.select({
|
||||
modelId: aiModels.modelId,
|
||||
modelKey: aiModels.modelKey,
|
||||
name: aiModels.name,
|
||||
providerId: aiModels.providerId
|
||||
})
|
||||
.from(siteResourceAiModels)
|
||||
.innerJoin(
|
||||
aiModels,
|
||||
eq(siteResourceAiModels.modelId, aiModels.modelId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
siteResourceAiModels.siteResourceId,
|
||||
options.siteResourceId
|
||||
),
|
||||
inArray(aiModels.providerId, selectProviderIds),
|
||||
eq(siteResourceAiModels.listType, "allow"),
|
||||
eq(aiModels.enabled, true)
|
||||
)
|
||||
);
|
||||
for (const row of rows) {
|
||||
models.push({
|
||||
...row,
|
||||
providerName: providerNameById.get(row.providerId) ?? ""
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
models.sort((a, b) => {
|
||||
const byProvider = a.providerName.localeCompare(
|
||||
b.providerName,
|
||||
undefined,
|
||||
{
|
||||
sensitivity: "base"
|
||||
}
|
||||
);
|
||||
if (byProvider !== 0) {
|
||||
return byProvider;
|
||||
}
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model list APIs require an inference resource with at least one select-mode
|
||||
* attached provider.
|
||||
*/
|
||||
export async function assertPublicModelListApiEligible(resource: {
|
||||
resourceId: number;
|
||||
mode: string;
|
||||
}): Promise<string | null> {
|
||||
if (resource.mode !== "inference") {
|
||||
return "AI model lists are only supported on inference-mode resources";
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({ providerId: resourceAiProviders.providerId })
|
||||
.from(resourceAiProviders)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceAiProviders.resourceId, resource.resourceId),
|
||||
eq(resourceAiProviders.accessMode, "select")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return "Set at least one attached AI provider to select mode before managing model lists";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function assertSiteModelListApiEligible(siteResource: {
|
||||
siteResourceId: number;
|
||||
mode: string;
|
||||
}): Promise<string | null> {
|
||||
if (siteResource.mode !== "inference") {
|
||||
return "AI model lists are only supported on inference-mode resources";
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({ providerId: siteResourceAiProviders.providerId })
|
||||
.from(siteResourceAiProviders)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
siteResourceAiProviders.siteResourceId,
|
||||
siteResource.siteResourceId
|
||||
),
|
||||
eq(siteResourceAiProviders.accessMode, "select")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return "Set at least one attached AI provider to select mode before managing model lists";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource model entries must belong to select-mode attached providers, and
|
||||
* listType must match the provider catalog entry (allow→allow, block→block).
|
||||
*/
|
||||
export async function assertPublicResourceModelEntriesValid(input: {
|
||||
orgId: string;
|
||||
resourceId: number;
|
||||
models: ResourceAiModelEntry[];
|
||||
}): Promise<string | null> {
|
||||
const uniqueModels = dedupeModelEntries(input.models);
|
||||
if (uniqueModels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachments = await db
|
||||
.select({
|
||||
providerId: resourceAiProviders.providerId,
|
||||
accessMode: resourceAiProviders.accessMode,
|
||||
enabled: resourceAiProviders.enabled
|
||||
})
|
||||
.from(resourceAiProviders)
|
||||
.innerJoin(
|
||||
aiProviders,
|
||||
eq(resourceAiProviders.providerId, aiProviders.providerId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceAiProviders.resourceId, input.resourceId),
|
||||
eq(aiProviders.orgId, input.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
return assertModelEntriesValid({
|
||||
orgId: input.orgId,
|
||||
modelEntries: uniqueModels,
|
||||
attachments,
|
||||
resourceLabel: "resource"
|
||||
});
|
||||
}
|
||||
|
||||
export async function assertSiteResourceModelEntriesValid(input: {
|
||||
orgId: string;
|
||||
siteResourceId: number;
|
||||
models: ResourceAiModelEntry[];
|
||||
}): Promise<string | null> {
|
||||
const uniqueModels = dedupeModelEntries(input.models);
|
||||
if (uniqueModels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attachments = await db
|
||||
.select({
|
||||
providerId: siteResourceAiProviders.providerId,
|
||||
accessMode: siteResourceAiProviders.accessMode,
|
||||
enabled: siteResourceAiProviders.enabled
|
||||
})
|
||||
.from(siteResourceAiProviders)
|
||||
.innerJoin(
|
||||
aiProviders,
|
||||
eq(siteResourceAiProviders.providerId, aiProviders.providerId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
siteResourceAiProviders.siteResourceId,
|
||||
input.siteResourceId
|
||||
),
|
||||
eq(aiProviders.orgId, input.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
return assertModelEntriesValid({
|
||||
orgId: input.orgId,
|
||||
modelEntries: uniqueModels,
|
||||
attachments,
|
||||
resourceLabel: "site resource"
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeModelEntries(
|
||||
models: ResourceAiModelEntry[]
|
||||
): ResourceAiModelEntry[] {
|
||||
const byModelId = new Map(
|
||||
models.map((m) => [m.modelId, m.listType] as const)
|
||||
);
|
||||
return [...byModelId.entries()].map(([modelId, listType]) => ({
|
||||
modelId,
|
||||
listType
|
||||
}));
|
||||
}
|
||||
|
||||
async function assertModelEntriesValid(input: {
|
||||
orgId: string;
|
||||
modelEntries: ResourceAiModelEntry[];
|
||||
attachments: ResourceAiProviderAttachment[];
|
||||
resourceLabel: string;
|
||||
}): Promise<string | null> {
|
||||
const selectProviderIds = input.attachments
|
||||
.filter((a) => a.accessMode === "select")
|
||||
.map((a) => a.providerId);
|
||||
|
||||
if (selectProviderIds.length === 0) {
|
||||
return "Set at least one attached AI provider to select mode before managing model lists";
|
||||
}
|
||||
|
||||
const modelIds = input.modelEntries.map((m) => m.modelId);
|
||||
const catalogRows = await db
|
||||
.select({
|
||||
modelId: aiModels.modelId,
|
||||
listType: aiModels.listType,
|
||||
providerId: aiModels.providerId,
|
||||
enabled: aiModels.enabled
|
||||
})
|
||||
.from(aiModels)
|
||||
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
|
||||
.where(
|
||||
and(
|
||||
inArray(aiModels.modelId, modelIds),
|
||||
inArray(aiModels.providerId, selectProviderIds),
|
||||
eq(aiProviders.orgId, input.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (catalogRows.length !== modelIds.length) {
|
||||
return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`;
|
||||
}
|
||||
|
||||
const catalogById = new Map(catalogRows.map((row) => [row.modelId, row]));
|
||||
for (const entry of input.modelEntries) {
|
||||
const catalog = catalogById.get(entry.modelId);
|
||||
if (!catalog) {
|
||||
return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`;
|
||||
}
|
||||
if (catalog.listType !== entry.listType) {
|
||||
return `Model ${entry.modelId} must use listType "${catalog.listType}" to match the provider catalog entry`;
|
||||
}
|
||||
if (!catalog.enabled) {
|
||||
return `Model ${entry.modelId} is disabled on its provider`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import type { AiCapability } from "@server/lib/aiCapabilities";
|
||||
import { sseDataFrames, tryParseJson } from "@server/lib/aiUsageExtraction";
|
||||
import logger from "@server/logger";
|
||||
|
||||
// Uniform, capability-agnostic representation of a chat message, used so
|
||||
// the AI session log can be searched/displayed the same way regardless of
|
||||
// which provider/capability produced it. Content is flattened to plain text
|
||||
// - non-text parts (images, tool calls/results) are rendered as readable
|
||||
// placeholders rather than preserved as structured data, which is enough for
|
||||
// a transcript-style replay view without a per-capability renderer.
|
||||
export type NormalizedRole = "system" | "user" | "assistant" | "tool";
|
||||
|
||||
export type NormalizedAiMessage = {
|
||||
role: NormalizedRole;
|
||||
content: string;
|
||||
};
|
||||
|
||||
function normalizeRole(role: unknown): NormalizedRole {
|
||||
if (
|
||||
role === "system" ||
|
||||
role === "user" ||
|
||||
role === "assistant" ||
|
||||
role === "tool"
|
||||
) {
|
||||
return role;
|
||||
}
|
||||
if (role === "model") return "assistant"; // Gemini
|
||||
if (role === "function") return "tool"; // OpenAI legacy function role
|
||||
return "user";
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value ?? {});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens one message "part"/"block" (OpenAI content parts, Anthropic
|
||||
* content blocks, Gemini parts, Bedrock converse content blocks - they all
|
||||
* follow the same rough shape) into readable text.
|
||||
*/
|
||||
function flattenContentPart(part: unknown): string {
|
||||
if (typeof part === "string") return part;
|
||||
if (part == null || typeof part !== "object") return "";
|
||||
const p = part as Record<string, unknown>;
|
||||
|
||||
if (typeof p.text === "string") return p.text;
|
||||
|
||||
if (
|
||||
p.type === "image_url" ||
|
||||
p.type === "image" ||
|
||||
p.type === "input_image" ||
|
||||
p.type === "output_image" ||
|
||||
"inlineData" in p
|
||||
) {
|
||||
return "[image]";
|
||||
}
|
||||
|
||||
// Anthropic-style tool_use / tool_result blocks
|
||||
if (p.type === "tool_use") {
|
||||
const name = typeof p.name === "string" ? p.name : "tool";
|
||||
return `[tool_call: ${name}(${safeJsonStringify(p.input)})]`;
|
||||
}
|
||||
if (p.type === "tool_result") {
|
||||
const content = p.content;
|
||||
const text =
|
||||
typeof content === "string"
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? flattenContentParts(content)
|
||||
: "";
|
||||
return `[tool_result: ${text}]`;
|
||||
}
|
||||
|
||||
// Gemini-style functionCall / functionResponse parts
|
||||
if (p.functionCall && typeof p.functionCall === "object") {
|
||||
const fc = p.functionCall as Record<string, unknown>;
|
||||
return `[tool_call: ${fc.name}(${safeJsonStringify(fc.args)})]`;
|
||||
}
|
||||
if (p.functionResponse && typeof p.functionResponse === "object") {
|
||||
const fr = p.functionResponse as Record<string, unknown>;
|
||||
return `[tool_result: ${fr.name}(${safeJsonStringify(fr.response)})]`;
|
||||
}
|
||||
|
||||
// Bedrock converse-style toolUse / toolResult content blocks
|
||||
if (p.toolUse && typeof p.toolUse === "object") {
|
||||
const tu = p.toolUse as Record<string, unknown>;
|
||||
return `[tool_call: ${tu.name}(${safeJsonStringify(tu.input)})]`;
|
||||
}
|
||||
if (p.toolResult && typeof p.toolResult === "object") {
|
||||
const tr = p.toolResult as Record<string, unknown>;
|
||||
const content = tr.content;
|
||||
const text = Array.isArray(content) ? flattenContentParts(content) : "";
|
||||
return `[tool_result: ${text}]`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function flattenContentParts(parts: unknown[]): string {
|
||||
return parts.map(flattenContentPart).join("");
|
||||
}
|
||||
|
||||
function flattenContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) return flattenContentParts(content);
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort scan for every `"text":"..."` JSON string value in raw text,
|
||||
* concatenated in order. Fallback for streaming formats we can't fully parse
|
||||
* as JSON/SSE (Gemini's array-JSON stream, Bedrock's binary event-stream
|
||||
* framing) - same spirit as aiUsageExtraction's scanNumericFields.
|
||||
*/
|
||||
function scanTextFragments(text: string): string {
|
||||
const out: string[] = [];
|
||||
const re = /"text"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
try {
|
||||
out.push(JSON.parse(`"${match[1]}"`));
|
||||
} catch {
|
||||
out.push(match[1]);
|
||||
}
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request (input) normalizers - operate on the already-parsed outbound body.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeOpenAiChatRequest(body: any): NormalizedAiMessage[] {
|
||||
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
||||
return messages.map((m: any) => ({
|
||||
role: normalizeRole(m?.role),
|
||||
content: flattenContent(m?.content)
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeOpenAiResponsesRequest(body: any): NormalizedAiMessage[] {
|
||||
const out: NormalizedAiMessage[] = [];
|
||||
if (typeof body?.instructions === "string" && body.instructions) {
|
||||
out.push({ role: "system", content: body.instructions });
|
||||
}
|
||||
const input = body?.input;
|
||||
if (typeof input === "string") {
|
||||
out.push({ role: "user", content: input });
|
||||
} else if (Array.isArray(input)) {
|
||||
for (const item of input) {
|
||||
if (item?.role) {
|
||||
out.push({
|
||||
role: normalizeRole(item.role),
|
||||
content: flattenContent(item.content)
|
||||
});
|
||||
} else if (typeof item?.type === "string") {
|
||||
out.push({ role: "tool", content: `[${item.type}]` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeAnthropicRequest(body: any): NormalizedAiMessage[] {
|
||||
const out: NormalizedAiMessage[] = [];
|
||||
if (body?.system) {
|
||||
const sys = flattenContent(body.system);
|
||||
if (sys) out.push({ role: "system", content: sys });
|
||||
}
|
||||
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
||||
for (const m of messages) {
|
||||
out.push({
|
||||
role: normalizeRole(m?.role),
|
||||
content: flattenContent(m?.content)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeGeminiRequest(body: any): NormalizedAiMessage[] {
|
||||
const out: NormalizedAiMessage[] = [];
|
||||
const sysParts = body?.systemInstruction?.parts;
|
||||
if (Array.isArray(sysParts)) {
|
||||
const text = flattenContentParts(sysParts);
|
||||
if (text) out.push({ role: "system", content: text });
|
||||
}
|
||||
const contents = Array.isArray(body?.contents) ? body.contents : [];
|
||||
for (const c of contents) {
|
||||
out.push({
|
||||
role: normalizeRole(c?.role),
|
||||
content: Array.isArray(c?.parts) ? flattenContentParts(c.parts) : ""
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeBedrockConverseRequest(body: any): NormalizedAiMessage[] {
|
||||
const out: NormalizedAiMessage[] = [];
|
||||
if (Array.isArray(body?.system)) {
|
||||
const text = flattenContentParts(body.system);
|
||||
if (text) out.push({ role: "system", content: text });
|
||||
}
|
||||
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
||||
for (const m of messages) {
|
||||
out.push({
|
||||
role: normalizeRole(m?.role),
|
||||
content: Array.isArray(m?.content)
|
||||
? flattenContentParts(m.content)
|
||||
: ""
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* bedrock_model_invoke and google_raw_predict are passthroughs - the body
|
||||
* shape depends entirely on the underlying model, not the capability. Try
|
||||
* the two shapes we're most likely to see (Anthropic Claude, then plain
|
||||
* OpenAI-style) and give up otherwise, same fallback spirit
|
||||
* aiUsageExtraction.ts uses for these two capabilities' usage extraction.
|
||||
*/
|
||||
function normalizeBestEffortRequest(body: any): NormalizedAiMessage[] | null {
|
||||
if (!Array.isArray(body?.messages)) return null;
|
||||
const looksAnthropicShaped = body.messages.some((m: any) =>
|
||||
Array.isArray(m?.content)
|
||||
);
|
||||
return looksAnthropicShaped
|
||||
? normalizeAnthropicRequest(body)
|
||||
: normalizeOpenAiChatRequest(body);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response (output) normalizers - operate on the raw response text, which
|
||||
// may be a single JSON document (non-streaming) or provider-framed streaming
|
||||
// text (SSE `data:` frames, a JSON-array stream, or binary event-stream
|
||||
// framing with JSON payloads embedded in it).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeOpenAiChatResponse(
|
||||
text: string,
|
||||
isStream: boolean
|
||||
): NormalizedAiMessage[] | null {
|
||||
if (isStream) {
|
||||
let role: unknown = "assistant";
|
||||
let content = "";
|
||||
let found = false;
|
||||
for (const frame of sseDataFrames(text)) {
|
||||
const delta = tryParseJson(frame)?.choices?.[0]?.delta;
|
||||
if (!delta) continue;
|
||||
found = true;
|
||||
if (typeof delta.role === "string") role = delta.role;
|
||||
if (typeof delta.content === "string") content += delta.content;
|
||||
}
|
||||
return found ? [{ role: normalizeRole(role), content }] : null;
|
||||
}
|
||||
|
||||
const message = tryParseJson(text)?.choices?.[0]?.message;
|
||||
if (!message) return null;
|
||||
return [
|
||||
{
|
||||
role: normalizeRole(message.role),
|
||||
content: flattenContent(message.content)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function extractOpenAiResponsesOutputText(response: any): string | null {
|
||||
if (typeof response?.output_text === "string") return response.output_text;
|
||||
const output = Array.isArray(response?.output) ? response.output : [];
|
||||
const pieces: string[] = [];
|
||||
for (const item of output) {
|
||||
if (item?.type === "message" && Array.isArray(item.content)) {
|
||||
pieces.push(flattenContentParts(item.content));
|
||||
}
|
||||
}
|
||||
return pieces.length > 0 ? pieces.join("") : null;
|
||||
}
|
||||
|
||||
function normalizeOpenAiResponsesResponse(
|
||||
text: string,
|
||||
isStream: boolean
|
||||
): NormalizedAiMessage[] | null {
|
||||
if (isStream) {
|
||||
let content = "";
|
||||
let found = false;
|
||||
for (const frame of sseDataFrames(text)) {
|
||||
const parsed = tryParseJson(frame);
|
||||
if (!parsed) continue;
|
||||
if (
|
||||
parsed.type === "response.output_text.delta" &&
|
||||
typeof parsed.delta === "string"
|
||||
) {
|
||||
content += parsed.delta;
|
||||
found = true;
|
||||
} else if (
|
||||
parsed.type === "response.completed" &&
|
||||
parsed.response
|
||||
) {
|
||||
const outputText = extractOpenAiResponsesOutputText(
|
||||
parsed.response
|
||||
);
|
||||
if (outputText != null) {
|
||||
content = outputText;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found ? [{ role: "assistant", content }] : null;
|
||||
}
|
||||
|
||||
const parsed = tryParseJson(text);
|
||||
const outputText = extractOpenAiResponsesOutputText(
|
||||
parsed?.response ?? parsed
|
||||
);
|
||||
return outputText != null
|
||||
? [{ role: "assistant", content: outputText }]
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeAnthropicResponse(
|
||||
text: string,
|
||||
isStream: boolean
|
||||
): NormalizedAiMessage[] | null {
|
||||
if (isStream) {
|
||||
let role: unknown = "assistant";
|
||||
let content = "";
|
||||
let found = false;
|
||||
for (const frame of sseDataFrames(text)) {
|
||||
const parsed = tryParseJson(frame);
|
||||
if (!parsed) continue;
|
||||
if (parsed.type === "message_start" && parsed.message?.role) {
|
||||
role = parsed.message.role;
|
||||
}
|
||||
if (
|
||||
parsed.type === "content_block_start" &&
|
||||
parsed.content_block?.type === "tool_use"
|
||||
) {
|
||||
const name = parsed.content_block.name ?? "tool";
|
||||
content += `[tool_call: ${name}]`;
|
||||
found = true;
|
||||
}
|
||||
if (
|
||||
parsed.type === "content_block_delta" &&
|
||||
typeof parsed.delta?.text === "string"
|
||||
) {
|
||||
content += parsed.delta.text;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
return found ? [{ role: normalizeRole(role), content }] : null;
|
||||
}
|
||||
|
||||
const parsed = tryParseJson(text);
|
||||
if (!parsed || !Array.isArray(parsed.content)) return null;
|
||||
return [
|
||||
{
|
||||
role: normalizeRole(parsed.role ?? "assistant"),
|
||||
content: flattenContentParts(parsed.content)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function geminiCandidateParts(node: any): string {
|
||||
const parts = node?.candidates?.[0]?.content?.parts;
|
||||
return Array.isArray(parts) ? flattenContentParts(parts) : "";
|
||||
}
|
||||
|
||||
function normalizeGeminiResponse(
|
||||
text: string,
|
||||
_isStream: boolean
|
||||
): NormalizedAiMessage[] | null {
|
||||
const frames = sseDataFrames(text);
|
||||
let content = "";
|
||||
let role: unknown = "model";
|
||||
let found = false;
|
||||
|
||||
if (frames.length > 0) {
|
||||
for (const frame of frames) {
|
||||
const parsed = tryParseJson(frame);
|
||||
const piece = geminiCandidateParts(parsed);
|
||||
if (piece) {
|
||||
content += piece;
|
||||
found = true;
|
||||
}
|
||||
const r = parsed?.candidates?.[0]?.content?.role;
|
||||
if (r) role = r;
|
||||
}
|
||||
} else {
|
||||
const parsed = tryParseJson(text);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const chunk of parsed) {
|
||||
const piece = geminiCandidateParts(chunk);
|
||||
if (piece) {
|
||||
content += piece;
|
||||
found = true;
|
||||
}
|
||||
const r = chunk?.candidates?.[0]?.content?.role;
|
||||
if (r) role = r;
|
||||
}
|
||||
} else if (parsed) {
|
||||
const piece = geminiCandidateParts(parsed);
|
||||
if (piece) {
|
||||
content = piece;
|
||||
found = true;
|
||||
}
|
||||
const r = parsed?.candidates?.[0]?.content?.role;
|
||||
if (r) role = r;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
const scanned = scanTextFragments(text);
|
||||
return scanned
|
||||
? [{ role: normalizeRole(role), content: scanned }]
|
||||
: null;
|
||||
}
|
||||
return [{ role: normalizeRole(role), content }];
|
||||
}
|
||||
|
||||
function normalizeBedrockConverseResponse(
|
||||
text: string,
|
||||
isStream: boolean
|
||||
): NormalizedAiMessage[] | null {
|
||||
if (!isStream) {
|
||||
const message = tryParseJson(text)?.output?.message;
|
||||
if (!message) return null;
|
||||
return [
|
||||
{
|
||||
role: normalizeRole(message.role ?? "assistant"),
|
||||
content: Array.isArray(message.content)
|
||||
? flattenContentParts(message.content)
|
||||
: ""
|
||||
}
|
||||
];
|
||||
}
|
||||
// converse-stream uses AWS's binary event-stream framing, but the JSON
|
||||
// payload of each event survives intact inside it (same assumption
|
||||
// aiUsageExtraction.ts makes for usage) - scan for the text pieces.
|
||||
const scanned = scanTextFragments(text);
|
||||
return scanned ? [{ role: "assistant", content: scanned }] : null;
|
||||
}
|
||||
|
||||
function normalizeBedrockModelInvokeResponse(
|
||||
text: string,
|
||||
isStream: boolean
|
||||
): NormalizedAiMessage[] | null {
|
||||
const anthropicStyle = normalizeAnthropicResponse(text, isStream);
|
||||
if (anthropicStyle) return anthropicStyle;
|
||||
const scanned = scanTextFragments(text);
|
||||
return scanned ? [{ role: "assistant", content: scanned }] : null;
|
||||
}
|
||||
|
||||
function normalizeGoogleRawPredictResponse(
|
||||
text: string,
|
||||
isStream: boolean
|
||||
): NormalizedAiMessage[] | null {
|
||||
const anthropicStyle = normalizeAnthropicResponse(text, isStream);
|
||||
if (anthropicStyle) return anthropicStyle;
|
||||
const scanned = scanTextFragments(text);
|
||||
return scanned ? [{ role: "assistant", content: scanned }] : null;
|
||||
}
|
||||
|
||||
const REQUEST_NORMALIZERS: Record<
|
||||
AiCapability,
|
||||
(body: any) => NormalizedAiMessage[] | null
|
||||
> = {
|
||||
openai_chat: normalizeOpenAiChatRequest,
|
||||
openai_responses: normalizeOpenAiResponsesRequest,
|
||||
anthropic_messages: normalizeAnthropicRequest,
|
||||
gemini_generate_content: normalizeGeminiRequest,
|
||||
google_generate_content: normalizeGeminiRequest,
|
||||
google_raw_predict: normalizeBestEffortRequest,
|
||||
bedrock_model_invoke: normalizeBestEffortRequest,
|
||||
bedrock_converse: normalizeBedrockConverseRequest
|
||||
};
|
||||
|
||||
const RESPONSE_NORMALIZERS: Record<
|
||||
AiCapability,
|
||||
(text: string, isStream: boolean) => NormalizedAiMessage[] | null
|
||||
> = {
|
||||
openai_chat: normalizeOpenAiChatResponse,
|
||||
openai_responses: normalizeOpenAiResponsesResponse,
|
||||
anthropic_messages: normalizeAnthropicResponse,
|
||||
gemini_generate_content: normalizeGeminiResponse,
|
||||
google_generate_content: normalizeGeminiResponse,
|
||||
google_raw_predict: normalizeGoogleRawPredictResponse,
|
||||
bedrock_model_invoke: normalizeBedrockModelInvokeResponse,
|
||||
bedrock_converse: normalizeBedrockConverseResponse
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes an outbound AI gateway request body into a uniform message
|
||||
* transcript, regardless of capability/provider. Returns null if the body
|
||||
* doesn't contain any recognizable messages (or parsing failed) - callers
|
||||
* should fall back to showing the raw request body.
|
||||
*/
|
||||
export function normalizeAiRequest(
|
||||
capability: AiCapability,
|
||||
body: unknown
|
||||
): NormalizedAiMessage[] | null {
|
||||
try {
|
||||
const result = REQUEST_NORMALIZERS[capability](body);
|
||||
return result && result.length > 0 ? result : null;
|
||||
} catch (error) {
|
||||
logger.debug("Failed to normalize AI request messages", {
|
||||
capability,
|
||||
error
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a completed (non-streaming or fully-accumulated streaming) AI
|
||||
* gateway response into a uniform message transcript. Returns null if
|
||||
* nothing recognizable could be extracted - callers should fall back to
|
||||
* showing the raw response body.
|
||||
*/
|
||||
export function normalizeAiResponse(
|
||||
capability: AiCapability,
|
||||
responseText: string,
|
||||
isStream: boolean
|
||||
): NormalizedAiMessage[] | null {
|
||||
try {
|
||||
const result = RESPONSE_NORMALIZERS[capability](responseText, isStream);
|
||||
return result && result.length > 0 ? result : null;
|
||||
} catch (error) {
|
||||
logger.debug("Failed to normalize AI response messages", {
|
||||
capability,
|
||||
error
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import fs from "node:fs";
|
||||
import axios from "axios";
|
||||
import { z } from "zod";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
||||
|
||||
export const CATALOG_PROVIDERS = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"vertex",
|
||||
"azure",
|
||||
"bedrock"
|
||||
] as const;
|
||||
|
||||
export type CatalogProvider = (typeof CATALOG_PROVIDERS)[number];
|
||||
|
||||
const CATALOG_PROVIDER_SET = new Set<string>(CATALOG_PROVIDERS);
|
||||
|
||||
// Each of our provider types maps to at most one catalog provider. Provider
|
||||
// types that proxy arbitrary underlying models (openRouter, vercelAiGateway,
|
||||
// custom) have no mapping.
|
||||
const PROVIDER_CATALOG_MAP: Record<
|
||||
Exclude<AiProviderType, "custom">,
|
||||
CatalogProvider | null
|
||||
> = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
googleGemini: "gemini",
|
||||
vertexAi: "vertex",
|
||||
bedrock: "bedrock",
|
||||
microsoftFoundry: "azure",
|
||||
openRouter: null,
|
||||
vercelAiGateway: null
|
||||
};
|
||||
|
||||
export function getCatalogProviderForType(
|
||||
type: AiProviderType
|
||||
): CatalogProvider | null {
|
||||
if (type === "custom") {
|
||||
return null;
|
||||
}
|
||||
return PROVIDER_CATALOG_MAP[type];
|
||||
}
|
||||
|
||||
export type AiModelCatalogEntry = {
|
||||
provider: CatalogProvider;
|
||||
model: string;
|
||||
pricing: {
|
||||
in: number | null;
|
||||
out: number | null;
|
||||
cache: number | null;
|
||||
reasoning: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
const catalogEntrySchema = z.object({
|
||||
model: z.string(),
|
||||
provider: z.string(),
|
||||
pricing: z
|
||||
.object({
|
||||
in: z.number().nullable().optional(),
|
||||
out: z.number().nullable().optional(),
|
||||
cache: z.number().nullable().optional(),
|
||||
reasoning: z.number().nullable().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
const catalogFileSchema = z.object({
|
||||
data: z.array(catalogEntrySchema).optional().default([])
|
||||
});
|
||||
|
||||
type RawCatalogEntry = z.infer<typeof catalogEntrySchema>;
|
||||
|
||||
function normalizeCatalogProvider(raw: string): CatalogProvider | null {
|
||||
if (CATALOG_PROVIDER_SET.has(raw)) {
|
||||
return raw as CatalogProvider;
|
||||
}
|
||||
if (raw.startsWith("bedrock")) {
|
||||
return "bedrock";
|
||||
}
|
||||
if (raw.startsWith("vertex")) {
|
||||
return "vertex";
|
||||
}
|
||||
if (raw.startsWith("azure")) {
|
||||
return "azure";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeEntry(raw: RawCatalogEntry): AiModelCatalogEntry | null {
|
||||
const provider = normalizeCatalogProvider(raw.provider);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!raw.model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
model: raw.model,
|
||||
pricing: {
|
||||
in: raw.pricing?.in ?? null,
|
||||
out: raw.pricing?.out ?? null,
|
||||
cache: raw.pricing?.cache ?? null,
|
||||
reasoning: raw.pricing?.reasoning ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function providerKey(provider: CatalogProvider, key: string): string {
|
||||
return `${provider}\0${key}`;
|
||||
}
|
||||
|
||||
export class AiModelCatalog {
|
||||
private entries: AiModelCatalogEntry[] = [];
|
||||
private byProvider = new Map<CatalogProvider, AiModelCatalogEntry[]>();
|
||||
private byProviderAndKey = new Map<string, AiModelCatalogEntry>();
|
||||
private byKey = new Map<string, AiModelCatalogEntry[]>();
|
||||
private refreshTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Loads the catalog into memory and schedules periodic background refreshes.
|
||||
* Call once at server startup.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
await this.refresh();
|
||||
this.scheduleNextRefresh();
|
||||
}
|
||||
|
||||
/** Exact lookup by catalog provider and model key. */
|
||||
get(
|
||||
provider: CatalogProvider,
|
||||
key: string
|
||||
): AiModelCatalogEntry | undefined {
|
||||
return this.byProviderAndKey.get(providerKey(provider, key));
|
||||
}
|
||||
|
||||
/** All models for a catalog provider. */
|
||||
list(provider: CatalogProvider): AiModelCatalogEntry[] {
|
||||
return this.byProvider.get(provider) ?? [];
|
||||
}
|
||||
|
||||
/** All catalog entries that share a model key, across providers. */
|
||||
listByKey(key: string): AiModelCatalogEntry[] {
|
||||
return this.byKey.get(key) ?? [];
|
||||
}
|
||||
|
||||
/** Full in-memory catalog. */
|
||||
getAll(): AiModelCatalogEntry[] {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
private setEntries(entries: AiModelCatalogEntry[]): void {
|
||||
const byProvider = new Map<CatalogProvider, AiModelCatalogEntry[]>();
|
||||
const byProviderAndKey = new Map<string, AiModelCatalogEntry>();
|
||||
const byKey = new Map<string, AiModelCatalogEntry[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const list = byProvider.get(entry.provider) ?? [];
|
||||
list.push(entry);
|
||||
byProvider.set(entry.provider, list);
|
||||
|
||||
const mapKey = providerKey(entry.provider, entry.model);
|
||||
if (!byProviderAndKey.has(mapKey)) {
|
||||
byProviderAndKey.set(mapKey, entry);
|
||||
}
|
||||
|
||||
const keyList = byKey.get(entry.model) ?? [];
|
||||
keyList.push(entry);
|
||||
byKey.set(entry.model, keyList);
|
||||
}
|
||||
|
||||
this.entries = entries;
|
||||
this.byProvider = byProvider;
|
||||
this.byProviderAndKey = byProviderAndKey;
|
||||
this.byKey = byKey;
|
||||
}
|
||||
|
||||
private async fetchFromFile(
|
||||
filePath: string
|
||||
): Promise<AiModelCatalogEntry[] | null> {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.warn(
|
||||
`AI model catalog file not found at ${filePath}; cost calculation will fall back to unknown pricing`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const result = catalogFileSchema.safeParse(JSON.parse(raw));
|
||||
if (!result.success) {
|
||||
logger.warn(
|
||||
`AI model catalog file at ${filePath} failed validation: ${result.error.message}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return result.data.data
|
||||
.map(normalizeEntry)
|
||||
.filter((e): e is AiModelCatalogEntry => e != null);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to read AI model catalog file", { error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchFromUpstream(
|
||||
upstreamUrl: string
|
||||
): Promise<AiModelCatalogEntry[] | null> {
|
||||
try {
|
||||
const res = await axios.get(upstreamUrl, { timeout: 15_000 });
|
||||
const result = catalogFileSchema.safeParse(res.data);
|
||||
if (!result.success) {
|
||||
logger.warn(
|
||||
`AI model catalog response from ${upstreamUrl} failed validation: ${result.error.message}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return result.data.data
|
||||
.map(normalizeEntry)
|
||||
.filter((e): e is AiModelCatalogEntry => e != null);
|
||||
} catch (error: any) {
|
||||
logger.warn(
|
||||
`Failed to fetch AI model catalog from ${upstreamUrl}: ${error.message || error}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
const { file, merge_file, upstream_url } =
|
||||
config.getRawConfig().ai.model_catalog;
|
||||
|
||||
const fetched = file
|
||||
? await this.fetchFromFile(file)
|
||||
: await this.fetchFromUpstream(upstream_url);
|
||||
|
||||
if (!fetched) {
|
||||
logger.debug(
|
||||
"AI model catalog refresh failed; keeping previously loaded catalog in memory"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let merged = fetched;
|
||||
if (merge_file) {
|
||||
const mergeEntries = await this.fetchFromFile(merge_file);
|
||||
if (mergeEntries) {
|
||||
// Entries from the base catalog take precedence; the merge
|
||||
// file only adds models not already present.
|
||||
merged = [...fetched, ...mergeEntries];
|
||||
}
|
||||
}
|
||||
|
||||
this.setEntries(merged);
|
||||
logger.debug(
|
||||
`AI model catalog refreshed: ${this.entries.length} models loaded`
|
||||
);
|
||||
}
|
||||
|
||||
private scheduleNextRefresh(): void {
|
||||
const { refresh_interval_min_hours, refresh_interval_max_hours } =
|
||||
config.getRawConfig().ai.model_catalog;
|
||||
|
||||
// Jittered rather than fixed so that many self-hosted instances don't
|
||||
// all hit the upstream catalog endpoint at the same moment.
|
||||
const minMs = refresh_interval_min_hours * 60 * 60 * 1000;
|
||||
const maxMs = refresh_interval_max_hours * 60 * 60 * 1000;
|
||||
const delayMs = minMs + Math.random() * Math.max(0, maxMs - minMs);
|
||||
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
}
|
||||
this.refreshTimer = setTimeout(async () => {
|
||||
await this.refresh();
|
||||
this.scheduleNextRefresh();
|
||||
}, delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
export const aiModelCatalog = new AiModelCatalog();
|
||||
|
||||
export function listCatalogModelsForType(
|
||||
type: AiProviderType,
|
||||
query?: string
|
||||
): { model: string }[] {
|
||||
const catalogProvider = getCatalogProviderForType(type);
|
||||
|
||||
let models = catalogProvider
|
||||
? aiModelCatalog.list(catalogProvider).map((entry) => ({
|
||||
model: entry.model
|
||||
}))
|
||||
: [];
|
||||
|
||||
if (query) {
|
||||
const q = query.toLowerCase();
|
||||
models = models.filter((m) => m.model.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
models = models.filter((m) => {
|
||||
if (seen.has(m.model)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(m.model);
|
||||
return true;
|
||||
});
|
||||
|
||||
models.sort((a, b) => a.model.localeCompare(b.model));
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the AI model pricing catalog into memory and schedules periodic
|
||||
* background refreshes. Call once at server startup.
|
||||
*/
|
||||
export async function initAiModelCatalog(): Promise<void> {
|
||||
await aiModelCatalog.init();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
const modelKeyRegexCache = new Map<string, RegExp>();
|
||||
|
||||
export function isModelKeyPattern(key: string): boolean {
|
||||
return key.includes("*") || key.includes("?");
|
||||
}
|
||||
|
||||
function getModelKeyRegex(pattern: string): RegExp {
|
||||
let regex = modelKeyRegexCache.get(pattern);
|
||||
if (!regex) {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
regex = new RegExp(
|
||||
`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`
|
||||
);
|
||||
modelKeyRegexCache.set(pattern, regex);
|
||||
}
|
||||
return regex;
|
||||
}
|
||||
|
||||
export function modelKeyMatches(
|
||||
pattern: string,
|
||||
requestedModel: string
|
||||
): boolean {
|
||||
return getModelKeyRegex(pattern).test(requestedModel);
|
||||
}
|
||||
|
||||
function wildcardCharCount(key: string): number {
|
||||
let count = 0;
|
||||
for (const char of key) {
|
||||
if (char === "*" || char === "?") {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function literalLength(key: string): number {
|
||||
return key.replace(/[*?]/g, "").length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort comparator: more specific patterns sort before less specific ones
|
||||
* (negative when `a` is more specific than `b`).
|
||||
*
|
||||
* 1. Exact keys beat patterns
|
||||
* 2. Fewer wildcard characters win
|
||||
* 3. Longer literal length wins
|
||||
*/
|
||||
export function compareModelKeySpecificity(a: string, b: string): number {
|
||||
const aIsPattern = isModelKeyPattern(a);
|
||||
const bIsPattern = isModelKeyPattern(b);
|
||||
|
||||
if (aIsPattern !== bIsPattern) {
|
||||
return aIsPattern ? 1 : -1;
|
||||
}
|
||||
|
||||
const wildcardDiff = wildcardCharCount(a) - wildcardCharCount(b);
|
||||
if (wildcardDiff !== 0) {
|
||||
return wildcardDiff;
|
||||
}
|
||||
|
||||
return literalLength(b) - literalLength(a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-layer policy: empty allowlist denies all. Blocklist only applies
|
||||
* after an allow match.
|
||||
*/
|
||||
export function isAllowedByLists(
|
||||
requested: string,
|
||||
allows: string[],
|
||||
blocks: string[]
|
||||
): boolean {
|
||||
if (allows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!allows.some((pattern) => modelKeyMatches(pattern, requested))) {
|
||||
return false;
|
||||
}
|
||||
if (blocks.some((pattern) => modelKeyMatches(pattern, requested))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Among allow patterns that match `requested`, return the most specific one,
|
||||
* or null if none match.
|
||||
*/
|
||||
export function mostSpecificMatchingAllow(
|
||||
requested: string,
|
||||
allows: string[]
|
||||
): string | null {
|
||||
const matching = allows.filter((pattern) =>
|
||||
modelKeyMatches(pattern, requested)
|
||||
);
|
||||
if (matching.length === 0) {
|
||||
return null;
|
||||
}
|
||||
matching.sort(compareModelKeySpecificity);
|
||||
return matching[0];
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
||||
import type { AiUsage } from "@server/lib/aiUsageExtraction";
|
||||
import {
|
||||
aiModelCatalog,
|
||||
getCatalogProviderForType,
|
||||
type AiModelCatalogEntry,
|
||||
type CatalogProvider
|
||||
} from "@server/lib/aiModelCatalog";
|
||||
|
||||
export type AiModelPricing = {
|
||||
inputCostPerToken: number | null;
|
||||
outputCostPerToken: number | null;
|
||||
cacheReadInputTokenCost: number | null;
|
||||
outputCostPerReasoningToken: number | null;
|
||||
// True when the match came from a different catalog provider than the
|
||||
// one mapped to this provider's type (e.g. an openRouter/custom model
|
||||
// id that only matched a global search across every provider). Costs
|
||||
// found this way are a best-effort approximation, not a guarantee the
|
||||
// upstream provider bills at the same rate.
|
||||
approximate: boolean;
|
||||
};
|
||||
|
||||
function stripVendorPrefix(modelId: string): string | null {
|
||||
const idx = modelId.indexOf("/");
|
||||
if (idx === -1 || idx === modelId.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return modelId.slice(idx + 1);
|
||||
}
|
||||
|
||||
function toPricing(
|
||||
entry: AiModelCatalogEntry,
|
||||
approximate: boolean
|
||||
): AiModelPricing {
|
||||
return {
|
||||
inputCostPerToken: entry.pricing.in,
|
||||
outputCostPerToken: entry.pricing.out,
|
||||
cacheReadInputTokenCost: entry.pricing.cache,
|
||||
outputCostPerReasoningToken: entry.pricing.reasoning,
|
||||
approximate
|
||||
};
|
||||
}
|
||||
|
||||
function findEntry(
|
||||
modelId: string,
|
||||
provider: CatalogProvider | null
|
||||
): AiModelCatalogEntry | null {
|
||||
const candidates = [modelId, stripVendorPrefix(modelId)].filter(
|
||||
(v): v is string => v != null
|
||||
);
|
||||
|
||||
for (const key of candidates) {
|
||||
if (provider) {
|
||||
const match = aiModelCatalog.get(provider, key);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = aiModelCatalog.listByKey(key)[0];
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up per-token pricing for a model, scoped first to the catalog
|
||||
* provider that corresponds to our provider type, then falling back to a
|
||||
* global search across every provider (marked `approximate`) for provider
|
||||
* types that proxy arbitrary underlying models.
|
||||
*/
|
||||
export function getModelPricing(
|
||||
providerType: AiProviderType,
|
||||
modelId: string | undefined
|
||||
): AiModelPricing | null {
|
||||
if (!modelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const catalogProvider = getCatalogProviderForType(providerType);
|
||||
|
||||
if (catalogProvider) {
|
||||
const scoped = findEntry(modelId, catalogProvider);
|
||||
if (scoped) {
|
||||
return toPricing(scoped, false);
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = findEntry(modelId, null);
|
||||
if (fallback) {
|
||||
return toPricing(fallback, true);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type AiCostBreakdown = {
|
||||
promptCost: number;
|
||||
cacheReadCost: number;
|
||||
cacheWriteCost: number;
|
||||
completionCost: number;
|
||||
reasoningCost: number;
|
||||
totalCost: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes a $ cost breakdown for a usage record given a model's pricing.
|
||||
* Cache writes and reasoning tokens fall back to the normal input/output
|
||||
* rate respectively when the catalog has no dedicated rate for them (the
|
||||
* catalog has no cache-write field at all, and only some models report a
|
||||
* distinct reasoning rate).
|
||||
*/
|
||||
export function calculateAiCost(
|
||||
pricing: AiModelPricing | null,
|
||||
usage: AiUsage
|
||||
): AiCostBreakdown | null {
|
||||
if (!pricing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputRate = pricing.inputCostPerToken ?? 0;
|
||||
const outputRate = pricing.outputCostPerToken ?? 0;
|
||||
const cacheReadRate = pricing.cacheReadInputTokenCost ?? inputRate;
|
||||
const reasoningRate = pricing.outputCostPerReasoningToken ?? outputRate;
|
||||
|
||||
const promptCost = usage.promptTokens * inputRate;
|
||||
const cacheReadCost = usage.cacheReadTokens * cacheReadRate;
|
||||
const cacheWriteCost = usage.cacheWriteTokens * inputRate;
|
||||
const completionCost = usage.completionTokens * outputRate;
|
||||
const reasoningCost = usage.reasoningTokens * reasoningRate;
|
||||
|
||||
return {
|
||||
promptCost,
|
||||
cacheReadCost,
|
||||
cacheWriteCost,
|
||||
completionCost,
|
||||
reasoningCost,
|
||||
totalCost:
|
||||
promptCost +
|
||||
cacheReadCost +
|
||||
cacheWriteCost +
|
||||
completionCost +
|
||||
reasoningCost
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { decrypt, encrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
parseCapabilities,
|
||||
type AiCapability
|
||||
} from "@server/lib/aiCapabilities";
|
||||
import { stripVirtualApiKeyAuthHeaders } from "@app/lib/virtualApiKeyFormat";
|
||||
import {
|
||||
AI_PROVIDER_AUTH_TYPES,
|
||||
AI_PROVIDER_DEFAULTS,
|
||||
authTypeRequiresApiKey,
|
||||
defaultsForProviderType,
|
||||
providerRequiresUpstreamUrl,
|
||||
type AiBudgetUnit,
|
||||
type AiProviderAuthType,
|
||||
type AiProviderRoutingMode,
|
||||
type AiProviderType
|
||||
} from "@app/lib/aiProviderDefaults";
|
||||
|
||||
export {
|
||||
AI_PROVIDER_AUTH_TYPES,
|
||||
AI_PROVIDER_DEFAULTS,
|
||||
authTypeRequiresApiKey,
|
||||
defaultsForProviderType,
|
||||
providerRequiresUpstreamUrl,
|
||||
type AiBudgetUnit,
|
||||
type AiProviderAuthType,
|
||||
type AiProviderRoutingMode,
|
||||
type AiProviderType
|
||||
};
|
||||
|
||||
const CONFLICTING_AUTH_HEADERS = [
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
"cf-aig-authorization"
|
||||
] as const;
|
||||
|
||||
export function resolveAiProviderCreateFields(input: {
|
||||
type: AiProviderType;
|
||||
upstreamUrl?: string | null;
|
||||
authType?: AiProviderAuthType | null;
|
||||
routingMode?: AiProviderRoutingMode | null;
|
||||
}): {
|
||||
upstreamUrl: string | null;
|
||||
authType: AiProviderAuthType;
|
||||
routingMode: AiProviderRoutingMode;
|
||||
} {
|
||||
const routingMode =
|
||||
input.type === "custom" ? (input.routingMode ?? "url") : "url";
|
||||
|
||||
if (routingMode === "target") {
|
||||
return {
|
||||
upstreamUrl: null,
|
||||
authType: input.authType ?? "bearer",
|
||||
routingMode
|
||||
};
|
||||
}
|
||||
|
||||
if (input.type === "custom") {
|
||||
return {
|
||||
upstreamUrl: input.upstreamUrl ?? null,
|
||||
authType: input.authType ?? "bearer",
|
||||
routingMode
|
||||
};
|
||||
}
|
||||
|
||||
const defaults = AI_PROVIDER_DEFAULTS[input.type];
|
||||
return {
|
||||
upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl,
|
||||
authType: input.authType ?? defaults.authType,
|
||||
routingMode
|
||||
};
|
||||
}
|
||||
|
||||
export type AiProviderHeader = { name: string; value: string };
|
||||
|
||||
export function serializeAiProviderHeaders(
|
||||
headers: AiProviderHeader[] | null | undefined,
|
||||
secret: string
|
||||
): string | null {
|
||||
if (!headers || headers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return encrypt(JSON.stringify(headers), secret);
|
||||
}
|
||||
|
||||
export function parseAiProviderHeaders(
|
||||
raw: string | null | undefined,
|
||||
secret: string
|
||||
): AiProviderHeader[] {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const decrypted = decrypt(raw, secret);
|
||||
const parsed = JSON.parse(decrypted);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter(
|
||||
(h): h is AiProviderHeader =>
|
||||
h != null &&
|
||||
typeof h === "object" &&
|
||||
typeof h.name === "string" &&
|
||||
typeof h.value === "string"
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAiProviderCustomHeaders(
|
||||
headers: Record<string, string>,
|
||||
raw: string | null | undefined,
|
||||
secret: string
|
||||
): void {
|
||||
for (const { name, value } of parseAiProviderHeaders(raw, secret)) {
|
||||
headers[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply provider auth to upstream headers.
|
||||
* - Always strips Pangolin virtual API key credentials from client auth headers.
|
||||
* - Injected modes: strip conflicting client auth headers, then set the provider key.
|
||||
* - none: strip conflicting client auth headers, send no auth.
|
||||
* - passthrough: leave remaining client auth headers as-is (after VAK strip).
|
||||
*/
|
||||
export function applyAiProviderAuthHeaders(
|
||||
headers: Record<string, string>,
|
||||
authType: AiProviderAuthType,
|
||||
apiKey: string | null
|
||||
): void {
|
||||
stripVirtualApiKeyAuthHeaders(headers);
|
||||
|
||||
if (authType === "passthrough") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const name of CONFLICTING_AUTH_HEADERS) {
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === name) {
|
||||
delete headers[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === "none") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`API key required for authType ${authType}`);
|
||||
}
|
||||
|
||||
switch (authType) {
|
||||
case "bearer":
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
break;
|
||||
case "x-api-key":
|
||||
headers["x-api-key"] = apiKey;
|
||||
break;
|
||||
case "x-goog-api-key":
|
||||
headers["x-goog-api-key"] = apiKey;
|
||||
break;
|
||||
case "hec":
|
||||
headers["Authorization"] = `Splunk ${apiKey}`;
|
||||
break;
|
||||
case "cf-aig-authorization":
|
||||
headers["cf-aig-authorization"] = `Bearer ${apiKey}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCapabilitiesForCreate(input: {
|
||||
type: AiProviderType;
|
||||
capabilities?: AiCapability[] | null;
|
||||
}): AiCapability[] {
|
||||
if (input.capabilities != null) {
|
||||
return parseCapabilities(input.capabilities);
|
||||
}
|
||||
if (input.type === "custom") {
|
||||
return [];
|
||||
}
|
||||
return [...AI_PROVIDER_DEFAULTS[input.type].capabilities];
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
aiModelCatalog,
|
||||
getCatalogProviderForType,
|
||||
type CatalogProvider
|
||||
} from "@server/lib/aiModelCatalog";
|
||||
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
|
||||
|
||||
function stripVendorPrefix(modelId: string): string | null {
|
||||
const idx = modelId.indexOf("/");
|
||||
if (idx === -1 || idx === modelId.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return modelId.slice(idx + 1);
|
||||
}
|
||||
|
||||
function modelKeysToTry(modelId: string): string[] {
|
||||
const keys = [modelId];
|
||||
const stripped = stripVendorPrefix(modelId);
|
||||
if (stripped) {
|
||||
keys.push(stripped);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function catalogOwnsModel(
|
||||
catalogProvider: CatalogProvider,
|
||||
modelId: string
|
||||
): boolean {
|
||||
for (const key of modelKeysToTry(modelId)) {
|
||||
if (aiModelCatalog.get(catalogProvider, key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function modelKnownInAnyCatalog(modelId: string): boolean {
|
||||
for (const key of modelKeysToTry(modelId)) {
|
||||
if (aiModelCatalog.listByKey(key).length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* How strongly a provider "owns" a requested model id via the known catalog.
|
||||
*
|
||||
* 2 - Typed provider whose catalog contains the model
|
||||
* 1 - Aggregator/custom that can proxy a catalog-known model
|
||||
* 0 - No ownership signal (typed miss, or unknown model on aggregator/custom)
|
||||
*/
|
||||
export function catalogOwnershipScore(
|
||||
type: AiProviderType,
|
||||
modelId: string
|
||||
): number {
|
||||
const catalogProvider = getCatalogProviderForType(type);
|
||||
if (catalogProvider != null) {
|
||||
return catalogOwnsModel(catalogProvider, modelId) ? 2 : 0;
|
||||
}
|
||||
return modelKnownInAnyCatalog(modelId) ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer native vendor providers over aggregators over custom when catalog
|
||||
* ownership is tied.
|
||||
*
|
||||
* 2 - Native typed provider (openai, anthropic, gemini, ...)
|
||||
* 1 - Aggregator gateway (openRouter, vercelAiGateway)
|
||||
* 0 - Custom
|
||||
*/
|
||||
export function providerClassRank(type: AiProviderType): number {
|
||||
if (type === "custom") {
|
||||
return 0;
|
||||
}
|
||||
if (type === "openRouter" || type === "vercelAiGateway") {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
export function keepBestScored<T>(
|
||||
items: T[],
|
||||
scoreFn: (item: T) => number
|
||||
): T[] {
|
||||
if (items.length <= 1) {
|
||||
return items;
|
||||
}
|
||||
let best = Number.NEGATIVE_INFINITY;
|
||||
for (const item of items) {
|
||||
const score = scoreFn(item);
|
||||
if (score > best) {
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
return items.filter((item) => scoreFn(item) === best);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { encode } from "gpt-tokenizer";
|
||||
import type { AiCapability } from "@server/lib/aiCapabilities";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export type AiUsage = {
|
||||
// Input tokens billed at the normal input rate (i.e. NOT already
|
||||
// covered by cacheReadTokens/cacheWriteTokens below).
|
||||
promptTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
// Output tokens billed at the normal output rate (i.e. NOT already
|
||||
// covered by reasoningTokens below).
|
||||
completionTokens: number;
|
||||
reasoningTokens: number;
|
||||
// True when these numbers are our own best-guess estimate (the upstream
|
||||
// response didn't report usage), rather than provider-reported figures.
|
||||
estimated: boolean;
|
||||
};
|
||||
|
||||
function emptyUsage(): AiUsage {
|
||||
return {
|
||||
promptTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
completionTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
estimated: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans raw (possibly binary-framed, e.g. Bedrock's vnd.amazon.eventstream)
|
||||
* text for `"fieldName":123` occurrences and returns the last value seen for
|
||||
* each field. Used as a best-effort fallback for response shapes we can't
|
||||
* fully parse as JSON/SSE (streaming Bedrock, raw predict passthroughs).
|
||||
*/
|
||||
function scanNumericFields(
|
||||
text: string,
|
||||
fields: string[]
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const field of fields) {
|
||||
const re = new RegExp(`"${field}"\\s*:\\s*(\\d+)`, "g");
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
out[field] = Number(match[1]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Exported for reuse by server/lib/aiMessageNormalization.ts, which needs
|
||||
// the same SSE-frame/JSON-parsing groundwork to extract message content
|
||||
// instead of usage numbers.
|
||||
export function sseDataFrames(text: string): string[] {
|
||||
const frames: string[] = [];
|
||||
for (const rawFrame of text.split(/\r?\n\r?\n/)) {
|
||||
for (const line of rawFrame.split(/\r?\n/)) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice("data:".length).trim();
|
||||
if (data && data !== "[DONE]") {
|
||||
frames.push(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function tryParseJson(text: string): any | null {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractOpenAiChat(text: string, isStream: boolean): AiUsage | null {
|
||||
let usage: any = null;
|
||||
|
||||
if (isStream) {
|
||||
for (const frame of sseDataFrames(text)) {
|
||||
const parsed = tryParseJson(frame);
|
||||
if (parsed?.usage) {
|
||||
usage = parsed.usage;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
usage = tryParseJson(text)?.usage ?? null;
|
||||
}
|
||||
|
||||
if (!usage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0;
|
||||
const reasoningTokens =
|
||||
usage.completion_tokens_details?.reasoning_tokens ?? 0;
|
||||
|
||||
return {
|
||||
promptTokens: Math.max(0, (usage.prompt_tokens ?? 0) - cacheReadTokens),
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens: 0,
|
||||
completionTokens: Math.max(
|
||||
0,
|
||||
(usage.completion_tokens ?? 0) - reasoningTokens
|
||||
),
|
||||
reasoningTokens,
|
||||
estimated: false
|
||||
};
|
||||
}
|
||||
|
||||
function extractOpenAiResponses(
|
||||
text: string,
|
||||
isStream: boolean
|
||||
): AiUsage | null {
|
||||
let usage: any = null;
|
||||
|
||||
if (isStream) {
|
||||
for (const frame of sseDataFrames(text)) {
|
||||
const parsed = tryParseJson(frame);
|
||||
if (
|
||||
parsed?.type === "response.completed" &&
|
||||
parsed?.response?.usage
|
||||
) {
|
||||
usage = parsed.response.usage;
|
||||
} else if (parsed?.usage) {
|
||||
usage = parsed.usage;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const parsed = tryParseJson(text);
|
||||
usage = parsed?.usage ?? parsed?.response?.usage ?? null;
|
||||
}
|
||||
|
||||
if (!usage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? 0;
|
||||
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0;
|
||||
|
||||
return {
|
||||
promptTokens: Math.max(0, (usage.input_tokens ?? 0) - cacheReadTokens),
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens: 0,
|
||||
completionTokens: Math.max(
|
||||
0,
|
||||
(usage.output_tokens ?? 0) - reasoningTokens
|
||||
),
|
||||
reasoningTokens,
|
||||
estimated: false
|
||||
};
|
||||
}
|
||||
|
||||
function extractAnthropicMessages(
|
||||
text: string,
|
||||
isStream: boolean
|
||||
): AiUsage | null {
|
||||
let inputTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let cacheWriteTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let found = false;
|
||||
|
||||
const applyUsage = (usage: any) => {
|
||||
if (!usage) return;
|
||||
found = true;
|
||||
if (typeof usage.input_tokens === "number") {
|
||||
inputTokens = usage.input_tokens;
|
||||
}
|
||||
if (typeof usage.cache_read_input_tokens === "number") {
|
||||
cacheReadTokens = usage.cache_read_input_tokens;
|
||||
}
|
||||
if (typeof usage.cache_creation_input_tokens === "number") {
|
||||
cacheWriteTokens = usage.cache_creation_input_tokens;
|
||||
}
|
||||
if (typeof usage.output_tokens === "number") {
|
||||
outputTokens = usage.output_tokens;
|
||||
}
|
||||
};
|
||||
|
||||
if (isStream) {
|
||||
for (const frame of sseDataFrames(text)) {
|
||||
const parsed = tryParseJson(frame);
|
||||
if (!parsed) continue;
|
||||
applyUsage(parsed.message?.usage);
|
||||
applyUsage(parsed.usage);
|
||||
}
|
||||
} else {
|
||||
applyUsage(tryParseJson(text)?.usage);
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
promptTokens: inputTokens,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
completionTokens: outputTokens,
|
||||
// Anthropic bills extended-thinking output at the normal output
|
||||
// rate, so there's no separate reasoning bucket to report.
|
||||
reasoningTokens: 0,
|
||||
estimated: false
|
||||
};
|
||||
}
|
||||
|
||||
function extractGoogleGenerateContent(
|
||||
text: string,
|
||||
_isStream: boolean
|
||||
): AiUsage | null {
|
||||
// Both the plain-JSON-array stream format and the SSE (?alt=sse) format
|
||||
// repeat a cumulative `usageMetadata` object per chunk; the regex scan
|
||||
// below naturally picks up the last (most complete) one either way.
|
||||
const fields = scanNumericFields(text, [
|
||||
"promptTokenCount",
|
||||
"candidatesTokenCount",
|
||||
"cachedContentTokenCount",
|
||||
"thoughtsTokenCount"
|
||||
]);
|
||||
|
||||
if (fields.promptTokenCount === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheReadTokens = fields.cachedContentTokenCount ?? 0;
|
||||
const reasoningTokens = fields.thoughtsTokenCount ?? 0;
|
||||
|
||||
return {
|
||||
promptTokens: Math.max(0, fields.promptTokenCount - cacheReadTokens),
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens: 0,
|
||||
completionTokens: fields.candidatesTokenCount ?? 0,
|
||||
reasoningTokens,
|
||||
estimated: false
|
||||
};
|
||||
}
|
||||
|
||||
function extractBedrockConverse(
|
||||
text: string,
|
||||
_isStream: boolean
|
||||
): AiUsage | null {
|
||||
// Non-streaming responses are plain JSON; converse-stream frames the
|
||||
// final `metadata` event's usage object inside binary event-stream
|
||||
// framing, but the JSON text survives intact inside that binary
|
||||
// envelope, so the same field scan works for both.
|
||||
const parsed = tryParseJson(text);
|
||||
const usage = parsed?.usage;
|
||||
if (usage) {
|
||||
const cacheReadTokens = usage.cacheReadInputTokens ?? 0;
|
||||
return {
|
||||
promptTokens: Math.max(
|
||||
0,
|
||||
(usage.inputTokens ?? 0) - cacheReadTokens
|
||||
),
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens: usage.cacheWriteInputTokens ?? 0,
|
||||
completionTokens: usage.outputTokens ?? 0,
|
||||
reasoningTokens: 0,
|
||||
estimated: false
|
||||
};
|
||||
}
|
||||
|
||||
const fields = scanNumericFields(text, [
|
||||
"inputTokens",
|
||||
"outputTokens",
|
||||
"cacheReadInputTokens",
|
||||
"cacheWriteInputTokens"
|
||||
]);
|
||||
if (fields.inputTokens === undefined) {
|
||||
return null;
|
||||
}
|
||||
const cacheReadTokens = fields.cacheReadInputTokens ?? 0;
|
||||
return {
|
||||
promptTokens: Math.max(0, fields.inputTokens - cacheReadTokens),
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens: fields.cacheWriteInputTokens ?? 0,
|
||||
completionTokens: fields.outputTokens ?? 0,
|
||||
reasoningTokens: 0,
|
||||
estimated: false
|
||||
};
|
||||
}
|
||||
|
||||
function extractBedrockModelInvoke(
|
||||
text: string,
|
||||
_isStream: boolean,
|
||||
headers: Headers
|
||||
): AiUsage | null {
|
||||
// Non-streaming invoke reports counts via response headers regardless
|
||||
// of the underlying model's payload format.
|
||||
const headerInput = headers.get("x-amzn-bedrock-input-token-count");
|
||||
const headerOutput = headers.get("x-amzn-bedrock-output-token-count");
|
||||
if (headerInput !== null || headerOutput !== null) {
|
||||
return {
|
||||
promptTokens: Number(headerInput ?? 0),
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
completionTokens: Number(headerOutput ?? 0),
|
||||
reasoningTokens: 0,
|
||||
estimated: false
|
||||
};
|
||||
}
|
||||
|
||||
// invoke-with-response-stream has no equivalent headers; the model's
|
||||
// own usage shape (frequently Anthropic-style on Bedrock) is embedded
|
||||
// inside binary event-stream framing, so fall back to a couple of
|
||||
// known field-name shapes via regex.
|
||||
const anthropicStyle = extractAnthropicMessages(text, true);
|
||||
if (anthropicStyle) {
|
||||
return anthropicStyle;
|
||||
}
|
||||
|
||||
const fields = scanNumericFields(text, [
|
||||
"inputTokenCount",
|
||||
"outputTokenCount"
|
||||
]);
|
||||
if (fields.inputTokenCount === undefined) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
promptTokens: fields.inputTokenCount,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
completionTokens: fields.outputTokenCount ?? 0,
|
||||
reasoningTokens: 0,
|
||||
estimated: false
|
||||
};
|
||||
}
|
||||
|
||||
const EXTRACTORS: Record<
|
||||
AiCapability,
|
||||
(text: string, isStream: boolean, headers: Headers) => AiUsage | null
|
||||
> = {
|
||||
openai_chat: extractOpenAiChat,
|
||||
openai_responses: extractOpenAiResponses,
|
||||
anthropic_messages: extractAnthropicMessages,
|
||||
gemini_generate_content: extractGoogleGenerateContent,
|
||||
google_generate_content: extractGoogleGenerateContent,
|
||||
// rawPredict is a passthrough to whatever the underlying publisher
|
||||
// model speaks (often Anthropic-shaped on Vertex); try that, then give
|
||||
// up to the token-count estimate.
|
||||
google_raw_predict: (text, isStream) =>
|
||||
extractAnthropicMessages(text, isStream),
|
||||
bedrock_model_invoke: extractBedrockModelInvoke,
|
||||
bedrock_converse: extractBedrockConverse
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to pull provider-reported token usage out of an upstream AI
|
||||
* gateway response. Returns null if the response didn't contain (or we
|
||||
* couldn't find) usage data, in which case callers should fall back to
|
||||
* `estimateUsage`.
|
||||
*/
|
||||
export function extractUsage(
|
||||
capability: AiCapability,
|
||||
responseText: string,
|
||||
isStream: boolean,
|
||||
headers: Headers
|
||||
): AiUsage | null {
|
||||
try {
|
||||
return EXTRACTORS[capability](responseText, isStream, headers);
|
||||
} catch (error) {
|
||||
logger.debug("Failed to extract AI usage from response", {
|
||||
capability,
|
||||
error
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-guess token estimate for when the provider doesn't report usage.
|
||||
* Uses OpenAI's BPE tokenizer as a stand-in for whatever tokenizer the
|
||||
* actual model uses - close enough for an approximate cost figure, not
|
||||
* exact for non-OpenAI models.
|
||||
*/
|
||||
export function estimateUsage(
|
||||
promptText: string,
|
||||
completionText: string
|
||||
): AiUsage {
|
||||
const usage = emptyUsage();
|
||||
usage.estimated = true;
|
||||
try {
|
||||
usage.promptTokens = promptText ? encode(promptText).length : 0;
|
||||
} catch (error) {
|
||||
logger.debug("Failed to estimate prompt tokens", { error });
|
||||
}
|
||||
try {
|
||||
usage.completionTokens = completionText
|
||||
? encode(completionText).length
|
||||
: 0;
|
||||
} catch (error) {
|
||||
logger.debug("Failed to estimate completion tokens", { error });
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI's Chat Completions API only includes a `usage` field in a
|
||||
* streaming response when the request opts in via `stream_options:
|
||||
* {include_usage: true}` - unlike the Responses API, Anthropic, Gemini and
|
||||
* Bedrock, which report usage in a streaming response by default. Returns
|
||||
* whether we need to inject that option ourselves to be able to track cost.
|
||||
*/
|
||||
export function needsStreamUsageInjection(
|
||||
capability: AiCapability,
|
||||
body: any
|
||||
): boolean {
|
||||
return (
|
||||
capability === "openai_chat" &&
|
||||
body?.stream === true &&
|
||||
body?.stream_options?.include_usage !== true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shallow-cloned body with `stream_options.include_usage`
|
||||
* injected, for capabilities/requests where `needsStreamUsageInjection`
|
||||
* is true. Leaves the original body untouched.
|
||||
*/
|
||||
export function withStreamUsageOption(body: any): any {
|
||||
return {
|
||||
...body,
|
||||
stream_options: { ...body.stream_options, include_usage: true }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* When we injected stream_options.include_usage ourselves (the caller
|
||||
* didn't ask for it), OpenAI appends an extra terminal SSE frame with an
|
||||
* empty `choices: []` array carrying only the usage data. Callers that
|
||||
* don't expect that shape (most minimal SSE parsers assume a non-empty
|
||||
* choices array) shouldn't see it, so it's stripped back out of the bytes
|
||||
* forwarded to the client.
|
||||
*/
|
||||
export function stripInjectedUsageFrame(sseText: string): string {
|
||||
const parts = sseText.split(/(\r?\n\r?\n)/);
|
||||
let out = "";
|
||||
for (let i = 0; i < parts.length; i += 2) {
|
||||
const frame = parts[i];
|
||||
const separator = parts[i + 1] ?? "";
|
||||
const dataLine = frame
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.startsWith("data:"));
|
||||
if (dataLine) {
|
||||
const data = dataLine.slice("data:".length).trim();
|
||||
const parsed = data !== "[DONE]" ? tryParseJson(data) : null;
|
||||
if (
|
||||
parsed &&
|
||||
Array.isArray(parsed.choices) &&
|
||||
parsed.choices.length === 0 &&
|
||||
parsed.usage
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += frame + separator;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort extraction of the model the upstream provider actually
|
||||
* served, which some gateways/routers echo back and which may differ from
|
||||
* the model the caller requested (e.g. an alias resolving to a dated
|
||||
* snapshot). Falls back to the caller's requested model when absent.
|
||||
*/
|
||||
export function extractResponseModel(responseText: string): string | null {
|
||||
const match = responseText.match(/"model"\s*:\s*"([^"]+)"/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export function isUsageEmpty(usage: AiUsage): boolean {
|
||||
return (
|
||||
usage.promptTokens === 0 &&
|
||||
usage.cacheReadTokens === 0 &&
|
||||
usage.cacheWriteTokens === 0 &&
|
||||
usage.completionTokens === 0 &&
|
||||
usage.reasoningTokens === 0
|
||||
);
|
||||
}
|
||||
@@ -202,6 +202,10 @@ async function handleResource(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.resourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [resource] = await trx
|
||||
.select()
|
||||
.from(resources)
|
||||
@@ -227,9 +231,7 @@ async function handleResource(
|
||||
|
||||
let health = "healthy";
|
||||
const allUnknown = monitoredTargets.length === 0;
|
||||
const allHealthy = monitoredTargets.every(
|
||||
(t) => t.hcHealth === "healthy"
|
||||
);
|
||||
const allHealthy = monitoredTargets.every((t) => t.hcHealth === "healthy");
|
||||
const allUnhealthy = monitoredTargets.every(
|
||||
(t) => t.hcHealth === "unhealthy"
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ export enum TierFeature {
|
||||
ActionLogs = "actionLogs", // set the retention period to none on downgrade
|
||||
ConnectionLogs = "connectionLogs",
|
||||
RotateCredentials = "rotateCredentials",
|
||||
MaintencePage = "maintencePage", // handle downgrade
|
||||
MaintenancePage = "maintenancePage", // handle downgrade
|
||||
DevicePosture = "devicePosture",
|
||||
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
|
||||
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
||||
@@ -25,8 +25,7 @@ export enum TierFeature {
|
||||
WildcardSubdomain = "wildcardSubdomain",
|
||||
NewtAutoUpdate = "newtAutoUpdate",
|
||||
ResourcePolicies = "resourcePolicies",
|
||||
AdvancedPublicResources = "advancedPublicResources",
|
||||
AdvancedPrivateResources = "advancedPrivateResources"
|
||||
RoleBasedSSHControls = "roleBasedSSHControls"
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -39,7 +38,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.TwoFactorEnforcement]: [
|
||||
"tier1",
|
||||
@@ -69,6 +68,5 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
|
||||
[TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"],
|
||||
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
|
||||
[TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { aiBudgets, Transaction } from "@server/db";
|
||||
|
||||
export type BlueprintAiBudgetInput = {
|
||||
amount: number;
|
||||
unit: "usd" | "tokens";
|
||||
period:
|
||||
| "monthly"
|
||||
| "yearly"
|
||||
| "lifetime"
|
||||
| "daily"
|
||||
| "hourly"
|
||||
| "weekly";
|
||||
enforcement: "hard" | "soft";
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type SyncAiBudgetsInput = {
|
||||
orgId: string;
|
||||
trx: Transaction;
|
||||
budgets: BlueprintAiBudgetInput[];
|
||||
} & (
|
||||
| { scope: "public"; resourceId: number }
|
||||
| { scope: "site"; siteResourceId: number }
|
||||
);
|
||||
|
||||
/**
|
||||
* Fully declarative: makes the resource's/site resource's AI budgets match
|
||||
* exactly what the blueprint declares (omitted unit/period budgets are removed).
|
||||
*/
|
||||
export async function syncAiBudgets(input: SyncAiBudgetsInput): Promise<void> {
|
||||
const { orgId, trx, budgets } = input;
|
||||
|
||||
const existing = await trx
|
||||
.select()
|
||||
.from(aiBudgets)
|
||||
.where(
|
||||
input.scope === "public"
|
||||
? eq(aiBudgets.resourceId, input.resourceId)
|
||||
: eq(aiBudgets.siteResourceId, input.siteResourceId)
|
||||
);
|
||||
|
||||
const existingByKey = new Map(
|
||||
existing.map((b) => [`${b.unit}::${b.period}`, b])
|
||||
);
|
||||
|
||||
const seenKeys = new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
for (const budget of budgets) {
|
||||
const key = `${budget.unit}::${budget.period}`;
|
||||
seenKeys.add(key);
|
||||
const existingBudget = existingByKey.get(key);
|
||||
|
||||
if (existingBudget) {
|
||||
await trx
|
||||
.update(aiBudgets)
|
||||
.set({
|
||||
amount: budget.amount,
|
||||
enforcement: budget.enforcement,
|
||||
enabled: budget.enabled,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(aiBudgets.budgetId, existingBudget.budgetId));
|
||||
} else {
|
||||
await trx.insert(aiBudgets).values({
|
||||
orgId,
|
||||
resourceId: input.scope === "public" ? input.resourceId : null,
|
||||
siteResourceId:
|
||||
input.scope === "site" ? input.siteResourceId : null,
|
||||
amount: budget.amount,
|
||||
unit: budget.unit,
|
||||
period: budget.period,
|
||||
enforcement: budget.enforcement,
|
||||
enabled: budget.enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, existingBudget] of existingByKey) {
|
||||
if (!seenKeys.has(key)) {
|
||||
await trx
|
||||
.delete(aiBudgets)
|
||||
.where(eq(aiBudgets.budgetId, existingBudget.budgetId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
aiModels,
|
||||
aiProviders,
|
||||
resourceAiModels,
|
||||
siteResourceAiModels,
|
||||
Transaction
|
||||
} from "@server/db";
|
||||
import {
|
||||
AccessMode,
|
||||
ModelListType,
|
||||
clearPublicResourceAiConfig,
|
||||
clearSiteResourceAiConfig,
|
||||
isInferenceFieldsError,
|
||||
resolveProviderAttachments,
|
||||
setPublicResourceAiProviders,
|
||||
setSiteResourceAiProviders
|
||||
} from "@server/lib/aiInferenceResource";
|
||||
|
||||
export type BlueprintAiModelInput = {
|
||||
model: string;
|
||||
listType: ModelListType;
|
||||
};
|
||||
|
||||
export type BlueprintAiProviderInput = {
|
||||
provider: string;
|
||||
accessMode: AccessMode;
|
||||
enabled: boolean;
|
||||
models: string[];
|
||||
};
|
||||
|
||||
async function resolveProviderNiceIds(
|
||||
orgId: string,
|
||||
niceIds: string[],
|
||||
trx: Transaction
|
||||
): Promise<Map<string, number>> {
|
||||
const unique = [...new Set(niceIds)];
|
||||
if (unique.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rows = await trx
|
||||
.select({
|
||||
providerId: aiProviders.providerId,
|
||||
niceId: aiProviders.niceId
|
||||
})
|
||||
.from(aiProviders)
|
||||
.where(
|
||||
and(
|
||||
eq(aiProviders.orgId, orgId),
|
||||
inArray(aiProviders.niceId, unique)
|
||||
)
|
||||
);
|
||||
|
||||
const byNiceId = new Map(rows.map((r) => [r.niceId, r.providerId]));
|
||||
const missing = unique.filter((id) => !byNiceId.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`AI provider(s) not found in this org: ${missing.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return byNiceId;
|
||||
}
|
||||
|
||||
async function resolveModelKeys(
|
||||
providers: BlueprintAiProviderInput[],
|
||||
providerIdByNiceId: Map<string, number>,
|
||||
trx: Transaction
|
||||
): Promise<Map<string, number>> {
|
||||
const providerIds = [
|
||||
...new Set(
|
||||
providers
|
||||
.filter((p) => p.models.length > 0)
|
||||
.map((p) => providerIdByNiceId.get(p.provider)!)
|
||||
)
|
||||
];
|
||||
|
||||
if (providerIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rows = await trx
|
||||
.select({
|
||||
modelId: aiModels.modelId,
|
||||
modelKey: aiModels.modelKey,
|
||||
providerId: aiModels.providerId
|
||||
})
|
||||
.from(aiModels)
|
||||
.where(inArray(aiModels.providerId, providerIds));
|
||||
|
||||
const byProviderAndKey = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
byProviderAndKey.set(`${row.providerId}::${row.modelKey}`, row.modelId);
|
||||
}
|
||||
|
||||
const modelIdByEntryKey = new Map<string, number>();
|
||||
const missing: string[] = [];
|
||||
for (const provider of providers) {
|
||||
const providerId = providerIdByNiceId.get(provider.provider)!;
|
||||
for (const m of provider.models) {
|
||||
const modelId = byProviderAndKey.get(`${providerId}::${m}`);
|
||||
if (modelId === undefined) {
|
||||
missing.push(`${provider.provider}/${m}`);
|
||||
continue;
|
||||
}
|
||||
modelIdByEntryKey.set(`${provider.provider}::${m}`, modelId);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`AI model(s) not found: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
return modelIdByEntryKey;
|
||||
}
|
||||
|
||||
async function validateModelEntries(input: {
|
||||
orgId: string;
|
||||
entries: { modelId: number }[];
|
||||
selectProviderIds: number[];
|
||||
trx: Transaction;
|
||||
}): Promise<void> {
|
||||
if (input.entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.selectProviderIds.length === 0) {
|
||||
throw new Error(
|
||||
"Set at least one attached AI provider to access-mode 'select' before declaring models"
|
||||
);
|
||||
}
|
||||
|
||||
const modelIds = input.entries.map((e) => e.modelId);
|
||||
const catalogRows = await input.trx
|
||||
.select({
|
||||
modelId: aiModels.modelId,
|
||||
listType: aiModels.listType,
|
||||
providerId: aiModels.providerId,
|
||||
enabled: aiModels.enabled
|
||||
})
|
||||
.from(aiModels)
|
||||
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
|
||||
.where(
|
||||
and(
|
||||
inArray(aiModels.modelId, modelIds),
|
||||
inArray(aiModels.providerId, input.selectProviderIds),
|
||||
eq(aiProviders.orgId, input.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const catalogById = new Map(catalogRows.map((row) => [row.modelId, row]));
|
||||
for (const entry of input.entries) {
|
||||
const catalog = catalogById.get(entry.modelId);
|
||||
if (!catalog) {
|
||||
throw new Error(
|
||||
`Model ${entry.modelId} does not exist or does not belong to a select-mode attached provider`
|
||||
);
|
||||
}
|
||||
if (!catalog.enabled) {
|
||||
throw new Error(
|
||||
`Model ${entry.modelId} is disabled on its provider`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SyncInferenceAiConfigInput = {
|
||||
orgId: string;
|
||||
trx: Transaction;
|
||||
mode: string;
|
||||
providers: BlueprintAiProviderInput[];
|
||||
} & (
|
||||
| { scope: "public"; resourceId: number }
|
||||
| { scope: "site"; siteResourceId: number }
|
||||
);
|
||||
|
||||
/**
|
||||
* Fully declarative: makes the resource's attached AI providers/models match
|
||||
* exactly what the blueprint declares (omitted providers/models are removed).
|
||||
* Non-inference resources have any leftover AI config cleared.
|
||||
*/
|
||||
export async function syncInferenceAiConfig(
|
||||
input: SyncInferenceAiConfigInput
|
||||
): Promise<void> {
|
||||
const { orgId, trx, mode } = input;
|
||||
|
||||
if (mode !== "inference") {
|
||||
if (input.scope === "public") {
|
||||
await clearPublicResourceAiConfig(input.resourceId, trx);
|
||||
} else {
|
||||
await clearSiteResourceAiConfig(input.siteResourceId, trx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const providerIdByNiceId = await resolveProviderNiceIds(
|
||||
orgId,
|
||||
input.providers.map((p) => p.provider),
|
||||
trx
|
||||
);
|
||||
|
||||
const resolvedAttachments = await resolveProviderAttachments({
|
||||
orgId,
|
||||
attachments: input.providers.map((p) => ({
|
||||
providerId: providerIdByNiceId.get(p.provider)!,
|
||||
accessMode: p.accessMode,
|
||||
enabled: p.enabled
|
||||
})),
|
||||
requireAtLeastOne: false
|
||||
});
|
||||
if (isInferenceFieldsError(resolvedAttachments)) {
|
||||
throw new Error(resolvedAttachments.error);
|
||||
}
|
||||
|
||||
if (input.scope === "public") {
|
||||
await setPublicResourceAiProviders(
|
||||
input.resourceId,
|
||||
resolvedAttachments,
|
||||
trx
|
||||
);
|
||||
} else {
|
||||
await setSiteResourceAiProviders(
|
||||
input.siteResourceId,
|
||||
resolvedAttachments,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
const modelIdByEntryKey = await resolveModelKeys(
|
||||
input.providers,
|
||||
providerIdByNiceId,
|
||||
trx
|
||||
);
|
||||
|
||||
const modelEntries = input.providers.flatMap((p) =>
|
||||
p.models.map((m) => ({
|
||||
modelId: modelIdByEntryKey.get(`${p.provider}::${m}`)!
|
||||
}))
|
||||
);
|
||||
|
||||
const selectProviderIds = resolvedAttachments
|
||||
.filter((a) => a.accessMode === "select")
|
||||
.map((a) => a.providerId);
|
||||
|
||||
await validateModelEntries({
|
||||
orgId,
|
||||
entries: modelEntries,
|
||||
selectProviderIds,
|
||||
trx
|
||||
});
|
||||
|
||||
if (input.scope === "public") {
|
||||
await trx
|
||||
.delete(resourceAiModels)
|
||||
.where(eq(resourceAiModels.resourceId, input.resourceId));
|
||||
if (modelEntries.length > 0) {
|
||||
await trx.insert(resourceAiModels).values(
|
||||
modelEntries.map((m) => ({
|
||||
resourceId: input.resourceId,
|
||||
modelId: m.modelId
|
||||
}))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await trx
|
||||
.delete(siteResourceAiModels)
|
||||
.where(
|
||||
eq(siteResourceAiModels.siteResourceId, input.siteResourceId)
|
||||
);
|
||||
if (modelEntries.length > 0) {
|
||||
await trx.insert(siteResourceAiModels).values(
|
||||
modelEntries.map((m) => ({
|
||||
siteResourceId: input.siteResourceId,
|
||||
modelId: m.modelId
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,6 @@ import {
|
||||
rebuildClientAssociationsFromSiteResource,
|
||||
waitForSiteResourceRebuildIdle
|
||||
} from "../rebuildClientAssociations";
|
||||
import { build } from "@server/build";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import next from "next";
|
||||
import { LimitId } from "../billing";
|
||||
import { usageService } from "../billing/usageService";
|
||||
|
||||
type ApplyBlueprintArgs = {
|
||||
orgId: string;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
labels,
|
||||
resourceLabels,
|
||||
siteResourceLabels,
|
||||
Transaction
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
|
||||
// Matches the "gray" swatch in the label color palette used by the UI
|
||||
// (src/components/labels-selector.tsx), used as the default for labels
|
||||
// auto-created from a blueprint where no color is specified.
|
||||
const DEFAULT_LABEL_COLOR = "#b4b4b4";
|
||||
|
||||
/**
|
||||
* Looks up labels by name (case-insensitive) within an org, auto-creating
|
||||
* any that don't already exist. Returns the resolved, de-duplicated labelIds.
|
||||
*/
|
||||
export async function getOrCreateLabelIds(
|
||||
orgId: string,
|
||||
labelNames: string[],
|
||||
trx: Transaction
|
||||
): Promise<number[]> {
|
||||
const labelIds = new Set<number>();
|
||||
|
||||
for (const name of labelNames) {
|
||||
let [label] = await trx
|
||||
.select({ labelId: labels.labelId })
|
||||
.from(labels)
|
||||
.where(
|
||||
and(
|
||||
eq(labels.orgId, orgId),
|
||||
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!label) {
|
||||
[label] = await trx
|
||||
.insert(labels)
|
||||
.values({ name, color: DEFAULT_LABEL_COLOR, orgId })
|
||||
.returning({ labelId: labels.labelId });
|
||||
logger.info(
|
||||
`Auto-created label "${name}" in org ${orgId} from blueprint`
|
||||
);
|
||||
}
|
||||
|
||||
labelIds.add(label.labelId);
|
||||
}
|
||||
|
||||
return Array.from(labelIds);
|
||||
}
|
||||
|
||||
export async function syncResourceLabels(
|
||||
resourceId: number,
|
||||
labelIds: number[],
|
||||
trx: Transaction
|
||||
) {
|
||||
await trx
|
||||
.delete(resourceLabels)
|
||||
.where(eq(resourceLabels.resourceId, resourceId));
|
||||
|
||||
if (labelIds.length > 0) {
|
||||
await trx
|
||||
.insert(resourceLabels)
|
||||
.values(labelIds.map((labelId) => ({ resourceId, labelId })));
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSiteResourceLabels(
|
||||
siteResourceId: number,
|
||||
labelIds: number[],
|
||||
trx: Transaction
|
||||
) {
|
||||
await trx
|
||||
.delete(siteResourceLabels)
|
||||
.where(eq(siteResourceLabels.siteResourceId, siteResourceId));
|
||||
|
||||
if (labelIds.length > 0) {
|
||||
await trx
|
||||
.insert(siteResourceLabels)
|
||||
.values(
|
||||
labelIds.map((labelId) => ({ siteResourceId, labelId }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,23 +19,22 @@ import {
|
||||
import { sites } from "@server/db";
|
||||
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
|
||||
import { Config } from "./types";
|
||||
import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels";
|
||||
import logger from "@server/logger";
|
||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||
import { getNextAvailableAliasAddress } from "../ip";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
import { createCertificate } from "@server/routers/certificates/createCertificate";
|
||||
import { build } from "@server/build";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import next from "next";
|
||||
import { LimitId } from "../billing";
|
||||
import { usageService } from "../billing/usageService";
|
||||
import { syncInferenceAiConfig } from "./aiProviders";
|
||||
import { syncAiBudgets } from "./aiBudgets";
|
||||
|
||||
async function getDomainForSiteResource(
|
||||
siteResourceId: number | undefined,
|
||||
fullDomain: string,
|
||||
orgId: string,
|
||||
isInference: boolean,
|
||||
trx: Transaction
|
||||
): Promise<{ subdomain: string | null; domainId: string }> {
|
||||
const [fullDomainExists] = await trx
|
||||
@@ -45,6 +44,11 @@ async function getDomainForSiteResource(
|
||||
and(
|
||||
eq(siteResources.fullDomain, fullDomain),
|
||||
eq(siteResources.orgId, orgId),
|
||||
// exclude looking at the ones on exit nodes if this is an inference resource,
|
||||
// and vice versa, so inference and non-inference resources can share a full-domain
|
||||
isInference
|
||||
? ne(siteResources.mode, "inference")
|
||||
: eq(siteResources.mode, "inference"),
|
||||
siteResourceId
|
||||
? ne(siteResources.siteResourceId, siteResourceId)
|
||||
: isNotNull(siteResources.siteResourceId)
|
||||
@@ -122,30 +126,6 @@ export async function updatePrivateResources(
|
||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||
config["client-resources"]
|
||||
)) {
|
||||
if (resourceData.mode === "http") {
|
||||
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
if (!hasHttpFeature) {
|
||||
throw new Error(
|
||||
"HTTP private resources are not included in your current plan. Please upgrade."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceData.mode === "ssh") {
|
||||
const hasSshFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
if (!hasSshFeature) {
|
||||
throw new Error(
|
||||
"SSH private resources are not included in your current plan. Please upgrade."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [existingResource] = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
@@ -201,60 +181,109 @@ export async function updatePrivateResources(
|
||||
}
|
||||
}
|
||||
|
||||
let resourceStatusFromSite: "approved" | "pending" = "approved";
|
||||
if (siteId && allSites.length === 0) {
|
||||
// only add if there are not provided sites
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
const [siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.select({ siteId: sites.siteId, status: sites.status })
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
if (siteSingle) {
|
||||
allSites.push(siteSingle);
|
||||
}
|
||||
resourceStatusFromSite = siteSingle.status ?? "approved";
|
||||
}
|
||||
|
||||
if (allSites.length === 0) {
|
||||
if (resourceData.mode !== "inference" && allSites.length === 0) {
|
||||
throw new Error(
|
||||
`No valid sites found for private private resource ${resourceNiceId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
const resourceEnabled =
|
||||
resourceData.enabled == undefined || resourceData.enabled == null
|
||||
? true
|
||||
: resourceStatusFromSite === "pending"
|
||||
? false
|
||||
: resourceData.enabled;
|
||||
|
||||
const resourceSsl =
|
||||
resourceData.mode === "inference" || resourceData.mode === "http"
|
||||
? resourceData.ssl == undefined || resourceData.ssl == null
|
||||
? true
|
||||
: resourceData.ssl
|
||||
: resourceData.ssl;
|
||||
|
||||
if (existingResource) {
|
||||
let domainInfo:
|
||||
| { subdomain: string | null; domainId: string }
|
||||
| undefined;
|
||||
if (resourceData["full-domain"] && resourceData.mode === "http") {
|
||||
if (
|
||||
resourceData["full-domain"] &&
|
||||
(resourceData.mode === "http" ||
|
||||
resourceData.mode === "inference")
|
||||
) {
|
||||
domainInfo = await getDomainForSiteResource(
|
||||
existingResource.siteResourceId,
|
||||
resourceData["full-domain"],
|
||||
orgId,
|
||||
resourceData.mode === "inference",
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceData.alias) {
|
||||
const [aliasConflict] = await trx
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.alias, resourceData.alias),
|
||||
ne(
|
||||
siteResources.siteResourceId,
|
||||
existingResource.siteResourceId
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (aliasConflict) {
|
||||
throw new Error(
|
||||
`Alias ${resourceData.alias} already in use by another site resource in org ${orgId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isInference = resourceData.mode === "inference";
|
||||
|
||||
// Update existing resource
|
||||
const [updatedResource] = await trx
|
||||
.update(siteResources)
|
||||
.set({
|
||||
name: resourceData.name || resourceNiceId,
|
||||
mode: resourceData.mode,
|
||||
ssl: resourceData.ssl,
|
||||
ssl: resourceSsl,
|
||||
scheme: resourceData.scheme,
|
||||
destination: resourceData.destination,
|
||||
destinationPort: resourceData["destination-port"],
|
||||
enabled: true, // hardcoded for now
|
||||
// enabled: resourceData.enabled ?? true,
|
||||
enabled: resourceEnabled,
|
||||
alias: resourceData.alias || null,
|
||||
disableIcmp:
|
||||
resourceData["disable-icmp"] ||
|
||||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
|
||||
(resourceData.mode == "http" || isInference
|
||||
? true
|
||||
: false), // default to true for http/inference resources, otherwise false
|
||||
tcpPortRangeString:
|
||||
resourceData.mode == "http"
|
||||
resourceData.mode == "http" || isInference
|
||||
? "443,80"
|
||||
: resourceData["tcp-ports"],
|
||||
udpPortRangeString:
|
||||
resourceData.mode == "http"
|
||||
resourceData.mode == "http" || isInference
|
||||
? ""
|
||||
: resourceData["udp-ports"],
|
||||
fullDomain: resourceData["full-domain"] || null,
|
||||
@@ -263,7 +292,10 @@ export async function updatePrivateResources(
|
||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||
authDaemonMode:
|
||||
resourceData["auth-daemon"]?.mode || "native",
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||
status: resourceStatusFromSite,
|
||||
networkId: isInference ? null : undefined,
|
||||
requiresExitNodeConnection: isInference
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -275,7 +307,19 @@ export async function updatePrivateResources(
|
||||
|
||||
const siteResourceId = existingResource.siteResourceId;
|
||||
|
||||
if (updatedResource.networkId) {
|
||||
if (isInference) {
|
||||
// inference resources are not attached to any site network
|
||||
if (existingResource.networkId) {
|
||||
await trx
|
||||
.delete(siteNetworks)
|
||||
.where(
|
||||
eq(
|
||||
siteNetworks.networkId,
|
||||
existingResource.networkId
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (updatedResource.networkId) {
|
||||
await trx
|
||||
.delete(siteNetworks)
|
||||
.where(
|
||||
@@ -290,6 +334,28 @@ export async function updatePrivateResources(
|
||||
}
|
||||
}
|
||||
|
||||
await syncInferenceAiConfig({
|
||||
orgId,
|
||||
trx,
|
||||
mode: resourceData.mode,
|
||||
scope: "site",
|
||||
siteResourceId,
|
||||
providers: resourceData["ai-providers"].map((p) => ({
|
||||
provider: p.provider,
|
||||
accessMode: p["access-mode"],
|
||||
enabled: p.enabled,
|
||||
models: p.models
|
||||
}))
|
||||
});
|
||||
|
||||
await syncAiBudgets({
|
||||
orgId,
|
||||
trx,
|
||||
scope: "site",
|
||||
siteResourceId,
|
||||
budgets: resourceData["ai-budget"]
|
||||
});
|
||||
|
||||
await trx
|
||||
.delete(clientSiteResources)
|
||||
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
|
||||
@@ -412,6 +478,13 @@ export async function updatePrivateResources(
|
||||
);
|
||||
}
|
||||
|
||||
const labelIds = await getOrCreateLabelIds(
|
||||
orgId,
|
||||
resourceData.labels,
|
||||
trx
|
||||
);
|
||||
await syncSiteResourceLabels(siteResourceId, labelIds, trx);
|
||||
|
||||
results.push({
|
||||
newSiteResource: updatedResource,
|
||||
oldSiteResource: existingResource,
|
||||
@@ -462,25 +535,55 @@ export async function updatePrivateResources(
|
||||
releaseAliasLock = release;
|
||||
}
|
||||
|
||||
const isInference = resourceData.mode === "inference";
|
||||
|
||||
let domainInfo:
|
||||
| { subdomain: string | null; domainId: string }
|
||||
| undefined;
|
||||
if (resourceData["full-domain"] && resourceData.mode === "http") {
|
||||
if (
|
||||
resourceData["full-domain"] &&
|
||||
(resourceData.mode === "http" || isInference)
|
||||
) {
|
||||
domainInfo = await getDomainForSiteResource(
|
||||
undefined,
|
||||
resourceData["full-domain"],
|
||||
orgId,
|
||||
isInference,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
const [network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
if (resourceData.alias) {
|
||||
const [aliasConflict] = await trx
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.alias, resourceData.alias)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (aliasConflict) {
|
||||
throw new Error(
|
||||
`Alias ${resourceData.alias} already in use by another site resource in org ${orgId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let network: typeof networks.$inferSelect | undefined;
|
||||
if (!isInference) {
|
||||
[network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
// Create new resource
|
||||
const [newResource] = await trx
|
||||
@@ -488,27 +591,28 @@ export async function updatePrivateResources(
|
||||
.values({
|
||||
orgId: orgId,
|
||||
niceId: resourceNiceId,
|
||||
networkId: network.networkId,
|
||||
defaultNetworkId: network.networkId,
|
||||
networkId: network ? network.networkId : null,
|
||||
defaultNetworkId: network ? network.networkId : null,
|
||||
name: resourceData.name || resourceNiceId,
|
||||
mode: resourceData.mode,
|
||||
ssl: resourceData.ssl,
|
||||
ssl: resourceSsl,
|
||||
scheme: resourceData.scheme,
|
||||
destination: resourceData.destination,
|
||||
destinationPort: resourceData["destination-port"],
|
||||
enabled: true, // hardcoded for now
|
||||
// enabled: resourceData.enabled ?? true,
|
||||
enabled: resourceEnabled,
|
||||
alias: resourceData.alias || null,
|
||||
aliasAddress: aliasAddress,
|
||||
disableIcmp:
|
||||
resourceData["disable-icmp"] ||
|
||||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
|
||||
(resourceData.mode == "http" || isInference
|
||||
? true
|
||||
: false), // default to true for http/inference resources, otherwise false
|
||||
tcpPortRangeString:
|
||||
resourceData.mode == "http"
|
||||
resourceData.mode == "http" || isInference
|
||||
? "443,80"
|
||||
: resourceData["tcp-ports"],
|
||||
udpPortRangeString:
|
||||
resourceData.mode == "http"
|
||||
resourceData.mode == "http" || isInference
|
||||
? ""
|
||||
: resourceData["udp-ports"],
|
||||
fullDomain: resourceData["full-domain"] || null,
|
||||
@@ -517,7 +621,9 @@ export async function updatePrivateResources(
|
||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||
authDaemonMode:
|
||||
resourceData["auth-daemon"]?.mode || "native",
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||
status: resourceStatusFromSite,
|
||||
requiresExitNodeConnection: isInference
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -525,13 +631,37 @@ export async function updatePrivateResources(
|
||||
|
||||
const siteResourceId = newResource.siteResourceId;
|
||||
|
||||
for (const site of allSites) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: site.siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
if (network) {
|
||||
for (const site of allSites) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: site.siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await syncInferenceAiConfig({
|
||||
orgId,
|
||||
trx,
|
||||
mode: resourceData.mode,
|
||||
scope: "site",
|
||||
siteResourceId,
|
||||
providers: resourceData["ai-providers"].map((p) => ({
|
||||
provider: p.provider,
|
||||
accessMode: p["access-mode"],
|
||||
enabled: p.enabled,
|
||||
models: p.models
|
||||
}))
|
||||
});
|
||||
|
||||
await syncAiBudgets({
|
||||
orgId,
|
||||
trx,
|
||||
scope: "site",
|
||||
siteResourceId,
|
||||
budgets: resourceData["ai-budget"]
|
||||
});
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -645,6 +775,13 @@ export async function updatePrivateResources(
|
||||
|
||||
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
|
||||
|
||||
const labelIds = await getOrCreateLabelIds(
|
||||
orgId,
|
||||
resourceData.labels,
|
||||
trx
|
||||
);
|
||||
await syncSiteResourceLabels(siteResourceId, labelIds, trx);
|
||||
|
||||
results.push({
|
||||
newSiteResource: newResource,
|
||||
newSites: allSites,
|
||||
|
||||
@@ -1,61 +1,60 @@
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { createCertificate } from "@server/routers/certificates/createCertificate";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
domains,
|
||||
domainNamespaces,
|
||||
domains,
|
||||
orgDomains,
|
||||
Resource,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resourcePolicyRules,
|
||||
resourcePolicyWhiteList,
|
||||
resourceRules,
|
||||
resources,
|
||||
resourceWhitelist,
|
||||
roleActions,
|
||||
rolePolicies,
|
||||
roleResources,
|
||||
roles,
|
||||
Site,
|
||||
sites,
|
||||
Target,
|
||||
TargetHealthCheck,
|
||||
targetHealthCheck,
|
||||
targets,
|
||||
Transaction,
|
||||
userOrgs,
|
||||
userPolicies,
|
||||
userResources,
|
||||
users,
|
||||
resourcePolicies,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyRules,
|
||||
resourcePolicyWhiteList,
|
||||
rolePolicies,
|
||||
userPolicies
|
||||
type ResourceRule
|
||||
} from "@server/db";
|
||||
import { resources, targets, sites } from "@server/db";
|
||||
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm";
|
||||
import {
|
||||
Config,
|
||||
ConfigSchema,
|
||||
isTargetsOnlyResource,
|
||||
TargetData
|
||||
} from "./types";
|
||||
import logger from "@server/logger";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { pickPort } from "@server/routers/target/helpers";
|
||||
import { resourcePassword } from "@server/db";
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
import { isValidRegionId } from "@server/db/regions";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||
import { build } from "@server/build";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import serverConfig from "@server/lib/config";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import next from "next";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||
import { pickPort } from "@server/routers/target/helpers";
|
||||
import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
import { Config, isTargetsOnlyResource, TargetData } from "./types";
|
||||
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
|
||||
import { LimitId } from "../billing";
|
||||
import { usageService } from "../billing/usageService";
|
||||
import { syncInferenceAiConfig } from "./aiProviders";
|
||||
import { syncAiBudgets } from "./aiBudgets";
|
||||
|
||||
export type PublicResourcesResults = {
|
||||
proxyResource: Resource;
|
||||
@@ -76,19 +75,40 @@ export async function updatePublicResources(
|
||||
)) {
|
||||
const targetsToUpdate: Target[] = [];
|
||||
const healthchecksToUpdate: TargetHealthCheck[] = [];
|
||||
|
||||
let resource: Resource;
|
||||
let resourceStatusFromSite: "approved" | "pending" = "approved";
|
||||
let providedSite: Partial<Site> | undefined;
|
||||
if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[providedSite] = await trx
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
type: sites.type,
|
||||
status: sites.status
|
||||
})
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
resourceStatusFromSite = providedSite?.status ?? "approved";
|
||||
}
|
||||
|
||||
async function createTarget( // reusable function to create a target
|
||||
resourceId: number,
|
||||
targetData: TargetData
|
||||
) {
|
||||
const targetSiteId = targetData.site;
|
||||
let site;
|
||||
let site: Partial<Site> | undefined;
|
||||
|
||||
if (targetSiteId) {
|
||||
// Look up site by niceId
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId, type: sites.type })
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
type: sites.type,
|
||||
status: sites.status
|
||||
})
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
@@ -97,15 +117,9 @@ export async function updatePublicResources(
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
} else if (siteId && providedSite) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId, type: sites.type })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
site = providedSite;
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
}
|
||||
@@ -141,7 +155,7 @@ export async function updatePublicResources(
|
||||
.insert(targets)
|
||||
.values({
|
||||
resourceId: resourceId,
|
||||
siteId: site.siteId,
|
||||
siteId: site.siteId!,
|
||||
ip: targetData.hostname,
|
||||
mode: resourceData.mode as Target["mode"],
|
||||
method: targetData.method,
|
||||
@@ -174,7 +188,7 @@ export async function updatePublicResources(
|
||||
.insert(targetHealthCheck)
|
||||
.values({
|
||||
name: `${targetData.hostname}:${targetData.port}`,
|
||||
siteId: site.siteId,
|
||||
siteId: site.siteId!,
|
||||
targetId: newTarget.targetId,
|
||||
orgId: orgId,
|
||||
hcEnabled: healthcheckData?.enabled || false,
|
||||
@@ -232,7 +246,10 @@ export async function updatePublicResources(
|
||||
const resourceEnabled =
|
||||
resourceData.enabled == undefined || resourceData.enabled == null
|
||||
? true
|
||||
: resourceData.enabled;
|
||||
: resourceStatusFromSite === "pending"
|
||||
? false
|
||||
: resourceData.enabled;
|
||||
|
||||
const resourceSsl =
|
||||
resourceData.ssl == undefined || resourceData.ssl == null
|
||||
? true
|
||||
@@ -242,18 +259,6 @@ export async function updatePublicResources(
|
||||
headers = JSON.stringify(resourceData.headers);
|
||||
}
|
||||
|
||||
if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.advancedPublicResources
|
||||
);
|
||||
if (!isLicensed) {
|
||||
throw new Error(
|
||||
"Your current subscription does not support browser gateway resources. Please upgrade to access this feature."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceData.policy) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
@@ -269,7 +274,9 @@ export async function updatePublicResources(
|
||||
if (existingResource) {
|
||||
let domain;
|
||||
if (
|
||||
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
||||
["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
) {
|
||||
if (resourceData["full-domain"]?.startsWith("*.")) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
@@ -287,6 +294,7 @@ export async function updatePublicResources(
|
||||
existingResource.resourceId,
|
||||
resourceData["full-domain"]!,
|
||||
orgId,
|
||||
resourceData.mode === "inference",
|
||||
trx
|
||||
);
|
||||
|
||||
@@ -308,7 +316,7 @@ export async function updatePublicResources(
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.maintencePage
|
||||
tierMatrix.maintenancePage
|
||||
);
|
||||
if (!isLicensed) {
|
||||
resourceData.maintenance = undefined;
|
||||
@@ -353,14 +361,22 @@ export async function updatePublicResources(
|
||||
name: resourceData.name || "Unnamed Resource",
|
||||
|
||||
mode: resourceData.mode,
|
||||
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
proxyPort: [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resourceData.mode || "")
|
||||
? null
|
||||
: resourceData["proxy-port"],
|
||||
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
fullDomain: [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resourceData.mode || "")
|
||||
? resourceData["full-domain"]
|
||||
: null,
|
||||
subdomain: domain ? domain.subdomain : null,
|
||||
@@ -408,7 +424,8 @@ export async function updatePublicResources(
|
||||
? (resourceData["proxy-protocol-version"] ??
|
||||
1)
|
||||
: 1,
|
||||
resourcePolicyId: sharedPolicy.resourcePolicyId
|
||||
resourcePolicyId: sharedPolicy.resourcePolicyId,
|
||||
status: resourceStatusFromSite
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -548,14 +565,23 @@ export async function updatePublicResources(
|
||||
.update(resources)
|
||||
.set({
|
||||
name: resourceData.name || "Unnamed Resource",
|
||||
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
mode: resourceData.mode,
|
||||
proxyPort: [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resourceData.mode || "")
|
||||
? null
|
||||
: resourceData["proxy-port"],
|
||||
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
fullDomain: [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resourceData.mode || "")
|
||||
? resourceData["full-domain"]
|
||||
: null,
|
||||
subdomain: domain ? domain.subdomain : null,
|
||||
@@ -592,7 +618,8 @@ export async function updatePublicResources(
|
||||
authDaemonPort:
|
||||
resourceData["auth-daemon"]?.port || 22123,
|
||||
resourcePolicyId: null,
|
||||
defaultResourcePolicyId: inlinePolicyId
|
||||
defaultResourcePolicyId: inlinePolicyId,
|
||||
status: resourceStatusFromSite
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -654,6 +681,30 @@ export async function updatePublicResources(
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
await syncInferenceAiConfig({
|
||||
orgId,
|
||||
trx,
|
||||
mode: resourceData.mode || "",
|
||||
scope: "public",
|
||||
resourceId: existingResource.resourceId,
|
||||
providers: (resourceData["ai-providers"] || []).map(
|
||||
(p) => ({
|
||||
provider: p.provider,
|
||||
accessMode: p["access-mode"],
|
||||
enabled: p.enabled,
|
||||
models: p.models
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
await syncAiBudgets({
|
||||
orgId,
|
||||
trx,
|
||||
scope: "public",
|
||||
resourceId: existingResource.resourceId,
|
||||
budgets: resourceData["ai-budget"] || []
|
||||
});
|
||||
}
|
||||
|
||||
const existingResourceTargets = await trx
|
||||
@@ -734,7 +785,7 @@ export async function updatePublicResources(
|
||||
: undefined),
|
||||
rewritePathType: targetData["rewrite-match"],
|
||||
priority: targetData.priority,
|
||||
mode: resourceData.mode
|
||||
mode: resourceData.mode as Target["mode"]
|
||||
})
|
||||
.where(eq(targets.targetId, existingTarget.targetId))
|
||||
.returning();
|
||||
@@ -909,7 +960,7 @@ export async function updatePublicResources(
|
||||
.update(resourceRules)
|
||||
.set({
|
||||
action: getRuleAction(rule.action),
|
||||
match: rule.match.toUpperCase(),
|
||||
match: rule.match.toUpperCase() as ResourceRule["match"],
|
||||
value: getRuleValue(
|
||||
rule.match.toUpperCase(),
|
||||
rule.value
|
||||
@@ -928,7 +979,7 @@ export async function updatePublicResources(
|
||||
await trx.insert(resourceRules).values({
|
||||
resourceId: existingResource.resourceId,
|
||||
action: getRuleAction(rule.action),
|
||||
match: rule.match.toUpperCase(),
|
||||
match: rule.match.toUpperCase() as ResourceRule["match"],
|
||||
value: getRuleValue(
|
||||
rule.match.toUpperCase(),
|
||||
rule.value
|
||||
@@ -1011,7 +1062,7 @@ export async function updatePublicResources(
|
||||
} else {
|
||||
// create a brand new resource
|
||||
|
||||
if (build == "saas") {
|
||||
if (build === "saas") {
|
||||
const usage = await usageService.getUsage(
|
||||
orgId,
|
||||
LimitId.PUBLIC_RESOURCES
|
||||
@@ -1039,7 +1090,9 @@ export async function updatePublicResources(
|
||||
|
||||
let domain;
|
||||
if (
|
||||
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
||||
["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
) {
|
||||
if (resourceData["full-domain"]?.startsWith("*.")) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
@@ -1057,6 +1110,7 @@ export async function updatePublicResources(
|
||||
undefined,
|
||||
resourceData["full-domain"]!,
|
||||
orgId,
|
||||
resourceData.mode === "inference",
|
||||
trx
|
||||
);
|
||||
|
||||
@@ -1069,7 +1123,7 @@ export async function updatePublicResources(
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.maintencePage
|
||||
tierMatrix.maintenancePage
|
||||
);
|
||||
if (!isLicensed) {
|
||||
resourceData.maintenance = undefined;
|
||||
@@ -1133,16 +1187,25 @@ export async function updatePublicResources(
|
||||
.values({
|
||||
orgId,
|
||||
niceId: resourceNiceId,
|
||||
status: resourceStatusFromSite,
|
||||
name: resourceData.name || "Unnamed Resource",
|
||||
mode: resourceData.mode,
|
||||
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
proxyPort: [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resourceData.mode || "")
|
||||
? null
|
||||
: resourceData["proxy-port"],
|
||||
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceData.mode || ""
|
||||
)
|
||||
fullDomain: [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resourceData.mode || "")
|
||||
? resourceData["full-domain"]
|
||||
: null,
|
||||
subdomain: domain ? domain.subdomain : null,
|
||||
@@ -1197,6 +1260,28 @@ export async function updatePublicResources(
|
||||
|
||||
resource = newResource;
|
||||
|
||||
await syncInferenceAiConfig({
|
||||
orgId,
|
||||
trx,
|
||||
mode: resourceData.mode || "",
|
||||
scope: "public",
|
||||
resourceId: newResource.resourceId,
|
||||
providers: (resourceData["ai-providers"] || []).map((p) => ({
|
||||
provider: p.provider,
|
||||
accessMode: p["access-mode"],
|
||||
enabled: p.enabled,
|
||||
models: p.models
|
||||
}))
|
||||
});
|
||||
|
||||
await syncAiBudgets({
|
||||
orgId,
|
||||
trx,
|
||||
scope: "public",
|
||||
resourceId: newResource.resourceId,
|
||||
budgets: resourceData["ai-budget"] || []
|
||||
});
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole.roleId,
|
||||
resourceId: newResource.resourceId
|
||||
@@ -1292,7 +1377,7 @@ export async function updatePublicResources(
|
||||
await trx.insert(resourceRules).values({
|
||||
resourceId: newResource.resourceId,
|
||||
action: getRuleAction(rule.action),
|
||||
match: rule.match.toUpperCase(),
|
||||
match: rule.match.toUpperCase() as ResourceRule["match"],
|
||||
value: getRuleValue(
|
||||
rule.match.toUpperCase(),
|
||||
rule.value
|
||||
@@ -1331,6 +1416,15 @@ export async function updatePublicResources(
|
||||
logger.debug(`Created resource ${newResource.resourceId}`);
|
||||
}
|
||||
|
||||
if (!isTargetsOnlyResource(resourceData)) {
|
||||
const labelIds = await getOrCreateLabelIds(
|
||||
orgId,
|
||||
resourceData.labels || [],
|
||||
trx
|
||||
);
|
||||
await syncResourceLabels(resource.resourceId, labelIds, trx);
|
||||
}
|
||||
|
||||
results.push({
|
||||
proxyResource: resource,
|
||||
targetsToUpdate,
|
||||
@@ -1355,7 +1449,7 @@ function getRuleAction(input: string) {
|
||||
|
||||
function getRuleValue(match: string, value: string) {
|
||||
// if the match is a country, uppercase the value
|
||||
if (match == "COUNTRY") {
|
||||
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
|
||||
return value.toUpperCase();
|
||||
}
|
||||
return value;
|
||||
@@ -2045,6 +2139,7 @@ export async function getDomain(
|
||||
resourceId: number | undefined,
|
||||
fullDomain: string,
|
||||
orgId: string,
|
||||
isInference: boolean,
|
||||
trx: Transaction
|
||||
) {
|
||||
const [fullDomainExists] = await trx
|
||||
@@ -2054,6 +2149,14 @@ export async function getDomain(
|
||||
and(
|
||||
eq(resources.fullDomain, fullDomain),
|
||||
eq(resources.orgId, orgId),
|
||||
// Inference resources route through the central AI gateway
|
||||
// rather than normal target-based proxying, so they're
|
||||
// allowed to share a full-domain with a non-inference
|
||||
// resource (and vice versa) - only conflicts within the
|
||||
// same routing category are rejected.
|
||||
isInference
|
||||
? ne(resources.mode, "inference")
|
||||
: eq(resources.mode, "inference"),
|
||||
resourceId
|
||||
? ne(resources.resourceId, resourceId)
|
||||
: isNotNull(resources.resourceId)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
idpOrg,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
@@ -20,6 +18,7 @@ import { Config, ResourcePolicyData } from "./types";
|
||||
import logger from "@server/logger";
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
|
||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
@@ -71,19 +70,13 @@ export async function updateResourcePolicies(
|
||||
|
||||
// Validate auto-login-idp if provided
|
||||
if (policyData["auto-login-idp"]) {
|
||||
const [provider] = await trx
|
||||
.select()
|
||||
.from(idp)
|
||||
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
||||
.where(
|
||||
and(
|
||||
eq(idp.idpId, policyData["auto-login-idp"]),
|
||||
eq(idpOrg.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const providerExists = await idpExistsForOrg(
|
||||
policyData["auto-login-idp"],
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
|
||||
if (!provider) {
|
||||
if (!providerExists) {
|
||||
throw new Error(
|
||||
`Identity provider not found for policy '${policyNiceId}' in this organization`
|
||||
);
|
||||
@@ -347,12 +340,13 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
|
||||
|
||||
function getRuleMatch(
|
||||
input: string
|
||||
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" {
|
||||
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
|
||||
return input.toUpperCase() as
|
||||
| "CIDR"
|
||||
| "IP"
|
||||
| "PATH"
|
||||
| "COUNTRY"
|
||||
| "COUNTRY_IS_NOT"
|
||||
| "ASN"
|
||||
| "REGION";
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
||||
import { isValidRegionId } from "@server/db/regions";
|
||||
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
aiBudgetEnforcementSchema,
|
||||
aiBudgetPeriodSchema,
|
||||
aiBudgetUnitSchema
|
||||
} from "@server/routers/aiBudget/validation";
|
||||
|
||||
const maxmindDbPath = config.getRawConfig().server.maxmind_db_path;
|
||||
const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path;
|
||||
@@ -28,7 +33,7 @@ export const TargetHealthCheckSchema = z.object({
|
||||
hostname: z.string(),
|
||||
port: z.int().min(1).max(65535),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
path: z.string().optional(),
|
||||
path: z.string().optional().default("/"),
|
||||
scheme: z.string().optional(),
|
||||
mode: z.string().default("http"),
|
||||
interval: z.int().default(30),
|
||||
@@ -183,6 +188,56 @@ export const HeaderSchema = z.object({
|
||||
value: z.string().min(1)
|
||||
});
|
||||
|
||||
export const AiProviderAttachmentSchema = z
|
||||
.object({
|
||||
provider: z.string().min(1),
|
||||
"access-mode": z
|
||||
.enum(["inherit", "select"])
|
||||
.optional()
|
||||
.default("inherit"),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
models: z.array(z.string()).optional().default([])
|
||||
})
|
||||
.refine(
|
||||
(provider) => {
|
||||
if (provider.models.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return provider["access-mode"] === "select";
|
||||
},
|
||||
{
|
||||
path: ["models"],
|
||||
error: "'models' can only be set on a provider with access-mode 'select'"
|
||||
}
|
||||
);
|
||||
|
||||
export const AiBudgetSchema = z.object({
|
||||
amount: z.number().positive(),
|
||||
unit: aiBudgetUnitSchema,
|
||||
period: aiBudgetPeriodSchema.optional().default("monthly"),
|
||||
enforcement: aiBudgetEnforcementSchema.optional().default("hard"),
|
||||
enabled: z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
const aiBudgetArraySchema = z.array(AiBudgetSchema).refine(
|
||||
(budgets) => {
|
||||
const keys = budgets.map((b) => `${b.unit}::${b.period}`);
|
||||
return keys.length === new Set(keys).size;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"'ai-budget' entries must not overlap: only one budget per unit/period combination is allowed"
|
||||
}
|
||||
);
|
||||
|
||||
// No default here: an object with only 'targets' set must remain
|
||||
// recognized as a targets-only resource by isTargetsOnlyResource().
|
||||
export const AiBudgetListSchema = aiBudgetArraySchema.optional();
|
||||
|
||||
export const AiBudgetListSchemaWithDefault = aiBudgetArraySchema
|
||||
.optional()
|
||||
.default([]);
|
||||
|
||||
export const AuthDaemonSchema = z
|
||||
.object({
|
||||
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
|
||||
@@ -209,7 +264,9 @@ export const PublicResourceSchema = z
|
||||
protocol: z
|
||||
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
|
||||
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
|
||||
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
|
||||
mode: z
|
||||
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc", "inference"])
|
||||
.optional(),
|
||||
policy: z.string().optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
@@ -225,7 +282,10 @@ export const PublicResourceSchema = z
|
||||
maintenance: MaintenanceSchema.optional(),
|
||||
"auth-daemon": AuthDaemonSchema.optional(),
|
||||
"proxy-protocol": z.boolean().optional(),
|
||||
"proxy-protocol-version": z.int().min(1).optional()
|
||||
"proxy-protocol-version": z.int().min(1).optional(),
|
||||
labels: z.array(z.string().min(1)).optional(),
|
||||
"ai-providers": z.array(AiProviderAttachmentSchema).optional(),
|
||||
"ai-budget": AiBudgetListSchema
|
||||
})
|
||||
.refine(
|
||||
(resource) => {
|
||||
@@ -314,11 +374,13 @@ export const PublicResourceSchema = z
|
||||
return true;
|
||||
}
|
||||
|
||||
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
|
||||
// If protocol/mode is http, ssh, rdp, vnc, or inference, it must have a full-domain
|
||||
const effectiveProtocol = resource.mode ?? resource.protocol;
|
||||
if (
|
||||
effectiveProtocol !== undefined &&
|
||||
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
|
||||
["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
effectiveProtocol
|
||||
)
|
||||
) {
|
||||
return (
|
||||
resource["full-domain"] !== undefined &&
|
||||
@@ -329,7 +391,43 @@ export const PublicResourceSchema = z
|
||||
},
|
||||
{
|
||||
path: ["full-domain"],
|
||||
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
|
||||
error: "When protocol is 'http', 'ssh', 'rdp', 'vnc', or 'inference', a 'full-domain' must be provided"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
if (isTargetsOnlyResource(resource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const effectiveMode = resource.mode ?? resource.protocol;
|
||||
if (effectiveMode !== "inference") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return resource.targets.every((target) => target == null);
|
||||
},
|
||||
{
|
||||
path: ["targets"],
|
||||
error: "When mode is 'inference', 'targets' must not be provided"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
if (isTargetsOnlyResource(resource)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const effectiveMode = resource.mode ?? resource.protocol;
|
||||
if (effectiveMode === "inference") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (resource["ai-providers"]?.length ?? 0) === 0;
|
||||
},
|
||||
{
|
||||
path: ["ai-providers"],
|
||||
error: "'ai-providers' can only be set when mode is 'inference'"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
@@ -463,14 +561,14 @@ export function isTargetsOnlyResource(resource: any): boolean {
|
||||
export const PrivateResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
|
||||
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
|
||||
sites: z.array(z.string()).optional().default([]),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
// proxyPort: z.int().positive().optional(),
|
||||
"destination-port": z.int().positive().optional(),
|
||||
destination: z.string().min(1).optional(),
|
||||
// enabled: z.boolean().default(true),
|
||||
enabled: z.boolean().default(true),
|
||||
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||
"disable-icmp": z.boolean().optional().default(false),
|
||||
@@ -493,16 +591,26 @@ export const PrivateResourceSchema = z
|
||||
}),
|
||||
users: z.array(z.string()).optional().default([]),
|
||||
machines: z.array(z.string()).optional().default([]),
|
||||
"auth-daemon": AuthDaemonSchema.optional()
|
||||
labels: z.array(z.string().min(1)).optional().default([]),
|
||||
"auth-daemon": AuthDaemonSchema.optional(),
|
||||
"ai-providers": z
|
||||
.array(AiProviderAttachmentSchema)
|
||||
.optional()
|
||||
.default([]),
|
||||
"ai-budget": AiBudgetListSchemaWithDefault
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// destination is optional only for ssh+native; required for everything else
|
||||
// destination is optional only for ssh+native or inference; required for everything else
|
||||
const isNativeSSH =
|
||||
data.mode === "ssh" &&
|
||||
(data["auth-daemon"] === undefined ||
|
||||
data["auth-daemon"].mode === "native");
|
||||
if (!isNativeSSH && !data.destination) {
|
||||
if (
|
||||
data.mode !== "inference" &&
|
||||
!isNativeSSH &&
|
||||
!data.destination
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -510,7 +618,19 @@ export const PrivateResourceSchema = z
|
||||
{
|
||||
path: ["destination"],
|
||||
message:
|
||||
"destination is required unless mode is 'ssh' with auth-daemon mode 'native'"
|
||||
"destination is required unless mode is 'ssh' with auth-daemon mode 'native', or mode is 'inference'"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode === "inference") {
|
||||
return true;
|
||||
}
|
||||
return (data["ai-providers"]?.length ?? 0) === 0;
|
||||
},
|
||||
{
|
||||
path: ["ai-providers"],
|
||||
error: "'ai-providers' can only be set when mode is 'inference'"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
@@ -632,7 +752,6 @@ export const ResourcePolicySchema = z.object({
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
.optional()
|
||||
.default([]),
|
||||
@@ -838,3 +957,4 @@ export type Target = z.infer<typeof TargetSchema>;
|
||||
export type Resource = z.infer<typeof PublicResourceSchema>;
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
|
||||
export type BlueprintAiBudget = z.infer<typeof AiBudgetSchema>;
|
||||
|
||||
@@ -10,12 +10,12 @@ export const localCache = new NodeCache({
|
||||
});
|
||||
|
||||
// Log cache statistics periodically for monitoring
|
||||
setInterval(() => {
|
||||
const stats = localCache.getStats();
|
||||
logger.debug(
|
||||
`Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
|
||||
);
|
||||
}, 300000); // Every 5 minutes
|
||||
// setInterval(() => {
|
||||
// const stats = localCache.getStats();
|
||||
// logger.debug(
|
||||
// `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
|
||||
// );
|
||||
// }, 300000); // Every 5 minutes
|
||||
|
||||
/**
|
||||
* Adaptive cache that uses Redis when available in multi-node environments,
|
||||
@@ -34,9 +34,9 @@ class AdaptiveCache {
|
||||
|
||||
// Use local cache as fallback or primary
|
||||
const success = localCache.set(key, value, effectiveTtl || 0);
|
||||
if (success) {
|
||||
logger.debug(`Set key in local cache: ${key}`);
|
||||
}
|
||||
// if (success) {
|
||||
// logger.debug(`Set key in local cache: ${key}`);
|
||||
// }
|
||||
return success;
|
||||
}
|
||||
|
||||
@@ -48,11 +48,11 @@ class AdaptiveCache {
|
||||
async get<T = any>(key: string): Promise<T | undefined> {
|
||||
// Use local cache as fallback or primary
|
||||
const value = localCache.get<T>(key);
|
||||
if (value !== undefined) {
|
||||
logger.debug(`Cache hit in local cache: ${key}`);
|
||||
} else {
|
||||
logger.debug(`Cache miss in local cache: ${key}`);
|
||||
}
|
||||
// if (value !== undefined) {
|
||||
// logger.debug(`Cache hit in local cache: ${key}`);
|
||||
// } else {
|
||||
// logger.debug(`Cache miss in local cache: ${key}`);
|
||||
// }
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -168,5 +168,5 @@ class AdaptiveCache {
|
||||
|
||||
// Export singleton instance
|
||||
export const cache = new AdaptiveCache();
|
||||
export const regionalCache = cache; // Alias for compatability with the private version
|
||||
export const regionalCache = cache; // Alias for compatibility with the private version
|
||||
export default cache;
|
||||
|
||||
@@ -339,19 +339,6 @@ export async function calculateUserClientsForOrgs(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get exit nodes for this org
|
||||
const exitNodesList = await getExitNodes(orgId);
|
||||
|
||||
if (exitNodesList.length === 0) {
|
||||
logger.warn(
|
||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const randomExitNode =
|
||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
||||
|
||||
// Get next available subnet
|
||||
const { value: newSubnet, release: releaseSubnetLock } =
|
||||
await getNextAvailableClientSubnet(orgId, trx);
|
||||
@@ -370,7 +357,6 @@ export async function calculateUserClientsForOrgs(
|
||||
const newClientData: InferInsertModel<typeof clients> = {
|
||||
userId,
|
||||
orgId: userOrg.orgId,
|
||||
exitNodeId: randomExitNode.exitNodeId,
|
||||
name: olm.name || "User Client",
|
||||
subnet: updatedSubnet,
|
||||
olmId: olm.olmId,
|
||||
|
||||
@@ -1,16 +1,226 @@
|
||||
import config from "@server/lib/config";
|
||||
import { certificates, db } from "@server/db";
|
||||
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
import { build } from "@server/build";
|
||||
|
||||
// Define the return type for clarity and type safety
|
||||
export type CertificateResult = {
|
||||
id: number;
|
||||
domain: string;
|
||||
queriedDomain: string; // The domain that was originally requested (may differ for wildcards)
|
||||
wildcard: boolean | null;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: number | null;
|
||||
updatedAt?: number | null;
|
||||
};
|
||||
|
||||
export async function getValidCertificatesForDomains(
|
||||
domains: Set<string>,
|
||||
useCache: boolean = true
|
||||
): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean | null;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: number | null;
|
||||
updatedAt?: number | null;
|
||||
}>
|
||||
> {
|
||||
return []; // stub
|
||||
): Promise<Array<CertificateResult>> {
|
||||
const finalResults: CertificateResult[] = [];
|
||||
const domainsToQuery = new Set<string>();
|
||||
|
||||
// 1. Check cache first if enabled
|
||||
if (useCache) {
|
||||
for (const domain of domains) {
|
||||
const cacheKey = `cert:${domain}`;
|
||||
const cachedCert = await cache.get<CertificateResult>(cacheKey);
|
||||
if (cachedCert) {
|
||||
finalResults.push(cachedCert); // Valid cache hit
|
||||
} else {
|
||||
// Also check for a wildcard cache entry covering this domain's parent
|
||||
const parts = domain.split(".");
|
||||
let wildcardHit = false;
|
||||
if (parts.length > 1) {
|
||||
const parentDomain = parts.slice(1).join(".");
|
||||
const wildcardCacheKey = `cert:*.${parentDomain}`;
|
||||
const cachedWildcard =
|
||||
await cache.get<CertificateResult>(wildcardCacheKey);
|
||||
if (cachedWildcard) {
|
||||
// Re-stamp queriedDomain so callers see the originally requested domain
|
||||
finalResults.push({
|
||||
...cachedWildcard,
|
||||
queriedDomain: domain
|
||||
});
|
||||
wildcardHit = true;
|
||||
}
|
||||
}
|
||||
if (!wildcardHit) {
|
||||
domainsToQuery.add(domain); // Cache miss or expired
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If caching is disabled, add all domains to the query set
|
||||
domains.forEach((d) => domainsToQuery.add(d));
|
||||
}
|
||||
|
||||
// 2. If all domains were resolved from the cache, return early
|
||||
if (domainsToQuery.size === 0) {
|
||||
const decryptedResults = decryptFinalResults(
|
||||
finalResults,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
// 3. Prepare domains for the database query
|
||||
const domainsToQueryArray = Array.from(domainsToQuery);
|
||||
const parentDomainsToQuery = new Set<string>();
|
||||
|
||||
domainsToQueryArray.forEach((domain) => {
|
||||
const parts = domain.split(".");
|
||||
// A wildcard can only match a domain with at least two parts (e.g., example.com)
|
||||
if (parts.length > 1) {
|
||||
parentDomainsToQuery.add(parts.slice(1).join("."));
|
||||
}
|
||||
});
|
||||
|
||||
const parentDomainsArray = Array.from(parentDomainsToQuery);
|
||||
|
||||
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
|
||||
const wildcardPrefixedArray =
|
||||
build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
|
||||
|
||||
// 4. Build and execute a single, efficient Drizzle query
|
||||
// This query fetches all potential exact and wildcard matches in one database round-trip.
|
||||
const potentialCerts = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.status, "valid"),
|
||||
isNotNull(certificates.certFile),
|
||||
isNotNull(certificates.keyFile),
|
||||
or(
|
||||
// Condition for exact matches on the requested domains
|
||||
inArray(certificates.domain, domainsToQueryArray),
|
||||
// Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com")
|
||||
parentDomainsArray.length > 0
|
||||
? and(
|
||||
inArray(certificates.domain, [
|
||||
...parentDomainsArray,
|
||||
...wildcardPrefixedArray
|
||||
]),
|
||||
eq(certificates.wildcard, true)
|
||||
)
|
||||
: // If there are no possible parent domains, this condition is false
|
||||
sql`false`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.")
|
||||
const normalizeWildcardDomain = (domain: string): string =>
|
||||
domain.startsWith("*.") ? domain.slice(2) : domain;
|
||||
|
||||
// 5. Process the database results, prioritizing exact matches over wildcards
|
||||
const exactMatches = new Map<string, (typeof potentialCerts)[0]>();
|
||||
const wildcardMatches = new Map<string, (typeof potentialCerts)[0]>();
|
||||
|
||||
for (const cert of potentialCerts) {
|
||||
if (cert.wildcard) {
|
||||
// Normalize to bare parent domain so lookups are consistent regardless of storage format
|
||||
wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert);
|
||||
} else {
|
||||
exactMatches.set(cert.domain, cert);
|
||||
}
|
||||
}
|
||||
|
||||
for (const domain of domainsToQuery) {
|
||||
let foundCert: (typeof potentialCerts)[0] | undefined = undefined;
|
||||
|
||||
// Priority 1: Check for an exact match (non-wildcard)
|
||||
if (exactMatches.has(domain)) {
|
||||
foundCert = exactMatches.get(domain);
|
||||
}
|
||||
// Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain
|
||||
else {
|
||||
const normalizedDomain = normalizeWildcardDomain(domain);
|
||||
if (wildcardMatches.has(normalizedDomain)) {
|
||||
foundCert = wildcardMatches.get(normalizedDomain);
|
||||
}
|
||||
// Priority 3: Check for a wildcard match on the parent domain
|
||||
else {
|
||||
const parts = normalizedDomain.split(".");
|
||||
if (parts.length > 1) {
|
||||
const parentDomain = parts.slice(1).join(".");
|
||||
if (wildcardMatches.has(parentDomain)) {
|
||||
foundCert = wildcardMatches.get(parentDomain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If a certificate was found, format it, add to results, and cache it
|
||||
if (foundCert) {
|
||||
logger.debug(
|
||||
`Creating result cert for ${domain} using cert from ${foundCert.domain}`
|
||||
);
|
||||
const resultCert: CertificateResult = {
|
||||
id: foundCert.certId,
|
||||
domain: foundCert.domain, // The actual domain of the cert record
|
||||
queriedDomain: domain, // The domain that was originally requested
|
||||
wildcard: foundCert.wildcard,
|
||||
certFile: foundCert.certFile,
|
||||
keyFile: foundCert.keyFile,
|
||||
expiresAt: foundCert.expiresAt,
|
||||
updatedAt: foundCert.updatedAt
|
||||
};
|
||||
|
||||
finalResults.push(resultCert);
|
||||
|
||||
// Add to cache for future requests, using the *requested domain* as the key
|
||||
if (useCache) {
|
||||
const cacheKey = `cert:${domain}`;
|
||||
await cache.set(cacheKey, resultCert, 180);
|
||||
|
||||
// Also cache wildcard certs under a pattern key so other subdomains
|
||||
// can find them without a DB round-trip
|
||||
if (resultCert.wildcard) {
|
||||
const normalizedCertDomain = normalizeWildcardDomain(
|
||||
resultCert.domain
|
||||
);
|
||||
const wildcardCacheKey = `cert:*.${normalizedCertDomain}`;
|
||||
await cache.set(wildcardCacheKey, resultCert, 180);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const decryptedResults = decryptFinalResults(
|
||||
finalResults,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
function decryptFinalResults(
|
||||
finalResults: CertificateResult[],
|
||||
secret: string
|
||||
): CertificateResult[] {
|
||||
const validCertsDecrypted = finalResults.map((cert) => {
|
||||
// Decrypt and save certificate file
|
||||
const decryptedCert = decrypt(
|
||||
cert.certFile!, // is not null from query
|
||||
secret
|
||||
);
|
||||
|
||||
// Decrypt and save key file
|
||||
const decryptedKey = decrypt(cert.keyFile!, secret);
|
||||
|
||||
// Return only the certificate data without org information
|
||||
return {
|
||||
...cert,
|
||||
certFile: decryptedCert,
|
||||
keyFile: decryptedKey
|
||||
};
|
||||
});
|
||||
|
||||
return validCertsDecrypted;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ import { cleanUpOldLogs as cleanUpOldAccessLogs } from "#dynamic/lib/logAccessAu
|
||||
import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/logActionAudit";
|
||||
import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit";
|
||||
import { cleanUpOldLogs as cleanUpOldConnectionLogs } from "#dynamic/routers/newt";
|
||||
import { cleanUpOldLogs as cleanUpOldAiSessionLogs } from "@server/routers/aiGateway/logAiSession";
|
||||
import { gt, or } from "drizzle-orm";
|
||||
import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export function initLogCleanupInterval() {
|
||||
if (build == "saas") { // skip log cleanup for saas builds
|
||||
if (build == "saas") {
|
||||
// skip log cleanup for saas builds
|
||||
return null;
|
||||
}
|
||||
return setInterval(
|
||||
@@ -23,7 +25,9 @@ export function initLogCleanupInterval() {
|
||||
settingsLogRetentionDaysRequest:
|
||||
orgs.settingsLogRetentionDaysRequest,
|
||||
settingsLogRetentionDaysConnection:
|
||||
orgs.settingsLogRetentionDaysConnection
|
||||
orgs.settingsLogRetentionDaysConnection,
|
||||
settingsLogRetentionDaysAISessions:
|
||||
orgs.settingsLogRetentionDaysAISessions
|
||||
})
|
||||
.from(orgs)
|
||||
.where(
|
||||
@@ -31,7 +35,8 @@ export function initLogCleanupInterval() {
|
||||
gt(orgs.settingsLogRetentionDaysAction, 0),
|
||||
gt(orgs.settingsLogRetentionDaysAccess, 0),
|
||||
gt(orgs.settingsLogRetentionDaysRequest, 0),
|
||||
gt(orgs.settingsLogRetentionDaysConnection, 0)
|
||||
gt(orgs.settingsLogRetentionDaysConnection, 0),
|
||||
gt(orgs.settingsLogRetentionDaysAISessions, 0)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -42,7 +47,8 @@ export function initLogCleanupInterval() {
|
||||
settingsLogRetentionDaysAction,
|
||||
settingsLogRetentionDaysAccess,
|
||||
settingsLogRetentionDaysRequest,
|
||||
settingsLogRetentionDaysConnection
|
||||
settingsLogRetentionDaysConnection,
|
||||
settingsLogRetentionDaysAISessions
|
||||
} = org;
|
||||
|
||||
if (settingsLogRetentionDaysAction > 0) {
|
||||
@@ -72,6 +78,13 @@ export function initLogCleanupInterval() {
|
||||
settingsLogRetentionDaysConnection
|
||||
);
|
||||
}
|
||||
|
||||
if (settingsLogRetentionDaysAISessions > 0) {
|
||||
await cleanUpOldAiSessionLogs(
|
||||
orgId,
|
||||
settingsLogRetentionDaysAISessions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await cleanUpOldFingerprintSnapshots(365);
|
||||
|
||||
@@ -18,3 +18,19 @@ export function canCompress(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Whether this newt client understands `tlsCertId` references into the
|
||||
// sync message's `certs` array, instead of requiring each target to carry
|
||||
// its own inline `tlsCert`/`tlsKey` PEM data. Bump the version floor here to
|
||||
// match whatever release first ships the newt-side support.
|
||||
export function supportsCertReferences(
|
||||
clientVersion: string | null | undefined
|
||||
): boolean {
|
||||
try {
|
||||
if (!clientVersion) return false;
|
||||
if (!semver.valid(clientVersion)) return false;
|
||||
return semver.gte(clientVersion, "1.16.0");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.20.0";
|
||||
export const APP_VERSION = "1.22.0";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
||||
@@ -93,6 +93,9 @@ export async function deleteOrgById(
|
||||
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
|
||||
}
|
||||
for (const client of orgClients) {
|
||||
if (client.exitNodeId && client.pubKey) {
|
||||
await deletePeer(client.exitNodeId, client.pubKey);
|
||||
}
|
||||
const [olm] = await trx
|
||||
.select()
|
||||
.from(olms)
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { removeTargets } from "@server/routers/newt/targets";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export type DeleteResourceResult = {
|
||||
deletedResource: Resource;
|
||||
@@ -66,13 +64,20 @@ export async function performDeleteResources(
|
||||
|
||||
const targetsByResourceId = new Map<number, Target[]>();
|
||||
for (const target of targetsToBeRemoved) {
|
||||
if (target.resourceId == null) {
|
||||
continue;
|
||||
}
|
||||
const existing = targetsByResourceId.get(target.resourceId) ?? [];
|
||||
existing.push(target);
|
||||
targetsByResourceId.set(target.resourceId, existing);
|
||||
}
|
||||
|
||||
const targetIdToResourceId = new Map(
|
||||
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
|
||||
targetsToBeRemoved.flatMap((target) =>
|
||||
target.resourceId == null
|
||||
? []
|
||||
: [[target.targetId, target.resourceId] as const]
|
||||
)
|
||||
);
|
||||
|
||||
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
|
||||
@@ -117,10 +122,10 @@ export async function runResourceDeleteSideEffects(
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
throw createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${target.siteId} not found`
|
||||
logger.debug(
|
||||
`Site with ID ${target.siteId} not found during resource delete side effects; skipping target removal`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (site.pubKey && site.type === "newt") {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import {
|
||||
db,
|
||||
resources,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
targets,
|
||||
@@ -32,9 +33,11 @@ export async function getResourceIdsForSite(
|
||||
const rows = await trx
|
||||
.selectDistinct({ resourceId: targets.resourceId })
|
||||
.from(targets)
|
||||
.where(eq(targets.siteId, siteId));
|
||||
.where(and(eq(targets.siteId, siteId), isNotNull(targets.resourceId)));
|
||||
|
||||
return rows.map((row) => row.resourceId);
|
||||
return rows
|
||||
.map((row) => row.resourceId)
|
||||
.filter((resourceId): resourceId is number => resourceId != null);
|
||||
}
|
||||
|
||||
export async function getSiteResourceIdsForSite(
|
||||
@@ -97,6 +100,64 @@ export function exceedsSiteAssociatedResourceDeleteLimit(
|
||||
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
|
||||
}
|
||||
|
||||
export async function getPendingResourceIdsForSite(
|
||||
siteId: number,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<number[]> {
|
||||
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
||||
if (resourceIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await trx
|
||||
.select({ resourceId: resources.resourceId })
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, resourceIds),
|
||||
eq(resources.status, "pending")
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.resourceId);
|
||||
}
|
||||
|
||||
export async function getPendingSiteResourceIdsForSite(
|
||||
siteId: number,
|
||||
orgId: string,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<number[]> {
|
||||
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
|
||||
if (siteResourceIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await trx
|
||||
.select({ siteResourceId: siteResources.siteResourceId })
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||
eq(siteResources.status, "pending")
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.siteResourceId);
|
||||
}
|
||||
|
||||
export async function getPendingAssociatedResourceCountForSite(
|
||||
siteId: number,
|
||||
orgId: string,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<number> {
|
||||
const [resourceIds, siteResourceIds] = await Promise.all([
|
||||
getPendingResourceIdsForSite(siteId, trx),
|
||||
getPendingSiteResourceIdsForSite(siteId, orgId, trx)
|
||||
]);
|
||||
|
||||
return resourceIds.length + siteResourceIds.length;
|
||||
}
|
||||
|
||||
export async function deleteAssociatedResourcesForSite(
|
||||
siteId: number,
|
||||
orgId: string,
|
||||
@@ -105,12 +166,32 @@ export async function deleteAssociatedResourcesForSite(
|
||||
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
||||
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
|
||||
|
||||
const [resources, siteResourcesDeleted] = await Promise.all([
|
||||
const [deletedResources, siteResourcesDeleted] = await Promise.all([
|
||||
performDeleteResources(resourceIds, trx),
|
||||
performDeleteSiteResources(siteResourceIds, trx)
|
||||
]);
|
||||
|
||||
return { resources, siteResources: siteResourcesDeleted };
|
||||
return { resources: deletedResources, siteResources: siteResourcesDeleted };
|
||||
}
|
||||
|
||||
export async function deletePendingAssociatedResourcesForSite(
|
||||
siteId: number,
|
||||
orgId: string,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
|
||||
const resourceIds = await getPendingResourceIdsForSite(siteId, trx);
|
||||
const siteResourceIds = await getPendingSiteResourceIdsForSite(
|
||||
siteId,
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
|
||||
const [deletedResources, siteResourcesDeleted] = await Promise.all([
|
||||
performDeleteResources(resourceIds, trx),
|
||||
performDeleteSiteResources(siteResourceIds, trx)
|
||||
]);
|
||||
|
||||
return { resources: deletedResources, siteResources: siteResourcesDeleted };
|
||||
}
|
||||
|
||||
export async function runDeleteSiteAssociatedResourcesSideEffects(
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { db, sites, clients } from "@server/db";
|
||||
import { and, eq, count } from "drizzle-orm";
|
||||
|
||||
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS)
|
||||
// higher = more desirable
|
||||
// like saying, this node has x% of its capacity left
|
||||
export async function calculateExitNodeWeight(
|
||||
exitNodeId: number,
|
||||
maxConnections: number | null | undefined
|
||||
): Promise<number | null> {
|
||||
if (maxConnections === null || maxConnections === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const [[siteConnections], [clientConnections]] = await Promise.all([
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(eq(sites.exitNodeId, exitNodeId), eq(sites.online, true))
|
||||
),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.exitNodeId, exitNodeId),
|
||||
eq(clients.online, true)
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
const currentConnections = siteConnections.count + clientConnections.count;
|
||||
|
||||
if (currentConnections >= maxConnections) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (maxConnections - currentConnections) / maxConnections;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { db, exitNodes, Transaction } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { ExitNodePingResult } from "@server/routers/newt";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function verifyExitNodeOrgAccess(
|
||||
@@ -52,6 +51,16 @@ export async function listExitNodes(
|
||||
return allExitNodes;
|
||||
}
|
||||
|
||||
export type ExitNodePingResult = {
|
||||
exitNodeId: number;
|
||||
latencyMs: number;
|
||||
weight: number;
|
||||
error?: string;
|
||||
exitNodeName: string;
|
||||
endpoint: string;
|
||||
wasPreviouslyConnected: boolean;
|
||||
};
|
||||
|
||||
export function selectBestExitNode(
|
||||
pingResults: ExitNodePingResult[]
|
||||
): ExitNodePingResult | null {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { db, ExitNode, Transaction, sites, clients } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||
import { lockManager } from "#dynamic/lib/lock";
|
||||
|
||||
export async function getUniqueSubnetForExitNode(
|
||||
exitNode: ExitNode,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<string | null> {
|
||||
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
|
||||
|
||||
return await lockManager.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
const [sitesQuery, clientsQuery] = await Promise.all([
|
||||
trx
|
||||
.select({ subnet: sites.exitNodeSubnet })
|
||||
.from(sites)
|
||||
.where(eq(sites.exitNodeId, exitNode.exitNodeId)),
|
||||
trx
|
||||
.select({ subnet: clients.exitNodeSubnet })
|
||||
.from(clients)
|
||||
.where(eq(clients.exitNodeId, exitNode.exitNodeId))
|
||||
]);
|
||||
|
||||
const blockSize = config.getRawConfig().gerbil.site_block_size;
|
||||
const subnets = [...sitesQuery, ...clientsQuery]
|
||||
.map((row) => row.subnet)
|
||||
.filter(
|
||||
(subnet): subnet is string =>
|
||||
!!subnet &&
|
||||
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
|
||||
);
|
||||
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
|
||||
|
||||
return findNextAvailableCidr(subnets, blockSize, exitNode.address);
|
||||
},
|
||||
5000 // 5 second lock TTL - subnet allocation should be quick
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,5 @@ export * from "./exitNodes";
|
||||
export * from "./exitNodeComms";
|
||||
export * from "./subnet";
|
||||
export * from "./getCurrentExitNodeId";
|
||||
export * from "./calculateExitNodeWeight";
|
||||
export * from "./getUniqueSubnetForExitNode";
|
||||
|
||||