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