@vib-rato/ai 0.16.0

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 (525) hide show
  1. package/CHANGELOG.md +3354 -0
  2. package/README.md +1194 -0
  3. package/dist/types/adapter-internals/provider-safety-stop.d.ts +45 -0
  4. package/dist/types/api-registry.d.ts +30 -0
  5. package/dist/types/auth-broker/client.d.ts +80 -0
  6. package/dist/types/auth-broker/index.d.ts +5 -0
  7. package/dist/types/auth-broker/redact.d.ts +7 -0
  8. package/dist/types/auth-broker/refresher.d.ts +25 -0
  9. package/dist/types/auth-broker/remote-store.d.ts +153 -0
  10. package/dist/types/auth-broker/server.d.ts +32 -0
  11. package/dist/types/auth-broker/types.d.ts +132 -0
  12. package/dist/types/auth-broker/wire-schemas.d.ts +555 -0
  13. package/dist/types/auth-gateway/http.d.ts +40 -0
  14. package/dist/types/auth-gateway/index.d.ts +3 -0
  15. package/dist/types/auth-gateway/server.d.ts +70 -0
  16. package/dist/types/auth-gateway/types.d.ts +129 -0
  17. package/dist/types/auth-storage.d.ts +1074 -0
  18. package/dist/types/cli.d.ts +2 -0
  19. package/dist/types/codex-tools.d.ts +4 -0
  20. package/dist/types/context-cap-policy.d.ts +68 -0
  21. package/dist/types/core.d.ts +35 -0
  22. package/dist/types/index.d.ts +55 -0
  23. package/dist/types/model-cache.d.ts +24 -0
  24. package/dist/types/model-manager.d.ts +77 -0
  25. package/dist/types/model-pricing.d.ts +3 -0
  26. package/dist/types/model-retirements.d.ts +6 -0
  27. package/dist/types/model-thinking.d.ts +100 -0
  28. package/dist/types/models.d.ts +21 -0
  29. package/dist/types/openai-completions-compat.d.ts +34 -0
  30. package/dist/types/provider-details.d.ts +24 -0
  31. package/dist/types/provider-models/bundled-references.d.ts +4 -0
  32. package/dist/types/provider-models/descriptors.d.ts +48 -0
  33. package/dist/types/provider-models/google.d.ts +20 -0
  34. package/dist/types/provider-models/index.d.ts +5 -0
  35. package/dist/types/provider-models/ollama.d.ts +7 -0
  36. package/dist/types/provider-models/openai-compat.d.ts +293 -0
  37. package/dist/types/provider-models/special.d.ts +29 -0
  38. package/dist/types/providers/amazon-bedrock.d.ts +60 -0
  39. package/dist/types/providers/anthropic-messages-server-schema.d.ts +450 -0
  40. package/dist/types/providers/anthropic-messages-server.d.ts +17 -0
  41. package/dist/types/providers/anthropic.d.ts +280 -0
  42. package/dist/types/providers/aws-credential-config.d.ts +19 -0
  43. package/dist/types/providers/aws-credentials.d.ts +43 -0
  44. package/dist/types/providers/aws-eventstream.d.ts +38 -0
  45. package/dist/types/providers/aws-sigv4.d.ts +55 -0
  46. package/dist/types/providers/azure-openai-responses.d.ts +22 -0
  47. package/dist/types/providers/composer-discipline.d.ts +32 -0
  48. package/dist/types/providers/cursor/client-version.d.ts +10 -0
  49. package/dist/types/providers/cursor/exec-modern.d.ts +98 -0
  50. package/dist/types/providers/cursor/gen/agent_pb.d.ts +16769 -0
  51. package/dist/types/providers/cursor-pi-args.d.ts +119 -0
  52. package/dist/types/providers/cursor.d.ts +72 -0
  53. package/dist/types/providers/dashscope-token-plan-headers.d.ts +57 -0
  54. package/dist/types/providers/error-message.d.ts +27 -0
  55. package/dist/types/providers/github-copilot-headers.d.ts +40 -0
  56. package/dist/types/providers/gitlab-duo.d.ts +27 -0
  57. package/dist/types/providers/google-auth.d.ts +26 -0
  58. package/dist/types/providers/google-gemini-cli.d.ts +75 -0
  59. package/dist/types/providers/google-gemini-headers.d.ts +43 -0
  60. package/dist/types/providers/google-shared.d.ts +183 -0
  61. package/dist/types/providers/google-types.d.ts +138 -0
  62. package/dist/types/providers/google-vertex.d.ts +11 -0
  63. package/dist/types/providers/google.d.ts +4 -0
  64. package/dist/types/providers/grammar.d.ts +1 -0
  65. package/dist/types/providers/kimi.d.ts +27 -0
  66. package/dist/types/providers/kiro-api-key.d.ts +50 -0
  67. package/dist/types/providers/kiro-codewhisperer.d.ts +11 -0
  68. package/dist/types/providers/mock.d.ts +189 -0
  69. package/dist/types/providers/ollama.d.ts +41 -0
  70. package/dist/types/providers/openai-anthropic-shim.d.ts +31 -0
  71. package/dist/types/providers/openai-bounded-rate-limits.d.ts +3 -0
  72. package/dist/types/providers/openai-chat-server-schema.d.ts +1733 -0
  73. package/dist/types/providers/openai-chat-server.d.ts +16 -0
  74. package/dist/types/providers/openai-codex/constants.d.ts +26 -0
  75. package/dist/types/providers/openai-codex/request-transformer.d.ts +50 -0
  76. package/dist/types/providers/openai-codex/response-handler.d.ts +18 -0
  77. package/dist/types/providers/openai-codex-responses.d.ts +71 -0
  78. package/dist/types/providers/openai-completions-compat.d.ts +6 -0
  79. package/dist/types/providers/openai-completions.d.ts +35 -0
  80. package/dist/types/providers/openai-opencodex-responses.d.ts +10 -0
  81. package/dist/types/providers/openai-request-transform.d.ts +4 -0
  82. package/dist/types/providers/openai-responses-server-schema.d.ts +392 -0
  83. package/dist/types/providers/openai-responses-server.d.ts +17 -0
  84. package/dist/types/providers/openai-responses-shared.d.ts +106 -0
  85. package/dist/types/providers/openai-responses.d.ts +37 -0
  86. package/dist/types/providers/pi-native-client.d.ts +29 -0
  87. package/dist/types/providers/pi-native-server.d.ts +60 -0
  88. package/dist/types/providers/register-builtins.d.ts +59 -0
  89. package/dist/types/providers/synthetic.d.ts +26 -0
  90. package/dist/types/providers/transform-messages.d.ts +33 -0
  91. package/dist/types/providers/vision-guard.d.ts +8 -0
  92. package/dist/types/rate-limit-utils.d.ts +19 -0
  93. package/dist/types/stream.d.ts +45 -0
  94. package/dist/types/types.d.ts +1073 -0
  95. package/dist/types/usage/claude.d.ts +3 -0
  96. package/dist/types/usage/gemini.d.ts +2 -0
  97. package/dist/types/usage/github-copilot.d.ts +7 -0
  98. package/dist/types/usage/google-antigravity.d.ts +2 -0
  99. package/dist/types/usage/grok-cli.d.ts +17 -0
  100. package/dist/types/usage/kimi.d.ts +4 -0
  101. package/dist/types/usage/minimax-code.d.ts +2 -0
  102. package/dist/types/usage/openai-codex.d.ts +3 -0
  103. package/dist/types/usage/shared.d.ts +1 -0
  104. package/dist/types/usage/zai.d.ts +2 -0
  105. package/dist/types/usage.d.ts +264 -0
  106. package/dist/types/utils/abort.d.ts +19 -0
  107. package/dist/types/utils/anthropic-auth.d.ts +39 -0
  108. package/dist/types/utils/block-symbols.d.ts +6 -0
  109. package/dist/types/utils/discovery/antigravity.d.ts +67 -0
  110. package/dist/types/utils/discovery/codex.d.ts +38 -0
  111. package/dist/types/utils/discovery/cursor.d.ts +49 -0
  112. package/dist/types/utils/discovery/gemini.d.ts +25 -0
  113. package/dist/types/utils/discovery/index.d.ts +4 -0
  114. package/dist/types/utils/discovery/openai-compatible.d.ts +81 -0
  115. package/dist/types/utils/event-stream.d.ts +41 -0
  116. package/dist/types/utils/fallback-transport.d.ts +110 -0
  117. package/dist/types/utils/fireworks-model-id.d.ts +10 -0
  118. package/dist/types/utils/foundry.d.ts +11 -0
  119. package/dist/types/utils/h2-fetch.d.ts +22 -0
  120. package/dist/types/utils/http-inspector.d.ts +59 -0
  121. package/dist/types/utils/idle-iterator.d.ts +122 -0
  122. package/dist/types/utils/json-parse.d.ts +98 -0
  123. package/dist/types/utils/oauth/alibaba-token-plan.d.ts +19 -0
  124. package/dist/types/utils/oauth/anthropic.d.ts +41 -0
  125. package/dist/types/utils/oauth/api-key-login.d.ts +38 -0
  126. package/dist/types/utils/oauth/api-key-validation.d.ts +39 -0
  127. package/dist/types/utils/oauth/bizrouter.d.ts +1 -0
  128. package/dist/types/utils/oauth/callback-server.d.ts +80 -0
  129. package/dist/types/utils/oauth/cerebras.d.ts +1 -0
  130. package/dist/types/utils/oauth/cloudflare-ai-gateway.d.ts +18 -0
  131. package/dist/types/utils/oauth/commandcode.d.ts +1 -0
  132. package/dist/types/utils/oauth/cursor.d.ts +15 -0
  133. package/dist/types/utils/oauth/deepinfra.d.ts +1 -0
  134. package/dist/types/utils/oauth/deepseek.d.ts +10 -0
  135. package/dist/types/utils/oauth/firepass.d.ts +1 -0
  136. package/dist/types/utils/oauth/fireworks.d.ts +1 -0
  137. package/dist/types/utils/oauth/fugu.d.ts +1 -0
  138. package/dist/types/utils/oauth/github-copilot.d.ts +38 -0
  139. package/dist/types/utils/oauth/gitlab-duo.d.ts +3 -0
  140. package/dist/types/utils/oauth/glm-zcode.d.ts +71 -0
  141. package/dist/types/utils/oauth/google-antigravity.d.ts +11 -0
  142. package/dist/types/utils/oauth/google-gemini-cli.d.ts +10 -0
  143. package/dist/types/utils/oauth/google-oauth-shared.d.ts +28 -0
  144. package/dist/types/utils/oauth/huggingface.d.ts +19 -0
  145. package/dist/types/utils/oauth/index.d.ts +39 -0
  146. package/dist/types/utils/oauth/kagi.d.ts +17 -0
  147. package/dist/types/utils/oauth/kilo.d.ts +5 -0
  148. package/dist/types/utils/oauth/kimi.d.ts +17 -0
  149. package/dist/types/utils/oauth/kiro.d.ts +71 -0
  150. package/dist/types/utils/oauth/litellm.d.ts +18 -0
  151. package/dist/types/utils/oauth/lm-studio.d.ts +17 -0
  152. package/dist/types/utils/oauth/mara.d.ts +1 -0
  153. package/dist/types/utils/oauth/minimax-code.d.ts +28 -0
  154. package/dist/types/utils/oauth/moonshot.d.ts +1 -0
  155. package/dist/types/utils/oauth/nanogpt.d.ts +1 -0
  156. package/dist/types/utils/oauth/nvidia.d.ts +18 -0
  157. package/dist/types/utils/oauth/ollama-cloud.d.ts +2 -0
  158. package/dist/types/utils/oauth/ollama.d.ts +18 -0
  159. package/dist/types/utils/oauth/openai-codex.d.ts +21 -0
  160. package/dist/types/utils/oauth/opencode.d.ts +18 -0
  161. package/dist/types/utils/oauth/opengateway.d.ts +1 -0
  162. package/dist/types/utils/oauth/openrouter.d.ts +1 -0
  163. package/dist/types/utils/oauth/parallel.d.ts +17 -0
  164. package/dist/types/utils/oauth/perplexity.d.ts +4 -0
  165. package/dist/types/utils/oauth/pkce.d.ts +8 -0
  166. package/dist/types/utils/oauth/qianfan.d.ts +17 -0
  167. package/dist/types/utils/oauth/qwen-portal.d.ts +19 -0
  168. package/dist/types/utils/oauth/sglang.d.ts +16 -0
  169. package/dist/types/utils/oauth/synthetic.d.ts +1 -0
  170. package/dist/types/utils/oauth/tavily.d.ts +17 -0
  171. package/dist/types/utils/oauth/together.d.ts +1 -0
  172. package/dist/types/utils/oauth/types.d.ts +56 -0
  173. package/dist/types/utils/oauth/venice.d.ts +18 -0
  174. package/dist/types/utils/oauth/vercel-ai-gateway.d.ts +18 -0
  175. package/dist/types/utils/oauth/vllm.d.ts +16 -0
  176. package/dist/types/utils/oauth/xai.d.ts +30 -0
  177. package/dist/types/utils/oauth/xiaomi.d.ts +25 -0
  178. package/dist/types/utils/oauth/zai.d.ts +18 -0
  179. package/dist/types/utils/oauth/zenmux.d.ts +1 -0
  180. package/dist/types/utils/overflow.d.ts +14 -0
  181. package/dist/types/utils/parse-bind.d.ts +26 -0
  182. package/dist/types/utils/provider-response.d.ts +6 -0
  183. package/dist/types/utils/provider-safety-stop.d.ts +8 -0
  184. package/dist/types/utils/proxy.d.ts +7 -0
  185. package/dist/types/utils/retry-after.d.ts +3 -0
  186. package/dist/types/utils/retry-budget.d.ts +1 -0
  187. package/dist/types/utils/retry.d.ts +29 -0
  188. package/dist/types/utils/schema/adapt.d.ts +24 -0
  189. package/dist/types/utils/schema/compatibility.d.ts +30 -0
  190. package/dist/types/utils/schema/dereference.d.ts +11 -0
  191. package/dist/types/utils/schema/draft.d.ts +10 -0
  192. package/dist/types/utils/schema/equality.d.ts +4 -0
  193. package/dist/types/utils/schema/fields.d.ts +49 -0
  194. package/dist/types/utils/schema/index.d.ts +14 -0
  195. package/dist/types/utils/schema/json-schema-validator.d.ts +12 -0
  196. package/dist/types/utils/schema/meta-validator.d.ts +2 -0
  197. package/dist/types/utils/schema/normalize.d.ts +93 -0
  198. package/dist/types/utils/schema/root-combinator.d.ts +12 -0
  199. package/dist/types/utils/schema/spill.d.ts +8 -0
  200. package/dist/types/utils/schema/stamps.d.ts +25 -0
  201. package/dist/types/utils/schema/types.d.ts +4 -0
  202. package/dist/types/utils/schema/wire.d.ts +54 -0
  203. package/dist/types/utils/schema/zod-decontaminate.d.ts +31 -0
  204. package/dist/types/utils/sse-debug.d.ts +10 -0
  205. package/dist/types/utils/tool-call-healing.d.ts +80 -0
  206. package/dist/types/utils/tool-choice-capability.d.ts +57 -0
  207. package/dist/types/utils/tool-choice.d.ts +50 -0
  208. package/dist/types/utils/validation.d.ts +17 -0
  209. package/dist/types/utils.d.ts +117 -0
  210. package/package.json +152 -0
  211. package/src/adapter-internals/provider-safety-stop.d.ts +45 -0
  212. package/src/adapter-internals/provider-safety-stop.ts +156 -0
  213. package/src/api-registry.d.ts +30 -0
  214. package/src/api-registry.ts +96 -0
  215. package/src/auth-broker/client.ts +444 -0
  216. package/src/auth-broker/index.ts +5 -0
  217. package/src/auth-broker/redact.ts +39 -0
  218. package/src/auth-broker/refresher.ts +130 -0
  219. package/src/auth-broker/remote-store.ts +1576 -0
  220. package/src/auth-broker/server.ts +764 -0
  221. package/src/auth-broker/types.ts +164 -0
  222. package/src/auth-broker/wire-schemas.ts +261 -0
  223. package/src/auth-gateway/http.ts +198 -0
  224. package/src/auth-gateway/index.ts +3 -0
  225. package/src/auth-gateway/server.ts +1315 -0
  226. package/src/auth-gateway/types.ts +160 -0
  227. package/src/auth-storage.ts +7312 -0
  228. package/src/cli.ts +269 -0
  229. package/src/codex-tools.d.ts +4 -0
  230. package/src/codex-tools.ts +24 -0
  231. package/src/context-cap-policy.d.ts +68 -0
  232. package/src/context-cap-policy.ts +123 -0
  233. package/src/core.ts +44 -0
  234. package/src/index.ts +61 -0
  235. package/src/model-cache.ts +236 -0
  236. package/src/model-manager.ts +744 -0
  237. package/src/model-pricing.d.ts +3 -0
  238. package/src/model-pricing.ts +68 -0
  239. package/src/model-retirements.d.ts +6 -0
  240. package/src/model-retirements.ts +19 -0
  241. package/src/model-thinking.d.ts +100 -0
  242. package/src/model-thinking.ts +1054 -0
  243. package/src/models.d.ts +21 -0
  244. package/src/models.json +94672 -0
  245. package/src/models.json.d.ts +9 -0
  246. package/src/models.ts +126 -0
  247. package/src/openai-completions-compat.d.ts +34 -0
  248. package/src/openai-completions-compat.ts +383 -0
  249. package/src/prompts/composer-bash-policy-recovery.md +1 -0
  250. package/src/prompts/cursor-composer-bash-policy-recovery.md +1 -0
  251. package/src/prompts/cursor-composer-edit-discipline.md +7 -0
  252. package/src/prompts/turn-aborted-guidance.md +4 -0
  253. package/src/provider-details.ts +90 -0
  254. package/src/provider-models/bundled-references.ts +38 -0
  255. package/src/provider-models/descriptors.ts +392 -0
  256. package/src/provider-models/google.ts +92 -0
  257. package/src/provider-models/index.ts +5 -0
  258. package/src/provider-models/ollama.ts +159 -0
  259. package/src/provider-models/openai-compat.ts +2920 -0
  260. package/src/provider-models/special.ts +185 -0
  261. package/src/providers/amazon-bedrock.d.ts +60 -0
  262. package/src/providers/amazon-bedrock.ts +939 -0
  263. package/src/providers/anthropic-messages-server-schema.ts +229 -0
  264. package/src/providers/anthropic-messages-server.ts +839 -0
  265. package/src/providers/anthropic.d.ts +280 -0
  266. package/src/providers/anthropic.ts +4421 -0
  267. package/src/providers/aws-credential-config.d.ts +19 -0
  268. package/src/providers/aws-credential-config.ts +179 -0
  269. package/src/providers/aws-credentials.d.ts +43 -0
  270. package/src/providers/aws-credentials.ts +457 -0
  271. package/src/providers/aws-eventstream.d.ts +38 -0
  272. package/src/providers/aws-eventstream.ts +185 -0
  273. package/src/providers/aws-sigv4.d.ts +55 -0
  274. package/src/providers/aws-sigv4.ts +218 -0
  275. package/src/providers/azure-openai-responses.d.ts +22 -0
  276. package/src/providers/azure-openai-responses.ts +447 -0
  277. package/src/providers/composer-discipline.d.ts +32 -0
  278. package/src/providers/composer-discipline.ts +95 -0
  279. package/src/providers/cursor/client-version.d.ts +10 -0
  280. package/src/providers/cursor/client-version.ts +10 -0
  281. package/src/providers/cursor/exec-modern.d.ts +98 -0
  282. package/src/providers/cursor/exec-modern.ts +497 -0
  283. package/src/providers/cursor/gen/agent_pb.d.ts +16769 -0
  284. package/src/providers/cursor/gen/agent_pb.ts +19780 -0
  285. package/src/providers/cursor/proto/agent.proto +4533 -0
  286. package/src/providers/cursor/proto/buf.gen.yaml +6 -0
  287. package/src/providers/cursor/proto/buf.yaml +17 -0
  288. package/src/providers/cursor-pi-args.d.ts +119 -0
  289. package/src/providers/cursor-pi-args.ts +187 -0
  290. package/src/providers/cursor.d.ts +72 -0
  291. package/src/providers/cursor.ts +3396 -0
  292. package/src/providers/dashscope-token-plan-headers.d.ts +57 -0
  293. package/src/providers/dashscope-token-plan-headers.ts +84 -0
  294. package/src/providers/error-message.d.ts +27 -0
  295. package/src/providers/error-message.ts +21 -0
  296. package/src/providers/github-copilot-headers.d.ts +40 -0
  297. package/src/providers/github-copilot-headers.ts +140 -0
  298. package/src/providers/gitlab-duo.d.ts +27 -0
  299. package/src/providers/gitlab-duo.ts +393 -0
  300. package/src/providers/google-auth.d.ts +26 -0
  301. package/src/providers/google-auth.ts +262 -0
  302. package/src/providers/google-gemini-cli.d.ts +75 -0
  303. package/src/providers/google-gemini-cli.ts +969 -0
  304. package/src/providers/google-gemini-headers.d.ts +43 -0
  305. package/src/providers/google-gemini-headers.ts +100 -0
  306. package/src/providers/google-shared.d.ts +183 -0
  307. package/src/providers/google-shared.ts +1105 -0
  308. package/src/providers/google-types.d.ts +138 -0
  309. package/src/providers/google-types.ts +167 -0
  310. package/src/providers/google-vertex.d.ts +11 -0
  311. package/src/providers/google-vertex.ts +124 -0
  312. package/src/providers/google.d.ts +4 -0
  313. package/src/providers/google.ts +41 -0
  314. package/src/providers/grammar.d.ts +1 -0
  315. package/src/providers/grammar.ts +70 -0
  316. package/src/providers/kimi.d.ts +27 -0
  317. package/src/providers/kimi.ts +52 -0
  318. package/src/providers/kiro-api-key.d.ts +50 -0
  319. package/src/providers/kiro-api-key.ts +786 -0
  320. package/src/providers/kiro-codewhisperer.d.ts +11 -0
  321. package/src/providers/kiro-codewhisperer.ts +600 -0
  322. package/src/providers/mock.ts +526 -0
  323. package/src/providers/ollama.d.ts +41 -0
  324. package/src/providers/ollama.ts +645 -0
  325. package/src/providers/openai-anthropic-shim.d.ts +31 -0
  326. package/src/providers/openai-anthropic-shim.ts +156 -0
  327. package/src/providers/openai-bounded-rate-limits.d.ts +3 -0
  328. package/src/providers/openai-bounded-rate-limits.ts +57 -0
  329. package/src/providers/openai-chat-server-schema.ts +254 -0
  330. package/src/providers/openai-chat-server.ts +724 -0
  331. package/src/providers/openai-codex/constants.d.ts +26 -0
  332. package/src/providers/openai-codex/constants.ts +43 -0
  333. package/src/providers/openai-codex/request-transformer.d.ts +50 -0
  334. package/src/providers/openai-codex/request-transformer.ts +219 -0
  335. package/src/providers/openai-codex/response-handler.d.ts +18 -0
  336. package/src/providers/openai-codex/response-handler.ts +111 -0
  337. package/src/providers/openai-codex-responses.d.ts +71 -0
  338. package/src/providers/openai-codex-responses.ts +3288 -0
  339. package/src/providers/openai-completions-compat.d.ts +6 -0
  340. package/src/providers/openai-completions-compat.ts +6 -0
  341. package/src/providers/openai-completions.d.ts +35 -0
  342. package/src/providers/openai-completions.ts +2294 -0
  343. package/src/providers/openai-opencodex-responses.ts +174 -0
  344. package/src/providers/openai-request-transform.d.ts +4 -0
  345. package/src/providers/openai-request-transform.ts +136 -0
  346. package/src/providers/openai-responses-server-schema.ts +290 -0
  347. package/src/providers/openai-responses-server.ts +1268 -0
  348. package/src/providers/openai-responses-shared.d.ts +106 -0
  349. package/src/providers/openai-responses-shared.ts +1253 -0
  350. package/src/providers/openai-responses.d.ts +37 -0
  351. package/src/providers/openai-responses.ts +989 -0
  352. package/src/providers/pi-native-client.d.ts +29 -0
  353. package/src/providers/pi-native-client.ts +243 -0
  354. package/src/providers/pi-native-server.ts +488 -0
  355. package/src/providers/register-builtins.d.ts +59 -0
  356. package/src/providers/register-builtins.ts +544 -0
  357. package/src/providers/synthetic.d.ts +26 -0
  358. package/src/providers/synthetic.ts +50 -0
  359. package/src/providers/transform-messages.d.ts +33 -0
  360. package/src/providers/transform-messages.ts +408 -0
  361. package/src/providers/vision-guard.d.ts +8 -0
  362. package/src/providers/vision-guard.ts +31 -0
  363. package/src/rate-limit-utils.d.ts +19 -0
  364. package/src/rate-limit-utils.ts +102 -0
  365. package/src/stream.d.ts +45 -0
  366. package/src/stream.ts +1306 -0
  367. package/src/types.d.ts +1073 -0
  368. package/src/types.ts +1305 -0
  369. package/src/usage/claude.ts +449 -0
  370. package/src/usage/gemini.ts +250 -0
  371. package/src/usage/github-copilot.ts +421 -0
  372. package/src/usage/google-antigravity.ts +201 -0
  373. package/src/usage/grok-cli.ts +259 -0
  374. package/src/usage/kimi.ts +285 -0
  375. package/src/usage/minimax-code.ts +31 -0
  376. package/src/usage/openai-codex.ts +503 -0
  377. package/src/usage/shared.ts +10 -0
  378. package/src/usage/zai.ts +247 -0
  379. package/src/usage.ts +190 -0
  380. package/src/utils/abort.d.ts +19 -0
  381. package/src/utils/abort.ts +51 -0
  382. package/src/utils/anthropic-auth.ts +95 -0
  383. package/src/utils/block-symbols.d.ts +6 -0
  384. package/src/utils/block-symbols.ts +11 -0
  385. package/src/utils/discovery/antigravity.ts +275 -0
  386. package/src/utils/discovery/codex.ts +362 -0
  387. package/src/utils/discovery/cursor.ts +388 -0
  388. package/src/utils/discovery/gemini.ts +248 -0
  389. package/src/utils/discovery/index.ts +4 -0
  390. package/src/utils/discovery/openai-compatible.ts +379 -0
  391. package/src/utils/event-stream.d.ts +41 -0
  392. package/src/utils/event-stream.ts +269 -0
  393. package/src/utils/fallback-transport.d.ts +110 -0
  394. package/src/utils/fallback-transport.ts +411 -0
  395. package/src/utils/fireworks-model-id.d.ts +10 -0
  396. package/src/utils/fireworks-model-id.ts +30 -0
  397. package/src/utils/foundry.d.ts +11 -0
  398. package/src/utils/foundry.ts +18 -0
  399. package/src/utils/h2-fetch.ts +60 -0
  400. package/src/utils/http-inspector.d.ts +59 -0
  401. package/src/utils/http-inspector.ts +380 -0
  402. package/src/utils/idle-iterator.d.ts +122 -0
  403. package/src/utils/idle-iterator.ts +410 -0
  404. package/src/utils/json-parse.d.ts +98 -0
  405. package/src/utils/json-parse.ts +607 -0
  406. package/src/utils/oauth/alibaba-token-plan.ts +60 -0
  407. package/src/utils/oauth/anthropic.ts +233 -0
  408. package/src/utils/oauth/api-key-login.ts +98 -0
  409. package/src/utils/oauth/api-key-validation.ts +344 -0
  410. package/src/utils/oauth/bizrouter.ts +15 -0
  411. package/src/utils/oauth/callback-server.d.ts +80 -0
  412. package/src/utils/oauth/callback-server.ts +359 -0
  413. package/src/utils/oauth/cerebras.ts +16 -0
  414. package/src/utils/oauth/cloudflare-ai-gateway.ts +48 -0
  415. package/src/utils/oauth/commandcode.ts +17 -0
  416. package/src/utils/oauth/cursor.ts +157 -0
  417. package/src/utils/oauth/deepinfra.ts +15 -0
  418. package/src/utils/oauth/deepseek.ts +53 -0
  419. package/src/utils/oauth/firepass.ts +24 -0
  420. package/src/utils/oauth/fireworks.ts +15 -0
  421. package/src/utils/oauth/fugu.ts +15 -0
  422. package/src/utils/oauth/github-copilot.d.ts +38 -0
  423. package/src/utils/oauth/github-copilot.ts +362 -0
  424. package/src/utils/oauth/gitlab-duo.ts +123 -0
  425. package/src/utils/oauth/glm-zcode.d.ts +71 -0
  426. package/src/utils/oauth/glm-zcode.ts +433 -0
  427. package/src/utils/oauth/google-antigravity.ts +200 -0
  428. package/src/utils/oauth/google-gemini-cli.ts +256 -0
  429. package/src/utils/oauth/google-oauth-shared.ts +110 -0
  430. package/src/utils/oauth/huggingface.ts +62 -0
  431. package/src/utils/oauth/index.ts +558 -0
  432. package/src/utils/oauth/kagi.ts +47 -0
  433. package/src/utils/oauth/kilo.ts +87 -0
  434. package/src/utils/oauth/kimi.d.ts +17 -0
  435. package/src/utils/oauth/kimi.ts +275 -0
  436. package/src/utils/oauth/kiro.ts +448 -0
  437. package/src/utils/oauth/litellm.ts +47 -0
  438. package/src/utils/oauth/lm-studio.ts +38 -0
  439. package/src/utils/oauth/mara.ts +16 -0
  440. package/src/utils/oauth/minimax-code.ts +78 -0
  441. package/src/utils/oauth/moonshot.ts +16 -0
  442. package/src/utils/oauth/nanogpt.ts +15 -0
  443. package/src/utils/oauth/nvidia.ts +70 -0
  444. package/src/utils/oauth/oauth.html +199 -0
  445. package/src/utils/oauth/ollama-cloud.ts +28 -0
  446. package/src/utils/oauth/ollama.ts +47 -0
  447. package/src/utils/oauth/openai-codex.ts +299 -0
  448. package/src/utils/oauth/opencode.ts +49 -0
  449. package/src/utils/oauth/opengateway.ts +15 -0
  450. package/src/utils/oauth/openrouter.ts +16 -0
  451. package/src/utils/oauth/parallel.ts +46 -0
  452. package/src/utils/oauth/perplexity.ts +225 -0
  453. package/src/utils/oauth/pkce.ts +18 -0
  454. package/src/utils/oauth/qianfan.ts +58 -0
  455. package/src/utils/oauth/qwen-portal.ts +60 -0
  456. package/src/utils/oauth/sglang.ts +42 -0
  457. package/src/utils/oauth/synthetic.ts +15 -0
  458. package/src/utils/oauth/tavily.ts +46 -0
  459. package/src/utils/oauth/together.ts +16 -0
  460. package/src/utils/oauth/types.d.ts +56 -0
  461. package/src/utils/oauth/types.ts +122 -0
  462. package/src/utils/oauth/venice.ts +59 -0
  463. package/src/utils/oauth/vercel-ai-gateway.ts +47 -0
  464. package/src/utils/oauth/vllm.ts +42 -0
  465. package/src/utils/oauth/xai.ts +246 -0
  466. package/src/utils/oauth/xiaomi.ts +199 -0
  467. package/src/utils/oauth/zai.ts +60 -0
  468. package/src/utils/oauth/zenmux.ts +15 -0
  469. package/src/utils/overflow.ts +275 -0
  470. package/src/utils/parse-bind.ts +81 -0
  471. package/src/utils/provider-response.d.ts +6 -0
  472. package/src/utils/provider-response.ts +30 -0
  473. package/src/utils/provider-safety-stop.ts +8 -0
  474. package/src/utils/proxy.d.ts +7 -0
  475. package/src/utils/proxy.ts +652 -0
  476. package/src/utils/retry-after.d.ts +3 -0
  477. package/src/utils/retry-after.ts +110 -0
  478. package/src/utils/retry-budget.d.ts +1 -0
  479. package/src/utils/retry-budget.ts +4 -0
  480. package/src/utils/retry.d.ts +29 -0
  481. package/src/utils/retry.ts +67 -0
  482. package/src/utils/schema/CONSTRAINTS.md +164 -0
  483. package/src/utils/schema/adapt.d.ts +24 -0
  484. package/src/utils/schema/adapt.ts +36 -0
  485. package/src/utils/schema/compatibility.d.ts +30 -0
  486. package/src/utils/schema/compatibility.ts +435 -0
  487. package/src/utils/schema/dereference.d.ts +11 -0
  488. package/src/utils/schema/dereference.ts +98 -0
  489. package/src/utils/schema/draft.d.ts +10 -0
  490. package/src/utils/schema/draft.ts +341 -0
  491. package/src/utils/schema/equality.d.ts +4 -0
  492. package/src/utils/schema/equality.ts +97 -0
  493. package/src/utils/schema/fields.d.ts +49 -0
  494. package/src/utils/schema/fields.ts +190 -0
  495. package/src/utils/schema/index.d.ts +14 -0
  496. package/src/utils/schema/index.ts +14 -0
  497. package/src/utils/schema/json-schema-validator.d.ts +12 -0
  498. package/src/utils/schema/json-schema-validator.ts +577 -0
  499. package/src/utils/schema/meta-validator.d.ts +2 -0
  500. package/src/utils/schema/meta-validator.ts +167 -0
  501. package/src/utils/schema/normalize.d.ts +93 -0
  502. package/src/utils/schema/normalize.ts +1588 -0
  503. package/src/utils/schema/root-combinator.d.ts +12 -0
  504. package/src/utils/schema/root-combinator.ts +143 -0
  505. package/src/utils/schema/spill.d.ts +8 -0
  506. package/src/utils/schema/spill.ts +43 -0
  507. package/src/utils/schema/stamps.d.ts +25 -0
  508. package/src/utils/schema/stamps.ts +97 -0
  509. package/src/utils/schema/types.d.ts +4 -0
  510. package/src/utils/schema/types.ts +11 -0
  511. package/src/utils/schema/wire.d.ts +54 -0
  512. package/src/utils/schema/wire.ts +213 -0
  513. package/src/utils/schema/zod-decontaminate.d.ts +31 -0
  514. package/src/utils/schema/zod-decontaminate.ts +331 -0
  515. package/src/utils/sse-debug.d.ts +10 -0
  516. package/src/utils/sse-debug.ts +289 -0
  517. package/src/utils/tool-call-healing.d.ts +80 -0
  518. package/src/utils/tool-call-healing.ts +298 -0
  519. package/src/utils/tool-choice-capability.d.ts +57 -0
  520. package/src/utils/tool-choice-capability.ts +633 -0
  521. package/src/utils/tool-choice.d.ts +50 -0
  522. package/src/utils/tool-choice.ts +99 -0
  523. package/src/utils/validation.ts +1080 -0
  524. package/src/utils.d.ts +117 -0
  525. package/src/utils.ts +523 -0
