@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,1403 @@
1
+ import { OpenAPIHono } from '@hono/zod-openapi';
2
+ import type { Context, MiddlewareHandler } from 'hono';
3
+ import { matchedRoutes } from 'hono/route';
4
+ import { createEmailVerificationToken } from 'better-auth/api';
5
+ import { jwtVerify } from 'jose';
6
+ import { registerOpenApiDoc } from './index.js';
7
+ import { ulid } from 'ulid';
8
+ import { and, asc, desc, eq } from 'drizzle-orm';
9
+ import { ACTIVE_WORKSPACE_HEADER, type Principal } from '@antasphere/chassis-contract';
10
+ import { instanceRoute, setupRoute } from '@antasphere/chassis-contract/routes';
11
+ import {
12
+ account,
13
+ instanceSettings,
14
+ user as userTable,
15
+ userOnboarding,
16
+ workspaceMembers,
17
+ workspaces,
18
+ type Db
19
+ } from '@antasphere/chassis-db';
20
+ import type { z } from 'zod';
21
+ import { hubConfig, type ToolEnv } from '../env.js';
22
+ import type { Logger } from '../logger.js';
23
+ import type { Auth } from '../identity/index.js';
24
+ import {
25
+ DEMO_SESSION_REFUSED_API_ROUTES,
26
+ demoSessionApiRefusalMessage,
27
+ demoSessionAuthRefusal
28
+ } from '../identity/index.js';
29
+ import { DemoPassService } from '../identity/demo-pass.js';
30
+ import type { PlatformRegistry } from '../platform/index.js';
31
+ import type {
32
+ ApiContext,
33
+ ApiRateLimitContext,
34
+ ApiRoutesContext,
35
+ BodyCap,
36
+ ToolDefinition,
37
+ ToolRateLimiters
38
+ } from '../tool-definition.js';
39
+ import type { ApiKeyService } from '../apikeys/index.js';
40
+ import type { EmailDriver } from '../email/index.js';
41
+ import { auditMiddleware, type AuditService } from '../audit/index.js';
42
+ import {
43
+ isDeferringGate,
44
+ registerEntitlementGate,
45
+ type EntitlementCloud,
46
+ type ToolEntitlementDeclaration
47
+ } from '../entitlements/index.js';
48
+ import { constantTimeEquals } from '../util/index.js';
49
+ import { isSecureSetupOrigin } from '../util/index.js';
50
+ import { authBodyGuard } from '../middleware/index.js';
51
+ import {
52
+ admitPrincipal,
53
+ authContext,
54
+ resolveSessionPrincipal,
55
+ type PrincipalGate
56
+ } from '../middleware/index.js';
57
+ import { idempotency } from '../middleware/index.js';
58
+ import { crossSiteGuard } from '../middleware/index.js';
59
+ import { jsonDepthLimit } from '../middleware/index.js';
60
+ import { deferrableBodyCap } from '../middleware/body-cap.js';
61
+ import { noStoreAuthenticated } from '../middleware/index.js';
62
+ import { oauthPublicEndpoints } from '../middleware/index.js';
63
+ import { createRequestQuota, emailKeyOf, makeClientIp, rateLimit } from '../middleware/index.js';
64
+ import type { OauthJwtVerifier } from '../identity/index.js';
65
+ import { HUB_SSO_PROVIDER_ID, type HubSsoService } from '../identity/index.js';
66
+ import type { HubGrantService } from '../identity/index.js';
67
+ import type { HubLogoutService } from '../identity/index.js';
68
+ import { registerBreakGlassRoutes } from './index.js';
69
+ import { registerCliAuthRoutes } from './index.js';
70
+ import { registerOnboardingRoutes } from './index.js';
71
+ import {
72
+ registerWorkspaceRoutes,
73
+ workspaceCreationPolicy,
74
+ workspaceCreationRefusal,
75
+ workspaceCreateWallClosed,
76
+ type WorkspaceCloudDeps
77
+ } from './index.js';
78
+ import { registerSsoConnectRoutes } from './index.js';
79
+ import { registerSsoLogoutRoutes } from './index.js';
80
+ import { registerMemberRoutes } from './index.js';
81
+ import { demoSessionJudge, demoSignInOn, registerDemoPassRoutes } from './index.js';
82
+ import { registerProjectRoutes } from './index.js';
83
+ import { registerTeamRoutes } from './index.js';
84
+ import { registerApiKeyRoutes } from './index.js';
85
+ import { registerInvitationRoutes } from './index.js';
86
+ import { registerAuditRoutes } from './index.js';
87
+ import { registerFileRoutes } from './index.js';
88
+ import { registerExportRoutes } from './index.js';
89
+ import type { AccountDeletionService } from '../accounts/index.js';
90
+ import type { FileService } from '../files/index.js';
91
+ import type { StorageDriver } from '../storage/index.js';
92
+
93
+ /** Inline error body matching the wire shape; keeps openapi handlers typed. */
94
+ const err = (code: string, message: string) => ({ error: { code, message } });
95
+ /** Where the sign-in library is mounted: the full path a middleware on `api` sees starts with it. */
96
+ const AUTH_MOUNT = '/api/v1/auth';
97
+
98
+ /**
99
+ * PRDCT-1437 door 1: neutralize the change-email consume's account-existence
100
+ * oracle by rewriting the REQUEST before Better Auth reads the token.
101
+ *
102
+ * The consume of a `change-email-verification` link updates the target's email
103
+ * to the new address; when that address already belongs to ANOTHER user the
104
+ * update dies at the `user.email` UNIQUE constraint as a raw 500, while a free
105
+ * address answers a 302 — a mint-then-consume "does this email exist anywhere
106
+ * on the instance" for any workspace owner, and a robustness bug. Rather than
107
+ * reimplement the consume (LESSONS.md M6: never hand-roll the Better Auth
108
+ * flow), on a collision we re-sign the token so its target is the member's
109
+ * CURRENT address — a no-op change that runs Better Auth's ENTIRE success path
110
+ * (its own `callbackURL` origin check, the session mint, the set-cookie, the
111
+ * 302-or-JSON, the audit hook) with no unique violation. Redirect, cookie, and
112
+ * origin-check verdict are then byte-identical to the free case BY
113
+ * CONSTRUCTION, and no `callbackURL` handling is added here, so no open-redirect
114
+ * surface is introduced (the route's own originCheck stays the only judge).
115
+ *
116
+ * The one residual tell — the success JSON echoes the resulting email in its
117
+ * `user` record — is reachable only WITHOUT a `callbackURL` (a real consume
118
+ * always carries one and redirects) and is stripped to `user:null` by the
119
+ * wrapper below. The honest, inherent residual (email uniqueness is
120
+ * instance-global, so the change's outcome is readable one step later on the
121
+ * roster) is documented in ADR 013's amendment. It runs in a before-hook only
122
+ * as a query mutation, which the endpoint's own parsed query never sees, so it
123
+ * MUST rewrite the request here. Re-verify the token shape and the
124
+ * `change-email-verification` requestType on ANY Better Auth bump.
125
+ */
126
+ /**
127
+ * Constrain a verify-email redirect Location to the instance's own origin
128
+ * (Major 3, PRDCT-1437 hardening). DEFENCE-IN-DEPTH, not a hole being closed:
129
+ * on the pinned Better Auth, `/verify-email` DOES run its own callbackURL
130
+ * origin check in production (the `originCheck` factory has no GET skip; only
131
+ * `originCheckMiddleware` does) — but the test harness disables that guard
132
+ * (`NODE_ENV=test` sets `skipOriginCheck`), and a future bump could regress
133
+ * it, so we constrain the redirect ourselves and the no-op collision rewrite
134
+ * routes the taken branch through the same constraint. A same-origin (or
135
+ * public-origin) target is returned
136
+ * as a relative path; anything off-origin is neutralized to `/`. Legitimate
137
+ * consumes send a relative `callbackURL` and are untouched; applied to every
138
+ * response the wrapper sees, so the taken and free branches stay identical.
139
+ */
140
+ function safeRedirectLocation(location: string, requestUrl: string, publicBaseUrl: string): string {
141
+ let resolved: URL;
142
+ let servingOrigin: string;
143
+ try {
144
+ servingOrigin = new URL(requestUrl).origin;
145
+ resolved = new URL(location, requestUrl);
146
+ } catch {
147
+ return '/';
148
+ }
149
+ let publicOrigin: string | null = null;
150
+ try {
151
+ publicOrigin = new URL(publicBaseUrl).origin;
152
+ } catch {
153
+ publicOrigin = null;
154
+ }
155
+ if (resolved.origin === servingOrigin || resolved.origin === publicOrigin) {
156
+ return resolved.pathname + resolved.search + resolved.hash;
157
+ }
158
+ return '/';
159
+ }
160
+
161
+ async function rewriteChangeEmailCollision(
162
+ auth: Auth,
163
+ authSecret: string,
164
+ request: Request
165
+ ): Promise<Request> {
166
+ const url = new URL(request.url);
167
+ const token = url.searchParams.get('token');
168
+ if (!token) return request;
169
+ let payload: Record<string, unknown>;
170
+ try {
171
+ const verified = await jwtVerify(token, new TextEncoder().encode(authSecret), {
172
+ algorithms: ['HS256']
173
+ });
174
+ payload = verified.payload as Record<string, unknown>;
175
+ } catch {
176
+ return request; // a bad token is Better Auth's to reject (uniform error redirect)
177
+ }
178
+ const email = payload.email;
179
+ const updateTo = payload.updateTo;
180
+ if (
181
+ payload.requestType !== 'change-email-verification' ||
182
+ typeof email !== 'string' ||
183
+ typeof updateTo !== 'string' ||
184
+ email === updateTo
185
+ ) {
186
+ return request;
187
+ }
188
+ try {
189
+ const authCtx = await auth.$context;
190
+ const target = await authCtx.internalAdapter.findUserByEmail(email);
191
+ if (!target) return request; // user_not_found — uniform on both branches
192
+ // A signed-in NON-target redirectOnErrors identically on both branches, so
193
+ // only the state that actually reaches the update needs neutralizing.
194
+ const session = await auth.api.getSession({ headers: request.headers }).catch(() => null);
195
+ if (session && session.user.email !== email) return request;
196
+ const holder = await authCtx.internalAdapter.findUserByEmail(updateTo);
197
+ if (!holder || holder.user.id === target.user.id) return request; // no collision
198
+ // Collision: re-sign as a no-op (change email → same email). Better Auth
199
+ // then updates the row to its own value (no 23505) and runs the full
200
+ // success path off the rewritten request.
201
+ const noopToken = await createEmailVerificationToken(authCtx.secret, email, email, 3600, {
202
+ requestType: 'change-email-verification'
203
+ });
204
+ url.searchParams.set('token', noopToken);
205
+ return new Request(url.toString(), request);
206
+ } catch {
207
+ // A lookup failure must not turn into a distinguishing error — let Better
208
+ // Auth answer, and the wrapper's 5xx→uniform mapping backstops it.
209
+ return request;
210
+ }
211
+ }
212
+
213
+ export interface ApiDeps<
214
+ TEnvShape extends z.ZodRawShape,
215
+ TDomain,
216
+ TBuckets extends string,
217
+ TEvents,
218
+ TToolOverrides
219
+ > {
220
+ db: Db;
221
+ env: ToolEnv<TEnvShape>;
222
+ auth: Auth;
223
+ registry: PlatformRegistry<TEvents>;
224
+ logger: Logger;
225
+ apiKeys: ApiKeyService;
226
+ audit: AuditService;
227
+ email: EmailDriver;
228
+ limiters: ToolRateLimiters<TBuckets>;
229
+ storage: StorageDriver;
230
+ fileService: FileService;
231
+ oauthJwt: OauthJwtVerifier;
232
+ /** Server auth secret — also derives the idempotency replay-cache cipher key. */
233
+ authSecret: string;
234
+ /**
235
+ * The credential POST /setup REQUIRES (PRDCT-1347): SETUP_TOKEN, or the
236
+ * token boot generated into the data volume for an unclaimed instance.
237
+ * null only once the instance is set up (nothing left to claim).
238
+ */
239
+ setupToken: string | null;
240
+ /** Removes the boot-generated token file after a successful claim (no-op when SETUP_TOKEN is set). */
241
+ clearGeneratedSetupToken: () => Promise<void>;
242
+ accountDeletion: AccountDeletionService;
243
+ /** The tool definition: its slots are invoked at their fixed positions below. */
244
+ tool: ToolDefinition<TEnvShape, TDomain, TBuckets, TEvents, TToolOverrides>;
245
+ /** What the tool's `services` slot returned — built in boot, shared with its public routes. */
246
+ domain: TDomain;
247
+ /** The tool's never-trusted origins, resolved ONCE in boot (the same list Better Auth got). */
248
+ untrustedOrigins: readonly string[];
249
+ /**
250
+ * Cloud edition only (internal/federation.md): the hub SSO binding. Its sole
251
+ * job here is wrapping the auth mount in the request-scoped login scope
252
+ * that carries the verified hub assertion from the callback's token
253
+ * verification to its after-hook (ADR 015).
254
+ */
255
+ hubSso?: HubSsoService | undefined;
256
+ /**
257
+ * Cloud edition only: the per-user hub grant store — /sso/cli-connect
258
+ * lands the H3 offline grant through its `acquireFromConnect` seam.
259
+ */
260
+ hubGrant?: HubGrantService | undefined;
261
+ /**
262
+ * Cloud edition only: RP-initiated hub logout — POST /sso/logout builds
263
+ * the end-session URL through it (SL-2). Absent on oss: the route is
264
+ * never registered and the path answers the JSON 404 terminator.
265
+ */
266
+ hubLogout?: HubLogoutService | undefined;
267
+ /**
268
+ * Cloud edition only: the post-resolution LIVE hub gate authContext runs
269
+ * (reconcile-as-the-user + suspension/revocation/grant-death verdicts).
270
+ * Absent on oss: the middleware carries zero hub surface.
271
+ */
272
+ principalGate?: PrincipalGate | undefined;
273
+ /**
274
+ * Cloud edition only: what POST /workspaces needs to create the
275
+ * organization at the hub AS THE CALLER and project it (api/workspaces.ts).
276
+ * Absent on oss: the route creates locally and carries zero hub surface.
277
+ */
278
+ workspaceCloud?: WorkspaceCloudDeps | undefined;
279
+ /**
280
+ * Cloud edition only: the organizations the hub's last definitive list
281
+ * named as not opening this tool to the person (the reconciler's
282
+ * `deniedOrgs`, a transient per-replica hint). `/me` carries it as
283
+ * `hubDenied` so the refusal page can name the organization to ask.
284
+ * Absent on oss: `/me` never carries the key.
285
+ */
286
+ hubDeniedOrgs?: ((userId: string) => Array<{ id: string; name: string }>) | undefined;
287
+ /**
288
+ * Cloud edition only: the reconciler's cached, single-flight, throttled
289
+ * pass (`reconcile(userId)`, the live gate's own call), run by the
290
+ * zero-membership `/me`. A session with no active membership resolves to
291
+ * no principal, so the live gate never reaches it; without this pass a
292
+ * person re-seated at the hub after their last organization was swept
293
+ * would stay on the zero state for ever. Its verdict is not an error
294
+ * there. Absent on oss: the zero state runs no pass.
295
+ */
296
+ reconcileForZeroState?: ((userId: string) => Promise<void>) | undefined;
297
+ /**
298
+ * The tool's billing-rail declarations (its `entitlements` slot, asserted
299
+ * at boot): the lists `GET /instance` shows and the routes the one
300
+ * entitlement gate enforces (entitlements/gate.ts).
301
+ */
302
+ entitlements: ToolEntitlementDeclaration;
303
+ /**
304
+ * Cloud edition only: the per-account plan source (the hub's
305
+ * `GET /usage/entitlements`, cached) and the upgrade link a plan refusal
306
+ * carries — the hub's organization page. Absent on oss: no plan exists,
307
+ * the gate compares limits against their oss values and emits nothing.
308
+ */
309
+ entitlementCloud?: EntitlementCloud | undefined;
310
+ }
311
+
312
+ /** Control-flow marker: the singleton claim lost (instance already set up). */
313
+ class SetupAlreadyDone extends Error {}
314
+
315
+ /**
316
+ * The versioned API, mounted at /api/v1. Every route is defined by a contract
317
+ * in the tool's contract package (`<tool contract>/routes`); the OpenAPI document is generated from those
318
+ * contracts and served at /api/v1/openapi.json.
319
+ */
320
+ export function createApiApp<
321
+ TEnvShape extends z.ZodRawShape,
322
+ TDomain,
323
+ TBuckets extends string,
324
+ TEvents,
325
+ TToolOverrides
326
+ >(deps: ApiDeps<TEnvShape, TDomain, TBuckets, TEvents, TToolOverrides>): OpenAPIHono {
327
+ const { db, env, auth, registry, logger, apiKeys: apiKeyService, audit, email, limiters, tool } = deps;
328
+ const { meRoute, apiKeysListRoute, apiKeyCreateRoute, apiKeyRevokeRoute, cliAuthCompleteRoute } =
329
+ tool.scopes.contractRoutes;
330
+ const { ssoCliConnectRoute } = tool.scopes.contractRoutes;
331
+ // The registry as the chassis routers read it (the generic events only):
332
+ // the same seams, the same bus, seen through its chassis view.
333
+ const chassisRegistry: PlatformRegistry = { ...registry, events: registry.events.chassisView() };
334
+ // What every positional hook of the tool receives.
335
+ const hookContext: ApiContext<TEnvShape, TBuckets, TEvents> = {
336
+ db,
337
+ env,
338
+ auth,
339
+ registry,
340
+ logger,
341
+ audit,
342
+ email,
343
+ limiters,
344
+ storage: deps.storage,
345
+ fileService: deps.fileService,
346
+ authSecret: deps.authSecret,
347
+ hubSso: deps.hubSso
348
+ };
349
+
350
+ // P7 (internal/federation.md): on EDITION=cloud, membership of a hub-origin
351
+ // (projected) workspace is managed at the hub — this is the pointer the
352
+ // membership-mutation gates and /me carry. Same single `hubConfig` switch
353
+ // as every cloud seam: null on oss, so oss wires no gate and /me answers
354
+ // hubManageUrl null.
355
+ const hub = hubConfig(env);
356
+ const hubManaged = hub ? { manageUrl: hub.issuerUrl } : undefined;
357
+ // Demo sign-in (the demo pass spec): DEMO_SIGN_IN on the self-hosted
358
+ // edition only. Off, no demo route, wall or discovery key exists.
359
+ const demoSignIn = demoSignInOn(env, hub, logger);
360
+
361
+ const api = new OpenAPIHono({
362
+ // Validation failures use the same wire shape as every other error.
363
+ defaultHook: (result, c) => {
364
+ if (!result.success) {
365
+ return c.json(
366
+ {
367
+ error: {
368
+ code: 'validation_error',
369
+ message: 'Request validation failed',
370
+ details: result.error.issues
371
+ }
372
+ },
373
+ 400
374
+ );
375
+ }
376
+ }
377
+ });
378
+
379
+ // NOTE (release-gate fix): malformed/empty JSON bodies used to 500 here —
380
+ // hono's json validator throws HTTPException(400) BEFORE zod runs, so the
381
+ // defaultHook above never fires. Hono routes thrown errors straight to the
382
+ // top-level errorHandler from the throwing frame (an upstream middleware
383
+ // try/catch never sees them), so the mapping to the 400 wire shape lives
384
+ // in app.ts app.onError — the one seam that covers every body route.
385
+
386
+ // ── Public OAuth endpoints first: wildcard CORS + OPTIONS 204 + no-store
387
+ // on token responses. Before the rate limits so preflights cost nothing.
388
+ api.use('/auth/*', oauthPublicEndpoints());
389
+
390
+ // ── Cross-site (CSRF) gate, before ANY other work: an unsafe method driven
391
+ // from another origin is refused before a body is buffered, before a
392
+ // limiter bucket is touched, and before a credential resolves. Sessions are
393
+ // ambient credentials — every business route below (the tool's resources,
394
+ // its tokens and collaborators, api-keys) would otherwise be drivable
395
+ // from any page the user happens to have open.
396
+ //
397
+ // SCOPED TO /api/v1 ON PURPOSE. The public viewer (`/v/:secret`) is mounted
398
+ // on the root app and is DELIBERATELY embeddable cross-origin (ADR 021 +
399
+ // /embed.js), including its password-form POST — mounting this guard there
400
+ // would break every legitimate embed. The viewer authenticates on the share
401
+ // secret and its own scoped cookie, not on the dashboard session, so it is
402
+ // not the ambient-credential surface this closes.
403
+ api.use(
404
+ '*',
405
+ crossSiteGuard({
406
+ publicBaseUrl: env.PUBLIC_BASE_URL,
407
+ // The tool's never-trusted origins (e.g. a viewer origin, PRDCT-1352:
408
+ // the origin author-controlled script runs on): never a trust
409
+ // grant here, even if it is the serving origin.
410
+ deniedOrigins: deps.untrustedOrigins,
411
+ // `/api/v1/viewer/*` is the share-token annotation API, and it is a
412
+ // DELIBERATE wildcard-CORS surface (viewer/annotations-api.ts): it is
413
+ // called by the overlay client running inside the sandboxed viewer
414
+ // iframe, whose origin is the opaque `null`. Nothing there is
415
+ // cookie-authenticated — the share token in the path is the credential —
416
+ // so it is not the ambient-credential class this guard closes, and
417
+ // refusing `Origin: null` would break annotations outright.
418
+ isExempt: (path) => tool.api.csrfExempt?.(path) ?? false
419
+ })
420
+ );
421
+
422
+ // ── Authenticated responses are never cached. Registered ABOVE the
423
+ // credential middleware so it wraps every route under /api/v1, and it reads
424
+ // the principal AFTER the inner chain has resolved it. Routes that set their
425
+ // own Cache-Control (public discovery, the OpenAPI document) keep it.
426
+ api.use('*', noStoreAuthenticated());
427
+ // The public viewer-token annotation surface (Phase 5): same posture — the
428
+ // overlay calls cross-origin from the sandboxed opaque origin (Origin:
429
+ // null), token-authed, never cookie-authed, so wildcard CORS is safe.
430
+ tool.api.early?.(api, hookContext);
431
+
432
+ // ── Body size caps, path-routed. 1 MiB is generous for every JSON/auth
433
+ // body. Exceptions:
434
+ // - /files: streamed uploads with their own mid-stream cap
435
+ // (MAX_FILE_SIZE_MB) plus a Content-Length entitlement check;
436
+ // - the tool's multipart asset uploads (its `bodyLimit` slot) — capped at
437
+ // MAX_FILE_SIZE_MB (+1 MiB multipart framing headroom) so an unbounded
438
+ // body can never balloon the buffering parse;
439
+ // - the rest of the tool's routes, which are JSON, but commit manifests are legal up to
440
+ // 5000 entries × 1 KiB paths — a 16 MiB cap fits any contract-valid
441
+ // manifest while still bounding abuse.
442
+ const jsonBodyCap: BodyCap = {
443
+ maxBytes: 1024 * 1024,
444
+ onError: (c) => c.json(err('payload_too_large', 'Request body exceeds the 1 MiB limit'), 413)
445
+ };
446
+ // The tool's own caps, declared ONCE (the slot's factory runs here, never
447
+ // per request) and built into middleware per cap object below.
448
+ const toolBodyLimit = tool.api.bodyLimit?.(hookContext);
449
+ // - the viewer's form file upload (PRDCT-2403): one raw streamed file per
450
+ // request, capped MID-STREAM by the form-upload ceiling in its handler.
451
+ //
452
+ // A declared Content-Length over a cap on a route whose gate judges a
453
+ // PLAN LIMIT is not refused here (PRDCT-2632): the entitlement gate must
454
+ // see the declared size first, so a metered account meets 403
455
+ // `plan_required` with its upgrade link at any size and the cap's 413
456
+ // answers only when the plan allows the size. `deferrableBodyCap`
457
+ // (middleware/body-cap.ts) parks the refusal on the context and DROPS the
458
+ // request's body, keyed on the gate itself being on the matched route;
459
+ // the gate fires it. Every cap goes through it, the tool's and the
460
+ // default JSON one alike, so a future declared-limit route left under
461
+ // the default cap is never shadowed either. An undeclared body is still
462
+ // counted and cut by the cap.
463
+ const deferring = (c: Context) => matchedRoutes(c).some((route) => isDeferringGate(route.handler));
464
+ const capMiddleware = new WeakMap<BodyCap, MiddlewareHandler>();
465
+ const capped = (cap: BodyCap): MiddlewareHandler => {
466
+ let built = capMiddleware.get(cap);
467
+ if (!built) {
468
+ built = deferrableBodyCap(cap, deferring);
469
+ capMiddleware.set(cap, built);
470
+ }
471
+ return built;
472
+ };
473
+ api.use('*', (c, next) => {
474
+ const path = c.req.path;
475
+ if (path.startsWith('/api/v1/files')) return next();
476
+ const verdict = toolBodyLimit?.(path);
477
+ if (verdict === 'exempt') return next();
478
+ return capped(verdict ?? jsonBodyCap)(c, next);
479
+ });
480
+
481
+ // ── JSON nesting cap, right after the size cap (so the scan is bounded by
482
+ // it): `JSON.parse` accepts any depth but `JSON.stringify` is recursive, so
483
+ // a small body of nothing but `[` turns any echo/audit/log of it into a
484
+ // RangeError 500 inside the shipped node:22-alpine image.
485
+ // The form file upload is exempt: its body is a FILE, never parsed, and a
486
+ // respondent's `.json` sent with a JSON content type would otherwise be
487
+ // cloned into memory whole (up to the upload ceiling) by the scan.
488
+ const depthLimit = jsonDepthLimit();
489
+ api.use('*', (c, next) => (tool.api.jsonDepthExempt?.(c.req.path) ? next() : depthLimit(c, next)));
490
+
491
+ // ── Auth-surface rate limits: registered FIRST so they run before auth
492
+ // resolution — abusive traffic is rejected before it costs a DB query.
493
+ const clientIp = makeClientIp(env.TRUST_PROXY);
494
+ // Login keys by IP AND email: Docker NAT (or a rotating attacker) can
495
+ // collapse/expand the IP dimension, so per-account protection must not
496
+ // depend on it. emailKeyOf returns [] for bodyless social sign-in.
497
+ //
498
+ // consumeOn: 'failure' — see rate-limit.ts. The email key is derived from
499
+ // the REQUEST BODY, i.e. from an address anyone can type, so consuming it on
500
+ // arrival made the wall an account-lockout tool: ten POSTs naming a victim
501
+ // emptied that account's bucket for the window. Consuming only when the
502
+ // credential was actually refused keeps the brute-force budget identical and
503
+ // makes the owner's own successful sign-ins free.
504
+ //
505
+ // The federated start (`/auth/sign-in/oauth2`, the silent start toward the
506
+ // hub) presents no credential: it only answers the hub's authorize URL, and
507
+ // the hub checks the person with its own wall. It stays off this one, so a
508
+ // wall drained by wrong passwords behind a shared address does not also
509
+ // close the hub sign-in to everyone behind it (PRDCT-2830).
510
+ const loginWall = rateLimit(limiters.login, clientIp, emailKeyOf, { consumeOn: 'failure' });
511
+ api.use('/auth/sign-in/*', (c, next) =>
512
+ c.req.path.endsWith('/auth/sign-in/oauth2') ? next() : loginWall(c, next)
513
+ );
514
+ api.use('/auth/email-otp/*', rateLimit(limiters.otp, clientIp, emailKeyOf));
515
+ api.use('/auth/sign-up/*', rateLimit(limiters.login, clientIp));
516
+ // Password reset: request keys by IP AND email (tight); reset + change key
517
+ // by IP (brute-forcing a token / the current password). The deprecated
518
+ // emailOTP forget-password alias shares the reset wall.
519
+ api.use('/auth/request-password-reset', rateLimit(limiters.passwordReset, clientIp, emailKeyOf));
520
+ api.use('/auth/reset-password', rateLimit(limiters.passwordReset, clientIp));
521
+ api.use('/auth/reset-password/*', rateLimit(limiters.passwordReset, clientIp));
522
+ api.use('/auth/forget-password/*', rateLimit(limiters.passwordReset, clientIp, emailKeyOf));
523
+ // Email change mints sign-in-equivalent tokens and sends mail — same tight
524
+ // wall as the reset surface (per IP; the route is session-gated anyway).
525
+ api.use('/auth/change-email', rateLimit(limiters.passwordReset, clientIp));
526
+ api.use('/auth/change-password', rateLimit(limiters.login, clientIp));
527
+ // The 2FA surface: verify-totp / verify-backup-code guess a 6-digit code or
528
+ // a backup code behind the 10-minute pending-sign-in cookie; enable/disable
529
+ // present the current password. Same brute-force wall as login (per IP).
530
+ api.use('/auth/two-factor/*', rateLimit(limiters.login, clientIp));
531
+ // Account deletion presents the current password — same brute-force wall.
532
+ api.use('/auth/delete-user', rateLimit(limiters.login, clientIp));
533
+ api.use('/auth/oauth2/register', rateLimit(limiters.oauthRegister, clientIp));
534
+ api.use('/auth/oauth2/token', rateLimit(limiters.oauthToken, clientIp));
535
+ api.use('/setup', rateLimit(limiters.setup, clientIp));
536
+ api.use('/invitations/accept', rateLimit(limiters.invitationAccept, clientIp));
537
+ api.use('/invitations/lookup', rateLimit(limiters.invitationAccept, clientIp));
538
+ // Collaborator claims are invitation acceptances in per-resource clothing —
539
+ // the same public token-redemption surface, the same wall.
540
+ const rateLimitContext: ApiRateLimitContext<TEnvShape, TBuckets, TEvents> = { ...hookContext, clientIp };
541
+ tool.api.rateLimits?.(api, rateLimitContext);
542
+ // Break-glass: a rare superadmin recovery action — a tight per-IP wall
543
+ // bounds allowlist probing before the handlers' own session checks run.
544
+ api.use('/admin/break-glass/*', rateLimit(limiters.breakGlass, clientIp));
545
+ // CLI email-OTP sign-in (api/cli-auth.ts): request = an OTP send (the OTP
546
+ // wall, per IP + email); complete = a credential guess (the login wall,
547
+ // per IP + email) on top of better-auth's own 3-attempts-per-code limit.
548
+ api.use('/cli/auth/request', rateLimit(limiters.otp, clientIp, emailKeyOf));
549
+ api.use('/cli/auth/complete', rateLimit(limiters.login, clientIp, emailKeyOf, { consumeOn: 'failure' }));
550
+ // The demo pass redeem (identity/demo-pass-plugin.ts): a secret presented
551
+ // anonymously, so every arrival costs, per address only. In front of the
552
+ // sign-in handler, and only where the endpoint exists.
553
+ if (demoSignIn) {
554
+ // Only a REFUSED redeem costs: the wall is there against guessing, and a
555
+ // person playing a demonstration opens many valid links in a row from one
556
+ // address (found on the pair: the 21st valid click in fifteen minutes was
557
+ // refused). A drained wall still refuses everything until it refills.
558
+ api.use(
559
+ '/auth/demo/redeem',
560
+ rateLimit(limiters.demoRedeem, clientIp, undefined, { consumeOn: 'failure' })
561
+ );
562
+ }
563
+ // The OpenAPI document is unauthenticated (PUBLIC_API_PATHS) so it never
564
+ // reaches the per-principal quota; the buffer is generated once at boot
565
+ // (openapi-doc.ts) and this wall is the defence in depth on top.
566
+ api.use('/openapi.json', rateLimit(limiters.openapiDoc, clientIp));
567
+ // CLI cross-tool connect (cloud only, api/sso-connect.ts): presenting an
568
+ // exchange token is a credential presentation, walled per client address
569
+ // on its own bucket sized for a room (CLI_CONNECT_IP_LIMIT_PER_15_MIN): on
570
+ // the login wall (10 per 15 minutes) the 11th `<tool> login` behind one
571
+ // venue address was refused (PRDCT-2958, verifier F1). The token itself is
572
+ // one-time and the hub's walls bound each person. Conditional like the
573
+ // route itself: oss mounts zero hub surface.
574
+ if (deps.hubSso) {
575
+ api.use('/sso/cli-connect', rateLimit(limiters.cliConnect, clientIp));
576
+ }
577
+
578
+ // ── JSON pre-validation in front of the Better Auth mount (AF-1 + the
579
+ // update-user half of SL-B4, PRDCT-1358): Better Auth answers its own 500
580
+ // on a malformed/empty JSON body and on a NUL that reaches Postgres —
581
+ // responses app.onError never sees, so the app-level mappings cannot help.
582
+ // Registered here, after the size and depth caps, so the guard's body read
583
+ // is bounded.
584
+ api.use('/auth/*', authBodyGuard());
585
+ // Demo sign-in, the second door of "a session a pass opened lives only
586
+ // while its pass does": the sign-in library's mount runs BEFORE the
587
+ // credential resolver, and it is where a session authorizes an OAuth client
588
+ // and answers get-session. A dead pass's session is deleted here first, so
589
+ // the library sees none. A live pass's session is a visit: the library
590
+ // paths that would leave a credential, a grant or an account change behind
591
+ // it are refused here (identity/demo-pass-rules.ts
592
+ // `DEMO_SESSION_REFUSED_AUTH_PATHS`). And the library's own session deletes
593
+ // (a pass session's sign-out, a person's own revoke of one) delete the
594
+ // session row only: those sessions are ended the pass's way first
595
+ // (`endSessions`), so their OAuth tokens and link rows go with them.
596
+ // The revoking caller is verified by the library: a forged or dead cookie
597
+ // ends nothing.
598
+ if (demoSignIn) {
599
+ const judge = demoSessionJudge(db);
600
+ const demoPassService = new DemoPassService(db);
601
+ api.use('/auth/*', async (c, next) => {
602
+ if (!c.req.header('cookie')) return next();
603
+ const verdict = await judge(c.req.raw.headers);
604
+ const authPath = c.req.path.startsWith(AUTH_MOUNT) ? c.req.path.slice(AUTH_MOUNT.length) : c.req.path;
605
+ if (verdict && verdict !== 'ended') {
606
+ if (demoSessionAuthRefusal(authPath)) {
607
+ return c.json(
608
+ err('demo_session', 'A session opened by a demo link cannot do this: sign in with your password'),
609
+ 403
610
+ );
611
+ }
612
+ // A pass session that signs itself out ends the pass's way; the
613
+ // library then answers as usual.
614
+ if (authPath === '/sign-out') await demoPassService.endSessions([verdict.sessionId]);
615
+ } else if (
616
+ authPath === '/revoke-session' ||
617
+ authPath === '/revoke-sessions' ||
618
+ authPath === '/revoke-other-sessions'
619
+ ) {
620
+ // A person's OWN session revoking a pass session (a pass session is
621
+ // refused these paths above). The caller is verified by the library
622
+ // (`getSession`, the cookie's signature checked): a forged or dead
623
+ // cookie ends nothing. The body is read from a clone: the library
624
+ // gets the request untouched. The exact path compare loses nothing:
625
+ // the library's router answers 404 to any other spelling.
626
+ const verified = await auth.api.getSession({ headers: c.req.raw.headers }).catch(() => null);
627
+ if (verified?.session?.id && verified?.user?.id) {
628
+ let bodyToken: string | null = null;
629
+ try {
630
+ const body: unknown = await c.req.raw.clone().json();
631
+ if (body && typeof body === 'object' && typeof (body as { token?: unknown }).token === 'string') {
632
+ bodyToken = (body as { token: string }).token;
633
+ }
634
+ } catch {
635
+ // No JSON body: nothing names a session (the library answers as it does).
636
+ }
637
+ const passSessions = await demoPassService.passSessionsRevokedBy(
638
+ authPath,
639
+ { sessionId: verified.session.id, userId: verified.user.id },
640
+ bodyToken
641
+ );
642
+ if (passSessions.length > 0) await demoPassService.endSessions(passSessions);
643
+ }
644
+ }
645
+ return next();
646
+ });
647
+ }
648
+
649
+ // Better Auth owns /api/v1/auth/* (mounted before the credential middleware
650
+ // — it IS the credential machinery). On cloud the whole mount runs inside
651
+ // the hub-SSO login scope: an AsyncLocalStorage span carrying the verified
652
+ // hub assertion from the SSO callback's getUserInfo to its after-hook —
653
+ // request-scoped, so concurrent logins can never cross-wire (ADR 015).
654
+ const { hubSso } = deps;
655
+ // The change-email consume, wrapped (PRDCT-1437). The request is rewritten
656
+ // on a collision (rewriteChangeEmailCollision above), then two response
657
+ // normalizations keep the taken and free cases indistinguishable:
658
+ //
659
+ // - JSON success body: the change-email success echoes the resulting email
660
+ // in its `user` record — the one observable the no-op rewrite leaves
661
+ // differing between the taken and free cases. It is reachable ONLY without
662
+ // a `callbackURL` (a real consume always carries one and redirects, never
663
+ // hitting this JSON), so stripping `user` to null for every change-email
664
+ // consume touches no legitimate flow. Only the change/legacy branches
665
+ // return a non-null user; the plain verify-email success is already null.
666
+ // - a residual 5xx: the rewrite closes the DECIDABLE collision, but two
667
+ // concurrent mints claiming one FRESH address (no pre-existing account —
668
+ // so NOT an existence probe) can still lose at the DB. Map it to the same
669
+ // benign JSON so it never surfaces a raw 500. No redirect is issued here —
670
+ // every redirect stays Better Auth's, judged by its own originCheck, so
671
+ // this wrapper adds no open-redirect surface.
672
+ //
673
+ // Registered BEFORE the wildcard mount so the exact route wins.
674
+ api.on(['GET'], '/auth/verify-email', async (c) => {
675
+ const req = await rewriteChangeEmailCollision(auth, deps.authSecret, c.req.raw);
676
+ const res = await (hubSso ? hubSso.runWithLoginScope(() => auth.handler(req)) : auth.handler(req));
677
+ if (res.status === 200 && (res.headers.get('content-type') ?? '').includes('application/json')) {
678
+ const body = (await res
679
+ .clone()
680
+ .json()
681
+ .catch(() => null)) as { status?: unknown; user?: unknown } | null;
682
+ if (body && body.status === true && body.user != null) {
683
+ const headers = new Headers(res.headers);
684
+ headers.delete('content-length');
685
+ return new Response(JSON.stringify({ status: true, user: null }), { status: 200, headers });
686
+ }
687
+ return res;
688
+ }
689
+ // A 3xx: constrain the Location to this origin (Major 3). Better Auth's
690
+ // GET verify-email skips originCheck (no Origin header on a navigation) and
691
+ // redirects to any callbackURL — an open redirect for anyone holding a
692
+ // verify token, applied to BOTH branches so it stays uniform.
693
+ if (res.status >= 300 && res.status < 400) {
694
+ const location = res.headers.get('location');
695
+ if (location) {
696
+ const safe = safeRedirectLocation(location, c.req.url, env.PUBLIC_BASE_URL);
697
+ if (safe !== location) {
698
+ const headers = new Headers(res.headers);
699
+ headers.set('location', safe);
700
+ return new Response(null, { status: res.status, headers });
701
+ }
702
+ }
703
+ return res;
704
+ }
705
+ if (res.status < 500) return res;
706
+ logger.warn(
707
+ { status: res.status },
708
+ 'verify-email answered 5xx — mapped to the uniform non-revealing response'
709
+ );
710
+ return c.json({ status: true, user: null }, 200);
711
+ });
712
+ api.on(['GET', 'POST'], '/auth/*', (c) =>
713
+ hubSso ? hubSso.runWithLoginScope(() => auth.handler(c.req.raw)) : auth.handler(c.req.raw)
714
+ );
715
+
716
+ api.use(
717
+ '*',
718
+ authContext({
719
+ registry: chassisRegistry,
720
+ isApiKeyToken: apiKeyService.isToken,
721
+ resolveApiKey: (token, requested) => apiKeyService.resolve(token, requested),
722
+ resolveOauthJwt: (token, requested) => deps.oauthJwt.resolve(token, requested),
723
+ keyFailureLimiter: limiters.apiKeyFailures,
724
+ clientIp,
725
+ // General per-principal quota (I3): runs inside authContext, after the
726
+ // credential resolves (the bucket key IS the principal identity) and
727
+ // before the scope gate. Public paths and /auth/* never reach it (no
728
+ // principal), so the auth-surface limiters above are not doubled.
729
+ // Limits ride the EntitlementService seam; buckets ride the same
730
+ // Redis-or-memory backend as every other limiter.
731
+ requestQuota: createRequestQuota({
732
+ entitlements: registry.entitlements,
733
+ make: limiters.make,
734
+ logger
735
+ }),
736
+ // Cloud edition's post-resolution veto (internal/federation.md P4);
737
+ // undefined on oss.
738
+ principalGate: deps.principalGate,
739
+ // Demo sign-in: a session a demo pass opened lives only while its pass
740
+ // does, and its audit rows carry the pass's id. Absent while the switch is off.
741
+ ...(demoSignIn ? { demoSession: demoSessionJudge(db) } : {}),
742
+ // The composed fail-closed allowlist: chassis rules, then the tool's rules.
743
+ requiredScopeFor: tool.scopes.requiredScopeFor,
744
+ guestForbiddenMessage: tool.copy.guestForbidden
745
+ })
746
+ );
747
+
748
+ // A session a demo link opened is a visit (identity/demo-pass-rules.ts
749
+ // `DEMO_SESSION_REFUSED_API_ROUTES`): the routes that mint a credential or
750
+ // make something the person keeps refuse it here, at one place, right after
751
+ // the credential resolves and before anything claims an idempotency key.
752
+ // `demoPassId` is only ever set while the switch is on. The tool's own
753
+ // routes of the kind (its `demoSessionRefusedRoutes` slot) join the list here.
754
+ for (const rule of [...DEMO_SESSION_REFUSED_API_ROUTES, ...(tool.demoSessionRefusedRoutes ?? [])]) {
755
+ const gate: MiddlewareHandler = async (c, next) => {
756
+ if (c.get('demoPassId')) {
757
+ return c.json(err('demo_session', demoSessionApiRefusalMessage(rule.does)), 403);
758
+ }
759
+ return next();
760
+ };
761
+ if (rule.method === 'ALL') api.use(rule.path, gate);
762
+ else api.on(rule.method, rule.path, gate);
763
+ }
764
+
765
+ // Idempotency sits strictly BETWEEN authContext (it needs the resolved
766
+ // principal to scope claims) and auditMiddleware (a replayed short-circuit
767
+ // never reaches the audit layer, so a retry cannot land a second audit row).
768
+ api.use(
769
+ '*',
770
+ idempotency({
771
+ db,
772
+ authSecret: deps.authSecret,
773
+ ...(tool.api.idempotencyTargets ? { toolTargets: tool.api.idempotencyTargets } : {})
774
+ })
775
+ );
776
+
777
+ api.use(
778
+ '*',
779
+ auditMiddleware(audit, clientIp, tool.api.auditExempt ? { exempt: tool.api.auditExempt } : {})
780
+ );
781
+
782
+ // Instance id for usage-event sources, cached after first read.
783
+ let cachedInstanceId: string | null = null;
784
+ const instanceId = async (): Promise<string> => {
785
+ if (cachedInstanceId) return cachedInstanceId;
786
+ const [row] = await db.select({ id: instanceSettings.instanceId }).from(instanceSettings).limit(1);
787
+ cachedInstanceId = row?.id ?? 'unsetup';
788
+ return cachedInstanceId;
789
+ };
790
+
791
+ // ── The entitlement gate (the billing rail, §7) — AFTER the scope gate
792
+ // (inside authContext), the idempotency claim and the audit middleware,
793
+ // BEFORE every handler: one middleware per declared route, so a route
794
+ // that declares a meter, a limit or a feature is checked here and its
795
+ // usage event emitted here, and no handler checks or emits by hand. The
796
+ // dashboard, the CLI and the MCP tools all pass through it.
797
+ registerEntitlementGate(api, {
798
+ declarations: deps.entitlements.routes,
799
+ tool: deps.entitlements,
800
+ entitlements: registry.entitlements,
801
+ usage: registry.usage,
802
+ cloud: deps.entitlementCloud,
803
+ source: async () => ({ instanceId: await instanceId(), edition: env.EDITION, version: env.APP_VERSION }),
804
+ logger
805
+ });
806
+
807
+ // ── GET /instance — unauthenticated discovery ────────────────────────────
808
+ api.openapi(instanceRoute, async (c) => {
809
+ const [row] = await db.select().from(instanceSettings).limit(1);
810
+ c.header('Cache-Control', 'public, max-age=60');
811
+ return c.json(
812
+ {
813
+ name: row?.name ?? tool.identity.displayName,
814
+ instanceId: row?.instanceId ?? null,
815
+ edition: env.EDITION,
816
+ version: env.APP_VERSION,
817
+ apiVersion: 'v1' as const,
818
+ setupRequired: !row,
819
+ auth: {
820
+ ...registry.identity.describe(),
821
+ // Cloud only (SL-1): the hint-cookie contract the dashboard's
822
+ // silent auto-connect reads — spread-gated on the same single
823
+ // `hub` switch as every cloud seam, so the oss discovery bytes
824
+ // never carry the key.
825
+ ...(hub
826
+ ? { sso: { hintCookieName: hub.hintCookieName, hintCookieDomain: hub.hintCookieDomain } }
827
+ : {})
828
+ },
829
+ features: { mcp: true, oauth: true, files: true },
830
+ // Present only while demo links sign people in here (the banner's cue).
831
+ ...(demoSignIn ? { demoSignIn: true } : {}),
832
+ // Present only on a cloud instance whose operator restricted the tool
833
+ // (PRDCT-2947): organizations are opened by Antasphere, not created here.
834
+ ...(hub && env.TOOL_RESTRICTED ? { restricted: true } : {}),
835
+ // What this version declares for the billing rail (§7): the hub seeds
836
+ // from it, staff read it, a client reads the limit it is held to.
837
+ entitlements: {
838
+ actions: deps.entitlements.actions,
839
+ limits: deps.entitlements.limits,
840
+ features: deps.entitlements.features
841
+ }
842
+ },
843
+ 200
844
+ );
845
+ });
846
+
847
+ // ── POST /setup — one-shot first-boot wizard ─────────────────────────────
848
+ api.openapi(setupRoute, async (c) => {
849
+ const body = c.req.valid('json');
850
+
851
+ // BEFORE the token check: an instance reachable only over plaintext on a
852
+ // non-loopback origin must not accept the owner password at all, whether
853
+ // or not the caller holds the token (setup-transport.ts).
854
+ if (!env.ALLOW_INSECURE_SETUP && !isSecureSetupOrigin(env.PUBLIC_BASE_URL)) {
855
+ logger.warn(
856
+ { publicBaseUrl: env.PUBLIC_BASE_URL },
857
+ 'refused setup over plaintext on a non-loopback origin'
858
+ );
859
+ return c.json(
860
+ err(
861
+ 'insecure_transport',
862
+ 'Setup over plaintext HTTP on a non-loopback origin is refused. Put TLS in front, reach the instance through an SSH tunnel, or set ALLOW_INSECURE_SETUP=true.'
863
+ ),
864
+ 403
865
+ );
866
+ }
867
+
868
+ const [existing] = await db.select({ id: instanceSettings.id }).from(instanceSettings).limit(1);
869
+ if (existing) {
870
+ return c.json(err('already_setup', 'This instance has already been set up'), 410);
871
+ }
872
+
873
+ // ALWAYS required (PRDCT-1347): the claim decides who owns the instance.
874
+ // `deps.setupToken` is SETUP_TOKEN or the boot-generated token; it is
875
+ // null only for a set-up instance, which the 410 above already answered
876
+ // — so a null here is a claim with no credential to compare against and
877
+ // fails closed rather than falling through to a free claim.
878
+ if (!deps.setupToken || !constantTimeEquals(body.setupToken ?? '', deps.setupToken)) {
879
+ return c.json(err('invalid_setup_token', 'A valid setup token is required'), 403);
880
+ }
881
+
882
+ // Create the owner user through Better Auth so credential hashing and
883
+ // account rows follow its rules exactly. Better Auth cannot join our
884
+ // transaction, so this happens first; a setup that later loses the claim
885
+ // race leaves an orphaned user who can sign in but reaches nothing (401
886
+ // on every route — no membership).
887
+ let ownerUserId: string;
888
+ try {
889
+ const created = await auth.api.signUpEmail({
890
+ body: { email: body.owner.email, password: body.owner.password, name: body.owner.name }
891
+ });
892
+ ownerUserId = created.user.id;
893
+ } catch (cause) {
894
+ // Retry after a crashed earlier attempt: the user may already exist.
895
+ // Reuse it ONLY if the presented credentials actually sign in — never
896
+ // attach a workspace to an account the caller cannot prove they own.
897
+ try {
898
+ const signedIn = await auth.api.signInEmail({
899
+ body: { email: body.owner.email, password: body.owner.password }
900
+ });
901
+ ownerUserId = signedIn.user.id;
902
+ } catch {
903
+ logger.error({ err: cause }, 'setup: owner creation failed');
904
+ return c.json(err('owner_creation_failed', 'Could not create the owner user'), 400);
905
+ }
906
+ }
907
+
908
+ // The operator drove the wizard (holding the setup token when one is
909
+ // set) — their address needs no mailbox dance, and on the cloud edition
910
+ // it MUST be verified (D9, internal/federation.md): under hub-only login the
911
+ // operator's only entrance is the hub trusted-link, and Better Auth
912
+ // refuses to link a trusted provider onto an UNVERIFIED local email
913
+ // (requireLocalEmailVerified stays on). An unverified operator bricks a
914
+ // cloud instance at bootstrap. The hub's own setup does the same.
915
+ await db.update(userTable).set({ emailVerified: true }).where(eq(userTable.id, ownerUserId));
916
+
917
+ // Claim the singleton and — on the OSS edition — create the FIRST
918
+ // workspace + owner membership in ONE transaction: either the instance
919
+ // is fully set up or nothing persisted. The workspace goes through the
920
+ // registry's WorkspaceService (ADR 014) — the same path product flows
921
+ // use for every LATER workspace. On CLOUD (`hub` set — the same single
922
+ // switch as every cloud seam) setup creates NO workspace: every cloud
923
+ // workspace is a hub-org projection (user-scoped federation), so the
924
+ // operator bootstrap mints a verified, break-glass capable USER only —
925
+ // the singleton claim, the edition stamp, and the D9 emailVerified
926
+ // force above all stay.
927
+ const instanceId = ulid();
928
+ let workspaceId: string | null;
929
+ try {
930
+ workspaceId = await db.transaction(async (tx) => {
931
+ const claimed = await tx
932
+ .insert(instanceSettings)
933
+ // The edition stamp is what the R7 boot guard compares against
934
+ // (boot.ts, internal/federation.md): setup records the edition this
935
+ // instance was born under.
936
+ .values({
937
+ id: 'instance',
938
+ instanceId,
939
+ name: body.instanceName,
940
+ edition: env.EDITION,
941
+ // The durable operator record (CLOUD-3): survives the orphan purge.
942
+ operatorUserId: ownerUserId
943
+ })
944
+ .onConflictDoNothing()
945
+ .returning({ id: instanceSettings.id });
946
+ if (claimed.length === 0) throw new SetupAlreadyDone();
947
+
948
+ if (hub) return null;
949
+ const created = await registry.workspaces.create(body.instanceName, ownerUserId, tx);
950
+ return created.workspaceId;
951
+ });
952
+ } catch (cause) {
953
+ if (cause instanceof SetupAlreadyDone) {
954
+ return c.json(err('already_setup', 'This instance has already been set up'), 410);
955
+ }
956
+ logger.error({ err: cause }, 'setup: transaction failed (nothing persisted)');
957
+ return c.json(err('internal', 'Setup failed; the instance remains uninitialized — retry'), 500);
958
+ }
959
+
960
+ chassisRegistry.events.emit('setup.completed', { workspaceId, instanceId });
961
+ logger.info({ workspaceId, instanceId }, 'first-boot setup completed');
962
+ // The generated token has done its one job; a leftover copy in the data
963
+ // volume is a secret with no purpose (it would ride in every backup).
964
+ await deps.clearGeneratedSetupToken().catch((cause: unknown) => {
965
+ logger.warn({ err: cause }, 'setup: could not remove the generated setup-token file');
966
+ });
967
+
968
+ // The genesis privileged action: audited directly (no principal in
969
+ // context yet — the middleware exempts /setup).
970
+ await audit.write({
971
+ workspaceId,
972
+ principal: null,
973
+ action: 'instance.setup',
974
+ resourceType: 'instance',
975
+ resourceId: instanceId,
976
+ requestId: c.get('requestId'),
977
+ metadata: { ownerUserId, ownerEmail: body.owner.email }
978
+ });
979
+
980
+ return c.json({ instanceId, workspaceId, ownerUserId }, 201);
981
+ });
982
+
983
+ /**
984
+ * SL-6 /me extras — CLOUD + SESSION callers only (both spread-gated
985
+ * below; machine credentials and every oss response never carry the
986
+ * keys):
987
+ *
988
+ * - `firstRunPending`: tool-local, retry-safe banner truth —
989
+ * `NOT EXISTS (user_onboarding row WHERE dismissed_at IS NOT NULL)`.
990
+ * A missing row (lost first-login insert) still shows the welcome;
991
+ * only an explicit dismissal (or the deploy backfill) hides it.
992
+ * - `ssoOnly`: the hint-watch discriminator — the user holds an
993
+ * `antasphere` account row AND no local credential (password) row.
994
+ * 'credential' is better-auth's password-account providerId (pinned
995
+ * 1.6.15; re-verify on bump). A break-glass-capable operator always
996
+ * has a credential row (setup mints it), so they are NEVER ssoOnly
997
+ * and the dashboard's hint-watch can never sign them out.
998
+ * - `hubDenied`: the organizations the person's last definitive hub
999
+ * list named as not opening this tool to them (a hint for the
1000
+ * refusal page; the hub is the truth), and `hubNoAccessUrl`, the
1001
+ * hub's page that lists whom to ask, ready-made with this tool's
1002
+ * client id.
1003
+ */
1004
+ // `/me.canCreateWorkspace` and POST /workspaces judge through the SAME
1005
+ // rule (api/workspaces.ts), so the flag never promises what the route
1006
+ // refuses. Never throws into /me: a failed read answers false.
1007
+ const creationPolicy = workspaceCreationPolicy({
1008
+ maxPerUser: env.MAX_WORKSPACES_PER_USER,
1009
+ restricted: env.TOOL_RESTRICTED,
1010
+ cloud: deps.workspaceCloud
1011
+ });
1012
+ const creationWall = { person: limiters.workspaceCreate, address: limiters.workspaceCreateAddress };
1013
+ const canCreateWorkspace = async (
1014
+ userId: string,
1015
+ via: 'session' | 'api_key' | 'oauth',
1016
+ address: string
1017
+ ) => {
1018
+ try {
1019
+ if ((await workspaceCreationRefusal(db, creationPolicy, { userId, via })) !== null) return false;
1020
+ // Honest about the rate wall too (read-only, nothing is spent): a
1021
+ // person the route would answer 429 is not offered the entry.
1022
+ return !(await workspaceCreateWallClosed(creationWall, address, userId));
1023
+ } catch (cause) {
1024
+ logger.warn({ err: cause }, '/me: canCreateWorkspace could not be computed — answering false');
1025
+ return false;
1026
+ }
1027
+ };
1028
+
1029
+ const CREDENTIAL_PROVIDER_ID = 'credential';
1030
+ // Cloud only (null on oss): the hub's no-access url, built once here where
1031
+ // the hub is known and handed to the session extras by both callers.
1032
+ const hubNoAccessUrl = hub
1033
+ ? `${hub.issuerUrl.replace(/\/+$/, '')}/no-access?client_id=${encodeURIComponent(hub.clientId)}`
1034
+ : null;
1035
+ const cloudSessionExtras = async (
1036
+ userId: string,
1037
+ noAccessUrl: string
1038
+ ): Promise<{
1039
+ firstRunPending: boolean;
1040
+ ssoOnly: boolean;
1041
+ hubDenied: Array<{ id: string; name: string }>;
1042
+ hubNoAccessUrl: string;
1043
+ }> => {
1044
+ const [onboardingRows, accountRows] = await Promise.all([
1045
+ db
1046
+ .select({ dismissedAt: userOnboarding.dismissedAt })
1047
+ .from(userOnboarding)
1048
+ .where(eq(userOnboarding.userId, userId))
1049
+ .limit(1),
1050
+ db.select({ providerId: account.providerId }).from(account).where(eq(account.userId, userId))
1051
+ ]);
1052
+ const providers = new Set(accountRows.map((r) => r.providerId));
1053
+ return {
1054
+ firstRunPending: onboardingRows[0]?.dismissedAt == null,
1055
+ ssoOnly: providers.has(HUB_SSO_PROVIDER_ID) && !providers.has(CREDENTIAL_PROVIDER_ID),
1056
+ hubDenied: deps.hubDeniedOrgs?.(userId) ?? [],
1057
+ hubNoAccessUrl: noAccessUrl
1058
+ };
1059
+ };
1060
+
1061
+ // The zero-state re-admission: one cached reconcile pass, then the
1062
+ // session is resolved again (the selection rule picks the workspace) and
1063
+ // passed through the live gate exactly as authContext would. Any verdict
1064
+ // other than a resolved, admitted principal keeps the zero state.
1065
+ const readmitAfterReconcile = async (c: Context, userId: string): Promise<Principal | null> => {
1066
+ try {
1067
+ await deps.reconcileForZeroState?.(userId);
1068
+ const resolved = await resolveSessionPrincipal(c, chassisRegistry);
1069
+ if (!resolved || resolved.userId !== userId) return null;
1070
+ const admitted = await admitPrincipal(c, resolved, deps.principalGate);
1071
+ return admitted.ok ? admitted.principal : null;
1072
+ } catch (cause) {
1073
+ logger.warn({ err: cause, userId }, '/me: zero-state re-admission failed — answering the zero state');
1074
+ return null;
1075
+ }
1076
+ };
1077
+
1078
+ // ── GET /me — whoami across all credential paths ─────────────────────────
1079
+ // No blanket requireAuth: the ZERO-MEMBERSHIP session state is route-local
1080
+ // (user-scoped federation). A LIVE session whose user holds no active
1081
+ // membership — the cloud operator before break-glass, a hub user whose
1082
+ // last org was removed — resolves to a null principal (principal
1083
+ // resolution requires a membership) but must NOT read as "signed out":
1084
+ // /me answers 200 with `workspaces: [], workspace: null` so the dashboard
1085
+ // renders the no-organization zero state instead of a login bounce.
1086
+ // Machine credentials keep their 401: an invalid/zero-membership key or
1087
+ // bearer dies inside authContext before this handler ever runs, so the
1088
+ // session lookup below can only ever fire for cookie-authenticated
1089
+ // requests.
1090
+ api.openapi(meRoute, async (c) => {
1091
+ let principal = c.get('principal');
1092
+ let zeroStateUser: { id: string; email: string; name: string } | null = null;
1093
+ if (!principal) {
1094
+ // The zero state exists ONLY for the selector-less default request: a
1095
+ // session that NAMED a workspace and failed its membership check must
1096
+ // keep the fail-closed 401 (no oracle about the workspace, and the
1097
+ // dashboard's stale-selection self-heal keys on that failure). With
1098
+ // no selector, a live session resolving to null principal means
1099
+ // exactly "zero active memberships" (the shared resolveMembership
1100
+ // default rule).
1101
+ if (c.req.header(ACTIVE_WORKSPACE_HEADER)?.trim()) {
1102
+ return c.json(err('unauthenticated', 'Authentication required'), 401);
1103
+ }
1104
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
1105
+ if (!session?.user) {
1106
+ return c.json(err('unauthenticated', 'Authentication required'), 401);
1107
+ }
1108
+ // Re-admission (cloud): the live gate never runs for a principal-less
1109
+ // session, so the zero-state read runs the reconciler's pass itself
1110
+ // (cached ~10 s, single-flight, retry-throttled — a polling refusal
1111
+ // page is not a hub call per request). When the pass re-projected a
1112
+ // membership, the session resolves again through the SAME resolver
1113
+ // and gate authContext uses, and the answer is the normal shape.
1114
+ if (deps.reconcileForZeroState) {
1115
+ principal = await readmitAfterReconcile(c, session.user.id);
1116
+ }
1117
+ if (!principal) zeroStateUser = session.user;
1118
+ }
1119
+ if (!principal) {
1120
+ const user = zeroStateUser;
1121
+ if (!user) return c.json(err('unauthenticated', 'Authentication required'), 401);
1122
+ return c.json(
1123
+ {
1124
+ user: { id: user.id, email: user.email, name: user.name },
1125
+ workspace: null,
1126
+ role: null,
1127
+ origin: null,
1128
+ via: 'session' as const,
1129
+ scopes: null,
1130
+ apiKeyExpiresAt: null,
1131
+ workspaces: [],
1132
+ activeWorkspaceId: null,
1133
+ // The zero state's CTA target on cloud: organizations are created
1134
+ // at the hub, never locally (internal/federation.md).
1135
+ hubManageUrl: hubManaged?.manageUrl ?? null,
1136
+ // oss: always false here — a zero-membership user is not an active
1137
+ // member of this instance. cloud: true with a live hub link (their
1138
+ // next organization is created from this very state).
1139
+ canCreateWorkspace: hub ? await canCreateWorkspace(user.id, 'session', clientIp(c)) : false,
1140
+ // Cloud + session extras (SL-6): the zero state is session-only
1141
+ // by construction, so only the edition gate applies here.
1142
+ ...(hubNoAccessUrl !== null ? await cloudSessionExtras(user.id, hubNoAccessUrl) : {})
1143
+ },
1144
+ 200
1145
+ );
1146
+ }
1147
+ // Workspaces this credential can name (user-scoped credential model):
1148
+ // EVERY credential kind lists ALL of the user's active memberships — a
1149
+ // credential is the user, and the org is a per-request parameter. The
1150
+ // order mirrors the selection rule (default first, then oldest), but
1151
+ // clients must read the `default` flag explicitly, never infer it from
1152
+ // the index. (A PINNED key still lists everything: the pin restricts
1153
+ // which workspace the key can REACH, not what its holder may see —
1154
+ // /me answers about the user behind the credential.)
1155
+ const memberships = await db
1156
+ .select({
1157
+ id: workspaces.id,
1158
+ name: workspaces.name,
1159
+ role: workspaceMembers.role,
1160
+ centralAccountId: workspaces.centralAccountId,
1161
+ hubStatus: workspaces.hubStatus,
1162
+ isDefault: workspaceMembers.isDefault,
1163
+ lookTheme: workspaces.lookTheme,
1164
+ lookPattern: workspaces.lookPattern,
1165
+ lookField: workspaces.lookField,
1166
+ lookGrain: workspaces.lookGrain
1167
+ })
1168
+ .from(workspaceMembers)
1169
+ .innerJoin(workspaces, eq(workspaceMembers.workspaceId, workspaces.id))
1170
+ .where(and(eq(workspaceMembers.userId, principal.userId), eq(workspaceMembers.isActive, true)))
1171
+ .orderBy(desc(workspaceMembers.isDefault), asc(workspaceMembers.createdAt), asc(workspaceMembers.id));
1172
+ // hubOrigin is the BOOLEAN projection flag (P7); the hub org id rides
1173
+ // beside it since PRDCT-2947 (the person's own organization ids, which
1174
+ // `--org <name>` resolves against). Map explicitly, never spread the row.
1175
+ const wireWorkspaces = memberships.map((m) => ({
1176
+ id: m.id,
1177
+ name: m.name,
1178
+ role: m.role,
1179
+ hubOrigin: m.centralAccountId !== null,
1180
+ centralAccountId: m.centralAccountId,
1181
+ look: { theme: m.lookTheme, pattern: m.lookPattern, field: m.lookField, grain: m.lookGrain },
1182
+ suspended: m.hubStatus === 'suspended',
1183
+ default: m.isDefault
1184
+ }));
1185
+ const active = wireWorkspaces.find((m) => m.id === principal.workspaceId);
1186
+ return c.json(
1187
+ {
1188
+ user: { id: principal.userId, email: principal.email, name: principal.name },
1189
+ workspace: {
1190
+ id: principal.workspaceId,
1191
+ name: active?.name ?? '',
1192
+ // The ACTIVE workspace's flag comes from the principal itself —
1193
+ // accountRef IS the request workspace's centralAccountId, read
1194
+ // live by every credential resolver.
1195
+ hubOrigin: Boolean(principal.accountRef),
1196
+ look: active?.look ?? { theme: null, pattern: null, field: null, grain: null }
1197
+ },
1198
+ role: principal.role,
1199
+ origin: principal.origin,
1200
+ via: principal.via,
1201
+ scopes: principal.scopes ? [...principal.scopes] : null,
1202
+ apiKeyExpiresAt: principal.apiKeyExpiresAt ?? null,
1203
+ workspaces: wireWorkspaces,
1204
+ activeWorkspaceId: principal.workspaceId,
1205
+ hubManageUrl: hubManaged && principal.accountRef ? hubManaged.manageUrl : null,
1206
+ // A session a demo link opened creates no workspace (POST /workspaces
1207
+ // refuses it before the rule), so it is not offered one.
1208
+ canCreateWorkspace: c.get('demoPassId')
1209
+ ? false
1210
+ : await canCreateWorkspace(principal.userId, principal.via, clientIp(c)),
1211
+ // Cloud + SESSION only (SL-6): machine credentials never carry the
1212
+ // onboarding/hint-watch keys — the banner and the auto-sign-out are
1213
+ // browser concerns.
1214
+ ...(hubNoAccessUrl !== null && principal.via === 'session'
1215
+ ? await cloudSessionExtras(principal.userId, hubNoAccessUrl)
1216
+ : {})
1217
+ },
1218
+ 200
1219
+ );
1220
+ });
1221
+
1222
+ // First-run onboarding dismiss (SL-6): cloud-only — an oss boot leaves
1223
+ // POST /me/onboarding/dismiss to the JSON 404 terminator. Session-only
1224
+ // (unlisted in the machine scope allowlist; route-local session check).
1225
+ if (hub) {
1226
+ registerOnboardingRoutes(api, { db, auth });
1227
+ }
1228
+
1229
+ // Workspace creation (PRDCT-2444 / PRDCT-2443): one route, both editions.
1230
+ // Session-only — unlisted in the machine scope allowlist, re-checked in
1231
+ // the handler; self-audited into the NEW workspace (audit-exempt path).
1232
+ registerWorkspaceRoutes(api, {
1233
+ db,
1234
+ auth,
1235
+ audit,
1236
+ registry: chassisRegistry,
1237
+ logger,
1238
+ clientIp,
1239
+ wall: creationWall,
1240
+ maxPerUser: env.MAX_WORKSPACES_PER_USER,
1241
+ restricted: env.TOOL_RESTRICTED,
1242
+ cloud: deps.workspaceCloud,
1243
+ guestForbiddenMessage: tool.copy.guestForbidden,
1244
+ displayName: tool.identity.displayName
1245
+ });
1246
+
1247
+ // ── Platform modules ─────────────────────────────────────────────────────
1248
+ // Break-glass first: it self-authenticates (superadmin sessions may carry
1249
+ // NO membership, so principal can be null) and is deliberately UNLISTED in
1250
+ // the machine scope allowlist — keys/tokens 403 fail-closed above.
1251
+ registerBreakGlassRoutes(api, {
1252
+ db,
1253
+ auth,
1254
+ audit,
1255
+ logger,
1256
+ superadminEmails: env.SUPERADMIN_EMAILS
1257
+ });
1258
+ // CLI email-OTP → API-key mint: PUBLIC pre-auth routes (listed in
1259
+ // PUBLIC_API_PATHS) riding the emailOTP plugin; rate-limited above. Also
1260
+ // registers DELETE /cli/auth/key — the authenticated self-revoke (CLI
1261
+ // logout), NOT public, open to machines under the tool's write scope. On
1262
+ // cloud (hubSso present) the mint pair refuses 403 cli_otp_disabled — the
1263
+ // D1 hub-only entrance closure; the self-revoke stays open.
1264
+ registerCliAuthRoutes(api, {
1265
+ db,
1266
+ auth,
1267
+ email,
1268
+ apiKeys: apiKeyService,
1269
+ audit,
1270
+ logger,
1271
+ hubSso,
1272
+ cliKeyScopes: tool.scopes.cliKey,
1273
+ routes: { cliAuthCompleteRoute },
1274
+ displayName: tool.identity.displayName
1275
+ });
1276
+ // CLI cross-tool connect (internal/federation.md P5): PUBLIC exchange of a
1277
+ // hub-minted 120 s JWT (+ its H3 offline grant) for a USER-scoped API
1278
+ // key. Registered ONLY on cloud — an oss boot leaves the path to the JSON
1279
+ // 404 terminator below, so the self-host edition provably carries zero
1280
+ // hub surface here.
1281
+ if (hubSso && deps.hubGrant) {
1282
+ registerSsoConnectRoutes(api, {
1283
+ db,
1284
+ auth,
1285
+ hubSso,
1286
+ grant: deps.hubGrant,
1287
+ apiKeys: apiKeyService,
1288
+ audit,
1289
+ logger,
1290
+ cliKeyScopes: tool.scopes.cliKey,
1291
+ routes: { ssoCliConnectRoute }
1292
+ });
1293
+ }
1294
+ // Single logout (SL-2): cloud-only like /sso/cli-connect — an oss boot
1295
+ // leaves POST /sso/logout to the JSON 404 terminator. Deliberately
1296
+ // UNLISTED in the machine scope allowlist: sessions only (incl.
1297
+ // zero-membership — the handler resolves the session route-locally).
1298
+ if (hubSso && deps.hubLogout && hub) {
1299
+ registerSsoLogoutRoutes(api, {
1300
+ auth,
1301
+ logout: deps.hubLogout,
1302
+ hint: {
1303
+ name: hub.hintCookieName,
1304
+ domain: hub.hintCookieDomain,
1305
+ secure: env.PUBLIC_BASE_URL.startsWith('https://')
1306
+ },
1307
+ logger
1308
+ });
1309
+ }
1310
+ registerMemberRoutes(api, {
1311
+ db,
1312
+ auth,
1313
+ publicBaseUrl: env.PUBLIC_BASE_URL,
1314
+ accountDeletion: deps.accountDeletion,
1315
+ hubManaged,
1316
+ guestTargetMessage: tool.copy.guestTarget,
1317
+ onMembershipRemoval: tool.membershipRemoval
1318
+ });
1319
+ // Teams: the tool's own on self-hosted (and in a cloud-local workspace), the
1320
+ // hub's projection in a hub-origin workspace, where the members' gate refuses the writes.
1321
+ registerTeamRoutes(api, { db, hubManaged });
1322
+ if (demoSignIn) {
1323
+ registerDemoPassRoutes(api, {
1324
+ db,
1325
+ publicBaseUrl: env.PUBLIC_BASE_URL,
1326
+ emailDomains: env.DEMO_SIGN_IN_EMAIL_DOMAINS,
1327
+ guestTargetMessage: tool.copy.guestTarget
1328
+ });
1329
+ }
1330
+ registerApiKeyRoutes(api, db, apiKeyService, { apiKeysListRoute, apiKeyCreateRoute, apiKeyRevokeRoute });
1331
+ registerInvitationRoutes(api, {
1332
+ db,
1333
+ env,
1334
+ auth,
1335
+ email,
1336
+ audit,
1337
+ registry: chassisRegistry,
1338
+ logger,
1339
+ mailBrand: { name: tool.identity.displayName, tagline: tool.copy.mail.tagline },
1340
+ inviteMail: { pitch: tool.copy.mail.invitePitch, preheader: tool.copy.mail.invitePreheader },
1341
+ hubManaged,
1342
+ // CLOUD-5: no local-password accounts minted through invitations on cloud.
1343
+ ssoOnly: Boolean(deps.hubSso),
1344
+ ...(demoSignIn ? { demoSession: demoSessionJudge(db) } : {})
1345
+ });
1346
+ // Projects: a subgroup of the workspace with its own members. Deliberately
1347
+ // handed NO `hubManaged`: project membership is the tool's own on both
1348
+ // editions, picked among the workspace's existing members.
1349
+ registerProjectRoutes(api, { db });
1350
+ registerAuditRoutes(api, db);
1351
+ registerExportRoutes(api, {
1352
+ db,
1353
+ env,
1354
+ storage: deps.storage,
1355
+ audit,
1356
+ limiters,
1357
+ clientIp,
1358
+ logger,
1359
+ workspaceExportRoute: tool.scopes.contractRoutes.workspaceExportRoute,
1360
+ // The tool's export entries, bound to its domain once, here. No slot: no field.
1361
+ ...(tool.api.exportEntries ? { exportEntries: tool.api.exportEntries(deps.domain) } : {})
1362
+ });
1363
+
1364
+ registerFileRoutes(api, {
1365
+ service: deps.fileService,
1366
+ storage: deps.storage,
1367
+ registry: chassisRegistry,
1368
+ env,
1369
+ logger,
1370
+ // The tool's blob policy. ADR 011 sharp edge closed: a blob referenced
1371
+ // by a live version manifest of the tool's resource is not deletable
1372
+ // through the generic files surface; SL-B1: the generic files surface
1373
+ // authorizes per RESOURCE, not per workspace — the ADR 013 policy expressed
1374
+ // as a WHERE predicate.
1375
+ ...tool.api.filePolicy(deps.domain),
1376
+ fileDeleteRoute: tool.scopes.contractRoutes.fileDeleteRoute,
1377
+ fileInUse: tool.copy.fileInUse
1378
+ });
1379
+ // The tool's own routes: AFTER the files routes, BEFORE the OpenAPI
1380
+ // document and the JSON 404. The tool keeps its own internal order.
1381
+ const routesContext: ApiRoutesContext<TEnvShape, TBuckets, TEvents> = { ...rateLimitContext, instanceId };
1382
+ tool.api.routes?.(api, routesContext, deps.domain);
1383
+
1384
+ // Generated ONCE here, at the end of route registration — never per
1385
+ // request (api/openapi-doc.ts explains why that mattered).
1386
+ registerOpenApiDoc(api, {
1387
+ openapi: '3.1.0',
1388
+ info: {
1389
+ title: 'Platform API',
1390
+ version: env.APP_VERSION
1391
+ },
1392
+ servers: [{ url: '/api/v1' }]
1393
+ });
1394
+
1395
+ // ── JSON 404 terminator — registered LAST ────────────────────────────────
1396
+ // An unmatched /api/v1 path answers the API wire shape instead of falling
1397
+ // through to the SPA catch-all (which would hand a browser session the
1398
+ // dashboard HTML with a 200). Machine principals never reach this: the
1399
+ // fail-closed scope gate already 403s every unlisted endpoint above.
1400
+ api.all('*', (c) => c.json(err('not_found', 'Not found'), 404));
1401
+
1402
+ return api;
1403
+ }