cartethyia 2.7.3 → 2.7.5

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 (625) hide show
  1. package/.env.example +2 -0
  2. package/README.md +17 -5
  3. package/dist/dashboard/assets/{BannedPage-2TJD37Hb.js → BannedPage-Cj15GsMp.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-Cj15GsMp.js.gz +0 -0
  5. package/dist/dashboard/assets/ClaimGiftsButton-D83pBJ6J.js +1 -0
  6. package/dist/dashboard/assets/ClaimGiftsButton-D83pBJ6J.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolDetailPage-xefJNRmJ.js → CliToolDetailPage-DZKmVuJN.js} +1 -1
  8. package/dist/dashboard/assets/CliToolDetailPage-DZKmVuJN.js.gz +0 -0
  9. package/dist/dashboard/assets/{CliToolsPage-Rj9iHQ3F.js → CliToolsPage-BIccZmeL.js} +1 -1
  10. package/dist/dashboard/assets/CliToolsPage-BIccZmeL.js.gz +0 -0
  11. package/dist/dashboard/assets/CombosPage-DEEKkYD-.js +1 -0
  12. package/dist/dashboard/assets/CombosPage-DEEKkYD-.js.gz +0 -0
  13. package/dist/dashboard/assets/ConsoleLogPage-D5rol2j2.js +1 -0
  14. package/dist/dashboard/assets/ConsoleLogPage-D5rol2j2.js.gz +0 -0
  15. package/dist/dashboard/assets/CustomizationPage-BKj_r0rp.js +1 -0
  16. package/dist/dashboard/assets/CustomizationPage-BKj_r0rp.js.gz +0 -0
  17. package/dist/dashboard/assets/InjectSystemPromptPanel-B5nZvmf0.js +656 -0
  18. package/dist/dashboard/assets/InjectSystemPromptPanel-B5nZvmf0.js.gz +0 -0
  19. package/dist/dashboard/assets/{LoginPage-C4w7vea-.js → LoginPage-DkXb6dx-.js} +1 -1
  20. package/dist/dashboard/assets/LoginPage-DkXb6dx-.js.gz +0 -0
  21. package/dist/dashboard/assets/{ModelPicker-BtPcAfot.js → ModelPicker-DnQPSIA8.js} +1 -1
  22. package/dist/dashboard/assets/ModelPicker-DnQPSIA8.js.gz +0 -0
  23. package/dist/dashboard/assets/OverviewPage-Ci2wQKUz.js +1 -0
  24. package/dist/dashboard/assets/OverviewPage-Ci2wQKUz.js.gz +0 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-odlgYrOg.js +5 -0
  26. package/dist/dashboard/assets/ProviderDetailPage-odlgYrOg.js.gz +0 -0
  27. package/dist/dashboard/assets/ProviderIcon-EfeloBn4.js +1 -0
  28. package/dist/dashboard/assets/ProviderIcon-EfeloBn4.js.gz +0 -0
  29. package/dist/dashboard/assets/ProvidersPage-CqFBQEB8.js +1 -0
  30. package/dist/dashboard/assets/ProvidersPage-CqFBQEB8.js.gz +0 -0
  31. package/dist/dashboard/assets/ProxyPage-5SnUSy3j.js +9 -0
  32. package/dist/dashboard/assets/ProxyPage-5SnUSy3j.js.gz +0 -0
  33. package/dist/dashboard/assets/QuotaPage-CmnMDzhP.js +1 -0
  34. package/dist/dashboard/assets/QuotaPage-CmnMDzhP.js.gz +0 -0
  35. package/dist/dashboard/assets/SettingsPage-BJtRZ4qD.js +1 -0
  36. package/dist/dashboard/assets/SettingsPage-BJtRZ4qD.js.gz +0 -0
  37. package/dist/dashboard/assets/{SetupPage-BQS0Rx_u.js → SetupPage-WTa0sktC.js} +1 -1
  38. package/dist/dashboard/assets/SetupPage-WTa0sktC.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-TP7voAQ0.js → StudioPage-BfAb5-o_.js} +3 -3
  40. package/dist/dashboard/assets/StudioPage-BfAb5-o_.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-BNzeZ2Vk.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-BNzeZ2Vk.js.gz +0 -0
  43. package/dist/dashboard/assets/api-keys-l29JROPv.js +1 -0
  44. package/dist/dashboard/assets/api-keys-l29JROPv.js.gz +0 -0
  45. package/dist/dashboard/assets/app--PJWq8_C.css +2 -0
  46. package/dist/dashboard/assets/app--PJWq8_C.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-C0YYuZJ4.js → assert-C0K-c4eo.js} +1 -1
  48. package/dist/dashboard/assets/assert-C0K-c4eo.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-B_DMpMJN.js → badge-DfbhQU4k.js} +1 -1
  50. package/dist/dashboard/assets/badge-DfbhQU4k.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-DMrMPmth.js → banner-BllT4LcF.js} +1 -1
  52. package/dist/dashboard/assets/banner-BllT4LcF.js.gz +0 -0
  53. package/dist/dashboard/assets/brand/login_register.jpg +0 -0
  54. package/dist/dashboard/assets/brand/login_register_initial.jpg +0 -0
  55. package/dist/dashboard/assets/brand/og_bansos.jpg +0 -0
  56. package/dist/dashboard/assets/brand/og_image.jpg +0 -0
  57. package/dist/dashboard/assets/{card-Be5Uzamx.js → card-CtkX66Wd.js} +1 -1
  58. package/dist/dashboard/assets/card-CtkX66Wd.js.gz +0 -0
  59. package/dist/dashboard/assets/chevron-left-Bf6CaTSJ.js +1 -0
  60. package/dist/dashboard/assets/chevron-left-Bf6CaTSJ.js.gz +0 -0
  61. package/dist/dashboard/assets/{cli-tools-muFRphDu.js → cli-tools-CQXVMn4P.js} +1 -1
  62. package/dist/dashboard/assets/cli-tools-CQXVMn4P.js.gz +0 -0
  63. package/dist/dashboard/assets/{clipboard-button-DOfrVmsi.js → clipboard-button-BQqBQd2n.js} +1 -1
  64. package/dist/dashboard/assets/clipboard-button-BQqBQd2n.js.gz +0 -0
  65. package/dist/dashboard/assets/{dialog-C9omdjQt.js → dialog-BEK7KP0H.js} +1 -1
  66. package/dist/dashboard/assets/dialog-BEK7KP0H.js.gz +0 -0
  67. package/dist/dashboard/assets/{drawer-Bm8WKaGY.js → drawer-BrTEZ8zu.js} +1 -1
  68. package/dist/dashboard/assets/drawer-BrTEZ8zu.js.gz +0 -0
  69. package/dist/dashboard/assets/entry-CHWxbS56.js +1 -0
  70. package/dist/dashboard/assets/entry-CHWxbS56.js.gz +0 -0
  71. package/dist/dashboard/assets/{entry-q5HwZ8nN.js → entry-EBHJ3DrE.js} +1 -1
  72. package/dist/dashboard/assets/entry-EBHJ3DrE.js.gz +0 -0
  73. package/dist/dashboard/assets/entry-LCS85hiq.js +4 -0
  74. package/dist/dashboard/assets/entry-LCS85hiq.js.gz +0 -0
  75. package/dist/dashboard/assets/{formatters-BTScrQpe.js → formatters-BAACA727.js} +1 -1
  76. package/dist/dashboard/assets/formatters-BAACA727.js.gz +0 -0
  77. package/dist/dashboard/assets/{github-badge-Orjuzemr.js → github-badge-YSCE4UC2.js} +1 -1
  78. package/dist/dashboard/assets/github-badge-YSCE4UC2.js.gz +0 -0
  79. package/dist/dashboard/assets/{index-cPrX5vn6.js → index-FQlj0HUT.js} +2 -2
  80. package/dist/dashboard/assets/index-FQlj0HUT.js.gz +0 -0
  81. package/dist/dashboard/assets/{inline-Dvux8w-0.js → inline-C4gU5iOL.js} +1 -1
  82. package/dist/dashboard/assets/inline-C4gU5iOL.js.gz +0 -0
  83. package/dist/dashboard/assets/{input-CU1Rgf3p.js → input-BHHX_Tmm.js} +1 -1
  84. package/dist/dashboard/assets/input-BHHX_Tmm.js.gz +0 -0
  85. package/dist/dashboard/assets/{layout-C_7A6H55.js → layout-CajrYUnD.js} +1 -1
  86. package/dist/dashboard/assets/{layout-C_7A6H55.js.gz → layout-CajrYUnD.js.gz} +0 -0
  87. package/dist/dashboard/assets/{list-row-CzFvogbj.js → list-row-Bx2I7XUe.js} +1 -1
  88. package/dist/dashboard/assets/list-row-Bx2I7XUe.js.gz +0 -0
  89. package/dist/dashboard/assets/live-DNNqRbj2.js +1 -0
  90. package/dist/dashboard/assets/live-DNNqRbj2.js.gz +0 -0
  91. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js +1 -0
  92. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js.gz +0 -0
  93. package/dist/dashboard/assets/{mutation-JJGQm8TD.js → mutation-Rr0TIvcj.js} +1 -1
  94. package/dist/dashboard/assets/mutation-Rr0TIvcj.js.gz +0 -0
  95. package/dist/dashboard/assets/network-C5UBACrJ.js +1 -0
  96. package/dist/dashboard/assets/network-C5UBACrJ.js.gz +0 -0
  97. package/dist/dashboard/assets/numbers-CUWAZK4J.js +2 -0
  98. package/dist/dashboard/assets/numbers-CUWAZK4J.js.gz +0 -0
  99. package/dist/dashboard/assets/pagination-Xsi9Qx04.js +1 -0
  100. package/dist/dashboard/assets/pagination-Xsi9Qx04.js.gz +0 -0
  101. package/dist/dashboard/assets/{query-keys-CLaNz8Xz.js → query-keys-BloKtaXJ.js} +1 -1
  102. package/dist/dashboard/assets/query-keys-BloKtaXJ.js.gz +0 -0
  103. package/dist/dashboard/assets/quota-DvQbD_sr.js +1 -0
  104. package/dist/dashboard/assets/quota-DvQbD_sr.js.gz +0 -0
  105. package/dist/dashboard/assets/{routing-BEH32QOC.js → routing-in0f_IfK.js} +1 -1
  106. package/dist/dashboard/assets/routing-in0f_IfK.js.gz +0 -0
  107. package/dist/dashboard/assets/{select-B3iTetHN.js → select-BLULNqTp.js} +2 -2
  108. package/dist/dashboard/assets/select-BLULNqTp.js.gz +0 -0
  109. package/dist/dashboard/assets/settings-CcGczj6I.js +1 -0
  110. package/dist/dashboard/assets/settings-CcGczj6I.js.gz +0 -0
  111. package/dist/dashboard/assets/shield-BMQNxORh.js +1 -0
  112. package/dist/dashboard/assets/shield-BMQNxORh.js.gz +0 -0
  113. package/dist/dashboard/assets/{stack-KrOrm7JZ.js → stack-D2lIR0tg.js} +1 -1
  114. package/dist/dashboard/assets/stack-D2lIR0tg.js.gz +0 -0
  115. package/dist/dashboard/assets/{state-DLS7IDMy.js → state-DTARjfiW.js} +1 -1
  116. package/dist/dashboard/assets/state-DTARjfiW.js.gz +0 -0
  117. package/dist/dashboard/assets/{switch-42ItvbAs.js → switch-c0oPSnqW.js} +1 -1
  118. package/dist/dashboard/assets/switch-c0oPSnqW.js.gz +0 -0
  119. package/dist/dashboard/assets/{useMutation-Dmnfh1Ue.js → useMutation-m8WGNPrz.js} +1 -1
  120. package/dist/dashboard/assets/useMutation-m8WGNPrz.js.gz +0 -0
  121. package/dist/dashboard/console-backgrounds.jpg +0 -0
  122. package/dist/dashboard/console-backgrounds_initial.jpg +0 -0
  123. package/dist/dashboard/index.html +2 -2
  124. package/dist/dashboard/index.html.gz +0 -0
  125. package/dist/dashboard/precompressed-assets.json +59 -57
  126. package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +9 -10
  127. package/migrations/full-postgres/0032_add_provider_pool_accounts.sql +53 -0
  128. package/migrations/full-postgres/0033_add_api_key_ban.sql +9 -0
  129. package/migrations/full-postgres/0034_add_api_key_ban_until.sql +7 -0
  130. package/migrations/full-postgres/0035_align_pool_account_status_enum.sql +21 -0
  131. package/migrations/full-postgres/0036_system_prompt_presets_refs.sql +10 -0
  132. package/migrations/full-postgres/0037_share_concurrency_scope.sql +3 -0
  133. package/migrations/full-postgres/0038_add_accounts_quota_snapshot.sql +7 -0
  134. package/migrations/full-postgres/0039_add_telemetry_provider_model_indexes.sql +13 -0
  135. package/migrations/full-postgres/0040_add_models_dialect.sql +3 -0
  136. package/migrations/full-postgres/0041_add_daily_checkin_ledger_account_fk.sql +7 -0
  137. package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +9 -10
  138. package/migrations/lite-sqlite3/0032_add_provider_pool_accounts.sql +44 -0
  139. package/migrations/lite-sqlite3/0033_add_api_key_ban.sql +8 -0
  140. package/migrations/lite-sqlite3/0034_add_api_key_ban_until.sql +5 -0
  141. package/migrations/lite-sqlite3/0035_align_pool_account_status_enum.sql +6 -0
  142. package/migrations/lite-sqlite3/0036_system_prompt_presets_refs.sql +14 -0
  143. package/migrations/lite-sqlite3/0037_share_concurrency_scope.sql +3 -0
  144. package/migrations/lite-sqlite3/0038_add_accounts_quota_snapshot.sql +4 -0
  145. package/migrations/lite-sqlite3/0039_add_telemetry_provider_model_indexes.sql +6 -0
  146. package/migrations/lite-sqlite3/0040_add_models_dialect.sql +3 -0
  147. package/migrations/lite-sqlite3/0041_add_daily_checkin_ledger_account_fk.sql +26 -0
  148. package/package.json +4 -5
  149. package/scripts/cli/update.ts +15 -41
  150. package/src/boot/app.ts +67 -10
  151. package/src/boot/dependencies.ts +786 -732
  152. package/src/boot/lifecycle.ts +11 -12
  153. package/src/config/cooldowns.ts +34 -55
  154. package/src/config/network.ts +8 -4
  155. package/src/config/package-paths.ts +1 -1
  156. package/src/config/providers.ts +13 -14
  157. package/src/config/resources.ts +0 -5
  158. package/src/config/runtime.ts +2 -5
  159. package/src/config/spec.ts +26 -6
  160. package/src/console/api-keys/ban-constants.ts +38 -0
  161. package/src/console/api-keys/contracts.ts +92 -31
  162. package/src/console/api-keys/routes.ts +131 -6
  163. package/src/console/audit/routes.ts +3 -3
  164. package/src/console/auth/access.ts +4 -4
  165. package/src/console/auth/account-reset.ts +5 -5
  166. package/src/console/auth/audit-recorder.ts +1 -1
  167. package/src/console/auth/lockout.ts +7 -3
  168. package/src/console/auth/row-coercion.ts +57 -12
  169. package/src/console/auth/session-resolver.ts +1 -1
  170. package/src/console/auth/session-service.ts +8 -2
  171. package/src/console/auth/session.ts +40 -34
  172. package/src/console/auth/setup-service.ts +4 -3
  173. package/src/console/auth/user-store.ts +1 -1
  174. package/src/console/backup/9router-map.ts +0 -3
  175. package/src/console/backup/contracts.ts +11 -9
  176. package/src/console/backup/export-endpoint.ts +11 -12
  177. package/src/console/backup/nine-router.ts +1 -7
  178. package/src/console/backup/routes.ts +32 -36
  179. package/src/console/backup/service.ts +468 -468
  180. package/src/console/backup/store.ts +47 -29
  181. package/src/console/backup/validate.ts +11 -1
  182. package/src/console/cli-tools/contracts.ts +7 -7
  183. package/src/console/cli-tools/injectors/cline.ts +110 -110
  184. package/src/console/cli-tools/injectors/codex.ts +181 -181
  185. package/src/console/cli-tools/injectors/copilot.ts +108 -108
  186. package/src/console/cli-tools/injectors/driver.ts +247 -247
  187. package/src/console/cli-tools/injectors/jcode.ts +115 -115
  188. package/src/console/cli-tools/injectors/openclaw.ts +161 -161
  189. package/src/console/cli-tools/injectors/opencode.ts +153 -153
  190. package/src/console/cli-tools/routes.ts +4 -4
  191. package/src/console/cli-tools/service.ts +1 -1
  192. package/src/console/cli-tools/store.ts +2 -2
  193. package/src/console/console-router-guards.ts +2 -2
  194. package/src/console/console-router.ts +36 -4
  195. package/src/console/dashboard-assets.ts +9 -9
  196. package/src/console/data-management/routes.ts +3 -3
  197. package/src/console/domain-registration.ts +18 -4
  198. package/src/console/inferhub/client.ts +0 -7
  199. package/src/console/inferhub/key-store.ts +1 -1
  200. package/src/console/inferhub/normalize.ts +2 -2
  201. package/src/console/inferhub/routes.ts +1 -1
  202. package/src/console/inferhub/types.ts +1 -1
  203. package/src/console/observability/contracts.ts +5 -1
  204. package/src/console/observability/live.ts +34 -21
  205. package/src/console/observability/logs.ts +4 -4
  206. package/src/console/observability/performance.ts +3 -3
  207. package/src/console/observability/routes.ts +3 -3
  208. package/src/console/observability/sse.ts +62 -8
  209. package/src/console/observability/store.ts +36 -20
  210. package/src/console/providers/catalog/catalog-projections.ts +1 -1
  211. package/src/console/providers/catalog/contracts.ts +82 -22
  212. package/src/console/providers/catalog/model-operations.ts +1 -1
  213. package/src/console/providers/catalog/provider-operations.ts +110 -17
  214. package/src/console/providers/catalog/public-model-store.ts +0 -0
  215. package/src/console/providers/catalog/routes.ts +30 -6
  216. package/src/console/providers/catalog/store-helpers.ts +349 -326
  217. package/src/console/providers/catalog/store.ts +1662 -1292
  218. package/src/console/providers/detail/routes.ts +4 -4
  219. package/src/console/providers/oauth/callback-listener.ts +100 -18
  220. package/src/console/providers/oauth/routes.ts +95 -16
  221. package/src/console/quota/account-quota-global.ts +41 -3
  222. package/src/console/quota/account-quota-shared.ts +10 -3
  223. package/src/console/quota/account-quota-tenant.ts +285 -67
  224. package/src/console/quota/account-test-egress.ts +4 -4
  225. package/src/console/quota/daily-growth-job.ts +308 -0
  226. package/src/console/quota/native-identity.ts +2 -2
  227. package/src/console/quota/quota-view.ts +1 -1
  228. package/src/console/remote-support/routes.ts +1 -1
  229. package/src/console/remote-support/service.ts +2 -2
  230. package/src/console/routing/model/contracts.ts +1 -1
  231. package/src/console/routing/model/routes.ts +3 -3
  232. package/src/console/routing/pools/contracts.ts +13 -10
  233. package/src/console/routing/pools/import-parse.ts +3 -3
  234. package/src/console/routing/pools/operations.ts +14 -7
  235. package/src/console/routing/pools/probe-verdict.ts +8 -3
  236. package/src/console/routing/pools/relay-deploy.ts +12 -12
  237. package/src/console/routing/pools/routes.ts +3 -3
  238. package/src/console/routing/pools/store.ts +21 -1
  239. package/src/console/settings/contracts.ts +29 -2
  240. package/src/console/settings/routes.ts +3 -3
  241. package/src/console/settings/store.ts +20 -0
  242. package/src/console/share/share-router.ts +112 -44
  243. package/src/console/share/share-stats.ts +109 -39
  244. package/src/console/share/share-usage.ts +1 -1
  245. package/src/console/shared/errors.ts +2 -2
  246. package/src/console/shared/object.ts +18 -0
  247. package/src/console/shared/read-through-cache.ts +42 -6
  248. package/src/console/shared/transaction.ts +14 -0
  249. package/src/console/system-prompt/contracts.ts +89 -12
  250. package/src/console/system-prompt/routes.ts +98 -45
  251. package/src/console/updates/apply-update.ts +244 -0
  252. package/src/console/updates/routes.ts +256 -98
  253. package/src/main.ts +11 -5
  254. package/src/network/http2-bidi.ts +1 -1
  255. package/src/network/ip-utils.ts +298 -0
  256. package/src/network/outbound-fetch.ts +76 -5
  257. package/src/network/pool/agent.ts +7 -6
  258. package/src/network/pool/byte-accounting.ts +1 -1
  259. package/src/network/pool/resolver.ts +55 -51
  260. package/src/network/pool/selector.ts +150 -136
  261. package/src/network/pool-cooldown-store.ts +71 -16
  262. package/src/network/pool-health-machine.ts +22 -2
  263. package/src/network/rate-limit-verdict.ts +68 -0
  264. package/src/network/retry.ts +1 -1
  265. package/src/network/ssrf.ts +24 -75
  266. package/src/observability/event-log.ts +2 -2
  267. package/src/observability/event-query.ts +286 -214
  268. package/src/observability/log-sanitize.ts +1 -1
  269. package/src/observability/metrics.ts +4 -4
  270. package/src/observability/payload-capture.ts +6 -5
  271. package/src/observability/payload-store.ts +1 -1
  272. package/src/observability/runtime-metrics.ts +1 -1
  273. package/src/observability/telemetry-buffer.ts +12 -3
  274. package/src/observability/telemetry-reconciliation.ts +3 -3
  275. package/src/observability/telemetry-status.ts +1 -1
  276. package/src/observability/token-speed.ts +1 -1
  277. package/src/persistence/connection-url.ts +1 -1
  278. package/src/persistence/db-handle.ts +1 -1
  279. package/src/persistence/db-pg.ts +2 -2
  280. package/src/persistence/db-sqlite.ts +10 -5
  281. package/src/persistence/dialect.ts +5 -1
  282. package/src/persistence/readiness.ts +19 -7
  283. package/src/persistence/redis.ts +1 -1
  284. package/src/persistence/schema.ts +177 -45
  285. package/src/persistence/shared-cache.ts +2 -2
  286. package/src/persistence/sort-order.ts +1 -1
  287. package/src/persistence/stores/api-key-store.ts +109 -4
  288. package/src/persistence/stores/share-store.ts +20 -8
  289. package/src/persistence/stores/telemetry-store.ts +1 -1
  290. package/src/persistence/system-prompt-store.ts +63 -18
  291. package/src/persistence/tenant-preferences.ts +1 -1
  292. package/src/persistence/ttl-cache.ts +3 -3
  293. package/src/persistence/usage-flush.ts +7 -2
  294. package/src/protocol/canonical-model.ts +1 -1
  295. package/src/protocol/codecs.ts +12 -16
  296. package/src/protocol/dsml.ts +3 -49
  297. package/src/protocol/gateway-error.ts +6 -3
  298. package/src/protocol/image-clamp.ts +2 -2
  299. package/src/protocol/image-generation.ts +2 -2
  300. package/src/protocol/reasoning-text.ts +1 -1
  301. package/src/protocol/reasoning.ts +7 -52
  302. package/src/protocol/registry.ts +50 -15
  303. package/src/protocol/request/chat.ts +46 -19
  304. package/src/protocol/request/codex.ts +1 -1
  305. package/src/protocol/request/messages.ts +46 -14
  306. package/src/protocol/request/responses.ts +19 -10
  307. package/src/protocol/response/chat.ts +27 -12
  308. package/src/protocol/response/codex.ts +2 -2
  309. package/src/protocol/response/gemini.ts +1 -1
  310. package/src/protocol/response/messages.ts +6 -6
  311. package/src/protocol/response/omp-tool-call-markup.ts +1 -1
  312. package/src/protocol/response/responses.ts +3 -3
  313. package/src/protocol/sse-decoder.ts +64 -4
  314. package/src/protocol/stop-reason.ts +2 -2
  315. package/src/protocol/think-split.ts +347 -0
  316. package/src/protocol/translation/attachment-integrity.ts +3 -3
  317. package/src/protocol/translation/cache-controls.ts +8 -20
  318. package/src/protocol/translation/capabilities.ts +8 -8
  319. package/src/protocol/translation/dialect/deepseek.ts +10 -0
  320. package/src/protocol/translation/dialect/inband.ts +39 -0
  321. package/src/protocol/translation/dialect/model-thinking.ts +89 -0
  322. package/src/protocol/translation/dialect/qwen.ts +114 -0
  323. package/src/protocol/translation/dialect/resolver.ts +83 -0
  324. package/src/protocol/translation/dialect/rules.ts +93 -0
  325. package/src/protocol/translation/dialect/thinking-ladder.ts +245 -0
  326. package/src/protocol/translation/dialect/types.ts +58 -0
  327. package/src/protocol/translation/quirks.ts +18 -4
  328. package/src/protocol/translation/thinking.ts +12 -252
  329. package/src/protocol/translation/tool-id.ts +3 -3
  330. package/src/protocol/translation/tool-repair.ts +1 -1
  331. package/src/protocol/upstream/post-json.ts +6 -1
  332. package/src/protocol/upstream/send-claude-messages.ts +3 -3
  333. package/src/providers/adapters/base-adapter.ts +1 -1
  334. package/src/providers/adapters/compatible-adapter.ts +44 -5
  335. package/src/providers/authentication/jwt-validator.ts +0 -5
  336. package/src/providers/authentication/oauth-client.ts +254 -253
  337. package/src/providers/authentication/oauth-flow-store.ts +42 -7
  338. package/src/providers/authentication/oauth-refresh-service.ts +2 -2
  339. package/src/providers/bundled-discovery-providers.ts +6 -30
  340. package/src/providers/configured-openai-providers.ts +82 -97
  341. package/src/providers/configured-provider.ts +1 -1
  342. package/src/providers/default-registry.ts +23 -35
  343. package/src/providers/discovery/catalog/catalog-format.ts +13 -2
  344. package/src/providers/discovery/discovery-types.ts +0 -1
  345. package/src/providers/discovery/openai-model-discovery.ts +2 -2
  346. package/src/providers/discovery/probe-phases.ts +50 -15
  347. package/src/providers/discovery/probing-service.ts +18 -20
  348. package/src/providers/integrations/agentrouter/agentrouter.ts +14 -5
  349. package/src/providers/integrations/alibabacp/alibabacp.ts +2 -2
  350. package/src/providers/integrations/anthropic.ts +4 -11
  351. package/src/providers/integrations/antigravity/antigravity-oauth.ts +0 -1
  352. package/src/providers/integrations/antigravity/antigravity-protocol.ts +0 -14
  353. package/src/providers/integrations/antigravity/antigravity.ts +15 -26
  354. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +2 -2
  355. package/src/providers/integrations/buddy/buddy-chat-shared.ts +20 -4
  356. package/src/providers/integrations/buddy/buddy-cli-headers.ts +19 -52
  357. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +37 -6
  358. package/src/providers/integrations/buddy/buddy-identity.ts +108 -1
  359. package/src/providers/integrations/buddy/buddy-media.ts +16 -16
  360. package/src/providers/integrations/buddy/buddy-oauth-shared.ts +1 -1
  361. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +1 -2
  362. package/src/providers/integrations/buddy/codebuddy/provider.ts +5 -16
  363. package/src/providers/integrations/buddy/codebuddy/shared.ts +30 -9
  364. package/src/providers/integrations/buddy/workbuddy/provider.ts +6 -10
  365. package/src/providers/integrations/buddy/workbuddy/shared.ts +21 -3
  366. package/src/providers/integrations/claude/claude-betas.ts +1 -1
  367. package/src/providers/integrations/claude/claude-cch.ts +148 -148
  368. package/src/providers/integrations/claude/claude-credentials.ts +1 -1
  369. package/src/providers/integrations/claude/claude-slow-lane-retry.ts +1 -1
  370. package/src/providers/integrations/claude/claude.ts +3 -10
  371. package/src/providers/integrations/claude/fallback-credit.ts +3 -7
  372. package/src/providers/integrations/cline/cline.ts +2 -2
  373. package/src/providers/integrations/cloudflareai/cloudflareai.ts +4 -7
  374. package/src/providers/integrations/codex/codex-device-code.ts +101 -101
  375. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  376. package/src/providers/integrations/codex/codex-oauth.ts +0 -1
  377. package/src/providers/integrations/codex/codex.ts +14 -15
  378. package/src/providers/integrations/commandcode/commandcode.ts +76 -116
  379. package/src/providers/integrations/commandcode/quota.ts +132 -48
  380. package/src/providers/integrations/cursor/cursor-http2.ts +11 -14
  381. package/src/providers/integrations/cursor/cursor-quota.ts +2 -6
  382. package/src/providers/integrations/cursor/cursor-request.ts +0 -87
  383. package/src/providers/integrations/cursor/cursor-shared.ts +0 -4
  384. package/src/providers/integrations/cursor/cursor.ts +53 -20
  385. package/src/providers/integrations/cursor/protocol/framing.ts +0 -56
  386. package/src/providers/integrations/cursor/protocol/messages.ts +0 -96
  387. package/src/providers/integrations/factory/factory.ts +13 -13
  388. package/src/providers/integrations/gemini.ts +11 -11
  389. package/src/providers/integrations/github/github-discovery.ts +55 -10
  390. package/src/providers/integrations/github/github-oauth.ts +6 -5
  391. package/src/providers/integrations/github/github.ts +47 -20
  392. package/src/providers/integrations/grok/grok-oauth.ts +261 -261
  393. package/src/providers/integrations/grok/grok-quota-frame.ts +1 -1
  394. package/src/providers/integrations/grok/grok-quota.ts +16 -7
  395. package/src/providers/integrations/grok/grok-ratelimit-store.ts +0 -5
  396. package/src/providers/integrations/grok/grok-ratelimit.ts +3 -0
  397. package/src/providers/integrations/grok/grok.ts +88 -42
  398. package/src/providers/integrations/inferhub.ts +4 -11
  399. package/src/providers/integrations/kilo/kilo-oauth.ts +1 -1
  400. package/src/providers/integrations/kimi/kimi.ts +4 -11
  401. package/src/providers/integrations/kiro/kiro-cache.ts +31 -0
  402. package/src/providers/integrations/kiro/kiro-catalog.ts +31 -5
  403. package/src/providers/integrations/kiro/kiro-discovery.ts +47 -43
  404. package/src/providers/integrations/kiro/kiro-machine-id.ts +6 -3
  405. package/src/providers/integrations/kiro/kiro-oauth.ts +523 -255
  406. package/src/providers/integrations/kiro/kiro-quota.ts +35 -47
  407. package/src/providers/integrations/kiro/kiro-request.ts +8 -11
  408. package/src/providers/integrations/kiro/kiro-stream.ts +13 -0
  409. package/src/providers/integrations/kiro/kiro.ts +91 -78
  410. package/src/providers/integrations/muse/muse-oauth.ts +0 -1
  411. package/src/providers/integrations/muse/muse-quota.ts +0 -4
  412. package/src/providers/integrations/muse/muse.ts +6 -9
  413. package/src/providers/integrations/opencode/fingerprint.ts +7 -5
  414. package/src/providers/integrations/opencode/provider.ts +17 -24
  415. package/src/providers/integrations/perplexity.ts +2 -2
  416. package/src/providers/integrations/qoder/qoder-catalog.ts +132 -17
  417. package/src/providers/integrations/qoder/qoder-checkin.ts +26 -8
  418. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +9 -8
  419. package/src/providers/integrations/qoder/qoder-oauth.ts +165 -25
  420. package/src/providers/integrations/qoder/qoder-quota.ts +48 -6
  421. package/src/providers/integrations/qoder/qoder-realm.ts +22 -28
  422. package/src/providers/integrations/qoder/qoder-sse.ts +159 -0
  423. package/src/providers/integrations/qoder/qoder-umid.ts +33 -38
  424. package/src/providers/integrations/qoder/qoder-virtual.ts +14 -2
  425. package/src/providers/integrations/qoder/qoder.ts +521 -63
  426. package/src/providers/integrations/shared/connect.ts +0 -54
  427. package/src/providers/integrations/shared/hash.ts +15 -0
  428. package/src/providers/integrations/shared/session-cache.ts +136 -0
  429. package/src/providers/integrations/tokenharbor.ts +2 -2
  430. package/src/providers/integrations/trae/trae-cache.ts +7 -87
  431. package/src/providers/integrations/trae/trae-catalog.ts +1 -6
  432. package/src/providers/integrations/trae/trae-protocol.ts +17 -8
  433. package/src/providers/integrations/trae/trae-realm.ts +0 -7
  434. package/src/providers/integrations/trae/trae.ts +59 -19
  435. package/src/providers/integrations/xai/xai-oauth.ts +5 -5
  436. package/src/providers/integrations/zcode/oauth.ts +1 -1
  437. package/src/providers/integrations/zcode-start-plan/gifts.ts +265 -0
  438. package/src/providers/integrations/zcode-start-plan/oauth.ts +154 -0
  439. package/src/providers/integrations/zcode-start-plan/provider.ts +131 -0
  440. package/src/providers/integrations/zcode-start-plan/quota.ts +152 -0
  441. package/src/providers/integrations/zcode-start-plan/zcode_prompt.json +10 -0
  442. package/src/providers/operations/account-error-classification.ts +131 -116
  443. package/src/providers/operations/account-health-service.ts +31 -39
  444. package/src/providers/operations/account-reset-service.ts +58 -1
  445. package/src/providers/operations/cli-platform.ts +31 -5
  446. package/src/providers/operations/client-version-resolver.ts +4 -6
  447. package/src/providers/operations/client-versions.ts +10 -7
  448. package/src/providers/operations/provider-catalog-seeder.ts +2 -0
  449. package/src/providers/operations/provider-catalog-service.ts +22 -6
  450. package/src/providers/operations/provider-credential-service.ts +35 -6
  451. package/src/providers/operations/upstream-deadline.ts +3 -3
  452. package/src/providers/provider-contracts.ts +65 -10
  453. package/src/providers/provider-credential-resolver.ts +1 -1
  454. package/src/providers/provider-metadata.ts +17 -2
  455. package/src/providers/provider-model-definition.ts +22 -1
  456. package/src/providers/provider-probe.ts +4 -2
  457. package/src/providers/provider-registry.ts +4 -4
  458. package/src/providers/provider-usage.ts +81 -16
  459. package/src/providers/quota/cache.ts +29 -0
  460. package/src/providers/quota/quota-contracts.ts +28 -10
  461. package/src/providers/quota/refresh.ts +29 -6
  462. package/src/providers/quota/snapshot.ts +94 -0
  463. package/src/providers/search/search-provider.ts +6 -8
  464. package/src/providers/search/search-providers.ts +2 -1
  465. package/src/security/abuse.ts +8 -8
  466. package/src/security/admission/reasons.ts +17 -17
  467. package/src/security/admission/service.ts +2 -2
  468. package/src/security/admission/ttl.ts +61 -61
  469. package/src/security/api-key-auth.ts +20 -8
  470. package/src/security/client-router-fingerprint.ts +4 -4
  471. package/src/security/credential-codec.ts +3 -3
  472. package/src/security/csrf.ts +2 -2
  473. package/src/security/ip-boundary.ts +16 -189
  474. package/src/security/model-access-rule.ts +1 -1
  475. package/src/security/outbound-headers.ts +18 -0
  476. package/src/shared/coerce.ts +19 -0
  477. package/src/shared/invoke-guard.ts +47 -0
  478. package/src/transport/contracts.ts +13 -4
  479. package/src/transport/dispatch/attempt-finalize.ts +5 -4
  480. package/src/transport/dispatch/attempt-loop.ts +10 -7
  481. package/src/transport/dispatch/capture-policy.ts +34 -37
  482. package/src/transport/dispatch/empty-stream.ts +28 -25
  483. package/src/transport/dispatch/generation.ts +33 -53
  484. package/src/transport/dispatch/leases.ts +8 -8
  485. package/src/transport/dispatch/native-shared.ts +127 -0
  486. package/src/transport/dispatch/proxy-request.ts +10 -8
  487. package/src/transport/dispatch/responses-compact.ts +35 -56
  488. package/src/transport/dispatch/retry-policy.ts +4 -67
  489. package/src/transport/dispatch/stream-bridge.ts +1 -1
  490. package/src/transport/dispatch/streaming/encoder.ts +18 -72
  491. package/src/transport/dispatch/streaming/finalize.ts +11 -10
  492. package/src/transport/dispatch/streaming/stream.ts +7 -3
  493. package/src/transport/dispatch/streaming/thinking-loop.ts +1 -1
  494. package/src/transport/dispatch/systemone.ts +27 -55
  495. package/src/transport/dispatch/token-count.ts +49 -46
  496. package/src/transport/dispatch/websearch.ts +24 -50
  497. package/src/transport/drain-endpoint.ts +3 -3
  498. package/src/transport/failure-policy.ts +19 -6
  499. package/src/transport/guards/auth-guard.ts +6 -11
  500. package/src/transport/guards/body-policy.ts +7 -7
  501. package/src/transport/guards/error-lifecycle.ts +18 -3
  502. package/src/transport/guards/guard-errors.ts +7 -3
  503. package/src/transport/guards/headroom-guard.ts +2 -2
  504. package/src/transport/guards/pipeline.ts +7 -7
  505. package/src/transport/guards/readiness-guard.ts +16 -2
  506. package/src/transport/guards/request-context.ts +15 -20
  507. package/src/transport/request/preparer.ts +306 -27
  508. package/src/transport/request/rtk/compress-request.ts +3 -3
  509. package/src/transport/request/rtk/rtk-constants.ts +2 -2
  510. package/src/transport/request/rtk/rtk-filters.ts +8 -8
  511. package/src/transport/request/state.ts +5 -20
  512. package/src/transport/request/system-prompt-injection.ts +74 -11
  513. package/src/transport/request/tenant-preferences.ts +22 -0
  514. package/src/transport/routing/candidate-index.ts +2 -2
  515. package/src/transport/routing/round-robin.ts +1 -1
  516. package/src/transport/routing/route-catalog.ts +113 -19
  517. package/src/transport/routing/route-model.ts +131 -38
  518. package/src/transport/routing/router.ts +154 -39
  519. package/src/transport/shutdown-notice.ts +21 -9
  520. package/src/transport/surface/adapters.ts +3 -3
  521. package/src/transport/surface/chat/encode.ts +7 -7
  522. package/src/transport/surface/chat/parse.ts +7 -7
  523. package/src/transport/surface/completion.ts +3 -3
  524. package/src/transport/surface/content-parts.ts +1 -1
  525. package/src/transport/surface/dialects.ts +2 -2
  526. package/src/transport/surface/messages/encode.ts +1 -1
  527. package/src/transport/surface/messages/parse.ts +3 -3
  528. package/src/transport/surface/responses/adapter.ts +1 -1
  529. package/src/transport/surface/responses/errors.ts +0 -9
  530. package/src/transport/vision-handoff-cache.ts +58 -0
  531. package/src/transport/vision-handoff.ts +376 -0
  532. package/src/workers/account-identity-sweep.ts +2 -2
  533. package/src/workers/buddy-growth-tasks.ts +392 -346
  534. package/src/workers/daily-checkin.ts +102 -49
  535. package/src/workers/maintenance-lease.ts +2 -35
  536. package/src/workers/oauth-refresh-sweep.ts +8 -1
  537. package/src/workers/quota-refresh-sweep.ts +120 -82
  538. package/src/workers/scheduler.ts +67 -4
  539. package/src/workers/sweep-runner.ts +24 -5
  540. package/src/workers/zcode-gift-claim.ts +108 -0
  541. package/dist/dashboard/assets/AccountCooldown-Bjo92Qat.js +0 -1
  542. package/dist/dashboard/assets/AccountCooldown-Bjo92Qat.js.gz +0 -0
  543. package/dist/dashboard/assets/BannedPage-2TJD37Hb.js.gz +0 -0
  544. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js.gz +0 -0
  545. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js.gz +0 -0
  546. package/dist/dashboard/assets/CombosPage-JEbYJFxw.js +0 -1
  547. package/dist/dashboard/assets/CombosPage-JEbYJFxw.js.gz +0 -0
  548. package/dist/dashboard/assets/ConsoleLogPage-C3xx_inM.js +0 -1
  549. package/dist/dashboard/assets/ConsoleLogPage-C3xx_inM.js.gz +0 -0
  550. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js +0 -1
  551. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js.gz +0 -0
  552. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js +0 -1
  553. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js.gz +0 -0
  554. package/dist/dashboard/assets/LoginPage-C4w7vea-.js.gz +0 -0
  555. package/dist/dashboard/assets/ModelPicker-BtPcAfot.js.gz +0 -0
  556. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js +0 -1
  557. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js.gz +0 -0
  558. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js +0 -4
  559. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js.gz +0 -0
  560. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +0 -1
  561. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
  562. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js +0 -1
  563. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js.gz +0 -0
  564. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js +0 -9
  565. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js.gz +0 -0
  566. package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js +0 -1
  567. package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js.gz +0 -0
  568. package/dist/dashboard/assets/SettingsPage-CDZjTt87.js +0 -1
  569. package/dist/dashboard/assets/SettingsPage-CDZjTt87.js.gz +0 -0
  570. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js.gz +0 -0
  571. package/dist/dashboard/assets/SortableList-BAL5npBC.js +0 -1
  572. package/dist/dashboard/assets/SortableList-BAL5npBC.js.gz +0 -0
  573. package/dist/dashboard/assets/StudioPage-TP7voAQ0.js.gz +0 -0
  574. package/dist/dashboard/assets/UsagePage-Cj_fEzhg.js +0 -56
  575. package/dist/dashboard/assets/UsagePage-Cj_fEzhg.js.gz +0 -0
  576. package/dist/dashboard/assets/api-keys-MbI7e7rt.js +0 -1
  577. package/dist/dashboard/assets/api-keys-MbI7e7rt.js.gz +0 -0
  578. package/dist/dashboard/assets/app-B2vyPoII.css +0 -2
  579. package/dist/dashboard/assets/app-B2vyPoII.css.gz +0 -0
  580. package/dist/dashboard/assets/assert-C0YYuZJ4.js.gz +0 -0
  581. package/dist/dashboard/assets/badge-B_DMpMJN.js.gz +0 -0
  582. package/dist/dashboard/assets/banner-DMrMPmth.js.gz +0 -0
  583. package/dist/dashboard/assets/card-Be5Uzamx.js.gz +0 -0
  584. package/dist/dashboard/assets/cli-tools-muFRphDu.js.gz +0 -0
  585. package/dist/dashboard/assets/clipboard-button-DOfrVmsi.js.gz +0 -0
  586. package/dist/dashboard/assets/dialog-C9omdjQt.js.gz +0 -0
  587. package/dist/dashboard/assets/drawer-Bm8WKaGY.js.gz +0 -0
  588. package/dist/dashboard/assets/entry-DS90gUks.js +0 -1
  589. package/dist/dashboard/assets/entry-DS90gUks.js.gz +0 -0
  590. package/dist/dashboard/assets/entry-j40LJotO.js +0 -4
  591. package/dist/dashboard/assets/entry-j40LJotO.js.gz +0 -0
  592. package/dist/dashboard/assets/entry-q5HwZ8nN.js.gz +0 -0
  593. package/dist/dashboard/assets/formatters-BTScrQpe.js.gz +0 -0
  594. package/dist/dashboard/assets/github-badge-Orjuzemr.js.gz +0 -0
  595. package/dist/dashboard/assets/index-cPrX5vn6.js.gz +0 -0
  596. package/dist/dashboard/assets/inline-Dvux8w-0.js.gz +0 -0
  597. package/dist/dashboard/assets/input-CU1Rgf3p.js.gz +0 -0
  598. package/dist/dashboard/assets/list-row-CzFvogbj.js.gz +0 -0
  599. package/dist/dashboard/assets/live-KaG15HLQ.js +0 -1
  600. package/dist/dashboard/assets/live-KaG15HLQ.js.gz +0 -0
  601. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +0 -1
  602. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  603. package/dist/dashboard/assets/mutation-JJGQm8TD.js.gz +0 -0
  604. package/dist/dashboard/assets/numbers-BNDZnsUE.js +0 -2
  605. package/dist/dashboard/assets/numbers-BNDZnsUE.js.gz +0 -0
  606. package/dist/dashboard/assets/query-keys-CLaNz8Xz.js.gz +0 -0
  607. package/dist/dashboard/assets/quota-DZGyp0N5.js +0 -1
  608. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  609. package/dist/dashboard/assets/routing-BEH32QOC.js.gz +0 -0
  610. package/dist/dashboard/assets/select-B3iTetHN.js.gz +0 -0
  611. package/dist/dashboard/assets/settings-Dt3Eqbnq.js +0 -1
  612. package/dist/dashboard/assets/settings-Dt3Eqbnq.js.gz +0 -0
  613. package/dist/dashboard/assets/stack-KrOrm7JZ.js.gz +0 -0
  614. package/dist/dashboard/assets/state-DLS7IDMy.js.gz +0 -0
  615. package/dist/dashboard/assets/stream-retry-5QnVuSSo.js +0 -1
  616. package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
  617. package/dist/dashboard/assets/switch-42ItvbAs.js.gz +0 -0
  618. package/dist/dashboard/assets/useMutation-Dmnfh1Ue.js.gz +0 -0
  619. package/src/network/response-headers.ts +0 -39
  620. package/src/protocol/response/dsml.ts +0 -38
  621. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +0 -229
  622. /package/dist/dashboard/assets/{app-wvwCi4Eh.js → app-Bgd3zANQ.js} +0 -0
  623. /package/dist/dashboard/assets/{app-wvwCi4Eh.js.gz → app-Bgd3zANQ.js.gz} +0 -0
  624. /package/dist/dashboard/assets/brand/{og_bansos.webp → og_bansos_initial.webp} +0 -0
  625. /package/dist/dashboard/assets/brand/{og_image.webp → og_image_initial.webp} +0 -0
