cartethyia 2.5.2 → 2.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-C2orBsI2.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  3. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +1 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-BV6pxVQo.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-Bsan4679.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-CW-wPmJ6.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-0p8uX7vB.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  13. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js +1 -0
  14. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-BvzCx8Kr.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  17. package/dist/dashboard/assets/{ModelPicker-CWkTUtpX.js → ModelPicker-BY1aKEkP.js} +1 -1
  18. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  19. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +1 -0
  20. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  21. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +3 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js +1 -0
  24. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  25. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +7 -0
  26. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  27. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +1 -0
  28. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  29. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +1 -0
  30. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  31. package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-DDbMQsoo.js} +1 -1
  32. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  33. package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-CB3Z7VTf.js} +1 -1
  34. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  35. package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-D95UCPHI.js} +3 -3
  36. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  37. package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-Cnae9SWA.js} +13 -13
  38. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  39. package/dist/dashboard/assets/{api-keys-uNywYlfv.js → api-keys-VY68Mhd1.js} +1 -1
  40. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  41. package/dist/dashboard/assets/app-COlK95oi.css +2 -0
  42. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  43. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-Chi7pCnt.js} +1 -1
  44. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  45. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-D4pQapKj.js} +1 -1
  46. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  47. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-DH6cg-_B.js} +1 -1
  48. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  49. package/dist/dashboard/assets/{card--AtFedAq.js → card-BKqlUOe4.js} +1 -1
  50. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  51. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-fTgljTGA.js} +1 -1
  52. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  53. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-D5Ys_sM0.js} +1 -1
  54. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  55. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  56. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  57. package/dist/dashboard/assets/{dialog-BtpsJU98.js → dialog-BYj-8WuK.js} +1 -1
  58. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  59. package/dist/dashboard/assets/{drawer-WwwtzihF.js → drawer-CmXIZ6H1.js} +1 -1
  60. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  61. package/dist/dashboard/assets/entry-CBg5zeu8.js +1 -0
  62. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  63. package/dist/dashboard/assets/entry-CQBr6U9c.js +1 -0
  64. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CgdSAzEV.js +4 -0
  66. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  67. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-BlptL8Uf.js} +1 -1
  68. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  69. package/dist/dashboard/assets/{index-DCBtROoK.js → index-js6TJvdr.js} +2 -2
  70. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  71. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-DUWDSmDV.js} +1 -1
  72. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  73. package/dist/dashboard/assets/{input-BG41et9G.js → input-LVjgZq4f.js} +1 -1
  74. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  75. package/dist/dashboard/assets/{layout-C8mer_Ak.js → layout-CPfxpsfy.js} +1 -1
  76. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  77. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-DupvLfDx.js} +1 -1
  78. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  79. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-C4pEJUqI.js} +1 -1
  80. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  81. package/dist/dashboard/assets/model-bans-BRiuufkB.js +1 -0
  82. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  83. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-B4ROwk8L.js} +1 -1
  84. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  85. package/dist/dashboard/assets/numbers-YNcHvRY7.js +2 -0
  86. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  87. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-gmjzC3RS.js} +1 -1
  88. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js.gz → query-keys-gmjzC3RS.js.gz} +0 -0
  89. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-C7tBT5o-.js} +1 -1
  90. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  91. package/dist/dashboard/assets/{select-DsX5QT8K.js → select-WUCXorXw.js} +2 -2
  92. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  93. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-C_zKl1GS.js} +1 -1
  94. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  95. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-_3O0F8Xn.js} +1 -1
  96. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  97. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-BgTxHcJS.js} +1 -1
  98. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  99. package/dist/dashboard/assets/switch-DEjTtOWf.js +1 -0
  100. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  101. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  102. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-BWSC0UAS.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  105. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  106. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  107. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  108. package/dist/dashboard/index.html +1 -1
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +54 -54
  111. package/package.json +1 -1
  112. package/scripts/cli/autostart.ts +237 -0
  113. package/scripts/cli/command/console-menu.ts +244 -246
  114. package/scripts/cli/command/tray.ts +208 -204
  115. package/scripts/cli/command/update.ts +27 -10
  116. package/scripts/cli/main.ts +28 -1
  117. package/src/boot/app.ts +693 -693
  118. package/src/boot/dependencies.ts +646 -616
  119. package/src/config/spec.ts +81 -2
  120. package/src/config/telemetry.ts +46 -0
  121. package/src/console/api-keys/bucket-spend.ts +15 -4
  122. package/src/console/backup/contracts.ts +15 -2
  123. package/src/console/backup/routes.ts +52 -1
  124. package/src/console/backup/service.ts +60 -4
  125. package/src/console/console-router.ts +8 -0
  126. package/src/console/data-management/contracts.ts +93 -0
  127. package/src/console/data-management/operations.ts +229 -0
  128. package/src/console/data-management/routes.ts +104 -0
  129. package/src/console/domain-registration.ts +39 -0
  130. package/src/console/observability/contracts.ts +524 -485
  131. package/src/console/observability/store.ts +1115 -1018
  132. package/src/console/providers/catalog/contracts.ts +6 -0
  133. package/src/console/providers/catalog/provider-operations.ts +20 -0
  134. package/src/console/providers/catalog/routes.ts +7 -0
  135. package/src/console/providers/catalog/store.ts +22 -1
  136. package/src/console/quota/account-quota-shared.ts +52 -39
  137. package/src/console/quota/refresh.ts +441 -429
  138. package/src/console/routing/pools/import-parse.ts +206 -0
  139. package/src/console/routing/pools/operations.ts +543 -501
  140. package/src/console/routing/pools/routes.ts +181 -164
  141. package/src/console/settings/contracts.ts +37 -0
  142. package/src/console/settings/store.ts +51 -0
  143. package/src/console/share/share-router.ts +6 -9
  144. package/src/console/updates/routes.ts +16 -0
  145. package/src/main.ts +10 -0
  146. package/src/observability/event-compaction.ts +315 -0
  147. package/src/observability/event-day-index.ts +442 -0
  148. package/src/observability/event-log.ts +152 -3
  149. package/src/observability/payload-capture.ts +373 -344
  150. package/src/observability/payload-store.ts +440 -366
  151. package/src/observability/telemetry-status.ts +28 -16
  152. package/src/persistence/health-event-flush.ts +36 -0
  153. package/src/persistence/schema.ts +1402 -1360
  154. package/src/persistence/stores/api-key-store.ts +6 -0
  155. package/src/persistence/stores/share-store.ts +583 -556
  156. package/src/persistence/stores/telemetry-store.ts +237 -214
  157. package/src/protocol/codecs.ts +1 -2
  158. package/src/protocol/image-clamp.ts +19 -1
  159. package/src/protocol/registry.ts +8 -0
  160. package/src/protocol/request/gemini.ts +9 -2
  161. package/src/protocol/request/messages.ts +14 -2
  162. package/src/protocol/response/gemini.ts +18 -4
  163. package/src/providers/adapters/compatible-adapter.ts +3 -0
  164. package/src/providers/bundled-discovery-providers.ts +122 -119
  165. package/src/providers/configured-openai-providers.ts +136 -134
  166. package/src/providers/default-registry.ts +5 -4
  167. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  168. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  169. package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
  170. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
  171. package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
  172. package/src/providers/integrations/cline/cline-quota.ts +132 -128
  173. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  174. package/src/providers/integrations/codex/codex.ts +2 -29
  175. package/src/providers/integrations/cursor/cursor-quota.ts +310 -0
  176. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  177. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  178. package/src/providers/integrations/cursor/cursor.ts +1485 -960
  179. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  180. package/src/providers/integrations/factory/factory.ts +8 -1
  181. package/src/providers/integrations/gemini.ts +6 -1
  182. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  183. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  184. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  185. package/src/providers/integrations/opencode/provider.ts +531 -532
  186. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  187. package/src/providers/integrations/tokenharbor.ts +2 -2
  188. package/src/providers/integrations/trae/trae-catalog.ts +5 -3
  189. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  190. package/src/providers/operations/account-health-service.ts +1523 -1457
  191. package/src/providers/operations/client-versions.ts +9 -2
  192. package/src/providers/provider-contracts.ts +7 -0
  193. package/src/providers/provider-metadata.ts +13 -1
  194. package/src/providers/quota/quota-support.ts +8 -0
  195. package/src/security/api-key-auth.ts +504 -494
  196. package/src/transport/dispatch/attempt-finalize.ts +46 -5
  197. package/src/transport/dispatch/generation.ts +6 -0
  198. package/src/transport/dispatch/proxy-request.ts +33 -1
  199. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  200. package/src/transport/dispatch/streaming/stream.ts +30 -2
  201. package/src/transport/dispatch/upstream.ts +9 -0
  202. package/src/transport/request/preparer.ts +7 -6
  203. package/src/transport/request/state.ts +9 -0
  204. package/src/transport/request/tenant-preferences.ts +60 -0
  205. package/src/transport/routing/route-catalog.ts +854 -799
  206. package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
  207. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js +0 -1
  208. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
  209. package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
  210. package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
  211. package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
  212. package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
  213. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js +0 -1
  214. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
  215. package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
  216. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  217. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js +0 -1
  218. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
  219. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
  220. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
  221. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js +0 -1
  222. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
  223. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js +0 -7
  224. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
  225. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js +0 -1
  226. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
  227. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
  228. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
  229. package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
  230. package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
  231. package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
  232. package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
  233. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  234. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  235. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  236. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  237. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  238. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  239. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  240. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  241. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  242. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  243. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  244. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  245. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  246. package/dist/dashboard/assets/entry-Dj9AR0gY.js +0 -4
  247. package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
  248. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  249. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  250. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  251. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  252. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  253. package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
  254. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  255. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  256. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  257. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  258. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  259. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  260. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  261. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  262. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  263. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  264. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  265. package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
  266. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  267. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  268. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  269. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  270. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  271. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  272. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  273. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  274. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  275. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  276. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-DCQZRmHY.js} +0 -0
  277. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-DCQZRmHY.js.gz} +0 -0
