@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,1310 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
3
+ import AdmZip from 'adm-zip';
4
+ import { and, eq, sql } from 'drizzle-orm';
5
+ import {
6
+ auditLog,
7
+ projectMembers,
8
+ projects,
9
+ projectTeams,
10
+ workspaceMembers,
11
+ workspaceTeamMembers,
12
+ workspaceTeams
13
+ } from '@antasphere/chassis-db';
14
+ import type { Principal } from '@antasphere/chassis-contract';
15
+ import { projectGrantPredicate, projectRole } from '@antasphere/chassis-server/projects';
16
+ import {
17
+ createDatabase,
18
+ createTestApp,
19
+ extractCookie,
20
+ readJson,
21
+ startPostgres,
22
+ type TestApp,
23
+ host
24
+ } from './helpers.js';
25
+
26
+ /**
27
+ * Projects: a subgroup of a workspace with members and three roles. This
28
+ * suite pins every tier of every route for every kind of caller:
29
+ *
30
+ * owner, admin → act as a manager on every project (the operator view)
31
+ * manager, editor, viewer → the three project roles
32
+ * outsider → a plain workspace member with no grant: 404 everywhere
33
+ * guest → 403 `guest_forbidden` on the whole subtree, and the
34
+ * predicate refuses a guest even with a row forced in
35
+ * API keys → the read scope reads, the write scope writes, an
36
+ * unlisted shape stays closed
37
+ *
38
+ * and the rules that hold between the routes: 404 before 403 before 409, an
39
+ * archived project is read-only, a grant dies with the workspace membership
40
+ * it rides on, a project is never deleted.
41
+ */
42
+ const PASSWORD = 'a-long-projects-password-1';
43
+ const OWNER = { email: 'owner@projects.test', name: 'Olive Owner', password: PASSWORD };
44
+
45
+ type Actor = { email: string; cookie: string; userId: string; memberId: string };
46
+
47
+ let container: StartedPostgreSqlContainer;
48
+ let app: TestApp;
49
+ let workspaceId = '';
50
+ let otherWorkspaceId = '';
51
+ const actors: Record<string, Actor> = {};
52
+ /** The project every tier test reads: created by `manager`, with `editor` and `viewer` on it. */
53
+ let projectId = '';
54
+
55
+ let ipCounter = 0;
56
+ const nextIp = () => `10.77.${Math.floor(ipCounter / 250)}.${(ipCounter++ % 250) + 1}`;
57
+
58
+ const send = (
59
+ method: string,
60
+ path: string,
61
+ who: { cookie?: string; key?: string },
62
+ body?: unknown,
63
+ headers: Record<string, string> = {}
64
+ ) =>
65
+ app.app.request(`/api/v1${path}`, {
66
+ method,
67
+ headers: {
68
+ 'x-forwarded-for': nextIp(),
69
+ ...(who.cookie ? { cookie: who.cookie } : {}),
70
+ ...(who.key ? { authorization: `Bearer ${who.key}` } : {}),
71
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
72
+ ...headers
73
+ },
74
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {})
75
+ });
76
+
77
+ async function expectError(res: Response, status: number, code: string): Promise<void> {
78
+ const body = await readJson(res);
79
+ expect({ status: res.status, code: body?.error?.code }).toEqual({ status, code });
80
+ }
81
+
82
+ async function signIn(email: string): Promise<string> {
83
+ const res = await send('POST', '/auth/sign-in/email', {}, { email, password: PASSWORD });
84
+ expect(res.status).toBe(200);
85
+ return extractCookie(res);
86
+ }
87
+
88
+ async function addActor(
89
+ name: string,
90
+ opts: { role?: 'owner' | 'admin' | 'member'; origin?: 'local' | 'guest' } = {}
91
+ ): Promise<Actor> {
92
+ const email = `${name}@projects.test`;
93
+ const created = await app.auth.api.signUpEmail({ body: { email, password: PASSWORD, name } });
94
+ const [row] = await app.db.db
95
+ .insert(workspaceMembers)
96
+ .values({
97
+ workspaceId,
98
+ userId: created.user.id,
99
+ role: opts.role ?? 'member',
100
+ origin: opts.origin ?? 'local'
101
+ })
102
+ .returning({ id: workspaceMembers.id });
103
+ const actor = { email, cookie: await signIn(email), userId: created.user.id, memberId: row!.id };
104
+ actors[name] = actor;
105
+ return actor;
106
+ }
107
+
108
+ async function mintKey(cookie: string, scopes: string[]): Promise<string> {
109
+ const res = await send('POST', '/api-keys', { cookie }, { name: `key-${scopes.join('-')}`, scopes });
110
+ expect(res.status).toBe(201);
111
+ return (await readJson(res)).key;
112
+ }
113
+
114
+ async function createProject(who: Actor, name: string): Promise<string> {
115
+ const res = await send('POST', '/projects', who, { name });
116
+ expect(res.status).toBe(201);
117
+ return (await readJson(res)).id;
118
+ }
119
+
120
+ const principalOf = (actor: Actor, over: Partial<Principal> = {}): Principal => ({
121
+ userId: actor.userId,
122
+ email: actor.email,
123
+ name: 'x',
124
+ workspaceId,
125
+ role: 'member',
126
+ origin: 'local',
127
+ via: 'session',
128
+ scopes: null,
129
+ ...over
130
+ });
131
+
132
+ beforeAll(async () => {
133
+ container = await startPostgres();
134
+ app = await createTestApp(await createDatabase(container, 'projects'));
135
+ const setup = await send(
136
+ 'POST',
137
+ '/setup',
138
+ {},
139
+ {
140
+ setupToken: 'integration-test-setup-token',
141
+ instanceName: 'Projects',
142
+ owner: OWNER
143
+ }
144
+ );
145
+ expect(setup.status).toBe(201);
146
+ workspaceId = (await readJson(setup)).workspaceId;
147
+ const ownerCookie = await signIn(OWNER.email);
148
+ const me = await readJson(await send('GET', '/me', { cookie: ownerCookie }));
149
+ const [ownerRow] = await app.db.db
150
+ .select({ id: workspaceMembers.id })
151
+ .from(workspaceMembers)
152
+ .where(and(eq(workspaceMembers.workspaceId, workspaceId), eq(workspaceMembers.userId, me.user.id)));
153
+ actors.owner = { email: OWNER.email, cookie: ownerCookie, userId: me.user.id, memberId: ownerRow!.id };
154
+ otherWorkspaceId = (await app.registry.workspaces.create('Elsewhere', me.user.id)).workspaceId;
155
+
156
+ await addActor('admin', { role: 'admin' });
157
+ for (const name of ['manager', 'editor', 'viewer', 'outsider', 'leaver', 'erased', 'sleeper']) {
158
+ await addActor(name);
159
+ }
160
+ await addActor('guest', { origin: 'guest' });
161
+
162
+ projectId = await createProject(actors.manager!, 'Spring campaign');
163
+ for (const [name, role] of [
164
+ ['editor', 'editor'],
165
+ ['viewer', 'viewer']
166
+ ] as const) {
167
+ const res = await send('POST', `/projects/${projectId}/members`, actors.manager!, {
168
+ userId: actors[name]!.userId,
169
+ role
170
+ });
171
+ expect(res.status).toBe(201);
172
+ }
173
+ }, 240_000);
174
+
175
+ afterAll(async () => {
176
+ await app?.stop();
177
+ await container?.stop();
178
+ });
179
+
180
+ describe('creating a project', () => {
181
+ it('any non-guest member creates one and becomes its first manager, in one transaction', async () => {
182
+ const res = await send('POST', '/projects', actors.outsider!, {
183
+ name: 'Outsider’s own',
184
+ description: 'A project of my own',
185
+ metadata: { client: 'acme' }
186
+ });
187
+ expect(res.status).toBe(201);
188
+ const body = await readJson(res);
189
+ expect(body).toMatchObject({
190
+ name: 'Outsider’s own',
191
+ description: 'A project of my own',
192
+ metadata: { client: 'acme' },
193
+ archivedAt: null,
194
+ createdBy: actors.outsider!.userId,
195
+ myRole: 'manager',
196
+ memberCount: 1
197
+ });
198
+ const rows = await app.db.db.select().from(projectMembers).where(eq(projectMembers.projectId, body.id));
199
+ expect(rows).toHaveLength(1);
200
+ expect(rows[0]).toMatchObject({ memberId: actors.outsider!.memberId, role: 'manager' });
201
+ });
202
+
203
+ it('refuses a guest, flat (403 guest_forbidden), on every route of the subtree', async () => {
204
+ const guest = actors.guest!;
205
+ await expectError(await send('POST', '/projects', guest, { name: 'nope' }), 403, 'guest_forbidden');
206
+ await expectError(await send('GET', '/projects', guest), 403, 'guest_forbidden');
207
+ await expectError(await send('GET', `/projects/${projectId}`, guest), 403, 'guest_forbidden');
208
+ await expectError(await send('GET', `/projects/${projectId}/members`, guest), 403, 'guest_forbidden');
209
+ await expectError(await send('POST', `/projects/${projectId}/archive`, guest), 403, 'guest_forbidden');
210
+ });
211
+
212
+ it('refuses an anonymous caller (401) and a body without a name (400)', async () => {
213
+ await expectError(await send('GET', '/projects', {}), 401, 'unauthenticated');
214
+ await expectError(await send('POST', '/projects', actors.manager!, {}), 400, 'validation_error');
215
+ });
216
+
217
+ it('an Idempotency-Key replays the first create instead of minting a second project', async () => {
218
+ const headers = { 'idempotency-key': 'projects-create-once' };
219
+ const first = await readJson(await send('POST', '/projects', actors.manager!, { name: 'Once' }, headers));
220
+ const second = await readJson(
221
+ await send('POST', '/projects', actors.manager!, { name: 'Once' }, headers)
222
+ );
223
+ expect(second.id).toBe(first.id);
224
+ const rows = await app.db.db.select().from(projects).where(eq(projects.name, 'Once'));
225
+ expect(rows).toHaveLength(1);
226
+ });
227
+ });
228
+
229
+ describe('reading: 404 to whoever holds no grant, the role to whoever does', () => {
230
+ it.each([
231
+ ['owner', 'manager'],
232
+ ['admin', 'manager'],
233
+ ['manager', 'manager'],
234
+ ['editor', 'editor'],
235
+ ['viewer', 'viewer']
236
+ ])('%s reads the project, myRole = %s', async (name, myRole) => {
237
+ const res = await send('GET', `/projects/${projectId}`, actors[name]!);
238
+ expect(res.status).toBe(200);
239
+ expect(await readJson(res)).toMatchObject({ id: projectId, myRole, memberCount: 3 });
240
+ const members = await send('GET', `/projects/${projectId}/members`, actors[name]!);
241
+ expect(members.status).toBe(200);
242
+ const roles = (await readJson(members)).members.map((m: { email: string; role: string }) => [
243
+ m.email,
244
+ m.role
245
+ ]);
246
+ expect(roles).toEqual([
247
+ ['viewer@projects.test', 'viewer'],
248
+ ['editor@projects.test', 'editor'],
249
+ ['manager@projects.test', 'manager']
250
+ ]);
251
+ });
252
+
253
+ it('a plain member with no grant gets 404 on every route, never 403', async () => {
254
+ const o = actors.outsider!;
255
+ const target = actors.viewer!.userId;
256
+ await expectError(await send('GET', `/projects/${projectId}`, o), 404, 'not_found');
257
+ await expectError(await send('GET', `/projects/${projectId}/members`, o), 404, 'not_found');
258
+ await expectError(await send('PATCH', `/projects/${projectId}`, o, { name: 'x' }), 404, 'not_found');
259
+ await expectError(await send('POST', `/projects/${projectId}/archive`, o), 404, 'not_found');
260
+ await expectError(await send('POST', `/projects/${projectId}/unarchive`, o), 404, 'not_found');
261
+ await expectError(
262
+ await send('POST', `/projects/${projectId}/members`, o, { userId: target, role: 'viewer' }),
263
+ 404,
264
+ 'not_found'
265
+ );
266
+ await expectError(
267
+ await send('PATCH', `/projects/${projectId}/members/${target}`, o, { role: 'editor' }),
268
+ 404,
269
+ 'not_found'
270
+ );
271
+ await expectError(await send('DELETE', `/projects/${projectId}/members/${target}`, o), 404, 'not_found');
272
+ // Removing THEMSELVES from a project they are not in is the same 404.
273
+ await expectError(
274
+ await send('DELETE', `/projects/${projectId}/members/${o.userId}`, o),
275
+ 404,
276
+ 'not_found'
277
+ );
278
+ // The same answer as an id that names nothing.
279
+ await expectError(
280
+ await send('GET', '/projects/00000000-0000-4000-8000-000000000000', o),
281
+ 404,
282
+ 'not_found'
283
+ );
284
+ });
285
+
286
+ it('the list is the caller’s projects: a member’s own, every project for an owner or admin', async () => {
287
+ const names = async (name: string) =>
288
+ (await readJson(await send('GET', '/projects?limit=100', actors[name]!))).projects.map(
289
+ (p: { name: string }) => p.name
290
+ );
291
+ expect(await names('viewer')).toEqual(['Spring campaign']);
292
+ expect(await names('outsider')).toEqual(['Outsider’s own']);
293
+ expect(await names('owner')).toEqual(
294
+ expect.arrayContaining(['Spring campaign', 'Outsider’s own', 'Once'])
295
+ );
296
+ expect(await names('admin')).toEqual(await names('owner'));
297
+ });
298
+
299
+ it('a project of another workspace is 404 from this one, for its own owner too', async () => {
300
+ const inOther = await send(
301
+ 'POST',
302
+ '/projects',
303
+ actors.owner!,
304
+ { name: 'Elsewhere’s' },
305
+ {
306
+ 'x-workspace-id': otherWorkspaceId
307
+ }
308
+ );
309
+ expect(inOther.status).toBe(201);
310
+ const { id } = await readJson(inOther);
311
+ await expectError(await send('GET', `/projects/${id}`, actors.owner!), 404, 'not_found');
312
+ const there = await send('GET', `/projects/${id}`, actors.owner!, undefined, {
313
+ 'x-workspace-id': otherWorkspaceId
314
+ });
315
+ expect(there.status).toBe(200);
316
+ });
317
+
318
+ it('a non-uuid id is a clean 400, never a Postgres cast error', async () => {
319
+ await expectError(await send('GET', '/projects/not-a-uuid', actors.owner!), 400, 'validation_error');
320
+ });
321
+ });
322
+
323
+ describe('changing a project: a manager’s act, 403 to a proven reader below it', () => {
324
+ it.each(['editor', 'viewer'])(
325
+ '%s gets 403 insufficient_project_role on every manager act',
326
+ async (name) => {
327
+ const a = actors[name]!;
328
+ const other = name === 'editor' ? actors.viewer! : actors.editor!;
329
+ const code = 'insufficient_project_role';
330
+ await expectError(await send('PATCH', `/projects/${projectId}`, a, { name: 'x' }), 403, code);
331
+ await expectError(await send('POST', `/projects/${projectId}/archive`, a), 403, code);
332
+ await expectError(await send('POST', `/projects/${projectId}/unarchive`, a), 403, code);
333
+ await expectError(
334
+ await send('POST', `/projects/${projectId}/members`, a, {
335
+ userId: actors.outsider!.userId,
336
+ role: 'viewer'
337
+ }),
338
+ 403,
339
+ code
340
+ );
341
+ await expectError(
342
+ await send('PATCH', `/projects/${projectId}/members/${other.userId}`, a, { role: 'manager' }),
343
+ 403,
344
+ code
345
+ );
346
+ await expectError(await send('DELETE', `/projects/${projectId}/members/${other.userId}`, a), 403, code);
347
+ // Promoting THEMSELVES is the same refusal.
348
+ await expectError(
349
+ await send('PATCH', `/projects/${projectId}/members/${a.userId}`, a, { role: 'manager' }),
350
+ 403,
351
+ code
352
+ );
353
+ }
354
+ );
355
+
356
+ it.each(['manager', 'admin', 'owner'])(
357
+ '%s renames it, and the description clears on null',
358
+ async (name) => {
359
+ const res = await send('PATCH', `/projects/${projectId}`, actors[name]!, {
360
+ name: `Spring campaign (${name})`,
361
+ description: 'set'
362
+ });
363
+ expect(res.status).toBe(200);
364
+ expect(await readJson(res)).toMatchObject({ name: `Spring campaign (${name})`, description: 'set' });
365
+ const cleared = await send('PATCH', `/projects/${projectId}`, actors[name]!, {
366
+ name: 'Spring campaign',
367
+ description: null
368
+ });
369
+ expect(await readJson(cleared)).toMatchObject({ name: 'Spring campaign', description: null });
370
+ }
371
+ );
372
+
373
+ it('an empty patch and an oversized metadata are 400', async () => {
374
+ await expectError(
375
+ await send('PATCH', `/projects/${projectId}`, actors.manager!, {}),
376
+ 400,
377
+ 'validation_error'
378
+ );
379
+ await expectError(
380
+ await send('PATCH', `/projects/${projectId}`, actors.manager!, {
381
+ metadata: { big: 'x'.repeat(17_000) }
382
+ }),
383
+ 400,
384
+ 'validation_error'
385
+ );
386
+ });
387
+ });
388
+
389
+ describe('members: picked among the workspace’s own active non-guest members', () => {
390
+ it('adds by email, refuses a second add (409), a guest (403), a stranger and an inactive member (404)', async () => {
391
+ const m = actors.manager!;
392
+ const path = `/projects/${projectId}/members`;
393
+ const added = await send('POST', path, m, { email: 'LEAVER@projects.test', role: 'viewer' });
394
+ expect(added.status).toBe(201);
395
+ expect(await readJson(added)).toMatchObject({
396
+ userId: actors.leaver!.userId,
397
+ email: 'leaver@projects.test',
398
+ role: 'viewer',
399
+ addedBy: m.userId
400
+ });
401
+ await expectError(
402
+ await send('POST', path, m, { userId: actors.leaver!.userId, role: 'editor' }),
403
+ 409,
404
+ 'already_member'
405
+ );
406
+ await expectError(
407
+ await send('POST', path, m, { userId: actors.guest!.userId, role: 'viewer' }),
408
+ 403,
409
+ 'guest_target'
410
+ );
411
+ await expectError(
412
+ await send('POST', path, m, { email: 'nobody@projects.test', role: 'viewer' }),
413
+ 404,
414
+ 'member_not_found'
415
+ );
416
+ await app.db.db
417
+ .update(workspaceMembers)
418
+ .set({ isActive: false })
419
+ .where(eq(workspaceMembers.id, actors.sleeper!.memberId));
420
+ await expectError(
421
+ await send('POST', path, m, { userId: actors.sleeper!.userId, role: 'viewer' }),
422
+ 404,
423
+ 'member_not_found'
424
+ );
425
+ // Exactly one of userId and email; a role outside the three is refused.
426
+ await expectError(
427
+ await send('POST', path, m, { userId: 'a', email: 'a@b.test', role: 'viewer' }),
428
+ 400,
429
+ 'validation_error'
430
+ );
431
+ await expectError(await send('POST', path, m, { role: 'viewer' }), 400, 'validation_error');
432
+ await expectError(
433
+ await send('POST', path, m, { userId: actors.outsider!.userId, role: 'owner' }),
434
+ 400,
435
+ 'validation_error'
436
+ );
437
+ });
438
+
439
+ it('the database itself refuses a role outside the three', async () => {
440
+ await expect(
441
+ app.db.pool.query(
442
+ `INSERT INTO project_members (project_id, member_id, role) VALUES ($1, $2, 'owner')`,
443
+ [projectId, actors.outsider!.memberId]
444
+ )
445
+ ).rejects.toThrow(/project_members_role_check/);
446
+ });
447
+
448
+ it('a manager changes a role; a member of the project removes THEMSELVES, whatever their role', async () => {
449
+ const m = actors.manager!;
450
+ const leaver = actors.leaver!;
451
+ const changed = await send('PATCH', `/projects/${projectId}/members/${leaver.userId}`, m, {
452
+ role: 'editor'
453
+ });
454
+ expect(changed.status).toBe(200);
455
+ expect(await readJson(changed)).toMatchObject({ userId: leaver.userId, role: 'editor' });
456
+ expect((await readJson(await send('GET', `/projects/${projectId}`, leaver))).myRole).toBe('editor');
457
+ await expectError(
458
+ await send('PATCH', `/projects/${projectId}/members/${actors.outsider!.userId}`, m, { role: 'editor' }),
459
+ 404,
460
+ 'member_not_found'
461
+ );
462
+
463
+ const left = await send('DELETE', `/projects/${projectId}/members/${leaver.userId}`, leaver);
464
+ expect(left.status).toBe(200);
465
+ // Gone on the very next request.
466
+ await expectError(await send('GET', `/projects/${projectId}`, leaver), 404, 'not_found');
467
+ });
468
+
469
+ it('the members list pages on a cursor that survives the removal of the row it names', async () => {
470
+ const paged = await createProject(actors.manager!, 'Paged');
471
+ for (const name of ['editor', 'viewer', 'outsider', 'leaver'] as const) {
472
+ await send('POST', `/projects/${paged}/members`, actors.manager!, {
473
+ userId: actors[name]!.userId,
474
+ role: 'viewer'
475
+ });
476
+ }
477
+ const first = await readJson(await send('GET', `/projects/${paged}/members?limit=2`, actors.manager!));
478
+ expect(first.members).toHaveLength(2);
479
+ expect(first.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/);
480
+ // The row the cursor names is removed before the next page is asked for.
481
+ const lastOnPage = first.members[1].userId;
482
+ expect((await send('DELETE', `/projects/${paged}/members/${lastOnPage}`, actors.manager!)).status).toBe(
483
+ 200
484
+ );
485
+ const second = await readJson(
486
+ await send('GET', `/projects/${paged}/members?limit=2&cursor=${first.nextCursor}`, actors.manager!)
487
+ );
488
+ expect(second.members).toHaveLength(2);
489
+ const third = await readJson(
490
+ await send('GET', `/projects/${paged}/members?limit=2&cursor=${second.nextCursor}`, actors.manager!)
491
+ );
492
+ expect(third.members).toHaveLength(1);
493
+ expect(third.nextCursor).toBeNull();
494
+ const seen = [...first.members, ...second.members, ...third.members].map(
495
+ (m: { userId: string }) => m.userId
496
+ );
497
+ expect(new Set(seen).size).toBe(5);
498
+ // A cursor that is not one of ours reads as no cursor, never as a 500:
499
+ // plain garbage, a well-formed pair whose halves the casts would refuse,
500
+ // and a huge one.
501
+ const b64 = (v: string) => Buffer.from(v, 'utf8').toString('base64url');
502
+ for (const bad of [
503
+ 'garbage',
504
+ b64('2026-09-20 20:00:00.5+00|not-a-uuid'),
505
+ b64('99999999-99-99 99:99:99+00|aaaaaaaa-0000-4000-8000-000000000003'),
506
+ b64('1e+14|aaaaaaaa-0000-4000-8000-000000000003'),
507
+ b64('x'.repeat(300))
508
+ ]) {
509
+ const res = await send(
510
+ 'GET',
511
+ `/projects/${paged}/members?limit=10&cursor=${encodeURIComponent(bad)}`,
512
+ actors.manager!
513
+ );
514
+ expect(res.status).toBe(200);
515
+ expect((await readJson(res)).members).toHaveLength(4);
516
+ }
517
+ });
518
+
519
+ it('members added in the same instant are all paged, none skipped, none repeated', async () => {
520
+ const same = await createProject(actors.manager!, 'Same instant');
521
+ // One INSERT statement: every row gets the SAME created_at, to the microsecond.
522
+ await app.db.pool.query(
523
+ `INSERT INTO project_members (project_id, member_id, role)
524
+ SELECT $1, id, 'viewer' FROM workspace_members WHERE id = ANY($2::uuid[])`,
525
+ [same, [actors.editor!, actors.viewer!, actors.outsider!, actors.leaver!].map((a) => a.memberId)]
526
+ );
527
+ const seen: string[] = [];
528
+ let cursor: string | null = null;
529
+ for (let pages = 0; pages < 10; pages++) {
530
+ const res = await readJson(
531
+ await send(
532
+ 'GET',
533
+ `/projects/${same}/members?limit=2${cursor ? `&cursor=${cursor}` : ''}`,
534
+ actors.manager!
535
+ )
536
+ );
537
+ seen.push(...res.members.map((m: { userId: string }) => m.userId));
538
+ cursor = res.nextCursor;
539
+ if (!cursor) break;
540
+ }
541
+ // The manager's own row plus the four: five distinct people, in three pages.
542
+ expect(seen).toHaveLength(5);
543
+ expect(new Set(seen).size).toBe(5);
544
+ });
545
+
546
+ it('a manager removes someone else, and there is no last-manager guard', async () => {
547
+ const solo = await createProject(actors.leaver!, 'Solo');
548
+ const gone = await send('DELETE', `/projects/${solo}/members/${actors.leaver!.userId}`, actors.leaver!);
549
+ expect(gone.status).toBe(200);
550
+ // Nobody is left on it, and the workspace's admins still manage it.
551
+ const res = await send('GET', `/projects/${solo}`, actors.admin!);
552
+ expect(await readJson(res)).toMatchObject({ myRole: 'manager', memberCount: 0 });
553
+ const back = await send('POST', `/projects/${solo}/members`, actors.admin!, {
554
+ userId: actors.leaver!.userId,
555
+ role: 'viewer'
556
+ });
557
+ expect(back.status).toBe(201);
558
+ const removed = await send('DELETE', `/projects/${solo}/members/${actors.leaver!.userId}`, actors.admin!);
559
+ expect(removed.status).toBe(200);
560
+ });
561
+ });
562
+
563
+ describe('archive, never delete', () => {
564
+ let archivedId = '';
565
+
566
+ it('there is no delete route: 404 for a session, closed for a key', async () => {
567
+ await expectError(await send('DELETE', `/projects/${projectId}`, actors.owner!), 404, 'not_found');
568
+ expect((await app.db.db.select().from(projects).where(eq(projects.id, projectId))).length).toBe(1);
569
+ });
570
+
571
+ it('an archived project leaves the default list, stays readable, and refuses every change but unarchive', async () => {
572
+ const m = actors.manager!;
573
+ archivedId = await createProject(m, 'To archive');
574
+ for (const [name, role] of [
575
+ ['editor', 'editor'],
576
+ ['viewer', 'viewer']
577
+ ] as const) {
578
+ await send('POST', `/projects/${archivedId}/members`, m, { userId: actors[name]!.userId, role });
579
+ }
580
+ const res = await send('POST', `/projects/${archivedId}/archive`, m);
581
+ expect(res.status).toBe(200);
582
+ expect((await readJson(res)).archivedAt).not.toBeNull();
583
+
584
+ const listed = async (query: string) =>
585
+ (await readJson(await send('GET', `/projects?limit=100${query}`, m))).projects.map(
586
+ (p: { id: string }) => p.id
587
+ );
588
+ expect(await listed('')).not.toContain(archivedId);
589
+ expect(await listed('&archived=false')).not.toContain(archivedId);
590
+ expect(await listed('&archived=true')).toEqual([archivedId]);
591
+ expect(await listed('&archived=all')).toEqual(expect.arrayContaining([archivedId, projectId]));
592
+
593
+ // Reads stay, for every role.
594
+ expect((await send('GET', `/projects/${archivedId}`, actors.viewer!)).status).toBe(200);
595
+ expect((await send('GET', `/projects/${archivedId}/members`, actors.viewer!)).status).toBe(200);
596
+
597
+ const code = 'project_archived';
598
+ await expectError(await send('PATCH', `/projects/${archivedId}`, m, { name: 'x' }), 409, code);
599
+ await expectError(await send('POST', `/projects/${archivedId}/archive`, m), 409, code);
600
+ await expectError(
601
+ await send('POST', `/projects/${archivedId}/members`, m, {
602
+ userId: actors.outsider!.userId,
603
+ role: 'viewer'
604
+ }),
605
+ 409,
606
+ code
607
+ );
608
+ await expectError(
609
+ await send('PATCH', `/projects/${archivedId}/members/${actors.viewer!.userId}`, m, { role: 'editor' }),
610
+ 409,
611
+ code
612
+ );
613
+ await expectError(
614
+ await send('DELETE', `/projects/${archivedId}/members/${actors.viewer!.userId}`, m),
615
+ 409,
616
+ code
617
+ );
618
+ // Leaving is a change too.
619
+ await expectError(
620
+ await send('DELETE', `/projects/${archivedId}/members/${actors.viewer!.userId}`, actors.viewer!),
621
+ 409,
622
+ code
623
+ );
624
+ // The owner is bound by it like anyone.
625
+ await expectError(
626
+ await send('PATCH', `/projects/${archivedId}`, actors.owner!, { name: 'x' }),
627
+ 409,
628
+ code
629
+ );
630
+ });
631
+
632
+ it('the order is 404, then 403, then 409', async () => {
633
+ await expectError(
634
+ await send('PATCH', `/projects/${archivedId}`, actors.outsider!, { name: 'x' }),
635
+ 404,
636
+ 'not_found'
637
+ );
638
+ await expectError(
639
+ await send('PATCH', `/projects/${archivedId}`, actors.viewer!, { name: 'x' }),
640
+ 403,
641
+ 'insufficient_project_role'
642
+ );
643
+ });
644
+
645
+ it('a manager unarchives it, and it takes changes again', async () => {
646
+ const m = actors.manager!;
647
+ const res = await send('POST', `/projects/${archivedId}/unarchive`, m);
648
+ expect(res.status).toBe(200);
649
+ expect((await readJson(res)).archivedAt).toBeNull();
650
+ await expectError(
651
+ await send('POST', `/projects/${archivedId}/unarchive`, m),
652
+ 409,
653
+ 'project_not_archived'
654
+ );
655
+ expect((await send('PATCH', `/projects/${archivedId}`, m, { name: 'Back' })).status).toBe(200);
656
+ });
657
+ });
658
+
659
+ describe('the seam a tool plugs in', () => {
660
+ const holds = async (
661
+ principal: Principal,
662
+ id: string,
663
+ atLeast: 'viewer' | 'editor' | 'manager',
664
+ access: 'read' | 'write'
665
+ ) => {
666
+ const predicate = projectGrantPredicate(principal, sql`${id}::uuid`, { atLeast, access });
667
+ const { rows } = await app.db.db.execute(sql`SELECT ${predicate} AS ok`);
668
+ return (rows[0] as { ok: boolean }).ok;
669
+ };
670
+
671
+ it('projectRole answers the effective role, and null for no read', async () => {
672
+ const db = app.db.db;
673
+ expect(await projectRole(db, principalOf(actors.manager!), projectId)).toBe('manager');
674
+ expect(await projectRole(db, principalOf(actors.editor!), projectId)).toBe('editor');
675
+ expect(await projectRole(db, principalOf(actors.viewer!), projectId)).toBe('viewer');
676
+ expect(await projectRole(db, principalOf(actors.outsider!), projectId)).toBeNull();
677
+ expect(await projectRole(db, principalOf(actors.admin!, { role: 'admin' }), projectId)).toBe('manager');
678
+ // The request's workspace is part of the question.
679
+ expect(
680
+ await projectRole(
681
+ db,
682
+ principalOf(actors.owner!, { role: 'owner', workspaceId: otherWorkspaceId }),
683
+ projectId
684
+ )
685
+ ).toBeNull();
686
+ expect(
687
+ await projectRole(db, principalOf(actors.editor!, { workspaceId: otherWorkspaceId }), projectId)
688
+ ).toBeNull();
689
+ });
690
+
691
+ it('the ladder: a role holds what it contains and nothing above it', async () => {
692
+ const editor = principalOf(actors.editor!);
693
+ expect(await holds(editor, projectId, 'viewer', 'read')).toBe(true);
694
+ expect(await holds(editor, projectId, 'editor', 'write')).toBe(true);
695
+ expect(await holds(editor, projectId, 'manager', 'read')).toBe(false);
696
+ expect(await holds(principalOf(actors.viewer!), projectId, 'editor', 'write')).toBe(false);
697
+ expect(await holds(principalOf(actors.outsider!), projectId, 'viewer', 'read')).toBe(false);
698
+ });
699
+
700
+ it('a WRITE through a grant is off while the project is archived, for the operator too; a READ stays', async () => {
701
+ const id = await createProject(actors.manager!, 'Archived for the seam');
702
+ await send('POST', `/projects/${id}/members`, actors.manager!, {
703
+ userId: actors.editor!.userId,
704
+ role: 'editor'
705
+ });
706
+ const editor = principalOf(actors.editor!);
707
+ const owner = principalOf(actors.owner!, { role: 'owner' });
708
+ expect(await holds(editor, id, 'editor', 'write')).toBe(true);
709
+ expect(await holds(owner, id, 'editor', 'write')).toBe(true);
710
+ expect((await send('POST', `/projects/${id}/archive`, actors.manager!)).status).toBe(200);
711
+ expect(await holds(editor, id, 'editor', 'write')).toBe(false);
712
+ expect(await holds(owner, id, 'editor', 'write')).toBe(false);
713
+ expect(await holds(editor, id, 'viewer', 'read')).toBe(true);
714
+ expect(await holds(owner, id, 'viewer', 'read')).toBe(true);
715
+ });
716
+
717
+ it('a guest never holds a grant: refused on the principal AND on the row the grant rides on', async () => {
718
+ const guest = actors.guest!;
719
+ // A row no route would ever write, forced in.
720
+ await app.db.db.insert(projectMembers).values({ projectId, memberId: guest.memberId, role: 'manager' });
721
+ expect(await holds(principalOf(guest, { origin: 'guest' }), projectId, 'viewer', 'read')).toBe(false);
722
+ expect(await projectRole(app.db.db, principalOf(guest, { origin: 'guest' }), projectId)).toBeNull();
723
+ // Even a principal that wrongly says `local`: the membership row says guest.
724
+ expect(await holds(principalOf(guest), projectId, 'viewer', 'read')).toBe(false);
725
+ // A guest who is somehow an admin is still a guest.
726
+ expect(
727
+ await holds(principalOf(guest, { origin: 'guest', role: 'admin' }), projectId, 'viewer', 'read')
728
+ ).toBe(false);
729
+ await app.db.db
730
+ .delete(projectMembers)
731
+ .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.memberId, guest.memberId)));
732
+ });
733
+
734
+ it('a deactivated membership holds nothing', async () => {
735
+ const sleeper = actors.sleeper!;
736
+ await app.db.db.insert(projectMembers).values({ projectId, memberId: sleeper.memberId, role: 'editor' });
737
+ expect(await holds(principalOf(sleeper), projectId, 'viewer', 'read')).toBe(false);
738
+ await app.db.db
739
+ .delete(projectMembers)
740
+ .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.memberId, sleeper.memberId)));
741
+ });
742
+ });
743
+
744
+ describe('a grant dies with the workspace membership it rides on', () => {
745
+ it('a swept membership loses every grant, and a re-invite starts with none', async () => {
746
+ const leaver = actors.leaver!;
747
+ const added = await send('POST', `/projects/${projectId}/members`, actors.manager!, {
748
+ userId: leaver.userId,
749
+ role: 'editor'
750
+ });
751
+ expect(added.status).toBe(201);
752
+ // What the hub reconcile does when it sweeps a membership.
753
+ await app.db.db.delete(workspaceMembers).where(eq(workspaceMembers.id, leaver.memberId));
754
+ const grants = await app.db.db
755
+ .select()
756
+ .from(projectMembers)
757
+ .where(eq(projectMembers.memberId, leaver.memberId));
758
+ expect(grants).toHaveLength(0);
759
+
760
+ await app.db.db.insert(workspaceMembers).values({ workspaceId, userId: leaver.userId, role: 'member' });
761
+ await expectError(await send('GET', `/projects/${projectId}`, leaver), 404, 'not_found');
762
+ expect((await readJson(await send('GET', '/projects', leaver))).projects).toEqual([]);
763
+ });
764
+
765
+ it('an admin deleting the member’s account takes the grants with it', async () => {
766
+ const erased = actors.erased!;
767
+ await send('POST', `/projects/${projectId}/members`, actors.manager!, {
768
+ userId: erased.userId,
769
+ role: 'viewer'
770
+ });
771
+ const res = await send('DELETE', `/members/${erased.memberId}`, actors.owner!);
772
+ expect(res.status).toBe(200);
773
+ const grants = await app.db.db
774
+ .select()
775
+ .from(projectMembers)
776
+ .where(eq(projectMembers.memberId, erased.memberId));
777
+ expect(grants).toHaveLength(0);
778
+ const members = (await readJson(await send('GET', `/projects/${projectId}/members`, actors.manager!)))
779
+ .members;
780
+ expect(members.map((m: { userId: string }) => m.userId)).not.toContain(erased.userId);
781
+ });
782
+ });
783
+
784
+ describe('machine principals: each route consciously opened, and nothing else', () => {
785
+ let readKey = '';
786
+ let writeKey = '';
787
+ let exportKey = '';
788
+
789
+ beforeAll(async () => {
790
+ const cookie = actors.manager!.cookie;
791
+ readKey = await mintKey(cookie, [host.scopes.read]);
792
+ writeKey = await mintKey(cookie, [host.scopes.write]);
793
+ exportKey = await mintKey(cookie, [host.scopes.dataExport]);
794
+ });
795
+
796
+ it('the read scope reads and does not write', async () => {
797
+ expect((await send('GET', '/projects', { key: readKey })).status).toBe(200);
798
+ expect((await send('GET', `/projects/${projectId}`, { key: readKey })).status).toBe(200);
799
+ expect((await send('GET', `/projects/${projectId}/members`, { key: readKey })).status).toBe(200);
800
+ const code = 'insufficient_scope';
801
+ await expectError(await send('POST', '/projects', { key: readKey }, { name: 'k' }), 403, code);
802
+ await expectError(
803
+ await send('PATCH', `/projects/${projectId}`, { key: readKey }, { name: 'k' }),
804
+ 403,
805
+ code
806
+ );
807
+ await expectError(await send('POST', `/projects/${projectId}/archive`, { key: readKey }), 403, code);
808
+ await expectError(await send('POST', `/projects/${projectId}/unarchive`, { key: readKey }), 403, code);
809
+ await expectError(
810
+ await send('POST', `/projects/${projectId}/members`, { key: readKey }, { userId: 'x', role: 'viewer' }),
811
+ 403,
812
+ code
813
+ );
814
+ await expectError(
815
+ await send('PATCH', `/projects/${projectId}/members/x`, { key: readKey }, { role: 'viewer' }),
816
+ 403,
817
+ code
818
+ );
819
+ await expectError(await send('DELETE', `/projects/${projectId}/members/x`, { key: readKey }), 403, code);
820
+ });
821
+
822
+ it('the write scope writes and does not read; a key with neither reaches nothing', async () => {
823
+ const created = await send('POST', '/projects', { key: writeKey }, { name: 'By a key' });
824
+ expect(created.status).toBe(201);
825
+ const { id } = await readJson(created);
826
+ expect(
827
+ (await send('PATCH', `/projects/${id}`, { key: writeKey }, { name: 'By a key, renamed' })).status
828
+ ).toBe(200);
829
+ const added = await send(
830
+ 'POST',
831
+ `/projects/${id}/members`,
832
+ { key: writeKey },
833
+ {
834
+ userId: actors.viewer!.userId,
835
+ role: 'viewer'
836
+ }
837
+ );
838
+ expect(added.status).toBe(201);
839
+ expect(
840
+ (
841
+ await send(
842
+ 'PATCH',
843
+ `/projects/${id}/members/${actors.viewer!.userId}`,
844
+ { key: writeKey },
845
+ { role: 'editor' }
846
+ )
847
+ ).status
848
+ ).toBe(200);
849
+ expect(
850
+ (await send('DELETE', `/projects/${id}/members/${actors.viewer!.userId}`, { key: writeKey })).status
851
+ ).toBe(200);
852
+ expect((await send('POST', `/projects/${id}/archive`, { key: writeKey })).status).toBe(200);
853
+ expect((await send('POST', `/projects/${id}/unarchive`, { key: writeKey })).status).toBe(200);
854
+ await expectError(await send('GET', '/projects', { key: writeKey }), 403, 'insufficient_scope');
855
+ await expectError(await send('GET', `/projects/${id}`, { key: exportKey }), 403, 'insufficient_scope');
856
+ await expectError(
857
+ await send('POST', '/projects', { key: exportKey }, { name: 'k' }),
858
+ 403,
859
+ 'insufficient_scope'
860
+ );
861
+ });
862
+
863
+ it('a key acts as its user: it reads what they read and nothing more', async () => {
864
+ const outsiderKey = await mintKey(actors.outsider!.cookie, [host.scopes.read, host.scopes.write]);
865
+ await expectError(await send('GET', `/projects/${projectId}`, { key: outsiderKey }), 404, 'not_found');
866
+ await expectError(
867
+ await send('PATCH', `/projects/${projectId}`, { key: outsiderKey }, { name: 'x' }),
868
+ 404,
869
+ 'not_found'
870
+ );
871
+ const viewerKey = await mintKey(actors.viewer!.cookie, [host.scopes.read, host.scopes.write]);
872
+ await expectError(
873
+ await send('PATCH', `/projects/${projectId}`, { key: viewerKey }, { name: 'x' }),
874
+ 403,
875
+ 'insufficient_project_role'
876
+ );
877
+ });
878
+
879
+ it('a shape that is not one of the ten routes stays closed to a key', async () => {
880
+ const both = await mintKey(actors.manager!.cookie, [host.scopes.read, host.scopes.write]);
881
+ const code = 'endpoint_not_allowed';
882
+ await expectError(await send('DELETE', `/projects/${projectId}`, { key: both }), 403, code);
883
+ await expectError(await send('PUT', `/projects/${projectId}`, { key: both }, {}), 403, code);
884
+ await expectError(await send('DELETE', '/projects', { key: both }), 403, code);
885
+ await expectError(await send('GET', `/projects/${projectId}/archive`, { key: both }), 403, code);
886
+ await expectError(await send('POST', `/projects/${projectId}/members/x`, { key: both }, {}), 403, code);
887
+ await expectError(await send('GET', `/projects/${projectId}/members/x/more`, { key: both }), 403, code);
888
+ await expectError(await send('GET', `/projects/${projectId}/anything`, { key: both }), 403, code);
889
+ });
890
+ });
891
+
892
+ describe('a team is a project member like a person (PRDCT-2794)', () => {
893
+ // Teams and seats are written straight to the tables: the team routes are
894
+ // not what this describe pins, the project side of a team is.
895
+ let teamCounter = 0;
896
+ async function makeTeam(ws = workspaceId): Promise<{ id: string; slug: string; name: string }> {
897
+ const slug = `team-${++teamCounter}`;
898
+ const name = `Team ${teamCounter}`;
899
+ const [row] = await app.db.db
900
+ .insert(workspaceTeams)
901
+ .values({ workspaceId: ws, slug, name, hubTeamId: null })
902
+ .returning({ id: workspaceTeams.id });
903
+ return { id: row!.id, slug, name };
904
+ }
905
+ const seat = async (teamId: string, who: Actor) =>
906
+ app.db.db.insert(workspaceTeamMembers).values({ teamId, memberId: who.memberId });
907
+ const unseat = async (teamId: string, who: Actor) =>
908
+ app.db.db
909
+ .delete(workspaceTeamMembers)
910
+ .where(and(eq(workspaceTeamMembers.teamId, teamId), eq(workspaceTeamMembers.memberId, who.memberId)));
911
+ const addTeam = (who: Actor | { key: string }, id: string, teamId: string, role: string) =>
912
+ send('POST', `/projects/${id}/members`, who, { teamId, role });
913
+ const roleOf = async (who: Actor, id: string) =>
914
+ (await readJson(await send('GET', `/projects/${id}`, who))).myRole;
915
+
916
+ beforeAll(async () => {
917
+ for (const name of ['tp1', 'tp2', 'tp3', 'seated', 'both']) await addActor(name);
918
+ await addActor('tguest', { origin: 'guest' });
919
+ });
920
+
921
+ /** A project the manager creates, one team on it at `viewer`, `seated` seated in it (and nobody else). */
922
+ let seatedProject = '';
923
+ let seatedTeam = '';
924
+
925
+ it('the owner adds a team: 201 with the team’s shape; a repeat, a foreign team and a bad body are refused', async () => {
926
+ const id = await createProject(actors.manager!, 'Teams: add');
927
+ const team = await makeTeam();
928
+ await seat(team.id, actors.tp1!);
929
+ await seat(team.id, actors.tp2!);
930
+ const res = await addTeam(actors.owner!, id, team.id, 'viewer');
931
+ expect(res.status).toBe(201);
932
+ expect(await readJson(res)).toMatchObject({
933
+ kind: 'team',
934
+ teamId: team.id,
935
+ slug: team.slug,
936
+ name: team.name,
937
+ membersCount: 2,
938
+ hubTeamId: null,
939
+ role: 'viewer',
940
+ addedBy: actors.owner!.userId
941
+ });
942
+ await expectError(await addTeam(actors.owner!, id, team.id, 'editor'), 409, 'already_member');
943
+ const foreign = await makeTeam(otherWorkspaceId);
944
+ await expectError(await addTeam(actors.owner!, id, foreign.id, 'viewer'), 404, 'team_not_found');
945
+ await expectError(
946
+ await addTeam(actors.owner!, id, '00000000-0000-4000-8000-0000000000aa', 'viewer'),
947
+ 404,
948
+ 'team_not_found'
949
+ );
950
+ const path = `/projects/${id}/members`;
951
+ await expectError(
952
+ await send('POST', path, actors.owner!, { email: actors.tp3!.email, teamId: team.id, role: 'viewer' }),
953
+ 400,
954
+ 'validation_error'
955
+ );
956
+ await expectError(await send('POST', path, actors.owner!, { role: 'viewer' }), 400, 'validation_error');
957
+ });
958
+
959
+ it('the members list carries people and teams, newest first, paged across the two kinds', async () => {
960
+ const id = await createProject(actors.manager!, 'Teams: list');
961
+ const t1 = await makeTeam();
962
+ const t2 = await makeTeam();
963
+ // Interleaved, so a page boundary falls between the two kinds.
964
+ const addPerson = async (who: Actor) =>
965
+ expect(
966
+ (
967
+ await send('POST', `/projects/${id}/members`, actors.manager!, {
968
+ userId: who.userId,
969
+ role: 'viewer'
970
+ })
971
+ ).status
972
+ ).toBe(201);
973
+ await addPerson(actors.tp1!);
974
+ expect((await addTeam(actors.manager!, id, t1.id, 'viewer')).status).toBe(201);
975
+ await addPerson(actors.tp2!);
976
+ expect((await addTeam(actors.manager!, id, t2.id, 'editor')).status).toBe(201);
977
+ await addPerson(actors.tp3!);
978
+
979
+ type Entry = { kind: 'person' | 'team'; userId?: string; teamId?: string; createdAt: string };
980
+ const seen: Entry[] = [];
981
+ let cursor: string | null = null;
982
+ for (let pages = 0; pages < 10; pages++) {
983
+ const res = await readJson(
984
+ await send(
985
+ 'GET',
986
+ `/projects/${id}/members?limit=2${cursor ? `&cursor=${cursor}` : ''}`,
987
+ actors.manager!
988
+ )
989
+ );
990
+ expect(res.members.length).toBeLessThanOrEqual(2);
991
+ seen.push(...res.members);
992
+ cursor = res.nextCursor;
993
+ if (!cursor) break;
994
+ }
995
+ const keyOf = (e: Entry) => (e.kind === 'person' ? `p:${e.userId}` : `t:${e.teamId}`);
996
+ // The creator's own manager entry, the three people, the two teams: six, none repeated, none skipped.
997
+ expect(seen.map(keyOf)).toEqual([
998
+ `p:${actors.tp3!.userId}`,
999
+ `t:${t2.id}`,
1000
+ `p:${actors.tp2!.userId}`,
1001
+ `t:${t1.id}`,
1002
+ `p:${actors.tp1!.userId}`,
1003
+ `p:${actors.manager!.userId}`
1004
+ ]);
1005
+ const times = seen.map((e) => Date.parse(e.createdAt));
1006
+ expect([...times].sort((a, b) => b - a)).toEqual(times);
1007
+ const team = seen.find((e) => e.kind === 'team' && e.teamId === t2.id);
1008
+ expect(team).toMatchObject({
1009
+ kind: 'team',
1010
+ slug: t2.slug,
1011
+ name: t2.name,
1012
+ role: 'editor',
1013
+ membersCount: 0
1014
+ });
1015
+ expect((await readJson(await send('GET', `/projects/${id}`, actors.manager!))).memberCount).toBe(6);
1016
+ });
1017
+
1018
+ it('a person seated in the team, with no entry of their own, reads the project; a member outside it does not', async () => {
1019
+ seatedProject = await createProject(actors.manager!, 'Teams: seated');
1020
+ seatedTeam = (await makeTeam()).id;
1021
+ await seat(seatedTeam, actors.seated!);
1022
+ expect((await addTeam(actors.manager!, seatedProject, seatedTeam, 'viewer')).status).toBe(201);
1023
+ const res = await send('GET', `/projects/${seatedProject}`, actors.seated!);
1024
+ expect(res.status).toBe(200);
1025
+ expect(await readJson(res)).toMatchObject({ id: seatedProject, myRole: 'viewer' });
1026
+ const listed = (await readJson(await send('GET', '/projects?limit=100', actors.seated!))).projects;
1027
+ expect(listed).toEqual([expect.objectContaining({ id: seatedProject, myRole: 'viewer' })]);
1028
+ await expectError(await send('GET', `/projects/${seatedProject}`, actors.outsider!), 404, 'not_found');
1029
+ await expectError(await send('GET', `/projects/${seatedProject}`, actors.tp3!), 404, 'not_found');
1030
+ });
1031
+
1032
+ it('the ladder through a team: viewer refuses a manager act, manager allows it', async () => {
1033
+ const path = `/projects/${seatedProject}/members`;
1034
+ await expectError(
1035
+ await send('POST', path, actors.seated!, { userId: actors.tp1!.userId, role: 'viewer' }),
1036
+ 403,
1037
+ 'insufficient_project_role'
1038
+ );
1039
+ const raised = await send('PATCH', `/projects/${seatedProject}/teams/${seatedTeam}`, actors.manager!, {
1040
+ role: 'manager'
1041
+ });
1042
+ expect(raised.status).toBe(200);
1043
+ expect(await readJson(raised)).toMatchObject({ kind: 'team', teamId: seatedTeam, role: 'manager' });
1044
+ expect(await roleOf(actors.seated!, seatedProject)).toBe('manager');
1045
+ expect(
1046
+ (await send('POST', path, actors.seated!, { userId: actors.tp1!.userId, role: 'viewer' })).status
1047
+ ).toBe(201);
1048
+ // Back to viewer for what follows.
1049
+ expect(
1050
+ (
1051
+ await send('PATCH', `/projects/${seatedProject}/teams/${seatedTeam}`, actors.manager!, {
1052
+ role: 'viewer'
1053
+ })
1054
+ ).status
1055
+ ).toBe(200);
1056
+ // A team not on the project is 404 team_not_found on both team routes.
1057
+ const stray = await makeTeam();
1058
+ await expectError(
1059
+ await send('PATCH', `/projects/${seatedProject}/teams/${stray.id}`, actors.manager!, {
1060
+ role: 'editor'
1061
+ }),
1062
+ 404,
1063
+ 'team_not_found'
1064
+ );
1065
+ await expectError(
1066
+ await send('DELETE', `/projects/${seatedProject}/teams/${stray.id}`, actors.manager!),
1067
+ 404,
1068
+ 'team_not_found'
1069
+ );
1070
+ });
1071
+
1072
+ it('the effective role is the highest of the person’s own entry and their teams’', async () => {
1073
+ const id = await createProject(actors.manager!, 'Teams: highest');
1074
+ const team = await makeTeam();
1075
+ await seat(team.id, actors.both!);
1076
+ expect(
1077
+ (
1078
+ await send('POST', `/projects/${id}/members`, actors.manager!, {
1079
+ userId: actors.both!.userId,
1080
+ role: 'viewer'
1081
+ })
1082
+ ).status
1083
+ ).toBe(201);
1084
+ expect((await addTeam(actors.manager!, id, team.id, 'manager')).status).toBe(201);
1085
+ expect(await roleOf(actors.both!, id)).toBe('manager');
1086
+ const removed = await send('DELETE', `/projects/${id}/teams/${team.id}`, actors.manager!);
1087
+ expect(removed.status).toBe(200);
1088
+ expect(await readJson(removed)).toMatchObject({
1089
+ kind: 'team',
1090
+ teamId: team.id,
1091
+ slug: team.slug,
1092
+ role: 'manager',
1093
+ membersCount: 1
1094
+ });
1095
+ expect(await roleOf(actors.both!, id)).toBe('viewer');
1096
+ await expectError(
1097
+ await send('DELETE', `/projects/${id}/teams/${team.id}`, actors.manager!),
1098
+ 404,
1099
+ 'team_not_found'
1100
+ );
1101
+ });
1102
+
1103
+ it('unseating the person ends the read on the next request; seating them again restores it', async () => {
1104
+ await unseat(seatedTeam, actors.seated!);
1105
+ await expectError(await send('GET', `/projects/${seatedProject}`, actors.seated!), 404, 'not_found');
1106
+ expect((await readJson(await send('GET', '/projects', actors.seated!))).projects).toEqual([]);
1107
+ await seat(seatedTeam, actors.seated!);
1108
+ expect(await roleOf(actors.seated!, seatedProject)).toBe('viewer');
1109
+ });
1110
+
1111
+ it('a deactivated membership seated in the team holds nothing', async () => {
1112
+ const seated = actors.seated!;
1113
+ await app.db.db
1114
+ .update(workspaceMembers)
1115
+ .set({ isActive: false })
1116
+ .where(eq(workspaceMembers.id, seated.memberId));
1117
+ try {
1118
+ // The predicate itself, for a principal that still claims the membership.
1119
+ expect(await projectRole(app.db.db, principalOf(seated), seatedProject)).toBeNull();
1120
+ // Over HTTP a deactivated membership resolves no principal in this workspace at all.
1121
+ expect((await send('GET', `/projects/${seatedProject}`, seated)).status).not.toBe(200);
1122
+ } finally {
1123
+ await app.db.db
1124
+ .update(workspaceMembers)
1125
+ .set({ isActive: true })
1126
+ .where(eq(workspaceMembers.id, seated.memberId));
1127
+ }
1128
+ expect(await roleOf(seated, seatedProject)).toBe('viewer');
1129
+ });
1130
+
1131
+ it('a guest seated by force is refused the subtree, and the predicate refuses the row', async () => {
1132
+ const guest = actors.tguest!;
1133
+ await seat(seatedTeam, guest);
1134
+ await expectError(await send('GET', `/projects/${seatedProject}`, guest), 403, 'guest_forbidden');
1135
+ await expectError(await send('GET', '/projects', guest), 403, 'guest_forbidden');
1136
+ expect(await projectRole(app.db.db, principalOf(guest, { origin: 'guest' }), seatedProject)).toBeNull();
1137
+ // Even a principal that wrongly says `local`: the membership row says guest.
1138
+ expect(await projectRole(app.db.db, principalOf(guest), seatedProject)).toBeNull();
1139
+ await unseat(seatedTeam, guest);
1140
+ });
1141
+
1142
+ it('deleting the team takes its place on the project (cascade) and the seated person’s read', async () => {
1143
+ const id = await createProject(actors.manager!, 'Teams: deleted');
1144
+ const team = await makeTeam();
1145
+ await seat(team.id, actors.tp2!);
1146
+ expect((await addTeam(actors.manager!, id, team.id, 'editor')).status).toBe(201);
1147
+ expect(await roleOf(actors.tp2!, id)).toBe('editor');
1148
+ await app.db.db.delete(workspaceTeams).where(eq(workspaceTeams.id, team.id));
1149
+ const rows = await app.db.db.select().from(projectTeams).where(eq(projectTeams.teamId, team.id));
1150
+ expect(rows).toHaveLength(0);
1151
+ await expectError(await send('GET', `/projects/${id}`, actors.tp2!), 404, 'not_found');
1152
+ });
1153
+
1154
+ it('an archived project refuses the three team changes, in the order 404, 403, 409', async () => {
1155
+ const m = actors.manager!;
1156
+ const id = await createProject(m, 'Teams: archived');
1157
+ const onIt = await makeTeam();
1158
+ const other = await makeTeam();
1159
+ expect((await addTeam(m, id, onIt.id, 'viewer')).status).toBe(201);
1160
+ expect(
1161
+ (await send('POST', `/projects/${id}/members`, m, { userId: actors.viewer!.userId, role: 'viewer' }))
1162
+ .status
1163
+ ).toBe(201);
1164
+ expect((await send('POST', `/projects/${id}/archive`, m)).status).toBe(200);
1165
+ const acts = [
1166
+ (who: Actor) => addTeam(who, id, other.id, 'viewer'),
1167
+ (who: Actor) => send('PATCH', `/projects/${id}/teams/${onIt.id}`, who, { role: 'editor' }),
1168
+ (who: Actor) => send('DELETE', `/projects/${id}/teams/${onIt.id}`, who)
1169
+ ];
1170
+ for (const act of acts) {
1171
+ await expectError(await act(actors.outsider!), 404, 'not_found');
1172
+ await expectError(await act(actors.viewer!), 403, 'insufficient_project_role');
1173
+ await expectError(await act(m), 409, 'project_archived');
1174
+ }
1175
+ expect(await app.db.db.select().from(projectTeams).where(eq(projectTeams.projectId, id))).toEqual([
1176
+ expect.objectContaining({ teamId: onIt.id, role: 'viewer' })
1177
+ ]);
1178
+ });
1179
+
1180
+ it('machine principals: the write scope adds a team and changes its role; the read scope does not', async () => {
1181
+ const writeKey = await mintKey(actors.manager!.cookie, [host.scopes.write]);
1182
+ const readKey = await mintKey(actors.manager!.cookie, [host.scopes.read]);
1183
+ const both = await mintKey(actors.manager!.cookie, [host.scopes.read, host.scopes.write]);
1184
+ const id = await createProject(actors.manager!, 'Teams: keys');
1185
+ const team = await makeTeam();
1186
+ await expectError(await addTeam({ key: readKey }, id, team.id, 'viewer'), 403, 'insufficient_scope');
1187
+ expect((await addTeam({ key: writeKey }, id, team.id, 'viewer')).status).toBe(201);
1188
+ const patched = await send(
1189
+ 'PATCH',
1190
+ `/projects/${id}/teams/${team.id}`,
1191
+ { key: writeKey },
1192
+ { role: 'editor' }
1193
+ );
1194
+ expect(patched.status).toBe(200);
1195
+ expect(await readJson(patched)).toMatchObject({ kind: 'team', role: 'editor' });
1196
+ // The two team routes are write routes: a read-only key meets the scope gate.
1197
+ await expectError(
1198
+ await send('PATCH', `/projects/${id}/teams/${team.id}`, { key: readKey }, { role: 'viewer' }),
1199
+ 403,
1200
+ 'insufficient_scope'
1201
+ );
1202
+ await expectError(
1203
+ await send('DELETE', `/projects/${id}/teams/${team.id}`, { key: readKey }),
1204
+ 403,
1205
+ 'insufficient_scope'
1206
+ );
1207
+ // A shape that is not one of the two team routes stays closed to any key.
1208
+ const code = 'endpoint_not_allowed';
1209
+ await expectError(await send('GET', `/projects/${id}/teams/${team.id}`, { key: both }), 403, code);
1210
+ await expectError(await send('POST', `/projects/${id}/teams/${team.id}`, { key: both }, {}), 403, code);
1211
+ await expectError(await send('GET', `/projects/${id}/teams`, { key: both }), 403, code);
1212
+ await expectError(
1213
+ await send('PATCH', `/projects/${id}/teams/not-a-uuid`, { key: both }, { role: 'viewer' }),
1214
+ 403,
1215
+ code
1216
+ );
1217
+ expect((await send('DELETE', `/projects/${id}/teams/${team.id}`, { key: writeKey })).status).toBe(200);
1218
+ });
1219
+
1220
+ it('a seat and a project entry forced across workspaces grant nothing (verifier round 1, mutation #1)', async () => {
1221
+ // Rows only a direct write can make: the member's membership of THIS
1222
+ // workspace seated in a team of ANOTHER, and that team put on a project of
1223
+ // this workspace. The predicate's `prj_t.workspace_id = prj_wm.workspace_id`
1224
+ // is what refuses it.
1225
+ const crossed = await addActor('crossed');
1226
+ const id = await createProject(actors.manager!, 'Teams: crossed');
1227
+ const foreign = await makeTeam(otherWorkspaceId);
1228
+ await seat(foreign.id, crossed);
1229
+ await app.db.db.insert(projectTeams).values({ projectId: id, teamId: foreign.id, role: 'manager' });
1230
+ await expectError(await send('GET', `/projects/${id}`, crossed), 404, 'not_found');
1231
+ expect(await projectRole(app.db.db, principalOf(crossed), id)).toBeNull();
1232
+ const listed = (await readJson(await send('GET', '/projects?limit=100', crossed))).projects as Array<{
1233
+ id: string;
1234
+ }>;
1235
+ expect(listed.map((p) => p.id)).not.toContain(id);
1236
+ });
1237
+
1238
+ it('a team deleted between the read and the insert answers 404 team_not_found, never 500 (verifier round 1, F2)', async () => {
1239
+ const id = await createProject(actors.manager!, 'Teams: vanishing');
1240
+ const team = await makeTeam();
1241
+ // The race, made certain: the team goes the moment its project entry is inserted.
1242
+ await app.db.pool.query(`
1243
+ CREATE FUNCTION test_vanish_team() RETURNS trigger LANGUAGE plpgsql AS $$
1244
+ BEGIN
1245
+ DELETE FROM workspace_teams WHERE id = NEW.team_id;
1246
+ RETURN NEW;
1247
+ END $$`);
1248
+ try {
1249
+ await app.db.pool.query(`
1250
+ CREATE TRIGGER test_vanish_team BEFORE INSERT ON project_teams
1251
+ FOR EACH ROW EXECUTE FUNCTION test_vanish_team()`);
1252
+ await expectError(await addTeam(actors.manager!, id, team.id, 'viewer'), 404, 'team_not_found');
1253
+ } finally {
1254
+ await app.db.pool.query(`DROP TRIGGER IF EXISTS test_vanish_team ON project_teams`);
1255
+ await app.db.pool.query(`DROP FUNCTION IF EXISTS test_vanish_team()`);
1256
+ }
1257
+ expect(await app.db.db.select().from(projectTeams).where(eq(projectTeams.projectId, id))).toEqual([]);
1258
+ // The refused insert rolled its transaction back, the trigger's delete with it.
1259
+ expect(await app.db.db.select().from(workspaceTeams).where(eq(workspaceTeams.id, team.id))).toHaveLength(
1260
+ 1
1261
+ );
1262
+ });
1263
+ });
1264
+
1265
+ describe('the record: one audit action per change, and the export', () => {
1266
+ it('every mutation wrote its audit action', async () => {
1267
+ const rows = await app.db.db
1268
+ .select({ action: auditLog.action })
1269
+ .from(auditLog)
1270
+ .where(sql`${auditLog.action} LIKE 'project.%'`);
1271
+ const actions = new Set(rows.map((r) => r.action));
1272
+ expect([...actions].sort()).toEqual([
1273
+ 'project.archive',
1274
+ 'project.create',
1275
+ 'project.member_add',
1276
+ 'project.member_remove',
1277
+ 'project.member_role',
1278
+ 'project.team_add',
1279
+ 'project.team_remove',
1280
+ 'project.team_role',
1281
+ 'project.unarchive',
1282
+ 'project.update'
1283
+ ]);
1284
+ });
1285
+
1286
+ it('the workspace export carries projects.json and project_members.json, archived projects included', async () => {
1287
+ const res = await send('GET', '/workspace/export', actors.owner!);
1288
+ expect(res.status).toBe(200);
1289
+ const zip = new AdmZip(Buffer.from(await res.arrayBuffer()));
1290
+ const names = zip.getEntries().map((e) => e.entryName);
1291
+ expect(names).toEqual(expect.arrayContaining(['projects.json', 'project_members.json']));
1292
+ const exported = JSON.parse(zip.readAsText('projects.json')) as Array<{ id: string; name: string }>;
1293
+ expect(exported.map((p) => p.name)).toEqual(
1294
+ expect.arrayContaining(['Spring campaign', 'Archived for the seam'])
1295
+ );
1296
+ // This workspace's only: the other workspace's project is not in it.
1297
+ expect(exported.map((p) => p.name)).not.toContain('Elsewhere’s');
1298
+ const members = JSON.parse(zip.readAsText('project_members.json')) as Array<{
1299
+ projectId: string;
1300
+ userId: string;
1301
+ role: string;
1302
+ }>;
1303
+ expect(members).toEqual(
1304
+ expect.arrayContaining([
1305
+ expect.objectContaining({ projectId, userId: actors.manager!.userId, role: 'manager' }),
1306
+ expect.objectContaining({ projectId, userId: actors.viewer!.userId, role: 'viewer' })
1307
+ ])
1308
+ );
1309
+ });
1310
+ });