@volter/twin-clerk 0.1.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 (321) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +102 -0
  3. package/client/clerk-js-dist/167_clerk.headless_b86dff_5.127.2.js +1 -0
  4. package/client/clerk-js-dist/2172_clerk.browser_0cc2cc_5.127.2.js +1 -0
  5. package/client/clerk-js-dist/2172_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  6. package/client/clerk-js-dist/368_clerk.headless_b86dff_5.127.2.js +3 -0
  7. package/client/clerk-js-dist/377_clerk.headless_b86dff_5.127.2.js +1 -0
  8. package/client/clerk-js-dist/4170_clerk.browser_0cc2cc_5.127.2.js +1 -0
  9. package/client/clerk-js-dist/4170_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  10. package/client/clerk-js-dist/437_clerk.headless_b86dff_5.127.2.js +1 -0
  11. package/client/clerk-js-dist/5192_clerk.browser_0cc2cc_5.127.2.js +1 -0
  12. package/client/clerk-js-dist/5192_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  13. package/client/clerk-js-dist/553_clerk.headless_b86dff_5.127.2.js +1 -0
  14. package/client/clerk-js-dist/5645_clerk.browser_0cc2cc_5.127.2.js +1 -0
  15. package/client/clerk-js-dist/5645_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  16. package/client/clerk-js-dist/623_clerk.headless_b86dff_5.127.2.js +42 -0
  17. package/client/clerk-js-dist/7067_clerk.browser_0cc2cc_5.127.2.js +1 -0
  18. package/client/clerk-js-dist/7067_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  19. package/client/clerk-js-dist/7502_clerk.browser_0cc2cc_5.127.2.js +1 -0
  20. package/client/clerk-js-dist/7502_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  21. package/client/clerk-js-dist/785_clerk.headless.browser_540199_5.127.2.js +1 -0
  22. package/client/clerk-js-dist/785_clerk.headless_b86dff_5.127.2.js +1 -0
  23. package/client/clerk-js-dist/8043_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  24. package/client/clerk-js-dist/825_clerk.headless.browser_540199_5.127.2.js +1 -0
  25. package/client/clerk-js-dist/825_clerk.headless_b86dff_5.127.2.js +1 -0
  26. package/client/clerk-js-dist/890_clerk.headless_b86dff_5.127.2.js +2 -0
  27. package/client/clerk-js-dist/901_clerk.headless.browser_540199_5.127.2.js +1 -0
  28. package/client/clerk-js-dist/apiKeys_clerk.browser_0cc2cc_5.127.2.js +1 -0
  29. package/client/clerk-js-dist/apiKeys_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  30. package/client/clerk-js-dist/base-account-sdk_clerk.browser_0cc2cc_5.127.2.js +42 -0
  31. package/client/clerk-js-dist/base-account-sdk_clerk.headless.browser_540199_5.127.2.js +42 -0
  32. package/client/clerk-js-dist/base-account-sdk_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  33. package/client/clerk-js-dist/blankcaptcha_clerk.browser_0cc2cc_5.127.2.js +1 -0
  34. package/client/clerk-js-dist/blankcaptcha_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  35. package/client/clerk-js-dist/checkout_clerk.browser_0cc2cc_5.127.2.js +9 -0
  36. package/client/clerk-js-dist/checkout_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  37. package/client/clerk-js-dist/clerk.browser.js +19 -0
  38. package/client/clerk-js-dist/clerk.headless.browser.js +19 -0
  39. package/client/clerk-js-dist/clerk.headless.js +19 -0
  40. package/client/clerk-js-dist/clerk.js +809 -0
  41. package/client/clerk-js-dist/clerk.legacy.browser.js +1 -0
  42. package/client/clerk-js-dist/clerk.no-rhc.js +766 -0
  43. package/client/clerk-js-dist/coinbase-wallet-sdk_clerk.browser_0cc2cc_5.127.2.js +3 -0
  44. package/client/clerk-js-dist/coinbase-wallet-sdk_clerk.headless.browser_540199_5.127.2.js +3 -0
  45. package/client/clerk-js-dist/coinbase-wallet-sdk_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  46. package/client/clerk-js-dist/cookieSuffix_clerk.browser_0cc2cc_5.127.2.js +1 -0
  47. package/client/clerk-js-dist/cookieSuffix_clerk.headless.browser_540199_5.127.2.js +1 -0
  48. package/client/clerk-js-dist/cookieSuffix_clerk.headless_b86dff_5.127.2.js +1 -0
  49. package/client/clerk-js-dist/cookieSuffix_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  50. package/client/clerk-js-dist/copy-api-key-modal_clerk.browser_0cc2cc_5.127.2.js +1 -0
  51. package/client/clerk-js-dist/copy-api-key-modal_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  52. package/client/clerk-js-dist/createorganization_clerk.browser_0cc2cc_5.127.2.js +1 -0
  53. package/client/clerk-js-dist/createorganization_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  54. package/client/clerk-js-dist/enableOrganizationsPrompt_clerk.browser_0cc2cc_5.127.2.js +247 -0
  55. package/client/clerk-js-dist/enableOrganizationsPrompt_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  56. package/client/clerk-js-dist/framework_clerk.browser_0cc2cc_5.127.2.js +1 -0
  57. package/client/clerk-js-dist/framework_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  58. package/client/clerk-js-dist/impersonationfab_clerk.browser_0cc2cc_5.127.2.js +1 -0
  59. package/client/clerk-js-dist/impersonationfab_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  60. package/client/clerk-js-dist/keylessPrompt_clerk.browser_0cc2cc_5.127.2.js +228 -0
  61. package/client/clerk-js-dist/keylessPrompt_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  62. package/client/clerk-js-dist/oauthConsent_clerk.browser_0cc2cc_5.127.2.js +1 -0
  63. package/client/clerk-js-dist/oauthConsent_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  64. package/client/clerk-js-dist/onetap_clerk.browser_0cc2cc_5.127.2.js +1 -0
  65. package/client/clerk-js-dist/onetap_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  66. package/client/clerk-js-dist/op-api-keys-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  67. package/client/clerk-js-dist/op-api-keys-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  68. package/client/clerk-js-dist/op-billing-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  69. package/client/clerk-js-dist/op-billing-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  70. package/client/clerk-js-dist/op-plans-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  71. package/client/clerk-js-dist/op-plans-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  72. package/client/clerk-js-dist/organizationlist_clerk.browser_0cc2cc_5.127.2.js +1 -0
  73. package/client/clerk-js-dist/organizationlist_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  74. package/client/clerk-js-dist/organizationprofile_clerk.browser_0cc2cc_5.127.2.js +1 -0
  75. package/client/clerk-js-dist/organizationprofile_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  76. package/client/clerk-js-dist/organizationswitcher_clerk.browser_0cc2cc_5.127.2.js +1 -0
  77. package/client/clerk-js-dist/organizationswitcher_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  78. package/client/clerk-js-dist/payment-attempt-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  79. package/client/clerk-js-dist/payment-attempt-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  80. package/client/clerk-js-dist/planDetails_clerk.browser_0cc2cc_5.127.2.js +1 -0
  81. package/client/clerk-js-dist/planDetails_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  82. package/client/clerk-js-dist/prefetchorganizationlist_clerk.browser_0cc2cc_5.127.2.js +1 -0
  83. package/client/clerk-js-dist/prefetchorganizationlist_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  84. package/client/clerk-js-dist/pricingTable_clerk.browser_0cc2cc_5.127.2.js +1 -0
  85. package/client/clerk-js-dist/pricingTable_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  86. package/client/clerk-js-dist/query-core-vendors_clerk.browser_0cc2cc_5.127.2.js +1 -0
  87. package/client/clerk-js-dist/query-core-vendors_clerk.headless.browser_540199_5.127.2.js +1 -0
  88. package/client/clerk-js-dist/query-core-vendors_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  89. package/client/clerk-js-dist/revoke-api-key-modal_clerk.browser_0cc2cc_5.127.2.js +1 -0
  90. package/client/clerk-js-dist/revoke-api-key-modal_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  91. package/client/clerk-js-dist/sessionTasks_clerk.browser_0cc2cc_5.127.2.js +1 -0
  92. package/client/clerk-js-dist/sessionTasks_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  93. package/client/clerk-js-dist/signin_clerk.browser_0cc2cc_5.127.2.js +1 -0
  94. package/client/clerk-js-dist/signin_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  95. package/client/clerk-js-dist/signup_clerk.browser_0cc2cc_5.127.2.js +1 -0
  96. package/client/clerk-js-dist/signup_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  97. package/client/clerk-js-dist/statement-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  98. package/client/clerk-js-dist/statement-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  99. package/client/clerk-js-dist/stripe-vendors_clerk.browser_0cc2cc_5.127.2.js +1 -0
  100. package/client/clerk-js-dist/stripe-vendors_clerk.headless.browser_540199_5.127.2.js +1 -0
  101. package/client/clerk-js-dist/stripe-vendors_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  102. package/client/clerk-js-dist/subscriptionDetails_clerk.browser_0cc2cc_5.127.2.js +1 -0
  103. package/client/clerk-js-dist/subscriptionDetails_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  104. package/client/clerk-js-dist/taskChooseOrganization_clerk.browser_0cc2cc_5.127.2.js +1 -0
  105. package/client/clerk-js-dist/taskChooseOrganization_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  106. package/client/clerk-js-dist/taskResetPassword_clerk.browser_0cc2cc_5.127.2.js +1 -0
  107. package/client/clerk-js-dist/taskResetPassword_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  108. package/client/clerk-js-dist/taskSetupMFA_clerk.browser_0cc2cc_5.127.2.js +4 -0
  109. package/client/clerk-js-dist/taskSetupMFA_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  110. package/client/clerk-js-dist/ui-common_clerk.browser_0cc2cc_5.127.2.js +124 -0
  111. package/client/clerk-js-dist/ui-common_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  112. package/client/clerk-js-dist/up-api-keys-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  113. package/client/clerk-js-dist/up-api-keys-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  114. package/client/clerk-js-dist/up-billing-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  115. package/client/clerk-js-dist/up-billing-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  116. package/client/clerk-js-dist/up-plans-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  117. package/client/clerk-js-dist/up-plans-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  118. package/client/clerk-js-dist/useravatar_clerk.browser_0cc2cc_5.127.2.js +1 -0
  119. package/client/clerk-js-dist/useravatar_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  120. package/client/clerk-js-dist/userbutton_clerk.browser_0cc2cc_5.127.2.js +1 -0
  121. package/client/clerk-js-dist/userbutton_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  122. package/client/clerk-js-dist/userprofile_clerk.browser_0cc2cc_5.127.2.js +4 -0
  123. package/client/clerk-js-dist/userprofile_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  124. package/client/clerk-js-dist/userverification_clerk.browser_0cc2cc_5.127.2.js +1 -0
  125. package/client/clerk-js-dist/userverification_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  126. package/client/clerk-js-dist/vendors_clerk.browser_0cc2cc_5.127.2.js +20 -0
  127. package/client/clerk-js-dist/vendors_clerk.headless.browser_540199_5.127.2.js +1 -0
  128. package/client/clerk-js-dist/vendors_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  129. package/client/clerk-js-dist/waitlist_clerk.browser_0cc2cc_5.127.2.js +1 -0
  130. package/client/clerk-js-dist/waitlist_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  131. package/client/clerk-js-dist/web3-solana-wallet-buttons_clerk.browser_0cc2cc_5.127.2.js +143 -0
  132. package/client/clerk-js-dist/web3-solana-wallet-buttons_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  133. package/client/clerk-js-dist/zxcvbn-common_clerk.browser_0cc2cc_5.127.2.js +1 -0
  134. package/client/clerk-js-dist/zxcvbn-common_clerk.headless.browser_540199_5.127.2.js +1 -0
  135. package/client/clerk-js-dist/zxcvbn-common_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  136. package/client/clerk-js-dist/zxcvbn-ts-core_clerk.browser_0cc2cc_5.127.2.js +2 -0
  137. package/client/clerk-js-dist/zxcvbn-ts-core_clerk.headless.browser_540199_5.127.2.js +2 -0
  138. package/client/clerk-js-dist/zxcvbn-ts-core_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  139. package/client/clerk-mirror.css +72 -0
  140. package/client/clerk-mirror.tsx +204 -0
  141. package/client/clerk.browser.js +19 -0
  142. package/dist/client/clerk-js-dist/167_clerk.headless_b86dff_5.127.2.js +1 -0
  143. package/dist/client/clerk-js-dist/2172_clerk.browser_0cc2cc_5.127.2.js +1 -0
  144. package/dist/client/clerk-js-dist/2172_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  145. package/dist/client/clerk-js-dist/368_clerk.headless_b86dff_5.127.2.js +3 -0
  146. package/dist/client/clerk-js-dist/377_clerk.headless_b86dff_5.127.2.js +1 -0
  147. package/dist/client/clerk-js-dist/4170_clerk.browser_0cc2cc_5.127.2.js +1 -0
  148. package/dist/client/clerk-js-dist/4170_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  149. package/dist/client/clerk-js-dist/437_clerk.headless_b86dff_5.127.2.js +1 -0
  150. package/dist/client/clerk-js-dist/5192_clerk.browser_0cc2cc_5.127.2.js +1 -0
  151. package/dist/client/clerk-js-dist/5192_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  152. package/dist/client/clerk-js-dist/553_clerk.headless_b86dff_5.127.2.js +1 -0
  153. package/dist/client/clerk-js-dist/5645_clerk.browser_0cc2cc_5.127.2.js +1 -0
  154. package/dist/client/clerk-js-dist/5645_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  155. package/dist/client/clerk-js-dist/623_clerk.headless_b86dff_5.127.2.js +42 -0
  156. package/dist/client/clerk-js-dist/7067_clerk.browser_0cc2cc_5.127.2.js +1 -0
  157. package/dist/client/clerk-js-dist/7067_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  158. package/dist/client/clerk-js-dist/7502_clerk.browser_0cc2cc_5.127.2.js +1 -0
  159. package/dist/client/clerk-js-dist/7502_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  160. package/dist/client/clerk-js-dist/785_clerk.headless.browser_540199_5.127.2.js +1 -0
  161. package/dist/client/clerk-js-dist/785_clerk.headless_b86dff_5.127.2.js +1 -0
  162. package/dist/client/clerk-js-dist/8043_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  163. package/dist/client/clerk-js-dist/825_clerk.headless.browser_540199_5.127.2.js +1 -0
  164. package/dist/client/clerk-js-dist/825_clerk.headless_b86dff_5.127.2.js +1 -0
  165. package/dist/client/clerk-js-dist/890_clerk.headless_b86dff_5.127.2.js +2 -0
  166. package/dist/client/clerk-js-dist/901_clerk.headless.browser_540199_5.127.2.js +1 -0
  167. package/dist/client/clerk-js-dist/apiKeys_clerk.browser_0cc2cc_5.127.2.js +1 -0
  168. package/dist/client/clerk-js-dist/apiKeys_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  169. package/dist/client/clerk-js-dist/base-account-sdk_clerk.browser_0cc2cc_5.127.2.js +42 -0
  170. package/dist/client/clerk-js-dist/base-account-sdk_clerk.headless.browser_540199_5.127.2.js +42 -0
  171. package/dist/client/clerk-js-dist/base-account-sdk_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  172. package/dist/client/clerk-js-dist/blankcaptcha_clerk.browser_0cc2cc_5.127.2.js +1 -0
  173. package/dist/client/clerk-js-dist/blankcaptcha_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  174. package/dist/client/clerk-js-dist/checkout_clerk.browser_0cc2cc_5.127.2.js +9 -0
  175. package/dist/client/clerk-js-dist/checkout_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  176. package/dist/client/clerk-js-dist/clerk.browser.js +19 -0
  177. package/dist/client/clerk-js-dist/clerk.headless.browser.js +19 -0
  178. package/dist/client/clerk-js-dist/clerk.headless.js +19 -0
  179. package/dist/client/clerk-js-dist/clerk.js +809 -0
  180. package/dist/client/clerk-js-dist/clerk.legacy.browser.js +1 -0
  181. package/dist/client/clerk-js-dist/clerk.no-rhc.js +766 -0
  182. package/dist/client/clerk-js-dist/coinbase-wallet-sdk_clerk.browser_0cc2cc_5.127.2.js +3 -0
  183. package/dist/client/clerk-js-dist/coinbase-wallet-sdk_clerk.headless.browser_540199_5.127.2.js +3 -0
  184. package/dist/client/clerk-js-dist/coinbase-wallet-sdk_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  185. package/dist/client/clerk-js-dist/cookieSuffix_clerk.browser_0cc2cc_5.127.2.js +1 -0
  186. package/dist/client/clerk-js-dist/cookieSuffix_clerk.headless.browser_540199_5.127.2.js +1 -0
  187. package/dist/client/clerk-js-dist/cookieSuffix_clerk.headless_b86dff_5.127.2.js +1 -0
  188. package/dist/client/clerk-js-dist/cookieSuffix_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  189. package/dist/client/clerk-js-dist/copy-api-key-modal_clerk.browser_0cc2cc_5.127.2.js +1 -0
  190. package/dist/client/clerk-js-dist/copy-api-key-modal_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  191. package/dist/client/clerk-js-dist/createorganization_clerk.browser_0cc2cc_5.127.2.js +1 -0
  192. package/dist/client/clerk-js-dist/createorganization_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  193. package/dist/client/clerk-js-dist/enableOrganizationsPrompt_clerk.browser_0cc2cc_5.127.2.js +247 -0
  194. package/dist/client/clerk-js-dist/enableOrganizationsPrompt_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  195. package/dist/client/clerk-js-dist/framework_clerk.browser_0cc2cc_5.127.2.js +1 -0
  196. package/dist/client/clerk-js-dist/framework_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  197. package/dist/client/clerk-js-dist/impersonationfab_clerk.browser_0cc2cc_5.127.2.js +1 -0
  198. package/dist/client/clerk-js-dist/impersonationfab_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  199. package/dist/client/clerk-js-dist/keylessPrompt_clerk.browser_0cc2cc_5.127.2.js +228 -0
  200. package/dist/client/clerk-js-dist/keylessPrompt_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  201. package/dist/client/clerk-js-dist/oauthConsent_clerk.browser_0cc2cc_5.127.2.js +1 -0
  202. package/dist/client/clerk-js-dist/oauthConsent_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  203. package/dist/client/clerk-js-dist/onetap_clerk.browser_0cc2cc_5.127.2.js +1 -0
  204. package/dist/client/clerk-js-dist/onetap_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  205. package/dist/client/clerk-js-dist/op-api-keys-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  206. package/dist/client/clerk-js-dist/op-api-keys-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  207. package/dist/client/clerk-js-dist/op-billing-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  208. package/dist/client/clerk-js-dist/op-billing-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  209. package/dist/client/clerk-js-dist/op-plans-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  210. package/dist/client/clerk-js-dist/op-plans-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  211. package/dist/client/clerk-js-dist/organizationlist_clerk.browser_0cc2cc_5.127.2.js +1 -0
  212. package/dist/client/clerk-js-dist/organizationlist_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  213. package/dist/client/clerk-js-dist/organizationprofile_clerk.browser_0cc2cc_5.127.2.js +1 -0
  214. package/dist/client/clerk-js-dist/organizationprofile_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  215. package/dist/client/clerk-js-dist/organizationswitcher_clerk.browser_0cc2cc_5.127.2.js +1 -0
  216. package/dist/client/clerk-js-dist/organizationswitcher_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  217. package/dist/client/clerk-js-dist/payment-attempt-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  218. package/dist/client/clerk-js-dist/payment-attempt-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  219. package/dist/client/clerk-js-dist/planDetails_clerk.browser_0cc2cc_5.127.2.js +1 -0
  220. package/dist/client/clerk-js-dist/planDetails_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  221. package/dist/client/clerk-js-dist/prefetchorganizationlist_clerk.browser_0cc2cc_5.127.2.js +1 -0
  222. package/dist/client/clerk-js-dist/prefetchorganizationlist_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  223. package/dist/client/clerk-js-dist/pricingTable_clerk.browser_0cc2cc_5.127.2.js +1 -0
  224. package/dist/client/clerk-js-dist/pricingTable_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  225. package/dist/client/clerk-js-dist/query-core-vendors_clerk.browser_0cc2cc_5.127.2.js +1 -0
  226. package/dist/client/clerk-js-dist/query-core-vendors_clerk.headless.browser_540199_5.127.2.js +1 -0
  227. package/dist/client/clerk-js-dist/query-core-vendors_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  228. package/dist/client/clerk-js-dist/revoke-api-key-modal_clerk.browser_0cc2cc_5.127.2.js +1 -0
  229. package/dist/client/clerk-js-dist/revoke-api-key-modal_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  230. package/dist/client/clerk-js-dist/sessionTasks_clerk.browser_0cc2cc_5.127.2.js +1 -0
  231. package/dist/client/clerk-js-dist/sessionTasks_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  232. package/dist/client/clerk-js-dist/signin_clerk.browser_0cc2cc_5.127.2.js +1 -0
  233. package/dist/client/clerk-js-dist/signin_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  234. package/dist/client/clerk-js-dist/signup_clerk.browser_0cc2cc_5.127.2.js +1 -0
  235. package/dist/client/clerk-js-dist/signup_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  236. package/dist/client/clerk-js-dist/statement-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  237. package/dist/client/clerk-js-dist/statement-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  238. package/dist/client/clerk-js-dist/stripe-vendors_clerk.browser_0cc2cc_5.127.2.js +1 -0
  239. package/dist/client/clerk-js-dist/stripe-vendors_clerk.headless.browser_540199_5.127.2.js +1 -0
  240. package/dist/client/clerk-js-dist/stripe-vendors_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  241. package/dist/client/clerk-js-dist/subscriptionDetails_clerk.browser_0cc2cc_5.127.2.js +1 -0
  242. package/dist/client/clerk-js-dist/subscriptionDetails_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  243. package/dist/client/clerk-js-dist/taskChooseOrganization_clerk.browser_0cc2cc_5.127.2.js +1 -0
  244. package/dist/client/clerk-js-dist/taskChooseOrganization_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  245. package/dist/client/clerk-js-dist/taskResetPassword_clerk.browser_0cc2cc_5.127.2.js +1 -0
  246. package/dist/client/clerk-js-dist/taskResetPassword_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  247. package/dist/client/clerk-js-dist/taskSetupMFA_clerk.browser_0cc2cc_5.127.2.js +4 -0
  248. package/dist/client/clerk-js-dist/taskSetupMFA_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  249. package/dist/client/clerk-js-dist/ui-common_clerk.browser_0cc2cc_5.127.2.js +124 -0
  250. package/dist/client/clerk-js-dist/ui-common_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  251. package/dist/client/clerk-js-dist/up-api-keys-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  252. package/dist/client/clerk-js-dist/up-api-keys-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  253. package/dist/client/clerk-js-dist/up-billing-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  254. package/dist/client/clerk-js-dist/up-billing-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  255. package/dist/client/clerk-js-dist/up-plans-page_clerk.browser_0cc2cc_5.127.2.js +1 -0
  256. package/dist/client/clerk-js-dist/up-plans-page_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  257. package/dist/client/clerk-js-dist/useravatar_clerk.browser_0cc2cc_5.127.2.js +1 -0
  258. package/dist/client/clerk-js-dist/useravatar_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  259. package/dist/client/clerk-js-dist/userbutton_clerk.browser_0cc2cc_5.127.2.js +1 -0
  260. package/dist/client/clerk-js-dist/userbutton_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  261. package/dist/client/clerk-js-dist/userprofile_clerk.browser_0cc2cc_5.127.2.js +4 -0
  262. package/dist/client/clerk-js-dist/userprofile_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  263. package/dist/client/clerk-js-dist/userverification_clerk.browser_0cc2cc_5.127.2.js +1 -0
  264. package/dist/client/clerk-js-dist/userverification_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  265. package/dist/client/clerk-js-dist/vendors_clerk.browser_0cc2cc_5.127.2.js +20 -0
  266. package/dist/client/clerk-js-dist/vendors_clerk.headless.browser_540199_5.127.2.js +1 -0
  267. package/dist/client/clerk-js-dist/vendors_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  268. package/dist/client/clerk-js-dist/waitlist_clerk.browser_0cc2cc_5.127.2.js +1 -0
  269. package/dist/client/clerk-js-dist/waitlist_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  270. package/dist/client/clerk-js-dist/web3-solana-wallet-buttons_clerk.browser_0cc2cc_5.127.2.js +143 -0
  271. package/dist/client/clerk-js-dist/web3-solana-wallet-buttons_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  272. package/dist/client/clerk-js-dist/zxcvbn-common_clerk.browser_0cc2cc_5.127.2.js +1 -0
  273. package/dist/client/clerk-js-dist/zxcvbn-common_clerk.headless.browser_540199_5.127.2.js +1 -0
  274. package/dist/client/clerk-js-dist/zxcvbn-common_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  275. package/dist/client/clerk-js-dist/zxcvbn-ts-core_clerk.browser_0cc2cc_5.127.2.js +2 -0
  276. package/dist/client/clerk-js-dist/zxcvbn-ts-core_clerk.headless.browser_540199_5.127.2.js +2 -0
  277. package/dist/client/clerk-js-dist/zxcvbn-ts-core_clerk.legacy.browser_6070dd_5.127.2.js +1 -0
  278. package/dist/client/clerk-mirror.bundle.js +321 -0
  279. package/dist/client/clerk-mirror.css +72 -0
  280. package/dist/client/clerk-mirror.d.ts +33 -0
  281. package/dist/client/clerk-mirror.js +143 -0
  282. package/dist/client/clerk-mirror.tsx +204 -0
  283. package/dist/client/clerk.browser.js +19 -0
  284. package/dist/src/clerk-capabilities.d.ts +4 -0
  285. package/dist/src/clerk-capabilities.js +1132 -0
  286. package/dist/src/clerk-conformance.d.ts +32 -0
  287. package/dist/src/clerk-conformance.js +158 -0
  288. package/dist/src/clerk-connector.d.ts +170 -0
  289. package/dist/src/clerk-connector.js +223 -0
  290. package/dist/src/clerk-events.d.ts +55 -0
  291. package/dist/src/clerk-events.js +146 -0
  292. package/dist/src/clerk-fapi.d.ts +19 -0
  293. package/dist/src/clerk-fapi.js +665 -0
  294. package/dist/src/clerk-jwt.d.ts +109 -0
  295. package/dist/src/clerk-jwt.js +230 -0
  296. package/dist/src/clerk-mirror-ui.d.ts +34 -0
  297. package/dist/src/clerk-mirror-ui.js +160 -0
  298. package/dist/src/clerk-server.d.ts +35 -0
  299. package/dist/src/clerk-server.js +172 -0
  300. package/dist/src/clerk-twin.d.ts +28 -0
  301. package/dist/src/clerk-twin.js +1658 -0
  302. package/dist/src/cli.d.ts +2 -0
  303. package/dist/src/cli.js +35 -0
  304. package/dist/src/index.d.ts +12 -0
  305. package/dist/src/index.js +56 -0
  306. package/dist/test-fixtures/clerk-openapi-operations.SOURCE.md +16 -0
  307. package/dist/test-fixtures/clerk-openapi-operations.json +1989 -0
  308. package/package.json +76 -0
  309. package/src/clerk-capabilities.ts +1246 -0
  310. package/src/clerk-conformance.ts +172 -0
  311. package/src/clerk-connector.ts +268 -0
  312. package/src/clerk-events.ts +166 -0
  313. package/src/clerk-fapi.ts +673 -0
  314. package/src/clerk-jwt.ts +256 -0
  315. package/src/clerk-mirror-ui.ts +165 -0
  316. package/src/clerk-server.ts +175 -0
  317. package/src/clerk-twin.ts +1580 -0
  318. package/src/cli.ts +33 -0
  319. package/src/index.ts +94 -0
  320. package/test-fixtures/clerk-openapi-operations.SOURCE.md +16 -0
  321. package/test-fixtures/clerk-openapi-operations.json +1989 -0