@@ -1,468 +1,468 @@
1
- /**
2
- * Backup and restore operations, plus the import path for a router export.
3
- *
4
- * Two things every entry point here has in common:
5
- *
6
- * 1. **Nothing is written before the whole payload validates.** Validation
7
- * resolves every table and column against the live schema, so a stale or
8
- * hostile file fails with the offending name and the database is untouched.
9
- * 2. **Everything happens in one transaction** (see `store.applyRestore`), so a
10
- * mid-restore SQL error rolls back rather than leaving half a
11
- * configuration — which is the state that makes an operator's day worse than
12
- * a plain failure would.
13
- *
14
- * Export is a JSON object at the operator's explicit request, and it carries
15
- * provider credentials and API keys decrypted — the only form in which they can
16
- * be restored into an install with a different encryption key (see
17
- * {@link sealedColumns}). So the file is as sensitive as the database: the layer
18
- * doc and the dashboard copy say so, and export requires password
19
- * re-authentication.
20
- */
21
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
22
- import type { Table } from "drizzle-orm";
23
- import {
24
- models,
25
- providerAccounts,
26
- providerOauthStates,
27
- providerRoutingSettings,
28
- providers,
29
- tenantDisabledModels,
30
- } from "../../persistence/schema";
31
- import { ConsoleDomainError } from "../shared/errors";
32
- import { tablesForSection, tableName } from "./contracts";
33
- import type {
34
- BackupSection,
35
- RestoreWarning,
36
- ValidatedRestore,
37
- ValidatedTable,
38
- } from "./contracts";
39
- import {
40
- applyRestore,
41
- decompressBackup,
42
- deleteAll,
43
- exportBackupStream,
44
- type BackupExportStream,
45
- type DeleteAllResult,
46
- type DeleteAllScope,
47
- } from "./store";
48
- import { detectFormat, restoreOrder, validateRestorePayload } from "./validate";
49
- import { convert9RouterBackup, type ImportReport } from "./nine-router";
50
- import { isRecord } from "../../protocol/codecs";
51
-
52
- export interface BackupServiceOptions {
53
- readonly db: CartethyiaDatabase;
54
- /** Verifies the signed-in operator's console password; supplied by the router. */
55
- readonly verifyPassword: (password: string) => Promise<boolean>;
56
- }
57
-
58
- export interface ExportOptions {
59
- readonly sections?: readonly BackupSection[];
60
- readonly password: string;
61
- /** The tenant whose rows are exported. Never another tenant's. */
62
- readonly tenantId: string;
63
- /** Gzips the file. Changes nothing about what the payload carries. */
64
- readonly compressed?: boolean;
65
- }
66
-
67
- /** One table as it appears in a backup file, and whether it can be restored. */
68
- export interface BackupInspectionRow {
69
- readonly section: string;
70
- readonly table: string;
71
- readonly count: number;
72
- /** False when the current schema has no such table: restore will skip it. */
73
- readonly supported: boolean;
74
- }
75
-
76
- /**
77
- * A backup's cover, read without writing.
78
- *
79
- * `warnings` carries what a real restore would leave out, so the operator sees
80
- * the loss before committing rather than after.
81
- */
82
- export interface BackupInspection {
83
- readonly format: "native" | "nine_router";
84
- readonly version: number;
85
- readonly exportedAt: string;
86
- readonly rows: readonly BackupInspectionRow[];
87
- readonly total: number;
88
- readonly warnings: readonly RestoreWarning[];
89
- }
90
-
91
- export interface ImportResult {
92
- readonly restored: Record<string, number>;
93
- readonly skipped: Record<string, number>;
94
- /** Parts of the file that could not be restored. Empty means it all applied. */
95
- readonly warnings: readonly RestoreWarning[];
96
- readonly report?: ImportReport;
97
- readonly format: "native" | "nine_router";
98
- }
99
-
100
- /**
101
- * Which parts of a backup file a restore should actually write.
102
- *
103
- * Absent or empty means "everything", which is the historical behavior and
104
- * what the dashboard's checklist defaults to. A table named here but absent
105
- * from the file is simply not restored — it is not an error, because the
106
- * checklist is built from an inspection of *this* file and a selection never
107
- * invents a table.
108
- */
109
- export interface BackupRestoreSelection {
110
- /** Table names to restore; absent or empty restores every table present. */
111
- readonly tables?: readonly string[];
112
- }
113
-
114
- /**
115
- * Narrows a validated backup to the tables the operator selected.
116
- *
117
- * The tenant row is the one thing that is not independently selectable: it is
118
- * not a table in either section (`BACKUP_SECTIONS` are config and telemetry),
119
- * so it never appears on the checklist, and every tenant-scoped table
120
- * cascades from it. Dropping it because it was not named would make a partial
121
- * restore fail on a foreign key for a table the operator *did* ask for. So it
122
- * rides along with any config selection and is written only when one is made.
123
- *
124
- * A table named in the selection but missing from the file is a no-op rather
125
- * than an error: the checklist is built from an inspection of this file, but
126
- * the selection is validated as names only, and refusing to restore anything
127
- * over one stale name would be worse than ignoring it.
128
- */
129
- function restrictToSelection(
130
- validation: ValidatedRestore,
131
- selection: BackupRestoreSelection | undefined,
132
- ): ValidatedRestore {
133
- const wanted = selection?.tables;
134
- if (wanted === undefined || wanted.length === 0) return validation;
135
- const selected = new Set(wanted);
136
- const tables = new Map<Table, ValidatedTable>();
137
- for (const [table, entry] of validation.tables) {
138
- if (selected.has(tableName(table))) tables.set(table, entry);
139
- }
140
- return {
141
- ...validation,
142
- tables,
143
- tenantRows: tables.size > 0 ? validation.tenantRows : [],
144
- };
145
- }
146
-
147
- function sectionsOrDefault(sections: readonly BackupSection[] | undefined): readonly BackupSection[] {
148
- if (sections === undefined || sections.length === 0) return ["config"];
149
- for (const section of sections) {
150
- if (section !== "config" && section !== "telemetry") {
151
- throw new ConsoleDomainError("invalid_request", 400, `unknown backup section "${String(section)}"`);
152
- }
153
- }
154
- return sections;
155
- }
156
-
157
- /** The bytes of an uploaded backup file, as carried by a string. */
158
- function toBackupBytes(input: string): Uint8Array {
159
- // Base64 is the only way file bytes travel inside a JSON body. A string that
160
- // already is JSON is taken as the file's text; anything else is the encoded
161
- // file, and a malformed payload still fails at `JSON.parse` rather than here.
162
- if (input.trimStart().startsWith("{")) return Buffer.from(input, "utf8");
163
- return Buffer.from(input, "base64");
164
- }
165
-
166
- /**
167
- * Accepts a backup in any form the operator can hand it over, and parses it.
168
- *
169
- * The export offers a compressed download and names it `.json.gz`, so the file
170
- * on the operator's disk is gzip bytes. The restore path used to read its body
171
- * as JSON text, which meant their own backup was the one file it could not
172
- * import. The shapes handled here are the ways a file actually arrives: a JSON
173
- * object (the dashboard decodes the file in the browser), the export's own
174
- * `{ bytes }` envelope, the file's text, and its bytes — base64-encoded for a
175
- * JSON body, or raw.
176
- *
177
- * Whether it is compressed is decided by the bytes' magic, never by the
178
- * filename: a `.json` that is really gzip is restored, and a `.gz` that is not
179
- * is too.
180
- */
181
- function normalizeBackupInput(input: unknown): unknown {
182
- if (input === null || input === undefined) return input;
183
- if (input instanceof Uint8Array) return parseBackupText(decompressBackup(input));
184
- if (input instanceof ArrayBuffer) return parseBackupText(decompressBackup(new Uint8Array(input)));
185
- if (typeof input === "string") return parseBackupText(decompressBackup(toBackupBytes(input)));
186
- // The export's own envelope, handed straight back.
187
- if (isRecord(input) && typeof input["bytes"] === "string" && Object.keys(input).length <= 3) {
188
- return parseBackupText(decompressBackup(toBackupBytes(input["bytes"])));
189
- }
190
- return input;
191
- }
192
-
193
- function parseBackupText(text: string): unknown {
194
- try {
195
- return JSON.parse(text) as unknown;
196
- } catch {
197
- throw new ConsoleDomainError("invalid_request", 400, "that file is not a JSON backup");
198
- }
199
- }
200
-
201
- async function omitRowsForUnavailableProviders(
202
- db: CartethyiaDatabase,
203
- validation: ValidatedRestore,
204
- tenantId: string,
205
- ): Promise<ValidatedRestore> {
206
- const existingProviders = await db
207
- .select({ id: providers.id, tenantId: providers.tenantId })
208
- .from(providers);
209
- const importedProviders = validation.tables.get(providers)?.rows ?? [];
210
- const providerIds = new Set([
211
- ...existingProviders
212
- .filter((provider) => provider.tenantId === null || provider.tenantId === tenantId)
213
- .map((provider) => provider.id),
214
- ...importedProviders.map((provider) => String(provider["id"])),
215
- ]);
216
- const tables = new Map(validation.tables);
217
- const warnings = [...validation.warnings];
218
- const counts = { ...validation.counts };
219
-
220
- for (const table of [providerAccounts, models, tenantDisabledModels, providerRoutingSettings]) {
221
- const entry = tables.get(table);
222
- if (entry === undefined) continue;
223
- const rows = entry.rows.filter(
224
- (row) => typeof row["provider_id"] === "string" && providerIds.has(row["provider_id"]),
225
- );
226
- if (rows.length === entry.rows.length) continue;
227
- tables.set(table, { ...entry, rows });
228
- counts[tableName(table)] = rows.length;
229
- warnings.push({
230
- table: tableName(table),
231
- column: "provider_id",
232
- reason: "referenced a provider not available in this installation; those rows were skipped",
233
- });
234
- }
235
-
236
- const accountRows = tables.get(providerAccounts)?.rows ?? [];
237
- const replacesTenantAccounts = accountRows.length > 0;
238
- const existingAccounts = await db
239
- .select({ id: providerAccounts.id, tenantId: providerAccounts.tenantId })
240
- .from(providerAccounts);
241
- const accountIds = new Set(
242
- existingAccounts
243
- .filter((account) => !replacesTenantAccounts || account.tenantId !== tenantId)
244
- .map((account) => account.id),
245
- );
246
- for (const account of accountRows) {
247
- if (typeof account["id"] === "string") accountIds.add(account["id"]);
248
- }
249
-
250
- const oauthEntry = tables.get(providerOauthStates);
251
- if (oauthEntry !== undefined) {
252
- const rows = oauthEntry.rows.filter(
253
- (row) =>
254
- typeof row["provider_account_id"] === "string" && accountIds.has(row["provider_account_id"]),
255
- );
256
- if (rows.length !== oauthEntry.rows.length) {
257
- tables.set(providerOauthStates, { ...oauthEntry, rows });
258
- counts[tableName(providerOauthStates)] = rows.length;
259
- warnings.push({
260
- table: tableName(providerOauthStates),
261
- column: "provider_account_id",
262
- reason: "referenced an account not restored or available in this installation; those rows were skipped",
263
- });
264
- }
265
- }
266
-
267
- return { ...validation, tables, counts, warnings };
268
- }
269
-
270
- export class BackupService {
271
- constructor(private readonly options: BackupServiceOptions) {}
272
-
273
- async export(options: ExportOptions): Promise<BackupExportStream> {
274
- if (!(await this.options.verifyPassword(options.password))) {
275
- throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
276
- }
277
- const sections = sectionsOrDefault(options.sections);
278
- return exportBackupStream(
279
- this.options.db,
280
- sections.flatMap((section) => tablesForSection(section)),
281
- options.tenantId,
282
- );
283
- }
284
-
285
- /**
286
- * Restores a payload, detecting its format first.
287
- *
288
- * A native payload is validated as-is. A router export is converted first —
289
- * and conversion is itself validation, because it refuses provider ids it
290
- * cannot map rather than guessing — then the converted payload goes through
291
- * the identical validation and transaction as a native one. There is one
292
- * write path, not two.
293
- *
294
- * `tenantId` is the restoring tenant, and it is the *only* tenant any write
295
- * can reach: the payload cannot name a different one, because every table is
296
- * filtered by ownership and the tenant row is forced to this id.
297
- */
298
- async restore(
299
- password: unknown,
300
- payload: unknown,
301
- tenantId: string,
302
- selection?: BackupRestoreSelection,
303
- ): Promise<ImportResult> {
304
- if (typeof password !== "string" || !(await this.options.verifyPassword(password))) {
305
- throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
306
- }
307
-
308
- const detected = detectFormat(normalizeBackupInput(payload));
309
- if (detected.kind === "unknown") {
310
- throw new ConsoleDomainError("invalid_request", 400, detected.reason);
311
- }
312
-
313
- if (detected.kind === "nine_router") {
314
- const { payload: converted, report } = convert9RouterBackup(detected.payload, tenantId);
315
- const result = await this.apply(converted, tenantId, selection);
316
- return { ...result, report, format: "nine_router" };
317
- }
318
-
319
- const result = await this.apply(detected.payload, tenantId, selection);
320
- return { ...result, format: "native" };
321
- }
322
-
323
- /**
324
- * Restores a native backup onto a brand-new tenant during first boot.
325
- *
326
- * There is no operator to re-authenticate, and no tenant to scope the write
327
- * to. The caller has already proven that no console user exists and holds
328
- * the setup lock.
329
- *
330
- * The configuration is restored; the sign-in accounts are not, because a
331
- * backup does not carry them (see {@link CONFIG_TABLES}). The caller creates
332
- * the administrator for the install it just recovered into.
333
- */
334
- async restoreFirstBoot(payload: unknown, tenantId: string): Promise<ImportResult> {
335
- const detected = detectFormat(normalizeBackupInput(payload));
336
- if (detected.kind !== "native") {
337
- throw new ConsoleDomainError(
338
- "invalid_request",
339
- 400,
340
- detected.kind === "unknown" ? detected.reason : "first-boot restore accepts a Cartethyia backup only",
341
- );
342
- }
343
- const result = await this.apply(detected.payload, tenantId);
344
- return { ...result, format: "native" };
345
- }
346
-
347
- /**
348
- * Describes a backup without writing any of it.
349
- *
350
- * The dashboard's restore wizard peeks at a file before committing to it, and
351
- * "which of these tables does the current build actually have" is a question
352
- * only the live schema can answer — a hand-maintained list of table names in
353
- * the browser would drift the moment a migration landed. So the answer comes
354
- * from the same validation a restore runs, and writes nothing.
355
- */
356
- inspect(payload: unknown): BackupInspection {
357
- const detected = detectFormat(normalizeBackupInput(payload));
358
- if (detected.kind === "unknown") {
359
- throw new ConsoleDomainError("invalid_request", 400, detected.reason);
360
- }
361
- const candidate = detected.kind === "native" ? detected.payload : null;
362
- if (candidate === null) {
363
- // A router export has no sections to list; it is converted at restore
364
- // time, so all that can be said here is what it is.
365
- return {
366
- format: "nine_router",
367
- version: 0,
368
- exportedAt: "",
369
- rows: [],
370
- total: 0,
371
- warnings: [],
372
- };
373
- }
374
- const validation = validateRestorePayload(candidate, "");
375
- const warnings = validation.ok ? validation.value.warnings : [];
376
- const supported = new Set(validation.ok ? [...validation.value.tables.keys()].map(tableName) : []);
377
- // `tenants` is diverted to `validation.value.tenantRows` during validate —
378
- // not included in `tables` — so it would show as "unsupported" in the
379
- // preview for any backup that carries it. Mark it supported explicitly.
380
- if (validation.ok) supported.add("tenants");
381
-
382
- const rows: BackupInspectionRow[] = [];
383
- let total = 0;
384
- const sections = isRecord(candidate.sections) ? candidate.sections : {};
385
- for (const [section, value] of Object.entries(sections)) {
386
- if (!isRecord(value)) continue;
387
- for (const [table, list] of Object.entries(value)) {
388
- if (!Array.isArray(list)) continue;
389
- rows.push({ section, table, count: list.length, supported: supported.has(table) });
390
- total += list.length;
391
- }
392
- }
393
- rows.sort((left, right) => right.count - left.count || left.table.localeCompare(right.table));
394
-
395
- return {
396
- format: "native",
397
- version: typeof candidate.version === "number" ? candidate.version : 0,
398
- exportedAt: typeof candidate.exportedAt === "string" ? candidate.exportedAt : "",
399
- rows,
400
- total,
401
- warnings,
402
- };
403
- }
404
-
405
- /** Validates and applies a native payload. Shared by both formats. */
406
- private async apply(
407
- payload: unknown,
408
- tenantId: string,
409
- selection?: BackupRestoreSelection,
410
- ): Promise<{ restored: Record<string, number>; skipped: Record<string, number>; warnings: readonly RestoreWarning[] }> {
411
- const validation = validateRestorePayload(payload, tenantId);
412
- if (!validation.ok) {
413
- throw new ConsoleDomainError("invalid_request", 400, validation.error);
414
- }
415
- try {
416
- const scoped = restrictToSelection(validation.value, selection);
417
- const compatible = await omitRowsForUnavailableProviders(this.options.db, scoped, tenantId);
418
- const applied = await applyRestore(this.options.db, compatible, restoreOrder(), tenantId);
419
- return { ...applied, warnings: compatible.warnings };
420
- } catch (error) {
421
- // The message carries table and column names only — the store never
422
- // interpolates a cell value — so a failed restore cannot leak credentials
423
- // into a log or an HTTP response.
424
- throw new ConsoleDomainError(
425
- "restore_failed",
426
- 500,
427
- `restore failed and was rolled back: ${error instanceof Error ? error.message : "unknown error"}`,
428
- );
429
- }
430
- }
431
-
432
- /** Permanently deletes selected tenant configuration after re-authentication. */
433
- async deleteAll(password: unknown, scopes: readonly DeleteAllScope[], tenantId: string): Promise<DeleteAllResult> {
434
- if (typeof password !== "string" || !(await this.options.verifyPassword(password))) {
435
- throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
436
- }
437
- if (scopes.length === 0) {
438
- throw new ConsoleDomainError("invalid_request", 400, "select at least one delete scope");
439
- }
440
- try {
441
- return await deleteAll(this.options.db, scopes, tenantId);
442
- } catch (error) {
443
- throw new ConsoleDomainError(
444
- "delete_failed",
445
- 500,
446
- `delete failed and was rolled back: ${error instanceof Error ? error.message : "unknown error"}`,
447
- );
448
- }
449
- }
450
-
451
- /** Counts a section without building its payload. */
452
- async preview(
453
- sections: readonly BackupSection[] | undefined,
454
- tenantId: string,
455
- ): Promise<Record<string, number>> {
456
- const stream = exportBackupStream(
457
- this.options.db,
458
- sectionsOrDefault(sections).flatMap((section) => tablesForSection(section)),
459
- tenantId,
460
- );
461
- // Walk the stream so the tally settles, discarding the bytes: a preview
462
- // must not hold the payload either.
463
- for await (const _chunk of stream.jsonChunks) {
464
- // discard
465
- }
466
- return stream.counts;
467
- }
468
- }
1
+ /**
2
+ * Backup and restore operations, plus the import path for a router export.
3
+ *
4
+ * Two things every entry point here has in common:
5
+ *
6
+ * 1. **Nothing is written before the whole payload validates.** Validation
7
+ * resolves every table and column against the live schema, so a stale or
8
+ * hostile file fails with the offending name and the database is untouched.
9
+ * 2. **Everything happens in one transaction** (see `store.applyRestore`), so a
10
+ * mid-restore SQL error rolls back rather than leaving half a
11
+ * configuration — which is the state that makes an operator's day worse than
12
+ * a plain failure would.
13
+ *
14
+ * Export is a JSON object at the operator's explicit request, and it carries
15
+ * provider credentials and API keys decrypted — the only form in which they can
16
+ * be restored into an install with a different encryption key (see
17
+ * {@link sealedColumns}). So the file is as sensitive as the database: the layer
18
+ * doc and the dashboard copy say so, and export requires password
19
+ * re-authentication.
20
+ */
21
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
22
+ import type { Table } from "drizzle-orm";
23
+ import {
24
+ models,
25
+ providerAccounts,
26
+ providerOauthStates,
27
+ providerRoutingSettings,
28
+ providers,
29
+ tenantDisabledModels,
30
+ } from "../../persistence/schema";
31
+ import { ConsoleDomainError } from "../shared/errors";
32
+ import { tablesForSection, tableName } from "./contracts";
33
+ import type {
34
+ BackupSection,
35
+ RestoreWarning,
36
+ ValidatedRestore,
37
+ ValidatedTable,
38
+ } from "./contracts";
39
+ import {
40
+ applyRestore,
41
+ decompressBackup,
42
+ deleteAll,
43
+ exportBackupStream,
44
+ type BackupExportStream,
45
+ type DeleteAllResult,
46
+ type DeleteAllScope,
47
+ } from "./store";
48
+ import { detectFormat, restoreOrder, validateRestorePayload } from "./validate";
49
+ import { convert9RouterBackup, type ImportReport } from "./nine-router";
50
+ import { isRecord } from "../../protocol/codecs";
51
+
52
+ interface BackupServiceOptions {
53
+ readonly db: CartethyiaDatabase;
54
+ /** Verifies the signed-in operator's console password; supplied by the router. */
55
+ readonly verifyPassword: (password: string) => Promise<boolean>;
56
+ }
57
+
58
+ interface ExportOptions {
59
+ readonly sections?: readonly BackupSection[];
60
+ readonly password: string;
61
+ /** The tenant whose rows are exported. Never another tenant's. */
62
+ readonly tenantId: string;
63
+ /** Gzips the file. Changes nothing about what the payload carries. */
64
+ readonly compressed?: boolean;
65
+ }
66
+
67
+ /** One table as it appears in a backup file, and whether it can be restored. */
68
+ interface BackupInspectionRow {
69
+ readonly section: string;
70
+ readonly table: string;
71
+ readonly count: number;
72
+ /** False when the current schema has no such table: restore will skip it. */
73
+ readonly supported: boolean;
74
+ }
75
+
76
+ /**
77
+ * A backup's cover, read without writing.
78
+ *
79
+ * `warnings` carries what a real restore would leave out, so the operator sees
80
+ * the loss before committing rather than after.
81
+ */
82
+ export interface BackupInspection {
83
+ readonly format: "native" | "nine_router";
84
+ readonly version: number;
85
+ readonly exportedAt: string;
86
+ readonly rows: readonly BackupInspectionRow[];
87
+ readonly total: number;
88
+ readonly warnings: readonly RestoreWarning[];
89
+ }
90
+
91
+ export interface ImportResult {
92
+ readonly restored: Record<string, number>;
93
+ readonly skipped: Record<string, number>;
94
+ /** Parts of the file that could not be restored. Empty means it all applied. */
95
+ readonly warnings: readonly RestoreWarning[];
96
+ readonly report?: ImportReport;
97
+ readonly format: "native" | "nine_router";
98
+ }
99
+
100
+ /**
101
+ * Which parts of a backup file a restore should actually write.
102
+ *
103
+ * Absent or empty means "everything", which is the historical behavior and
104
+ * what the dashboard's checklist defaults to. A table named here but absent
105
+ * from the file is simply not restored — it is not an error, because the
106
+ * checklist is built from an inspection of *this* file and a selection never
107
+ * invents a table.
108
+ */
109
+ interface BackupRestoreSelection {
110
+ /** Table names to restore; absent or empty restores every table present. */
111
+ readonly tables?: readonly string[];
112
+ }
113
+
114
+ /**
115
+ * Narrows a validated backup to the tables the operator selected.
116
+ *
117
+ * The tenant row is the one thing that is not independently selectable: it is
118
+ * not a table in either section (`BACKUP_SECTIONS` are config and telemetry),
119
+ * so it never appears on the checklist, and every tenant-scoped table
120
+ * cascades from it. Dropping it because it was not named would make a partial
121
+ * restore fail on a foreign key for a table the operator *did* ask for. So it
122
+ * rides along with any config selection and is written only when one is made.
123
+ *
124
+ * A table named in the selection but missing from the file is a no-op rather
125
+ * than an error: the checklist is built from an inspection of this file, but
126
+ * the selection is validated as names only, and refusing to restore anything
127
+ * over one stale name would be worse than ignoring it.
128
+ */
129
+ function restrictToSelection(
130
+ validation: ValidatedRestore,
131
+ selection: BackupRestoreSelection | undefined,
132
+ ): ValidatedRestore {
133
+ const wanted = selection?.tables;
134
+ if (wanted === undefined || wanted.length === 0) return validation;
135
+ const selected = new Set(wanted);
136
+ const tables = new Map<Table, ValidatedTable>();
137
+ for (const [table, entry] of validation.tables) {
138
+ if (selected.has(tableName(table))) tables.set(table, entry);
139
+ }
140
+ return {
141
+ ...validation,
142
+ tables,
143
+ tenantRows: tables.size > 0 ? validation.tenantRows : [],
144
+ };
145
+ }
146
+
147
+ function sectionsOrDefault(sections: readonly BackupSection[] | undefined): readonly BackupSection[] {
148
+ if (sections === undefined || sections.length === 0) return ["config"];
149
+ for (const section of sections) {
150
+ if (section !== "config" && section !== "telemetry") {
151
+ throw new ConsoleDomainError("invalid_request", 400, `unknown backup section "${String(section)}"`);
152
+ }
153
+ }
154
+ return sections;
155
+ }
156
+
157
+ /** The bytes of an uploaded backup file, as carried by a string. */
158
+ function toBackupBytes(input: string): Uint8Array {
159
+ // Base64 is the only way file bytes travel inside a JSON body. A string that
160
+ // already is JSON is taken as the file's text; anything else is the encoded
161
+ // file, and a malformed payload still fails at `JSON.parse` rather than here.
162
+ if (input.trimStart().startsWith("{")) return Buffer.from(input, "utf8");
163
+ return Buffer.from(input, "base64");
164
+ }
165
+
166
+ /**
167
+ * Accepts a backup in any form the operator can hand it over, and parses it.
168
+ *
169
+ * The export offers a compressed download and names it `.json.gz`, so the file
170
+ * on the operator's disk is gzip bytes. The restore path used to read its body
171
+ * as JSON text, which meant their own backup was the one file it could not
172
+ * import. The shapes handled here are the ways a file actually arrives: a JSON
173
+ * object (the dashboard decodes the file in the browser), the export's own
174
+ * `{ bytes }` envelope, the file's text, and its bytes — base64-encoded for a
175
+ * JSON body, or raw.
176
+ *
177
+ * Whether it is compressed is decided by the bytes' magic, never by the
178
+ * filename: a `.json` that is really gzip is restored, and a `.gz` that is not
179
+ * is too.
180
+ */
181
+ function normalizeBackupInput(input: unknown): unknown {
182
+ if (input === null || input === undefined) return input;
183
+ if (input instanceof Uint8Array) return parseBackupText(decompressBackup(input));
184
+ if (input instanceof ArrayBuffer) return parseBackupText(decompressBackup(new Uint8Array(input)));
185
+ if (typeof input === "string") return parseBackupText(decompressBackup(toBackupBytes(input)));
186
+ // The export's own envelope, handed straight back.
187
+ if (isRecord(input) && typeof input["bytes"] === "string" && Object.keys(input).length <= 3) {
188
+ return parseBackupText(decompressBackup(toBackupBytes(input["bytes"])));
189
+ }
190
+ return input;
191
+ }
192
+
193
+ function parseBackupText(text: string): unknown {
194
+ try {
195
+ return JSON.parse(text) as unknown;
196
+ } catch {
197
+ throw new ConsoleDomainError("invalid_request", 400, "that file is not a JSON backup");
198
+ }
199
+ }
200
+
201
+ async function omitRowsForUnavailableProviders(
202
+ db: CartethyiaDatabase,
203
+ validation: ValidatedRestore,
204
+ tenantId: string,
205
+ ): Promise<ValidatedRestore> {
206
+ const existingProviders = await db
207
+ .select({ id: providers.id, tenantId: providers.tenantId })
208
+ .from(providers);
209
+ const importedProviders = validation.tables.get(providers)?.rows ?? [];
210
+ const providerIds = new Set([
211
+ ...existingProviders
212
+ .filter((provider) => provider.tenantId === null || provider.tenantId === tenantId)
213
+ .map((provider) => provider.id),
214
+ ...importedProviders.map((provider) => String(provider["id"])),
215
+ ]);
216
+ const tables = new Map(validation.tables);
217
+ const warnings = [...validation.warnings];
218
+ const counts = { ...validation.counts };
219
+
220
+ for (const table of [providerAccounts, models, tenantDisabledModels, providerRoutingSettings]) {
221
+ const entry = tables.get(table);
222
+ if (entry === undefined) continue;
223
+ const rows = entry.rows.filter(
224
+ (row) => typeof row["provider_id"] === "string" && providerIds.has(row["provider_id"]),
225
+ );
226
+ if (rows.length === entry.rows.length) continue;
227
+ tables.set(table, { ...entry, rows });
228
+ counts[tableName(table)] = rows.length;
229
+ warnings.push({
230
+ table: tableName(table),
231
+ column: "provider_id",
232
+ reason: "referenced a provider not available in this installation; those rows were skipped",
233
+ });
234
+ }
235
+
236
+ const accountRows = tables.get(providerAccounts)?.rows ?? [];
237
+ const replacesTenantAccounts = accountRows.length > 0;
238
+ const existingAccounts = await db
239
+ .select({ id: providerAccounts.id, tenantId: providerAccounts.tenantId })
240
+ .from(providerAccounts);
241
+ const accountIds = new Set(
242
+ existingAccounts
243
+ .filter((account) => !replacesTenantAccounts || account.tenantId !== tenantId)
244
+ .map((account) => account.id),
245
+ );
246
+ for (const account of accountRows) {
247
+ if (typeof account["id"] === "string") accountIds.add(account["id"]);
248
+ }
249
+
250
+ const oauthEntry = tables.get(providerOauthStates);
251
+ if (oauthEntry !== undefined) {
252
+ const rows = oauthEntry.rows.filter(
253
+ (row) =>
254
+ typeof row["provider_account_id"] === "string" && accountIds.has(row["provider_account_id"]),
255
+ );
256
+ if (rows.length !== oauthEntry.rows.length) {
257
+ tables.set(providerOauthStates, { ...oauthEntry, rows });
258
+ counts[tableName(providerOauthStates)] = rows.length;
259
+ warnings.push({
260
+ table: tableName(providerOauthStates),
261
+ column: "provider_account_id",
262
+ reason: "referenced an account not restored or available in this installation; those rows were skipped",
263
+ });
264
+ }
265
+ }
266
+
267
+ return { ...validation, tables, counts, warnings };
268
+ }
269
+
270
+ export class BackupService {
271
+ constructor(private readonly options: BackupServiceOptions) {}
272
+
273
+ async export(options: ExportOptions): Promise<BackupExportStream> {
274
+ if (!(await this.options.verifyPassword(options.password))) {
275
+ throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
276
+ }
277
+ const sections = sectionsOrDefault(options.sections);
278
+ return exportBackupStream(
279
+ this.options.db,
280
+ sections.flatMap((section) => tablesForSection(section)),
281
+ options.tenantId,
282
+ );
283
+ }
284
+
285
+ /**
286
+ * Restores a payload, detecting its format first.
287
+ *
288
+ * A native payload is validated as-is. A router export is converted first —
289
+ * and conversion is itself validation, because it refuses provider ids it
290
+ * cannot map rather than guessing — then the converted payload goes through
291
+ * the identical validation and transaction as a native one. There is one
292
+ * write path, not two.
293
+ *
294
+ * `tenantId` is the restoring tenant, and it is the *only* tenant any write
295
+ * can reach: the payload cannot name a different one, because every table is
296
+ * filtered by ownership and the tenant row is forced to this id.
297
+ */
298
+ async restore(
299
+ password: unknown,
300
+ payload: unknown,
301
+ tenantId: string,
302
+ selection?: BackupRestoreSelection,
303
+ ): Promise<ImportResult> {
304
+ if (typeof password !== "string" || !(await this.options.verifyPassword(password))) {
305
+ throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
306
+ }
307
+
308
+ const detected = detectFormat(normalizeBackupInput(payload));
309
+ if (detected.kind === "unknown") {
310
+ throw new ConsoleDomainError("invalid_request", 400, detected.reason);
311
+ }
312
+
313
+ if (detected.kind === "nine_router") {
314
+ const { payload: converted, report } = convert9RouterBackup(detected.payload, tenantId);
315
+ const result = await this.apply(converted, tenantId, selection);
316
+ return { ...result, report, format: "nine_router" };
317
+ }
318
+
319
+ const result = await this.apply(detected.payload, tenantId, selection);
320
+ return { ...result, format: "native" };
321
+ }
322
+
323
+ /**
324
+ * Restores a native backup onto a brand-new tenant during first boot.
325
+ *
326
+ * There is no operator to re-authenticate, and no tenant to scope the write
327
+ * to. The caller has already proven that no console user exists and holds
328
+ * the setup lock.
329
+ *
330
+ * The configuration is restored; the sign-in accounts are not, because a
331
+ * backup does not carry them (see {@link CONFIG_TABLES}). The caller creates
332
+ * the administrator for the install it just recovered into.
333
+ */
334
+ async restoreFirstBoot(payload: unknown, tenantId: string): Promise<ImportResult> {
335
+ const detected = detectFormat(normalizeBackupInput(payload));
336
+ if (detected.kind !== "native") {
337
+ throw new ConsoleDomainError(
338
+ "invalid_request",
339
+ 400,
340
+ detected.kind === "unknown" ? detected.reason : "first-boot restore accepts a Cartethyia backup only",
341
+ );
342
+ }
343
+ const result = await this.apply(detected.payload, tenantId);
344
+ return { ...result, format: "native" };
345
+ }
346
+
347
+ /**
348
+ * Describes a backup without writing any of it.
349
+ *
350
+ * The dashboard's restore wizard peeks at a file before committing to it, and
351
+ * "which of these tables does the current build actually have" is a question
352
+ * only the live schema can answer — a hand-maintained list of table names in
353
+ * the browser would drift the moment a migration landed. So the answer comes
354
+ * from the same validation a restore runs, and writes nothing.
355
+ */
356
+ inspect(payload: unknown): BackupInspection {
357
+ const detected = detectFormat(normalizeBackupInput(payload));
358
+ if (detected.kind === "unknown") {
359
+ throw new ConsoleDomainError("invalid_request", 400, detected.reason);
360
+ }
361
+ const candidate = detected.kind === "native" ? detected.payload : null;
362
+ if (candidate === null) {
363
+ // A router export has no sections to list; it is converted at restore
364
+ // time, so all that can be said here is what it is.
365
+ return {
366
+ format: "nine_router",
367
+ version: 0,
368
+ exportedAt: "",
369
+ rows: [],
370
+ total: 0,
371
+ warnings: [],
372
+ };
373
+ }
374
+ const validation = validateRestorePayload(candidate, "");
375
+ const warnings = validation.ok ? validation.value.warnings : [];
376
+ const supported = new Set(validation.ok ? [...validation.value.tables.keys()].map(tableName) : []);
377
+ // `tenants` is diverted to `validation.value.tenantRows` during validate —
378
+ // not included in `tables` — so it would show as "unsupported" in the
379
+ // preview for any backup that carries it. Mark it supported explicitly.
380
+ if (validation.ok) supported.add("tenants");
381
+
382
+ const rows: BackupInspectionRow[] = [];
383
+ let total = 0;
384
+ const sections = isRecord(candidate.sections) ? candidate.sections : {};
385
+ for (const [section, value] of Object.entries(sections)) {
386
+ if (!isRecord(value)) continue;
387
+ for (const [table, list] of Object.entries(value)) {
388
+ if (!Array.isArray(list)) continue;
389
+ rows.push({ section, table, count: list.length, supported: supported.has(table) });
390
+ total += list.length;
391
+ }
392
+ }
393
+ rows.sort((left, right) => right.count - left.count || left.table.localeCompare(right.table));
394
+
395
+ return {
396
+ format: "native",
397
+ version: typeof candidate.version === "number" ? candidate.version : 0,
398
+ exportedAt: typeof candidate.exportedAt === "string" ? candidate.exportedAt : "",
399
+ rows,
400
+ total,
401
+ warnings,
402
+ };
403
+ }
404
+
405
+ /** Validates and applies a native payload. Shared by both formats. */
406
+ private async apply(
407
+ payload: unknown,
408
+ tenantId: string,
409
+ selection?: BackupRestoreSelection,
410
+ ): Promise<{ restored: Record<string, number>; skipped: Record<string, number>; warnings: readonly RestoreWarning[] }> {
411
+ const validation = validateRestorePayload(payload, tenantId);
412
+ if (!validation.ok) {
413
+ throw new ConsoleDomainError("invalid_request", 400, validation.error);
414
+ }
415
+ try {
416
+ const scoped = restrictToSelection(validation.value, selection);
417
+ const compatible = await omitRowsForUnavailableProviders(this.options.db, scoped, tenantId);
418
+ const applied = await applyRestore(this.options.db, compatible, restoreOrder(), tenantId);
419
+ return { ...applied, warnings: compatible.warnings };
420
+ } catch (error) {
421
+ // The message carries table and column names only — the store never
422
+ // interpolates a cell value — so a failed restore cannot leak credentials
423
+ // into a log or an HTTP response.
424
+ throw new ConsoleDomainError(
425
+ "restore_failed",
426
+ 500,
427
+ `restore failed and was rolled back: ${error instanceof Error ? error.message : "unknown error"}`,
428
+ );
429
+ }
430
+ }
431
+
432
+ /** Permanently deletes selected tenant configuration after re-authentication. */
433
+ async deleteAll(password: unknown, scopes: readonly DeleteAllScope[], tenantId: string): Promise<DeleteAllResult> {
434
+ if (typeof password !== "string" || !(await this.options.verifyPassword(password))) {
435
+ throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
436
+ }
437
+ if (scopes.length === 0) {
438
+ throw new ConsoleDomainError("invalid_request", 400, "select at least one delete scope");
439
+ }
440
+ try {
441
+ return await deleteAll(this.options.db, scopes, tenantId);
442
+ } catch (error) {
443
+ throw new ConsoleDomainError(
444
+ "delete_failed",
445
+ 500,
446
+ `delete failed and was rolled back: ${error instanceof Error ? error.message : "unknown error"}`,
447
+ );
448
+ }
449
+ }
450
+
451
+ /** Counts a section without building its payload. */
452
+ async preview(
453
+ sections: readonly BackupSection[] | undefined,
454
+ tenantId: string,
455
+ ): Promise<Record<string, number>> {
456
+ const stream = exportBackupStream(
457
+ this.options.db,
458
+ sectionsOrDefault(sections).flatMap((section) => tablesForSection(section)),
459
+ tenantId,
460
+ );
461
+ // Walk the stream so the tally settles, discarding the bytes: a preview
462
+ // must not hold the payload either.
463
+ for await (const _chunk of stream.jsonChunks) {
464
+ // discard
465
+ }
466
+ return stream.counts;
467
+ }
468
+ }