@antasphere/chassis-server 1.0.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 (798) hide show
  1. package/LICENSE +82 -0
  2. package/dist/accounts/deletion.d.ts +163 -0
  3. package/dist/accounts/deletion.d.ts.map +1 -0
  4. package/dist/accounts/deletion.js +360 -0
  5. package/dist/accounts/deletion.js.map +1 -0
  6. package/dist/accounts/erasure-log.d.ts +66 -0
  7. package/dist/accounts/erasure-log.d.ts.map +1 -0
  8. package/dist/accounts/erasure-log.js +117 -0
  9. package/dist/accounts/erasure-log.js.map +1 -0
  10. package/dist/accounts/index.d.ts +6 -0
  11. package/dist/accounts/index.d.ts.map +1 -0
  12. package/dist/accounts/index.js +6 -0
  13. package/dist/accounts/index.js.map +1 -0
  14. package/dist/accounts/mint-refusal.d.ts +47 -0
  15. package/dist/accounts/mint-refusal.d.ts.map +1 -0
  16. package/dist/accounts/mint-refusal.js +59 -0
  17. package/dist/accounts/mint-refusal.js.map +1 -0
  18. package/dist/accounts/signup-duplicate.d.ts +14 -0
  19. package/dist/accounts/signup-duplicate.d.ts.map +1 -0
  20. package/dist/accounts/signup-duplicate.js +25 -0
  21. package/dist/accounts/signup-duplicate.js.map +1 -0
  22. package/dist/accounts/superadmin.d.ts +27 -0
  23. package/dist/accounts/superadmin.d.ts.map +1 -0
  24. package/dist/accounts/superadmin.js +46 -0
  25. package/dist/accounts/superadmin.js.map +1 -0
  26. package/dist/api/apikeys.d.ts +14 -0
  27. package/dist/api/apikeys.d.ts.map +1 -0
  28. package/dist/api/apikeys.js +124 -0
  29. package/dist/api/apikeys.js.map +1 -0
  30. package/dist/api/audit.d.ts +4 -0
  31. package/dist/api/audit.d.ts.map +1 -0
  32. package/dist/api/audit.js +75 -0
  33. package/dist/api/audit.js.map +1 -0
  34. package/dist/api/break-glass.d.ts +48 -0
  35. package/dist/api/break-glass.d.ts.map +1 -0
  36. package/dist/api/break-glass.js +217 -0
  37. package/dist/api/break-glass.js.map +1 -0
  38. package/dist/api/cli-auth.d.ts +84 -0
  39. package/dist/api/cli-auth.d.ts.map +1 -0
  40. package/dist/api/cli-auth.js +177 -0
  41. package/dist/api/cli-auth.js.map +1 -0
  42. package/dist/api/create-api.d.ts +123 -0
  43. package/dist/api/create-api.d.ts.map +1 -0
  44. package/dist/api/create-api.js +1165 -0
  45. package/dist/api/create-api.js.map +1 -0
  46. package/dist/api/demo-passes.d.ts +45 -0
  47. package/dist/api/demo-passes.d.ts.map +1 -0
  48. package/dist/api/demo-passes.js +143 -0
  49. package/dist/api/demo-passes.js.map +1 -0
  50. package/dist/api/errors.d.ts +5 -0
  51. package/dist/api/errors.d.ts.map +1 -0
  52. package/dist/api/errors.js +5 -0
  53. package/dist/api/errors.js.map +1 -0
  54. package/dist/api/export.d.ts +63 -0
  55. package/dist/api/export.d.ts.map +1 -0
  56. package/dist/api/export.js +393 -0
  57. package/dist/api/export.js.map +1 -0
  58. package/dist/api/files.d.ts +40 -0
  59. package/dist/api/files.d.ts.map +1 -0
  60. package/dist/api/files.js +150 -0
  61. package/dist/api/files.js.map +1 -0
  62. package/dist/api/index.d.ts +17 -0
  63. package/dist/api/index.d.ts.map +1 -0
  64. package/dist/api/index.js +17 -0
  65. package/dist/api/index.js.map +1 -0
  66. package/dist/api/invitations.d.ts +50 -0
  67. package/dist/api/invitations.d.ts.map +1 -0
  68. package/dist/api/invitations.js +313 -0
  69. package/dist/api/invitations.js.map +1 -0
  70. package/dist/api/members.d.ts +25 -0
  71. package/dist/api/members.d.ts.map +1 -0
  72. package/dist/api/members.js +522 -0
  73. package/dist/api/members.js.map +1 -0
  74. package/dist/api/onboarding.d.ts +26 -0
  75. package/dist/api/onboarding.d.ts.map +1 -0
  76. package/dist/api/onboarding.js +23 -0
  77. package/dist/api/onboarding.js.map +1 -0
  78. package/dist/api/openapi-doc.d.ts +21 -0
  79. package/dist/api/openapi-doc.d.ts.map +1 -0
  80. package/dist/api/openapi-doc.js +25 -0
  81. package/dist/api/openapi-doc.js.map +1 -0
  82. package/dist/api/projects.d.ts +24 -0
  83. package/dist/api/projects.d.ts.map +1 -0
  84. package/dist/api/projects.js +624 -0
  85. package/dist/api/projects.js.map +1 -0
  86. package/dist/api/scope-routes.d.ts +12 -0
  87. package/dist/api/scope-routes.d.ts.map +1 -0
  88. package/dist/api/scope-routes.js +2 -0
  89. package/dist/api/scope-routes.js.map +1 -0
  90. package/dist/api/sso-connect.d.ts +70 -0
  91. package/dist/api/sso-connect.d.ts.map +1 -0
  92. package/dist/api/sso-connect.js +164 -0
  93. package/dist/api/sso-connect.js.map +1 -0
  94. package/dist/api/sso-logout.d.ts +41 -0
  95. package/dist/api/sso-logout.d.ts.map +1 -0
  96. package/dist/api/sso-logout.js +34 -0
  97. package/dist/api/sso-logout.js.map +1 -0
  98. package/dist/api/teams.d.ts +43 -0
  99. package/dist/api/teams.d.ts.map +1 -0
  100. package/dist/api/teams.js +447 -0
  101. package/dist/api/teams.js.map +1 -0
  102. package/dist/api/workspaces.d.ts +114 -0
  103. package/dist/api/workspaces.d.ts.map +1 -0
  104. package/dist/api/workspaces.js +462 -0
  105. package/dist/api/workspaces.js.map +1 -0
  106. package/dist/apikeys/index.d.ts +3 -0
  107. package/dist/apikeys/index.d.ts.map +1 -0
  108. package/dist/apikeys/index.js +3 -0
  109. package/dist/apikeys/index.js.map +1 -0
  110. package/dist/apikeys/peppers.d.ts +53 -0
  111. package/dist/apikeys/peppers.d.ts.map +1 -0
  112. package/dist/apikeys/peppers.js +97 -0
  113. package/dist/apikeys/peppers.js.map +1 -0
  114. package/dist/apikeys/service.d.ts +56 -0
  115. package/dist/apikeys/service.d.ts.map +1 -0
  116. package/dist/apikeys/service.js +196 -0
  117. package/dist/apikeys/service.js.map +1 -0
  118. package/dist/app.d.ts +65 -0
  119. package/dist/app.d.ts.map +1 -0
  120. package/dist/app.js +142 -0
  121. package/dist/app.js.map +1 -0
  122. package/dist/audit/filters.d.ts +38 -0
  123. package/dist/audit/filters.d.ts.map +1 -0
  124. package/dist/audit/filters.js +97 -0
  125. package/dist/audit/filters.js.map +1 -0
  126. package/dist/audit/index.d.ts +3 -0
  127. package/dist/audit/index.d.ts.map +1 -0
  128. package/dist/audit/index.js +3 -0
  129. package/dist/audit/index.js.map +1 -0
  130. package/dist/audit/service.d.ts +65 -0
  131. package/dist/audit/service.d.ts.map +1 -0
  132. package/dist/audit/service.js +103 -0
  133. package/dist/audit/service.js.map +1 -0
  134. package/dist/boot.d.ts +8 -0
  135. package/dist/boot.d.ts.map +1 -0
  136. package/dist/boot.js +822 -0
  137. package/dist/boot.js.map +1 -0
  138. package/dist/constant-time.d.ts +3 -0
  139. package/dist/constant-time.d.ts.map +1 -0
  140. package/dist/constant-time.js +10 -0
  141. package/dist/constant-time.js.map +1 -0
  142. package/dist/email/driver.d.ts +16 -0
  143. package/dist/email/driver.d.ts.map +1 -0
  144. package/dist/email/driver.js +136 -0
  145. package/dist/email/driver.js.map +1 -0
  146. package/dist/email/index.d.ts +4 -0
  147. package/dist/email/index.d.ts.map +1 -0
  148. package/dist/email/index.js +4 -0
  149. package/dist/email/index.js.map +1 -0
  150. package/dist/email/shell.d.ts +72 -0
  151. package/dist/email/shell.d.ts.map +1 -0
  152. package/dist/email/shell.js +146 -0
  153. package/dist/email/shell.js.map +1 -0
  154. package/dist/email/templates.d.ts +83 -0
  155. package/dist/email/templates.d.ts.map +1 -0
  156. package/dist/email/templates.js +96 -0
  157. package/dist/email/templates.js.map +1 -0
  158. package/dist/entitlements/check.d.ts +140 -0
  159. package/dist/entitlements/check.d.ts.map +1 -0
  160. package/dist/entitlements/check.js +276 -0
  161. package/dist/entitlements/check.js.map +1 -0
  162. package/dist/entitlements/clock-skew.d.ts +27 -0
  163. package/dist/entitlements/clock-skew.d.ts.map +1 -0
  164. package/dist/entitlements/clock-skew.js +58 -0
  165. package/dist/entitlements/clock-skew.js.map +1 -0
  166. package/dist/entitlements/gate.d.ts +113 -0
  167. package/dist/entitlements/gate.d.ts.map +1 -0
  168. package/dist/entitlements/gate.js +430 -0
  169. package/dist/entitlements/gate.js.map +1 -0
  170. package/dist/entitlements/hub-machine-token.d.ts +83 -0
  171. package/dist/entitlements/hub-machine-token.d.ts.map +1 -0
  172. package/dist/entitlements/hub-machine-token.js +175 -0
  173. package/dist/entitlements/hub-machine-token.js.map +1 -0
  174. package/dist/entitlements/hub-subject.d.ts +32 -0
  175. package/dist/entitlements/hub-subject.d.ts.map +1 -0
  176. package/dist/entitlements/hub-subject.js +68 -0
  177. package/dist/entitlements/hub-subject.js.map +1 -0
  178. package/dist/entitlements/index.d.ts +9 -0
  179. package/dist/entitlements/index.d.ts.map +1 -0
  180. package/dist/entitlements/index.js +9 -0
  181. package/dist/entitlements/index.js.map +1 -0
  182. package/dist/entitlements/poster.d.ts +81 -0
  183. package/dist/entitlements/poster.d.ts.map +1 -0
  184. package/dist/entitlements/poster.js +169 -0
  185. package/dist/entitlements/poster.js.map +1 -0
  186. package/dist/entitlements/profiles.d.ts +121 -0
  187. package/dist/entitlements/profiles.d.ts.map +1 -0
  188. package/dist/entitlements/profiles.js +212 -0
  189. package/dist/entitlements/profiles.js.map +1 -0
  190. package/dist/entitlements/slot.d.ts +33 -0
  191. package/dist/entitlements/slot.d.ts.map +1 -0
  192. package/dist/entitlements/slot.js +75 -0
  193. package/dist/entitlements/slot.js.map +1 -0
  194. package/dist/env.d.ts +293 -0
  195. package/dist/env.d.ts.map +1 -0
  196. package/dist/env.js +383 -0
  197. package/dist/env.js.map +1 -0
  198. package/dist/files/http.d.ts +23 -0
  199. package/dist/files/http.d.ts.map +1 -0
  200. package/dist/files/http.js +66 -0
  201. package/dist/files/http.js.map +1 -0
  202. package/dist/files/index.d.ts +5 -0
  203. package/dist/files/index.d.ts.map +1 -0
  204. package/dist/files/index.js +5 -0
  205. package/dist/files/index.js.map +1 -0
  206. package/dist/files/serve.d.ts +43 -0
  207. package/dist/files/serve.d.ts.map +1 -0
  208. package/dist/files/serve.js +71 -0
  209. package/dist/files/serve.js.map +1 -0
  210. package/dist/files/service.d.ts +69 -0
  211. package/dist/files/service.d.ts.map +1 -0
  212. package/dist/files/service.js +188 -0
  213. package/dist/files/service.js.map +1 -0
  214. package/dist/files/spool.d.ts +21 -0
  215. package/dist/files/spool.d.ts.map +1 -0
  216. package/dist/files/spool.js +47 -0
  217. package/dist/files/spool.js.map +1 -0
  218. package/dist/identity/better-auth.d.ts +3927 -0
  219. package/dist/identity/better-auth.d.ts.map +1 -0
  220. package/dist/identity/better-auth.js +896 -0
  221. package/dist/identity/better-auth.js.map +1 -0
  222. package/dist/identity/demo-pass-plugin.d.ts +29 -0
  223. package/dist/identity/demo-pass-plugin.d.ts.map +1 -0
  224. package/dist/identity/demo-pass-plugin.js +88 -0
  225. package/dist/identity/demo-pass-plugin.js.map +1 -0
  226. package/dist/identity/demo-pass-rules.d.ts +84 -0
  227. package/dist/identity/demo-pass-rules.d.ts.map +1 -0
  228. package/dist/identity/demo-pass-rules.js +223 -0
  229. package/dist/identity/demo-pass-rules.js.map +1 -0
  230. package/dist/identity/demo-pass.d.ts +159 -0
  231. package/dist/identity/demo-pass.d.ts.map +1 -0
  232. package/dist/identity/demo-pass.js +319 -0
  233. package/dist/identity/demo-pass.js.map +1 -0
  234. package/dist/identity/hub-grant.d.ts +182 -0
  235. package/dist/identity/hub-grant.d.ts.map +1 -0
  236. package/dist/identity/hub-grant.js +594 -0
  237. package/dist/identity/hub-grant.js.map +1 -0
  238. package/dist/identity/hub-jwt.d.ts +22 -0
  239. package/dist/identity/hub-jwt.d.ts.map +1 -0
  240. package/dist/identity/hub-jwt.js +77 -0
  241. package/dist/identity/hub-jwt.js.map +1 -0
  242. package/dist/identity/hub-live-gate.d.ts +17 -0
  243. package/dist/identity/hub-live-gate.d.ts.map +1 -0
  244. package/dist/identity/hub-live-gate.js +146 -0
  245. package/dist/identity/hub-live-gate.js.map +1 -0
  246. package/dist/identity/hub-logout.d.ts +88 -0
  247. package/dist/identity/hub-logout.d.ts.map +1 -0
  248. package/dist/identity/hub-logout.js +158 -0
  249. package/dist/identity/hub-logout.js.map +1 -0
  250. package/dist/identity/hub-projection.d.ts +103 -0
  251. package/dist/identity/hub-projection.d.ts.map +1 -0
  252. package/dist/identity/hub-projection.js +130 -0
  253. package/dist/identity/hub-projection.js.map +1 -0
  254. package/dist/identity/hub-reconcile.d.ts +151 -0
  255. package/dist/identity/hub-reconcile.d.ts.map +1 -0
  256. package/dist/identity/hub-reconcile.js +400 -0
  257. package/dist/identity/hub-reconcile.js.map +1 -0
  258. package/dist/identity/hub-sso.d.ts +363 -0
  259. package/dist/identity/hub-sso.d.ts.map +1 -0
  260. package/dist/identity/hub-sso.js +536 -0
  261. package/dist/identity/hub-sso.js.map +1 -0
  262. package/dist/identity/hub-user-client.d.ts +222 -0
  263. package/dist/identity/hub-user-client.d.ts.map +1 -0
  264. package/dist/identity/hub-user-client.js +406 -0
  265. package/dist/identity/hub-user-client.js.map +1 -0
  266. package/dist/identity/index.d.ts +13 -0
  267. package/dist/identity/index.d.ts.map +1 -0
  268. package/dist/identity/index.js +13 -0
  269. package/dist/identity/index.js.map +1 -0
  270. package/dist/identity/oauth-jwt.d.ts +32 -0
  271. package/dist/identity/oauth-jwt.d.ts.map +1 -0
  272. package/dist/identity/oauth-jwt.js +122 -0
  273. package/dist/identity/oauth-jwt.js.map +1 -0
  274. package/dist/identity/resolve-membership.d.ts +28 -0
  275. package/dist/identity/resolve-membership.d.ts.map +1 -0
  276. package/dist/identity/resolve-membership.js +69 -0
  277. package/dist/identity/resolve-membership.js.map +1 -0
  278. package/dist/identity/signing-key.d.ts +49 -0
  279. package/dist/identity/signing-key.d.ts.map +1 -0
  280. package/dist/identity/signing-key.js +144 -0
  281. package/dist/identity/signing-key.js.map +1 -0
  282. package/dist/index.d.ts +21 -0
  283. package/dist/index.d.ts.map +1 -0
  284. package/dist/index.js +11 -0
  285. package/dist/index.js.map +1 -0
  286. package/dist/invitations/index.d.ts +2 -0
  287. package/dist/invitations/index.d.ts.map +1 -0
  288. package/dist/invitations/index.js +2 -0
  289. package/dist/invitations/index.js.map +1 -0
  290. package/dist/invitations/service.d.ts +36 -0
  291. package/dist/invitations/service.d.ts.map +1 -0
  292. package/dist/invitations/service.js +122 -0
  293. package/dist/invitations/service.js.map +1 -0
  294. package/dist/jobs/index.d.ts +3 -0
  295. package/dist/jobs/index.d.ts.map +1 -0
  296. package/dist/jobs/index.js +3 -0
  297. package/dist/jobs/index.js.map +1 -0
  298. package/dist/jobs/pgboss.d.ts +119 -0
  299. package/dist/jobs/pgboss.d.ts.map +1 -0
  300. package/dist/jobs/pgboss.js +521 -0
  301. package/dist/jobs/pgboss.js.map +1 -0
  302. package/dist/jobs/timers.d.ts +127 -0
  303. package/dist/jobs/timers.d.ts.map +1 -0
  304. package/dist/jobs/timers.js +228 -0
  305. package/dist/jobs/timers.js.map +1 -0
  306. package/dist/logger.d.ts +17 -0
  307. package/dist/logger.d.ts.map +1 -0
  308. package/dist/logger.js +64 -0
  309. package/dist/logger.js.map +1 -0
  310. package/dist/mcp/errors.d.ts +37 -0
  311. package/dist/mcp/errors.d.ts.map +1 -0
  312. package/dist/mcp/errors.js +77 -0
  313. package/dist/mcp/errors.js.map +1 -0
  314. package/dist/mcp/http.d.ts +35 -0
  315. package/dist/mcp/http.d.ts.map +1 -0
  316. package/dist/mcp/http.js +80 -0
  317. package/dist/mcp/http.js.map +1 -0
  318. package/dist/mcp/index.d.ts +8 -0
  319. package/dist/mcp/index.d.ts.map +1 -0
  320. package/dist/mcp/index.js +8 -0
  321. package/dist/mcp/index.js.map +1 -0
  322. package/dist/mcp/inputs.d.ts +19 -0
  323. package/dist/mcp/inputs.d.ts.map +1 -0
  324. package/dist/mcp/inputs.js +28 -0
  325. package/dist/mcp/inputs.js.map +1 -0
  326. package/dist/mcp/projects.d.ts +31 -0
  327. package/dist/mcp/projects.d.ts.map +1 -0
  328. package/dist/mcp/projects.js +315 -0
  329. package/dist/mcp/projects.js.map +1 -0
  330. package/dist/mcp/server.d.ts +52 -0
  331. package/dist/mcp/server.d.ts.map +1 -0
  332. package/dist/mcp/server.js +93 -0
  333. package/dist/mcp/server.js.map +1 -0
  334. package/dist/mcp/teams.d.ts +7 -0
  335. package/dist/mcp/teams.d.ts.map +1 -0
  336. package/dist/mcp/teams.js +62 -0
  337. package/dist/mcp/teams.js.map +1 -0
  338. package/dist/mcp/tool-kit.d.ts +67 -0
  339. package/dist/mcp/tool-kit.d.ts.map +1 -0
  340. package/dist/mcp/tool-kit.js +74 -0
  341. package/dist/mcp/tool-kit.js.map +1 -0
  342. package/dist/members/removal.d.ts +95 -0
  343. package/dist/members/removal.d.ts.map +1 -0
  344. package/dist/members/removal.js +143 -0
  345. package/dist/members/removal.js.map +1 -0
  346. package/dist/middleware/auth-body.d.ts +25 -0
  347. package/dist/middleware/auth-body.d.ts.map +1 -0
  348. package/dist/middleware/auth-body.js +55 -0
  349. package/dist/middleware/auth-body.js.map +1 -0
  350. package/dist/middleware/auth-context.d.ts +170 -0
  351. package/dist/middleware/auth-context.d.ts.map +1 -0
  352. package/dist/middleware/auth-context.js +251 -0
  353. package/dist/middleware/auth-context.js.map +1 -0
  354. package/dist/middleware/body-cap.d.ts +17 -0
  355. package/dist/middleware/body-cap.d.ts.map +1 -0
  356. package/dist/middleware/body-cap.js +31 -0
  357. package/dist/middleware/body-cap.js.map +1 -0
  358. package/dist/middleware/body-refusal.d.ts +20 -0
  359. package/dist/middleware/body-refusal.d.ts.map +1 -0
  360. package/dist/middleware/body-refusal.js +5 -0
  361. package/dist/middleware/body-refusal.js.map +1 -0
  362. package/dist/middleware/cross-site.d.ts +61 -0
  363. package/dist/middleware/cross-site.d.ts.map +1 -0
  364. package/dist/middleware/cross-site.js +59 -0
  365. package/dist/middleware/cross-site.js.map +1 -0
  366. package/dist/middleware/hub-managed.d.ts +45 -0
  367. package/dist/middleware/hub-managed.d.ts.map +1 -0
  368. package/dist/middleware/hub-managed.js +56 -0
  369. package/dist/middleware/hub-managed.js.map +1 -0
  370. package/dist/middleware/idempotency.d.ts +14 -0
  371. package/dist/middleware/idempotency.d.ts.map +1 -0
  372. package/dist/middleware/idempotency.js +222 -0
  373. package/dist/middleware/idempotency.js.map +1 -0
  374. package/dist/middleware/index.d.ts +12 -0
  375. package/dist/middleware/index.d.ts.map +1 -0
  376. package/dist/middleware/index.js +12 -0
  377. package/dist/middleware/index.js.map +1 -0
  378. package/dist/middleware/json-depth.d.ts +32 -0
  379. package/dist/middleware/json-depth.d.ts.map +1 -0
  380. package/dist/middleware/json-depth.js +80 -0
  381. package/dist/middleware/json-depth.js.map +1 -0
  382. package/dist/middleware/no-store.d.ts +21 -0
  383. package/dist/middleware/no-store.d.ts.map +1 -0
  384. package/dist/middleware/no-store.js +32 -0
  385. package/dist/middleware/no-store.js.map +1 -0
  386. package/dist/middleware/oauth-public.d.ts +16 -0
  387. package/dist/middleware/oauth-public.d.ts.map +1 -0
  388. package/dist/middleware/oauth-public.js +50 -0
  389. package/dist/middleware/oauth-public.js.map +1 -0
  390. package/dist/middleware/rate-limit.d.ts +182 -0
  391. package/dist/middleware/rate-limit.d.ts.map +1 -0
  392. package/dist/middleware/rate-limit.js +277 -0
  393. package/dist/middleware/rate-limit.js.map +1 -0
  394. package/dist/middleware/request-id.d.ts +10 -0
  395. package/dist/middleware/request-id.d.ts.map +1 -0
  396. package/dist/middleware/request-id.js +42 -0
  397. package/dist/middleware/request-id.js.map +1 -0
  398. package/dist/middleware/scopes.d.ts +33 -0
  399. package/dist/middleware/scopes.d.ts.map +1 -0
  400. package/dist/middleware/scopes.js +139 -0
  401. package/dist/middleware/scopes.js.map +1 -0
  402. package/dist/middleware/security-headers.d.ts +53 -0
  403. package/dist/middleware/security-headers.d.ts.map +1 -0
  404. package/dist/middleware/security-headers.js +96 -0
  405. package/dist/middleware/security-headers.js.map +1 -0
  406. package/dist/observability/index.d.ts +3 -0
  407. package/dist/observability/index.d.ts.map +1 -0
  408. package/dist/observability/index.js +3 -0
  409. package/dist/observability/index.js.map +1 -0
  410. package/dist/observability/metrics.d.ts +17 -0
  411. package/dist/observability/metrics.d.ts.map +1 -0
  412. package/dist/observability/metrics.js +82 -0
  413. package/dist/observability/metrics.js.map +1 -0
  414. package/dist/observability/otel.d.ts +20 -0
  415. package/dist/observability/otel.d.ts.map +1 -0
  416. package/dist/observability/otel.js +54 -0
  417. package/dist/observability/otel.js.map +1 -0
  418. package/dist/pagination.d.ts +57 -0
  419. package/dist/pagination.d.ts.map +1 -0
  420. package/dist/pagination.js +90 -0
  421. package/dist/pagination.js.map +1 -0
  422. package/dist/platform/edition.d.ts +57 -0
  423. package/dist/platform/edition.d.ts.map +1 -0
  424. package/dist/platform/edition.js +90 -0
  425. package/dist/platform/edition.js.map +1 -0
  426. package/dist/platform/entitlements.d.ts +26 -0
  427. package/dist/platform/entitlements.d.ts.map +1 -0
  428. package/dist/platform/entitlements.js +28 -0
  429. package/dist/platform/entitlements.js.map +1 -0
  430. package/dist/platform/events.d.ts +87 -0
  431. package/dist/platform/events.d.ts.map +1 -0
  432. package/dist/platform/events.js +32 -0
  433. package/dist/platform/events.js.map +1 -0
  434. package/dist/platform/index.d.ts +8 -0
  435. package/dist/platform/index.d.ts.map +1 -0
  436. package/dist/platform/index.js +8 -0
  437. package/dist/platform/index.js.map +1 -0
  438. package/dist/platform/local-identity.d.ts +39 -0
  439. package/dist/platform/local-identity.d.ts.map +1 -0
  440. package/dist/platform/local-identity.js +87 -0
  441. package/dist/platform/local-identity.js.map +1 -0
  442. package/dist/platform/registry.d.ts +23 -0
  443. package/dist/platform/registry.d.ts.map +1 -0
  444. package/dist/platform/registry.js +5 -0
  445. package/dist/platform/registry.js.map +1 -0
  446. package/dist/platform/usage.d.ts +11 -0
  447. package/dist/platform/usage.d.ts.map +1 -0
  448. package/dist/platform/usage.js +12 -0
  449. package/dist/platform/usage.js.map +1 -0
  450. package/dist/platform/workspaces.d.ts +25 -0
  451. package/dist/platform/workspaces.d.ts.map +1 -0
  452. package/dist/platform/workspaces.js +44 -0
  453. package/dist/platform/workspaces.js.map +1 -0
  454. package/dist/projects/access.d.ts +40 -0
  455. package/dist/projects/access.d.ts.map +1 -0
  456. package/dist/projects/access.js +133 -0
  457. package/dist/projects/access.js.map +1 -0
  458. package/dist/projects/index.d.ts +2 -0
  459. package/dist/projects/index.d.ts.map +1 -0
  460. package/dist/projects/index.js +2 -0
  461. package/dist/projects/index.js.map +1 -0
  462. package/dist/route-label.d.ts +23 -0
  463. package/dist/route-label.d.ts.map +1 -0
  464. package/dist/route-label.js +29 -0
  465. package/dist/route-label.js.map +1 -0
  466. package/dist/routes/health.d.ts +30 -0
  467. package/dist/routes/health.d.ts.map +1 -0
  468. package/dist/routes/health.js +54 -0
  469. package/dist/routes/health.js.map +1 -0
  470. package/dist/routes/index.d.ts +3 -0
  471. package/dist/routes/index.d.ts.map +1 -0
  472. package/dist/routes/index.js +3 -0
  473. package/dist/routes/index.js.map +1 -0
  474. package/dist/routes/wellknown.d.ts +11 -0
  475. package/dist/routes/wellknown.d.ts.map +1 -0
  476. package/dist/routes/wellknown.js +45 -0
  477. package/dist/routes/wellknown.js.map +1 -0
  478. package/dist/secret.d.ts +30 -0
  479. package/dist/secret.d.ts.map +1 -0
  480. package/dist/secret.js +97 -0
  481. package/dist/secret.js.map +1 -0
  482. package/dist/setup-transport.d.ts +23 -0
  483. package/dist/setup-transport.d.ts.map +1 -0
  484. package/dist/setup-transport.js +52 -0
  485. package/dist/setup-transport.js.map +1 -0
  486. package/dist/state.d.ts +21 -0
  487. package/dist/state.d.ts.map +1 -0
  488. package/dist/state.js +4 -0
  489. package/dist/state.js.map +1 -0
  490. package/dist/storage/driver.d.ts +38 -0
  491. package/dist/storage/driver.d.ts.map +1 -0
  492. package/dist/storage/driver.js +14 -0
  493. package/dist/storage/driver.js.map +1 -0
  494. package/dist/storage/factory.d.ts +4 -0
  495. package/dist/storage/factory.d.ts.map +1 -0
  496. package/dist/storage/factory.js +20 -0
  497. package/dist/storage/factory.js.map +1 -0
  498. package/dist/storage/index.d.ts +4 -0
  499. package/dist/storage/index.d.ts.map +1 -0
  500. package/dist/storage/index.js +4 -0
  501. package/dist/storage/index.js.map +1 -0
  502. package/dist/storage/local.d.ts +21 -0
  503. package/dist/storage/local.d.ts.map +1 -0
  504. package/dist/storage/local.js +88 -0
  505. package/dist/storage/local.js.map +1 -0
  506. package/dist/storage/s3.d.ts +29 -0
  507. package/dist/storage/s3.d.ts.map +1 -0
  508. package/dist/storage/s3.js +82 -0
  509. package/dist/storage/s3.js.map +1 -0
  510. package/dist/testing/fake-hub.d.ts +425 -0
  511. package/dist/testing/fake-hub.d.ts.map +1 -0
  512. package/dist/testing/fake-hub.js +1074 -0
  513. package/dist/testing/fake-hub.js.map +1 -0
  514. package/dist/testing/helpers.d.ts +124 -0
  515. package/dist/testing/helpers.d.ts.map +1 -0
  516. package/dist/testing/helpers.js +174 -0
  517. package/dist/testing/helpers.js.map +1 -0
  518. package/dist/testing/index.d.ts +12 -0
  519. package/dist/testing/index.d.ts.map +1 -0
  520. package/dist/testing/index.js +12 -0
  521. package/dist/testing/index.js.map +1 -0
  522. package/dist/testing/setup-origins.d.ts +21 -0
  523. package/dist/testing/setup-origins.d.ts.map +1 -0
  524. package/dist/testing/setup-origins.js +57 -0
  525. package/dist/testing/setup-origins.js.map +1 -0
  526. package/dist/testing/sso-helpers.d.ts +43 -0
  527. package/dist/testing/sso-helpers.d.ts.map +1 -0
  528. package/dist/testing/sso-helpers.js +159 -0
  529. package/dist/testing/sso-helpers.js.map +1 -0
  530. package/dist/tool-definition.d.ts +382 -0
  531. package/dist/tool-definition.d.ts.map +1 -0
  532. package/dist/tool-definition.js +46 -0
  533. package/dist/tool-definition.js.map +1 -0
  534. package/dist/util/index.d.ts +8 -0
  535. package/dist/util/index.d.ts.map +1 -0
  536. package/dist/util/index.js +8 -0
  537. package/dist/util/index.js.map +1 -0
  538. package/package.json +161 -0
  539. package/src/accounts/deletion.ts +389 -0
  540. package/src/accounts/erasure-log.ts +140 -0
  541. package/src/accounts/index.ts +5 -0
  542. package/src/accounts/mint-refusal.ts +67 -0
  543. package/src/accounts/signup-duplicate.ts +21 -0
  544. package/src/accounts/superadmin.ts +46 -0
  545. package/src/api/apikeys.ts +147 -0
  546. package/src/api/audit.ts +83 -0
  547. package/src/api/break-glass.ts +294 -0
  548. package/src/api/cli-auth.ts +308 -0
  549. package/src/api/create-api.ts +1403 -0
  550. package/src/api/demo-passes.ts +207 -0
  551. package/src/api/errors.ts +13 -0
  552. package/src/api/export.ts +482 -0
  553. package/src/api/files.ts +200 -0
  554. package/src/api/index.ts +29 -0
  555. package/src/api/invitations.ts +396 -0
  556. package/src/api/members.ts +617 -0
  557. package/src/api/onboarding.ts +50 -0
  558. package/src/api/openapi-doc.ts +34 -0
  559. package/src/api/projects.ts +788 -0
  560. package/src/api/scope-routes.ts +12 -0
  561. package/src/api/sso-connect.ts +246 -0
  562. package/src/api/sso-logout.ts +76 -0
  563. package/src/api/teams.ts +575 -0
  564. package/src/api/workspaces.ts +649 -0
  565. package/src/apikeys/index.ts +2 -0
  566. package/src/apikeys/peppers.ts +118 -0
  567. package/src/apikeys/service.ts +223 -0
  568. package/src/app.ts +228 -0
  569. package/src/audit/filters.ts +101 -0
  570. package/src/audit/index.ts +9 -0
  571. package/src/audit/service.ts +154 -0
  572. package/src/boot.ts +953 -0
  573. package/src/constant-time.ts +9 -0
  574. package/src/email/driver.ts +139 -0
  575. package/src/email/index.ts +26 -0
  576. package/src/email/shell.ts +201 -0
  577. package/src/email/templates.ts +184 -0
  578. package/src/entitlements/check.ts +435 -0
  579. package/src/entitlements/clock-skew.ts +58 -0
  580. package/src/entitlements/gate.ts +636 -0
  581. package/src/entitlements/hub-machine-token.ts +205 -0
  582. package/src/entitlements/hub-subject.ts +77 -0
  583. package/src/entitlements/index.ts +37 -0
  584. package/src/entitlements/poster.ts +265 -0
  585. package/src/entitlements/profiles.ts +303 -0
  586. package/src/entitlements/slot.ts +105 -0
  587. package/src/env.ts +456 -0
  588. package/src/files/http.ts +77 -0
  589. package/src/files/index.ts +4 -0
  590. package/src/files/serve.ts +115 -0
  591. package/src/files/service.ts +209 -0
  592. package/src/files/spool.ts +58 -0
  593. package/src/identity/better-auth.ts +1103 -0
  594. package/src/identity/demo-pass-plugin.ts +121 -0
  595. package/src/identity/demo-pass-rules.ts +229 -0
  596. package/src/identity/demo-pass.ts +390 -0
  597. package/src/identity/hub-grant.ts +704 -0
  598. package/src/identity/hub-jwt.ts +78 -0
  599. package/src/identity/hub-live-gate.ts +167 -0
  600. package/src/identity/hub-logout.ts +174 -0
  601. package/src/identity/hub-projection.ts +251 -0
  602. package/src/identity/hub-reconcile.ts +572 -0
  603. package/src/identity/hub-sso.ts +696 -0
  604. package/src/identity/hub-user-client.ts +554 -0
  605. package/src/identity/index.ts +56 -0
  606. package/src/identity/oauth-jwt.ts +128 -0
  607. package/src/identity/resolve-membership.ts +104 -0
  608. package/src/identity/signing-key.ts +164 -0
  609. package/src/index.ts +65 -0
  610. package/src/invitations/index.ts +1 -0
  611. package/src/invitations/service.ts +171 -0
  612. package/src/jobs/index.ts +25 -0
  613. package/src/jobs/pgboss.ts +594 -0
  614. package/src/jobs/timers.ts +287 -0
  615. package/src/logger.ts +67 -0
  616. package/src/mcp/errors.ts +111 -0
  617. package/src/mcp/http.ts +129 -0
  618. package/src/mcp/index.ts +28 -0
  619. package/src/mcp/inputs.ts +33 -0
  620. package/src/mcp/projects.ts +480 -0
  621. package/src/mcp/server.ts +174 -0
  622. package/src/mcp/teams.ts +97 -0
  623. package/src/mcp/tool-kit.ts +148 -0
  624. package/src/members/removal.ts +216 -0
  625. package/src/middleware/auth-body.ts +58 -0
  626. package/src/middleware/auth-context.ts +384 -0
  627. package/src/middleware/body-cap.ts +33 -0
  628. package/src/middleware/body-refusal.ts +23 -0
  629. package/src/middleware/cross-site.ts +122 -0
  630. package/src/middleware/hub-managed.ts +61 -0
  631. package/src/middleware/idempotency.ts +264 -0
  632. package/src/middleware/index.ts +33 -0
  633. package/src/middleware/json-depth.ts +74 -0
  634. package/src/middleware/no-store.ts +30 -0
  635. package/src/middleware/oauth-public.ts +54 -0
  636. package/src/middleware/rate-limit.ts +432 -0
  637. package/src/middleware/request-id.ts +54 -0
  638. package/src/middleware/scopes.ts +150 -0
  639. package/src/middleware/security-headers.ts +129 -0
  640. package/src/observability/index.ts +2 -0
  641. package/src/observability/metrics.ts +102 -0
  642. package/src/observability/otel.ts +79 -0
  643. package/src/pagination.ts +118 -0
  644. package/src/platform/edition.ts +140 -0
  645. package/src/platform/entitlements.ts +47 -0
  646. package/src/platform/events.ts +71 -0
  647. package/src/platform/index.ts +7 -0
  648. package/src/platform/local-identity.ts +90 -0
  649. package/src/platform/registry.ts +28 -0
  650. package/src/platform/usage.ts +13 -0
  651. package/src/platform/workspaces.ts +41 -0
  652. package/src/projects/access.ts +160 -0
  653. package/src/projects/index.ts +7 -0
  654. package/src/route-label.ts +29 -0
  655. package/src/routes/health.ts +79 -0
  656. package/src/routes/index.ts +2 -0
  657. package/src/routes/wellknown.ts +67 -0
  658. package/src/secret.ts +115 -0
  659. package/src/setup-transport.ts +46 -0
  660. package/src/state.ts +23 -0
  661. package/src/storage/driver.ts +48 -0
  662. package/src/storage/factory.ts +34 -0
  663. package/src/storage/index.ts +3 -0
  664. package/src/storage/local.ts +93 -0
  665. package/src/storage/s3.ts +109 -0
  666. package/src/testing/fake-hub.ts +1316 -0
  667. package/src/testing/helpers.ts +254 -0
  668. package/src/testing/index.ts +11 -0
  669. package/src/testing/setup-origins.ts +58 -0
  670. package/src/testing/sso-helpers.ts +178 -0
  671. package/src/tool-definition.ts +463 -0
  672. package/src/util/index.ts +17 -0
  673. package/test/fixtures/onboarding-backfill.sql +13 -0
  674. package/test/fixtures/operator-user-id-backfill.sql +18 -0
  675. package/test/host/dr-lib.sh +253 -0
  676. package/test/host/drizzle/0000_chassis_baseline.sql +438 -0
  677. package/test/host/drizzle/0001_last_owner_guard.sql +42 -0
  678. package/test/host/drizzle/meta/0000_snapshot.json +3566 -0
  679. package/test/host/drizzle/meta/_journal.json +20 -0
  680. package/test/host/drizzle.config.ts +17 -0
  681. package/test/host/identity.ts +41 -0
  682. package/test/host/minimal-host.ts +35 -0
  683. package/test/host/minimal-tool.ts +123 -0
  684. package/test/integration/account-oracle-uniformity.test.ts +284 -0
  685. package/test/integration/account-recovery.test.ts +236 -0
  686. package/test/integration/account.test.ts +120 -0
  687. package/test/integration/api-quota.test.ts +208 -0
  688. package/test/integration/apikey-expiry.test.ts +178 -0
  689. package/test/integration/apikey-pepper-rotation.test.ts +190 -0
  690. package/test/integration/boot-posture.test.ts +169 -0
  691. package/test/integration/break-glass.test.ts +484 -0
  692. package/test/integration/cloud-lifecycle.test.ts +233 -0
  693. package/test/integration/credential-boundaries.test.ts +195 -0
  694. package/test/integration/default-workspace.test.ts +414 -0
  695. package/test/integration/demo-pass-rules.test.ts +332 -0
  696. package/test/integration/demo-pass.test.ts +1222 -0
  697. package/test/integration/dr-verify-real-dump.test.ts +154 -0
  698. package/test/integration/edition.test.ts +868 -0
  699. package/test/integration/email-change.test.ts +394 -0
  700. package/test/integration/empty-tool.test.ts +267 -0
  701. package/test/integration/entitlements-null-hook.test.ts +187 -0
  702. package/test/integration/entitlements.test.ts +693 -0
  703. package/test/integration/export-entries.test.ts +321 -0
  704. package/test/integration/files.test.ts +320 -0
  705. package/test/integration/first-boot-claim.test.ts +131 -0
  706. package/test/integration/gdpr.test.ts +532 -0
  707. package/test/integration/helpers.ts +29 -0
  708. package/test/integration/hub-grant.test.ts +505 -0
  709. package/test/integration/hub-live-gate.test.ts +612 -0
  710. package/test/integration/hub-managed-membership.test.ts +559 -0
  711. package/test/integration/hub-org-selector.test.ts +281 -0
  712. package/test/integration/hub-reconcile.test.ts +646 -0
  713. package/test/integration/hub-sso.test.ts +704 -0
  714. package/test/integration/hub-teams.test.ts +771 -0
  715. package/test/integration/idempotency.test.ts +261 -0
  716. package/test/integration/invite-verification.test.ts +210 -0
  717. package/test/integration/last-owner-race.test.ts +263 -0
  718. package/test/integration/m9-fixes.test.ts +290 -0
  719. package/test/integration/mcp-projects.test.ts +463 -0
  720. package/test/integration/member-removal-race.test.ts +466 -0
  721. package/test/integration/member-removal.test.ts +794 -0
  722. package/test/integration/oauth-mcp.test.ts +477 -0
  723. package/test/integration/oauth-workspace.test.ts +371 -0
  724. package/test/integration/observability.test.ts +183 -0
  725. package/test/integration/onboarding.test.ts +268 -0
  726. package/test/integration/orphan-purge.test.ts +451 -0
  727. package/test/integration/pagination.test.ts +344 -0
  728. package/test/integration/platform-core.test.ts +305 -0
  729. package/test/integration/platform.test.ts +246 -0
  730. package/test/integration/pool-survives-restart.test.ts +80 -0
  731. package/test/integration/projects-cloud.test.ts +193 -0
  732. package/test/integration/projects.test.ts +1310 -0
  733. package/test/integration/security-fixes.test.ts +190 -0
  734. package/test/integration/setup-transport.test.ts +118 -0
  735. package/test/integration/signin-wall.test.ts +103 -0
  736. package/test/integration/signing-key-rotation.test.ts +349 -0
  737. package/test/integration/sso-helpers.ts +15 -0
  738. package/test/integration/sso-logout.test.ts +283 -0
  739. package/test/integration/teams-cloud.test.ts +194 -0
  740. package/test/integration/teams.test.ts +551 -0
  741. package/test/integration/timers.test.ts +377 -0
  742. package/test/integration/two-factor.test.ts +301 -0
  743. package/test/integration/usage-window.test.ts +161 -0
  744. package/test/integration/workspace-create-cloud.test.ts +523 -0
  745. package/test/integration/workspace-create.test.ts +510 -0
  746. package/test/integration/workspace-scoping.test.ts +530 -0
  747. package/test/integration/zz-adv-replay-owner.test.ts +205 -0
  748. package/test/unit/apikey-peppers.test.ts +110 -0
  749. package/test/unit/apikey-prefix.test.ts +34 -0
  750. package/test/unit/audit-filters.test.ts +135 -0
  751. package/test/unit/auth-origins.test.ts +92 -0
  752. package/test/unit/body-cap.test.ts +84 -0
  753. package/test/unit/clock-skew.test.ts +68 -0
  754. package/test/unit/cross-site.test.ts +166 -0
  755. package/test/unit/declared-credits.test.ts +13 -0
  756. package/test/unit/demo-pass-rules.test.ts +192 -0
  757. package/test/unit/email-driver.test.ts +127 -0
  758. package/test/unit/end-pool.test.ts +181 -0
  759. package/test/unit/entitlement-check.test.ts +492 -0
  760. package/test/unit/entitlement-gate.test.ts +1333 -0
  761. package/test/unit/entitlement-profiles.test.ts +289 -0
  762. package/test/unit/erasure-log.test.ts +72 -0
  763. package/test/unit/error-mapping.test.ts +89 -0
  764. package/test/unit/export-entries.test.ts +85 -0
  765. package/test/unit/export-sanitize.test.ts +27 -0
  766. package/test/unit/files-head-route.test.ts +155 -0
  767. package/test/unit/files-http.test.ts +97 -0
  768. package/test/unit/head-rewrite.test.ts +72 -0
  769. package/test/unit/hub-create-org.test.ts +181 -0
  770. package/test/unit/hub-jwt.test.ts +93 -0
  771. package/test/unit/hub-sso-params.test.ts +39 -0
  772. package/test/unit/hub-subject.test.ts +83 -0
  773. package/test/unit/idempotency-crypto.test.ts +45 -0
  774. package/test/unit/json-depth.test.ts +88 -0
  775. package/test/unit/logger-redaction.test.ts +66 -0
  776. package/test/unit/mcp-errors.test.ts +110 -0
  777. package/test/unit/mcp-inputs.test.ts +106 -0
  778. package/test/unit/mcp-project-hints.test.ts +26 -0
  779. package/test/unit/no-store.test.ts +49 -0
  780. package/test/unit/no-tool-migration-filename.test.ts +70 -0
  781. package/test/unit/observability.test.ts +64 -0
  782. package/test/unit/openapi-doc.test.ts +93 -0
  783. package/test/unit/rate-limit-consume-on.test.ts +76 -0
  784. package/test/unit/rate-limit.test.ts +39 -0
  785. package/test/unit/readyz-storage.test.ts +157 -0
  786. package/test/unit/request-quota.test.ts +146 -0
  787. package/test/unit/scopes.test.ts +66 -0
  788. package/test/unit/security-headers.test.ts +131 -0
  789. package/test/unit/serve-content-type.test.ts +74 -0
  790. package/test/unit/setup-transport.test.ts +29 -0
  791. package/test/unit/storage-local.test.ts +53 -0
  792. package/test/unit/superadmin.test.ts +32 -0
  793. package/test/unit/timers.test.ts +287 -0
  794. package/test/unit/tool-definition-guards.test.ts +166 -0
  795. package/test/unit/tool-entitlements-guard.test.ts +168 -0
  796. package/test/unit/usage-poster.test.ts +542 -0
  797. package/test/unit/usage-window.test.ts +48 -0
  798. package/test/unit/workspace-create-audit.test.ts +48 -0