@@ -0,0 +1,1576 @@
1
+ /**
2
+ * Client-side {@link AuthCredentialStore} that mirrors a remote broker's
3
+ * snapshot. Refresh tokens never leave the broker; mutating methods (`replace*`,
4
+ * `upsert*`, `delete*ForProvider`) throw because login flows are server-side.
5
+ *
6
+ * Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral —
7
+ * usage reports cache TTL is 5 minutes per credential, so durability across
8
+ * runs isn't required.
9
+ */
10
+
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ import * as fs from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import { scheduler } from "node:timers/promises";
15
+
16
+ import { getConfigRootDir, isEnoent, logger } from "@vib-rato/utils";
17
+ import {
18
+ type AuthCredential,
19
+ type AuthCredentialIfAbsentResult,
20
+ type AuthCredentialStore,
21
+ assertCanonicalMCPOAuthBinding,
22
+ type CachedCredentialHealth,
23
+ type CachedUsagePresentation,
24
+ type CredentialDispatchTicket,
25
+ type CredentialInventoryRecord,
26
+ type MCPOAuthRefreshClient,
27
+ type OAuthCredential,
28
+ REMOTE_REFRESH_SENTINEL,
29
+ type SafeUsageReport,
30
+ type StoredAuthCredential,
31
+ } from "../auth-storage";
32
+ import type { Provider } from "../types";
33
+ import type { UsageReport } from "../usage";
34
+ import type { OAuthCredentials } from "../utils/oauth/types";
35
+ import {
36
+ type AuthBrokerClient,
37
+ AuthBrokerCredentialMetadataUnsupportedError,
38
+ AuthBrokerStreamUnsupportedError,
39
+ } from "./client";
40
+ import { cleanReason } from "./redact";
41
+ import type {
42
+ CredentialMetadataRecord,
43
+ RefresherSchedule,
44
+ SnapshotEntry,
45
+ SnapshotResponse,
46
+ SnapshotStreamEvent,
47
+ } from "./types";
48
+
49
+ export type CredentialInventoryMetadataCapability = "pending" | "supported" | "unsupported" | "mismatch" | "failed";
50
+
51
+ export interface CachedInventoryNotice {
52
+ status: Exclude<CredentialInventoryMetadataCapability, "supported">;
53
+ reason: string;
54
+ generation?: number;
55
+ }
56
+
57
+ export interface CredentialInventoryMetadataState {
58
+ capability: CredentialInventoryMetadataCapability;
59
+ generation: number;
60
+ records: readonly CredentialInventoryRecord[];
61
+ notice?: CachedInventoryNotice;
62
+ }
63
+
64
+ /**
65
+ * Client-side TTL for the aggregate `/v1/usage` response. Set below the
66
+ * broker server's own 30s usage cache so we typically pick up the broker's
67
+ * cached value instead of re-walking the network — but high enough to absorb
68
+ * the parallel fan-out from `#rankOAuthSelections` into a single round-trip.
69
+ */
70
+ const USAGE_CACHE_TTL_MS = 15_000;
71
+ const WAIT_THRESHOLD_MS = 1_000;
72
+ const MAX_WAIT_MS = 5_000;
73
+ const BACKGROUND_WAIT_MS = 30_000;
74
+ const BACKGROUND_BACKOFF_INITIAL_MS = 500;
75
+ const BACKGROUND_BACKOFF_MAX_MS = 30_000;
76
+
77
+ function epochRank(epoch: string): number | undefined {
78
+ const match = /^(\d+)-/.exec(epoch);
79
+ return match ? Number(match[1]) : undefined;
80
+ }
81
+ const PRESENTATION_FRESH_MS = 5 * 60_000;
82
+ const PRESENTATION_RETENTION_MS = 24 * 60 * 60_000;
83
+ const PRESENTATION_SIDECAR_VERSION = 1;
84
+ /**
85
+ * Default location of the redacted presentation sidecar.
86
+ *
87
+ * Derived when a store is constructed, never at import time: the trusted
88
+ * config root is call-time state (#4761, #4772), so an import-time constant
89
+ * keeps pointing at the home that was in effect when this module first loaded
90
+ * and a process can read and write one logical profile through two different
91
+ * roots (#4786).
92
+ */
93
+ function defaultPresentationSidecarPath(): string {
94
+ return path.join(getConfigRootDir(), "auth-broker-presentations.json");
95
+ }
96
+
97
+ function emptySnapshot(): SnapshotResponse {
98
+ return {
99
+ generation: 0,
100
+ generatedAt: 0,
101
+ serverNowMs: 0,
102
+ refresher: {
103
+ enabled: false,
104
+ intervalMs: 0,
105
+ skewMs: 0,
106
+ nextSweepInMs: Number.MAX_SAFE_INTEGER,
107
+ },
108
+ credentials: [],
109
+ };
110
+ }
111
+
112
+ interface CacheEntry {
113
+ value: string;
114
+ expiresAtSec: number;
115
+ }
116
+
117
+ interface UsageCacheEntry {
118
+ reports: UsageReport[];
119
+ fetchedAt: number;
120
+ }
121
+
122
+ interface PersistedPresentation {
123
+ credentialId: number;
124
+ provider: string;
125
+ identityDigest: string;
126
+ health?: {
127
+ v: 1;
128
+ status: "ok" | "failed" | "unverifiable";
129
+ reason: string | null;
130
+ checkedAt?: number;
131
+ retainUntil: number;
132
+ };
133
+ usage?: CachedUsagePresentation;
134
+ }
135
+
136
+ interface PresentationSidecarFile {
137
+ version: 1;
138
+ authorities: Record<string, Record<string, PersistedPresentation>>;
139
+ sourceHealth?: Record<string, Record<string, CacheEntry>>;
140
+ }
141
+
142
+ function presentationRecordKey(provider: string, identityDigest: string): string {
143
+ return `${provider}\u0000${identityDigest}`;
144
+ }
145
+
146
+ function safePresentationReason(value: unknown): string | null {
147
+ return cleanReason(value) ?? null;
148
+ }
149
+
150
+ export interface RemoteAuthCredentialStoreOptions {
151
+ client: AuthBrokerClient;
152
+ /**
153
+ * Initial snapshot. When omitted, callers must call
154
+ * {@link RemoteAuthCredentialStore.refreshSnapshot} before the first read.
155
+ */
156
+ initialSnapshot?: SnapshotResponse;
157
+ /**
158
+ * Subscribe to the broker's SSE snapshot stream when available. Falls back
159
+ * to long-poll permanently when the broker returns 404. Default `true`.
160
+ */
161
+ streamSnapshots?: boolean;
162
+ /** Override the local redacted presentation sidecar path (primarily for tests). */
163
+ presentationPath?: string;
164
+ }
165
+
166
+ export class RemoteAuthCredentialStore implements AuthCredentialStore {
167
+ readonly #client: AuthBrokerClient;
168
+ readonly #streamSnapshots: boolean;
169
+ #snapshot: SnapshotResponse = emptySnapshot();
170
+ #snapshotReceivedAt = Date.now();
171
+ #generation = 0;
172
+ #epoch?: string;
173
+ #retiredEpochs = new Set<string>();
174
+ #snapshotAuthorityTail: Promise<void> = Promise.resolve();
175
+ #snapshotListeners = new Set<() => void>();
176
+ #backgroundAbort = new AbortController();
177
+ #cache: Map<string, CacheEntry> = new Map();
178
+ #usageCache?: UsageCacheEntry;
179
+ #usageInflight?: Promise<UsageReport[] | null>;
180
+ #scopedUsageCache = new Map<Provider, UsageCacheEntry>();
181
+ #scopedUsageInflight = new Map<Provider, Promise<UsageReport[] | null>>();
182
+ #scopedUsageFailures = new Map<Provider, number>();
183
+ #usageCacheEpoch = 0;
184
+ #inventoryMetadata = new Map<number, CredentialMetadataRecord>();
185
+ #inventoryMetadataGeneration = -1;
186
+ #inventoryState: CredentialInventoryMetadataState = {
187
+ capability: "pending",
188
+ generation: 0,
189
+ records: [],
190
+ notice: { status: "pending", reason: "credential metadata sync pending", generation: 0 },
191
+ };
192
+ #inventorySyncInflight?: Promise<Readonly<CredentialInventoryMetadataState>>;
193
+ #inventoryMetadataUnsupported = false;
194
+ #usagePresentations = new Map<number, CachedUsagePresentation>();
195
+ readonly #presentationPath: string;
196
+ readonly #presentationAuthority: string;
197
+ #persistedPresentations = new Map<string, PersistedPresentation>();
198
+ #sidecarAuthorities: Record<string, Record<string, PersistedPresentation>> = {};
199
+ #persistedSourceHealth: Record<string, Record<string, CacheEntry>> = {};
200
+ #presentationReady: Promise<void>;
201
+ #presentationWriteChain: Promise<void> = Promise.resolve();
202
+ #closed = false;
203
+ /**
204
+ * `true` once the SSE consumer received its first frame and hasn't dropped
205
+ * since. Writes consult this to suppress the otherwise-mandatory
206
+ * `refreshSnapshot()` follow-up — the stream will deliver the new
207
+ * generation without an extra GET.
208
+ */
209
+ #streamingActive = false;
210
+ /** Latched once the broker has answered 404 — never try the stream again. */
211
+ #streamingUnsupported = false;
212
+ #loadingInitialSnapshot = true;
213
+ #snapshotAuthoritative = false;
214
+
215
+ constructor(opts: RemoteAuthCredentialStoreOptions) {
216
+ this.#client = opts.client;
217
+ this.#streamSnapshots = opts.streamSnapshots ?? true;
218
+ this.#presentationPath = opts.presentationPath ?? defaultPresentationSidecarPath();
219
+ this.#presentationAuthority = createHash("sha256").update(this.#client.baseUrl).digest("hex");
220
+ this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0, false);
221
+ this.#loadingInitialSnapshot = false;
222
+ this.#snapshotAuthoritative = opts.initialSnapshot !== undefined;
223
+ this.#setInventoryState("pending", this.#generation, {
224
+ status: "pending",
225
+ reason: "credential metadata sync pending",
226
+ generation: this.#generation,
227
+ });
228
+ this.#presentationReady = this.#loadPresentationSidecar();
229
+ this.#scheduleInventoryMetadataSync();
230
+ void this.#runBackground();
231
+ }
232
+
233
+ get client(): AuthBrokerClient {
234
+ return this.#client;
235
+ }
236
+
237
+ /** Wait for redacted presentation hydration and initial inventory metadata. */
238
+ async waitForReady(): Promise<void> {
239
+ await this.#presentationReady;
240
+ await this.syncInventoryMetadata();
241
+ }
242
+
243
+ /** Await pending atomic sidecar writes (useful to bounded shutdown callers). */
244
+ async flushPresentationPersistence(): Promise<void> {
245
+ await this.#presentationReady;
246
+ await this.#presentationWriteChain;
247
+ }
248
+
249
+ async #loadPresentationSidecar(): Promise<void> {
250
+ let raw: string;
251
+ try {
252
+ const stat = await fs.lstat(this.#presentationPath);
253
+ if (stat.isSymbolicLink() || !stat.isFile()) return;
254
+ raw = await fs.readFile(this.#presentationPath, "utf8");
255
+ } catch (error) {
256
+ if (isEnoent(error)) return;
257
+ logger.debug("auth-broker presentation sidecar unavailable", { error: String(error) });
258
+ return;
259
+ }
260
+ try {
261
+ const parsed = JSON.parse(raw) as Partial<PresentationSidecarFile>;
262
+ if (parsed.version !== PRESENTATION_SIDECAR_VERSION || !parsed.authorities) return;
263
+ this.#sidecarAuthorities = parsed.authorities;
264
+ this.#persistedSourceHealth = parsed.sourceHealth ?? {};
265
+ for (const [key, entry] of Object.entries(this.#persistedSourceHealth[this.#presentationAuthority] ?? {})) {
266
+ if (entry.expiresAtSec * 1000 > Date.now()) this.#cache.set(key, entry);
267
+ }
268
+ const records = this.#sidecarAuthorities[this.#presentationAuthority];
269
+ if (!records || typeof records !== "object") return;
270
+ const now = Date.now();
271
+ for (const [key, candidate] of Object.entries(records)) {
272
+ if (!candidate || typeof candidate !== "object") continue;
273
+ const record = candidate as PersistedPresentation;
274
+ if (!Number.isInteger(record.credentialId) || typeof record.provider !== "string") continue;
275
+ if (record.health && (!Number.isFinite(record.health.retainUntil) || record.health.retainUntil <= now)) {
276
+ delete record.health;
277
+ }
278
+ if (record.usage && (!Number.isFinite(record.usage.retainUntil) || record.usage.retainUntil <= now)) {
279
+ delete record.usage;
280
+ }
281
+ if (!record.health && !record.usage) continue;
282
+ this.#persistedPresentations.set(key, record);
283
+ }
284
+ if (this.#snapshotAuthoritative) {
285
+ this.#reconcilePersistedPresentations();
286
+ this.#hydratePresentations();
287
+ this.#queuePresentationWrite();
288
+ }
289
+ } catch (error) {
290
+ logger.debug("auth-broker presentation sidecar invalid", { error: String(error) });
291
+ }
292
+ }
293
+
294
+ #hydratePresentations(): void {
295
+ for (const entry of this.#snapshot.credentials) {
296
+ const key = presentationRecordKey(entry.provider, this.#identityDigestForEntry(entry));
297
+ const persisted = this.#persistedPresentations.get(key);
298
+ if (!persisted) continue;
299
+ if (persisted.usage) {
300
+ this.#usagePresentations.set(entry.id, {
301
+ ...persisted.usage,
302
+ credentialId: entry.id,
303
+ provider: entry.provider,
304
+ inventoryGeneration: this.#generation,
305
+ identityDigest: this.#identityDigestForEntry(entry),
306
+ });
307
+ }
308
+ if (persisted.health && persisted.health.retainUntil <= Date.now()) delete persisted.health;
309
+ }
310
+ }
311
+
312
+ #reconcilePersistedPresentations(): void {
313
+ if (this.#persistedPresentations.size === 0) return;
314
+ let changed = false;
315
+ const current = new Map(
316
+ this.#snapshot.credentials.map(entry => [
317
+ entry.id,
318
+ { provider: entry.provider, identityDigest: this.#identityDigestForEntry(entry) },
319
+ ]),
320
+ );
321
+ for (const [key, record] of this.#persistedPresentations) {
322
+ const identity = current.get(record.credentialId);
323
+ if (
324
+ !identity ||
325
+ identity.provider !== record.provider ||
326
+ key !== presentationRecordKey(identity.provider, identity.identityDigest)
327
+ ) {
328
+ this.#persistedPresentations.delete(key);
329
+ changed = true;
330
+ }
331
+ }
332
+ if (changed) this.#queuePresentationWrite();
333
+ }
334
+
335
+ #queuePresentationWrite(): void {
336
+ const next = this.#presentationWriteChain
337
+ .catch(() => {})
338
+ .then(async () => {
339
+ const parsed: PresentationSidecarFile = {
340
+ version: 1,
341
+ authorities: {
342
+ ...this.#sidecarAuthorities,
343
+ [this.#presentationAuthority]: Object.fromEntries(this.#persistedPresentations),
344
+ },
345
+ sourceHealth: this.#persistedSourceHealth,
346
+ };
347
+ this.#sidecarAuthorities = parsed.authorities;
348
+ const directory = path.dirname(this.#presentationPath);
349
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
350
+ const existing = await fs.lstat(this.#presentationPath).catch(error => {
351
+ if (isEnoent(error)) return undefined;
352
+ throw error;
353
+ });
354
+ if (existing?.isSymbolicLink() || (existing && !existing.isFile()))
355
+ throw new Error("Auth-broker presentation sidecar path is unsafe");
356
+ const temporary = `${this.#presentationPath}.${process.pid}.${randomUUID()}.tmp`;
357
+ try {
358
+ try {
359
+ await fs.writeFile(temporary, JSON.stringify(parsed), { flag: "wx", mode: 0o600 });
360
+ } catch (error) {
361
+ if (isEnoent(error)) return;
362
+ throw error;
363
+ }
364
+ await fs.chmod(temporary, 0o600).catch(() => {});
365
+ await fs.rename(temporary, this.#presentationPath).catch(error => {
366
+ if (!isEnoent(error)) throw error;
367
+ });
368
+ } finally {
369
+ await fs.unlink(temporary).catch(() => {});
370
+ }
371
+ });
372
+ this.#presentationWriteChain = next;
373
+ }
374
+
375
+ get snapshot(): SnapshotResponse {
376
+ return this.#snapshot;
377
+ }
378
+
379
+ getInventoryMetadataState(): Readonly<CredentialInventoryMetadataState> {
380
+ const records = this.#buildInventoryRecords().map(record => ({ ...record }));
381
+ const notice = this.#inventoryState.notice ? { ...this.#inventoryState.notice } : undefined;
382
+ return {
383
+ capability: this.#inventoryState.capability,
384
+ generation: this.#inventoryState.generation,
385
+ records,
386
+ ...(notice ? { notice } : {}),
387
+ };
388
+ }
389
+
390
+ #applySnapshot(snapshot: SnapshotResponse, generation: number, scheduleMetadata = true): boolean {
391
+ // Broker generations are process-local and reset when the broker restarts.
392
+ // A lower generation from a newer server timestamp is therefore a new
393
+ // broker epoch, while an older timestamp is an out-of-order response from
394
+ // the current epoch and must be discarded.
395
+ if (this.#epoch) {
396
+ if (!snapshot.epoch) return false;
397
+ if (snapshot.epoch === this.#epoch) {
398
+ if (generation < this.#generation) return false;
399
+ if (generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs) return false;
400
+ } else {
401
+ if (this.#retiredEpochs.has(snapshot.epoch)) return false;
402
+ const currentRank = epochRank(this.#epoch);
403
+ const incomingRank = epochRank(snapshot.epoch);
404
+ // An unseen epoch is only safe to accept when both broker epochs carry
405
+ // the authoritative monotonic sequence prefix. Opaque epoch strings
406
+ // cannot prove that a delayed response is newer, so fail closed rather
407
+ // than letting it replace the current credential snapshot.
408
+ if (currentRank === undefined || incomingRank === undefined || incomingRank <= currentRank) return false;
409
+ this.#retiredEpochs.add(this.#epoch);
410
+ }
411
+ } else if (snapshot.epoch) {
412
+ // First epoch-bearing response establishes the authority namespace.
413
+ } else if (
414
+ (generation < this.#generation && snapshot.serverNowMs <= this.#snapshot.serverNowMs) ||
415
+ (generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs)
416
+ ) {
417
+ // Epoch-less legacy brokers use serverNowMs as the restart discriminator:
418
+ // accept a reset generation only when the broker proves it is newer than
419
+ // the last accepted legacy snapshot, while delayed old responses fail closed.
420
+ if (
421
+ (generation < this.#generation && snapshot.serverNowMs <= this.#snapshot.serverNowMs) ||
422
+ (generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs)
423
+ )
424
+ return false;
425
+ }
426
+ const generationChanged =
427
+ generation !== this.#generation ||
428
+ snapshot.epoch !== this.#epoch ||
429
+ this.#inventoryMetadataGeneration !== generation;
430
+ this.#snapshot = snapshot;
431
+ this.#generation = generation;
432
+ if (!this.#loadingInitialSnapshot) this.#snapshotAuthoritative = true;
433
+ this.#epoch = snapshot.epoch ?? this.#epoch;
434
+ this.#snapshotReceivedAt = Date.now();
435
+ if (generationChanged) {
436
+ this.#inventoryMetadata.clear();
437
+ this.#inventoryMetadataGeneration = -1;
438
+ this.#invalidateUsageCache();
439
+ this.#reconcileUsagePresentations();
440
+ if (this.#inventoryMetadataUnsupported) {
441
+ this.#setInventoryState("unsupported", generation, {
442
+ status: "unsupported",
443
+ reason: "credential metadata endpoint unsupported; disabled rows unavailable",
444
+ generation,
445
+ });
446
+ } else {
447
+ this.#setInventoryState("pending", generation, {
448
+ status: "pending",
449
+ reason: "credential metadata sync pending",
450
+ generation,
451
+ });
452
+ if (scheduleMetadata) this.#scheduleInventoryMetadataSync();
453
+ }
454
+ }
455
+ if (this.#snapshotAuthoritative) {
456
+ this.#reconcilePersistedPresentations();
457
+ this.#hydratePresentations();
458
+ }
459
+ for (const listener of this.#snapshotListeners) listener();
460
+ return true;
461
+ }
462
+
463
+ /**
464
+ * A full GET can race the SSE stream. Once a newer snapshot from the same
465
+ * broker epoch has been accepted, an older GET is merely superseded data —
466
+ * not an authority failure that should turn a healthy request into a 503.
467
+ * Epoch regressions remain rejected so a delayed response from a retired
468
+ * broker cannot cross an authority boundary.
469
+ */
470
+ #isSupersededSnapshot(snapshot: SnapshotResponse, generation: number): boolean {
471
+ if (this.#epoch !== undefined || snapshot.epoch !== undefined) {
472
+ return (
473
+ snapshot.epoch === this.#epoch &&
474
+ (generation < this.#generation ||
475
+ (generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs))
476
+ );
477
+ }
478
+ return (
479
+ (generation < this.#generation && snapshot.serverNowMs <= this.#snapshot.serverNowMs) ||
480
+ (generation === this.#generation && snapshot.serverNowMs < this.#snapshot.serverNowMs)
481
+ );
482
+ }
483
+
484
+ onSnapshotChanged(listener: () => void): () => void {
485
+ this.#snapshotListeners.add(listener);
486
+ return () => this.#snapshotListeners.delete(listener);
487
+ }
488
+
489
+ #withSnapshotAuthority<T>(operation: () => Promise<T>): Promise<T> {
490
+ const run = this.#snapshotAuthorityTail.then(operation);
491
+ this.#snapshotAuthorityTail = run.then(
492
+ () => undefined,
493
+ () => undefined,
494
+ );
495
+ return run;
496
+ }
497
+
498
+ /**
499
+ * Queue a provider-admission ticket behind all currently pending snapshot
500
+ * authority work. Snapshot applications that arrive after this ticket wait
501
+ * for its release, so a revocation is ordered either before admission or
502
+ * after it — never in the middle of the dispatch boundary.
503
+ */
504
+ async acquireCredentialDispatchTicket(_provider: Provider, signal?: AbortSignal): Promise<CredentialDispatchTicket> {
505
+ const previous = this.#snapshotAuthorityTail;
506
+ const deferred = Promise.withResolvers<void>();
507
+ const ticketTail = previous.then(
508
+ () => deferred.promise,
509
+ () => deferred.promise,
510
+ );
511
+ this.#snapshotAuthorityTail = ticketTail;
512
+ let released = false;
513
+ const release = (): void => {
514
+ if (released) return;
515
+ released = true;
516
+ deferred.resolve();
517
+ };
518
+ try {
519
+ await this.#raceWithSignal(previous, signal);
520
+ } catch (error) {
521
+ release();
522
+ throw error;
523
+ }
524
+ return { release };
525
+ }
526
+
527
+ #setInventoryState(
528
+ capability: CredentialInventoryMetadataCapability,
529
+ generation: number,
530
+ notice?: CachedInventoryNotice,
531
+ ): void {
532
+ const records = this.#buildInventoryRecords();
533
+ this.#inventoryState = {
534
+ capability,
535
+ generation,
536
+ records,
537
+ ...(notice ? { notice } : {}),
538
+ };
539
+ }
540
+
541
+ #buildInventoryRecords(): CredentialInventoryRecord[] {
542
+ const metadataReady = this.#inventoryMetadataGeneration === this.#generation;
543
+ const records: CredentialInventoryRecord[] = [];
544
+ const seen = new Set<number>();
545
+ for (const entry of this.#snapshot.credentials) {
546
+ const metadata = metadataReady ? this.#inventoryMetadata.get(entry.id) : undefined;
547
+ seen.add(entry.id);
548
+ records.push({
549
+ id: entry.id,
550
+ provider: entry.provider,
551
+ credentialKind: metadata?.type ?? entry.credential.type,
552
+ identityLabel: metadata?.identity ?? snapshotIdentityLabel(entry),
553
+ disabled: false,
554
+ disabledCause: null,
555
+ });
556
+ }
557
+ if (metadataReady) {
558
+ for (const metadata of this.#inventoryMetadata.values()) {
559
+ if (seen.has(metadata.id) || metadata.disabledCause === null) continue;
560
+ records.push({
561
+ id: metadata.id,
562
+ provider: metadata.provider,
563
+ credentialKind: metadata.type,
564
+ identityLabel: metadata.identity,
565
+ disabled: true,
566
+ disabledCause: metadata.disabledCause,
567
+ });
568
+ }
569
+ }
570
+ return records;
571
+ }
572
+
573
+ #scheduleInventoryMetadataSync(): void {
574
+ if (this.#closed || this.#inventoryMetadataUnsupported) return;
575
+ if (this.#inventorySyncInflight) return;
576
+ void this.syncInventoryMetadata().catch(() => {});
577
+ }
578
+
579
+ async syncInventoryMetadata(): Promise<Readonly<CredentialInventoryMetadataState>> {
580
+ if (this.#inventoryMetadataUnsupported) return this.getInventoryMetadataState();
581
+ if (this.#inventoryState.capability === "supported" && this.#inventoryMetadataGeneration === this.#generation) {
582
+ return this.getInventoryMetadataState();
583
+ }
584
+ if (this.#inventorySyncInflight) return this.#inventorySyncInflight;
585
+ const inflight = this.#syncInventoryMetadata().finally(() => {
586
+ this.#inventorySyncInflight = undefined;
587
+ });
588
+ this.#inventorySyncInflight = inflight;
589
+ return inflight;
590
+ }
591
+
592
+ async #syncInventoryMetadata(): Promise<Readonly<CredentialInventoryMetadataState>> {
593
+ for (let attempt = 0; attempt < 2; attempt += 1) {
594
+ try {
595
+ const metadata = await this.#client.fetchCredentialMetadata();
596
+ if (metadata.epoch !== this.#epoch) {
597
+ if (attempt === 0) {
598
+ await this.refreshSnapshot().catch(() => {});
599
+ continue;
600
+ }
601
+ this.#inventoryMetadata.clear();
602
+ this.#inventoryMetadataGeneration = -1;
603
+ this.#setInventoryState("mismatch", this.#generation, {
604
+ status: "mismatch",
605
+ reason: "credential metadata epoch mismatch",
606
+ generation: this.#generation,
607
+ });
608
+ return this.getInventoryMetadataState();
609
+ }
610
+ if (metadata.generation !== this.#generation) {
611
+ if (attempt === 0) {
612
+ await this.refreshSnapshot().catch(() => {});
613
+ continue;
614
+ }
615
+ this.#inventoryMetadata.clear();
616
+ this.#inventoryMetadataGeneration = -1;
617
+ this.#setInventoryState("mismatch", this.#generation, {
618
+ status: "mismatch",
619
+ reason: "credential metadata generation mismatch",
620
+ generation: this.#generation,
621
+ });
622
+ return this.getInventoryMetadataState();
623
+ }
624
+ this.#inventoryMetadata = new Map(metadata.credentials.map(record => [record.id, { ...record }]));
625
+ this.#inventoryMetadataGeneration = metadata.generation;
626
+ this.#setInventoryState("supported", metadata.generation);
627
+ this.#reconcileUsagePresentations();
628
+ return this.getInventoryMetadataState();
629
+ } catch (error) {
630
+ if (error instanceof AuthBrokerCredentialMetadataUnsupportedError || isErrorStatus(error, 404)) {
631
+ this.#inventoryMetadataUnsupported = true;
632
+ this.#inventoryMetadata.clear();
633
+ this.#inventoryMetadataGeneration = -1;
634
+ this.#setInventoryState("unsupported", this.#generation, {
635
+ status: "unsupported",
636
+ reason: "credential metadata endpoint unsupported; disabled rows unavailable",
637
+ generation: this.#generation,
638
+ });
639
+ return this.getInventoryMetadataState();
640
+ }
641
+ this.#setInventoryState("failed", this.#generation, {
642
+ status: "failed",
643
+ reason: "credential metadata sync failed; retry explicitly",
644
+ generation: this.#generation,
645
+ });
646
+ return this.getInventoryMetadataState();
647
+ }
648
+ }
649
+ this.#setInventoryState("mismatch", this.#generation, {
650
+ status: "mismatch",
651
+ reason: "credential metadata generation mismatch",
652
+ generation: this.#generation,
653
+ });
654
+ return this.getInventoryMetadataState();
655
+ }
656
+
657
+ async #runBackground(): Promise<void> {
658
+ let backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
659
+ while (!this.#closed && !this.#backgroundAbort.signal.aborted) {
660
+ if (this.#streamSnapshots && !this.#streamingUnsupported) {
661
+ try {
662
+ await this.#consumeSnapshotStream();
663
+ backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
664
+ continue;
665
+ } catch (error) {
666
+ if (this.#closed || this.#backgroundAbort.signal.aborted) break;
667
+ if (error instanceof AuthBrokerStreamUnsupportedError) {
668
+ this.#streamingUnsupported = true;
669
+ logger.debug("auth-broker snapshot stream unsupported; falling back to long-poll");
670
+ continue;
671
+ }
672
+ logger.debug("auth-broker snapshot stream failed; backing off", { error: String(error) });
673
+ await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
674
+ backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
675
+ continue;
676
+ }
677
+ }
678
+ try {
679
+ const result = await this.#client.fetchSnapshot({
680
+ ifGenerationGt: this.#generation,
681
+ ifEpoch: this.#epoch,
682
+ waitMs: BACKGROUND_WAIT_MS,
683
+ signal: this.#backgroundAbort.signal,
684
+ });
685
+ if (result.status === 200) {
686
+ await this.#withSnapshotAuthority(async () => {
687
+ if (!this.#applySnapshot(result.snapshot, result.generation)) {
688
+ if (!this.#isSupersededSnapshot(result.snapshot, result.generation)) {
689
+ throw new Error("Auth broker background snapshot authority was rejected");
690
+ }
691
+ }
692
+ });
693
+ }
694
+ backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
695
+ } catch (error) {
696
+ if (this.#closed || this.#backgroundAbort.signal.aborted) break;
697
+ logger.debug("auth-broker background snapshot sync failed", { error: String(error) });
698
+ await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
699
+ backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
700
+ }
701
+ }
702
+ }
703
+
704
+ async #consumeSnapshotStream(): Promise<void> {
705
+ const iterator = this.#client.openSnapshotStream({ signal: this.#backgroundAbort.signal });
706
+ try {
707
+ for await (const event of iterator) {
708
+ if (this.#closed || this.#backgroundAbort.signal.aborted) break;
709
+ this.#streamingActive = true;
710
+ await this.#applyStreamEvent(event);
711
+ }
712
+ } finally {
713
+ this.#streamingActive = false;
714
+ }
715
+ }
716
+
717
+ async #applyStreamEvent(event: SnapshotStreamEvent): Promise<void> {
718
+ await this.#withSnapshotAuthority(async () => {
719
+ switch (event.kind) {
720
+ case "snapshot": {
721
+ // Strip the discriminator so we store the wire-shape SnapshotResponse.
722
+ const { kind: _kind, ...snapshot } = event;
723
+ if (!this.#applySnapshot(snapshot, snapshot.generation)) {
724
+ if (!this.#isSupersededSnapshot(snapshot, snapshot.generation)) {
725
+ throw new Error("Auth broker stream snapshot authority was rejected");
726
+ }
727
+ }
728
+ return;
729
+ }
730
+ case "entry": {
731
+ const applied = this.#applyStreamEntry(
732
+ event.entry,
733
+ event.refresher,
734
+ event.generation,
735
+ event.serverNowMs,
736
+ event.epoch,
737
+ );
738
+ if (!applied && !this.#isSupersededStreamEvent(event)) {
739
+ throw new Error("Auth broker stream entry authority was rejected");
740
+ }
741
+ return;
742
+ }
743
+ case "removed": {
744
+ const applied = this.#removeStreamCredential(
745
+ event.id,
746
+ event.refresher,
747
+ event.generation,
748
+ event.serverNowMs,
749
+ event.epoch,
750
+ );
751
+ if (!applied && !this.#isSupersededStreamEvent(event)) {
752
+ throw new Error("Auth broker stream removal authority was rejected");
753
+ }
754
+ return;
755
+ }
756
+ }
757
+ });
758
+ }
759
+
760
+ #isSupersededStreamEvent(event: SnapshotStreamEvent): boolean {
761
+ return this.#isSupersededSnapshot(
762
+ { ...this.#snapshot, epoch: event.epoch, generation: event.generation, serverNowMs: event.serverNowMs },
763
+ event.generation,
764
+ );
765
+ }
766
+
767
+ #applyStreamEntry(
768
+ entry: SnapshotEntry,
769
+ refresher: RefresherSchedule,
770
+ generation: number,
771
+ serverNowMs: number,
772
+ epoch?: string,
773
+ ): boolean {
774
+ if (!epoch && this.#epoch && epochRank(this.#epoch) !== undefined) return false;
775
+ const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
776
+ const credentials =
777
+ index === -1
778
+ ? [...this.#snapshot.credentials, entry]
779
+ : this.#snapshot.credentials.map((candidate, i) => (i === index ? entry : candidate));
780
+ return this.#applySnapshot(
781
+ { ...this.#snapshot, epoch, generation, serverNowMs, refresher, credentials },
782
+ generation,
783
+ );
784
+ }
785
+
786
+ #removeStreamCredential(
787
+ id: number,
788
+ refresher: RefresherSchedule,
789
+ generation: number,
790
+ serverNowMs: number,
791
+ epoch?: string,
792
+ ): boolean {
793
+ if (!epoch && this.#epoch && epochRank(this.#epoch) !== undefined) return false;
794
+ const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
795
+ return this.#applySnapshot(
796
+ { ...this.#snapshot, epoch, generation, serverNowMs, refresher, credentials },
797
+ generation,
798
+ );
799
+ }
800
+
801
+ /**
802
+ * Payload-free inventory view. This method never performs network I/O; metadata
803
+ * rows appear only after an explicit or background metadata synchronization for
804
+ * the current snapshot generation.
805
+ */
806
+ listCredentialInventory(provider?: string): CredentialInventoryRecord[] {
807
+ return this.#buildInventoryRecords()
808
+ .filter(record => provider === undefined || record.provider === provider)
809
+ .map(record => ({ ...record }));
810
+ }
811
+
812
+ /** Re-hydrate the in-memory snapshot from the broker. */
813
+ async refreshSnapshot(signal?: AbortSignal): Promise<SnapshotResponse> {
814
+ const result = await this.#client.fetchSnapshot({ signal });
815
+ if (result.status === 200) {
816
+ await this.#raceWithSignal(
817
+ this.#withSnapshotAuthority(async () => {
818
+ if (!this.#applySnapshot(result.snapshot, result.generation)) {
819
+ if (!this.#isSupersededSnapshot(result.snapshot, result.generation)) {
820
+ throw new Error("Auth broker snapshot authority was rejected");
821
+ }
822
+ }
823
+ }),
824
+ signal,
825
+ );
826
+ }
827
+ return this.#snapshot;
828
+ }
829
+
830
+ listAuthCredentials(provider?: string): StoredAuthCredential[] {
831
+ const out: StoredAuthCredential[] = [];
832
+ for (const entry of this.#snapshot.credentials) {
833
+ if (provider !== undefined && entry.provider !== provider) continue;
834
+ out.push({
835
+ id: entry.id,
836
+ provider: entry.provider,
837
+ credential: entry.credential as AuthCredential,
838
+ disabledCause: null,
839
+ ...(entry.revision === undefined ? {} : { revision: entry.revision }),
840
+ });
841
+ }
842
+ return out;
843
+ }
844
+
845
+ /**
846
+ * In-memory update from a successful refresh through the broker. AuthStorage
847
+ * calls this after `#replaceCredentialAt`; the broker already persisted the
848
+ * authoritative row, so we just mirror it.
849
+ */
850
+ updateAuthCredential(id: number, credential: AuthCredential): void {
851
+ void id;
852
+ void credential;
853
+ throw new Error("Remote auth-broker credentials must be updated through broker authority");
854
+ }
855
+
856
+ deleteAuthCredential(_id: number, _disabledCause: string): void {
857
+ throw new Error("Remote auth-broker credentials can only be disabled on the broker host");
858
+ }
859
+
860
+ allocateMonotonicSequence(_key: string, _expiresAtSec: number): number {
861
+ throw new Error("Remote auth-broker credentials cannot allocate broker incarnation sequences");
862
+ }
863
+
864
+ tryDisableAuthCredentialIfMatches(_id: number, _expectedData: string, _disabledCause: string): boolean {
865
+ return false;
866
+ }
867
+
868
+ /** Disable one credential through the authenticated broker mutation endpoint. */
869
+ async disableAuthCredentialRemote(
870
+ credentialId: number,
871
+ disabledCause: string,
872
+ signal?: AbortSignal,
873
+ expectedRevision?: number,
874
+ ): Promise<boolean> {
875
+ try {
876
+ await this.#client.disableCredential(credentialId, disabledCause, signal, expectedRevision);
877
+ } catch (error) {
878
+ if (!isErrorStatus(error, 404)) throw error;
879
+ // A peer may have disabled the row first. Reconcile the local mirror so
880
+ // AuthStorage can select a fallback without ever mutating remote state
881
+ // through the read-only synchronous methods.
882
+ await this.refreshSnapshot().catch(refreshError => {
883
+ logger.debug("auth-broker snapshot refresh after remote disable miss failed", {
884
+ error: String(refreshError),
885
+ });
886
+ });
887
+ return false;
888
+ }
889
+ this.#removeCredentialEntry(credentialId);
890
+ this.#maybeRefreshSnapshot("disable");
891
+ return true;
892
+ }
893
+
894
+ async waitForFreshSnapshot(maxWaitMs: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
895
+ const previousGeneration = this.#generation;
896
+ const previousEpoch = this.#epoch;
897
+ const result = await this.#client.fetchSnapshot({
898
+ ifGenerationGt: this.#generation,
899
+ ifEpoch: this.#epoch,
900
+ waitMs: maxWaitMs,
901
+ signal: opts.signal,
902
+ });
903
+ if (result.status === 200) {
904
+ await this.#withSnapshotAuthority(async () => {
905
+ if (!this.#applySnapshot(result.snapshot, result.generation)) {
906
+ if (!this.#isSupersededSnapshot(result.snapshot, result.generation)) {
907
+ throw new Error("Auth broker freshness snapshot authority was rejected");
908
+ }
909
+ }
910
+ });
911
+ }
912
+ return this.#generation !== previousGeneration || this.#epoch !== previousEpoch;
913
+ }
914
+
915
+ async prepareForRequest(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
916
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
917
+ if (entry?.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
918
+ const remainingMs = this.#snapshotReceivedAt + entry.rotatesInMs - Date.now();
919
+ if (remainingMs > WAIT_THRESHOLD_MS) return false;
920
+ return this.waitForFreshSnapshot(MAX_WAIT_MS, opts);
921
+ }
922
+
923
+ async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
924
+ const current = this.#snapshot.credentials.find(entry => entry.id === credentialId);
925
+ if (current?.credential.type !== "oauth") {
926
+ // API-key rows have no refresh token, and a stale id may already have
927
+ // disappeared. A 401 means the broker may have rotated or removed the
928
+ // row; refresh the authoritative snapshot so the caller can reselect a
929
+ // live key instead of hitting the OAuth endpoint.
930
+ await this.refreshSnapshot(opts.signal);
931
+ return;
932
+ }
933
+ await this.#client.refreshCredential(credentialId, opts.signal);
934
+ await this.refreshSnapshot(opts.signal);
935
+ }
936
+
937
+ replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] {
938
+ throw new Error(
939
+ "RemoteAuthCredentialStore is read-only on the client. Use `vib auth-broker login <provider>` to mutate credentials.",
940
+ );
941
+ }
942
+
943
+ upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[] {
944
+ throw new Error(
945
+ "RemoteAuthCredentialStore is read-only on the client. Use `vib auth-broker login <provider>` to mutate credentials.",
946
+ );
947
+ }
948
+
949
+ upsertAuthCredentialForProviderIfAbsent(
950
+ _provider: string,
951
+ _credential: AuthCredential,
952
+ ): AuthCredentialIfAbsentResult {
953
+ throw new Error(
954
+ "RemoteAuthCredentialStore is read-only on the client. Use `vib auth-broker login <provider>` to mutate credentials.",
955
+ );
956
+ }
957
+
958
+ deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void {
959
+ throw new Error(
960
+ "RemoteAuthCredentialStore is read-only on the client. Use `vib auth-broker logout <provider>` to mutate credentials.",
961
+ );
962
+ }
963
+
964
+ /** Logout authority remains on the broker; the client only mirrors its result. */
965
+ async deleteAuthCredentialsRemote(provider: string, disabledCause: string): Promise<void> {
966
+ const existing = this.listAuthCredentials(provider);
967
+ for (const entry of existing) await this.#client.disableCredential(entry.id, disabledCause);
968
+ await this.refreshSnapshot();
969
+ }
970
+
971
+ /**
972
+ * Upsert a single credential through the broker. The broker server is the
973
+ * canonical writer — see `POST /v1/credential`. The redacted snapshot
974
+ * entries returned by the server replace the provider's rows in our local
975
+ * snapshot, and the global snapshot is then refreshed in the background so
976
+ * any concurrent peer (refresh, generation bump) stays in sync.
977
+ */
978
+ async upsertAuthCredentialRemote(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]> {
979
+ await this.#client.uploadCredential(provider, credential);
980
+ await this.refreshSnapshot();
981
+ return this.listAuthCredentials(provider);
982
+ }
983
+
984
+ async upsertAuthCredentialRemoteIfAbsent(
985
+ provider: string,
986
+ credential: AuthCredential,
987
+ ): Promise<AuthCredentialIfAbsentResult> {
988
+ const { inserted, reason } = await this.#client.uploadCredentialIfAbsent(provider, credential);
989
+ await this.refreshSnapshot();
990
+ return { inserted, reason, provider, entries: this.listAuthCredentials(provider) };
991
+ }
992
+
993
+ /**
994
+ * Replace-all semantics: disable every active credential for the provider,
995
+ * then upload each of the new credentials. Used by API-key login so a new
996
+ * key clobbers any previously stored key for the same provider.
997
+ */
998
+ async replaceAuthCredentialsRemote(
999
+ provider: string,
1000
+ credentials: AuthCredential[],
1001
+ ): Promise<StoredAuthCredential[]> {
1002
+ const existing = this.listAuthCredentials(provider);
1003
+ for (const entry of existing) {
1004
+ await this.#client.disableCredential(entry.id, "replaced by newer credential");
1005
+ }
1006
+ await this.refreshSnapshot();
1007
+ for (const credential of credentials) {
1008
+ await this.#client.uploadCredential(provider, credential);
1009
+ await this.refreshSnapshot();
1010
+ }
1011
+ this.#maybeRefreshSnapshot("replace");
1012
+ return this.listAuthCredentials(provider);
1013
+ }
1014
+
1015
+ #removeCredentialEntry(credentialId: number): void {
1016
+ const credentials = this.#snapshot.credentials.filter(entry => entry.id !== credentialId);
1017
+ if (credentials.length === this.#snapshot.credentials.length) return;
1018
+ this.#applySnapshot({ ...this.#snapshot, credentials }, this.#generation, false);
1019
+ }
1020
+ /**
1021
+ * Fire-and-forget `refreshSnapshot()` after a write. When the SSE stream is
1022
+ * active the broker will deliver the new generation push, so the extra GET
1023
+ * is wasted bandwidth and we skip it.
1024
+ */
1025
+ #maybeRefreshSnapshot(reason: string): void {
1026
+ if (this.#streamingActive) return;
1027
+ void this.refreshSnapshot().catch(error => {
1028
+ logger.debug("auth-broker snapshot refresh after write failed", { reason, error: String(error) });
1029
+ });
1030
+ }
1031
+
1032
+ getCache(key: string): string | null {
1033
+ const entry = this.#cache.get(key);
1034
+ if (!entry) return null;
1035
+ if (entry.expiresAtSec * 1000 <= Date.now()) {
1036
+ this.#cache.delete(key);
1037
+ return null;
1038
+ }
1039
+ return entry.value;
1040
+ }
1041
+
1042
+ setCache(key: string, value: string, expiresAtSec: number): void {
1043
+ let persistedValue = value;
1044
+ if (key.startsWith("account_health:v1:source:")) {
1045
+ try {
1046
+ const parsed = JSON.parse(value) as { reason?: unknown } & Record<string, unknown>;
1047
+ persistedValue = JSON.stringify({ ...parsed, reason: safePresentationReason(parsed.reason) });
1048
+ } catch {
1049
+ persistedValue = JSON.stringify({ status: "unknown", reason: null });
1050
+ }
1051
+ }
1052
+ const entry = { value: persistedValue, expiresAtSec };
1053
+ this.#cache.set(key, entry);
1054
+ if (key.startsWith("account_health:v1:source:")) {
1055
+ this.#persistedSourceHealth[this.#presentationAuthority] = {
1056
+ ...(this.#persistedSourceHealth[this.#presentationAuthority] ?? {}),
1057
+ [key]: entry,
1058
+ };
1059
+ this.#queuePresentationWrite();
1060
+ }
1061
+ }
1062
+
1063
+ cleanExpiredCache(): void {
1064
+ const nowSec = Math.floor(Date.now() / 1000);
1065
+ for (const [key, entry] of this.#cache) {
1066
+ if (entry.expiresAtSec <= nowSec) this.#cache.delete(key);
1067
+ }
1068
+ }
1069
+
1070
+ deleteCachePrefix(prefix: string): void {
1071
+ for (const key of this.#cache.keys()) {
1072
+ if (key.startsWith(prefix)) this.#cache.delete(key);
1073
+ }
1074
+ if (prefix.startsWith("usage_cache:")) this.#invalidateUsageCache();
1075
+ }
1076
+
1077
+ #invalidateUsageCache(): void {
1078
+ this.#usageCache = undefined;
1079
+ this.#usageInflight = undefined;
1080
+ this.#scopedUsageCache.clear();
1081
+ this.#scopedUsageInflight.clear();
1082
+ this.#scopedUsageFailures.clear();
1083
+ this.#usageCacheEpoch += 1;
1084
+ }
1085
+
1086
+ /**
1087
+ * Store-level hook consumed by `AuthStorage` — routes refresh through the
1088
+ * broker so the actual refresh token never leaves the broker host. Returns
1089
+ * the broker-redacted credential with {@link REMOTE_REFRESH_SENTINEL} in
1090
+ * the `refresh` slot.
1091
+ */
1092
+ async refreshOAuthCredential(
1093
+ _provider: Provider,
1094
+ credentialId: number,
1095
+ _credential: OAuthCredential,
1096
+ signal?: AbortSignal,
1097
+ ): Promise<OAuthCredentials> {
1098
+ try {
1099
+ await this.#client.refreshCredential(credentialId, signal);
1100
+ } catch (error) {
1101
+ if (isErrorStatus(error, 404) && !this.#streamingActive) {
1102
+ await this.refreshSnapshot().catch(refreshError => {
1103
+ logger.debug("auth-broker snapshot refresh after missing credential refresh failed", {
1104
+ error: String(refreshError),
1105
+ });
1106
+ });
1107
+ }
1108
+ throw error;
1109
+ }
1110
+ await this.refreshSnapshot(signal);
1111
+ const accepted = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1112
+ if (accepted?.credential.type !== "oauth") {
1113
+ throw new Error(`Broker snapshot no longer contains OAuth credential id=${credentialId}`);
1114
+ }
1115
+ const refreshed = accepted.credential;
1116
+ return {
1117
+ access: refreshed.access,
1118
+ refresh: REMOTE_REFRESH_SENTINEL,
1119
+ expires: refreshed.expires,
1120
+ accountId: refreshed.accountId,
1121
+ email: refreshed.email,
1122
+ projectId: refreshed.projectId,
1123
+ enterpriseUrl: refreshed.enterpriseUrl,
1124
+ };
1125
+ }
1126
+
1127
+ async refreshMCPOAuthCredential(
1128
+ credentialId: number,
1129
+ credential: OAuthCredential,
1130
+ client: MCPOAuthRefreshClient,
1131
+ signal?: AbortSignal,
1132
+ ): Promise<OAuthCredential> {
1133
+ await this.#client.refreshMCPCredential(credentialId, client, signal);
1134
+ await this.refreshSnapshot(signal);
1135
+ const accepted = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1136
+ if (accepted?.credential.type !== "oauth") {
1137
+ throw new Error(`Broker snapshot no longer contains OAuth credential id=${credentialId}`);
1138
+ }
1139
+ assertCanonicalMCPOAuthBinding(credential.mcpBinding);
1140
+ assertCanonicalMCPOAuthBinding(accepted.credential.mcpBinding);
1141
+ if (
1142
+ accepted.credential.mcpBinding.resourceOrigin !== credential.mcpBinding.resourceOrigin ||
1143
+ accepted.credential.mcpBinding.tokenEndpoint !== credential.mcpBinding.tokenEndpoint
1144
+ ) {
1145
+ throw new Error("Broker returned mismatched MCP OAuth credential binding");
1146
+ }
1147
+ return accepted.credential;
1148
+ }
1149
+
1150
+ /**
1151
+ * Store-level hook consumed by `AuthStorage.fetchUsageReports()` — proxies
1152
+ * to the broker's `/v1/usage` endpoint. The broker's egress IP isn't
1153
+ * rate-limited by Anthropic's per-IP `/usage` cap the way a heavy
1154
+ * residential laptop is, so all credentials surface every cycle.
1155
+ */
1156
+ async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
1157
+ return this.#raceWithSignal(this.#loadUsageReports(), signal);
1158
+ }
1159
+
1160
+ async fetchUsageReportsForProvider(provider: Provider, signal?: AbortSignal): Promise<UsageReport[] | null> {
1161
+ return this.#raceWithSignal(this.#loadUsageReports(provider), signal);
1162
+ }
1163
+
1164
+ /** Synchronous, zero-network usage presentation read. */
1165
+ peekCachedUsagePresentation(provider: Provider, credentialId: number): CachedUsagePresentation | undefined {
1166
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1167
+ if (!entry || entry.provider !== provider) return undefined;
1168
+ const cached = this.#usagePresentations.get(credentialId);
1169
+ if (!cached) return undefined;
1170
+ const now = Date.now();
1171
+ if (cached.retainUntil <= now) {
1172
+ this.#usagePresentations.delete(credentialId);
1173
+ return undefined;
1174
+ }
1175
+ const identityDigest = this.#identityDigestForEntry(entry);
1176
+ if (
1177
+ cached.provider !== provider ||
1178
+ cached.inventoryGeneration !== this.#generation ||
1179
+ cached.identityDigest !== identityDigest
1180
+ ) {
1181
+ this.#usagePresentations.delete(credentialId);
1182
+ return undefined;
1183
+ }
1184
+ return cloneUsagePresentation(cached);
1185
+ }
1186
+
1187
+ /** Synchronous, zero-network health presentation read backed by the redacted sidecar. */
1188
+ peekCachedCredentialHealth(provider: Provider, credentialId: number): CachedCredentialHealth | undefined {
1189
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1190
+ if (!entry || entry.provider !== provider) return undefined;
1191
+ const key = presentationRecordKey(provider, this.#identityDigestForEntry(entry));
1192
+ const health = this.#persistedPresentations.get(key)?.health;
1193
+ if (!health || health.retainUntil <= Date.now()) {
1194
+ if (health) {
1195
+ const record = this.#persistedPresentations.get(key);
1196
+ if (record) {
1197
+ delete record.health;
1198
+ if (!record.usage) this.#persistedPresentations.delete(key);
1199
+ this.#queuePresentationWrite();
1200
+ }
1201
+ }
1202
+ return undefined;
1203
+ }
1204
+ return {
1205
+ status: health.status,
1206
+ reason: health.reason,
1207
+ ...(health.checkedAt === undefined ? {} : { checkedAt: health.checkedAt }),
1208
+ retainUntil: health.retainUntil,
1209
+ };
1210
+ }
1211
+
1212
+ /** Persist a safe health result without credential or bearer-token material. */
1213
+ recordCredentialHealth(provider: Provider, credentialId: number, health: CachedCredentialHealth): void {
1214
+ if (health.status === "unknown" || !health.retainUntil || health.retainUntil <= Date.now()) return;
1215
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1216
+ if (!entry || entry.provider !== provider) return;
1217
+ const identityDigest = this.#identityDigestForEntry(entry);
1218
+ const key = presentationRecordKey(provider, identityDigest);
1219
+ const record = this.#persistedPresentations.get(key) ?? { credentialId, provider, identityDigest };
1220
+ record.health = {
1221
+ v: 1,
1222
+ status: health.status,
1223
+ reason: safePresentationReason(health.reason),
1224
+ ...(health.checkedAt === undefined ? {} : { checkedAt: health.checkedAt }),
1225
+ retainUntil: health.retainUntil,
1226
+ };
1227
+ this.#persistedPresentations.set(key, record);
1228
+ this.#queuePresentationWrite();
1229
+ }
1230
+
1231
+ /** Record a safe usage observation after an explicit broker usage/check call. */
1232
+ recordUsagePresentation(observation: CachedUsagePresentation): void {
1233
+ if (!Number.isInteger(observation.credentialId)) return;
1234
+ if (!Number.isFinite(observation.inventoryGeneration) || observation.inventoryGeneration !== this.#generation)
1235
+ return;
1236
+ if (!Number.isFinite(observation.fetchedAt) || !Number.isFinite(observation.freshUntil)) return;
1237
+ if (!Number.isFinite(observation.retainUntil) || observation.retainUntil <= observation.fetchedAt) return;
1238
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === observation.credentialId);
1239
+ if (!entry || entry.provider !== observation.provider) return;
1240
+ if (this.#identityDigestForEntry(entry) !== observation.identityDigest) return;
1241
+ const usage = safePresentationUsageReport(observation.usage);
1242
+ const stored = cloneUsagePresentation({
1243
+ credentialId: observation.credentialId,
1244
+ provider: observation.provider,
1245
+ inventoryGeneration: observation.inventoryGeneration,
1246
+ identityDigest: observation.identityDigest,
1247
+ usage,
1248
+ fetchedAt: observation.fetchedAt,
1249
+ freshUntil: Math.min(observation.freshUntil, observation.fetchedAt + PRESENTATION_FRESH_MS),
1250
+ retainUntil: Math.min(observation.retainUntil, observation.fetchedAt + PRESENTATION_RETENTION_MS),
1251
+ });
1252
+ this.#usagePresentations.set(observation.credentialId, stored);
1253
+ const key = presentationRecordKey(observation.provider, observation.identityDigest);
1254
+ const persisted = this.#persistedPresentations.get(key) ?? {
1255
+ credentialId: observation.credentialId,
1256
+ provider: observation.provider,
1257
+ identityDigest: observation.identityDigest,
1258
+ };
1259
+ persisted.usage = stored;
1260
+ this.#persistedPresentations.set(key, persisted);
1261
+ this.#queuePresentationWrite();
1262
+ }
1263
+
1264
+ /** Persist an explicit usage/check report for the current credential identity. */
1265
+ recordCredentialUsage(provider: Provider, credentialId: number, report: SafeUsageReport): void {
1266
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1267
+ if (!entry || entry.provider !== provider) return;
1268
+ const fetchedAt = Number.isFinite(report.fetchedAt) ? report.fetchedAt : Date.now();
1269
+ this.recordUsagePresentation({
1270
+ credentialId,
1271
+ provider,
1272
+ inventoryGeneration: this.#generation,
1273
+ identityDigest: this.#identityDigestForEntry(entry),
1274
+ usage: safePresentationUsageReport(report),
1275
+ fetchedAt,
1276
+ freshUntil: fetchedAt + PRESENTATION_FRESH_MS,
1277
+ retainUntil: fetchedAt + PRESENTATION_RETENTION_MS,
1278
+ });
1279
+ }
1280
+
1281
+ /**
1282
+ * Per-credential usage hook consumed by `AuthStorage.#getUsageReport`. Pulls
1283
+ * the aggregate broker `/v1/usage` once and serves all callers from the
1284
+ * same response (coalesced + cached), then matches the credential to a
1285
+ * report by provider + identity (accountId / email / projectId).
1286
+ *
1287
+ * The broker already aggregates with its own 30s TTL on the server side; our
1288
+ * 15s client TTL is below that so we usually re-use the broker's cache too.
1289
+ */
1290
+ async getUsageReport(
1291
+ provider: Provider,
1292
+ credential: OAuthCredential,
1293
+ signal?: AbortSignal,
1294
+ ): Promise<UsageReport | null> {
1295
+ let reports: UsageReport[] | null;
1296
+ try {
1297
+ reports = await this.#raceWithSignal(this.#loadUsageReports(provider), signal);
1298
+ } catch (error) {
1299
+ // A caller cancellation is control flow, not a missing usage report.
1300
+ // Preserve it so selection/dispatch can stop promptly; only ordinary
1301
+ // broker/provider failures degrade to "no usage".
1302
+ if (signal?.aborted) throw error;
1303
+ return null;
1304
+ }
1305
+ if (!reports) return null;
1306
+ return matchUsageReport(reports, provider, credential);
1307
+ }
1308
+
1309
+ /**
1310
+ * Reject the awaited promise when the caller's signal aborts, without
1311
+ * affecting the shared upstream fetch. Used to give each caller their
1312
+ * own cancel without one caller's abort cascading into a peer's in-flight
1313
+ * request through the single-flight `#usageInflight`.
1314
+ */
1315
+ #raceWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
1316
+ if (!signal) return promise;
1317
+ if (signal.aborted) return Promise.reject(new Error("auth-broker request aborted"));
1318
+ return new Promise<T>((resolve, reject) => {
1319
+ const onAbort = (): void => {
1320
+ signal.removeEventListener("abort", onAbort);
1321
+ reject(new Error("auth-broker request aborted"));
1322
+ };
1323
+ signal.addEventListener("abort", onAbort, { once: true });
1324
+ promise.then(
1325
+ value => {
1326
+ signal.removeEventListener("abort", onAbort);
1327
+ resolve(value);
1328
+ },
1329
+ err => {
1330
+ signal.removeEventListener("abort", onAbort);
1331
+ reject(err);
1332
+ },
1333
+ );
1334
+ });
1335
+ }
1336
+
1337
+ #identityDigestForEntry(entry: SnapshotEntry): string {
1338
+ const credential = entry.credential;
1339
+ const identity =
1340
+ credential.type === "oauth"
1341
+ ? {
1342
+ accountId: credential.accountId ?? null,
1343
+ email: credential.email?.trim().toLowerCase() ?? null,
1344
+ projectId: credential.projectId ?? null,
1345
+ enterpriseUrl: credential.enterpriseUrl ?? null,
1346
+ mcpBinding: credential.mcpBinding ?? null,
1347
+ }
1348
+ : { type: credential.type };
1349
+ return createHash("sha256")
1350
+ .update(JSON.stringify({ id: entry.id, provider: entry.provider, type: credential.type, identity }))
1351
+ .digest("hex");
1352
+ }
1353
+
1354
+ #reconcileUsagePresentations(): void {
1355
+ for (const [credentialId, cached] of this.#usagePresentations) {
1356
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1357
+ if (
1358
+ !entry ||
1359
+ entry.provider !== cached.provider ||
1360
+ this.#identityDigestForEntry(entry) !== cached.identityDigest
1361
+ ) {
1362
+ this.#usagePresentations.delete(credentialId);
1363
+ continue;
1364
+ }
1365
+ this.#usagePresentations.set(credentialId, { ...cached, inventoryGeneration: this.#generation });
1366
+ }
1367
+ }
1368
+
1369
+ #recordUsageReports(reports: UsageReport[], epoch: number): void {
1370
+ if (this.#usageCacheEpoch !== epoch) return;
1371
+ for (const entry of this.#snapshot.credentials) {
1372
+ if (entry.credential.type !== "oauth") continue;
1373
+ const report = matchUsageReport(reports, entry.provider, entry.credential);
1374
+ if (!report) continue;
1375
+ const fetchedAt = Number.isFinite(report.fetchedAt) ? report.fetchedAt : Date.now();
1376
+ this.recordUsagePresentation({
1377
+ credentialId: entry.id,
1378
+ provider: entry.provider,
1379
+ inventoryGeneration: this.#generation,
1380
+ identityDigest: this.#identityDigestForEntry(entry),
1381
+ usage: safePresentationUsageReport(report),
1382
+ fetchedAt,
1383
+ freshUntil: fetchedAt + PRESENTATION_FRESH_MS,
1384
+ retainUntil: fetchedAt + PRESENTATION_RETENTION_MS,
1385
+ });
1386
+ }
1387
+ }
1388
+
1389
+ #loadUsageReports(provider?: Provider): Promise<UsageReport[] | null> {
1390
+ if (provider) {
1391
+ const cached = this.#scopedUsageCache.get(provider);
1392
+ if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) return Promise.resolve(cached.reports);
1393
+ const failedAt = this.#scopedUsageFailures.get(provider);
1394
+ if (failedAt !== undefined && Date.now() - failedAt < USAGE_CACHE_TTL_MS) return Promise.resolve(null);
1395
+ const existing = this.#scopedUsageInflight.get(provider);
1396
+ if (existing) return existing;
1397
+ const epoch = this.#usageCacheEpoch;
1398
+ const inflight = this.#client
1399
+ .fetchUsage(undefined, provider)
1400
+ .then(body => {
1401
+ if (this.#usageCacheEpoch === epoch) {
1402
+ this.#scopedUsageCache.set(provider, { reports: body.reports, fetchedAt: Date.now() });
1403
+ this.#scopedUsageFailures.delete(provider);
1404
+ this.#recordUsageReports(body.reports, epoch);
1405
+ }
1406
+ return body.reports;
1407
+ })
1408
+ .catch(error => {
1409
+ logger.warn("auth-broker scoped usage fetch failed", {
1410
+ provider,
1411
+ error: cleanReason(error) ?? "Usage unavailable.",
1412
+ });
1413
+ if (this.#usageCacheEpoch === epoch) this.#scopedUsageFailures.set(provider, Date.now());
1414
+ return null;
1415
+ })
1416
+ .finally(() => {
1417
+ if (this.#scopedUsageInflight.get(provider) === inflight) {
1418
+ this.#scopedUsageInflight.delete(provider);
1419
+ }
1420
+ });
1421
+ this.#scopedUsageInflight.set(provider, inflight);
1422
+ return inflight;
1423
+ }
1424
+ const cached = this.#usageCache;
1425
+ if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
1426
+ return Promise.resolve(cached.reports);
1427
+ }
1428
+ if (this.#usageInflight) return this.#usageInflight;
1429
+ const epoch = this.#usageCacheEpoch;
1430
+ const inflight = this.#client
1431
+ .fetchUsage()
1432
+ .then(body => {
1433
+ if (this.#usageCacheEpoch === epoch) {
1434
+ this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
1435
+ this.#recordUsageReports(body.reports, epoch);
1436
+ }
1437
+ return body.reports;
1438
+ })
1439
+ .catch(error => {
1440
+ logger.warn("auth-broker usage fetch failed", { error: String(error) });
1441
+ return null;
1442
+ })
1443
+ .finally(() => {
1444
+ if (this.#usageCacheEpoch === epoch) this.#usageInflight = undefined;
1445
+ });
1446
+ this.#usageInflight = inflight;
1447
+ return inflight;
1448
+ }
1449
+
1450
+ close(): void {
1451
+ if (this.#closed) return;
1452
+ this.#backgroundAbort.abort();
1453
+ void this.#presentationWriteChain.finally(() => {
1454
+ this.#persistedPresentations.clear();
1455
+ });
1456
+ this.#closed = true;
1457
+ this.#snapshotListeners.clear();
1458
+ this.#cache.clear();
1459
+ this.#usagePresentations.clear();
1460
+ this.#inventoryMetadata.clear();
1461
+ this.#inventorySyncInflight = undefined;
1462
+ }
1463
+ }
1464
+
1465
+ function isErrorStatus(error: unknown, status: number): boolean {
1466
+ return (
1467
+ typeof error === "object" &&
1468
+ error !== null &&
1469
+ "status" in error &&
1470
+ (error as { status?: unknown }).status === status
1471
+ );
1472
+ }
1473
+
1474
+ function snapshotIdentityLabel(entry: SnapshotEntry): string | null {
1475
+ if (entry.credential.type !== "oauth") return null;
1476
+ return entry.credential.email ?? entry.credential.accountId ?? entry.credential.projectId ?? null;
1477
+ }
1478
+
1479
+ function safePresentationUsageReport(report: UsageReport): SafeUsageReport {
1480
+ const sanitize = (value: string): string =>
1481
+ (cleanReason(value) ?? "Usage unavailable.")
1482
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
1483
+ .replace(/\s+/g, " ")
1484
+ .trim()
1485
+ .slice(0, 160);
1486
+ const { raw: _raw, metadata: _metadata, ...safe } = report;
1487
+ return {
1488
+ ...safe,
1489
+ limits: safe.limits.map(limit => {
1490
+ const { accountId: _accountId, projectId: _projectId, orgId: _orgId, ...scope } = limit.scope;
1491
+ return {
1492
+ ...limit,
1493
+ id: sanitize(limit.id),
1494
+ label: sanitize(limit.label),
1495
+ ...(limit.window
1496
+ ? { window: { ...limit.window, id: sanitize(limit.window.id), label: sanitize(limit.window.label) } }
1497
+ : {}),
1498
+ ...(limit.notes ? { notes: limit.notes.map(sanitize) } : {}),
1499
+ scope,
1500
+ };
1501
+ }),
1502
+ };
1503
+ }
1504
+
1505
+ function cloneUsagePresentation(observation: CachedUsagePresentation): CachedUsagePresentation {
1506
+ return {
1507
+ ...observation,
1508
+ usage: {
1509
+ ...observation.usage,
1510
+ limits: observation.usage.limits.map(limit => ({
1511
+ ...limit,
1512
+ scope: { ...limit.scope },
1513
+ window: limit.window ? { ...limit.window } : undefined,
1514
+ amount: { ...limit.amount },
1515
+ notes: limit.notes ? [...limit.notes] : undefined,
1516
+ })),
1517
+ metadata: observation.usage.metadata ? { ...observation.usage.metadata } : undefined,
1518
+ },
1519
+ };
1520
+ }
1521
+
1522
+ /**
1523
+ * Match a broker-supplied usage report to a specific OAuth credential. The
1524
+ * broker returns aggregate reports across all credentials it manages, so we
1525
+ * pick the one whose identity (accountId / email / projectId) lines up with
1526
+ * the credential the caller is asking about.
1527
+ *
1528
+ * Falls back to the lone candidate when only one matches the provider; falls
1529
+ * through to `null` when nothing matches, which `AuthStorage` treats as "no
1530
+ * usage data" (ranking proceeds without a usage signal for this credential).
1531
+ */
1532
+ function matchUsageReport(reports: UsageReport[], provider: Provider, credential: OAuthCredential): UsageReport | null {
1533
+ const candidates = reports.filter(report => report.provider === provider);
1534
+ if (candidates.length === 0) return null;
1535
+ if (candidates.length === 1) return candidates[0];
1536
+ const accountId = credential.accountId?.trim().toLowerCase();
1537
+ const email = credential.email?.trim().toLowerCase();
1538
+ const projectId = credential.projectId?.trim().toLowerCase();
1539
+ for (const report of candidates) {
1540
+ if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
1541
+ }
1542
+ return null;
1543
+ }
1544
+
1545
+ function reportMatchesIdentity(
1546
+ report: UsageReport,
1547
+ accountId: string | undefined,
1548
+ email: string | undefined,
1549
+ projectId: string | undefined,
1550
+ ): boolean {
1551
+ const metadata = (report.metadata ?? {}) as Record<string, unknown>;
1552
+ if (accountId) {
1553
+ const metaAccount = readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id");
1554
+ if (metaAccount && metaAccount.toLowerCase() === accountId) return true;
1555
+ for (const limit of report.limits) {
1556
+ if (limit.scope.accountId?.toLowerCase() === accountId) return true;
1557
+ }
1558
+ }
1559
+ if (email) {
1560
+ const metaEmail = readMetadataString(metadata, "email");
1561
+ if (metaEmail && metaEmail.toLowerCase() === email) return true;
1562
+ }
1563
+ if (projectId) {
1564
+ const metaProject = readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id");
1565
+ if (metaProject && metaProject.toLowerCase() === projectId) return true;
1566
+ for (const limit of report.limits) {
1567
+ if (limit.scope.projectId?.toLowerCase() === projectId) return true;
1568
+ }
1569
+ }
1570
+ return false;
1571
+ }
1572
+
1573
+ function readMetadataString(metadata: Record<string, unknown>, key: string): string | undefined {
1574
+ const value = metadata[key];
1575
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
1576
+ }