@@ -0,0 +1,1580 @@
1
+ // Clerk Backend API twin REQUEST HANDLER — the canonical api.clerk.com surface for the
2
+ // twin, backed by the event/action-log kernel. Contract:
3
+ // handleClerkTwinRequest({method, path, body}) -> {status, body}. Response SHAPES mirror
4
+ // real Clerk (snake_case ids with vendor prefixes — user_/sess_/org_/orgmem_/orginv_,
5
+ // object discriminators, paginated list shapes) so the real `@clerk/backend` SDK works
6
+ // unmodified. HTTP wrapper: clerk-server.ts → createClerkTwinServer.
7
+ //
8
+ // State lives in the action log (R18): writes are local actions, reads are the
9
+ // projection. No real Clerk is ever called. A twin fakes auth, so ANY Backend API key is
10
+ // accepted (the real vendor's 401 on a bad key is not modeled — see README ## Coverage).
11
+ //
12
+ // THE meaty part: session tokens and JWT-template tokens are REAL RS256 JWTs signed from
13
+ // a persisted local keypair, and /.well-known/jwks.json serves the matching JWKS, so an
14
+ // app verifying a twin-issued token against the twin's JWKS succeeds unmodified.
15
+ import { applyTwinWrite, projectResources, nodeBuiltin, resolveSubjectId } from '@volter/world-core';
16
+ import { buildJwks, signJwt, signJwtHs256, verifyJwtWithJwks } from './clerk-jwt.ts';
17
+ import { emitClerkEvent, eventTypeFor, type ClerkWebhookTarget } from './clerk-events.ts';
18
+
19
+ const SERVICE = 'clerk';
20
+
21
+ export type ClerkRequest = {
22
+ method: string; path: string; body?: string; occurredAt?: string; root?: string; readOnly?: boolean;
23
+ /** The Host this request arrived on (`host` / `x-forwarded-host`), so self-referential URLs the
24
+ * API emits point back at wherever this twin is actually bound. Runtime contract R7: a twin
25
+ * learns its own origin from the request and never assumes a port. */
26
+ host?: string;
27
+ /** The Origin of the browser request that asked for this work, set only by the Frontend API
28
+ * door (clerk-js runs in a page). Real Clerk puts it in a session token's `azp`; a Backend
29
+ * API call has no browser behind it and carries none. */
30
+ origin?: string;
31
+ };
32
+
33
+ /** The instance's issuer: the `iss` of every token the twin signs, and the `issuer` its OIDC
34
+ * discovery document names. One value, so a verifier's `iss === discovery.issuer` holds. */
35
+ export const CLERK_TWIN_ISSUER = 'https://twin.clerk.accounts.dev';
36
+
37
+ export type ClerkResponse = { status: number; body: unknown };
38
+
39
+ /** The absolute origin this request arrived on. Falls back to the domain's own name (a real,
40
+ * port-free origin) only when the caller supplied no Host at all — e.g. an in-process call. */
41
+ function originOf(req: ClerkRequest, fallbackHost: string): string {
42
+ const raw = (req.host ?? '').trim();
43
+ if (!raw) return `https://${fallbackHost}`;
44
+ if (/^https?:\/\//i.test(raw)) return raw.replace(/\/+$/, '');
45
+ // A local twin is plain HTTP; a real hostname is HTTPS.
46
+ const scheme = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(raw) ? 'http' : 'https';
47
+ return `${scheme}://${raw}`;
48
+ }
49
+
50
+ // resource type → id prefix (Clerk's conventions).
51
+ const PREFIX: Record<string, string> = {
52
+ user: 'user', session: 'sess', organization: 'org', organization_membership: 'orgmem',
53
+ organization_invitation: 'orginv', invitation: 'inv', allowlist_identifier: 'alid',
54
+ blocklist_identifier: 'blid', jwt_template: 'jtmpl', email_address: 'idn', phone_number: 'idn',
55
+ client: 'client', sign_in_token: 'sit', oauth_application: 'oauth_app', redirect_url: 'ru',
56
+ saml_connection: 'samlc', domain: 'dmn', actor_token: 'actor',
57
+ organization_domain: 'orgdmn', testing_token: 'test',
58
+ web3_wallet: 'idn', webhook_endpoint: 'ep', webhook_attempt: 'wha',
59
+ waitlist_entry: 'wle', proxy_check: 'proxy_check',
60
+ };
61
+
62
+ // Clerk's error envelope: { errors: [{ code, message, long_message }], clerk_trace_id }.
63
+ function err(message: string, status = 404, code = 'resource_not_found', longMessage?: string): ClerkResponse {
64
+ return {
65
+ status,
66
+ body: {
67
+ errors: [{ code, message, long_message: longMessage ?? message, meta: {} }],
68
+ clerk_trace_id: 'trace_twin',
69
+ },
70
+ };
71
+ }
72
+
73
+ function nowUnixMs(occurredAt?: string): number {
74
+ return occurredAt ? Date.parse(occurredAt) : Date.now();
75
+ }
76
+
77
+ // ── password hashing (REAL crypto) ──────────────────────────────────────────────
78
+ // Clerk stores password digests with a chosen hasher (bcrypt/scrypt/argon2/…). The twin
79
+ // uses node:crypto scrypt for a genuine salted one-way hash (`scrypt$<saltB64>$<hashB64>`)
80
+ // so verify_password is a real constant-time comparison — never a plaintext compare.
81
+ // EDGE-CPU CAVEAT (Cloudflare Worker host): scryptSync at node's default cost (N=16384, r=8,
82
+ // ~16 MiB, memory-hard) can exceed the free-plan 10ms CPU budget → intermittent 503/1102 —
83
+ // the SAME limit the baked RSA key exists to dodge. The managed-accounts identity path never
84
+ // calls this (no password), so it's latent; if a password path is used on the edge, move this
85
+ // off the serve path or lower the cost params. On a Node host it is fine as-is.
86
+ type NodeCrypto = typeof import('node:crypto');
87
+ function nodeCrypto(): NodeCrypto {
88
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
89
+ return nodeBuiltin('node:crypto') as NodeCrypto;
90
+ }
91
+ function hashPassword(password: string): string {
92
+ const { randomBytes, scryptSync } = nodeCrypto();
93
+ const salt = randomBytes(16);
94
+ const hash = scryptSync(password, salt, 32);
95
+ return `scrypt$${salt.toString('base64')}$${hash.toString('base64')}`;
96
+ }
97
+ function verifyPassword(password: string, digest: string): boolean {
98
+ const { scryptSync, timingSafeEqual } = nodeCrypto();
99
+ const parts = String(digest).split('$');
100
+ if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
101
+ const salt = Buffer.from(parts[1]!, 'base64');
102
+ const expected = Buffer.from(parts[2]!, 'base64');
103
+ const actual = scryptSync(password, salt, expected.length);
104
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
105
+ }
106
+
107
+ // ── SECRET material (real randomness, no network): a TOTP seed, backup codes, an OAuth client
108
+ // secret, a webhook signing secret. Secrecy is their purpose, so they are never derived from
109
+ // world state; ids and bearer tokens are, through stableHex (R9). ──
110
+ function secretHex(bytes: number): string {
111
+ return nodeCrypto().randomBytes(bytes).toString('hex');
112
+ }
113
+ /** A base32 (RFC 4648) secret, the shape Clerk/authenticator apps use for TOTP. */
114
+ function base32Secret(bytes = 20): string {
115
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
116
+ const buf = nodeCrypto().randomBytes(bytes);
117
+ let bits = 0, value = 0, out = '';
118
+ for (const byte of buf) {
119
+ value = (value << 8) | byte;
120
+ bits += 8;
121
+ while (bits >= 5) { out += alphabet[(value >>> (bits - 5)) & 31]; bits -= 5; }
122
+ }
123
+ if (bits > 0) out += alphabet[(value << (5 - bits)) & 31];
124
+ return out;
125
+ }
126
+
127
+ function parseBody(body?: string): Record<string, unknown> {
128
+ if (!body) return {};
129
+ try {
130
+ const v = JSON.parse(body);
131
+ return v && typeof v === 'object' ? (v as Record<string, unknown>) : {};
132
+ } catch {
133
+ return {};
134
+ }
135
+ }
136
+
137
+ // Live resources of a type. A delete is the kernel's tombstone (`deleted: true`, cleared by the next write — protocol 2).
138
+ function rows(type: string, root?: string) {
139
+ return projectResources(SERVICE, root).filter((r) => r.type === type && !r.id.startsWith('_') && (r as Record<string, unknown>).deleted !== true && !r.id.startsWith('_') && (r as Record<string, unknown>)._fapi !== true);
140
+ }
141
+ /** FNV-1a over the seed, widened by re-hashing with a round counter until `n` hex chars exist.
142
+ * The resend exemplar's helper — the only entropy source an id is allowed on the serve path. */
143
+ export function stableHex(seed: string, n: number): string {
144
+ let out = '';
145
+ for (let round = 0; out.length < n; round += 1) {
146
+ let h = 0x811c9dc5;
147
+ const s = `${round}:${seed}`;
148
+ for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
149
+ out += h.toString(16).padStart(8, '0');
150
+ }
151
+ return out.slice(0, n);
152
+ }
153
+ // DETERMINISTIC (R9, resource level): the suffix is a stable hash of
154
+ // `${type}:${occurredAt}:${ordinal}` — the world instant the write happened at plus the count
155
+ // of rows the type already holds — never `Math.random`, so two identical worlds mint identical
156
+ // ids and a replay is byte-identical. `discriminator` separates ids minted inside ONE request
157
+ // at ONE instant off ONE ordinal (a user's nested identifiers), which the row count cannot.
158
+ // The SHAPE is unchanged: `<prefix>_twin_<n>_<8 hex>`.
159
+ function nextId(type: string, occurredAt: string | undefined, root?: string, discriminator = ''): string {
160
+ const prefix = PREFIX[type] ?? type;
161
+ let n = 0;
162
+ for (const r of rows(type, root)) {
163
+ const m = new RegExp(`^${prefix}_twin_(\\d+)_`).exec(r.id);
164
+ if (m) n = Math.max(n, Number(m[1]));
165
+ }
166
+ return `${prefix}_twin_${n + 1}_${stableHex(`${type}:${occurredAt ?? ''}:${n}:${discriminator}`, 8)}`;
167
+ }
168
+
169
+ // Clerk reserves several vendor field names that collide with kernel meta or are
170
+ // dropped on projection (the kernel reserves `type`). Stash a vendor `type` under
171
+ // `_clerk_type` and restore on view.
172
+ // Recursively drop twin-internal `_`-prefixed keys (e.g. a nested `_access_token` inside
173
+ // external_accounts) so they never leak into a vendor-facing body.
174
+ function deepClean(value: unknown): unknown {
175
+ if (Array.isArray(value)) return value.map(deepClean);
176
+ if (value && typeof value === 'object') {
177
+ const out: Record<string, unknown> = {};
178
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) if (!k.startsWith('_')) out[k] = deepClean(v);
179
+ return out;
180
+ }
181
+ return value;
182
+ }
183
+ function view(type: string, r: Record<string, unknown>): Record<string, unknown> {
184
+ const { type: _t, updatedAt: _u, _clerk_type, ...rest } = r as Record<string, unknown> & { _clerk_type?: unknown };
185
+ // drop any twin-internal `_`-prefixed fields (top-level + nested) from the vendor body
186
+ const clean: Record<string, unknown> = {};
187
+ for (const [k, v] of Object.entries(rest)) if (!k.startsWith('_')) clean[k] = deepClean(v);
188
+ const out: Record<string, unknown> = { ...clean, id: r.id };
189
+ if (_clerk_type !== undefined) out.type = _clerk_type;
190
+ return out;
191
+ }
192
+
193
+ function getRow(type: string, id: string, root?: string): Record<string, unknown> | undefined {
194
+ return rows(type, root).find((r) => r.id === id);
195
+ }
196
+ function getOne(type: string, id: string, root?: string): Record<string, unknown> | undefined {
197
+ const r = getRow(type, id, root);
198
+ return r ? view(type, r) : undefined;
199
+ }
200
+
201
+ // Clerk list shape. The Backend API returns either a bare array (older endpoints) or a
202
+ // paginated envelope `{ data: [...], total_count }`. The SDK's Backend resources use the
203
+ // paginated envelope for list endpoints (users/organizations/...). Honors limit/offset.
204
+ function paginate<T>(items: T[], params: Record<string, unknown>): { page: T[]; total: number } {
205
+ const total = items.length;
206
+ let limit = 10;
207
+ if (params.limit !== undefined) {
208
+ const n = Number(params.limit);
209
+ if (Number.isFinite(n)) limit = Math.min(500, Math.max(1, Math.trunc(n)));
210
+ }
211
+ let offset = 0;
212
+ if (params.offset !== undefined) {
213
+ const n = Number(params.offset);
214
+ if (Number.isFinite(n) && n >= 0) offset = Math.trunc(n);
215
+ }
216
+ return { page: items.slice(offset, offset + limit), total };
217
+ }
218
+ function listEnvelope<T>(items: T[], params: Record<string, unknown>) {
219
+ const { page, total } = paginate(items, params);
220
+ return { data: page, total_count: total };
221
+ }
222
+
223
+ async function writeResource(
224
+ type: string,
225
+ id: string,
226
+ fields: Record<string, unknown>,
227
+ op: string,
228
+ req: ClerkRequest,
229
+ ): Promise<Record<string, unknown>> {
230
+ const { resource } = await applyTwinWrite(
231
+ SERVICE,
232
+ { operation: op, subjectType: type, subjectId: id, fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } },
233
+ req.root,
234
+ );
235
+ const out = view(type, resource);
236
+ const evType = eventTypeFor(op);
237
+ if (evType) {
238
+ await emitClerkEvent(op, out, { ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), endpoints: webhookTargets(req.root) });
239
+ await recordWebhookAttempts(evType, out, op, req);
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /** The World's live webhook endpoints, from its tree: delivery survives a restart and belongs to
245
+ * this World alone. A disabled or deleted endpoint receives nothing, like the vendor. */
246
+ function webhookTargets(root: string | undefined): ClerkWebhookTarget[] {
247
+ return rows('webhook_endpoint', root)
248
+ .filter((ep) => ep.disabled !== true && typeof ep.url === 'string' && typeof ep.signing_secret === 'string')
249
+ .map((ep) => ({ url: String(ep.url), secret: String(ep.signing_secret), ...(Array.isArray(ep.filter_types) ? { filterTypes: ep.filter_types.map(String) } : {}) }));
250
+ }
251
+
252
+ // For every persisted webhook endpoint (created via the Backend API), record a delivery
253
+ // attempt for this event — the attempts log a consumer inspects (svix-style). Delivery itself
254
+ // is emitClerkEvent's (fire-and-forget); this records a `succeeded` attempt (disabled endpoints record
255
+ // nothing). Endpoints whose `filter_types` exclude this event type are skipped.
256
+ async function recordWebhookAttempts(evType: string, data: Record<string, unknown>, op: string, req: ClerkRequest): Promise<void> {
257
+ if (op.startsWith('webhook_')) return; // never recurse on our own bookkeeping writes
258
+ const endpoints = rows('webhook_endpoint', req.root);
259
+ for (const ep of endpoints) {
260
+ if (ep.disabled === true) continue;
261
+ const filters = ep.filter_types;
262
+ if (Array.isArray(filters) && filters.length > 0 && !filters.includes(evType)) continue;
263
+ const at = nowUnixMs(req.occurredAt);
264
+ await create('webhook_attempt', {
265
+ endpoint_id: ep.id, event_type: evType, status: 'succeeded', response_status_code: 200,
266
+ payload: { type: evType, object: 'event', data: { id: data.id } }, attempted_at: at,
267
+ }, 'webhook_attempt.create', req);
268
+ }
269
+ }
270
+
271
+ // Move a colliding vendor `type` field to `_clerk_type` so it survives projection.
272
+ function stashType(fields: Record<string, unknown>): Record<string, unknown> {
273
+ if (!('type' in fields)) return fields;
274
+ const { type: vt, ...rest } = fields;
275
+ return { ...rest, _clerk_type: vt };
276
+ }
277
+
278
+ async function create(type: string, fields: Record<string, unknown>, op: string, req: ClerkRequest, providedId?: string): Promise<Record<string, unknown>> {
279
+ const id = providedId ?? nextId(type, req.occurredAt, req.root);
280
+ const at = nowUnixMs(req.occurredAt);
281
+ const stamped = stashType({ object: type, created_at: at, updated_at: at, ...fields });
282
+ return writeResource(type, id, stamped, op, req);
283
+ }
284
+
285
+ // ── USERS ───────────────────────────────────────────────────────────────────────
286
+ const emailIdsFor = (u: Record<string, unknown>): Array<Record<string, unknown>> =>
287
+ Array.isArray(u.email_addresses) ? (u.email_addresses as Array<Record<string, unknown>>) : [];
288
+
289
+ function buildUser(params: Record<string, unknown>, req: ClerkRequest): Record<string, unknown> {
290
+ const emails = Array.isArray(params.email_address) ? params.email_address as string[] : [];
291
+ const phones = Array.isArray(params.phone_number) ? params.phone_number as string[] : [];
292
+ // DETERMINISTIC (R9): the identifier id is minted from the world instant the create happened
293
+ // at plus the identifier's own ordinal — `Date.now()` here made every fresh-root replay serve
294
+ // different bytes for the same user.
295
+ const at = req.occurredAt ?? '';
296
+ const email_addresses = emails.map((email, i) => ({
297
+ id: `idn_twin_${stableHex(`email_address:${at}:${i}:${email}`, 12)}_${i}`, object: 'email_address', email_address: email,
298
+ verification: { status: 'verified', strategy: 'admin' }, linked_to: [], reserved: false,
299
+ }));
300
+ const phone_numbers = phones.map((phone, i) => ({
301
+ id: `idn_twin_${stableHex(`phone_number:${at}:${i}:${phone}`, 12)}_p${i}`, object: 'phone_number', phone_number: phone,
302
+ verification: { status: 'verified', strategy: 'admin' }, linked_to: [], reserved: false,
303
+ }));
304
+ const primary = email_addresses[0]?.id ?? null;
305
+ // Password: accept a plaintext `password` (hash it) or a pre-computed `password_digest`.
306
+ const hasPassword = typeof params.password === 'string' || typeof params.password_digest === 'string';
307
+ const passwordDigest =
308
+ typeof params.password === 'string' ? hashPassword(params.password)
309
+ : typeof params.password_digest === 'string' ? String(params.password_digest)
310
+ : null;
311
+ return {
312
+ username: (params.username as string) ?? null,
313
+ first_name: (params.first_name as string) ?? null,
314
+ last_name: (params.last_name as string) ?? null,
315
+ image_url: '',
316
+ has_image: false,
317
+ primary_email_address_id: primary,
318
+ primary_phone_number_id: phone_numbers[0]?.id ?? null,
319
+ email_addresses,
320
+ phone_numbers,
321
+ web3_wallets: [],
322
+ external_accounts: Array.isArray(params.external_accounts) ? params.external_accounts as unknown[] : [],
323
+ saml_accounts: [],
324
+ public_metadata: (params.public_metadata as object) ?? {},
325
+ private_metadata: (params.private_metadata as object) ?? {},
326
+ unsafe_metadata: (params.unsafe_metadata as object) ?? {},
327
+ banned: false,
328
+ locked: false,
329
+ last_sign_in_at: null,
330
+ two_factor_enabled: false,
331
+ totp_enabled: false,
332
+ backup_code_enabled: false,
333
+ last_active_at: null,
334
+ external_id: (params.external_id as string) ?? null,
335
+ password_enabled: hasPassword,
336
+ // `_password_digest` is twin-internal (never serialized — dropped by view()).
337
+ _password_digest: passwordDigest,
338
+ };
339
+ }
340
+
341
+ // ── route handler ────────────────────────────────────────────────────────────────
342
+ export async function handleClerkTwinRequest(req: ClerkRequest): Promise<ClerkResponse> {
343
+ const method = req.method.toUpperCase();
344
+ const rawPath = req.path.split('?');
345
+ const path = rawPath[0]!.replace(/\/+$/, '') || '/';
346
+ const query = Object.fromEntries(new URLSearchParams(rawPath[1] ?? ''));
347
+ const body = parseBody(req.body);
348
+ const params: Record<string, unknown> = { ...query, ...body };
349
+ const seg = path.replace(/^\/+/, '').split('/'); // ["v1","users",...]
350
+ // protocol 2: after a landing adopted the vendor's id, a caller on a branch may still address a user or an organization
351
+ // by the local id — resolved through the alias map once, here at the boundary
352
+ if ((seg[1] === 'users' || seg[1] === 'organizations') && seg[2] && /^(user|org)_/.test(seg[2])) seg[2] = resolveSubjectId(SERVICE, seg[1] === 'users' ? 'user' : 'organization', decodeURIComponent(seg[2]), req.root);
353
+ const idAt = (i: number) => decodeURIComponent(seg[i] ?? '');
354
+
355
+ if (req.readOnly && method !== 'GET') {
356
+ return err('twin is read-only; omit readOnly to accept writes', 405, 'read_only');
357
+ }
358
+
359
+ // ── JWKS (the verifier endpoint; served at the well-known path Clerk uses) ──
360
+ if (method === 'GET' && (path === '/.well-known/jwks.json' || path === '/v1/jwks')) {
361
+ return { status: 200, body: await buildJwks(req.root) };
362
+ }
363
+
364
+ // ── OIDC discovery (what a verifier reads first: the issuer and where its keys live) ──
365
+ // Real Clerk serves this at the instance's issuer; the twin names only what it serves —
366
+ // the JWKS at this request's own origin, RS256, public subjects.
367
+ if (method === 'GET' && path === '/.well-known/openid-configuration') {
368
+ return {
369
+ status: 200,
370
+ body: {
371
+ issuer: CLERK_TWIN_ISSUER,
372
+ jwks_uri: `${originOf(req, new URL(CLERK_TWIN_ISSUER).host)}/.well-known/jwks.json`,
373
+ subject_types_supported: ['public'],
374
+ id_token_signing_alg_values_supported: ['RS256'],
375
+ },
376
+ };
377
+ }
378
+
379
+ // ── root ──
380
+ if (path === '/' || path === '/v1') {
381
+ return { status: 200, body: { service: 'clerk', object: 'twin', version: 'twin' } };
382
+ }
383
+
384
+ if (seg[0] !== 'v1') return err(`Unknown route: ${path}`, 404, 'resource_not_found');
385
+ const collection = seg[1];
386
+
387
+ // ── USERS ──
388
+ if (collection === 'users') {
389
+ if (seg.length === 2 && method === 'POST') {
390
+ const errResp = validateUserCreate(params, req);
391
+ if (errResp) return errResp;
392
+ const user = await create('user', buildUser(params, req), 'user.create', req);
393
+ return { status: 200, body: user };
394
+ }
395
+ if (seg.length === 2 && method === 'GET') {
396
+ let items = rows('user', req.root).map((r) => view('user', r));
397
+ // filters: email_address[], username[], user_id[], query, organization_id[]
398
+ if (query.query) items = items.filter((u) => userMatchesQuery(u, String(query.query)));
399
+ const list = applyUserFilters(items, query);
400
+ // GET /v1/users returns a BARE ARRAY in the Backend API (the SDK wraps total separately)
401
+ return { status: 200, body: list };
402
+ }
403
+ if (seg[1] === 'users' && seg[2] === 'count' && method === 'GET') {
404
+ return { status: 200, body: { object: 'total_count', total_count: rows('user', req.root).length } };
405
+ }
406
+ if (seg.length === 3 && method === 'GET') {
407
+ const u = getOne('user', idAt(2), req.root);
408
+ return u ? { status: 200, body: u } : err(`User not found`, 404, 'resource_not_found');
409
+ }
410
+ if (seg.length === 3 && (method === 'PATCH' || method === 'PUT')) {
411
+ const cur = getRow('user', idAt(2), req.root);
412
+ if (!cur) return err('User not found', 404, 'resource_not_found');
413
+ const patch = userPatch(params);
414
+ // Setting/changing a password hashes it and flips password_enabled (real crypto).
415
+ if (typeof params.password === 'string') {
416
+ patch._password_digest = hashPassword(params.password);
417
+ patch.password_enabled = true;
418
+ }
419
+ return { status: 200, body: await writeResource('user', idAt(2), { ...patch, updated_at: nowUnixMs(req.occurredAt) }, 'user.update', req) };
420
+ }
421
+ if (seg.length === 3 && method === 'DELETE') {
422
+ const cur = getRow('user', idAt(2), req.root);
423
+ if (!cur) return err('User not found', 404, 'resource_not_found');
424
+ await writeResource('user', idAt(2), { deleted: true }, 'user.delete', req);
425
+ return { status: 200, body: { object: 'user', id: idAt(2), deleted: true } };
426
+ }
427
+ // POST /v1/users/:id/ban | /unban | /lock | /unlock
428
+ if (seg.length === 4 && method === 'POST' && ['ban', 'unban', 'lock', 'unlock'].includes(seg[3]!)) {
429
+ const cur = getRow('user', idAt(2), req.root);
430
+ if (!cur) return err('User not found', 404, 'resource_not_found');
431
+ const verb = seg[3]!;
432
+ const fields = verb === 'ban' ? { banned: true } : verb === 'unban' ? { banned: false } : verb === 'lock' ? { locked: true } : { locked: false };
433
+ return { status: 200, body: await writeResource('user', idAt(2), { ...fields, updated_at: nowUnixMs(req.occurredAt) }, 'user.update', req) };
434
+ }
435
+ // PATCH /v1/users/:id/metadata — merge metadata bags
436
+ if (seg.length === 4 && seg[3] === 'metadata' && method === 'PATCH') {
437
+ const cur = getRow('user', idAt(2), req.root);
438
+ if (!cur) return err('User not found', 404, 'resource_not_found');
439
+ const merged = mergeMetadata(cur, params);
440
+ return { status: 200, body: await writeResource('user', idAt(2), { ...merged, updated_at: nowUnixMs(req.occurredAt) }, 'user.update', req) };
441
+ }
442
+ // GET /v1/users/:id/organization_memberships
443
+ if (seg.length === 4 && seg[3] === 'organization_memberships' && method === 'GET') {
444
+ const memberships = rows('organization_membership', req.root)
445
+ .map((r) => expandMembership(view('organization_membership', r), req.root))
446
+ .filter((m) => (m.public_user_data as Record<string, unknown>)?.user_id === idAt(2));
447
+ return { status: 200, body: listEnvelope(memberships, query) };
448
+ }
449
+ // POST /v1/users/:id/verify_password — REAL scrypt verify (constant-time)
450
+ if (seg.length === 4 && seg[3] === 'verify_password' && method === 'POST') {
451
+ const cur = getRow('user', idAt(2), req.root);
452
+ if (!cur) return err('User not found', 404, 'resource_not_found');
453
+ const password = params.password as string | undefined;
454
+ if (!password) return err('password is required', 422, 'form_param_missing');
455
+ const digest = cur._password_digest as string | undefined;
456
+ if (!digest) return err('User does not have a password set', 422, 'form_password_not_set');
457
+ if (!verifyPassword(password, digest)) return err('Incorrect password', 422, 'form_password_incorrect');
458
+ return { status: 200, body: { verified: true } };
459
+ }
460
+ // ── TOTP / 2FA: POST creates a secret (+ otpauth uri), DELETE disables it ──
461
+ if (seg.length === 4 && seg[3] === 'totp' && method === 'POST') {
462
+ const cur = getRow('user', idAt(2), req.root);
463
+ if (!cur) return err('User not found', 404, 'resource_not_found');
464
+ const secret = base32Secret();
465
+ const userName = emailIdsFor(view('user', cur))[0]?.email_address ?? String(cur.id);
466
+ const otp = await create('totp', {
467
+ secret, uri: `otpauth://totp/Clerk:${encodeURIComponent(String(userName))}?secret=${secret}&issuer=Clerk&algorithm=SHA1&digits=6&period=30`,
468
+ verified: true, backup_codes: [],
469
+ }, 'totp.create', req, `totp_twin_${idAt(2)}`);
470
+ await writeResource('user', idAt(2), { totp_enabled: true, two_factor_enabled: true, updated_at: nowUnixMs(req.occurredAt) }, 'user.update', req);
471
+ return { status: 200, body: { object: 'totp', id: otp.id, secret: otp.secret, uri: otp.uri, verified: true, backup_codes: null } };
472
+ }
473
+ if (seg.length === 4 && seg[3] === 'totp' && method === 'DELETE') {
474
+ const cur = getRow('user', idAt(2), req.root);
475
+ if (!cur) return err('User not found', 404, 'resource_not_found');
476
+ const otp = getRow('totp', `totp_twin_${idAt(2)}`, req.root);
477
+ if (otp) await writeResource('totp', String(otp.id), { deleted: true }, 'totp.delete', req);
478
+ await writeResource('user', idAt(2), { totp_enabled: false, two_factor_enabled: false, backup_code_enabled: false, updated_at: nowUnixMs(req.occurredAt) }, 'user.update', req);
479
+ return { status: 200, body: { object: 'totp', user_id: idAt(2), deleted: true } };
480
+ }
481
+ // POST /v1/users/:id/backup_codes — (re)generate the user's 2FA recovery codes
482
+ if (seg.length === 4 && seg[3] === 'backup_codes' && method === 'POST') {
483
+ const cur = getRow('user', idAt(2), req.root);
484
+ if (!cur) return err('User not found', 404, 'resource_not_found');
485
+ const codes = Array.from({ length: 10 }, () => `${secretHex(4)}-${secretHex(4)}`);
486
+ await writeResource('user', idAt(2), { backup_code_enabled: true, updated_at: nowUnixMs(req.occurredAt) }, 'user.update', req);
487
+ return { status: 200, body: { object: 'backup_code', codes } };
488
+ }
489
+ // ── PROFILE IMAGE: POST sets image_url (+ has_image), DELETE clears it ──
490
+ if (seg.length === 4 && seg[3] === 'profile_image' && method === 'POST') {
491
+ const cur = getRow('user', idAt(2), req.root);
492
+ if (!cur) return err('User not found', 404, 'resource_not_found');
493
+ // Accept a data-url/url (file uploads arrive as multipart in real Clerk; the twin
494
+ // takes a `file` url string). A twin does not host bytes — it records the reference.
495
+ const url = (params.file as string) || `https://img.clerk.twin/${idAt(2)}.png`;
496
+ return { status: 200, body: await writeResource('user', idAt(2), { image_url: url, has_image: true, updated_at: nowUnixMs(req.occurredAt) }, 'user.update', req) };
497
+ }
498
+ if (seg.length === 4 && seg[3] === 'profile_image' && method === 'DELETE') {
499
+ const cur = getRow('user', idAt(2), req.root);
500
+ if (!cur) return err('User not found', 404, 'resource_not_found');
501
+ return { status: 200, body: await writeResource('user', idAt(2), { image_url: '', has_image: false, updated_at: nowUnixMs(req.occurredAt) }, 'user.update', req) };
502
+ }
503
+ // POST /v1/users/:id/oauth_access_tokens/:provider — a user's OAuth provider token(s)
504
+ if (seg.length === 5 && seg[3] === 'oauth_access_tokens' && method === 'GET') {
505
+ const cur = getRow('user', idAt(2), req.root);
506
+ if (!cur) return err('User not found', 404, 'resource_not_found');
507
+ const provider = idAt(4);
508
+ const accounts = Array.isArray(cur.external_accounts) ? cur.external_accounts as Array<Record<string, unknown>> : [];
509
+ const acct = accounts.find((a) => a.provider === provider || a.provider === `oauth_${provider}`);
510
+ const tokens = acct && acct._access_token
511
+ ? [{ object: 'oauth_access_token', token: acct._access_token, provider: acct.provider, scopes: acct.approved_scopes ?? [], label: null }]
512
+ : [];
513
+ return { status: 200, body: listEnvelope(tokens, query) };
514
+ }
515
+ // DELETE /v1/users/:id/external_accounts/:eaid — unlink an OAuth (external) account
516
+ if (seg.length === 5 && seg[3] === 'external_accounts' && method === 'DELETE') {
517
+ const cur = getRow('user', idAt(2), req.root);
518
+ if (!cur) return err('User not found', 404, 'resource_not_found');
519
+ const accounts = Array.isArray(cur.external_accounts) ? cur.external_accounts as Array<Record<string, unknown>> : [];
520
+ if (!accounts.some((a) => a.id === idAt(4))) return err('External account not found', 404, 'resource_not_found');
521
+ const remaining = accounts.filter((a) => a.id !== idAt(4));
522
+ await writeResource('user', idAt(2), { external_accounts: remaining, updated_at: nowUnixMs(req.occurredAt) }, 'user.update', req);
523
+ return { status: 200, body: { object: 'external_account', id: idAt(4), deleted: true } };
524
+ }
525
+ }
526
+
527
+ // ── EMAIL ADDRESSES (add an identifier to a user + verify it) ──
528
+ if (collection === 'email_addresses') {
529
+ if (seg.length === 2 && method === 'POST') {
530
+ const userId = params.user_id as string;
531
+ const email = params.email_address as string;
532
+ if (!userId || !email) return err('user_id and email_address are required', 422, 'form_param_missing');
533
+ const user = getRow('user', userId, req.root);
534
+ if (!user) return err('User not found', 404, 'resource_not_found');
535
+ const verified = params.verified === true;
536
+ const idn = await addUserIdentifier(user, 'email_addresses', {
537
+ object: 'email_address', email_address: email,
538
+ verification: verified ? { status: 'verified', strategy: 'admin' } : null,
539
+ linked_to: [], reserved: false,
540
+ }, req);
541
+ return { status: 200, body: idn };
542
+ }
543
+ return emailPhoneSubRoutes('email_addresses', 'email_address', seg, method, params, query, req, idAt);
544
+ }
545
+ // ── PHONE NUMBERS ──
546
+ if (collection === 'phone_numbers') {
547
+ if (seg.length === 2 && method === 'POST') {
548
+ const userId = params.user_id as string;
549
+ const phone = params.phone_number as string;
550
+ if (!userId || !phone) return err('user_id and phone_number are required', 422, 'form_param_missing');
551
+ const user = getRow('user', userId, req.root);
552
+ if (!user) return err('User not found', 404, 'resource_not_found');
553
+ const verified = params.verified === true;
554
+ const idn = await addUserIdentifier(user, 'phone_numbers', {
555
+ object: 'phone_number', phone_number: phone,
556
+ verification: verified ? { status: 'verified', strategy: 'admin' } : null,
557
+ linked_to: [], reserved: false,
558
+ }, req);
559
+ return { status: 200, body: idn };
560
+ }
561
+ return emailPhoneSubRoutes('phone_numbers', 'phone_number', seg, method, params, query, req, idAt);
562
+ }
563
+ // ── WEB3 WALLETS (a user identifier: a crypto wallet address) ──
564
+ if (collection === 'web3_wallets') {
565
+ if (seg.length === 2 && method === 'POST') {
566
+ const userId = params.user_id as string;
567
+ const walletAddress = (params.web3_wallet ?? params.wallet_address) as string;
568
+ if (!userId || !walletAddress) return err('user_id and web3_wallet are required', 422, 'form_param_missing');
569
+ const user = getRow('user', userId, req.root);
570
+ if (!user) return err('User not found', 404, 'resource_not_found');
571
+ const verified = params.verified === true;
572
+ const idn = await addUserIdentifier(user, 'web3_wallets', {
573
+ object: 'web3_wallet', web3_wallet: walletAddress,
574
+ verification: verified ? { status: 'verified', strategy: 'web3_metamask_signature' } : null,
575
+ }, req);
576
+ return { status: 200, body: idn };
577
+ }
578
+ return emailPhoneSubRoutes('web3_wallets', 'web3_wallet', seg, method, params, query, req, idAt);
579
+ }
580
+
581
+ // ── SESSIONS ──
582
+ if (collection === 'sessions') {
583
+ if (seg.length === 2 && method === 'POST') {
584
+ const userId = params.user_id as string;
585
+ if (!userId) return err('user_id is required', 422, 'form_param_missing');
586
+ if (!getRow('user', userId, req.root)) return err('User not found', 404, 'resource_not_found');
587
+ const at = nowUnixMs(req.occurredAt);
588
+ // client_id is a twin extension: the Frontend API surface owns real browser client
589
+ // identities and stamps them here so both surfaces project the same session rows.
590
+ const session = await create('session', {
591
+ client_id: typeof params.client_id === 'string' && params.client_id !== '' ? params.client_id : 'client_twin',
592
+ user_id: userId, status: 'active',
593
+ last_active_at: at, expire_at: at + 7 * 24 * 3600 * 1000, abandon_at: at + 30 * 24 * 3600 * 1000,
594
+ last_active_organization_id: null, actor: null,
595
+ }, 'session.create', req);
596
+ return { status: 200, body: session };
597
+ }
598
+ if (seg.length === 2 && method === 'GET') {
599
+ let items = rows('session', req.root).map((r) => view('session', r));
600
+ if (query.user_id) items = items.filter((s) => s.user_id === query.user_id);
601
+ if (query.status) items = items.filter((s) => s.status === query.status);
602
+ return { status: 200, body: items };
603
+ }
604
+ if (seg.length === 3 && method === 'GET') {
605
+ const s = getOne('session', idAt(2), req.root);
606
+ return s ? { status: 200, body: s } : err('Session not found', 404, 'resource_not_found');
607
+ }
608
+ // POST /v1/sessions/:id/revoke
609
+ if (seg.length === 4 && seg[3] === 'revoke' && method === 'POST') {
610
+ const cur = getRow('session', idAt(2), req.root);
611
+ if (!cur) return err('Session not found', 404, 'resource_not_found');
612
+ return { status: 200, body: await writeResource('session', idAt(2), { status: 'revoked', updated_at: nowUnixMs(req.occurredAt) }, 'session.revoke', req) };
613
+ }
614
+ // POST /v1/sessions/:id/touch — bump last_active_at (+ optionally last active org)
615
+ if (seg.length === 4 && seg[3] === 'touch' && method === 'POST') {
616
+ const cur = getRow('session', idAt(2), req.root);
617
+ if (!cur) return err('Session not found', 404, 'resource_not_found');
618
+ if (cur.status !== 'active') return err('Session is not active', 400, 'invalid_session');
619
+ const patch: Record<string, unknown> = { last_active_at: nowUnixMs(req.occurredAt) };
620
+ if (typeof params.active_organization_id === 'string') patch.last_active_organization_id = params.active_organization_id;
621
+ return { status: 200, body: await writeResource('session', idAt(2), { ...patch, updated_at: nowUnixMs(req.occurredAt) }, 'session.update', req) };
622
+ }
623
+ // POST /v1/sessions/:id/verify — verify a session token round-trip
624
+ if (seg.length === 4 && seg[3] === 'verify' && method === 'POST') {
625
+ const cur = getRow('session', idAt(2), req.root);
626
+ if (!cur) return err('Session not found', 404, 'resource_not_found');
627
+ const token = params.token as string | undefined;
628
+ if (token) {
629
+ const result = verifyJwtWithJwks(token, await buildJwks(req.root));
630
+ if (!result.valid) return err(`Invalid session token: ${result.reason}`, 400, 'invalid_token');
631
+ }
632
+ if (cur.status !== 'active') return err('Session is not active', 400, 'invalid_session');
633
+ return { status: 200, body: view('session', cur) };
634
+ }
635
+ // POST /v1/sessions/:id/tokens and /tokens/:template — issue a REAL signed JWT
636
+ if (seg.length >= 4 && seg[3] === 'tokens' && method === 'POST') {
637
+ const cur = getRow('session', idAt(2), req.root);
638
+ if (!cur) return err('Session not found', 404, 'resource_not_found');
639
+ const template = seg[4] ? decodeURIComponent(seg[4]) : undefined;
640
+ const claims = await sessionTokenClaims(cur, template, req);
641
+ const now = req.occurredAt ? Math.floor(nowUnixMs(req.occurredAt) / 1000) : undefined;
642
+ // A template with a custom HS256 signing key is signed with the shared secret (no JWKS);
643
+ // everything else is the twin's persisted RS256 keypair (served via JWKS).
644
+ const tmplRow = template ? rows('jwt_template', req.root).find((t) => t.name === template) : undefined;
645
+ const lifetime = (typeof tmplRow?.lifetime === 'number' ? tmplRow.lifetime : 60);
646
+ let jwt: string;
647
+ if (tmplRow?.custom_signing_key === true && typeof tmplRow._signing_key === 'string') {
648
+ jwt = signJwtHs256(claims, tmplRow._signing_key, { expiresInSeconds: lifetime, ...(now !== undefined ? { now } : {}) });
649
+ } else {
650
+ jwt = await signJwt(claims, { root: req.root, expiresInSeconds: lifetime, ...(now !== undefined ? { now } : {}) });
651
+ }
652
+ return { status: 200, body: { object: 'token', jwt } };
653
+ }
654
+ }
655
+
656
+ // ── ORGANIZATIONS ──
657
+ if (collection === 'organizations') {
658
+ if (seg.length === 2 && method === 'POST') {
659
+ const name = params.name as string;
660
+ if (!name) return err('name is required', 422, 'form_param_missing');
661
+ // Clerk: a slug the caller gives must be free; a slug Clerk derives from the name is made unique with a numeric suffix
662
+ const taken = (s: string) => rows('organization', req.root).some((o) => o.slug === s);
663
+ let slug = (params.slug as string) ?? slugify(name);
664
+ if (params.slug === undefined) for (let n = 2; taken(slug); n += 1) slug = `${slugify(name)}-${n}`;
665
+ if (taken(slug)) return err('That slug is taken', 422, 'form_identifier_exists');
666
+ const org = await create('organization', {
667
+ name, slug, image_url: '', has_image: false, members_count: params.created_by ? 1 : 0,
668
+ pending_invitations_count: 0, max_allowed_memberships: (params.max_allowed_memberships as number) ?? 5,
669
+ admin_delete_enabled: true, created_by: (params.created_by as string) ?? null,
670
+ public_metadata: (params.public_metadata as object) ?? {}, private_metadata: (params.private_metadata as object) ?? {},
671
+ }, 'organization.create', req);
672
+ // creator becomes an admin member
673
+ if (params.created_by) {
674
+ await createMembership(String(org.id), String(params.created_by), 'admin', req);
675
+ }
676
+ return { status: 200, body: org };
677
+ }
678
+ if (seg.length === 2 && method === 'GET') {
679
+ let items = rows('organization', req.root).map((r) => view('organization', r));
680
+ if (query.query) items = items.filter((o) => String(o.name ?? '').toLowerCase().includes(String(query.query).toLowerCase()) || String(o.slug ?? '').includes(String(query.query)));
681
+ return { status: 200, body: listEnvelope(items, query) };
682
+ }
683
+ if (seg.length === 3 && method === 'GET') {
684
+ const o = getOne('organization', idAt(2), req.root) ?? getBySlug('organization', idAt(2), req.root);
685
+ return o ? { status: 200, body: o } : err('Organization not found', 404, 'resource_not_found');
686
+ }
687
+ if (seg.length === 3 && (method === 'PATCH' || method === 'PUT')) {
688
+ const cur = getRow('organization', idAt(2), req.root);
689
+ if (!cur) return err('Organization not found', 404, 'resource_not_found');
690
+ const patch: Record<string, unknown> = {};
691
+ for (const k of ['name', 'slug', 'max_allowed_memberships', 'admin_delete_enabled', 'public_metadata', 'private_metadata']) if (k in params) patch[k] = params[k];
692
+ return { status: 200, body: await writeResource('organization', idAt(2), { ...patch, updated_at: nowUnixMs(req.occurredAt) }, 'organization.update', req) };
693
+ }
694
+ if (seg.length === 4 && seg[3] === 'metadata' && method === 'PATCH') {
695
+ const cur = getRow('organization', idAt(2), req.root);
696
+ if (!cur) return err('Organization not found', 404, 'resource_not_found');
697
+ const merged = mergeMetadata(cur, params);
698
+ return { status: 200, body: await writeResource('organization', idAt(2), { ...merged, updated_at: nowUnixMs(req.occurredAt) }, 'organization.update', req) };
699
+ }
700
+ if (seg.length === 3 && method === 'DELETE') {
701
+ const cur = getRow('organization', idAt(2), req.root);
702
+ if (!cur) return err('Organization not found', 404, 'resource_not_found');
703
+ // Clerk cascades: the org's memberships go with it (a user's membership list never shows an organization that no longer exists)
704
+ for (const m of membershipsOf(idAt(2), req.root)) await writeResource('organization_membership', String(m.id), { deleted: true }, 'organization_membership.delete', req);
705
+ await writeResource('organization', idAt(2), { deleted: true }, 'organization.delete', req);
706
+ return { status: 200, body: { object: 'organization', id: idAt(2), deleted: true, slug: cur.slug } };
707
+ }
708
+
709
+ // ── ORGANIZATION MEMBERSHIPS: /v1/organizations/:org/memberships ──
710
+ if (seg.length === 4 && seg[3] === 'memberships' && method === 'POST') {
711
+ const org = getRow('organization', idAt(2), req.root);
712
+ if (!org) return err('Organization not found', 404, 'resource_not_found');
713
+ const userId = params.user_id as string;
714
+ const role = (params.role as string) ?? 'basic_member';
715
+ if (!userId) return err('user_id is required', 422, 'form_param_missing');
716
+ if (!getRow('user', userId, req.root)) return err('User not found', 404, 'resource_not_found');
717
+ if (membershipFor(idAt(2), userId, req.root)) return err('User is already a member', 422, 'duplicate_record');
718
+ const m = await createMembership(idAt(2), userId, role, req);
719
+ return { status: 200, body: m };
720
+ }
721
+ if (seg.length === 4 && seg[3] === 'memberships' && method === 'GET') {
722
+ const org = getRow('organization', idAt(2), req.root);
723
+ if (!org) return err('Organization not found', 404, 'resource_not_found');
724
+ const items = membershipsOf(idAt(2), req.root);
725
+ return { status: 200, body: listEnvelope(items, query) };
726
+ }
727
+ if (seg.length === 5 && seg[3] === 'memberships' && method === 'PATCH') {
728
+ const m = membershipFor(idAt(2), idAt(4), req.root);
729
+ if (!m) return err('Membership not found', 404, 'resource_not_found');
730
+ const role = params.role as string;
731
+ if (!role) return err('role is required', 422, 'form_param_missing');
732
+ const updated = await writeResource('organization_membership', String(m.id), { role, permissions: rolePermissions(role, req.root), updated_at: nowUnixMs(req.occurredAt) }, 'organization_membership.update', req);
733
+ return { status: 200, body: expandMembership(updated, req.root) };
734
+ }
735
+ if (seg.length === 5 && seg[3] === 'memberships' && method === 'DELETE') {
736
+ const m = membershipFor(idAt(2), idAt(4), req.root);
737
+ if (!m) return err('Membership not found', 404, 'resource_not_found');
738
+ await writeResource('organization_membership', String(m.id), { deleted: true }, 'organization_membership.delete', req);
739
+ await decrementMembers(idAt(2), req);
740
+ return { status: 200, body: { object: 'organization_membership', id: m.id, deleted: true } };
741
+ }
742
+
743
+ // ── ORGANIZATION INVITATIONS ──
744
+ if (seg.length === 4 && seg[3] === 'invitations' && method === 'POST') {
745
+ const org = getRow('organization', idAt(2), req.root);
746
+ if (!org) return err('Organization not found', 404, 'resource_not_found');
747
+ const email = params.email_address as string;
748
+ if (!email) return err('email_address is required', 422, 'form_param_missing');
749
+ const inv = await create('organization_invitation', {
750
+ email_address: email, role: (params.role as string) ?? 'basic_member', organization_id: idAt(2),
751
+ status: 'pending', public_metadata: (params.public_metadata as object) ?? {}, private_metadata: (params.private_metadata as object) ?? {},
752
+ }, 'organization_invitation.create', req);
753
+ await writeResource('organization', idAt(2), { pending_invitations_count: Number(org.pending_invitations_count ?? 0) + 1 }, 'organization.update', req);
754
+ return { status: 200, body: inv };
755
+ }
756
+ if (seg.length === 4 && seg[3] === 'invitations' && method === 'GET') {
757
+ const org = getRow('organization', idAt(2), req.root);
758
+ if (!org) return err('Organization not found', 404, 'resource_not_found');
759
+ let items = rows('organization_invitation', req.root).map((r) => view('organization_invitation', r)).filter((i) => i.organization_id === idAt(2));
760
+ if (query.status) items = items.filter((i) => i.status === query.status);
761
+ return { status: 200, body: listEnvelope(items, query) };
762
+ }
763
+ if (seg.length === 6 && seg[3] === 'invitations' && seg[5] === 'revoke' && method === 'POST') {
764
+ const inv = getRow('organization_invitation', idAt(4), req.root);
765
+ if (!inv || inv.organization_id !== idAt(2)) return err('Invitation not found', 404, 'resource_not_found');
766
+ return { status: 200, body: await writeResource('organization_invitation', idAt(4), { status: 'revoked', updated_at: nowUnixMs(req.occurredAt) }, 'organization_invitation.update', req) };
767
+ }
768
+ // POST /v1/organizations/:org/invitations/:inv/accept — accept → creates a membership
769
+ if (seg.length === 6 && seg[3] === 'invitations' && seg[5] === 'accept' && method === 'POST') {
770
+ const inv = getRow('organization_invitation', idAt(4), req.root);
771
+ if (!inv || inv.organization_id !== idAt(2)) return err('Invitation not found', 404, 'resource_not_found');
772
+ if (inv.status !== 'pending') return err('Invitation is no longer pending', 422, 'organization_invitation_not_pending');
773
+ const userId = params.user_id as string;
774
+ if (!userId) return err('user_id is required', 422, 'form_param_missing');
775
+ if (!getRow('user', userId, req.root)) return err('User not found', 404, 'resource_not_found');
776
+ if (membershipFor(idAt(2), userId, req.root)) return err('User is already a member', 422, 'duplicate_record');
777
+ const m = await createMembership(idAt(2), userId, String(inv.role ?? 'basic_member'), req);
778
+ await writeResource('organization_invitation', idAt(4), { status: 'accepted', updated_at: nowUnixMs(req.occurredAt) }, 'organization_invitation.update', req);
779
+ const org = getRow('organization', idAt(2), req.root);
780
+ if (org) await writeResource('organization', idAt(2), { pending_invitations_count: Math.max(0, Number(org.pending_invitations_count ?? 0) - 1) }, 'organization.update', req);
781
+ return { status: 200, body: m };
782
+ }
783
+
784
+ // ── ORGANIZATION DOMAINS ──
785
+ if (seg.length === 4 && seg[3] === 'domains' && method === 'POST') {
786
+ const org = getRow('organization', idAt(2), req.root);
787
+ if (!org) return err('Organization not found', 404, 'resource_not_found');
788
+ const name = params.name as string;
789
+ if (!name) return err('name is required', 422, 'form_param_missing');
790
+ const mode = (params.enrollment_mode as string) ?? 'manual_invitation';
791
+ if (!['manual_invitation', 'automatic_invitation', 'automatic_suggestion'].includes(mode)) {
792
+ return err('Unknown enrollment_mode', 422, 'form_param_value_invalid');
793
+ }
794
+ if (rows('organization_domain', req.root).some((d) => d.organization_id === idAt(2) && d.name === name)) {
795
+ return err('That domain is already added', 422, 'duplicate_record');
796
+ }
797
+ const d = await create('organization_domain', {
798
+ organization_id: idAt(2), name, enrollment_mode: mode,
799
+ verification: { status: 'verified', strategy: 'admin', attempts: 0, expire_at: null },
800
+ affiliation_email_address: null, total_pending_invitations: 0, total_pending_suggestions: 0,
801
+ }, 'organization_domain.create', req);
802
+ return { status: 200, body: d };
803
+ }
804
+ if (seg.length === 4 && seg[3] === 'domains' && method === 'GET') {
805
+ const org = getRow('organization', idAt(2), req.root);
806
+ if (!org) return err('Organization not found', 404, 'resource_not_found');
807
+ const items = rows('organization_domain', req.root).map((r) => view('organization_domain', r)).filter((d) => d.organization_id === idAt(2));
808
+ return { status: 200, body: listEnvelope(items, query) };
809
+ }
810
+ if (seg.length === 5 && seg[3] === 'domains' && method === 'PATCH') {
811
+ const d = getRow('organization_domain', idAt(4), req.root);
812
+ if (!d || d.organization_id !== idAt(2)) return err('Domain not found', 404, 'resource_not_found');
813
+ const patch: Record<string, unknown> = {};
814
+ if ('enrollment_mode' in params) {
815
+ const mode = String(params.enrollment_mode);
816
+ if (!['manual_invitation', 'automatic_invitation', 'automatic_suggestion'].includes(mode)) return err('Unknown enrollment_mode', 422, 'form_param_value_invalid');
817
+ patch.enrollment_mode = mode;
818
+ }
819
+ return { status: 200, body: await writeResource('organization_domain', idAt(4), { ...patch, updated_at: nowUnixMs(req.occurredAt) }, 'organization_domain.update', req) };
820
+ }
821
+ if (seg.length === 5 && seg[3] === 'domains' && method === 'DELETE') {
822
+ const d = getRow('organization_domain', idAt(4), req.root);
823
+ if (!d || d.organization_id !== idAt(2)) return err('Domain not found', 404, 'resource_not_found');
824
+ await writeResource('organization_domain', idAt(4), { deleted: true }, 'organization_domain.delete', req);
825
+ return { status: 200, body: { object: 'organization_domain', id: idAt(4), deleted: true } };
826
+ }
827
+
828
+ // ── ORGANIZATION LOGO upload / delete ──
829
+ if (seg.length === 4 && seg[3] === 'logo' && method === 'PUT') {
830
+ const org = getRow('organization', idAt(2), req.root);
831
+ if (!org) return err('Organization not found', 404, 'resource_not_found');
832
+ const url = (params.file as string) || `https://img.clerk.twin/org/${idAt(2)}.png`;
833
+ return { status: 200, body: await writeResource('organization', idAt(2), { image_url: url, has_image: true, updated_at: nowUnixMs(req.occurredAt) }, 'organization.update', req) };
834
+ }
835
+ if (seg.length === 4 && seg[3] === 'logo' && method === 'DELETE') {
836
+ const org = getRow('organization', idAt(2), req.root);
837
+ if (!org) return err('Organization not found', 404, 'resource_not_found');
838
+ return { status: 200, body: await writeResource('organization', idAt(2), { image_url: '', has_image: false, updated_at: nowUnixMs(req.occurredAt) }, 'organization.update', req) };
839
+ }
840
+
841
+ // ── ORGANIZATION INVITATIONS: bulk create ──
842
+ if (seg.length === 5 && seg[3] === 'invitations' && seg[4] === 'bulk' && method === 'POST') {
843
+ const org = getRow('organization', idAt(2), req.root);
844
+ if (!org) return err('Organization not found', 404, 'resource_not_found');
845
+ const list = Array.isArray(params) ? params as unknown[] : Array.isArray(params.invitations) ? params.invitations as unknown[] : [];
846
+ if (list.length === 0) return err('a non-empty array of invitations is required', 422, 'form_param_missing');
847
+ const created: Array<Record<string, unknown>> = [];
848
+ for (const raw of list) {
849
+ const item = (raw && typeof raw === 'object') ? raw as Record<string, unknown> : {};
850
+ const email = item.email_address as string;
851
+ if (!email) return err('each invitation requires an email_address', 422, 'form_param_missing');
852
+ const inv = await create('organization_invitation', {
853
+ email_address: email, role: (item.role as string) ?? 'basic_member', organization_id: idAt(2),
854
+ status: 'pending', public_metadata: (item.public_metadata as object) ?? {}, private_metadata: (item.private_metadata as object) ?? {},
855
+ }, 'organization_invitation.create', req);
856
+ created.push(inv);
857
+ }
858
+ const org2 = getRow('organization', idAt(2), req.root);
859
+ if (org2) await writeResource('organization', idAt(2), { pending_invitations_count: Number(org2.pending_invitations_count ?? 0) + created.length }, 'organization.update', req);
860
+ return { status: 200, body: created };
861
+ }
862
+ }
863
+
864
+ // ── INSTANCE ORGANIZATION ROLES + PERMISSIONS ──
865
+ // Clerk exposes the instance's role set (each role carries a permission key list) and
866
+ // membership roles resolve their permissions from this registry. Defaults are the system
867
+ // admin/member roles; custom roles can be created and assigned to memberships.
868
+ if (collection === 'organizations_roles' || collection === 'organization_roles') {
869
+ if (seg.length === 2 && method === 'POST') {
870
+ const key = params.key as string;
871
+ const name = params.name as string;
872
+ if (!key || !name) return err('key and name are required', 422, 'form_param_missing');
873
+ if (defaultRoles(req.root).some((r) => r.key === key)) return err('That role key already exists', 422, 'duplicate_record');
874
+ const permissions = Array.isArray(params.permissions) ? params.permissions as string[] : [];
875
+ const role = await create('organization_role', { key, name, description: (params.description as string) ?? '', permissions, is_creator_eligible: !!params.is_creator_eligible }, 'organization_role.create', req);
876
+ return { status: 200, body: role };
877
+ }
878
+ if (seg.length === 2 && method === 'GET') {
879
+ return { status: 200, body: listEnvelope(defaultRoles(req.root), query) };
880
+ }
881
+ }
882
+ if (collection === 'organizations_permissions' || collection === 'organization_permissions') {
883
+ if (seg.length === 2 && method === 'GET') {
884
+ return { status: 200, body: listEnvelope([...SYSTEM_PERMISSIONS], query) };
885
+ }
886
+ }
887
+
888
+ // ── INVITATIONS (application-level) ──
889
+ if (collection === 'invitations') {
890
+ if (seg.length === 2 && method === 'POST') {
891
+ const email = params.email_address as string;
892
+ if (!email) return err('email_address is required', 422, 'form_param_missing');
893
+ // `notify` defaults true (real Clerk emails an invite link); a twin never delivers, so
894
+ // when notify !== false it RECORDS the invite url + a queued notification instead of
895
+ // sending. `redirect_url` is the post-accept landing url carried on the invite link.
896
+ const notify = params.notify !== false;
897
+ const redirectUrl = (params.redirect_url as string) ?? null;
898
+ const url = notify ? `https://twin.clerk.accounts.dev/invitation?redirect_url=${encodeURIComponent(redirectUrl ?? '')}` : null;
899
+ const inv = await create('invitation', {
900
+ email_address: email, status: 'pending', public_metadata: (params.public_metadata as object) ?? {},
901
+ revoked: false, url, notified: notify, redirect_url: redirectUrl,
902
+ }, 'invitation.create', req);
903
+ return { status: 200, body: inv };
904
+ }
905
+ if (seg.length === 2 && method === 'GET') {
906
+ let items = rows('invitation', req.root).map((r) => view('invitation', r));
907
+ if (query.status) items = items.filter((i) => i.status === query.status);
908
+ return { status: 200, body: items };
909
+ }
910
+ if (seg.length === 4 && seg[3] === 'revoke' && method === 'POST') {
911
+ const cur = getRow('invitation', idAt(2), req.root);
912
+ if (!cur) return err('Invitation not found', 404, 'resource_not_found');
913
+ return { status: 200, body: await writeResource('invitation', idAt(2), { status: 'revoked', revoked: true, updated_at: nowUnixMs(req.occurredAt) }, 'invitation.update', req) };
914
+ }
915
+ }
916
+
917
+ // ── ALLOWLIST / BLOCKLIST ──
918
+ for (const [coll, type] of [['allowlist_identifiers', 'allowlist_identifier'], ['blocklist_identifiers', 'blocklist_identifier']] as const) {
919
+ if (collection === coll) {
920
+ if (seg.length === 2 && method === 'POST') {
921
+ const identifier = params.identifier as string;
922
+ if (!identifier) return err('identifier is required', 422, 'form_param_missing');
923
+ const idType = identifier.includes('@') ? 'email_address' : 'phone_number';
924
+ const row = await create(type, { identifier, identifier_type: idType }, `${type}.create`, req);
925
+ return { status: 200, body: row };
926
+ }
927
+ if (seg.length === 2 && method === 'GET') {
928
+ return { status: 200, body: rows(type, req.root).map((r) => view(type, r)) };
929
+ }
930
+ if (seg.length === 3 && method === 'DELETE') {
931
+ const cur = getRow(type, idAt(2), req.root);
932
+ if (!cur) return err('Identifier not found', 404, 'resource_not_found');
933
+ await writeResource(type, idAt(2), { deleted: true }, `${type}.delete`, req);
934
+ return { status: 200, body: { object: type, id: idAt(2), deleted: true } };
935
+ }
936
+ }
937
+ }
938
+
939
+ // ── JWT TEMPLATES ──
940
+ if (collection === 'jwt_templates') {
941
+ if (seg.length === 2 && method === 'POST') {
942
+ const name = params.name as string;
943
+ if (!name) return err('name is required', 422, 'form_param_missing');
944
+ // custom_signing_key: bring-your-own HS256 secret (e.g. Supabase's shared secret). When
945
+ // custom_signing_key=true the template MUST carry signing_algorithm=HS256 + a `key`.
946
+ const custom = params.custom_signing_key === true;
947
+ const algo = custom ? String(params.signing_algorithm ?? 'HS256') : 'RS256';
948
+ if (custom) {
949
+ if (algo !== 'HS256') return err('custom_signing_key only supports HS256', 422, 'form_param_value_invalid');
950
+ if (typeof params.key !== 'string' || !params.key) return err('key is required when custom_signing_key is set', 422, 'form_param_missing');
951
+ }
952
+ const t = await create('jwt_template', {
953
+ name, claims: (params.claims as object) ?? {}, lifetime: (params.lifetime as number) ?? 60,
954
+ allowed_clock_skew: (params.allowed_clock_skew as number) ?? 5, custom_signing_key: custom, signing_algorithm: algo,
955
+ // `_signing_key` is twin-internal (never serialized — view() drops `_`-keys).
956
+ ...(custom ? { _signing_key: String(params.key) } : {}),
957
+ }, 'jwt_template.create', req);
958
+ return { status: 200, body: t };
959
+ }
960
+ if (seg.length === 2 && method === 'GET') {
961
+ return { status: 200, body: rows('jwt_template', req.root).map((r) => view('jwt_template', r)) };
962
+ }
963
+ if (seg.length === 3 && method === 'GET') {
964
+ const t = getOne('jwt_template', idAt(2), req.root);
965
+ return t ? { status: 200, body: t } : err('Template not found', 404, 'resource_not_found');
966
+ }
967
+ if (seg.length === 3 && method === 'PATCH') {
968
+ const cur = getRow('jwt_template', idAt(2), req.root);
969
+ if (!cur) return err('Template not found', 404, 'resource_not_found');
970
+ const patch: Record<string, unknown> = {};
971
+ for (const k of ['name', 'claims', 'lifetime', 'allowed_clock_skew']) if (k in params) patch[k] = params[k];
972
+ return { status: 200, body: await writeResource('jwt_template', idAt(2), { ...patch, updated_at: nowUnixMs(req.occurredAt) }, 'jwt_template.update', req) };
973
+ }
974
+ if (seg.length === 3 && method === 'DELETE') {
975
+ const cur = getRow('jwt_template', idAt(2), req.root);
976
+ if (!cur) return err('Template not found', 404, 'resource_not_found');
977
+ await writeResource('jwt_template', idAt(2), { deleted: true }, 'jwt_template.delete', req);
978
+ return { status: 200, body: { object: 'jwt_template', id: idAt(2), deleted: true } };
979
+ }
980
+ }
981
+
982
+ // ── SIGN-IN TOKENS (issue a real signed JWT for a user, magic-link style) ──
983
+ if (collection === 'sign_in_tokens') {
984
+ if (seg.length === 2 && method === 'POST') {
985
+ const userId = params.user_id as string;
986
+ if (!userId) return err('user_id is required', 422, 'form_param_missing');
987
+ if (!getRow('user', userId, req.root)) return err('User not found', 404, 'resource_not_found');
988
+ const jwt = await signJwt({ sub: userId, sit: true }, { root: req.root, expiresInSeconds: (params.expires_in_seconds as number) ?? 2592000, ...(req.occurredAt ? { now: Math.floor(nowUnixMs(req.occurredAt) / 1000) } : {}) });
989
+ const t = await create('sign_in_token', { user_id: userId, status: 'pending', token: jwt, url: null }, 'sign_in_token.create', req);
990
+ return { status: 200, body: t };
991
+ }
992
+ // POST /v1/sign_in_tokens/:id/revoke — revoke a still-pending sign-in token
993
+ if (seg.length === 4 && seg[3] === 'revoke' && method === 'POST') {
994
+ const cur = getRow('sign_in_token', idAt(2), req.root);
995
+ if (!cur) return err('Sign-in token not found', 404, 'resource_not_found');
996
+ if (cur.status !== 'pending') return err('Sign-in token is no longer pending', 422, 'sign_in_token_cannot_be_revoked');
997
+ return { status: 200, body: await writeResource('sign_in_token', idAt(2), { status: 'revoked', updated_at: nowUnixMs(req.occurredAt) }, 'sign_in_token.update', req) };
998
+ }
999
+ }
1000
+
1001
+ // ── ACTOR TOKENS (impersonation: a token that signs in as a user, optionally AS an actor) ──
1002
+ if (collection === 'actor_tokens') {
1003
+ if (seg.length === 2 && method === 'POST') {
1004
+ const userId = params.user_id as string;
1005
+ const actor = params.actor as Record<string, unknown> | undefined;
1006
+ if (!userId) return err('user_id is required', 422, 'form_param_missing');
1007
+ if (!actor || typeof actor !== 'object' || typeof actor.sub !== 'string') return err('actor.sub is required', 422, 'form_param_missing');
1008
+ if (!getRow('user', userId, req.root)) return err('User not found', 404, 'resource_not_found');
1009
+ const jwt = await signJwt({ sub: userId, act: actor }, { root: req.root, expiresInSeconds: (params.expires_in_seconds as number) ?? 3600, ...(req.occurredAt ? { now: Math.floor(nowUnixMs(req.occurredAt) / 1000) } : {}) });
1010
+ const t = await create('actor_token', { user_id: userId, actor, status: 'pending', token: jwt, url: null }, 'actor_token.create', req);
1011
+ return { status: 200, body: t };
1012
+ }
1013
+ if (seg.length === 4 && seg[3] === 'revoke' && method === 'POST') {
1014
+ const cur = getRow('actor_token', idAt(2), req.root);
1015
+ if (!cur) return err('Actor token not found', 404, 'resource_not_found');
1016
+ if (cur.status !== 'pending') return err('Actor token is no longer pending', 422, 'actor_token_cannot_be_revoked');
1017
+ return { status: 200, body: await writeResource('actor_token', idAt(2), { status: 'revoked', updated_at: nowUnixMs(req.occurredAt) }, 'actor_token.update', req) };
1018
+ }
1019
+ }
1020
+
1021
+ // ── TESTING TOKENS (bypass bot protection in E2E; a short-lived opaque token) ──
1022
+ if (collection === 'testing_tokens' && seg.length === 2 && method === 'POST') {
1023
+ const at = nowUnixMs(req.occurredAt);
1024
+ const tok = `testing_token_${stableHex(`testing_token:${req.occurredAt ?? ''}:${projectResources(SERVICE, req.root).filter((x) => x.type === 'testing_token').length}`, 32)}`;
1025
+ const t = await create('testing_token', { token: tok, expires_at: at + 3600 * 1000 }, 'testing_token.create', req);
1026
+ return { status: 200, body: { object: 'testing_token', id: t.id, token: tok, expires_at: at + 3600 * 1000 } };
1027
+ }
1028
+
1029
+ // ── CLIENTS (verify a client/session token + handshake) ──
1030
+ if (collection === 'clients') {
1031
+ // POST /v1/clients/verify {token} — verify a session JWT and return the bound client+session
1032
+ if (seg.length === 3 && seg[2] === 'verify' && method === 'POST') {
1033
+ const token = params.token as string | undefined;
1034
+ if (!token) return err('token is required', 422, 'form_param_missing');
1035
+ const result = verifyJwtWithJwks(token, await buildJwks(req.root));
1036
+ if (!result.valid) return err(`Invalid token: ${result.reason}`, 401, 'invalid_token');
1037
+ const sid = String(result.payload?.sid ?? '');
1038
+ const session = sid ? getRow('session', sid, req.root) : undefined;
1039
+ const sub = String(result.payload?.sub ?? '');
1040
+ return {
1041
+ status: 200,
1042
+ body: {
1043
+ object: 'client', id: `client_twin`, session_ids: session ? [sid] : [],
1044
+ sessions: session ? [view('session', session)] : [],
1045
+ sign_in_id: null, sign_up_id: null, last_active_session_id: sid || null,
1046
+ ...(sub ? { _user_id: undefined } : {}),
1047
+ },
1048
+ };
1049
+ }
1050
+ if (seg.length === 2 && method === 'GET') {
1051
+ // GET /v1/clients — active clients are derived from active sessions (the twin has one client)
1052
+ const active = rows('session', req.root).filter((s) => s.status === 'active');
1053
+ const clients = active.length === 0 ? [] : [{ object: 'client', id: 'client_twin', session_ids: active.map((s) => s.id), last_active_session_id: active[0]!.id }];
1054
+ return { status: 200, body: listEnvelope(clients, query) };
1055
+ }
1056
+ }
1057
+
1058
+ // ── INSTANCE SETTINGS (restrictions, MFA, attack protection) ──
1059
+ if (collection === 'instance') {
1060
+ if (seg.length === 2 && method === 'GET') {
1061
+ return { status: 200, body: instanceSettings(req.root) };
1062
+ }
1063
+ // PATCH /v1/instance and the focused sub-resources Clerk exposes
1064
+ if (seg.length === 2 && (method === 'PATCH' || method === 'PUT')) {
1065
+ return { status: 200, body: await patchInstance(params, req) };
1066
+ }
1067
+ if (seg.length === 3 && (method === 'PATCH' || method === 'PUT') && ['restrictions', 'organization_settings'].includes(seg[2]!)) {
1068
+ return { status: 200, body: await patchInstance({ [seg[2]!]: params }, req) };
1069
+ }
1070
+ }
1071
+
1072
+ // ── REDIRECT URLS (the instance allowlist of post-auth redirect URLs) ──
1073
+ if (collection === 'redirect_urls') {
1074
+ if (seg.length === 2 && method === 'POST') {
1075
+ const url = params.url as string;
1076
+ if (!url) return err('url is required', 422, 'form_param_missing');
1077
+ const r = await create('redirect_url', { url }, 'redirect_url.create', req);
1078
+ return { status: 200, body: r };
1079
+ }
1080
+ if (seg.length === 2 && method === 'GET') return { status: 200, body: rows('redirect_url', req.root).map((r) => view('redirect_url', r)) };
1081
+ if (seg.length === 3 && method === 'GET') {
1082
+ const r = getOne('redirect_url', idAt(2), req.root);
1083
+ return r ? { status: 200, body: r } : err('Redirect URL not found', 404, 'resource_not_found');
1084
+ }
1085
+ if (seg.length === 3 && method === 'DELETE') {
1086
+ const cur = getRow('redirect_url', idAt(2), req.root);
1087
+ if (!cur) return err('Redirect URL not found', 404, 'resource_not_found');
1088
+ await writeResource('redirect_url', idAt(2), { deleted: true }, 'redirect_url.delete', req);
1089
+ return { status: 200, body: { object: 'redirect_url', id: idAt(2), deleted: true } };
1090
+ }
1091
+ }
1092
+
1093
+ // ── OAUTH APPLICATIONS (the instance's own OAuth/OIDC apps; carries a client secret) ──
1094
+ if (collection === 'oauth_applications') {
1095
+ if (seg.length === 2 && method === 'POST') {
1096
+ const name = params.name as string;
1097
+ if (!name) return err('name is required', 422, 'form_param_missing');
1098
+ const clientId = `client_${stableHex(`oauth_application:${req.occurredAt ?? ''}:${projectResources(SERVICE, req.root).filter((x) => x.type === 'oauth_application').length}`, 24)}`;
1099
+ const clientSecret = `secret_${secretHex(24)}`;
1100
+ const app = await create('oauth_application', {
1101
+ name, instance_id: INSTANCE_ID, client_id: clientId, client_secret: clientSecret,
1102
+ public: !!params.public, scopes: (params.scopes as string) ?? 'profile email',
1103
+ callback_url: (params.callback_url as string) ?? null,
1104
+ redirect_uris: Array.isArray(params.redirect_uris) ? params.redirect_uris : [],
1105
+ authorize_url: 'https://twin.clerk.accounts.dev/oauth/authorize',
1106
+ token_fetch_url: 'https://twin.clerk.accounts.dev/oauth/token',
1107
+ user_info_url: 'https://twin.clerk.accounts.dev/oauth/userinfo',
1108
+ }, 'oauth_application.create', req);
1109
+ return { status: 200, body: app };
1110
+ }
1111
+ if (seg.length === 2 && method === 'GET') return { status: 200, body: listEnvelope(rows('oauth_application', req.root).map((r) => view('oauth_application', r)), query) };
1112
+ if (seg.length === 3 && method === 'GET') {
1113
+ const a = getOne('oauth_application', idAt(2), req.root);
1114
+ return a ? { status: 200, body: a } : err('OAuth application not found', 404, 'resource_not_found');
1115
+ }
1116
+ if (seg.length === 3 && method === 'PATCH') {
1117
+ const cur = getRow('oauth_application', idAt(2), req.root);
1118
+ if (!cur) return err('OAuth application not found', 404, 'resource_not_found');
1119
+ const patch: Record<string, unknown> = {};
1120
+ for (const k of ['name', 'callback_url', 'scopes', 'public', 'redirect_uris']) if (k in params) patch[k] = params[k];
1121
+ return { status: 200, body: await writeResource('oauth_application', idAt(2), { ...patch, updated_at: nowUnixMs(req.occurredAt) }, 'oauth_application.update', req) };
1122
+ }
1123
+ if (seg.length === 3 && method === 'DELETE') {
1124
+ const cur = getRow('oauth_application', idAt(2), req.root);
1125
+ if (!cur) return err('OAuth application not found', 404, 'resource_not_found');
1126
+ await writeResource('oauth_application', idAt(2), { deleted: true }, 'oauth_application.delete', req);
1127
+ return { status: 200, body: { object: 'oauth_application', id: idAt(2), deleted: true } };
1128
+ }
1129
+ // POST /v1/oauth_applications/:id/rotate_secret — issue a fresh client secret
1130
+ if (seg.length === 4 && seg[3] === 'rotate_secret' && method === 'POST') {
1131
+ const cur = getRow('oauth_application', idAt(2), req.root);
1132
+ if (!cur) return err('OAuth application not found', 404, 'resource_not_found');
1133
+ return { status: 200, body: await writeResource('oauth_application', idAt(2), { client_secret: `secret_${secretHex(24)}`, updated_at: nowUnixMs(req.occurredAt) }, 'oauth_application.update', req) };
1134
+ }
1135
+ }
1136
+
1137
+ // ── SAML CONNECTIONS (enterprise SSO connections) ──
1138
+ if (collection === 'saml_connections') {
1139
+ if (seg.length === 2 && method === 'POST') {
1140
+ const name = params.name as string;
1141
+ const domain = params.domain as string;
1142
+ const provider = params.provider as string;
1143
+ if (!name || !domain || !provider) return err('name, domain and provider are required', 422, 'form_param_missing');
1144
+ const conn = await create('saml_connection', {
1145
+ name, domain, provider, active: params.active === true, sync_user_attributes: params.sync_user_attributes !== false,
1146
+ idp_entity_id: (params.idp_entity_id as string) ?? null, idp_sso_url: (params.idp_sso_url as string) ?? null,
1147
+ idp_certificate: (params.idp_certificate as string) ?? null, acs_url: 'https://twin.clerk.accounts.dev/v1/saml/acs',
1148
+ sp_entity_id: `https://twin.clerk.accounts.dev/saml/${domain}`, attribute_mapping: (params.attribute_mapping as object) ?? {},
1149
+ }, 'saml_connection.create', req);
1150
+ return { status: 200, body: conn };
1151
+ }
1152
+ if (seg.length === 2 && method === 'GET') return { status: 200, body: listEnvelope(rows('saml_connection', req.root).map((r) => view('saml_connection', r)), query) };
1153
+ if (seg.length === 3 && method === 'GET') {
1154
+ const c = getOne('saml_connection', idAt(2), req.root);
1155
+ return c ? { status: 200, body: c } : err('SAML connection not found', 404, 'resource_not_found');
1156
+ }
1157
+ if (seg.length === 3 && method === 'PATCH') {
1158
+ const cur = getRow('saml_connection', idAt(2), req.root);
1159
+ if (!cur) return err('SAML connection not found', 404, 'resource_not_found');
1160
+ const patch: Record<string, unknown> = {};
1161
+ for (const k of ['name', 'domain', 'active', 'sync_user_attributes', 'idp_entity_id', 'idp_sso_url', 'idp_certificate', 'attribute_mapping']) if (k in params) patch[k] = params[k];
1162
+ return { status: 200, body: await writeResource('saml_connection', idAt(2), { ...patch, updated_at: nowUnixMs(req.occurredAt) }, 'saml_connection.update', req) };
1163
+ }
1164
+ if (seg.length === 3 && method === 'DELETE') {
1165
+ const cur = getRow('saml_connection', idAt(2), req.root);
1166
+ if (!cur) return err('SAML connection not found', 404, 'resource_not_found');
1167
+ await writeResource('saml_connection', idAt(2), { deleted: true }, 'saml_connection.delete', req);
1168
+ return { status: 200, body: { object: 'saml_connection', id: idAt(2), deleted: true } };
1169
+ }
1170
+ }
1171
+
1172
+ // ── INSTANCE DOMAINS (the primary + satellite domains of the instance) ──
1173
+ if (collection === 'domains') {
1174
+ if (seg.length === 2 && method === 'POST') {
1175
+ const name = params.name as string;
1176
+ if (!name) return err('name is required', 422, 'form_param_missing');
1177
+ const isSatellite = params.is_satellite === true;
1178
+ if (!isSatellite && rows('domain', req.root).some((d) => !d.is_satellite)) {
1179
+ return err('A primary domain already exists; satellite domains must set is_satellite', 422, 'duplicate_record');
1180
+ }
1181
+ if (rows('domain', req.root).some((d) => d.name === name)) return err('That domain is already added', 422, 'duplicate_record');
1182
+ const d = await create('domain', {
1183
+ name, is_satellite: isSatellite, frontend_api_url: `https://clerk.${name}`,
1184
+ accounts_portal_url: isSatellite ? null : `https://accounts.${name}`,
1185
+ proxy_url: (params.proxy_url as string) ?? null,
1186
+ // Clerk's Domain object carries the ORIGIN the development instance is served from.
1187
+ // Derived from this request (R7), or taken verbatim when the caller declares its own.
1188
+ development_origin: typeof params.development_origin === 'string' ? params.development_origin : originOf(req, name),
1189
+ cname_targets: [],
1190
+ }, 'domain.create', req);
1191
+ return { status: 200, body: d };
1192
+ }
1193
+ if (seg.length === 2 && method === 'GET') return { status: 200, body: listEnvelope(rows('domain', req.root).map((r) => view('domain', r)), query) };
1194
+ if (seg.length === 3 && method === 'PATCH') {
1195
+ const cur = getRow('domain', idAt(2), req.root);
1196
+ if (!cur) return err('Domain not found', 404, 'resource_not_found');
1197
+ const patch: Record<string, unknown> = {};
1198
+ for (const k of ['name', 'proxy_url']) if (k in params) patch[k] = params[k];
1199
+ return { status: 200, body: await writeResource('domain', idAt(2), { ...patch, updated_at: nowUnixMs(req.occurredAt) }, 'domain.update', req) };
1200
+ }
1201
+ if (seg.length === 3 && method === 'DELETE') {
1202
+ const cur = getRow('domain', idAt(2), req.root);
1203
+ if (!cur) return err('Domain not found', 404, 'resource_not_found');
1204
+ if (!cur.is_satellite) return err('The primary domain cannot be deleted', 422, 'primary_domain_not_deletable');
1205
+ await writeResource('domain', idAt(2), { deleted: true }, 'domain.delete', req);
1206
+ return { status: 200, body: { object: 'domain', id: idAt(2), deleted: true } };
1207
+ }
1208
+ }
1209
+
1210
+ // ── WEBHOOK ENDPOINTS (manage svix endpoints via the Backend API) + delivery attempts ──
1211
+ if (collection === 'webhooks') {
1212
+ // POST/GET/PATCH/DELETE /v1/webhooks/endpoints[/:id]
1213
+ if (seg[2] === 'endpoints') {
1214
+ if (seg.length === 3 && method === 'POST') {
1215
+ const url = params.url as string;
1216
+ if (!url) return err('url is required', 422, 'form_param_missing');
1217
+ const secret = `whsec_${Buffer.from(secretHex(24)).toString('base64').slice(0, 32)}`;
1218
+ const ep = await create('webhook_endpoint', {
1219
+ url, signing_secret: secret, description: (params.description as string) ?? null,
1220
+ disabled: false, filter_types: Array.isArray(params.filter_types) ? params.filter_types : null,
1221
+ }, 'webhook_endpoint.create', req);
1222
+ // Delivery reads the endpoint from the tree (webhookTargets), signed with this signing_secret.
1223
+ return { status: 200, body: ep };
1224
+ }
1225
+ if (seg.length === 3 && method === 'GET') return { status: 200, body: listEnvelope(rows('webhook_endpoint', req.root).map((r) => view('webhook_endpoint', r)), query) };
1226
+ if (seg.length === 4 && method === 'GET') {
1227
+ const ep = getOne('webhook_endpoint', idAt(3), req.root);
1228
+ return ep ? { status: 200, body: ep } : err('Webhook endpoint not found', 404, 'resource_not_found');
1229
+ }
1230
+ if (seg.length === 4 && method === 'PATCH') {
1231
+ const cur = getRow('webhook_endpoint', idAt(3), req.root);
1232
+ if (!cur) return err('Webhook endpoint not found', 404, 'resource_not_found');
1233
+ const patch: Record<string, unknown> = {};
1234
+ for (const k of ['url', 'description', 'disabled', 'filter_types']) if (k in params) patch[k] = params[k];
1235
+ return { status: 200, body: await writeResource('webhook_endpoint', idAt(3), { ...patch, updated_at: nowUnixMs(req.occurredAt) }, 'webhook_endpoint.update', req) };
1236
+ }
1237
+ if (seg.length === 4 && method === 'DELETE') {
1238
+ const cur = getRow('webhook_endpoint', idAt(3), req.root);
1239
+ if (!cur) return err('Webhook endpoint not found', 404, 'resource_not_found');
1240
+ await writeResource('webhook_endpoint', idAt(3), { deleted: true }, 'webhook_endpoint.delete', req);
1241
+ return { status: 200, body: { object: 'webhook_endpoint', id: idAt(3), deleted: true } };
1242
+ }
1243
+ // GET /v1/webhooks/endpoints/:id/attempts — the delivery attempts log for this endpoint
1244
+ if (seg.length === 5 && seg[4] === 'attempts' && method === 'GET') {
1245
+ const cur = getRow('webhook_endpoint', idAt(3), req.root);
1246
+ if (!cur) return err('Webhook endpoint not found', 404, 'resource_not_found');
1247
+ const attempts = rows('webhook_attempt', req.root).map((r) => view('webhook_attempt', r)).filter((a) => a.endpoint_id === idAt(3));
1248
+ return { status: 200, body: listEnvelope(attempts, query) };
1249
+ }
1250
+ }
1251
+ // POST /v1/webhooks/svix — provision the svix app (returns the svix app id; idempotent)
1252
+ if (seg.length === 3 && seg[2] === 'svix' && method === 'POST') {
1253
+ return { status: 200, body: { object: 'svix_app', id: 'svix_app_twin', url: 'https://twin.clerk.accounts.dev/svix' } };
1254
+ }
1255
+ }
1256
+
1257
+ // ── WAITLIST ENTRIES (POST creates / returns the existing entry for an email; GET lists) ──
1258
+ if (collection === 'waitlist_entries') {
1259
+ if (seg.length === 2 && method === 'POST') {
1260
+ const email = params.email_address as string;
1261
+ if (!email || typeof email !== 'string' || !email.includes('@')) {
1262
+ return err('email_address is required', 422, 'form_param_missing');
1263
+ }
1264
+ // Real Clerk: if the email is already on the waitlist, no new entry is created — the
1265
+ // existing one is returned (idempotent on email_address).
1266
+ const existing = rows('waitlist_entry', req.root).find((r) => r.email_address === email);
1267
+ if (existing) return { status: 200, body: view('waitlist_entry', existing) };
1268
+ // `notify` defaults true (real Clerk emails on approval); a twin never delivers.
1269
+ const entry = await create('waitlist_entry', {
1270
+ email_address: email, status: 'pending', is_locked: false,
1271
+ invitation: null,
1272
+ }, 'waitlist_entry.create', req);
1273
+ return { status: 200, body: entry };
1274
+ }
1275
+ if (seg.length === 2 && method === 'GET') {
1276
+ let items = rows('waitlist_entry', req.root)
1277
+ // default ordering: creation date descending (newest first), as the real API does
1278
+ .sort((a, b) => Number(b.created_at ?? 0) - Number(a.created_at ?? 0))
1279
+ .map((r) => view('waitlist_entry', r));
1280
+ if (query.status) items = items.filter((i) => i.status === query.status);
1281
+ if (query.email_address) items = items.filter((i) => i.email_address === query.email_address);
1282
+ return { status: 200, body: listEnvelope(items, query) };
1283
+ }
1284
+ }
1285
+
1286
+ // ── PROXY CHECKS (validate that a proxy_url maps to a working proxy for a domain) ──
1287
+ // The REAL endpoint runs a live DNS/HTTP reachability probe against the proxy — that is
1288
+ // a genuine network op the twin can't perform offline. What IS modellable (and faithful)
1289
+ // is the request validation + the recorded proxy_check record: the domain must exist, the
1290
+ // proxy_url must be a well-formed https URL, and the deterministic result is "successful"
1291
+ // when those hold (the twin's network is its own state — the proxy it would reach is the
1292
+ // twin itself, so a well-formed config is reachable). See README ## Coverage.
1293
+ if (collection === 'proxy_checks' && seg.length === 2 && method === 'POST') {
1294
+ const domainId = params.domain_id as string;
1295
+ const proxyUrl = params.proxy_url as string;
1296
+ if (!domainId) return err('domain_id is required', 422, 'form_param_missing');
1297
+ if (!proxyUrl) return err('proxy_url is required', 422, 'form_param_missing');
1298
+ const domain = getRow('domain', domainId, req.root);
1299
+ if (!domain) return err('Domain not found', 404, 'resource_not_found');
1300
+ let wellFormed = false;
1301
+ try {
1302
+ const u = new URL(proxyUrl);
1303
+ wellFormed = u.protocol === 'https:' && !!u.hostname;
1304
+ } catch {
1305
+ wellFormed = false;
1306
+ }
1307
+ if (!wellFormed) return err('proxy_url must be a valid https URL', 422, 'form_param_value_invalid');
1308
+ const check = await create('proxy_check', {
1309
+ domain_id: domainId, proxy_url: proxyUrl,
1310
+ successful: true, last_run_at: nowUnixMs(req.occurredAt),
1311
+ }, 'proxy_check.create', req);
1312
+ return { status: 200, body: check };
1313
+ }
1314
+
1315
+ // ── BETA FEATURES (instance-settings fields still in beta; folded into instance settings) ──
1316
+ // PATCH /v1/beta_features/instance_settings — real Clerk mutates the beta instance settings
1317
+ // (from_email_address / progressive_sign_up / test_mode) and returns the InstanceSettings.
1318
+ if (collection === 'beta_features') {
1319
+ if (seg.length === 3 && seg[2] === 'instance_settings' && (method === 'PATCH' || method === 'PUT')) {
1320
+ const patch: Record<string, unknown> = {};
1321
+ for (const k of ['from_email_address', 'progressive_sign_up', 'test_mode']) if (k in params) patch[k] = params[k];
1322
+ return { status: 200, body: await patchInstance(patch, req) };
1323
+ }
1324
+ }
1325
+
1326
+ // ── EMAIL / SMS (the twin RETURNS the OTP/message; it does NOT deliver — see README) ──
1327
+ if (collection === 'emails' && seg.length === 2 && method === 'POST') {
1328
+ const email = await create('email_address', {
1329
+ to_email_address: params.email_address ?? null, subject: params.subject ?? null,
1330
+ body: params.body ?? null, status: 'queued', delivered_by_clerk: false,
1331
+ }, 'email.create', req);
1332
+ return { status: 200, body: email };
1333
+ }
1334
+
1335
+ return err(`Unknown route: ${method} ${path}`, 404, 'resource_not_found');
1336
+ }
1337
+
1338
+ // ── email/phone identifier sub-resources ─────────────────────────────────────────
1339
+ // Clerk identifiers (email_address / phone_number) are first-class objects WITH their
1340
+ // own ids, but they live inside the owning user. The twin keeps them in the user's
1341
+ // nested array and resolves an identifier id back to its user for retrieve/verify/delete.
1342
+ async function addUserIdentifier(user: Record<string, unknown>, listKey: string, idn: Record<string, unknown>, req: ClerkRequest): Promise<Record<string, unknown>> {
1343
+ const list = Array.isArray(user[listKey]) ? user[listKey] as Array<Record<string, unknown>> : [];
1344
+ const idType = idn.object === 'phone_number' ? 'phone_number' : idn.object === 'web3_wallet' ? 'web3_wallet' : 'email_address';
1345
+ // The identifier ordinal is the owning user's list length: two identifiers added to one user
1346
+ // at the same instant are distinct (the `rows()` count cannot see them — they are nested).
1347
+ const withId = { id: nextId(idType, req.occurredAt, req.root, `${String(user.id)}:${listKey}:${list.length}`), ...idn };
1348
+ const next = [...list, withId];
1349
+ await writeResource('user', String(user.id), { [listKey]: next, updated_at: nowUnixMs(req.occurredAt) }, 'user.update', req);
1350
+ return withId;
1351
+ }
1352
+ function findIdentifier(listKey: string, idnId: string, root?: string): { user: Record<string, unknown>; idn: Record<string, unknown> } | undefined {
1353
+ for (const u of rows('user', root)) {
1354
+ const list = Array.isArray(u[listKey]) ? u[listKey] as Array<Record<string, unknown>> : [];
1355
+ const idn = list.find((e) => e.id === idnId);
1356
+ if (idn) return { user: u, idn };
1357
+ }
1358
+ return undefined;
1359
+ }
1360
+ async function emailPhoneSubRoutes(
1361
+ listKey: string, objectName: string, seg: string[], method: string,
1362
+ params: Record<string, unknown>, query: Record<string, string>, req: ClerkRequest, idAt: (i: number) => string,
1363
+ ): Promise<ClerkResponse> {
1364
+ if (seg.length === 3 && method === 'GET') {
1365
+ const found = findIdentifier(listKey, idAt(2), req.root);
1366
+ return found ? { status: 200, body: found.idn } : err('Identifier not found', 404, 'resource_not_found');
1367
+ }
1368
+ // PATCH /v1/email_addresses/:id {verified: true} — admin-mark the identifier verified
1369
+ if (seg.length === 3 && (method === 'PATCH' || method === 'POST')) {
1370
+ const found = findIdentifier(listKey, idAt(2), req.root);
1371
+ if (!found) return err('Identifier not found', 404, 'resource_not_found');
1372
+ const list = found.user[listKey] as Array<Record<string, unknown>>;
1373
+ const verified = params.verified === true;
1374
+ const updated = list.map((e) => e.id === idAt(2)
1375
+ ? { ...e, verification: verified ? { status: 'verified', strategy: 'admin' } : { status: 'unverified', strategy: null } }
1376
+ : e);
1377
+ await writeResource('user', String(found.user.id), { [listKey]: updated, updated_at: nowUnixMs(req.occurredAt) }, 'user.update', req);
1378
+ return { status: 200, body: updated.find((e) => e.id === idAt(2))! };
1379
+ }
1380
+ if (seg.length === 3 && method === 'DELETE') {
1381
+ const found = findIdentifier(listKey, idAt(2), req.root);
1382
+ if (!found) return err('Identifier not found', 404, 'resource_not_found');
1383
+ const list = found.user[listKey] as Array<Record<string, unknown>>;
1384
+ await writeResource('user', String(found.user.id), { [listKey]: list.filter((e) => e.id !== idAt(2)), updated_at: nowUnixMs(req.occurredAt) }, 'user.update', req);
1385
+ return { status: 200, body: { object: objectName, id: idAt(2), deleted: true } };
1386
+ }
1387
+ return err(`Unknown route for ${listKey}`, 404, 'resource_not_found');
1388
+ }
1389
+
1390
+ // ── validation + helpers ───────────────────────────────────────────────────────
1391
+ function validateUserCreate(params: Record<string, unknown>, req: ClerkRequest): ClerkResponse | null {
1392
+ const emails = Array.isArray(params.email_address) ? params.email_address as string[] : [];
1393
+ const phones = Array.isArray(params.phone_number) ? params.phone_number as string[] : [];
1394
+ const username = params.username as string | undefined;
1395
+ if (emails.length === 0 && phones.length === 0 && !username && !params.external_id) {
1396
+ return err('You must provide at least one of the following: email_address, phone_number, username, external_id.', 422, 'form_param_missing');
1397
+ }
1398
+ // unique email across users
1399
+ for (const email of emails) {
1400
+ const taken = rows('user', req.root).some((u) => emailIdsFor(view('user', u)).some((e) => e.email_address === email));
1401
+ if (taken) return err('That email address is taken.', 422, 'form_identifier_exists');
1402
+ }
1403
+ return null;
1404
+ }
1405
+
1406
+ function userPatch(params: Record<string, unknown>): Record<string, unknown> {
1407
+ const patch: Record<string, unknown> = {};
1408
+ for (const k of ['first_name', 'last_name', 'username', 'public_metadata', 'private_metadata', 'unsafe_metadata', 'external_id', 'primary_email_address_id']) {
1409
+ if (k in params) patch[k] = params[k];
1410
+ }
1411
+ return patch;
1412
+ }
1413
+
1414
+ function applyUserFilters(items: Array<Record<string, unknown>>, query: Record<string, string>): Array<Record<string, unknown>> {
1415
+ let out = items;
1416
+ if (query.username) out = out.filter((u) => u.username === query.username);
1417
+ if (query.email_address) out = out.filter((u) => emailIdsFor(u).some((e) => e.email_address === query.email_address));
1418
+ if (query.external_id) out = out.filter((u) => u.external_id === query.external_id);
1419
+ if (query.user_id) out = out.filter((u) => u.id === query.user_id);
1420
+ return out;
1421
+ }
1422
+
1423
+ function userMatchesQuery(u: Record<string, unknown>, q: string): boolean {
1424
+ const hay = [u.first_name, u.last_name, u.username, u.external_id, ...emailIdsFor(u).map((e) => e.email_address)].filter(Boolean).map(String).join(' ').toLowerCase();
1425
+ return hay.includes(q.toLowerCase());
1426
+ }
1427
+
1428
+ function mergeMetadata(cur: Record<string, unknown>, params: Record<string, unknown>): Record<string, unknown> {
1429
+ const out: Record<string, unknown> = {};
1430
+ for (const bag of ['public_metadata', 'private_metadata', 'unsafe_metadata']) {
1431
+ if (bag in params) {
1432
+ const existing = (cur[bag] && typeof cur[bag] === 'object') ? cur[bag] as object : {};
1433
+ out[bag] = { ...existing, ...(params[bag] as object) };
1434
+ }
1435
+ }
1436
+ return out;
1437
+ }
1438
+
1439
+ function slugify(name: string): string {
1440
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'org';
1441
+ }
1442
+ function getBySlug(type: string, slug: string, root?: string): Record<string, unknown> | undefined {
1443
+ const r = rows(type, root).find((x) => x.slug === slug);
1444
+ return r ? view(type, r) : undefined;
1445
+ }
1446
+
1447
+ // ── memberships ──
1448
+ // Membership lookups operate on the RAW projected row (not view()) so the twin-internal
1449
+ // `_user_id` link is visible; expandMembership() then renders the vendor-facing shape.
1450
+ function membershipFor(orgId: string, userId: string, root?: string): Record<string, unknown> | undefined {
1451
+ return rows('organization_membership', root)
1452
+ .find((m) => m.organization_id === orgId && (m as Record<string, unknown>)._user_id === userId);
1453
+ }
1454
+ function membershipsOf(orgId: string, root?: string): Array<Record<string, unknown>> {
1455
+ return rows('organization_membership', root)
1456
+ .filter((m) => m.organization_id === orgId)
1457
+ .map((m) => expandMembership(m, root));
1458
+ }
1459
+ async function createMembership(orgId: string, userId: string, role: string, req: ClerkRequest): Promise<Record<string, unknown>> {
1460
+ const m = await create('organization_membership', {
1461
+ organization_id: orgId, _user_id: userId, role, permissions: rolePermissions(role, req.root),
1462
+ public_metadata: {}, private_metadata: {},
1463
+ }, 'organization_membership.create', req);
1464
+ const org = getRow('organization', orgId, req.root);
1465
+ if (org) await writeResource('organization', orgId, { members_count: Number(org.members_count ?? 0) + 1 }, 'organization.update', req);
1466
+ return expandMembership(m, req.root);
1467
+ }
1468
+ async function decrementMembers(orgId: string, req: ClerkRequest): Promise<void> {
1469
+ const org = getRow('organization', orgId, req.root);
1470
+ if (org) await writeResource('organization', orgId, { members_count: Math.max(0, Number(org.members_count ?? 0) - 1) }, 'organization.update', req);
1471
+ }
1472
+
1473
+ // ── organization roles + permissions registry ─────────────────────────────────────
1474
+ // The full set of system permission keys an org membership can hold (Clerk's `org:sys_*`).
1475
+ const SYSTEM_PERMISSIONS = [
1476
+ { object: 'permission', key: 'org:sys_profile:manage', name: 'Manage organization', type: 'system' },
1477
+ { object: 'permission', key: 'org:sys_profile:delete', name: 'Delete organization', type: 'system' },
1478
+ { object: 'permission', key: 'org:sys_memberships:manage', name: 'Manage members', type: 'system' },
1479
+ { object: 'permission', key: 'org:sys_memberships:read', name: 'Read members', type: 'system' },
1480
+ { object: 'permission', key: 'org:sys_domains:manage', name: 'Manage domains', type: 'system' },
1481
+ { object: 'permission', key: 'org:sys_domains:read', name: 'Read domains', type: 'system' },
1482
+ ] as const;
1483
+ const SYSTEM_ROLES: Array<{ object: string; key: string; name: string; description: string; permissions: string[]; is_creator_eligible: boolean }> = [
1484
+ { object: 'role', key: 'admin', name: 'Admin', description: 'Full organization control', permissions: ['org:sys_profile:manage', 'org:sys_profile:delete', 'org:sys_memberships:manage', 'org:sys_memberships:read', 'org:sys_domains:manage', 'org:sys_domains:read'], is_creator_eligible: true },
1485
+ { object: 'role', key: 'basic_member', name: 'Member', description: 'Standard organization member', permissions: ['org:sys_profile:read', 'org:sys_memberships:read'], is_creator_eligible: false },
1486
+ ];
1487
+ /** All roles for the instance: the two system roles plus any custom-created roles. */
1488
+ function defaultRoles(root?: string): Array<Record<string, unknown>> {
1489
+ const custom = rows('organization_role', root).map((r) => view('organization_role', r));
1490
+ return [...SYSTEM_ROLES.map((r) => ({ ...r })), ...custom];
1491
+ }
1492
+ /** Resolve a role key → its permission key list (system role, custom role, or empty). */
1493
+ function rolePermissions(role: string, root?: string): string[] {
1494
+ const found = defaultRoles(root).find((r) => r.key === role);
1495
+ if (found && Array.isArray(found.permissions)) return found.permissions as string[];
1496
+ return ['org:sys_profile:read', 'org:sys_memberships:read'];
1497
+ }
1498
+
1499
+ // ── instance settings (singleton) ─────────────────────────────────────────────────
1500
+ const INSTANCE_ID = 'instance_twin';
1501
+ const DEFAULT_INSTANCE: Record<string, unknown> = {
1502
+ object: 'instance', id: INSTANCE_ID, environment_type: 'development',
1503
+ restrictions: { allowlist: false, blocklist: false, block_email_subaddresses: false, block_disposable_email_domains: false, ignore_dots_for_gmail_addresses: false },
1504
+ organization_settings: { enabled: true, max_allowed_memberships: 5, creator_role: 'admin', domains: { enabled: false, enrollment_modes: [] } },
1505
+ attack_protection: { user_lockout: { enabled: true, max_attempts: 100, duration_in_minutes: 60 }, pii: { enabled: false } },
1506
+ mfa: { totp: false, sms: false, backup_codes: false },
1507
+ // Beta-features instance settings (PATCH /v1/beta_features/instance_settings).
1508
+ from_email_address: 'support', progressive_sign_up: false, test_mode: false,
1509
+ };
1510
+ function instanceSettings(root?: string): Record<string, unknown> {
1511
+ const stored = projectResources(SERVICE, root).find((r) => r.type === 'instance' && r.id === INSTANCE_ID && (r as Record<string, unknown>)._deleted !== true);
1512
+ return stored ? { ...DEFAULT_INSTANCE, ...view('instance', stored), object: 'instance', id: INSTANCE_ID } : { ...DEFAULT_INSTANCE };
1513
+ }
1514
+ async function patchInstance(params: Record<string, unknown>, req: ClerkRequest): Promise<Record<string, unknown>> {
1515
+ const cur = instanceSettings(req.root);
1516
+ const merged: Record<string, unknown> = { ...cur };
1517
+ // Deep-merge the nested setting bags (restrictions / organization_settings / attack_protection / mfa).
1518
+ for (const [k, v] of Object.entries(params)) {
1519
+ if (v && typeof v === 'object' && !Array.isArray(v) && cur[k] && typeof cur[k] === 'object') {
1520
+ merged[k] = { ...(cur[k] as object), ...(v as object) };
1521
+ } else {
1522
+ merged[k] = v;
1523
+ }
1524
+ }
1525
+ merged.object = 'instance';
1526
+ await applyTwinWrite(
1527
+ SERVICE,
1528
+ { operation: 'instance.update', subjectType: 'instance', subjectId: INSTANCE_ID, fields: { ...merged, updated_at: nowUnixMs(req.occurredAt) }, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } },
1529
+ req.root,
1530
+ );
1531
+ return instanceSettings(req.root);
1532
+ }
1533
+ // Render a membership in Clerk's shape: nested organization + public_user_data, with the
1534
+ // internal `_user_id` removed and exposed inside public_user_data.user_id. Resolves the raw
1535
+ // projected row by id so the `_user_id` link is reliable regardless of whether the caller
1536
+ // passes a raw row or a view()'d body.
1537
+ function expandMembership(input: Record<string, unknown>, root?: string): Record<string, unknown> {
1538
+ const raw = (input._user_id !== undefined ? input : projectResources(SERVICE, root).find((r) => r.id === input.id)) ?? input;
1539
+ const m = { ...input, ...raw };
1540
+ const userId = String(m._user_id ?? '');
1541
+ const user = getRow('user', userId, root);
1542
+ const userView = user ? view('user', user) : undefined;
1543
+ const org = getRow('organization', String(m.organization_id), root);
1544
+ const { _user_id, _deleted, ...rest } = m as Record<string, unknown> & { _user_id?: unknown; _deleted?: unknown };
1545
+ return {
1546
+ ...rest,
1547
+ public_user_data: {
1548
+ user_id: userId,
1549
+ first_name: userView?.first_name ?? null,
1550
+ last_name: userView?.last_name ?? null,
1551
+ image_url: userView?.image_url ?? '',
1552
+ has_image: false,
1553
+ identifier: emailIdsFor(userView ?? {})[0]?.email_address ?? null,
1554
+ },
1555
+ organization: org ? view('organization', org) : null,
1556
+ };
1557
+ }
1558
+
1559
+ // ── session token claims (the JWT payload Clerk puts in a session token) ──
1560
+ async function sessionTokenClaims(session: Record<string, unknown>, template: string | undefined, req: ClerkRequest): Promise<Record<string, unknown>> {
1561
+ const base: Record<string, unknown> = {
1562
+ // The authorized party is the Origin of the browser request that produced the token;
1563
+ // a Backend API mint has no browser behind it.
1564
+ azp: req.origin ?? 'http://localhost',
1565
+ sid: String(session.id),
1566
+ sub: String(session.user_id),
1567
+ iss: CLERK_TWIN_ISSUER,
1568
+ };
1569
+ if (!template) return base;
1570
+ const tmpl = rows('jwt_template', req.root).map((r) => view('jwt_template', r)).find((t) => t.name === template);
1571
+ const claims = tmpl && tmpl.claims && typeof tmpl.claims === 'object' ? tmpl.claims as Record<string, unknown> : {};
1572
+ return { ...base, ...claims };
1573
+ }
1574
+
1575
+ // Resource types the twin serves (for the conformance + mirror surfaces).
1576
+ export const CLERK_RESOURCE_TYPES = [
1577
+ 'user', 'session', 'organization', 'organization_membership', 'organization_invitation',
1578
+ 'invitation', 'allowlist_identifier', 'blocklist_identifier', 'jwt_template', 'sign_in_token', 'email_address',
1579
+ 'actor_token', 'redirect_url', 'oauth_application', 'saml_connection', 'domain', 'webhook_endpoint',
1580
+ ] as const;