@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,1333 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { OpenAPIHono } from '@hono/zod-openapi';
3
+ import type { Context } from 'hono';
4
+ import {
5
+ declareRouteEntitlements,
6
+ declaredContentLength,
7
+ auditedSizeBytes,
8
+ type ActorRef,
9
+ type EntitlementDecision,
10
+ type Principal,
11
+ type ToolEntitlements,
12
+ type UsageEvent
13
+ } from '@antasphere/chassis-contract';
14
+ import {
15
+ assertToolEntitlements,
16
+ honoPath,
17
+ isDeferringGate,
18
+ registerEntitlementGate,
19
+ type CreditCheckRequest,
20
+ type CreditVerdict,
21
+ type EntitlementCloud
22
+ } from '../../src/entitlements/index.js';
23
+ import { EntitlementProfiles, HubMachineToken } from '../../src/entitlements/index.js';
24
+ import type { Logger } from '../../src/logger.js';
25
+
26
+ /**
27
+ * The one entitlement gate, on a bare Hono app (the billing rail, §7): the
28
+ * order feature → limit → credits on cloud, the credit check first and the
29
+ * oss value after it on oss, 403 `plan_required` with its details, 413
30
+ * `entitlement_denied` byte for byte, the event's enrichment (the hub user,
31
+ * the `via`, the resource the handler recorded, the stored size), no event
32
+ * on a refusal, on a 4xx, on oss or on a cloud-local workspace, the `actor`
33
+ * hook's shape, and the OpenAPI path spelled the way Hono registers it.
34
+ * Phase 2 (PRDCT-2664): on a metered account the hub's credit check replaces
35
+ * the local one (402 entitlement_denied with the top-up link, 403
36
+ * account_suspended, 403 hub_unavailable), an upload that declares no size
37
+ * meets 411 (PRDCT-2652), and the upgrade link is the hub's page for the
38
+ * organization with the key and the required plan appended.
39
+ */
40
+
41
+ /** Every warning the gate logged (a throwing hook must be visible to an operator, PRDCT-2702). */
42
+ const warnings: Array<{ msg: string; fields: Record<string, unknown> }> = [];
43
+ const logger = {
44
+ error: () => {},
45
+ warn: (fields: Record<string, unknown>, msg: string) => void warnings.push({ msg, fields }),
46
+ info: () => {},
47
+ debug: () => {}
48
+ } as unknown as Logger;
49
+
50
+ const TOOL: ToolEntitlements = {
51
+ actions: [
52
+ { key: 'things.make', creditsPerUnit: 10, unit: 'call', label: 'Make a thing' },
53
+ { key: 'files.upload', creditsPerUnit: 5, unit: 'bytes', label: 'Upload' }
54
+ ],
55
+ limits: {
56
+ 'files.maxBytes': { oss: 100, free: 50, pro: 500 },
57
+ 'things.max': { oss: null, free: 2, pro: null },
58
+ // A count limit with an operator's ceiling on oss (PRDCT-2702).
59
+ 'things.capped': { oss: 2, free: 1, pro: 2 }
60
+ },
61
+ features: { premium: { free: false, pro: true }, nowhere: { free: false, pro: false } }
62
+ };
63
+
64
+ const ROUTES = declareRouteEntitlements([
65
+ { route: { method: 'post', path: '/things' }, meter: { key: 'things.make', unit: 'call' } },
66
+ {
67
+ route: { method: 'post', path: '/files' },
68
+ meter: { key: 'files.upload', unit: 'bytes', quantity: auditedSizeBytes },
69
+ limit: { key: 'files.maxBytes', value: declaredContentLength }
70
+ },
71
+ { route: { method: 'post', path: '/things/{id}/premium' }, feature: 'premium' },
72
+ {
73
+ route: { method: 'post', path: '/premium-files' },
74
+ feature: 'premium',
75
+ limit: { key: 'files.maxBytes', value: declaredContentLength }
76
+ },
77
+ { route: { method: 'post', path: '/things/{id}/nowhere' }, feature: 'nowhere' },
78
+ {
79
+ route: { method: 'post', path: '/owned/{id}' },
80
+ meter: {
81
+ key: 'things.make',
82
+ unit: 'call',
83
+ actor: (ctx): ActorRef => ({
84
+ userId: null,
85
+ workspaceId: `ws-of-${ctx.params.id}`,
86
+ accountRef: 'acct-owner'
87
+ })
88
+ }
89
+ },
90
+ // An anonymous surface whose hook fails (a lookup error): left to the route.
91
+ {
92
+ route: { method: 'post', path: '/exploding/{id}' },
93
+ meter: {
94
+ key: 'things.make',
95
+ unit: 'call',
96
+ actor: () => {
97
+ throw new Error('lookup failed');
98
+ }
99
+ }
100
+ },
101
+ // An anonymous surface whose hook resolves nothing (an unknown secret).
102
+ {
103
+ route: { method: 'post', path: '/unknown/{id}' },
104
+ meter: { key: 'things.make', unit: 'call', actor: () => null }
105
+ },
106
+ // An anonymous upload door: metered in bytes, no plan limit declared.
107
+ {
108
+ route: { method: 'post', path: '/owned-files/{id}' },
109
+ meter: {
110
+ key: 'files.upload',
111
+ unit: 'bytes',
112
+ quantity: auditedSizeBytes,
113
+ actor: ownerActor
114
+ }
115
+ },
116
+ // An anonymous surface behind a feature (none exists today; the shape is pinned).
117
+ {
118
+ route: { method: 'post', path: '/owned-premium/{id}' },
119
+ feature: 'premium',
120
+ meter: { key: 'things.make', unit: 'call', actor: ownerActor }
121
+ },
122
+ // A count limit read from the path: no meter, the body's size is not judged.
123
+ {
124
+ route: { method: 'post', path: '/count/{n}' },
125
+ limit: { key: 'things.max', value: (ctx) => Number(ctx.params.n) }
126
+ },
127
+ // A size limit with no meter: the limit judges the declared length.
128
+ {
129
+ route: { method: 'post', path: '/sized' },
130
+ limit: { key: 'files.maxBytes', value: declaredContentLength }
131
+ },
132
+ // An owner-attributed surface whose owner has no account (a cloud-local workspace).
133
+ {
134
+ route: { method: 'post', path: '/owned-local/{id}' },
135
+ meter: {
136
+ key: 'things.make',
137
+ unit: 'call',
138
+ actor: (ctx): ActorRef => ({ userId: null, workspaceId: `ws-of-${ctx.params.id}` })
139
+ }
140
+ },
141
+ // ── PRDCT-2702: what a count limit and a conditional feature need ──
142
+ // A count read from the BODY, asynchronously: the count after this action.
143
+ {
144
+ route: { method: 'post', path: '/counted' },
145
+ limit: { key: 'things.max', value: countAfter }
146
+ },
147
+ // The same count against the operator's ceiling on oss.
148
+ {
149
+ route: { method: 'post', path: '/counted-capped' },
150
+ limit: { key: 'things.capped', value: countAfter }
151
+ },
152
+ // A hook that says there is nothing to judge, or fails: the route answers.
153
+ {
154
+ route: { method: 'post', path: '/counted-maybe/{what}' },
155
+ limit: {
156
+ key: 'things.max',
157
+ value: (ctx) => {
158
+ if (ctx.params.what === 'throws') throw new Error('lookup failed');
159
+ return ctx.params.what === 'none' ? null : 9;
160
+ }
161
+ }
162
+ },
163
+ // A feature the route needs only when the body sets a password.
164
+ {
165
+ route: { method: 'post', path: '/lockable' },
166
+ feature: { key: 'premium', when: passwordSet }
167
+ },
168
+ // A feature on a metered route, conditional too (the mint of a share link).
169
+ {
170
+ route: { method: 'post', path: '/lockable-metered' },
171
+ meter: { key: 'things.make', unit: 'call' },
172
+ feature: { key: 'premium', when: passwordSet },
173
+ limit: { key: 'things.max', value: countAfter }
174
+ },
175
+ // A public door with an entry-level actor and a count: the workspace the
176
+ // door opens is judged, the caller is a viewer.
177
+ {
178
+ route: { method: 'post', path: '/door/{id}' },
179
+ actor: ownerActor,
180
+ limit: { key: 'things.max', value: countAfter }
181
+ },
182
+ // A public door whose feature is conditional, no meter.
183
+ {
184
+ route: { method: 'post', path: '/door-lockable/{id}' },
185
+ actor: ownerActor,
186
+ feature: { key: 'premium', when: passwordSet }
187
+ }
188
+ ]);
189
+
190
+ /** The count after this action, read from the body: `{ count }` is the count before it. */
191
+ async function countAfter(ctx: { body: () => Promise<unknown> }): Promise<number | null> {
192
+ const body = (await ctx.body()) as { count?: unknown } | undefined;
193
+ return typeof body?.count === 'number' ? body.count + 1 : null;
194
+ }
195
+
196
+ /** Whether the body sets a password (a string; null is a removal, undefined is no change). */
197
+ async function passwordSet(ctx: { body: () => Promise<unknown> }): Promise<boolean> {
198
+ const body = (await ctx.body()) as { password?: unknown } | undefined;
199
+ return typeof body?.password === 'string';
200
+ }
201
+
202
+ /** How many times an anonymous hook ran (the oss case must never run it). */
203
+ const hookRuns = { count: 0 };
204
+ function ownerActor(ctx: { params: Record<string, string> }): ActorRef {
205
+ hookRuns.count += 1;
206
+ return { userId: null, workspaceId: `ws-of-${ctx.params.id}`, accountRef: 'acct-owner' };
207
+ }
208
+
209
+ function principal(over: Partial<Principal> = {}): Principal {
210
+ return {
211
+ userId: 'user-1',
212
+ email: 'u@example.test',
213
+ name: 'U',
214
+ workspaceId: 'ws-1',
215
+ role: 'member',
216
+ origin: 'hub',
217
+ via: 'session',
218
+ scopes: null,
219
+ accountRef: 'acct-1',
220
+ ...over
221
+ };
222
+ }
223
+
224
+ interface Fixture {
225
+ app: OpenAPIHono;
226
+ emitted: UsageEvent[];
227
+ checks: Array<{ key: string; quantity: number; unit: string }>;
228
+ /** Every call of the hub's credit check (the stubbed `cloud.credits`). */
229
+ creditChecks: CreditCheckRequest[];
230
+ hub: { profile: unknown; reads: number };
231
+ }
232
+
233
+ /** The hub's upgrade page for the organization, as every hub since 0.11.0 sends it on the plan read. */
234
+ const HUB_UPGRADE_PAGE = 'https://hub.test/billing/upgrade?org=acct-1&tool=things&plan=free';
235
+
236
+ function fixture(opts: {
237
+ cloud: boolean;
238
+ principal: Principal | null;
239
+ decision?: EntitlementDecision;
240
+ hubProfile?: unknown;
241
+ handler?: (c: Context) => Response | Promise<Response>;
242
+ /** A size refusal the cap deferred to the gate (PRDCT-2632), parked on every request. */
243
+ deferredRefusal?: boolean;
244
+ /** The hub's credit check verdict; allowed by the hub when absent. */
245
+ credits?: (req: CreditCheckRequest) => CreditVerdict;
246
+ }): Fixture {
247
+ const app = new OpenAPIHono();
248
+ const emitted: UsageEvent[] = [];
249
+ const checks: Array<{ key: string; quantity: number; unit: string }> = [];
250
+ const creditChecks: CreditCheckRequest[] = [];
251
+ const hub = { profile: opts.hubProfile ?? { plan: 'free' }, reads: 0 };
252
+ app.use('*', async (c, next) => {
253
+ c.set('principal', opts.principal);
254
+ if (opts.deferredRefusal) {
255
+ c.set('bodyRefusal', () => c.json({ error: { code: 'file_too_large', message: 'the cap' } }, 413));
256
+ }
257
+ await next();
258
+ });
259
+ const fetchImpl = (async (input: string | URL | Request) => {
260
+ if (String(input).includes('/oauth2/token'))
261
+ return Response.json({ access_token: 'mach', expires_in: 900 });
262
+ hub.reads += 1;
263
+ // The hub's whole answer shape (the contract's copy, checked against the
264
+ // hub's wire snapshot; `upgradeUrl` required since PRDCT-2677): the
265
+ // test's `hubProfile` overrides its fields.
266
+ return Response.json({
267
+ accountRef: '77777777-aaaa-4bbb-8ccc-000000000001',
268
+ planUntil: null,
269
+ limits: {},
270
+ features: [],
271
+ upgradeUrl: HUB_UPGRADE_PAGE,
272
+ ...(hub.profile as object)
273
+ });
274
+ }) as typeof fetch;
275
+ const token = new HubMachineToken({
276
+ issuerUrl: 'https://hub.test',
277
+ clientId: 'c',
278
+ clientSecret: 's'.repeat(20),
279
+ resource: 'https://hub.test/mcp',
280
+ logger,
281
+ fetchImpl
282
+ });
283
+ const cloud: EntitlementCloud | undefined = opts.cloud
284
+ ? {
285
+ profiles: new EntitlementProfiles({ issuerUrl: 'https://hub.test', token, logger, fetchImpl }),
286
+ upgradeUrl: 'https://hub.test',
287
+ credits: {
288
+ check: async (req) => {
289
+ creditChecks.push(req);
290
+ return opts.credits?.(req) ?? { allowed: true, source: 'hub', check: null };
291
+ }
292
+ },
293
+ hubSubject: async (userId) => (userId === 'user-1' ? 'hub-sub-1' : null)
294
+ }
295
+ : undefined;
296
+ registerEntitlementGate(app, {
297
+ declarations: ROUTES,
298
+ tool: TOOL,
299
+ entitlements: {
300
+ check: async (_p, action) => {
301
+ checks.push(action);
302
+ return opts.decision ?? { allowed: true };
303
+ },
304
+ getRequestQuota: async () => ({ perMinute: 0, burstPerSecond: 0 })
305
+ },
306
+ usage: { emit: async (e) => void emitted.push(e) },
307
+ cloud,
308
+ source: async () => ({ instanceId: 'inst', edition: opts.cloud ? 'cloud' : 'oss', version: 'test' }),
309
+ logger
310
+ });
311
+ const handler =
312
+ opts.handler ??
313
+ ((c) => {
314
+ c.set('audit', {
315
+ action: 'x',
316
+ resourceType: 'thing',
317
+ resourceId: 'thing-9',
318
+ metadata: { sizeBytes: 7 }
319
+ });
320
+ return c.json({ ok: true }, 201);
321
+ });
322
+ app.post('/things', handler);
323
+ app.post('/files', handler);
324
+ app.post('/exploding/:id', handler);
325
+ app.post('/unknown/:id', handler);
326
+ app.post('/owned-files/:id', handler);
327
+ app.post('/owned-premium/:id', handler);
328
+ app.post('/things/:id/premium', handler);
329
+ app.post('/premium-files', handler);
330
+ app.post('/things/:id/nowhere', handler);
331
+ app.post('/owned/:id', handler);
332
+ app.post('/count/:n', handler);
333
+ app.post('/sized', handler);
334
+ app.post('/owned-local/:id', handler);
335
+ app.post('/counted', handler);
336
+ app.post('/counted-capped', handler);
337
+ app.post('/counted-maybe/:what', handler);
338
+ app.post('/lockable', handler);
339
+ app.post('/lockable-metered', handler);
340
+ app.post('/door/:id', handler);
341
+ app.post('/door-lockable/:id', handler);
342
+ return { app, emitted, checks, creditChecks, hub };
343
+ }
344
+
345
+ const tick = () => new Promise((r) => setTimeout(r, 5));
346
+
347
+ /** The wire's error body, typed for the assertions. */
348
+ const errorOf = (res: Response) =>
349
+ res.json() as Promise<{ error: { code: string; message: string; details?: unknown } }>;
350
+
351
+ /** A principal of a cloud-LOCAL (or oss) workspace: no account behind it. */
352
+ function localPrincipal(over: Partial<Principal> = {}): Principal {
353
+ const p = principal({ origin: 'local', ...over });
354
+ delete (p as Partial<Principal>).accountRef;
355
+ return p;
356
+ }
357
+
358
+ describe('honoPath', () => {
359
+ it('spells the OpenAPI path the way Hono registers it', () => {
360
+ expect(honoPath('/things/{id}/versions/{version}')).toBe('/things/:id/versions/:version');
361
+ expect(honoPath('/files')).toBe('/files');
362
+ });
363
+ });
364
+
365
+ describe('the gate on cloud, a hub-projected workspace', () => {
366
+ it('emits one enriched event after a 2xx: the hub user, the via, the resource, the stored size', async () => {
367
+ const f = fixture({ cloud: true, principal: principal({ via: 'api_key' }) });
368
+ const res = await f.app.request('/files', {
369
+ method: 'POST',
370
+ headers: { 'content-length': '40' },
371
+ body: 'x'.repeat(40)
372
+ });
373
+ expect(res.status).toBe(201);
374
+ await tick();
375
+ expect(f.emitted).toHaveLength(1);
376
+ expect(f.emitted[0]).toMatchObject({
377
+ meter: 'files.upload',
378
+ actionKey: 'files.upload',
379
+ quantity: 7,
380
+ unit: 'bytes',
381
+ workspaceId: 'ws-1',
382
+ accountRef: 'acct-1',
383
+ userId: 'hub-sub-1',
384
+ via: 'api_key',
385
+ resourceType: 'thing',
386
+ resourceId: 'thing-9',
387
+ source: { instanceId: 'inst', edition: 'cloud', version: 'test' }
388
+ });
389
+ // The body never names the tool: the hub takes it from the token (PRDCT-2629).
390
+ expect(f.emitted[0]).not.toHaveProperty('toolSlug');
391
+ expect(f.emitted[0]!.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
392
+ // The hub's check saw the DECLARED size, the emit the stored one; the
393
+ // local check is not asked on a metered account (PRDCT-2653).
394
+ expect(f.creditChecks).toEqual([
395
+ { accountRef: 'acct-1', actionKey: 'files.upload', quantity: 40, unit: 'bytes' }
396
+ ]);
397
+ expect(f.checks).toEqual([]);
398
+ expect(f.hub.reads).toBe(1);
399
+ });
400
+
401
+ it('refuses a limit over the tier value with 403 plan_required and the details, before the credit check, and emits nothing', async () => {
402
+ const f = fixture({ cloud: true, principal: principal() });
403
+ const res = await f.app.request('/files', {
404
+ method: 'POST',
405
+ headers: { 'content-length': '60' },
406
+ body: 'x'.repeat(60)
407
+ });
408
+ expect(res.status).toBe(403);
409
+ const body = await errorOf(res);
410
+ expect(body.error.code).toBe('plan_required');
411
+ expect(body.error.details).toEqual({
412
+ key: 'files.maxBytes',
413
+ plan: 'free',
414
+ requiredPlan: 'pro',
415
+ upgradeUrl: `${HUB_UPGRADE_PAGE}&key=files.maxBytes&requiredPlan=pro`
416
+ });
417
+ expect(f.checks).toEqual([]);
418
+ expect(f.creditChecks).toEqual([]);
419
+ await tick();
420
+ expect(f.emitted).toEqual([]);
421
+ });
422
+
423
+ it('the feature is judged before the limit: a request refused on both names the feature', async () => {
424
+ const f = fixture({ cloud: true, principal: principal() });
425
+ const res = await f.app.request('/premium-files', {
426
+ method: 'POST',
427
+ headers: { 'content-length': '60' },
428
+ body: 'x'.repeat(60)
429
+ });
430
+ expect(res.status).toBe(403);
431
+ expect((await errorOf(res)).error.details).toMatchObject({ key: 'premium', requiredPlan: 'pro' });
432
+ // A pro account passes the feature and is then held to the pro limit.
433
+ const pro = fixture({ cloud: true, principal: principal(), hubProfile: { plan: 'pro' } });
434
+ const over = await pro.app.request('/premium-files', {
435
+ method: 'POST',
436
+ headers: { 'content-length': '600' },
437
+ body: 'x'.repeat(600)
438
+ });
439
+ expect((await errorOf(over)).error.details).toMatchObject({
440
+ key: 'files.maxBytes',
441
+ plan: 'pro',
442
+ requiredPlan: null
443
+ });
444
+ });
445
+
446
+ it('a feature the plan lacks is 403 plan_required naming the plan that has it; one no plan has says so', async () => {
447
+ const f = fixture({ cloud: true, principal: principal() });
448
+ const premium = await f.app.request('/things/9/premium', { method: 'POST' });
449
+ expect(premium.status).toBe(403);
450
+ expect((await errorOf(premium)).error.details).toMatchObject({
451
+ key: 'premium',
452
+ plan: 'free',
453
+ requiredPlan: 'pro'
454
+ });
455
+ const nowhere = await f.app.request('/things/9/nowhere', { method: 'POST' });
456
+ expect(nowhere.status).toBe(403);
457
+ expect((await errorOf(nowhere)).error.details).toMatchObject({ key: 'nowhere', requiredPlan: null });
458
+ });
459
+
460
+ it('the hub’s plan and overrides decide: a pro account passes the feature and the wider limit', async () => {
461
+ const f = fixture({ cloud: true, principal: principal(), hubProfile: { plan: 'pro' } });
462
+ expect((await f.app.request('/things/9/premium', { method: 'POST' })).status).toBe(201);
463
+ expect(
464
+ (
465
+ await f.app.request('/files', {
466
+ method: 'POST',
467
+ headers: { 'content-length': '400' },
468
+ body: 'x'.repeat(400)
469
+ })
470
+ ).status
471
+ ).toBe(201);
472
+ const over = fixture({
473
+ cloud: true,
474
+ principal: principal(),
475
+ hubProfile: { plan: 'free', limits: { 'files.maxBytes': 70 } }
476
+ });
477
+ expect(
478
+ (
479
+ await over.app.request('/files', {
480
+ method: 'POST',
481
+ headers: { 'content-length': '60' },
482
+ body: 'x'.repeat(60)
483
+ })
484
+ ).status
485
+ ).toBe(201);
486
+ });
487
+
488
+ it('a short balance is 402 entitlement_denied with the price, the balance and the top-up link, and emits nothing', async () => {
489
+ const topUpUrl = 'https://hub.test/billing/top-up?org=acct-1&credits=10&balance=3';
490
+ const f = fixture({
491
+ cloud: true,
492
+ principal: principal(),
493
+ credits: (req) => ({
494
+ allowed: false,
495
+ reason: 'insufficient_credits',
496
+ source: 'hub',
497
+ check: {
498
+ accountRef: req.accountRef,
499
+ actionKey: req.actionKey,
500
+ quantity: req.quantity,
501
+ allowed: false,
502
+ credits: 10,
503
+ balance: 3,
504
+ unit: 'call',
505
+ priced: true,
506
+ plan: 'free',
507
+ reason: 'insufficient_credits',
508
+ topUpUrl
509
+ }
510
+ })
511
+ });
512
+ const res = await f.app.request('/things', { method: 'POST' });
513
+ expect(res.status).toBe(402);
514
+ expect(await errorOf(res)).toEqual({
515
+ error: {
516
+ code: 'entitlement_denied',
517
+ message: `This needs 10 credits and the organization holds 3; top up at ${topUpUrl}`,
518
+ details: { credits: 10, balance: 3, topUpUrl }
519
+ }
520
+ });
521
+ await tick();
522
+ expect(f.emitted).toEqual([]);
523
+ });
524
+
525
+ it('a suspended organization is 403 account_suspended, an outage past the window 403 hub_unavailable, in the live gate’s words', async () => {
526
+ const suspended = fixture({
527
+ cloud: true,
528
+ principal: principal(),
529
+ credits: () => ({ allowed: false, reason: 'account_suspended', source: 'hub', check: null })
530
+ });
531
+ const s = await suspended.app.request('/things', { method: 'POST' });
532
+ expect(s.status).toBe(403);
533
+ expect(await errorOf(s)).toEqual({
534
+ error: { code: 'account_suspended', message: 'This organization is suspended on Antasphere' }
535
+ });
536
+ const closed = fixture({
537
+ cloud: true,
538
+ principal: principal(),
539
+ credits: () => ({ allowed: false, reason: 'hub_unavailable', source: 'closed', check: null })
540
+ });
541
+ const c = await closed.app.request('/things', { method: 'POST' });
542
+ expect(c.status).toBe(403);
543
+ expect(await errorOf(c)).toEqual({
544
+ error: {
545
+ code: 'hub_unavailable',
546
+ message:
547
+ 'The Antasphere hub has been unreachable for too long; requests are refused until it recovers'
548
+ }
549
+ });
550
+ await tick();
551
+ expect(suspended.emitted).toEqual([]);
552
+ expect(closed.emitted).toEqual([]);
553
+ });
554
+
555
+ it('a quantity the hub cannot price is 413 entitlement_denied with the price and the balance, no top-up link, and emits nothing (PRDCT-2677)', async () => {
556
+ const topUpUrl = 'https://hub.test/billing/top-up?org=acct-1&credits=9007199254740991&balance=3';
557
+ const f = fixture({
558
+ cloud: true,
559
+ principal: principal(),
560
+ credits: (req) => ({
561
+ allowed: false,
562
+ reason: 'unpriceable',
563
+ source: 'hub',
564
+ check: {
565
+ accountRef: req.accountRef,
566
+ actionKey: req.actionKey,
567
+ quantity: req.quantity,
568
+ allowed: false,
569
+ credits: Number.MAX_SAFE_INTEGER,
570
+ balance: 3,
571
+ unit: 'call',
572
+ priced: true,
573
+ plan: 'free',
574
+ reason: 'unpriceable',
575
+ topUpUrl
576
+ }
577
+ })
578
+ });
579
+ const res = await f.app.request('/things', { method: 'POST' });
580
+ expect(res.status).toBe(413);
581
+ expect(await errorOf(res)).toEqual({
582
+ error: {
583
+ code: 'entitlement_denied',
584
+ message:
585
+ 'This quantity cannot be priced (9007199254740991 credits or more, the organization holds 3); nothing was charged',
586
+ // No link: a top-up cannot cover it, and a link would draw the top-up card (verifier round 1).
587
+ details: { credits: Number.MAX_SAFE_INTEGER, balance: 3 }
588
+ }
589
+ });
590
+ await tick();
591
+ expect(f.emitted).toEqual([]);
592
+ });
593
+
594
+ it('the local credit check is never asked on a metered account (PRDCT-2653): its refusal does not reach the request', async () => {
595
+ const f = fixture({
596
+ cloud: true,
597
+ principal: principal(),
598
+ hubProfile: { plan: 'pro' },
599
+ decision: { allowed: false, reason: 'file exceeds MAX_FILE_SIZE_MB (1MB)' }
600
+ });
601
+ const res = await f.app.request('/files', {
602
+ method: 'POST',
603
+ headers: { 'content-length': '400' },
604
+ body: 'x'.repeat(400)
605
+ });
606
+ expect(res.status).toBe(201);
607
+ expect(f.checks).toEqual([]);
608
+ expect(f.creditChecks).toHaveLength(1);
609
+ });
610
+
611
+ it('a metered upload that declares no size is 411 length_required before the handler, the check and the event (PRDCT-2652)', async () => {
612
+ const f = fixture({ cloud: true, principal: principal() });
613
+ const res = await f.app.request('/files', { method: 'POST', body: 'x'.repeat(40) });
614
+ expect(res.status).toBe(411);
615
+ expect(await errorOf(res)).toEqual({
616
+ error: { code: 'length_required', message: 'A metered upload must declare its size (Content-Length)' }
617
+ });
618
+ expect(f.creditChecks).toEqual([]);
619
+ await tick();
620
+ expect(f.emitted).toEqual([]);
621
+ // A declared zero is a declaration: it passes.
622
+ expect(
623
+ (await f.app.request('/files', { method: 'POST', headers: { 'content-length': '0' } })).status
624
+ ).toBe(201);
625
+ // A metered route without a limit does not judge a size: no 411.
626
+ expect((await f.app.request('/things', { method: 'POST', body: 'x' })).status).toBe(201);
627
+ });
628
+
629
+ it('no 411 where no plan applies: oss, and a cloud-local workspace', async () => {
630
+ const oss = fixture({ cloud: false, principal: localPrincipal() });
631
+ expect((await oss.app.request('/files', { method: 'POST', body: 'x'.repeat(40) })).status).toBe(201);
632
+ const local = fixture({ cloud: true, principal: localPrincipal() });
633
+ expect((await local.app.request('/files', { method: 'POST', body: 'x'.repeat(40) })).status).toBe(201);
634
+ expect(local.creditChecks).toEqual([]);
635
+ });
636
+
637
+ it('the upgrade link is the hub’s page for the organization with the key and the required plan appended', async () => {
638
+ const page = 'https://hub.test/billing/upgrade?org=acct-1&tool=things&plan=free';
639
+ const f = fixture({
640
+ cloud: true,
641
+ principal: principal(),
642
+ hubProfile: { plan: 'free', upgradeUrl: page }
643
+ });
644
+ const over = await f.app.request('/files', {
645
+ method: 'POST',
646
+ headers: { 'content-length': '60' },
647
+ body: 'x'.repeat(60)
648
+ });
649
+ expect(over.status).toBe(403);
650
+ expect((await errorOf(over)).error.details).toEqual({
651
+ key: 'files.maxBytes',
652
+ plan: 'free',
653
+ requiredPlan: 'pro',
654
+ upgradeUrl: `${page}&key=files.maxBytes&requiredPlan=pro`
655
+ });
656
+ // No plan has the feature: the key alone is appended.
657
+ const nowhere = await f.app.request('/things/9/nowhere', { method: 'POST' });
658
+ expect((await errorOf(nowhere)).error.details).toMatchObject({
659
+ requiredPlan: null,
660
+ upgradeUrl: `${page}&key=nowhere`
661
+ });
662
+ });
663
+
664
+ it('a handler refusal (4xx) emits nothing', async () => {
665
+ const f = fixture({ cloud: true, principal: principal(), handler: (c) => c.json({ error: {} }, 409) });
666
+ expect((await f.app.request('/things', { method: 'POST' })).status).toBe(409);
667
+ await tick();
668
+ expect(f.emitted).toEqual([]);
669
+ });
670
+
671
+ it('a cloud-LOCAL workspace (no account) is unmetered: no plan, no hub read, no event, the oss value applies', async () => {
672
+ const f = fixture({ cloud: true, principal: localPrincipal() });
673
+ expect(
674
+ (
675
+ await f.app.request('/files', {
676
+ method: 'POST',
677
+ headers: { 'content-length': '60' },
678
+ body: 'x'.repeat(60)
679
+ })
680
+ ).status
681
+ ).toBe(201);
682
+ expect(f.hub.reads).toBe(0);
683
+ await tick();
684
+ expect(f.emitted).toEqual([]);
685
+ const over = await f.app.request('/files', {
686
+ method: 'POST',
687
+ headers: { 'content-length': '101' },
688
+ body: 'x'.repeat(101)
689
+ });
690
+ expect(over.status).toBe(413);
691
+ expect((await errorOf(over)).error.code).toBe('entitlement_denied');
692
+ });
693
+
694
+ it('a user with no hub link reports null, and the actor hook resolves the owner’s workspace and account', async () => {
695
+ const f = fixture({ cloud: true, principal: principal({ userId: 'local-only' }) });
696
+ expect((await f.app.request('/owned/42', { method: 'POST' })).status).toBe(201);
697
+ await tick();
698
+ expect(f.emitted[0]).toMatchObject({ userId: null, workspaceId: 'ws-of-42', accountRef: 'acct-owner' });
699
+ // The owner's account is the one the hub is asked to charge.
700
+ expect(f.creditChecks[0]).toMatchObject({
701
+ accountRef: 'acct-owner',
702
+ actionKey: 'things.make',
703
+ quantity: 1
704
+ });
705
+ expect((await f.app.request('/things', { method: 'POST' })).status).toBe(201);
706
+ await tick();
707
+ expect(f.emitted[1]).toMatchObject({ userId: null, workspaceId: 'ws-1', accountRef: 'acct-1' });
708
+ });
709
+
710
+ it('a deferred size refusal fires AFTER the plan check on a metered account: over the plan is 403 with the upgrade link, within the plan is the cap’s 413, and nothing is checked or emitted', async () => {
711
+ // Over the free cap (50): the plan refusal, not the cap's.
712
+ const free = fixture({ cloud: true, principal: principal(), deferredRefusal: true });
713
+ const over = await free.app.request('/files', {
714
+ method: 'POST',
715
+ headers: { 'content-length': '60' },
716
+ body: 'x'.repeat(60)
717
+ });
718
+ expect(over.status).toBe(403);
719
+ expect((await errorOf(over)).error.details).toMatchObject({ key: 'files.maxBytes', requiredPlan: 'pro' });
720
+ // The plan allows it (pro, 500): the instance's hard ceiling answers.
721
+ const pro = fixture({
722
+ cloud: true,
723
+ principal: principal(),
724
+ hubProfile: { plan: 'pro' },
725
+ deferredRefusal: true
726
+ });
727
+ const capped = await pro.app.request('/files', {
728
+ method: 'POST',
729
+ headers: { 'content-length': '400' },
730
+ body: 'x'.repeat(400)
731
+ });
732
+ expect(capped.status).toBe(413);
733
+ expect((await errorOf(capped)).error.code).toBe('file_too_large');
734
+ expect(pro.checks).toEqual([]);
735
+ expect(pro.creditChecks).toEqual([]);
736
+ await tick();
737
+ expect(pro.emitted).toEqual([]);
738
+ });
739
+
740
+ it('an OAuth bearer reports via oauth', async () => {
741
+ const f = fixture({ cloud: true, principal: principal({ via: 'oauth' }) });
742
+ await f.app.request('/things', { method: 'POST' });
743
+ await tick();
744
+ expect(f.emitted[0]).toMatchObject({ via: 'oauth' });
745
+ });
746
+
747
+ it('a deferred size refusal fires before anything else when no plan applies: no principal, or a cloud-local workspace', async () => {
748
+ const anonymous = fixture({ cloud: true, principal: null, deferredRefusal: true });
749
+ const res = await anonymous.app.request('/files', {
750
+ method: 'POST',
751
+ headers: { 'content-length': '9' },
752
+ body: 'x'.repeat(9)
753
+ });
754
+ expect(res.status).toBe(413);
755
+ expect((await errorOf(res)).error.code).toBe('file_too_large');
756
+ const local = fixture({ cloud: true, principal: localPrincipal(), deferredRefusal: true });
757
+ const localRes = await local.app.request('/files', {
758
+ method: 'POST',
759
+ headers: { 'content-length': '9' },
760
+ body: 'x'.repeat(9)
761
+ });
762
+ expect(localRes.status).toBe(413);
763
+ expect(local.checks).toEqual([]);
764
+ expect(local.hub.reads).toBe(0);
765
+ });
766
+
767
+ it('a request with no principal is left to the route’s own auth', async () => {
768
+ const f = fixture({ cloud: true, principal: null });
769
+ expect((await f.app.request('/things', { method: 'POST' })).status).toBe(201);
770
+ expect(f.checks).toEqual([]);
771
+ await tick();
772
+ expect(f.emitted).toEqual([]);
773
+ });
774
+ });
775
+
776
+ describe('the gate on oss', () => {
777
+ it('runs the credit check first (today’s refusal byte for byte), then the oss value, and emits nothing', async () => {
778
+ const refused = fixture({
779
+ cloud: false,
780
+ principal: localPrincipal(),
781
+ decision: { allowed: false, reason: 'file exceeds MAX_FILE_SIZE_MB (1MB)' }
782
+ });
783
+ const res = await refused.app.request('/files', {
784
+ method: 'POST',
785
+ headers: { 'content-length': '60' },
786
+ body: 'x'.repeat(60)
787
+ });
788
+ expect(res.status).toBe(413);
789
+ expect(await errorOf(res)).toEqual({
790
+ error: { code: 'entitlement_denied', message: 'file exceeds MAX_FILE_SIZE_MB (1MB)' }
791
+ });
792
+
793
+ const f = fixture({ cloud: false, principal: localPrincipal() });
794
+ expect(
795
+ (
796
+ await f.app.request('/files', {
797
+ method: 'POST',
798
+ headers: { 'content-length': '60' },
799
+ body: 'x'.repeat(60)
800
+ })
801
+ ).status
802
+ ).toBe(201);
803
+ const over = await f.app.request('/files', {
804
+ method: 'POST',
805
+ headers: { 'content-length': '101' },
806
+ body: 'x'.repeat(101)
807
+ });
808
+ expect(over.status).toBe(413);
809
+ expect((await errorOf(over)).error).toEqual({
810
+ code: 'entitlement_denied',
811
+ message: 'files.maxBytes: 101 exceeds the instance limit (100)'
812
+ });
813
+ // Features have no tier on oss: every feature route passes.
814
+ expect((await f.app.request('/things/9/premium', { method: 'POST' })).status).toBe(201);
815
+ await tick();
816
+ expect(f.emitted).toEqual([]);
817
+ expect(f.hub.reads).toBe(0);
818
+ });
819
+ });
820
+
821
+ describe('the gate on an anonymous surface: the route’s actor hook runs with no principal (PRDCT-2634)', () => {
822
+ it('cloud: the owner the hook resolves pays and is reported, the viewer is never a principal', async () => {
823
+ const f = fixture({ cloud: true, principal: null });
824
+ const res = await f.app.request('/owned/deck-7', { method: 'POST' });
825
+ expect(res.status).toBe(201);
826
+ expect(f.creditChecks).toEqual([
827
+ { accountRef: 'acct-owner', actionKey: 'things.make', quantity: 1, unit: 'call' }
828
+ ]);
829
+ expect(f.checks).toEqual([]); // the local check is never asked on a metered account
830
+ expect(f.emitted).toHaveLength(1);
831
+ expect(f.emitted[0]).toMatchObject({
832
+ actionKey: 'things.make',
833
+ accountRef: 'acct-owner',
834
+ workspaceId: 'ws-of-deck-7',
835
+ userId: null,
836
+ via: 'session'
837
+ });
838
+ });
839
+
840
+ it('cloud: a credit refusal on an anonymous surface is a NEUTRAL 402: the code, one sentence, no details, no link', async () => {
841
+ const f = fixture({
842
+ cloud: true,
843
+ principal: null,
844
+ credits: () => ({
845
+ allowed: false,
846
+ reason: 'insufficient_credits',
847
+ source: 'hub',
848
+ check: {
849
+ accountRef: '77777777-aaaa-4bbb-8ccc-000000000001',
850
+ actionKey: 'things.make',
851
+ quantity: 1,
852
+ allowed: false,
853
+ credits: 10,
854
+ balance: 0,
855
+ unit: 'call',
856
+ priced: true,
857
+ plan: 'free',
858
+ reason: 'insufficient_credits',
859
+ topUpUrl: 'https://hub.test/billing/top-up?org=secret-org'
860
+ }
861
+ })
862
+ });
863
+ const res = await f.app.request('/owned/deck-7', { method: 'POST' });
864
+ expect(res.status).toBe(402);
865
+ const body = (await res.json()) as { error: { code: string; message: string; details?: unknown } };
866
+ expect(body.error.code).toBe('entitlement_denied');
867
+ expect(body.error.details).toBeUndefined();
868
+ expect(body.error.message).not.toContain('hub.test');
869
+ expect(body.error.message).not.toContain('10');
870
+ expect(body.error.message).not.toContain('secret-org');
871
+ expect(f.emitted).toEqual([]);
872
+ });
873
+
874
+ it('a hook that resolves nothing, or throws, leaves the route to its own handling: nothing checked, nothing emitted', async () => {
875
+ for (const path of ['/unknown/x', '/exploding/x']) {
876
+ const f = fixture({ cloud: true, principal: null });
877
+ const res = await f.app.request(path, { method: 'POST' });
878
+ expect(res.status, path).toBe(201);
879
+ expect(f.creditChecks, path).toEqual([]);
880
+ expect(f.emitted, path).toEqual([]);
881
+ }
882
+ });
883
+
884
+ it('a route with no actor and no principal is untouched: the route’s own auth answers', async () => {
885
+ const f = fixture({ cloud: true, principal: null });
886
+ const res = await f.app.request('/things', { method: 'POST' });
887
+ expect(res.status).toBe(201);
888
+ expect(f.creditChecks).toEqual([]);
889
+ expect(f.emitted).toEqual([]);
890
+ });
891
+
892
+ it('oss: the owner resolves with no account, the surface is unmetered byte for byte', async () => {
893
+ const f = fixture({ cloud: false, principal: null });
894
+ const res = await f.app.request('/owned/deck-7', { method: 'POST' });
895
+ expect(res.status).toBe(201);
896
+ expect(f.checks).toEqual([]);
897
+ expect(f.emitted).toEqual([]);
898
+ });
899
+ });
900
+
901
+ describe('the anonymous surface, round 3 of the verifier', () => {
902
+ it('an anonymous upload door metered in bytes refuses a body with no declared size: 411, no check, nothing emitted', async () => {
903
+ const f = fixture({ cloud: true, principal: null });
904
+ const res = await f.app.request('/owned-files/deck-7', { method: 'POST', body: 'x'.repeat(54) });
905
+ expect(res.status).toBe(411);
906
+ expect(f.creditChecks).toEqual([]);
907
+ expect(f.emitted).toEqual([]);
908
+ const ok = await f.app.request('/owned-files/deck-7', {
909
+ method: 'POST',
910
+ headers: { 'content-length': '54' },
911
+ body: 'x'.repeat(54)
912
+ });
913
+ expect(ok.status).toBe(201);
914
+ expect(f.creditChecks).toEqual([
915
+ { accountRef: 'acct-owner', actionKey: 'files.upload', quantity: 54, unit: 'bytes' }
916
+ ]);
917
+ expect(f.emitted).toHaveLength(1);
918
+ expect(f.emitted[0]).toMatchObject({ actionKey: 'files.upload', quantity: 7, accountRef: 'acct-owner' });
919
+ });
920
+
921
+ it('a suspended owner, or a closed hub, reads the same neutral 402 to a viewer: no status, no wording of the hub', async () => {
922
+ for (const reason of ['account_suspended', 'hub_unavailable'] as const) {
923
+ const f = fixture({
924
+ cloud: true,
925
+ principal: null,
926
+ credits: () => ({ allowed: false, reason, source: 'hub', check: null })
927
+ });
928
+ const res = await f.app.request('/owned/deck-7', { method: 'POST' });
929
+ expect(res.status, reason).toBe(402);
930
+ const body = (await res.json()) as { error: { code: string; message: string; details?: unknown } };
931
+ expect(body.error.code, reason).toBe('entitlement_denied');
932
+ expect(body.error.details, reason).toBeUndefined();
933
+ expect(body.error.message, reason).not.toMatch(/suspend|Antasphere|hub/i);
934
+ expect(f.emitted, reason).toEqual([]);
935
+ }
936
+ });
937
+
938
+ it('an unpriceable quantity reads the same neutral 402 to a viewer: no figure, no link (PRDCT-2677)', async () => {
939
+ const f = fixture({
940
+ cloud: true,
941
+ principal: null,
942
+ credits: () => ({
943
+ allowed: false,
944
+ reason: 'unpriceable',
945
+ source: 'hub',
946
+ check: {
947
+ accountRef: '77777777-aaaa-4bbb-8ccc-000000000001',
948
+ actionKey: 'things.make',
949
+ quantity: 1,
950
+ allowed: false,
951
+ credits: Number.MAX_SAFE_INTEGER,
952
+ balance: 0,
953
+ unit: 'call',
954
+ priced: true,
955
+ plan: 'free',
956
+ reason: 'unpriceable',
957
+ topUpUrl: 'https://hub.test/billing/top-up?org=secret-org'
958
+ }
959
+ })
960
+ });
961
+ const res = await f.app.request('/owned/deck-7', { method: 'POST' });
962
+ expect(res.status).toBe(402);
963
+ const body = (await res.json()) as { error: { code: string; message: string; details?: unknown } };
964
+ expect(body.error.code).toBe('entitlement_denied');
965
+ expect(body.error.details).toBeUndefined();
966
+ expect(body.error.message).toBe('The owner of this content cannot take this action right now');
967
+ await tick();
968
+ expect(f.emitted).toEqual([]);
969
+ });
970
+
971
+ it('a plan refusal on an anonymous surface is neutral too: no key, no plan, no upgrade link', async () => {
972
+ const f = fixture({ cloud: true, principal: null, hubProfile: { plan: 'free' } });
973
+ const res = await f.app.request('/owned-premium/deck-7', { method: 'POST' });
974
+ expect(res.status).toBe(403);
975
+ const body = (await res.json()) as { error: { code: string; message: string; details?: unknown } };
976
+ expect(body.error.code).toBe('plan_required');
977
+ expect(body.error.details).toBeUndefined();
978
+ expect(body.error.message).not.toMatch(/premium|free|pro|hub\.test/);
979
+ expect(f.emitted).toEqual([]);
980
+ });
981
+
982
+ it('oss never runs the hook: no lookup for a self-hosted viewer', async () => {
983
+ hookRuns.count = 0;
984
+ const f = fixture({ cloud: false, principal: null });
985
+ const res = await f.app.request('/owned-files/deck-7', {
986
+ method: 'POST',
987
+ headers: { 'content-length': '3' },
988
+ body: 'abc'
989
+ });
990
+ expect(res.status).toBe(201);
991
+ expect(hookRuns.count).toBe(0);
992
+ expect(f.emitted).toEqual([]);
993
+ });
994
+
995
+ it('a meter-only route reads no plan: the hub is not asked for the profile', async () => {
996
+ const f = fixture({ cloud: true, principal: principal() });
997
+ const res = await f.app.request('/things', { method: 'POST' });
998
+ expect(res.status).toBe(201);
999
+ expect(f.hub.reads).toBe(0);
1000
+ expect(f.creditChecks).toHaveLength(1);
1001
+ });
1002
+ });
1003
+
1004
+ describe('a signed-in holder of a share link is a viewer too (the code review)', () => {
1005
+ it('the owner pays and is reported, via session, whatever the viewer signed in with; the refusal is neutral', async () => {
1006
+ const f = fixture({
1007
+ cloud: true,
1008
+ principal: principal({ via: 'api_key', accountRef: 'acct-viewer', workspaceId: 'ws-viewer' }),
1009
+ credits: () => ({ allowed: false, reason: 'insufficient_credits', source: 'hub', check: null })
1010
+ });
1011
+ const res = await f.app.request('/owned/deck-7', { method: 'POST' });
1012
+ expect(res.status).toBe(402);
1013
+ const body = (await res.json()) as { error: { message: string; details?: unknown } };
1014
+ expect(body.error.details).toBeUndefined();
1015
+ expect(body.error.message).not.toContain('acct');
1016
+ expect(f.creditChecks).toEqual([
1017
+ { accountRef: 'acct-owner', actionKey: 'things.make', quantity: 1, unit: 'call' }
1018
+ ]);
1019
+ const ok = fixture({ cloud: true, principal: principal({ via: 'api_key', accountRef: 'acct-viewer' }) });
1020
+ expect((await ok.app.request('/owned/deck-7', { method: 'POST' })).status).toBe(201);
1021
+ expect(ok.emitted[0]).toMatchObject({ accountRef: 'acct-owner', via: 'session', userId: null });
1022
+ });
1023
+
1024
+ it('a hook that resolves nothing never falls back to the signed-in viewer: the route answers, nothing is checked', async () => {
1025
+ const f = fixture({ cloud: true, principal: principal() });
1026
+ for (const path of ['/unknown/x', '/exploding/x']) {
1027
+ const res = await f.app.request(path, { method: 'POST' });
1028
+ expect(res.status, path).toBe(201);
1029
+ }
1030
+ expect(f.creditChecks).toEqual([]);
1031
+ expect(f.checks).toEqual([]);
1032
+ expect(f.emitted).toEqual([]);
1033
+ });
1034
+
1035
+ it('a count limit on a JSON route does not demand a Content-Length: only a body-judging limit or a bytes meter does', async () => {
1036
+ const f = fixture({ cloud: true, principal: principal(), hubProfile: { plan: 'free' } });
1037
+ const res = await f.app.request('/count/1', { method: 'POST', body: '{}' });
1038
+ expect(res.status).toBe(201);
1039
+ expect(f.creditChecks).toEqual([]);
1040
+ // The limit is still judged: free allows 2.
1041
+ const over = await f.app.request('/count/3', {
1042
+ method: 'POST',
1043
+ headers: { 'content-length': '2' },
1044
+ body: '{}'
1045
+ });
1046
+ expect(over.status).toBe(403);
1047
+ expect((await errorOf(over)).error.code).toBe('plan_required');
1048
+ });
1049
+
1050
+ it('a size limit with no bytes meter still demands the declared size: 411 on an undeclared body', async () => {
1051
+ const f = fixture({ cloud: true, principal: principal(), hubProfile: { plan: 'pro' } });
1052
+ const res = await f.app.request('/sized', { method: 'POST', body: 'x'.repeat(10) });
1053
+ expect(res.status).toBe(411);
1054
+ expect((await errorOf(res)).error.code).toBe('length_required');
1055
+ const ok = await f.app.request('/sized', {
1056
+ method: 'POST',
1057
+ headers: { 'content-length': '10' },
1058
+ body: 'x'.repeat(10)
1059
+ });
1060
+ expect(ok.status).toBe(201);
1061
+ });
1062
+
1063
+ it('a feature refusal to a signed-in holder of the link is neutral too: no key, no plan, no upgrade link', async () => {
1064
+ const f = fixture({ cloud: true, principal: principal(), hubProfile: { plan: 'free' } });
1065
+ const res = await f.app.request('/owned-premium/1', { method: 'POST' });
1066
+ expect(res.status).toBe(403);
1067
+ const body = await errorOf(res);
1068
+ expect(body.error.code).toBe('plan_required');
1069
+ expect(body.error.details).toBeUndefined();
1070
+ expect(body.error.message).toBe('The owner of this content cannot take this action right now');
1071
+ expect(body.error.message).not.toContain('premium');
1072
+ expect(body.error.message).not.toContain('free');
1073
+ expect(body.error.message).not.toContain('http');
1074
+ expect(f.emitted).toEqual([]);
1075
+ });
1076
+
1077
+ it('an owner with no account behind the link: nothing is checked for the signed-in holder, not even the local credit service', async () => {
1078
+ const f = fixture({ cloud: true, principal: principal() });
1079
+ const res = await f.app.request('/owned-local/1', { method: 'POST' });
1080
+ expect(res.status).toBe(201);
1081
+ expect(f.checks).toEqual([]);
1082
+ expect(f.creditChecks).toEqual([]);
1083
+ expect(f.emitted).toEqual([]);
1084
+ });
1085
+
1086
+ it('oss never runs the hook for a signed-in holder either', async () => {
1087
+ hookRuns.count = 0;
1088
+ const f = fixture({ cloud: false, principal: principal() });
1089
+ // `/owned-premium` resolves through `ownerActor`, the counted hook.
1090
+ const res = await f.app.request('/owned-premium/1', { method: 'POST' });
1091
+ expect(res.status).toBe(201);
1092
+ expect(hookRuns.count).toBe(0);
1093
+ expect(f.checks).toEqual([]);
1094
+ expect(f.emitted).toEqual([]);
1095
+ });
1096
+ });
1097
+
1098
+ describe('a count limit and a conditional feature (PRDCT-2702)', () => {
1099
+ const jsonPost = (body: unknown, headers: Record<string, string> = {}) => ({
1100
+ method: 'POST',
1101
+ headers: { 'content-type': 'application/json', ...headers },
1102
+ body: JSON.stringify(body)
1103
+ });
1104
+
1105
+ it('cloud: a count read from the body is judged after the action: over the tier value is 403 plan_required naming the plan that allows it, at the value it passes', async () => {
1106
+ const f = fixture({ cloud: true, principal: principal() });
1107
+ const over = await f.app.request('/counted', jsonPost({ count: 2 }));
1108
+ expect(over.status).toBe(403);
1109
+ const body = await errorOf(over);
1110
+ expect(body.error.code).toBe('plan_required');
1111
+ expect(body.error.message).toBe('things.max is limited to 2 on the free plan; the pro plan allows it');
1112
+ expect(body.error.details).toEqual({
1113
+ key: 'things.max',
1114
+ plan: 'free',
1115
+ requiredPlan: 'pro',
1116
+ upgradeUrl: `${HUB_UPGRADE_PAGE}&key=things.max&requiredPlan=pro`
1117
+ });
1118
+ const at = await f.app.request('/counted', jsonPost({ count: 1 }));
1119
+ expect(at.status).toBe(201);
1120
+ });
1121
+
1122
+ it('the gate’s read of the body is the handler’s: the handler still reads the same JSON after the check', async () => {
1123
+ const f = fixture({
1124
+ cloud: true,
1125
+ principal: principal(),
1126
+ handler: async (c) => c.json({ echoed: await c.req.json() }, 201)
1127
+ });
1128
+ const res = await f.app.request('/counted', jsonPost({ count: 0, name: 'x' }));
1129
+ expect(res.status).toBe(201);
1130
+ expect(await res.json()).toEqual({ echoed: { count: 0, name: 'x' } });
1131
+ });
1132
+
1133
+ it('a body that is not JSON, or none, reads as nothing to count: the route answers, never the gate', async () => {
1134
+ const f = fixture({ cloud: true, principal: principal() });
1135
+ const text = await f.app.request('/counted', {
1136
+ method: 'POST',
1137
+ headers: { 'content-type': 'text/plain' },
1138
+ body: 'not json'
1139
+ });
1140
+ expect(text.status).toBe(201);
1141
+ const none = await f.app.request('/counted', { method: 'POST' });
1142
+ expect(none.status).toBe(201);
1143
+ });
1144
+
1145
+ it('a count hook never demands a Content-Length on a metered account: a body with no declared size is judged on its count, not 411', async () => {
1146
+ const f = fixture({ cloud: true, principal: principal() });
1147
+ // `app.request` with a body and no header sends none (the 411 suite's own reproduction).
1148
+ const over = await f.app.request('/counted', {
1149
+ method: 'POST',
1150
+ headers: { 'content-type': 'application/json' },
1151
+ body: JSON.stringify({ count: 5 })
1152
+ });
1153
+ expect(over.status).toBe(403);
1154
+ expect((await errorOf(over)).error.code).toBe('plan_required');
1155
+ });
1156
+
1157
+ it('the hub’s override and the pro plan decide the count too', async () => {
1158
+ const wide = fixture({
1159
+ cloud: true,
1160
+ principal: principal(),
1161
+ hubProfile: { plan: 'free', limits: { 'things.max': 10 } }
1162
+ });
1163
+ expect((await wide.app.request('/counted', jsonPost({ count: 9 }))).status).toBe(201);
1164
+ const pro = fixture({ cloud: true, principal: principal(), hubProfile: { plan: 'pro' } });
1165
+ expect((await pro.app.request('/counted', jsonPost({ count: 999 }))).status).toBe(201);
1166
+ });
1167
+
1168
+ it('a hook that resolves null, or throws, leaves the route alone: no plan refusal on a lookup that found nothing, and a throw is logged', async () => {
1169
+ const f = fixture({ cloud: true, principal: principal() });
1170
+ expect((await f.app.request('/counted-maybe/none', { method: 'POST' })).status).toBe(201);
1171
+ warnings.length = 0;
1172
+ expect((await f.app.request('/counted-maybe/throws', { method: 'POST' })).status).toBe(201);
1173
+ expect(warnings).toEqual([
1174
+ expect.objectContaining({
1175
+ msg: expect.stringContaining('limit hook threw'),
1176
+ fields: expect.objectContaining({ route: 'POST /counted-maybe/{what}', key: 'things.max' })
1177
+ })
1178
+ ]);
1179
+ // The same hook with a number over the value is refused: the null is the difference.
1180
+ expect((await f.app.request('/counted-maybe/some', { method: 'POST' })).status).toBe(403);
1181
+ });
1182
+
1183
+ it('oss: the count is judged against the operator’s ceiling, 413 entitlement_denied byte for byte; a null ceiling never runs the hook', async () => {
1184
+ const f = fixture({ cloud: false, principal: localPrincipal() });
1185
+ const over = await f.app.request('/counted-capped', jsonPost({ count: 2 }));
1186
+ expect(over.status).toBe(413);
1187
+ expect(await errorOf(over)).toEqual({
1188
+ error: { code: 'entitlement_denied', message: 'things.capped: 3 exceeds the instance limit (2)' }
1189
+ });
1190
+ expect((await f.app.request('/counted-capped', jsonPost({ count: 1 }))).status).toBe(201);
1191
+ // `things.max` has no oss ceiling: a count that would be over the free
1192
+ // value passes on oss, and the handler still reads the body untouched.
1193
+ const g = fixture({
1194
+ cloud: false,
1195
+ principal: localPrincipal(),
1196
+ handler: async (c) => c.json({ echoed: await c.req.json() }, 201)
1197
+ });
1198
+ const res = await g.app.request('/counted', jsonPost({ count: 50 }));
1199
+ expect(res.status).toBe(201);
1200
+ expect(await res.json()).toEqual({ echoed: { count: 50 } });
1201
+ expect(f.emitted).toEqual([]);
1202
+ expect(g.emitted).toEqual([]);
1203
+ });
1204
+
1205
+ it('a conditional feature refuses the ACT, never the route: setting a password on free is 403 plan_required, no password or a removal passes, pro passes with one', async () => {
1206
+ const f = fixture({ cloud: true, principal: principal() });
1207
+ const locked = await f.app.request('/lockable', jsonPost({ password: 'hunter22' }));
1208
+ expect(locked.status).toBe(403);
1209
+ const body = await errorOf(locked);
1210
+ expect(body.error.code).toBe('plan_required');
1211
+ expect(body.error.details).toEqual({
1212
+ key: 'premium',
1213
+ plan: 'free',
1214
+ requiredPlan: 'pro',
1215
+ upgradeUrl: `${HUB_UPGRADE_PAGE}&key=premium&requiredPlan=pro`
1216
+ });
1217
+ expect((await f.app.request('/lockable', jsonPost({ name: 'open' }))).status).toBe(201);
1218
+ expect((await f.app.request('/lockable', jsonPost({ password: null }))).status).toBe(201);
1219
+ expect((await f.app.request('/lockable', { method: 'POST' })).status).toBe(201);
1220
+ const pro = fixture({ cloud: true, principal: principal(), hubProfile: { plan: 'pro' } });
1221
+ expect((await pro.app.request('/lockable', jsonPost({ password: 'hunter22' }))).status).toBe(201);
1222
+ // A hub answer that lists the feature for a free account passes too.
1223
+ const listed = fixture({
1224
+ cloud: true,
1225
+ principal: principal(),
1226
+ hubProfile: { plan: 'free', limits: { 'things.max': 2 }, features: ['premium'] }
1227
+ });
1228
+ expect((await listed.app.request('/lockable', jsonPost({ password: 'hunter22' }))).status).toBe(201);
1229
+ });
1230
+
1231
+ it('on a metered route the conditional feature is judged before the count, the count before the credits, and a refusal emits nothing', async () => {
1232
+ const f = fixture({ cloud: true, principal: principal() });
1233
+ const both = await f.app.request('/lockable-metered', jsonPost({ password: 'p4ss', count: 9 }));
1234
+ expect((await errorOf(both)).error.details).toMatchObject({ key: 'premium' });
1235
+ const count = await f.app.request('/lockable-metered', jsonPost({ count: 9 }));
1236
+ expect((await errorOf(count)).error.details).toMatchObject({ key: 'things.max' });
1237
+ expect(f.creditChecks).toEqual([]);
1238
+ expect(f.emitted).toEqual([]);
1239
+ const ok = await f.app.request('/lockable-metered', jsonPost({ count: 0 }));
1240
+ expect(ok.status).toBe(201);
1241
+ expect(f.creditChecks).toHaveLength(1);
1242
+ await tick();
1243
+ expect(f.emitted).toHaveLength(1);
1244
+ });
1245
+
1246
+ it('oss: a conditional feature is never judged (a self-hosted instance has every feature)', async () => {
1247
+ const f = fixture({ cloud: false, principal: localPrincipal() });
1248
+ expect((await f.app.request('/lockable', jsonPost({ password: 'hunter22' }))).status).toBe(201);
1249
+ });
1250
+
1251
+ it('a public door with an entry-level actor: the workspace the door opens is judged on the count, and the caller reads the neutral refusal, with or without a session', async () => {
1252
+ hookRuns.count = 0;
1253
+ const anonymous = fixture({ cloud: true, principal: null });
1254
+ const over = await anonymous.app.request('/door/7', jsonPost({ count: 2 }));
1255
+ expect(over.status).toBe(403);
1256
+ const body = await errorOf(over);
1257
+ expect(body.error.code).toBe('plan_required');
1258
+ expect(body.error.details).toBeUndefined();
1259
+ expect(body.error.message).toBe('The owner of this content cannot take this action right now');
1260
+ expect(hookRuns.count).toBe(1);
1261
+ expect((await anonymous.app.request('/door/7', jsonPost({ count: 1 }))).status).toBe(201);
1262
+ // A signed-in person of another workspace at the same door is a viewer too.
1263
+ const signedIn = fixture({
1264
+ cloud: true,
1265
+ principal: principal({ workspaceId: 'ws-other', accountRef: 'acct-other' })
1266
+ });
1267
+ const res = await signedIn.app.request('/door/7', jsonPost({ count: 2 }));
1268
+ expect(res.status).toBe(403);
1269
+ expect((await errorOf(res)).error.details).toBeUndefined();
1270
+ expect(anonymous.emitted).toEqual([]);
1271
+ expect(signedIn.emitted).toEqual([]);
1272
+ });
1273
+
1274
+ it('a public door behind a conditional feature reads the same neutral sentence, and passes without the act', async () => {
1275
+ const f = fixture({ cloud: true, principal: null });
1276
+ const locked = await f.app.request('/door-lockable/7', jsonPost({ password: 'p4ss' }));
1277
+ expect(locked.status).toBe(403);
1278
+ expect((await errorOf(locked)).error.details).toBeUndefined();
1279
+ expect((await f.app.request('/door-lockable/7', jsonPost({ name: 'x' }))).status).toBe(201);
1280
+ });
1281
+
1282
+ it('oss never runs an entry-level hook: the door is unmetered and unjudged', async () => {
1283
+ hookRuns.count = 0;
1284
+ const f = fixture({ cloud: false, principal: null });
1285
+ expect((await f.app.request('/door/7', jsonPost({ count: 50 }))).status).toBe(201);
1286
+ expect(hookRuns.count).toBe(0);
1287
+ });
1288
+
1289
+ it('the slot guard reads a conditional feature’s key: an undeclared one stops the boot naming the route', () => {
1290
+ // A slot of its own: the fixture's `files.maxBytes` advertises a pro
1291
+ // value above its oss ceiling on purpose (the gate's tests), which the
1292
+ // guard refuses first.
1293
+ const slot = { actions: [], limits: {}, features: TOOL.features };
1294
+ expect(() =>
1295
+ assertToolEntitlements({
1296
+ ...slot,
1297
+ routes: declareRouteEntitlements([
1298
+ { route: { method: 'post', path: '/x' }, feature: { key: 'nothing.declared', when: () => true } }
1299
+ ])
1300
+ })
1301
+ ).toThrow('route POST /x needs the feature "nothing.declared"');
1302
+ expect(() =>
1303
+ assertToolEntitlements({
1304
+ ...slot,
1305
+ routes: declareRouteEntitlements([
1306
+ { route: { method: 'post', path: '/x' }, feature: { key: 'premium' } }
1307
+ ])
1308
+ })
1309
+ ).not.toThrow();
1310
+ });
1311
+
1312
+ it('only a size limit marks a deferring gate: a count route meets the cap at once, a size route after the plan (PRDCT-2632)', () => {
1313
+ const f = fixture({ cloud: true, principal: principal() });
1314
+ // The handlers as registered (`api.on`), the shape create-api.ts's cap reads them in.
1315
+ const gatesOf = (path: string) =>
1316
+ f.app.routes.filter((r) => r.method === 'POST' && r.path === path).map((r) => r.handler);
1317
+ expect(gatesOf('/counted')).toHaveLength(2);
1318
+ expect(gatesOf('/counted').some(isDeferringGate)).toBe(false);
1319
+ expect(gatesOf('/counted-capped').some(isDeferringGate)).toBe(false);
1320
+ expect(gatesOf('/door/:id').some(isDeferringGate)).toBe(false);
1321
+ expect(gatesOf('/sized').some(isDeferringGate)).toBe(true);
1322
+ expect(gatesOf('/files').some(isDeferringGate)).toBe(true);
1323
+ expect(gatesOf('/premium-files').some(isDeferringGate)).toBe(true);
1324
+ });
1325
+
1326
+ it('a conditional feature whose condition does not hold reads no plan: the hub is not asked', async () => {
1327
+ const f = fixture({ cloud: true, principal: principal() });
1328
+ expect((await f.app.request('/lockable', jsonPost({ name: 'open' }))).status).toBe(201);
1329
+ expect(f.hub.reads).toBe(0);
1330
+ expect((await f.app.request('/lockable', jsonPost({ password: 'hunter22' }))).status).toBe(403);
1331
+ expect(f.hub.reads).toBe(1);
1332
+ });
1333
+ });