@@ -1,556 +1,583 @@
1
- import { createHash, randomBytes, randomInt } from "node:crypto";
2
-
3
- // Share-link persistence and atomic child-key issuance. The hash is the lookup
4
- // key; the bearer token is retained encrypted so the console can re-display a
5
- // stable link, and is never selected by a public lookup.
6
-
7
- import { and, desc, eq, isNull, sql, or,} from "drizzle-orm";
8
- import type { CartethyiaDatabase } from "../db-handle";
9
- import { lockRows } from "../dialect";
10
- import { apiKeys, shareLinks, sharePopups, type ShareLinkKind, type ApiKeyModelAccessMode } from "../schema";
11
-
12
- /** Hashes a share bearer token for storage and lookup. */
13
- /**
14
- * A fresh public slug for a share page.
15
- *
16
- * Held in the clear and looked up by index, so it must be unguessable on its
17
- * own: the same 256 bits of entropy as the bearer token, base64url-encoded.
18
- */
19
- export function generateShareSlug(): string {
20
- return randomBytes(32).toString("base64url");
21
- }
22
-
23
- export function hashShareToken(token: string): string {
24
- return createHash("sha256").update(token, "utf8").digest("hex");
25
- }
26
-
27
- /** Newly created share-link metadata. */
28
- export interface ShareLinkRecord {
29
- readonly id: string;
30
- readonly apiKeyId: string;
31
- readonly kind: ShareLinkKind;
32
- readonly createdAt: Date;
33
- readonly expiresAt: Date | null;
34
- }
35
-
36
- /**
37
- * Console-facing view of one link. The caller supplies the decrypted token;
38
- * this record itself never reads the token column.
39
- */
40
- export interface ShareLinkSummary {
41
- readonly id: string;
42
- readonly apiKeyId: string;
43
- readonly kind: ShareLinkKind;
44
- readonly active: boolean;
45
- readonly createdAt: Date;
46
- readonly expiresAt: Date | null;
47
- readonly lastViewedAt: Date | null;
48
- }
49
-
50
- /** A stable link's encrypted token, or null when it predates token retention. */
51
- export interface ShareLinkToken {
52
- readonly id: string;
53
- readonly apiKeyId: string;
54
- readonly kind: ShareLinkKind;
55
- readonly expiresAt: Date | null;
56
- readonly tokenEncrypted: Buffer | null;
57
- /** Public address fragment, held in the clear; null on pre-migration rows. */
58
- readonly slug: string | null;
59
- }
60
-
61
- /**
62
- * Policy fields a share recipient sees, whichever kind of link carried them.
63
- * Both resolutions project the same columns, so the public payload and the
64
- * model filter read one shape instead of two near-identical ones.
65
- */
66
- export interface ShareLinkPolicy {
67
- readonly id: string;
68
- readonly tenantId: string;
69
- readonly name: string;
70
- readonly keyPrefix: string | null;
71
- readonly requestsPerMinute: number | null;
72
- readonly dailyTokenLimit: number | null;
73
- readonly monthlyTokenLimit: number | null;
74
- readonly lifetimeTokenBudget: number | null;
75
- readonly maxConcurrentRequests: number | null;
76
- readonly modelAccessMode: ApiKeyModelAccessMode | null;
77
- readonly modelList: readonly string[] | null;
78
- readonly modelPrefix: string | null;
79
- readonly notesTitle: string | null;
80
- readonly notesSubtitle: string | null;
81
- readonly notesBody: string | null;
82
- readonly sharePopupEnabled: boolean;
83
- /** Uploaded popup art; the share page fetches it from its own image route. */
84
- readonly sharePopupImage: Buffer | null;
85
- readonly sharePopupImageMime: string | null;
86
- readonly sharePopupTitle: string | null;
87
- readonly sharePopupBody: string | null;
88
- readonly expiresAt: string | null;
89
- }
90
-
91
- /** Public template fields authorized for a valid enrollment-link lookup. */
92
- export interface ShareApiKeyRow extends ShareLinkPolicy {
93
- readonly active: boolean;
94
- readonly createdAt: string;
95
- }
96
-
97
- /** A personal key revealed by its handoff link. */
98
- export interface ShareHandoffRow extends ShareLinkPolicy {
99
- /** Encrypted personal credential; null when the row predates secret storage. */
100
- readonly keyEncrypted: Buffer | null;
101
- }
102
-
103
- /**
104
- * What one bearer token resolves to. A link is exactly one of these, so a
105
- * public lookup answers with a discriminated result instead of the caller
106
- * guessing which endpoint to try.
107
- */
108
- export type ShareLinkResolution =
109
- | { readonly kind: "enroll"; readonly key: ShareApiKeyRow }
110
- | { readonly kind: "handoff"; readonly key: ShareHandoffRow };
111
-
112
- /**
113
- * Why a share link stopped resolving.
114
- *
115
- * A paused key (`disabled`) is not a revoked one and not an expired link, but
116
- * the resolver answered `null` for all three, so the page could only say
117
- * "link unavailable" — which tells an operator nothing about whether to
118
- * regenerate the link, re-enable the key, or issue a new one.
119
- */
120
- export type ShareLinkRefusal = "not_found" | "expired" | "revoked" | "disabled";
121
-
122
- export type ShareLinkOutcome =
123
- | { readonly ok: true; readonly resolution: ShareLinkResolution }
124
- | { readonly ok: false; readonly refusal: ShareLinkRefusal };
125
-
126
- export interface SharedApiKeyMaterial {
127
- readonly keyHash: string;
128
- readonly keyPrefix: string;
129
- readonly clientIp: string;
130
- readonly clientIpKey: string;
131
- /** Recipient-supplied name hint; the store composes the final label from it. */
132
- readonly nameHint?: string;
133
- }
134
-
135
- export type SharedApiKeyIssueResult =
136
- | {
137
- readonly kind: "issued";
138
- readonly apiKeyId: string;
139
- readonly parentKeyId: string;
140
- readonly tenantId: string;
141
- readonly label: string;
142
- readonly keyPrefix: string;
143
- readonly createdAt: Date;
144
- }
145
- | { readonly kind: "link_unavailable" };
146
-
147
- type ApiKeyRow = typeof apiKeys.$inferSelect;
148
- type ShareLinkRow = typeof shareLinks.$inferSelect;
149
-
150
- type PopupRow = typeof sharePopups.$inferSelect;
151
-
152
- function mapShareRow(key: ApiKeyRow, link: ShareLinkRow, popup?: PopupRow): ShareApiKeyRow {
153
- return {
154
- id: key.id,
155
- tenantId: key.tenantId,
156
- name: key.label,
157
- keyPrefix: key.keyPrefix,
158
- active: key.revokedAt === null,
159
- requestsPerMinute: key.requestsPerMinute,
160
- dailyTokenLimit: key.dailyTokenLimit,
161
- monthlyTokenLimit: key.monthlyTokenLimit,
162
- lifetimeTokenBudget: key.lifetimeTokenBudget,
163
- maxConcurrentRequests: key.maxConcurrentRequests,
164
- modelAccessMode: key.modelAccessMode as ApiKeyModelAccessMode | null,
165
- modelList: key.modelList as readonly string[] | null,
166
- modelPrefix: key.modelPrefix,
167
- notesTitle: key.notesTitle,
168
- notesSubtitle: key.notesSubtitle,
169
- notesBody: key.notesBody,
170
- sharePopupEnabled: popup?.enabled ?? false,
171
- sharePopupImage: popup?.image ?? null,
172
- sharePopupImageMime: popup?.imageMime ?? null,
173
- sharePopupTitle: popup?.title ?? null,
174
- sharePopupBody: popup?.body ?? null,
175
- createdAt: key.createdAt.toISOString(),
176
- expiresAt: link.expiresAt?.toISOString() ?? null,
177
- };
178
- }
179
-
180
- function mapHandoffRow(key: ApiKeyRow, link: ShareLinkRow, popup?: PopupRow): ShareHandoffRow {
181
- return {
182
- id: key.id,
183
- tenantId: key.tenantId,
184
- name: key.label,
185
- keyPrefix: key.keyPrefix,
186
- keyEncrypted: key.keyEncrypted,
187
- requestsPerMinute: key.requestsPerMinute,
188
- dailyTokenLimit: key.dailyTokenLimit,
189
- monthlyTokenLimit: key.monthlyTokenLimit,
190
- lifetimeTokenBudget: key.lifetimeTokenBudget,
191
- maxConcurrentRequests: key.maxConcurrentRequests,
192
- modelAccessMode: key.modelAccessMode as ApiKeyModelAccessMode | null,
193
- modelList: key.modelList as readonly string[] | null,
194
- modelPrefix: key.modelPrefix,
195
- notesTitle: key.notesTitle,
196
- notesSubtitle: key.notesSubtitle,
197
- notesBody: key.notesBody,
198
- sharePopupEnabled: popup?.enabled ?? false,
199
- sharePopupImage: popup?.image ?? null,
200
- sharePopupImageMime: popup?.imageMime ?? null,
201
- sharePopupTitle: popup?.title ?? null,
202
- sharePopupBody: popup?.body ?? null,
203
- expiresAt: link.expiresAt?.toISOString() ?? null,
204
- };
205
- }
206
-
207
- /** Persistence boundary for public enrollment links and child API keys. */
208
- export interface ShareLinkStore {
209
- /**
210
- * Establishes the key's single link, or rotates it in place.
211
- *
212
- * One active link per key is the contract: calling this again replaces the
213
- * token so the previous URL stops resolving. `rotate` false returns the
214
- * existing link untouched.
215
- */
216
- create(input: {
217
- apiKeyId: string;
218
- tokenHash: string;
219
- tokenEncrypted: Buffer;
220
- /** Public address fragment; kept across rotates so the URL is stable. */
221
- slug: string;
222
- kind: ShareLinkKind;
223
- expiresAt: Date | null;
224
- rotate: boolean;
225
- }): Promise<ShareLinkRecord>;
226
- /**
227
- * Resolves a bearer token to the link it names, in one lookup.
228
- *
229
- * Serving enrollment and handoff through separate methods let the browser
230
- * page and the router disagree about which one a given URL is: a personal
231
- * key's handoff link resolved on one endpoint and 404'd on the other. One
232
- * resolver cannot drift from itself.
233
- */
234
- resolveShareLink(tokenHash: string): Promise<ShareLinkResolution | null>;
235
- /** Same lookup as `resolveShareLink`, but names why a link stopped resolving. */
236
- resolveShareLinkOutcome(tokenHash: string): Promise<ShareLinkOutcome>;
237
- /** The key's active link with its retained token, for console re-display. */
238
- findTokenForApiKey(apiKeyId: string): Promise<ShareLinkToken | null>;
239
- issueSharedApiKey(
240
- tokenHash: string,
241
- material: SharedApiKeyMaterial,
242
- ): Promise<SharedApiKeyIssueResult>;
243
- touchView(tokenHash: string): Promise<void>;
244
- /** All enrollment links for one API key, newest first (never tokens). */
245
- listForApiKey(apiKeyId: string): Promise<readonly ShareLinkSummary[]>;
246
- /** Deactivates one enrollment link scoped to its owning parent API key. */
247
- revoke(apiKeyId: string, shareId: string): Promise<boolean>;
248
- }
249
-
250
- export class DrizzleShareLinkStore implements ShareLinkStore {
251
- constructor(private readonly db: CartethyiaDatabase) {}
252
-
253
- async create(input: {
254
- apiKeyId: string;
255
- tokenHash: string;
256
- tokenEncrypted: Buffer;
257
- slug: string;
258
- kind: ShareLinkKind;
259
- expiresAt: Date | null;
260
- rotate: boolean;
261
- }): Promise<ShareLinkRecord> {
262
- return await this.db.transaction(async (tx) => {
263
- const existing = await lockRows(
264
- tx
265
- .select()
266
- .from(shareLinks)
267
- .where(and(eq(shareLinks.apiKeyId, input.apiKeyId), eq(shareLinks.active, true)))
268
- .orderBy(desc(shareLinks.createdAt))
269
- .limit(1),
270
- );
271
- const current = existing[0];
272
- if (current && !input.rotate) {
273
- return {
274
- id: current.id,
275
- apiKeyId: current.apiKeyId,
276
- kind: current.kind,
277
- createdAt: current.createdAt,
278
- expiresAt: current.expiresAt,
279
- };
280
- }
281
- if (current) {
282
- const updated = await tx
283
- .update(shareLinks)
284
- .set({
285
- tokenHash: input.tokenHash,
286
- tokenEncrypted: input.tokenEncrypted,
287
- // The slug is the address and survives a rotate, so an operator who
288
- // rotates does not invalidate every copy of the link they shared.
289
- ...(current.slug === null ? { slug: input.slug } : {}),
290
- expiresAt: input.expiresAt,
291
- lastViewedAt: null,
292
- })
293
- .where(eq(shareLinks.id, current.id))
294
- .returning();
295
- const row = updated[0];
296
- if (!row) throw new Error("share link rotate returned no row");
297
- return {
298
- id: row.id,
299
- apiKeyId: row.apiKeyId,
300
- kind: row.kind,
301
- createdAt: row.createdAt,
302
- expiresAt: row.expiresAt,
303
- };
304
- }
305
- const rows = await tx
306
- .insert(shareLinks)
307
- .values({
308
- apiKeyId: input.apiKeyId,
309
- tokenHash: input.tokenHash,
310
- tokenEncrypted: input.tokenEncrypted,
311
- slug: input.slug,
312
- kind: input.kind,
313
- expiresAt: input.expiresAt,
314
- })
315
- .returning();
316
- const row = rows[0];
317
- if (!row) throw new Error("share link insert returned no row");
318
- return {
319
- id: row.id,
320
- apiKeyId: row.apiKeyId,
321
- kind: row.kind,
322
- createdAt: row.createdAt,
323
- expiresAt: row.expiresAt,
324
- };
325
- });
326
- }
327
-
328
- async resolveShareLink(tokenHash: string): Promise<ShareLinkResolution | null> {
329
- const outcome = await this.resolveShareLinkOutcome(tokenHash);
330
- return outcome.ok ? outcome.resolution : null;
331
- }
332
-
333
- /**
334
- * Same lookup as {@link resolveShareLink}, but reports *why* a link stopped
335
- * resolving instead of collapsing every cause into `null`. The refusal is
336
- * decided from the row that actually exists: dropping the health predicates
337
- * from the `WHERE` and classifying afterwards is the only way to tell an
338
- * expired link from a revoked key from a paused one, since a row failing any
339
- * of them is simply absent from the filtered result.
340
- */
341
- async resolveShareLinkOutcome(tokenHash: string): Promise<ShareLinkOutcome> {
342
- const rows = await this.db
343
- .select({ key: apiKeys, link: shareLinks, popup: sharePopups })
344
- .from(shareLinks)
345
- .innerJoin(apiKeys, eq(shareLinks.apiKeyId, apiKeys.id))
346
- .leftJoin(sharePopups, eq(sharePopups.apiKeyId, apiKeys.id))
347
- // The path segment may be the slug or the bearer token: the slug is the
348
- // address a restored page keeps, the token is what the page was issued
349
- // with before slugs existed. Either one names the same link.
350
- .where(
351
- and(
352
- or(eq(shareLinks.slug, tokenHash), eq(shareLinks.tokenHash, tokenHash)),
353
- isNull(apiKeys.parentKeyId),
354
- ),
355
- )
356
- .limit(1);
357
- const row = rows[0];
358
- if (row === undefined) return { ok: false, refusal: "not_found" };
359
- // Ordering: a deactivated link was revoked by its owner, not expired —
360
- // "expired" would send them to regenerate a link they deliberately killed.
361
- // Beyond that, report the state the operator can actually change: a key
362
- // can be both paused and expired, and either way the link needs
363
- // regenerating, so the key's own state is the more actionable answer.
364
- if (row.link.active !== true) return { ok: false, refusal: "revoked" };
365
- if (row.key.revokedAt !== null) return { ok: false, refusal: "revoked" };
366
- if (row.key.enabled !== true) return { ok: false, refusal: "disabled" };
367
- if (row.link.expiresAt !== null && row.link.expiresAt.getTime() <= Date.now())
368
- return { ok: false, refusal: "expired" };
369
- // The link's kind and the key's mode must agree: an enrollment link hands
370
- // out child keys from a share template, which has no credential of its own
371
- // to reveal, while a handoff link reveals a personal key. A row where the
372
- // two disagree is a mismatched pair, not a link to serve.
373
- if (row.link.kind === "enroll") {
374
- return row.key.keyMode === "share"
375
- ? { ok: true, resolution: { kind: "enroll", key: mapShareRow(row.key, row.link, row.popup ?? undefined) } }
376
- : { ok: false, refusal: "not_found" };
377
- }
378
- return row.key.keyMode === "personal"
379
- ? { ok: true, resolution: { kind: "handoff", key: mapHandoffRow(row.key, row.link, row.popup ?? undefined) } }
380
- : { ok: false, refusal: "not_found" };
381
- }
382
-
383
- async findTokenForApiKey(apiKeyId: string): Promise<ShareLinkToken | null> {
384
- const rows = await this.db
385
- .select({
386
- id: shareLinks.id,
387
- apiKeyId: shareLinks.apiKeyId,
388
- kind: shareLinks.kind,
389
- expiresAt: shareLinks.expiresAt,
390
- tokenEncrypted: shareLinks.tokenEncrypted,
391
- slug: shareLinks.slug,
392
- })
393
- .from(shareLinks)
394
- .where(
395
- and(
396
- eq(shareLinks.apiKeyId, apiKeyId),
397
- eq(shareLinks.active, true),
398
- sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
399
- ),
400
- )
401
- .orderBy(desc(shareLinks.createdAt))
402
- .limit(1);
403
- return rows[0] ?? null;
404
- }
405
-
406
- async issueSharedApiKey(
407
- tokenHash: string,
408
- material: SharedApiKeyMaterial,
409
- ): Promise<SharedApiKeyIssueResult> {
410
- return this.db.transaction(async (tx) => {
411
- // Read the link first without locking, then lock its parent before the
412
- // link. Parent revocation uses the same lock order and is atomic with
413
- // child revocation, so a concurrent issue cannot escape the parent.
414
- const [linkRef] = await tx
415
- .select({ id: shareLinks.id, apiKeyId: shareLinks.apiKeyId })
416
- .from(shareLinks)
417
- .where(
418
- and(
419
- eq(shareLinks.tokenHash, tokenHash),
420
- eq(shareLinks.kind, "enroll"),
421
- eq(shareLinks.active, true),
422
- sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
423
- ),
424
- )
425
- .limit(1);
426
- if (!linkRef) return { kind: "link_unavailable" };
427
-
428
- const [parent] = await lockRows(
429
- tx
430
- .select()
431
- .from(apiKeys)
432
- .where(
433
- and(
434
- eq(apiKeys.id, linkRef.apiKeyId),
435
- eq(apiKeys.keyMode, "share"),
436
- isNull(apiKeys.parentKeyId),
437
- isNull(apiKeys.revokedAt),
438
- ),
439
- )
440
- .limit(1),
441
- );
442
- if (!parent) return { kind: "link_unavailable" };
443
-
444
- const [activeLink] = await lockRows(
445
- tx
446
- .select({ id: shareLinks.id })
447
- .from(shareLinks)
448
- .where(
449
- and(
450
- eq(shareLinks.id, linkRef.id),
451
- eq(shareLinks.tokenHash, tokenHash),
452
- eq(shareLinks.active, true),
453
- sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
454
- ),
455
- )
456
- .limit(1),
457
- );
458
- if (!activeLink) return { kind: "link_unavailable" };
459
-
460
- // The label is `hint + random`, capped at 12 chars — never the
461
- // parent's full label plus a suffix, which grew past every table
462
- // column it rendered in. Built here (not imported from the console
463
- // domain) because persistence never imports console code.
464
- const hint = (material.nameHint ?? "").trim().slice(0, 7);
465
- const suffix = randomInt(1000, 10_000).toString();
466
- const label =
467
- hint.length === 0 ? `key-${suffix}` : `${hint}-${suffix}`.slice(0, 12);
468
- const [child] = await tx
469
- .insert(apiKeys)
470
- .values({
471
- tenantId: parent.tenantId,
472
- keyHash: material.keyHash,
473
- keyMode: "share",
474
- parentKeyId: parent.id,
475
- issuedClientIp: material.clientIp,
476
- issuedClientIpKey: material.clientIpKey,
477
- label,
478
- scopes: parent.scopes,
479
- keyPrefix: material.keyPrefix,
480
- keyEncrypted: null,
481
- notesTitle: parent.notesTitle,
482
- notesSubtitle: parent.notesSubtitle,
483
- notesBody: parent.notesBody,
484
- requestsPerMinute: parent.requestsPerMinute,
485
- dailyTokenLimit: parent.dailyTokenLimit,
486
- monthlyTokenLimit: parent.monthlyTokenLimit,
487
- lifetimeTokenBudget: parent.lifetimeTokenBudget,
488
- maxConcurrentRequests: parent.maxConcurrentRequests,
489
- modelPrefix: parent.modelPrefix,
490
- modelAccessMode: parent.modelAccessMode,
491
- modelList: parent.modelList,
492
- clientRouterDenylist: parent.clientRouterDenylist,
493
- lifetimeTokensConsumed: 0,
494
- })
495
- .returning({ id: apiKeys.id, tenantId: apiKeys.tenantId, createdAt: apiKeys.createdAt });
496
- if (!child) throw new Error("shared API-key insert returned no row");
497
- return {
498
- kind: "issued",
499
- apiKeyId: child.id,
500
- parentKeyId: parent.id,
501
- tenantId: child.tenantId,
502
- label,
503
- keyPrefix: material.keyPrefix,
504
- createdAt: child.createdAt,
505
- };
506
- });
507
- }
508
-
509
- async touchView(tokenHash: string): Promise<void> {
510
- // Expired-but-active links must not bump lastViewedAt: the bump makes
511
- // expiry auditing lie about a link that no longer serves.
512
- await this.db
513
- .update(shareLinks)
514
- .set({ lastViewedAt: new Date() })
515
- .where(
516
- and(
517
- eq(shareLinks.tokenHash, tokenHash),
518
- eq(shareLinks.active, true),
519
- sql`${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > NOW()`,
520
- ),
521
- );
522
- }
523
-
524
- async listForApiKey(apiKeyId: string): Promise<readonly ShareLinkSummary[]> {
525
- const rows = await this.db
526
- .select()
527
- .from(shareLinks)
528
- .where(and(eq(shareLinks.apiKeyId, apiKeyId), eq(shareLinks.kind, "enroll")))
529
- .orderBy(desc(shareLinks.createdAt));
530
- return rows.map((row) => ({
531
- id: row.id,
532
- apiKeyId: row.apiKeyId,
533
- kind: "enroll",
534
- active: row.active,
535
- createdAt: row.createdAt,
536
- expiresAt: row.expiresAt,
537
- lastViewedAt: row.lastViewedAt,
538
- }));
539
- }
540
-
541
- async revoke(apiKeyId: string, shareId: string): Promise<boolean> {
542
- const rows = await this.db
543
- .update(shareLinks)
544
- .set({ active: false })
545
- .where(
546
- and(
547
- eq(shareLinks.id, shareId),
548
- eq(shareLinks.apiKeyId, apiKeyId),
549
- eq(shareLinks.kind, "enroll"),
550
- eq(shareLinks.active, true),
551
- ),
552
- )
553
- .returning({ id: shareLinks.id });
554
- return rows.length > 0;
555
- }
556
- }
1
+ import { createHash, randomBytes, randomInt } from "node:crypto";
2
+
3
+ // Share-link persistence and atomic child-key issuance. The hash is the lookup
4
+ // key; the bearer token is retained encrypted so the console can re-display a
5
+ // stable link, and is never selected by a public lookup.
6
+
7
+ import { and, desc, eq, isNull, or, sql, type SQL } from "drizzle-orm";
8
+ import type { CartethyiaDatabase } from "../db-handle";
9
+ import { lockRows } from "../dialect";
10
+ import { apiKeys, shareLinks, sharePopups, type ShareLinkKind, type ApiKeyModelAccessMode } from "../schema";
11
+
12
+ /** Hashes a share bearer token for storage and lookup. */
13
+ /**
14
+ * A fresh public slug for a share page.
15
+ *
16
+ * Held in the clear and looked up by index, so it must be unguessable on its
17
+ * own: the same 256 bits of entropy as the bearer token, base64url-encoded.
18
+ */
19
+ export function generateShareSlug(): string {
20
+ return randomBytes(32).toString("base64url");
21
+ }
22
+
23
+ export function hashShareToken(token: string): string {
24
+ return createHash("sha256").update(token, "utf8").digest("hex");
25
+ }
26
+
27
+ /**
28
+ * The predicate matching one link's two public address forms.
29
+ *
30
+ * `slug` is held in the clear — that is what lets a restore onto a fresh
31
+ * deployment keep every address — while the bearer token is only ever stored
32
+ * as `hashShareToken(token)`. So the two columns cannot be compared against
33
+ * one value: a lookup built from a hash alone silently never matches the slug
34
+ * column, which is how a freshly generated link answered "not found".
35
+ */
36
+ function addressMatches(address: string): SQL {
37
+ return or(eq(shareLinks.slug, address), eq(shareLinks.tokenHash, hashShareToken(address)))!;
38
+ }
39
+
40
+ /** Newly created share-link metadata. */
41
+ export interface ShareLinkRecord {
42
+ readonly id: string;
43
+ readonly apiKeyId: string;
44
+ readonly kind: ShareLinkKind;
45
+ readonly createdAt: Date;
46
+ readonly expiresAt: Date | null;
47
+ }
48
+
49
+ /**
50
+ * Console-facing view of one link. The caller supplies the decrypted token;
51
+ * this record itself never reads the token column.
52
+ */
53
+ export interface ShareLinkSummary {
54
+ readonly id: string;
55
+ readonly apiKeyId: string;
56
+ readonly kind: ShareLinkKind;
57
+ readonly active: boolean;
58
+ readonly createdAt: Date;
59
+ readonly expiresAt: Date | null;
60
+ readonly lastViewedAt: Date | null;
61
+ }
62
+
63
+ /** A stable link's encrypted token, or null when it predates token retention. */
64
+ export interface ShareLinkToken {
65
+ readonly id: string;
66
+ readonly apiKeyId: string;
67
+ readonly kind: ShareLinkKind;
68
+ readonly expiresAt: Date | null;
69
+ readonly tokenEncrypted: Buffer | null;
70
+ /** Public address fragment, held in the clear; null on pre-migration rows. */
71
+ readonly slug: string | null;
72
+ }
73
+
74
+ /**
75
+ * Policy fields a share recipient sees, whichever kind of link carried them.
76
+ * Both resolutions project the same columns, so the public payload and the
77
+ * model filter read one shape instead of two near-identical ones.
78
+ */
79
+ export interface ShareLinkPolicy {
80
+ readonly id: string;
81
+ readonly tenantId: string;
82
+ readonly name: string;
83
+ readonly keyPrefix: string | null;
84
+ readonly requestsPerMinute: number | null;
85
+ readonly dailyTokenLimit: number | null;
86
+ readonly monthlyTokenLimit: number | null;
87
+ readonly lifetimeTokenBudget: number | null;
88
+ readonly maxConcurrentRequests: number | null;
89
+ readonly modelAccessMode: ApiKeyModelAccessMode | null;
90
+ readonly modelList: readonly string[] | null;
91
+ readonly modelPrefix: string | null;
92
+ readonly notesTitle: string | null;
93
+ readonly notesSubtitle: string | null;
94
+ readonly notesBody: string | null;
95
+ readonly sharePopupEnabled: boolean;
96
+ /** Uploaded popup art; the share page fetches it from its own image route. */
97
+ readonly sharePopupImage: Buffer | null;
98
+ readonly sharePopupImageMime: string | null;
99
+ readonly sharePopupTitle: string | null;
100
+ readonly sharePopupBody: string | null;
101
+ readonly expiresAt: string | null;
102
+ }
103
+
104
+ /** Public template fields authorized for a valid enrollment-link lookup. */
105
+ export interface ShareApiKeyRow extends ShareLinkPolicy {
106
+ readonly active: boolean;
107
+ readonly createdAt: string;
108
+ }
109
+
110
+ /** A personal key revealed by its handoff link. */
111
+ export interface ShareHandoffRow extends ShareLinkPolicy {
112
+ /** Encrypted personal credential; null when the row predates secret storage. */
113
+ readonly keyEncrypted: Buffer | null;
114
+ }
115
+
116
+ /**
117
+ * What one bearer token resolves to. A link is exactly one of these, so a
118
+ * public lookup answers with a discriminated result instead of the caller
119
+ * guessing which endpoint to try.
120
+ */
121
+ export type ShareLinkResolution =
122
+ | { readonly kind: "enroll"; readonly key: ShareApiKeyRow }
123
+ | { readonly kind: "handoff"; readonly key: ShareHandoffRow };
124
+
125
+ /**
126
+ * Why a share link stopped resolving.
127
+ *
128
+ * A paused key (`disabled`) is not a revoked one and not an expired link, but
129
+ * the resolver answered `null` for all three, so the page could only say
130
+ * "link unavailable" — which tells an operator nothing about whether to
131
+ * regenerate the link, re-enable the key, or issue a new one.
132
+ */
133
+ export type ShareLinkRefusal = "not_found" | "expired" | "revoked" | "disabled";
134
+
135
+ export type ShareLinkOutcome =
136
+ | { readonly ok: true; readonly resolution: ShareLinkResolution }
137
+ | { readonly ok: false; readonly refusal: ShareLinkRefusal };
138
+
139
+ export interface SharedApiKeyMaterial {
140
+ readonly keyHash: string;
141
+ readonly keyPrefix: string;
142
+ readonly clientIp: string;
143
+ readonly clientIpKey: string;
144
+ /** Recipient-supplied name hint; the store composes the final label from it. */
145
+ readonly nameHint?: string;
146
+ }
147
+
148
+ export type SharedApiKeyIssueResult =
149
+ | {
150
+ readonly kind: "issued";
151
+ readonly apiKeyId: string;
152
+ readonly parentKeyId: string;
153
+ readonly tenantId: string;
154
+ readonly label: string;
155
+ readonly keyPrefix: string;
156
+ readonly createdAt: Date;
157
+ }
158
+ | { readonly kind: "link_unavailable" };
159
+
160
+ type ApiKeyRow = typeof apiKeys.$inferSelect;
161
+ type ShareLinkRow = typeof shareLinks.$inferSelect;
162
+
163
+ type PopupRow = typeof sharePopups.$inferSelect;
164
+
165
+ function mapShareRow(key: ApiKeyRow, link: ShareLinkRow, popup?: PopupRow): ShareApiKeyRow {
166
+ return {
167
+ id: key.id,
168
+ tenantId: key.tenantId,
169
+ name: key.label,
170
+ keyPrefix: key.keyPrefix,
171
+ active: key.revokedAt === null,
172
+ requestsPerMinute: key.requestsPerMinute,
173
+ dailyTokenLimit: key.dailyTokenLimit,
174
+ monthlyTokenLimit: key.monthlyTokenLimit,
175
+ lifetimeTokenBudget: key.lifetimeTokenBudget,
176
+ maxConcurrentRequests: key.maxConcurrentRequests,
177
+ modelAccessMode: key.modelAccessMode as ApiKeyModelAccessMode | null,
178
+ modelList: key.modelList as readonly string[] | null,
179
+ modelPrefix: key.modelPrefix,
180
+ notesTitle: key.notesTitle,
181
+ notesSubtitle: key.notesSubtitle,
182
+ notesBody: key.notesBody,
183
+ sharePopupEnabled: popup?.enabled ?? false,
184
+ sharePopupImage: popup?.image ?? null,
185
+ sharePopupImageMime: popup?.imageMime ?? null,
186
+ sharePopupTitle: popup?.title ?? null,
187
+ sharePopupBody: popup?.body ?? null,
188
+ createdAt: key.createdAt.toISOString(),
189
+ expiresAt: link.expiresAt?.toISOString() ?? null,
190
+ };
191
+ }
192
+
193
+ function mapHandoffRow(key: ApiKeyRow, link: ShareLinkRow, popup?: PopupRow): ShareHandoffRow {
194
+ return {
195
+ id: key.id,
196
+ tenantId: key.tenantId,
197
+ name: key.label,
198
+ keyPrefix: key.keyPrefix,
199
+ keyEncrypted: key.keyEncrypted,
200
+ requestsPerMinute: key.requestsPerMinute,
201
+ dailyTokenLimit: key.dailyTokenLimit,
202
+ monthlyTokenLimit: key.monthlyTokenLimit,
203
+ lifetimeTokenBudget: key.lifetimeTokenBudget,
204
+ maxConcurrentRequests: key.maxConcurrentRequests,
205
+ modelAccessMode: key.modelAccessMode as ApiKeyModelAccessMode | null,
206
+ modelList: key.modelList as readonly string[] | null,
207
+ modelPrefix: key.modelPrefix,
208
+ notesTitle: key.notesTitle,
209
+ notesSubtitle: key.notesSubtitle,
210
+ notesBody: key.notesBody,
211
+ sharePopupEnabled: popup?.enabled ?? false,
212
+ sharePopupImage: popup?.image ?? null,
213
+ sharePopupImageMime: popup?.imageMime ?? null,
214
+ sharePopupTitle: popup?.title ?? null,
215
+ sharePopupBody: popup?.body ?? null,
216
+ expiresAt: link.expiresAt?.toISOString() ?? null,
217
+ };
218
+ }
219
+
220
+ /** Persistence boundary for public enrollment links and child API keys. */
221
+ export interface ShareLinkStore {
222
+ /**
223
+ * Establishes the key's single link, or rotates it in place.
224
+ *
225
+ * One active link per key is the contract: calling this again replaces the
226
+ * token so the previous URL stops resolving. `rotate` false returns the
227
+ * existing link untouched.
228
+ */
229
+ create(input: {
230
+ apiKeyId: string;
231
+ tokenHash: string;
232
+ tokenEncrypted: Buffer;
233
+ /** Public address fragment; kept across rotates so the URL is stable. */
234
+ slug: string;
235
+ kind: ShareLinkKind;
236
+ expiresAt: Date | null;
237
+ rotate: boolean;
238
+ }): Promise<ShareLinkRecord>;
239
+ /**
240
+ * Resolves a link's public address to the link it names, in one lookup.
241
+ *
242
+ * The argument is the raw path segment — a slug or a bearer token — not a
243
+ * hash: the store owns hashing, so a caller cannot hand it a slug-shaped
244
+ * value that only matches the hashed column.
245
+ *
246
+ * Serving enrollment and handoff through separate methods let the browser
247
+ * page and the router disagree about which one a given URL is: a personal
248
+ * key's handoff link resolved on one endpoint and 404'd on the other. One
249
+ * resolver cannot drift from itself.
250
+ */
251
+ resolveShareLink(address: string): Promise<ShareLinkResolution | null>;
252
+ /** Same lookup as `resolveShareLink`, but names why a link stopped resolving. */
253
+ resolveShareLinkOutcome(address: string): Promise<ShareLinkOutcome>;
254
+ /** The key's active link with its retained token, for console re-display. */
255
+ findTokenForApiKey(apiKeyId: string): Promise<ShareLinkToken | null>;
256
+ /**
257
+ * Mints one child key under the link named by `address`.
258
+ *
259
+ * `address` is the raw path segment, so a slug-addressed link issues
260
+ * exactly like a token-addressed one.
261
+ */
262
+ issueSharedApiKey(
263
+ address: string,
264
+ material: SharedApiKeyMaterial,
265
+ ): Promise<SharedApiKeyIssueResult>;
266
+ /** Records a view of the link named by `address` (slug or bearer token). */
267
+ touchView(address: string): Promise<void>;
268
+ /** All enrollment links for one API key, newest first (never tokens). */
269
+ listForApiKey(apiKeyId: string): Promise<readonly ShareLinkSummary[]>;
270
+ /** Deactivates one enrollment link scoped to its owning parent API key. */
271
+ revoke(apiKeyId: string, shareId: string): Promise<boolean>;
272
+ }
273
+
274
+ export class DrizzleShareLinkStore implements ShareLinkStore {
275
+ constructor(private readonly db: CartethyiaDatabase) {}
276
+
277
+ async create(input: {
278
+ apiKeyId: string;
279
+ tokenHash: string;
280
+ tokenEncrypted: Buffer;
281
+ slug: string;
282
+ kind: ShareLinkKind;
283
+ expiresAt: Date | null;
284
+ rotate: boolean;
285
+ }): Promise<ShareLinkRecord> {
286
+ return await this.db.transaction(async (tx) => {
287
+ const existing = await lockRows(
288
+ tx
289
+ .select()
290
+ .from(shareLinks)
291
+ .where(and(eq(shareLinks.apiKeyId, input.apiKeyId), eq(shareLinks.active, true)))
292
+ .orderBy(desc(shareLinks.createdAt))
293
+ .limit(1),
294
+ );
295
+ const current = existing[0];
296
+ if (current && !input.rotate) {
297
+ return {
298
+ id: current.id,
299
+ apiKeyId: current.apiKeyId,
300
+ kind: current.kind,
301
+ createdAt: current.createdAt,
302
+ expiresAt: current.expiresAt,
303
+ };
304
+ }
305
+ if (current) {
306
+ const updated = await tx
307
+ .update(shareLinks)
308
+ .set({
309
+ tokenHash: input.tokenHash,
310
+ tokenEncrypted: input.tokenEncrypted,
311
+ // The slug is the address and survives a rotate, so an operator who
312
+ // rotates does not invalidate every copy of the link they shared.
313
+ ...(current.slug === null ? { slug: input.slug } : {}),
314
+ expiresAt: input.expiresAt,
315
+ lastViewedAt: null,
316
+ })
317
+ .where(eq(shareLinks.id, current.id))
318
+ .returning();
319
+ const row = updated[0];
320
+ if (!row) throw new Error("share link rotate returned no row");
321
+ return {
322
+ id: row.id,
323
+ apiKeyId: row.apiKeyId,
324
+ kind: row.kind,
325
+ createdAt: row.createdAt,
326
+ expiresAt: row.expiresAt,
327
+ };
328
+ }
329
+ const rows = await tx
330
+ .insert(shareLinks)
331
+ .values({
332
+ apiKeyId: input.apiKeyId,
333
+ tokenHash: input.tokenHash,
334
+ tokenEncrypted: input.tokenEncrypted,
335
+ slug: input.slug,
336
+ kind: input.kind,
337
+ expiresAt: input.expiresAt,
338
+ })
339
+ .returning();
340
+ const row = rows[0];
341
+ if (!row) throw new Error("share link insert returned no row");
342
+ return {
343
+ id: row.id,
344
+ apiKeyId: row.apiKeyId,
345
+ kind: row.kind,
346
+ createdAt: row.createdAt,
347
+ expiresAt: row.expiresAt,
348
+ };
349
+ });
350
+ }
351
+
352
+ async resolveShareLink(address: string): Promise<ShareLinkResolution | null> {
353
+ const outcome = await this.resolveShareLinkOutcome(address);
354
+ return outcome.ok ? outcome.resolution : null;
355
+ }
356
+
357
+ /**
358
+ * Same lookup as {@link resolveShareLink}, but reports *why* a link stopped
359
+ * resolving instead of collapsing every cause into `null`. The refusal is
360
+ * decided from the row that actually exists: dropping the health predicates
361
+ * from the `WHERE` and classifying afterwards is the only way to tell an
362
+ * expired link from a revoked key from a paused one, since a row failing any
363
+ * of them is simply absent from the filtered result.
364
+ */
365
+ async resolveShareLinkOutcome(address: string): Promise<ShareLinkOutcome> {
366
+ // Two address forms name one link: the public slug, and the bearer token
367
+ // a pre-slug page was issued with. They are matched in their own form —
368
+ // the slug is stored in the clear (that is how a restored deployment keeps
369
+ // its addresses), while the token is only ever stored as a hash. Comparing
370
+ // the slug against a hash can never match, which made every slug-addressed
371
+ // link answer "not found" for a page that had just been generated.
372
+ const rows = await this.db
373
+ .select({ key: apiKeys, link: shareLinks, popup: sharePopups })
374
+ .from(shareLinks)
375
+ .innerJoin(apiKeys, eq(shareLinks.apiKeyId, apiKeys.id))
376
+ .leftJoin(sharePopups, eq(sharePopups.apiKeyId, apiKeys.id))
377
+ .where(
378
+ and(
379
+ addressMatches(address),
380
+ isNull(apiKeys.parentKeyId),
381
+ ),
382
+ )
383
+ .limit(1);
384
+ const row = rows[0];
385
+ if (row === undefined) return { ok: false, refusal: "not_found" };
386
+ // Ordering: a deactivated link was revoked by its owner, not expired —
387
+ // "expired" would send them to regenerate a link they deliberately killed.
388
+ // Beyond that, report the state the operator can actually change: a key
389
+ // can be both paused and expired, and either way the link needs
390
+ // regenerating, so the key's own state is the more actionable answer.
391
+ if (row.link.active !== true) return { ok: false, refusal: "revoked" };
392
+ if (row.key.revokedAt !== null) return { ok: false, refusal: "revoked" };
393
+ if (row.key.enabled !== true) return { ok: false, refusal: "disabled" };
394
+ if (row.link.expiresAt !== null && row.link.expiresAt.getTime() <= Date.now())
395
+ return { ok: false, refusal: "expired" };
396
+ // The link's kind and the key's mode must agree: an enrollment link hands
397
+ // out child keys from a share template, which has no credential of its own
398
+ // to reveal, while a handoff link reveals a personal key. A row where the
399
+ // two disagree is a mismatched pair, not a link to serve.
400
+ if (row.link.kind === "enroll") {
401
+ return row.key.keyMode === "share"
402
+ ? { ok: true, resolution: { kind: "enroll", key: mapShareRow(row.key, row.link, row.popup ?? undefined) } }
403
+ : { ok: false, refusal: "not_found" };
404
+ }
405
+ return row.key.keyMode === "personal"
406
+ ? { ok: true, resolution: { kind: "handoff", key: mapHandoffRow(row.key, row.link, row.popup ?? undefined) } }
407
+ : { ok: false, refusal: "not_found" };
408
+ }
409
+
410
+ async findTokenForApiKey(apiKeyId: string): Promise<ShareLinkToken | null> {
411
+ const rows = await this.db
412
+ .select({
413
+ id: shareLinks.id,
414
+ apiKeyId: shareLinks.apiKeyId,
415
+ kind: shareLinks.kind,
416
+ expiresAt: shareLinks.expiresAt,
417
+ tokenEncrypted: shareLinks.tokenEncrypted,
418
+ slug: shareLinks.slug,
419
+ })
420
+ .from(shareLinks)
421
+ .where(
422
+ and(
423
+ eq(shareLinks.apiKeyId, apiKeyId),
424
+ eq(shareLinks.active, true),
425
+ sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
426
+ ),
427
+ )
428
+ .orderBy(desc(shareLinks.createdAt))
429
+ .limit(1);
430
+ return rows[0] ?? null;
431
+ }
432
+
433
+ async issueSharedApiKey(
434
+ address: string,
435
+ material: SharedApiKeyMaterial,
436
+ ): Promise<SharedApiKeyIssueResult> {
437
+ return this.db.transaction(async (tx) => {
438
+ // Read the link first without locking, then lock its parent before the
439
+ // link. Parent revocation uses the same lock order and is atomic with
440
+ // child revocation, so a concurrent issue cannot escape the parent.
441
+ const [linkRef] = await tx
442
+ .select({ id: shareLinks.id, apiKeyId: shareLinks.apiKeyId })
443
+ .from(shareLinks)
444
+ .where(
445
+ and(
446
+ addressMatches(address),
447
+ eq(shareLinks.kind, "enroll"),
448
+ eq(shareLinks.active, true),
449
+ sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
450
+ ),
451
+ )
452
+ .limit(1);
453
+ if (!linkRef) return { kind: "link_unavailable" };
454
+
455
+ const [parent] = await lockRows(
456
+ tx
457
+ .select()
458
+ .from(apiKeys)
459
+ .where(
460
+ and(
461
+ eq(apiKeys.id, linkRef.apiKeyId),
462
+ eq(apiKeys.keyMode, "share"),
463
+ isNull(apiKeys.parentKeyId),
464
+ isNull(apiKeys.revokedAt),
465
+ ),
466
+ )
467
+ .limit(1),
468
+ );
469
+ if (!parent) return { kind: "link_unavailable" };
470
+
471
+ const [activeLink] = await lockRows(
472
+ tx
473
+ .select({ id: shareLinks.id })
474
+ .from(shareLinks)
475
+ .where(
476
+ and(
477
+ eq(shareLinks.id, linkRef.id),
478
+ addressMatches(address),
479
+ eq(shareLinks.active, true),
480
+ sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
481
+ ),
482
+ )
483
+ .limit(1),
484
+ );
485
+ if (!activeLink) return { kind: "link_unavailable" };
486
+
487
+ // The label is `hint + random`, capped at 12 chars — never the
488
+ // parent's full label plus a suffix, which grew past every table
489
+ // column it rendered in. Built here (not imported from the console
490
+ // domain) because persistence never imports console code.
491
+ const hint = (material.nameHint ?? "").trim().slice(0, 7);
492
+ const suffix = randomInt(1000, 10_000).toString();
493
+ const label =
494
+ hint.length === 0 ? `key-${suffix}` : `${hint}-${suffix}`.slice(0, 12);
495
+ const [child] = await tx
496
+ .insert(apiKeys)
497
+ .values({
498
+ tenantId: parent.tenantId,
499
+ keyHash: material.keyHash,
500
+ keyMode: "share",
501
+ parentKeyId: parent.id,
502
+ issuedClientIp: material.clientIp,
503
+ issuedClientIpKey: material.clientIpKey,
504
+ label,
505
+ scopes: parent.scopes,
506
+ keyPrefix: material.keyPrefix,
507
+ keyEncrypted: null,
508
+ notesTitle: parent.notesTitle,
509
+ notesSubtitle: parent.notesSubtitle,
510
+ notesBody: parent.notesBody,
511
+ requestsPerMinute: parent.requestsPerMinute,
512
+ dailyTokenLimit: parent.dailyTokenLimit,
513
+ monthlyTokenLimit: parent.monthlyTokenLimit,
514
+ lifetimeTokenBudget: parent.lifetimeTokenBudget,
515
+ maxConcurrentRequests: parent.maxConcurrentRequests,
516
+ modelPrefix: parent.modelPrefix,
517
+ modelAccessMode: parent.modelAccessMode,
518
+ modelList: parent.modelList,
519
+ clientRouterDenylist: parent.clientRouterDenylist,
520
+ lifetimeTokensConsumed: 0,
521
+ })
522
+ .returning({ id: apiKeys.id, tenantId: apiKeys.tenantId, createdAt: apiKeys.createdAt });
523
+ if (!child) throw new Error("shared API-key insert returned no row");
524
+ return {
525
+ kind: "issued",
526
+ apiKeyId: child.id,
527
+ parentKeyId: parent.id,
528
+ tenantId: child.tenantId,
529
+ label,
530
+ keyPrefix: material.keyPrefix,
531
+ createdAt: child.createdAt,
532
+ };
533
+ });
534
+ }
535
+
536
+ async touchView(address: string): Promise<void> {
537
+ // Expired-but-active links must not bump lastViewedAt: the bump makes
538
+ // expiry auditing lie about a link that no longer serves.
539
+ await this.db
540
+ .update(shareLinks)
541
+ .set({ lastViewedAt: new Date() })
542
+ .where(
543
+ and(
544
+ addressMatches(address),
545
+ eq(shareLinks.active, true),
546
+ sql`${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > NOW()`,
547
+ ),
548
+ );
549
+ }
550
+
551
+ async listForApiKey(apiKeyId: string): Promise<readonly ShareLinkSummary[]> {
552
+ const rows = await this.db
553
+ .select()
554
+ .from(shareLinks)
555
+ .where(and(eq(shareLinks.apiKeyId, apiKeyId), eq(shareLinks.kind, "enroll")))
556
+ .orderBy(desc(shareLinks.createdAt));
557
+ return rows.map((row) => ({
558
+ id: row.id,
559
+ apiKeyId: row.apiKeyId,
560
+ kind: "enroll",
561
+ active: row.active,
562
+ createdAt: row.createdAt,
563
+ expiresAt: row.expiresAt,
564
+ lastViewedAt: row.lastViewedAt,
565
+ }));
566
+ }
567
+
568
+ async revoke(apiKeyId: string, shareId: string): Promise<boolean> {
569
+ const rows = await this.db
570
+ .update(shareLinks)
571
+ .set({ active: false })
572
+ .where(
573
+ and(
574
+ eq(shareLinks.id, shareId),
575
+ eq(shareLinks.apiKeyId, apiKeyId),
576
+ eq(shareLinks.kind, "enroll"),
577
+ eq(shareLinks.active, true),
578
+ ),
579
+ )
580
+ .returning({ id: shareLinks.id });
581
+ return rows.length > 0;
582
+ }
583
+ }