@@ -0,0 +1,1316 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
2
+ import {
3
+ entitlementProfileSchema,
4
+ usageCheckRequestSchema,
5
+ usageCheckSchema,
6
+ usageEventSchema,
7
+ usageEventsBatchSchema,
8
+ usageIngestResultSchema
9
+ } from '@antasphere/chassis-contract';
10
+ import { randomUUID } from 'node:crypto';
11
+ import { exportJWK, generateKeyPair, SignJWT, type JWK } from 'jose';
12
+
13
+ /**
14
+ * A minimal in-process Antasphere-hub stand-in for tests, faithful to the
15
+ * USER-SCOPED federation contract (internal/federation.md; verified against the
16
+ * hub's Stage A `api/orgs.ts` + the pinned oauth-provider plugin):
17
+ *
18
+ * - OIDC discovery + JWKS + token endpoint. The token endpoint honors
19
+ * `grant_type=authorization_code` (one-shot codes via `mintCode`) AND
20
+ * `grant_type=refresh_token` with ROTATION + RFC 9700 REUSE DETECTION:
21
+ * every refresh rotates the token; presenting a rotated-out token
22
+ * answers 400 `invalid_grant` and tears down the whole (sub, client)
23
+ * family — the single most important fixture of the grant suite.
24
+ * - RFC 8707 `resource` controls JWT minting on BOTH grants: with it the
25
+ * access token is an RS256 JWT audienced `[<resource>, <userinfo>]`;
26
+ * without it the token is OPAQUE (and dies at the fake's /api/v1 exactly
27
+ * like at the real hub's).
28
+ * - `GET /api/v1/orgs` is CALLER-SCOPED: the presented bearer must be a
29
+ * live JWT this fake minted whose `aud` includes the HUB's own resource
30
+ * (`<issuer>/mcp`) — pinning the tokenResource seam: a tool-audienced
31
+ * token is refused 401. The answer is the token's SUBJECT's org list
32
+ * (`setUserOrg`/`removeUserOrg`; a `mintCode` fixture seeds its own org).
33
+ * There is NO target-user parameter anywhere.
34
+ * - `POST /api/v1/auth/oauth2/introspect` (RFC 7662, client-credentialed)
35
+ * answers `active` for a refresh token exactly like the pinned plugin:
36
+ * unknown / rotated-out / torn-down → `active:false`, and — the property
37
+ * the grant service's probe relies on — introspection is READ-ONLY: it
38
+ * never rotates and never tears a family down.
39
+ * - `GET /api/v1/teams` (PRDCT-2813): the org's whole team list for any
40
+ * member of the org `X-Workspace-Id` names (`setOrgTeams`, else the
41
+ * union of the members' own teams), paged; `teamsMode` adds `http404`
42
+ * (an older hub without the route), `teamsRequests` logs each read.
43
+ * - failure injection per surface: `orgsMode`/`tokenMode` ∈ ok | http500 |
44
+ * network (+ token-only: invalid_grant | invalid_client | hang |
45
+ * commit_then_hang — the latter ROTATES, then never answers: the
46
+ * slow-but-alive hub of PRDCT-1370), `introspectMode`, plus delays.
47
+ * - The billing rail's machine surface (`/api/v1/usage/*`, a
48
+ * client_credentials token with `usage:write` only): the event ingest
49
+ * (per-element judgement, dedupe by id), the plan read (`upgradeUrl`
50
+ * included since phase 2) and, since phase 2 (PRDCT-2663/2664), the
51
+ * credit check `POST /usage/check` over a price book (`setPrice`) and
52
+ * per-organization balances (`setBalance`, `grant`, `balanceOf`, a
53
+ * known organization holding the 5,000-credit sign-up grant like the
54
+ * real hub). Ingest DEBITS: an accepted event is priced by the same rule
55
+ * as the check, its `credits` returned on its result, the balance moved
56
+ * and a `debit` appended to `ledger` with the event id as its source.
57
+ * The check answers the hub's three reasons in the hub's order
58
+ * (`account_suspended`, then `unpriceable` for a price past
59
+ * `Number.MAX_SAFE_INTEGER`, `credits` clamped to it, then
60
+ * `insufficient_credits`), and every answer of the three usage routes
61
+ * goes through the chassis's own schema before it is sent (PRDCT-2677):
62
+ * a fake answer the chassis could not parse throws in the test that
63
+ * produced it.
64
+ *
65
+ * Access tokens still carry the TRANSITIONAL advisory org claims the real
66
+ * hub emits through the compat window ({role, workspace_id, …}) — the tool
67
+ * must ignore them, and the suites prove nothing reads them.
68
+ */
69
+
70
+ export interface HubTokenOverrides {
71
+ /** Replace the access token's aud entirely (wrong-resource tests). */
72
+ accessAud?: string | string[] | undefined;
73
+ /** Replace iss on both tokens (wrong-issuer tests). */
74
+ iss?: string | undefined;
75
+ /** Seconds from now for exp; negative = already expired. */
76
+ expiresInSeconds?: number | undefined;
77
+ /** Return a non-JWT access token even when `resource` was sent. */
78
+ forceOpaque?: boolean | undefined;
79
+ /** Give the id_token a different sub (cross-pin tests). */
80
+ idTokenSub?: string | undefined;
81
+ /**
82
+ * Put a `sid` claim on the id_token — what the real hub mints once its
83
+ * tool client carries `enableEndSession` (RP-initiated logout). Absent by
84
+ * default: the pre-flip token shape, which the tool's logout must degrade
85
+ * on (sid-less hints hard-fail at the end-session endpoint).
86
+ */
87
+ idTokenSid?: string | undefined;
88
+ /**
89
+ * The scope string this user's grant carries, whatever the sign-in
90
+ * requested — e.g. `LEGACY_GRANT_SCOPE` for a person who signed in before
91
+ * the tool asked for `orgs:create`.
92
+ */
93
+ scope?: string | undefined;
94
+ }
95
+
96
+ /** What the tool requested before PRDCT-2443 — and what the hub's H3 connect grant carries. */
97
+ export const LEGACY_GRANT_SCOPE = 'openid profile email offline_access account:read';
98
+ /** The hub's DEDICATED scope for POST /api/v1/orgs. `account:write` does NOT open it. */
99
+ export const ORG_CREATE_SCOPE = 'orgs:create';
100
+
101
+ export interface HubUserFixture {
102
+ sub: string;
103
+ email: string;
104
+ emailVerified?: boolean | undefined;
105
+ name?: string | undefined;
106
+ /** The user's (first) hub org — seeded into the org registry at mintCode. */
107
+ workspaceId: string;
108
+ role: string;
109
+ /** Org display name; set null to simulate a hub without names. */
110
+ workspaceName?: string | null | undefined;
111
+ overrides?: HubTokenOverrides | undefined;
112
+ }
113
+
114
+ /** One entry of a user's org registry (what GET /orgs serves). */
115
+ export interface HubOrgEntry {
116
+ name: string | null;
117
+ role: string;
118
+ status?: 'active' | 'suspended' | undefined;
119
+ isDefault?: boolean | undefined;
120
+ /** The caller's own teams in this org (served as the entry's `teams`; none when absent). */
121
+ teams?: Array<{ id: string; slug: string; name: string }> | undefined;
122
+ }
123
+
124
+ interface HubKey {
125
+ kid: string;
126
+ privateKey: Awaited<ReturnType<typeof generateKeyPair>>['privateKey'];
127
+ publicJwk: JWK;
128
+ }
129
+
130
+ interface AccessTokenRecord {
131
+ sub: string;
132
+ aud: string[];
133
+ expMs: number;
134
+ /** Space-separated granted scopes — what POST /api/v1/orgs is judged on. */
135
+ scope: string;
136
+ }
137
+
138
+ interface RefreshTokenRecord {
139
+ sub: string;
140
+ clientId: string;
141
+ family: string;
142
+ rotatedOut: boolean;
143
+ /** The grant's scopes: a refresh re-mints EXACTLY these, never more. */
144
+ scope: string;
145
+ }
146
+
147
+ /** One row of the fake's price book (the hub's `tool_prices`, reduced to what the price rule reads). */
148
+ export interface FakePriceRow {
149
+ creditsPerUnit: number;
150
+ unit: string;
151
+ /** How many units one `creditsPerUnit` buys; 1 when absent. */
152
+ per?: number | undefined;
153
+ /** The quantity each account uses free every month before the price applies; 0 when absent. */
154
+ freeQuantityPerMonth?: number | undefined;
155
+ }
156
+
157
+ /** The credits a new organization is granted at the hub (the sign-up grant). */
158
+ const SIGNUP_GRANT = 5_000;
159
+
160
+ /** The hub's `z.uuid()` on an account reference, as zod 4 spells it (the nil and max uuids included). */
161
+ const UUID_RE =
162
+ /^([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i;
163
+
164
+ export class FakeHub {
165
+ readonly codes = new Map<string, HubUserFixture>();
166
+ /** code → the scope string the grant will carry (see mintCode). */
167
+ private readonly codeScopes = new Map<string, string>();
168
+ /** Body of the most recent token request — pins the `resource` passthrough. */
169
+ lastTokenRequest: URLSearchParams | null = null;
170
+
171
+ /** sub → orgId → entry: the caller-scoped truth GET /orgs serves. */
172
+ private readonly userOrgs = new Map<string, Map<string, HubOrgEntry>>();
173
+ /**
174
+ * orgId → the organization's WHOLE team list (what GET /teams serves,
175
+ * PRDCT-2813). An org with no entry here serves the union of the teams its
176
+ * members' `GET /orgs` entries carry (the real hub's list always holds
177
+ * every team a member sits in), so a suite that seats teams through
178
+ * `setUserOrg` alone never sees them swept by the team-list read.
179
+ */
180
+ private readonly orgTeamRegistry = new Map<string, Array<{ id: string; slug: string; name: string }>>();
181
+ /** sub → the orgs that do NOT open the tool to them (GET /orgs' top-level `denied`). */
182
+ private readonly userDenied = new Map<string, Array<{ id: string; name: string }>>();
183
+ /** Every JWT access token this fake minted (bearer lookup for /api/v1). */
184
+ private readonly accessTokens = new Map<string, AccessTokenRecord>();
185
+ /** Live + rotated-out refresh tokens (rotation families). */
186
+ private readonly refreshTokens = new Map<string, RefreshTokenRecord>();
187
+ /** Families torn down by reuse detection (or `revokeGrants`). */
188
+ private readonly deadFamilies = new Set<string>();
189
+
190
+ // ── Failure injection ─────────────────────────────────────────────────
191
+ /** GET /api/v1/orgs behavior. */
192
+ orgsMode: 'ok' | 'http500' | 'network' = 'ok';
193
+ /** GET /api/v1/teams behavior (`http404` = an older hub without the route). */
194
+ teamsMode: 'ok' | 'http500' | 'network' | 'http404' = 'ok';
195
+ /** Hold every /orgs answer this long (single-flight/race tests). */
196
+ orgsDelayMs = 0;
197
+ /** Hold every GET /api/v1/teams answer this long (the stale-delete race). */
198
+ teamsDelayMs = 0;
199
+ /**
200
+ * The real hub's answer when the cursor's team was deleted between two page
201
+ * reads: an unknown keyset cursor answers an EMPTY page with no cursor. When
202
+ * true, every GET /api/v1/teams carrying a `cursor` answers exactly that;
203
+ * the first page is unaffected.
204
+ */
205
+ teamsCursorLost = false;
206
+ /**
207
+ * POST /api/v1/orgs behavior (the as-the-user org creation, PRDCT-2443).
208
+ * `limit` = the hub's per-user cap (403 org_limit_reached, the real hub's
209
+ * answer); `forbidden` = a hub that does not open creation to this grant;
210
+ * `commit_then_500` creates the org and THEN answers 500 — the ambiguity
211
+ * the tool must never resolve by re-posting.
212
+ */
213
+ orgCreateMode: 'ok' | 'limit' | 'forbidden' | 'http500' | 'network' | 'commit_then_500' = 'ok';
214
+ /** Every POST /api/v1/orgs: the presented Authorization header + parsed body. */
215
+ readonly orgCreateRequests: Array<{ auth: string | null; body: unknown }> = [];
216
+ /**
217
+ * Token endpoint behavior (both grants). `hang` never answers (the request
218
+ * is parked, nothing consumed); `commit_then_hang` consumes the presented
219
+ * refresh token — rotation committed hub-side — and THEN never answers.
220
+ */
221
+ tokenMode: 'ok' | 'http500' | 'network' | 'invalid_grant' | 'invalid_client' | 'hang' | 'commit_then_hang' =
222
+ 'ok';
223
+ /** Hold every token answer this long (refresh-race tests). */
224
+ tokenDelayMs = 0;
225
+ /** How long a `hang` / `commit_then_hang` request is parked before the fake gives up on it. */
226
+ tokenHangMs = 30_000;
227
+ /** Introspection endpoint behavior. */
228
+ introspectMode: 'ok' | 'http500' | 'network' | 'invalid_client' | 'malformed' = 'ok';
229
+ /** Hold every introspection answer this long (lock-hold tests). */
230
+ introspectDelayMs = 0;
231
+ /** Lifetime of newly minted access tokens (seconds). */
232
+ accessTokenTtlSeconds = 900;
233
+
234
+ /** Every GET /api/v1/teams request: the presented Authorization header and the org it named. */
235
+ readonly teamsRequests: Array<{ auth: string | null; orgId: string | null }> = [];
236
+ /** Every /api/v1/orgs request: the presented Authorization header. */
237
+ readonly orgsRequests: Array<{ auth: string | null }> = [];
238
+ /** Every refresh-grant presentation (single-flight/rotation pins). */
239
+ readonly refreshRequests: Array<{
240
+ refreshToken: string;
241
+ resource: string | null;
242
+ clientId: string | null;
243
+ }> = [];
244
+ /** Every introspection call (the grant service's probe pins). */
245
+ readonly introspectRequests: Array<{ token: string; hint: string | null; clientId: string | null }> = [];
246
+
247
+ // ── The billing rail's machine channel (the spec, §5 and §6) ──────────
248
+ /**
249
+ * `grant_type=client_credentials` on a registry tool client, scope
250
+ * `usage:write`: the token carries a client and no subject, and the fake
251
+ * refuses it on every route but `/api/v1/usage/*` — exactly the hub's
252
+ * stance. Client auth is basic or post; the secret is checked when the
253
+ * fake was started with one.
254
+ */
255
+ private readonly machineTokens = new Map<string, { expMs: number; scope: string }>();
256
+ /** How many machine tokens were minted (single-flight and refresh pins). */
257
+ machineTokenMints = 0;
258
+ /** Lifetime of a machine token (seconds; the real hub mints 900). */
259
+ machineTokenTtlSeconds = 900;
260
+ /** `POST /api/v1/usage/events` behavior; `http401_once` refuses the first post with 401 (a dead token), then answers. */
261
+ usageMode: 'ok' | 'http404' | 'http500' | 'network' | 'http400' | 'http401_once' = 'ok';
262
+ /** Every usage post: the presented Authorization header + the parsed body. */
263
+ readonly usageRequests: Array<{ auth: string | null; body: unknown }> = [];
264
+ /** id → the event as ingested (the dedupe table). */
265
+ readonly usageEvents = new Map<string, Record<string, unknown>>();
266
+ /** `GET /api/v1/usage/entitlements` behavior. */
267
+ entitlementsMode: 'ok' | 'http404' | 'http500' | 'network' = 'ok';
268
+ /** Every entitlements read: the accountRef asked and the Authorization header. */
269
+ readonly entitlementsRequests: Array<{ accountRef: string | null; auth: string | null }> = [];
270
+ /** accountRef → the profile the hub answers; an account not set here is `free` with no overrides. */
271
+ private readonly entitlementProfiles = new Map<
272
+ string,
273
+ { plan: 'free' | 'pro'; limits?: Record<string, number | boolean>; features?: string[] }
274
+ >();
275
+ /** `POST /api/v1/usage/check` behavior; `hang` parks the request (`tokenHangMs`). */
276
+ checkMode: 'ok' | 'http404' | 'http500' | 'network' | 'hang' = 'ok';
277
+ /** Hold every check answer this long. */
278
+ checkDelayMs = 0;
279
+ /** Every credit check: the presented Authorization header + the parsed body. */
280
+ readonly checkRequests: Array<{ auth: string | null; body: unknown }> = [];
281
+ /** actionKey → its price row (the hub's price book for this tool). */
282
+ private readonly prices = new Map<string, FakePriceRow>();
283
+ /** accountRef → credits; a known organization with no entry holds the sign-up grant. */
284
+ private readonly balances = new Map<string, number>();
285
+ /** Every balance movement, in order: the sign-up grant, manual grants, ingest debits. */
286
+ readonly ledger: Array<{
287
+ accountRef: string;
288
+ kind: 'signup' | 'manual' | 'debit';
289
+ amount: number;
290
+ sourceRef: string;
291
+ }> = [];
292
+
293
+ private constructor(
294
+ private readonly server: Server,
295
+ readonly issuer: string,
296
+ private keys: HubKey[],
297
+ private readonly configuredClientId: string | undefined,
298
+ private readonly configuredClientSecret: string | undefined,
299
+ private readonly configuredToolSlug: string | undefined
300
+ ) {}
301
+
302
+ /**
303
+ * `clientId` is the TOOL's OAuth client id at the hub (its `HUB_CLIENT_ID`):
304
+ * the client the fake mints a connect grant for, stamps as `azp`, and keys a
305
+ * grant family on when the request names none. The fake spells no tool's id:
306
+ * a suite that reaches one of those paths passes it, one that only needs an
307
+ * issuer may omit it.
308
+ */
309
+ static async start(
310
+ options: {
311
+ clientId?: string | undefined;
312
+ clientSecret?: string | undefined;
313
+ /**
314
+ * The tool's REGISTRY slug at the hub (`TOOL_REGISTRY[].slug`, the
315
+ * name the hub records on every usage row and judges a body's
316
+ * `toolSlug` against). Defaults to the client id without its `tool-`
317
+ * prefix (`tool-things-cloud` → `things-cloud`), which is what
318
+ * the production recipe and the drill harness name it — and NOT the
319
+ * tool's identity slug, so a body that stamps its own name is refused
320
+ * here exactly as the real hub refuses it (PRDCT-2629).
321
+ */
322
+ toolSlug?: string | undefined;
323
+ } = {}
324
+ ): Promise<FakeHub> {
325
+ const key = await newKey();
326
+ const server = createServer();
327
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
328
+ const address = server.address();
329
+ if (!address || typeof address === 'string') throw new Error('fake hub failed to bind');
330
+ const hub = new FakeHub(
331
+ server,
332
+ `http://127.0.0.1:${address.port}`,
333
+ [key],
334
+ options.clientId,
335
+ options.clientSecret,
336
+ options.toolSlug
337
+ );
338
+ server.on('request', (req, res) => void hub.handle(req, res));
339
+ return hub;
340
+ }
341
+
342
+ /** The tool's client id, for the paths that need one the request did not carry. */
343
+ private get clientId(): string {
344
+ if (!this.configuredClientId) {
345
+ throw new Error(
346
+ 'FakeHub: this path needs the tool client id — start it with FakeHub.start({ clientId })'
347
+ );
348
+ }
349
+ return this.configuredClientId;
350
+ }
351
+
352
+ async stop(): Promise<void> {
353
+ await new Promise<void>((resolve, reject) => this.server.close((err) => (err ? reject(err) : resolve())));
354
+ }
355
+
356
+ /** Rotate the signing key: new tokens sign with the new key; old key drops out of the JWKS. */
357
+ async rotateKey(): Promise<void> {
358
+ this.keys = [await newKey()];
359
+ }
360
+
361
+ /** The hub's own API resource identifier — the aud /api/v1 requires. */
362
+ get apiResource(): string {
363
+ return `${this.issuer}/mcp`;
364
+ }
365
+
366
+ /** The tool's registry slug: what every usage row carries, from the token, never from the body. */
367
+ get toolSlug(): string {
368
+ return this.configuredToolSlug ?? this.clientId.replace(/^tool-/, '');
369
+ }
370
+
371
+ // ── The org registry (what GET /orgs serves per sub) ──────────────────
372
+
373
+ setUserOrg(sub: string, orgId: string, entry: HubOrgEntry): void {
374
+ let orgs = this.userOrgs.get(sub);
375
+ if (!orgs) {
376
+ orgs = new Map();
377
+ this.userOrgs.set(sub, orgs);
378
+ }
379
+ orgs.set(orgId, entry);
380
+ }
381
+
382
+ removeUserOrg(sub: string, orgId: string): void {
383
+ this.userOrgs.get(sub)?.delete(orgId);
384
+ }
385
+
386
+ clearUserOrgs(sub: string): void {
387
+ this.userOrgs.delete(sub);
388
+ }
389
+
390
+ /** The organization's whole team list, as GET /teams serves it. */
391
+ setOrgTeams(orgId: string, teams: Array<{ id: string; slug: string; name: string }>): void {
392
+ this.orgTeamRegistry.set(orgId, teams);
393
+ }
394
+
395
+ /** Back to the default: the union of the members' own teams. */
396
+ clearOrgTeams(orgId: string): void {
397
+ this.orgTeamRegistry.delete(orgId);
398
+ }
399
+
400
+ /** The orgs GET /orgs names in `denied` for this sub (default none). */
401
+ setUserDenied(sub: string, denied: Array<{ id: string; name: string }>): void {
402
+ this.userDenied.set(sub, denied);
403
+ }
404
+
405
+ /**
406
+ * The hub-side "revoke this tool's access" action: tears down the user's
407
+ * refresh families AND forgets their live access tokens (emulating expiry
408
+ * — real JWTs die at their 15-minute exp; tests shouldn't wait).
409
+ */
410
+ revokeGrants(sub: string): void {
411
+ for (const [, record] of this.refreshTokens) {
412
+ if (record.sub === sub) this.deadFamilies.add(record.family);
413
+ }
414
+ for (const [token, record] of this.accessTokens) {
415
+ if (record.sub === sub) this.accessTokens.delete(token);
416
+ }
417
+ }
418
+
419
+ /** Number of refresh-grant presentations seen so far (single-flight pins). */
420
+ /** Whether reuse detection (or `revokeGrants`) tore this (sub, client) family down. */
421
+ isFamilyDead(sub: string, clientId = this.clientId): boolean {
422
+ return this.deadFamilies.has(`${sub}:${clientId}`);
423
+ }
424
+
425
+ refreshCount(): number {
426
+ return this.refreshRequests.length;
427
+ }
428
+
429
+ /** Register a one-shot authorization code for the given user fixture. */
430
+ /**
431
+ * `requestedScope` is the `scope` parameter of the tool's authorize URL
432
+ * (sso-helpers reads it off the real sign-in leg): like the real hub, the
433
+ * grant carries WHAT WAS REQUESTED and nothing else. A caller that skips
434
+ * it gets the legacy grant — the fake never hands out `orgs:create`
435
+ * unasked, so a tool that stops requesting it turns the create suites red.
436
+ */
437
+ mintCode(fixture: HubUserFixture, requestedScope?: string | null): string {
438
+ const code = `code_${randomUUID()}`;
439
+ this.codes.set(code, fixture);
440
+ this.codeScopes.set(code, fixture.overrides?.scope ?? requestedScope ?? LEGACY_GRANT_SCOPE);
441
+ return code;
442
+ }
443
+
444
+ /** Sign an access token directly (no HTTP dance); recorded as a live bearer. */
445
+ async signAccessToken(fixture: HubUserFixture, resource: string): Promise<string> {
446
+ return this.accessToken(fixture, resource, fixture.overrides?.scope ?? LEGACY_GRANT_SCOPE);
447
+ }
448
+
449
+ /**
450
+ * Sign an H3 exchange token — what the real hub's `POST /sso/tool-token`
451
+ * mints for the tool's `/sso/cli-connect` (the P5 pinned contract): a
452
+ * 120 s RS256 JWT, `aud` = the TOOL's resource URL (single string, no
453
+ * userinfo entry — this is not an OIDC access token), the transitional
454
+ * org-claims payload (the tool must IGNORE it — suites prove nothing
455
+ * reads it), `purpose: 'sso-connect'`, and a unique `jti`. Like the real
456
+ * H3 response, a `hubRefreshToken` rides along: a RAW one-time
457
+ * offline-grant refresh token in the ordinary (sub, client) rotation
458
+ * family — redeemable at this fake's token endpoint under the TOOL
459
+ * CLIENT's credentials, rotation + reuse detection included. And like
460
+ * the real hub (whose H3 mint proves a live membership of the target
461
+ * org), the fixture's org is seeded into the sub's registry so a
462
+ * subsequent as-the-user `GET /orgs` asserts it. `opts` bends the
463
+ * connect-specific claims for negative tests; `fixture.overrides` still
464
+ * bends iss/exp/aud like everywhere else.
465
+ */
466
+ async signConnectToken(
467
+ fixture: HubUserFixture,
468
+ resource: string,
469
+ opts: {
470
+ /** Explicit jti (replay tests share one); default = fresh unique. */
471
+ jti?: string | undefined;
472
+ /** true = omit the jti claim entirely. */
473
+ omitJti?: boolean | undefined;
474
+ /** Replacement purpose; null = omit the claim. Default 'sso-connect'. */
475
+ purpose?: string | null | undefined;
476
+ } = {}
477
+ ): Promise<{ token: string; jti: string; hubRefreshToken: string }> {
478
+ const key = this.signingKey();
479
+ const o = fixture.overrides ?? {};
480
+ const now = Math.floor(Date.now() / 1000);
481
+ const jti = opts.jti ?? `jti_${randomUUID()}`;
482
+ const purpose = opts.purpose === undefined ? 'sso-connect' : opts.purpose;
483
+ const token = await new SignJWT({
484
+ role: fixture.role,
485
+ workspace_id: fixture.workspaceId,
486
+ ...(fixture.workspaceName === null ? {} : { workspace_name: fixture.workspaceName ?? 'Fake Org' }),
487
+ email: fixture.email,
488
+ ...(purpose === null ? {} : { purpose }),
489
+ ...(opts.omitJti ? {} : { jti }),
490
+ aud: o.accessAud ?? resource
491
+ })
492
+ .setProtectedHeader({ alg: 'RS256', kid: key.kid })
493
+ .setSubject(fixture.sub)
494
+ .setIssuer(o.iss ?? this.issuer)
495
+ .setIssuedAt(now)
496
+ .setExpirationTime(now + (o.expiresInSeconds ?? 120))
497
+ .sign(key.privateKey);
498
+ // The org registry mirror of the real H3 membership precondition —
499
+ // registry-level extras survive, exactly like the code-exchange seed.
500
+ const previous = this.userOrgs.get(fixture.sub)?.get(fixture.workspaceId);
501
+ this.setUserOrg(fixture.sub, fixture.workspaceId, {
502
+ name: fixture.workspaceName === null ? null : (fixture.workspaceName ?? 'Fake Org'),
503
+ role: fixture.role,
504
+ ...(previous?.status !== undefined ? { status: previous.status } : {}),
505
+ ...(previous?.isDefault !== undefined ? { isDefault: previous.isDefault } : {}),
506
+ ...(previous?.teams !== undefined ? { teams: previous.teams } : {})
507
+ });
508
+ // A fresh H3 exchange is a FRESH grant, like a fresh consent: a past
509
+ // reuse-detection teardown must not shadow a new connect.
510
+ this.deadFamilies.delete(`${fixture.sub}:${this.clientId}`);
511
+ // The hub mints the H3 grant itself: the tool requests nothing here, so
512
+ // it carries the legacy scopes — never orgs:create.
513
+ const hubRefreshToken = this.mintRefreshToken(
514
+ fixture.sub,
515
+ this.clientId,
516
+ fixture.overrides?.scope ?? LEGACY_GRANT_SCOPE
517
+ );
518
+ return { token, jti, hubRefreshToken };
519
+ }
520
+
521
+ private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
522
+ const url = new URL(req.url ?? '/', this.issuer);
523
+ if (req.method === 'GET' && url.pathname === '/.well-known/openid-configuration') {
524
+ return sendJson(res, 200, {
525
+ issuer: this.issuer,
526
+ authorization_endpoint: `${this.issuer}/api/v1/auth/oauth2/authorize`,
527
+ token_endpoint: `${this.issuer}/api/v1/auth/oauth2/token`,
528
+ userinfo_endpoint: `${this.issuer}/api/v1/auth/oauth2/userinfo`,
529
+ jwks_uri: `${this.issuer}/api/v1/auth/jwks`,
530
+ // RP-initiated logout (SL-2): the real hub's oauth-provider plugin
531
+ // advertises this once end-session ships; the tool discovers it here
532
+ // and builds ?id_token_hint&post_logout_redirect_uri URLs against it.
533
+ end_session_endpoint: `${this.issuer}/api/v1/auth/oauth2/end-session`,
534
+ code_challenge_methods_supported: ['S256'],
535
+ id_token_signing_alg_values_supported: ['RS256']
536
+ });
537
+ }
538
+ if (req.method === 'GET' && url.pathname === '/api/v1/auth/jwks') {
539
+ return sendJson(res, 200, { keys: this.keys.map((k) => ({ ...k.publicJwk, kid: k.kid })) });
540
+ }
541
+ if (req.method === 'GET' && url.pathname === '/api/v1/orgs') {
542
+ return this.handleOrgs(req, res);
543
+ }
544
+ if (req.method === 'GET' && url.pathname === '/api/v1/teams') {
545
+ return this.handleTeams(req, res, url);
546
+ }
547
+ if (req.method === 'POST' && url.pathname === '/api/v1/orgs') {
548
+ return this.handleOrgCreate(req, res);
549
+ }
550
+ if (req.method === 'POST' && url.pathname === '/api/v1/auth/oauth2/token') {
551
+ return this.handleToken(req, res);
552
+ }
553
+ if (req.method === 'POST' && url.pathname === '/api/v1/auth/oauth2/introspect') {
554
+ return this.handleIntrospect(req, res);
555
+ }
556
+ if (req.method === 'POST' && url.pathname === '/api/v1/usage/events') {
557
+ return this.handleUsageEvents(req, res);
558
+ }
559
+ if (req.method === 'GET' && url.pathname === '/api/v1/usage/entitlements') {
560
+ return this.handleUsageEntitlements(req, res, url);
561
+ }
562
+ if (req.method === 'POST' && url.pathname === '/api/v1/usage/check') {
563
+ return this.handleUsageCheck(req, res);
564
+ }
565
+ sendJson(res, 404, { error: 'not_found' });
566
+ }
567
+
568
+ /** Set what `GET /usage/entitlements` answers for one account (the tier's defaults plus overrides). */
569
+ setEntitlements(
570
+ accountRef: string,
571
+ profile: { plan: 'free' | 'pro'; limits?: Record<string, number | boolean>; features?: string[] }
572
+ ): void {
573
+ this.entitlementProfiles.set(accountRef, profile);
574
+ }
575
+
576
+ // ── The price book and the balances (phase 2, PRDCT-2663) ─────────────
577
+
578
+ /** Set the price row of an action: `creditsPerUnit` credits per `per` units, after a monthly free allowance. */
579
+ setPrice(actionKey: string, row: FakePriceRow): void {
580
+ this.prices.set(actionKey, row);
581
+ }
582
+
583
+ clearPrices(): void {
584
+ this.prices.clear();
585
+ }
586
+
587
+ /** Set an organization's balance outright (no ledger entry). */
588
+ setBalance(accountRef: string, credits: number): void {
589
+ this.balances.set(accountRef, credits);
590
+ }
591
+
592
+ /** Add (or, negative, take) credits by hand: a `manual` ledger entry. */
593
+ grant(accountRef: string, delta: number): void {
594
+ this.balances.set(accountRef, this.balanceOf(accountRef) + delta);
595
+ this.ledger.push({
596
+ accountRef,
597
+ kind: 'manual',
598
+ amount: delta,
599
+ sourceRef: `manual-${this.ledger.length + 1}`
600
+ });
601
+ }
602
+
603
+ /**
604
+ * An organization's balance. One the fake knows (an org registry entry)
605
+ * with no balance set holds the sign-up grant, 5,000 credits, recorded as
606
+ * a `signup` ledger entry the first time it is read — like the real hub,
607
+ * which grants it at the organization's creation. Unknown: 0.
608
+ */
609
+ balanceOf(accountRef: string): number {
610
+ const held = this.balances.get(accountRef);
611
+ if (held !== undefined) return held;
612
+ if (!this.knownOrgs().has(accountRef)) return 0;
613
+ this.balances.set(accountRef, SIGNUP_GRANT);
614
+ this.ledger.push({ accountRef, kind: 'signup', amount: SIGNUP_GRANT, sourceRef: 'signup' });
615
+ return SIGNUP_GRANT;
616
+ }
617
+
618
+ private knownOrgs(): Set<string> {
619
+ const known = new Set<string>();
620
+ for (const orgs of this.userOrgs.values()) for (const id of orgs.keys()) known.add(id);
621
+ return known;
622
+ }
623
+
624
+ /**
625
+ * The price of a quantity of an action for an account, by the hub's rule:
626
+ * `ceil(max(0, quantity − freeLeft) / per) × creditsPerUnit`, where
627
+ * `freeLeft` is the monthly allowance minus the quantities already
628
+ * ingested for that account and action. The fake has NO month rollover:
629
+ * every event it ever ingested counts against the allowance.
630
+ */
631
+ private priceOf(
632
+ accountRef: string,
633
+ actionKey: string,
634
+ quantity: number
635
+ ): { credits: number; priced: boolean; unit: string | null } {
636
+ const row = this.prices.get(actionKey);
637
+ if (!row) return { credits: 0, priced: false, unit: null };
638
+ let used = 0;
639
+ for (const e of this.usageEvents.values()) {
640
+ if (
641
+ e.accountRef === accountRef &&
642
+ (e.actionKey ?? e.meter) === actionKey &&
643
+ typeof e.quantity === 'number'
644
+ ) {
645
+ used += e.quantity;
646
+ }
647
+ }
648
+ const freeLeft = Math.max(0, (row.freeQuantityPerMonth ?? 0) - used);
649
+ const billable = Math.max(0, quantity - freeLeft);
650
+ return {
651
+ credits: Math.ceil(billable / (row.per ?? 1)) * row.creditsPerUnit,
652
+ priced: true,
653
+ unit: row.unit
654
+ };
655
+ }
656
+
657
+ /**
658
+ * Forget every machine token: the next post answers 401 until re-minted.
659
+ * Models a REGISTRY REMOVAL or a rotated client secret, not a restart —
660
+ * the real hub persists its signing keys and never refuses a live JWT on
661
+ * restart (proven on the pair, lane C of the billing rail wave).
662
+ */
663
+ revokeMachineTokens(): void {
664
+ this.machineTokens.clear();
665
+ }
666
+
667
+ /** The machine-token check of `/api/v1/usage/*`: a live client_credentials token with `usage:write`. */
668
+ private machineBearer(req: IncomingMessage): boolean {
669
+ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null;
670
+ const record = bearer ? this.machineTokens.get(bearer) : undefined;
671
+ return Boolean(record && record.expMs > Date.now() && record.scope.split(' ').includes('usage:write'));
672
+ }
673
+
674
+ // ── POST /api/v1/usage/events: at-least-once in, exactly-once by id ─────
675
+ private async handleUsageEvents(req: IncomingMessage, res: ServerResponse): Promise<void> {
676
+ let body: unknown = null;
677
+ try {
678
+ body = JSON.parse(await readBody(req));
679
+ } catch {
680
+ body = null;
681
+ }
682
+ this.usageRequests.push({ auth: req.headers.authorization ?? null, body });
683
+ if (this.usageMode === 'network') {
684
+ req.destroy();
685
+ return;
686
+ }
687
+ if (this.usageMode === 'http404') return sendJson(res, 404, { error: { code: 'not_found' } });
688
+ if (this.usageMode === 'http500') return sendJson(res, 500, { error: { code: 'internal' } });
689
+ if (this.usageMode === 'http400') {
690
+ return sendJson(res, 400, { error: { code: 'validation_error', message: 'injected' } });
691
+ }
692
+ if (this.usageMode === 'http401_once') {
693
+ this.usageMode = 'ok';
694
+ return sendJson(res, 401, { error: { code: 'invalid_token', message: 'Unauthorized' } });
695
+ }
696
+ if (!this.machineBearer(req)) {
697
+ return sendJson(res, 401, { error: { code: 'invalid_token', message: 'Unauthorized' } });
698
+ }
699
+ const batch = usageEventsBatchSchema.safeParse(body);
700
+ if (!batch.success) {
701
+ return sendJson(res, 400, { error: { code: 'validation_error', message: '1 to 500 events' } });
702
+ }
703
+ const { events } = batch.data;
704
+ // The real hub's per-element judgement (PRDCT-2625, `classifyBatch` in
705
+ // its api/usage.ts), in its order: each element parses on its own
706
+ // against the hub's schema (the chassis-contract copy, checked against the hub's wire snapshot: the
707
+ // uuid account reference, the maximum lengths, the action key or its
708
+ // alias — PRDCT-2629), a body that names a tool other than the token's
709
+ // registry slug is `tool_mismatch`, the account must be an organization
710
+ // this hub holds, a named user must be a hub user, a duplicate id is
711
+ // never an error.
712
+ const knownOrgs = this.knownOrgs();
713
+ const results = events.map((raw: unknown) => {
714
+ const parsed = usageEventSchema.safeParse(raw);
715
+ if (!parsed.success) {
716
+ const rawId = (raw as { id?: unknown } | null)?.id;
717
+ return {
718
+ id: typeof rawId === 'string' ? rawId : null,
719
+ status: 'rejected' as const,
720
+ reason: 'invalid_event',
721
+ details: parsed.error.issues.map((issue) => ({
722
+ path: issue.path.join('.'),
723
+ message: issue.message
724
+ }))
725
+ };
726
+ }
727
+ const e = parsed.data;
728
+ const id = e.id;
729
+ if (e.toolSlug !== undefined && e.toolSlug !== this.toolSlug) {
730
+ return { id, status: 'rejected' as const, reason: 'tool_mismatch' };
731
+ }
732
+ if (!knownOrgs.has(e.accountRef)) return { id, status: 'rejected' as const, reason: 'unknown_account' };
733
+ if (e.userId != null && !this.userOrgs.has(e.userId)) {
734
+ return { id, status: 'rejected' as const, reason: 'unknown_user' };
735
+ }
736
+ if (this.usageEvents.has(id)) return { id, status: 'duplicate' as const };
737
+ // Priced BEFORE it is recorded, so its own quantity is not counted
738
+ // against the free allowance twice; the debit lands with it.
739
+ const { credits } = this.priceOf(e.accountRef, e.actionKey ?? e.meter!, e.quantity);
740
+ this.usageEvents.set(id, { ...(raw as Record<string, unknown>), toolSlug: this.toolSlug });
741
+ if (credits > 0) {
742
+ this.balances.set(e.accountRef, this.balanceOf(e.accountRef) - credits);
743
+ this.ledger.push({ accountRef: e.accountRef, kind: 'debit', amount: -credits, sourceRef: id });
744
+ }
745
+ return { id, status: 'accepted' as const, credits };
746
+ });
747
+ const count = (status: string) => results.filter((r) => r.status === status).length;
748
+ return sendJson(
749
+ res,
750
+ 200,
751
+ usageIngestResultSchema.parse({
752
+ results,
753
+ accepted: count('accepted'),
754
+ duplicate: count('duplicate'),
755
+ rejected: count('rejected')
756
+ })
757
+ );
758
+ }
759
+
760
+ // ── GET /api/v1/usage/entitlements?accountRef=: the account's plan ─────
761
+ private async handleUsageEntitlements(req: IncomingMessage, res: ServerResponse, url: URL): Promise<void> {
762
+ const accountRef = url.searchParams.get('accountRef');
763
+ this.entitlementsRequests.push({ accountRef, auth: req.headers.authorization ?? null });
764
+ if (this.entitlementsMode === 'network') {
765
+ req.destroy();
766
+ return;
767
+ }
768
+ if (this.entitlementsMode === 'http404') return sendJson(res, 404, { error: { code: 'not_found' } });
769
+ if (this.entitlementsMode === 'http500') return sendJson(res, 500, { error: { code: 'internal' } });
770
+ if (!this.machineBearer(req)) {
771
+ return sendJson(res, 401, { error: { code: 'invalid_token', message: 'Unauthorized' } });
772
+ }
773
+ // The real hub validates the query (`accountRef` a uuid): 400, not 404.
774
+ if (!accountRef || !UUID_RE.test(accountRef))
775
+ return sendJson(res, 400, {
776
+ error: { code: 'validation_error', message: 'accountRef must be a uuid' }
777
+ });
778
+ if (!this.knownOrgs().has(accountRef) && !this.entitlementProfiles.has(accountRef)) {
779
+ return sendJson(res, 404, {
780
+ error: { code: 'unknown_account', message: 'no organization holds this id' }
781
+ });
782
+ }
783
+ const profile = this.entitlementProfiles.get(accountRef) ?? { plan: 'free' as const };
784
+ return sendJson(
785
+ res,
786
+ 200,
787
+ entitlementProfileSchema.parse({
788
+ accountRef,
789
+ plan: profile.plan,
790
+ planUntil: null,
791
+ limits: profile.limits ?? {},
792
+ features: profile.features ?? [],
793
+ upgradeUrl: `${this.issuer}/billing/upgrade?org=${accountRef}&tool=${this.toolSlug}&plan=${profile.plan}`
794
+ })
795
+ );
796
+ }
797
+
798
+ // ── POST /api/v1/usage/check: the price and the balance, no side effect ─
799
+ private async handleUsageCheck(req: IncomingMessage, res: ServerResponse): Promise<void> {
800
+ let body: unknown = null;
801
+ try {
802
+ body = JSON.parse(await readBody(req));
803
+ } catch {
804
+ body = null;
805
+ }
806
+ this.checkRequests.push({ auth: req.headers.authorization ?? null, body });
807
+ if (this.checkDelayMs > 0) await new Promise((r) => setTimeout(r, this.checkDelayMs));
808
+ if (this.checkMode === 'network') {
809
+ req.destroy();
810
+ return;
811
+ }
812
+ if (this.checkMode === 'hang') return this.park(res);
813
+ if (this.checkMode === 'http404') return sendJson(res, 404, { error: { code: 'not_found' } });
814
+ if (this.checkMode === 'http500') return sendJson(res, 500, { error: { code: 'internal' } });
815
+ if (!this.machineBearer(req)) {
816
+ return sendJson(res, 401, { error: { code: 'invalid_token', message: 'Unauthorized' } });
817
+ }
818
+ const parsed = usageCheckRequestSchema.safeParse(body);
819
+ if (!parsed.success) {
820
+ return sendJson(res, 400, {
821
+ error: {
822
+ code: 'validation_error',
823
+ message: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')
824
+ }
825
+ });
826
+ }
827
+ const { accountRef, actionKey, quantity } = parsed.data;
828
+ const orgKnown = this.knownOrgs().has(accountRef);
829
+ if (!orgKnown && !this.balances.has(accountRef)) {
830
+ return sendJson(res, 404, {
831
+ error: { code: 'unknown_account', message: 'no organization holds this id' }
832
+ });
833
+ }
834
+ let suspended = false;
835
+ for (const orgs of this.userOrgs.values()) {
836
+ if (orgs.get(accountRef)?.status === 'suspended') suspended = true;
837
+ }
838
+ const priced = this.priceOf(accountRef, actionKey, quantity);
839
+ const { unit } = priced;
840
+ // The hub's rule (its api/usage.ts): a price past the exactly
841
+ // representable range is answered as `unpriceable`, the credits clamped
842
+ // to the bound (PRDCT-2677).
843
+ const unpriceableNow = priced.credits > Number.MAX_SAFE_INTEGER;
844
+ const credits = unpriceableNow ? Number.MAX_SAFE_INTEGER : priced.credits;
845
+ const balance = this.balanceOf(accountRef);
846
+ // The hub's rule since its fix round (lane A, 23 September, ce2ce1b): a
847
+ // 0-credit action is allowed whatever the balance, negative included; an
848
+ // unpriced action is never refused. Suspension is tested first.
849
+ const covered = credits === 0 || balance >= credits;
850
+ const reason = suspended
851
+ ? ('account_suspended' as const)
852
+ : unpriceableNow
853
+ ? ('unpriceable' as const)
854
+ : covered
855
+ ? null
856
+ : ('insufficient_credits' as const);
857
+ return sendJson(
858
+ res,
859
+ 200,
860
+ usageCheckSchema.parse({
861
+ accountRef,
862
+ actionKey,
863
+ quantity,
864
+ allowed: reason === null,
865
+ credits,
866
+ balance,
867
+ unit,
868
+ priced: priced.priced,
869
+ plan: this.entitlementProfiles.get(accountRef)?.plan ?? 'free',
870
+ reason,
871
+ topUpUrl: `${this.issuer}/billing/top-up?org=${accountRef}&credits=${credits}&balance=${balance}&tool=${this.toolSlug}&action=${actionKey}`
872
+ })
873
+ );
874
+ }
875
+
876
+ // ── GET /api/v1/orgs: the caller-scoped org list ──────────────────────
877
+ private async handleOrgs(req: IncomingMessage, res: ServerResponse): Promise<void> {
878
+ this.orgsRequests.push({ auth: req.headers.authorization ?? null });
879
+ if (this.orgsDelayMs > 0) await new Promise((r) => setTimeout(r, this.orgsDelayMs));
880
+ if (this.orgsMode === 'network') {
881
+ req.destroy(); // mid-request connection failure, no HTTP answer
882
+ return;
883
+ }
884
+ if (this.orgsMode === 'http500') return sendJson(res, 500, { error: { code: 'internal' } });
885
+ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null;
886
+ const record = bearer ? this.accessTokens.get(bearer) : undefined;
887
+ // The real hub's OauthJwtVerifier: JWT it minted, unexpired, aud pinned
888
+ // to ITS OWN resource. An opaque/unknown/expired/foreign-aud bearer is
889
+ // one uniform 401 — exactly what a tool-audienced token gets.
890
+ if (!record || record.expMs <= Date.now() || !record.aud.includes(this.apiResource)) {
891
+ return sendJson(res, 401, { error: { code: 'invalid_token', message: 'Unauthorized' } });
892
+ }
893
+ const orgs = [...(this.userOrgs.get(record.sub) ?? new Map<string, HubOrgEntry>())].map(
894
+ ([id, entry]) => ({
895
+ id,
896
+ name: entry.name,
897
+ role: entry.role,
898
+ personal: false,
899
+ status: entry.status ?? 'active',
900
+ isDefault: entry.isDefault ?? false,
901
+ createdAt: new Date(0).toISOString(),
902
+ teams: entry.teams ?? []
903
+ })
904
+ );
905
+ return sendJson(res, 200, { orgs, denied: this.userDenied.get(record.sub) ?? [] });
906
+ }
907
+
908
+ // ── GET /api/v1/teams: the organization's whole team list ─────────────
909
+ // Mirrors the hub's api/teams.ts: the org is the one `X-Workspace-Id`
910
+ // names, any member of it reads the list, paged by `limit` / `cursor`.
911
+ private async handleTeams(req: IncomingMessage, res: ServerResponse, url: URL): Promise<void> {
912
+ const header = req.headers['x-workspace-id'];
913
+ const orgId = (Array.isArray(header) ? header[0] : header) ?? null;
914
+ this.teamsRequests.push({ auth: req.headers.authorization ?? null, orgId });
915
+ if (this.teamsDelayMs > 0) await new Promise((r) => setTimeout(r, this.teamsDelayMs));
916
+ if (this.teamsMode === 'network') {
917
+ req.destroy();
918
+ return;
919
+ }
920
+ if (this.teamsMode === 'http500') return sendJson(res, 500, { error: { code: 'internal' } });
921
+ if (this.teamsMode === 'http404') return sendJson(res, 404, { error: 'not_found' });
922
+ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null;
923
+ const record = bearer ? this.accessTokens.get(bearer) : undefined;
924
+ if (!record || record.expMs <= Date.now() || !record.aud.includes(this.apiResource)) {
925
+ return sendJson(res, 401, { error: { code: 'invalid_token', message: 'Unauthorized' } });
926
+ }
927
+ if (!orgId || !this.userOrgs.get(record.sub)?.has(orgId)) {
928
+ return sendJson(res, 403, { error: { code: 'workspace_mismatch' } });
929
+ }
930
+ let all = this.orgTeamRegistry.get(orgId);
931
+ if (!all) {
932
+ const union = new Map<string, { id: string; slug: string; name: string }>();
933
+ for (const orgs of this.userOrgs.values()) {
934
+ for (const team of orgs.get(orgId)?.teams ?? []) if (!union.has(team.id)) union.set(team.id, team);
935
+ }
936
+ all = [...union.values()];
937
+ }
938
+ if (this.teamsCursorLost && url.searchParams.has('cursor')) {
939
+ return sendJson(res, 200, { teams: [], nextCursor: null });
940
+ }
941
+ const rawLimit = Number(url.searchParams.get('limit') ?? 50);
942
+ const limit = Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 100) : 50;
943
+ const start = Number(url.searchParams.get('cursor') ?? 0) || 0;
944
+ const page = all.slice(start, start + limit);
945
+ const stamp = new Date(0).toISOString();
946
+ return sendJson(res, 200, {
947
+ teams: page.map((team) => ({
948
+ id: team.id,
949
+ slug: team.slug,
950
+ name: team.name,
951
+ membersCount: 0,
952
+ isMember: false,
953
+ createdAt: stamp,
954
+ updatedAt: stamp
955
+ })),
956
+ nextCursor: start + limit < all.length ? String(start + limit) : null
957
+ });
958
+ }
959
+
960
+ // ── POST /api/v1/orgs: create an org AS THE BEARER ────────────────────
961
+ // Mirrors the hub's api/orgs.ts: the bearer's subject becomes the owner —
962
+ // the body carries a name and nothing that could name another user.
963
+ private async handleOrgCreate(req: IncomingMessage, res: ServerResponse): Promise<void> {
964
+ let body: unknown = null;
965
+ try {
966
+ body = JSON.parse(await readBody(req));
967
+ } catch {
968
+ body = null;
969
+ }
970
+ this.orgCreateRequests.push({ auth: req.headers.authorization ?? null, body });
971
+ if (this.orgCreateMode === 'network') {
972
+ req.destroy();
973
+ return;
974
+ }
975
+ if (this.orgCreateMode === 'http500') return sendJson(res, 500, { error: { code: 'internal' } });
976
+ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null;
977
+ const record = bearer ? this.accessTokens.get(bearer) : undefined;
978
+ if (!record || record.expMs <= Date.now() || !record.aud.includes(this.apiResource)) {
979
+ return sendJson(res, 401, { error: { code: 'invalid_token', message: 'Unauthorized' } });
980
+ }
981
+ // The real hub's entrance rule (api/orgs.ts `orgCreateRefusal`): a tool
982
+ // grant creates an organization ONLY with the dedicated scope. A legacy
983
+ // grant (account:read), and one carrying account:write, are refused the
984
+ // same way — 403 insufficient_scope, before the cap is even looked at.
985
+ if (!record.scope.split(' ').includes(ORG_CREATE_SCOPE)) {
986
+ return sendJson(res, 403, {
987
+ error: {
988
+ code: 'insufficient_scope',
989
+ message: `This credential was not granted "${ORG_CREATE_SCOPE}"`
990
+ }
991
+ });
992
+ }
993
+ if (this.orgCreateMode === 'limit') {
994
+ return sendJson(res, 403, { error: { code: 'org_limit_reached', message: 'cap' } });
995
+ }
996
+ if (this.orgCreateMode === 'forbidden') {
997
+ return sendJson(res, 403, { error: { code: 'forbidden', message: 'sessions only' } });
998
+ }
999
+ const name = (body as { name?: unknown } | null)?.name;
1000
+ if (typeof name !== 'string' || !name.trim()) {
1001
+ return sendJson(res, 400, { error: { code: 'validation_error', message: 'name' } });
1002
+ }
1003
+ const id = randomUUID();
1004
+ this.setUserOrg(record.sub, id, { name: name.trim(), role: 'owner' });
1005
+ if (this.orgCreateMode === 'commit_then_500') {
1006
+ return sendJson(res, 500, { error: { code: 'internal' } });
1007
+ }
1008
+ return sendJson(res, 201, {
1009
+ org: {
1010
+ id,
1011
+ name: name.trim(),
1012
+ role: 'owner',
1013
+ personal: false,
1014
+ status: 'active',
1015
+ isDefault: false,
1016
+ createdAt: new Date().toISOString()
1017
+ }
1018
+ });
1019
+ }
1020
+
1021
+ /** The org ids the hub holds for a subject (what a test asserts creation against). */
1022
+ orgIdsOf(sub: string): string[] {
1023
+ return [...(this.userOrgs.get(sub)?.keys() ?? [])];
1024
+ }
1025
+
1026
+ // ── POST /api/v1/auth/oauth2/token: both grants ───────────────────────
1027
+ private async handleToken(req: IncomingMessage, res: ServerResponse): Promise<void> {
1028
+ const body = new URLSearchParams(await readBody(req));
1029
+ this.lastTokenRequest = body;
1030
+ if (this.tokenDelayMs > 0) await new Promise((r) => setTimeout(r, this.tokenDelayMs));
1031
+ if (this.tokenMode === 'network') {
1032
+ req.destroy();
1033
+ return;
1034
+ }
1035
+ if (this.tokenMode === 'http500') return sendJson(res, 500, { error: { code: 'internal' } });
1036
+ if (this.tokenMode === 'invalid_grant') {
1037
+ return sendJson(res, 400, { error: 'invalid_grant', error_description: 'injected' });
1038
+ }
1039
+ if (this.tokenMode === 'invalid_client') {
1040
+ return sendJson(res, 401, { error: 'invalid_client', error_description: 'injected' });
1041
+ }
1042
+ if (this.tokenMode === 'hang') return this.park(res);
1043
+
1044
+ const grantType = body.get('grant_type') ?? (body.get('code') ? 'authorization_code' : '');
1045
+ if (grantType === 'refresh_token') return this.handleRefreshGrant(body, res);
1046
+ if (grantType === 'client_credentials') return this.handleClientCredentials(req, body, res);
1047
+
1048
+ const fixture = this.codes.get(body.get('code') ?? '');
1049
+ if (!fixture) {
1050
+ return sendJson(res, 400, { error: 'invalid_grant', error_description: 'unknown code' });
1051
+ }
1052
+ const grantScope = this.codeScopes.get(body.get('code')!) ?? LEGACY_GRANT_SCOPE;
1053
+ this.codes.delete(body.get('code')!); // one-shot, like the real thing
1054
+ this.codeScopes.delete(body.get('code')!);
1055
+ // The code's fixture seeds the sub's org registry (the hub knows the
1056
+ // orgs its own users SSO from) — name/role from the fixture, while the
1057
+ // registry-level extras (`status`, `isDefault`, `teams` — managed via
1058
+ // `setUserOrg`) survive a re-login's re-seed.
1059
+ const previous = this.userOrgs.get(fixture.sub)?.get(fixture.workspaceId);
1060
+ this.setUserOrg(fixture.sub, fixture.workspaceId, {
1061
+ name: fixture.workspaceName === null ? null : (fixture.workspaceName ?? 'Fake Org'),
1062
+ role: fixture.role,
1063
+ ...(previous?.status !== undefined ? { status: previous.status } : {}),
1064
+ ...(previous?.isDefault !== undefined ? { isDefault: previous.isDefault } : {}),
1065
+ ...(previous?.teams !== undefined ? { teams: previous.teams } : {})
1066
+ });
1067
+ const clientId = body.get('client_id') ?? this.clientId;
1068
+ // A fresh code exchange is a FRESH grant: the real hub's new consent
1069
+ // mints a new family — a past reuse-detection teardown does not shadow
1070
+ // a re-login (the hub_grant_expired → browser-re-login heal path).
1071
+ this.deadFamilies.delete(`${fixture.sub}:${clientId}`);
1072
+ const resource = body.get('resource');
1073
+ // JWT iff the token request carries `resource` (checkResource →
1074
+ // isJwtAccessToken on the real hub); otherwise opaque.
1075
+ const accessToken =
1076
+ resource && !fixture.overrides?.forceOpaque
1077
+ ? await this.accessToken(fixture, resource, grantScope)
1078
+ : `opaque_${randomUUID()}`;
1079
+ const refreshToken = this.mintRefreshToken(fixture.sub, clientId, grantScope);
1080
+ return sendJson(res, 200, {
1081
+ access_token: accessToken,
1082
+ id_token: await this.idToken(fixture, clientId),
1083
+ refresh_token: refreshToken,
1084
+ token_type: 'Bearer',
1085
+ expires_in: fixture.overrides?.expiresInSeconds ?? this.accessTokenTtlSeconds,
1086
+ scope: grantScope
1087
+ });
1088
+ }
1089
+
1090
+ /**
1091
+ * The registry client's machine grant (the billing rail, §6): client auth
1092
+ * (basic, or client_id + client_secret in the body) on THIS tool's client,
1093
+ * scope `usage:write` only. Answers a bearer with no subject.
1094
+ */
1095
+ private handleClientCredentials(req: IncomingMessage, body: URLSearchParams, res: ServerResponse): void {
1096
+ const basic = /^Basic\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null;
1097
+ let clientId = body.get('client_id');
1098
+ let clientSecret = body.get('client_secret');
1099
+ if (basic) {
1100
+ const decoded = Buffer.from(basic, 'base64').toString('utf8');
1101
+ const colon = decoded.indexOf(':');
1102
+ if (colon > 0) {
1103
+ clientId = decodeURIComponent(decoded.slice(0, colon));
1104
+ clientSecret = decodeURIComponent(decoded.slice(colon + 1));
1105
+ }
1106
+ }
1107
+ if (!clientId || !clientSecret || clientId !== this.clientId) {
1108
+ return sendJson(res, 401, { error: 'invalid_client', error_description: 'client auth required' });
1109
+ }
1110
+ if (this.configuredClientSecret !== undefined && clientSecret !== this.configuredClientSecret) {
1111
+ return sendJson(res, 401, { error: 'invalid_client', error_description: 'bad secret' });
1112
+ }
1113
+ // The real hub (PRDCT-2625): the scope must be EXACTLY usage:write, and
1114
+ // the RFC 8707 resource must be the hub's own API resource.
1115
+ if (body.get('scope') !== 'usage:write') {
1116
+ return sendJson(res, 400, { error: 'invalid_scope', error_description: 'usage:write only' });
1117
+ }
1118
+ if (body.get('resource') !== this.apiResource) {
1119
+ return sendJson(res, 400, { error: 'invalid_target', error_description: 'resource must be the hub' });
1120
+ }
1121
+ this.machineTokenMints += 1;
1122
+ const token = `mach_${randomUUID()}`;
1123
+ this.machineTokens.set(token, {
1124
+ expMs: Date.now() + this.machineTokenTtlSeconds * 1000,
1125
+ scope: 'usage:write'
1126
+ });
1127
+ return sendJson(res, 200, {
1128
+ access_token: token,
1129
+ token_type: 'Bearer',
1130
+ expires_in: this.machineTokenTtlSeconds,
1131
+ scope: 'usage:write'
1132
+ });
1133
+ }
1134
+
1135
+ private handleRefreshGrant(body: URLSearchParams, res: ServerResponse): void {
1136
+ const presented = body.get('refresh_token') ?? '';
1137
+ const clientId = body.get('client_id');
1138
+ this.refreshRequests.push({
1139
+ refreshToken: presented,
1140
+ resource: body.get('resource'),
1141
+ clientId
1142
+ });
1143
+ if (!clientId || !body.get('client_secret')) {
1144
+ return sendJson(res, 401, { error: 'invalid_client', error_description: 'client auth required' });
1145
+ }
1146
+ const record = this.refreshTokens.get(presented);
1147
+ if (!record) {
1148
+ return sendJson(res, 400, { error: 'invalid_grant', error_description: 'unknown refresh token' });
1149
+ }
1150
+ if (this.deadFamilies.has(record.family)) {
1151
+ return sendJson(res, 400, { error: 'invalid_grant', error_description: 'grant family revoked' });
1152
+ }
1153
+ if (record.rotatedOut) {
1154
+ // RFC 9700 REUSE DETECTION: presenting a rotated-out token is treated
1155
+ // as theft — the WHOLE (sub, client) family dies, current token
1156
+ // included. The single most important behavior this fake models.
1157
+ this.deadFamilies.add(record.family);
1158
+ return sendJson(res, 400, {
1159
+ error: 'invalid_grant',
1160
+ error_description: 'reuse detected — family revoked'
1161
+ });
1162
+ }
1163
+ record.rotatedOut = true;
1164
+ const rotated = this.mintRefreshToken(record.sub, record.clientId, record.scope);
1165
+ // The slow-but-alive hub: the rotation above is committed and the
1166
+ // answer never leaves. Whoever presented `presented` now holds a
1167
+ // rotated-out token without knowing it.
1168
+ if (this.tokenMode === 'commit_then_hang') return this.park(res);
1169
+ const resource = body.get('resource');
1170
+ const respond = async () => {
1171
+ const accessToken = resource
1172
+ ? await this.accessToken(
1173
+ { sub: record.sub, email: 'refresh@fake.hub', workspaceId: '', role: 'member' },
1174
+ resource,
1175
+ record.scope
1176
+ )
1177
+ : `opaque_${randomUUID()}`;
1178
+ sendJson(res, 200, {
1179
+ access_token: accessToken,
1180
+ refresh_token: rotated,
1181
+ token_type: 'Bearer',
1182
+ expires_in: this.accessTokenTtlSeconds,
1183
+ scope: record.scope
1184
+ });
1185
+ };
1186
+ void respond();
1187
+ }
1188
+
1189
+ /** Never answer; give up with a 504 long after any client timeout (nothing observes it). */
1190
+ private park(res: ServerResponse): void {
1191
+ const t = setTimeout(() => {
1192
+ if (!res.writableEnded) sendJson(res, 504, { error: 'parked' });
1193
+ }, this.tokenHangMs);
1194
+ t.unref();
1195
+ }
1196
+
1197
+ // ── POST /api/v1/auth/oauth2/introspect: RFC 7662, read-only ──────────
1198
+ private async handleIntrospect(req: IncomingMessage, res: ServerResponse): Promise<void> {
1199
+ const body = new URLSearchParams(await readBody(req));
1200
+ const token = body.get('token') ?? '';
1201
+ const clientId = body.get('client_id');
1202
+ this.introspectRequests.push({ token, hint: body.get('token_type_hint'), clientId });
1203
+ if (this.introspectDelayMs > 0) await new Promise((r) => setTimeout(r, this.introspectDelayMs));
1204
+ if (this.introspectMode === 'invalid_client') {
1205
+ return sendJson(res, 401, { error: 'invalid_client', error_description: 'injected' });
1206
+ }
1207
+ if (this.introspectMode === 'malformed') return sendJson(res, 200, { hello: 'world' });
1208
+ if (this.introspectMode === 'network') {
1209
+ req.destroy();
1210
+ return;
1211
+ }
1212
+ if (this.introspectMode === 'http500') return sendJson(res, 500, { error: { code: 'internal' } });
1213
+ if (!clientId || !body.get('client_secret')) {
1214
+ return sendJson(res, 401, { error: 'invalid_client', error_description: 'client auth required' });
1215
+ }
1216
+ // Refresh tokens only (the grant service's probe always hints it); an
1217
+ // access token is out of scope for this fake's introspection.
1218
+ const record = this.refreshTokens.get(token);
1219
+ if (
1220
+ !record ||
1221
+ record.clientId !== clientId ||
1222
+ record.rotatedOut ||
1223
+ this.deadFamilies.has(record.family)
1224
+ ) {
1225
+ return sendJson(res, 200, { active: false });
1226
+ }
1227
+ return sendJson(res, 200, {
1228
+ active: true,
1229
+ client_id: clientId,
1230
+ sub: record.sub,
1231
+ token_type: 'refresh_token'
1232
+ });
1233
+ }
1234
+
1235
+ private mintRefreshToken(sub: string, clientId: string, scope: string): string {
1236
+ const token = `rt_${randomUUID()}`;
1237
+ this.refreshTokens.set(token, { sub, clientId, family: `${sub}:${clientId}`, rotatedOut: false, scope });
1238
+ return token;
1239
+ }
1240
+
1241
+ private signingKey(): HubKey {
1242
+ const key = this.keys[0];
1243
+ if (!key) throw new Error('fake hub has no signing key');
1244
+ return key;
1245
+ }
1246
+
1247
+ private async accessToken(fixture: HubUserFixture, resource: string, scope: string): Promise<string> {
1248
+ const key = this.signingKey();
1249
+ const o = fixture.overrides ?? {};
1250
+ const rawAud = o.accessAud ?? [resource, `${this.issuer}/api/v1/auth/oauth2/userinfo`];
1251
+ const aud = Array.isArray(rawAud) ? rawAud : [rawAud];
1252
+ const now = Math.floor(Date.now() / 1000);
1253
+ const exp = now + (o.expiresInSeconds ?? this.accessTokenTtlSeconds);
1254
+ const token = await new SignJWT({
1255
+ // TRANSITIONAL advisory org claims (compat window) — never read by
1256
+ // the new login path; suites prove it.
1257
+ role: fixture.role,
1258
+ ...(fixture.workspaceId ? { workspace_id: fixture.workspaceId } : {}),
1259
+ ...(fixture.workspaceName === null ? {} : { workspace_name: fixture.workspaceName ?? 'Fake Org' }),
1260
+ email: fixture.email,
1261
+ azp: this.clientId,
1262
+ scope,
1263
+ aud
1264
+ })
1265
+ .setProtectedHeader({ alg: 'RS256', kid: key.kid })
1266
+ .setSubject(fixture.sub)
1267
+ .setIssuer(o.iss ?? this.issuer)
1268
+ .setIssuedAt(now)
1269
+ .setExpirationTime(exp)
1270
+ .sign(key.privateKey);
1271
+ // Recorded so /api/v1/orgs can resolve the bearer to its subject —
1272
+ // with the REAL exp/aud so wrong-audience and expiry refuse honestly.
1273
+ this.accessTokens.set(token, { sub: fixture.sub, aud, expMs: exp * 1000, scope });
1274
+ return token;
1275
+ }
1276
+
1277
+ private async idToken(fixture: HubUserFixture, clientId: string): Promise<string> {
1278
+ const key = this.signingKey();
1279
+ const o = fixture.overrides ?? {};
1280
+ const now = Math.floor(Date.now() / 1000);
1281
+ return new SignJWT({
1282
+ name: fixture.name ?? 'Fake Hub User',
1283
+ email: fixture.email,
1284
+ email_verified: fixture.emailVerified ?? true,
1285
+ ...(o.idTokenSid ? { sid: o.idTokenSid } : {}),
1286
+ aud: clientId
1287
+ })
1288
+ .setProtectedHeader({ alg: 'RS256', kid: key.kid })
1289
+ .setSubject(o.idTokenSub ?? fixture.sub)
1290
+ .setIssuer(o.iss ?? this.issuer)
1291
+ .setIssuedAt(now)
1292
+ .setExpirationTime(now + (o.expiresInSeconds ?? 3600))
1293
+ .sign(key.privateKey);
1294
+ }
1295
+ }
1296
+
1297
+ async function newKey(): Promise<HubKey> {
1298
+ const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true });
1299
+ const publicJwk = await exportJWK(publicKey);
1300
+ return { kid: `kid_${randomUUID()}`, privateKey, publicJwk };
1301
+ }
1302
+
1303
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
1304
+ const payload = JSON.stringify(body);
1305
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) });
1306
+ res.end(payload);
1307
+ }
1308
+
1309
+ function readBody(req: IncomingMessage): Promise<string> {
1310
+ return new Promise((resolve, reject) => {
1311
+ const chunks: Buffer[] = [];
1312
+ req.on('data', (c: Buffer) => chunks.push(c));
1313
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
1314
+ req.on('error', reject);
1315
+ });
1316
+ }