PlatformWebhooks.store.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import { useEffect, useState } from 'react'
  2. import { PLATFORM_WEBHOOKS_MOCK_DATA } from './PlatformWebhooks.mock'
  3. import type {
  4. PlatformWebhooksState,
  5. UpsertWebhookEndpointInput,
  6. WebhookDelivery,
  7. WebhookEndpoint,
  8. WebhookScope,
  9. } from './PlatformWebhooks.types'
  10. import { getWebhookEndpointDisplayName } from './PlatformWebhooks.utils'
  11. interface CreateEndpointOptions {
  12. now?: string
  13. endpointId?: string
  14. createdBy?: string
  15. signingSecret?: string
  16. }
  17. interface UpdateEndpointOptions {
  18. headerIdFactory?: () => string
  19. }
  20. interface RetryDeliveryOptions {
  21. now?: string
  22. }
  23. const secureRandomHex = (length: number) => {
  24. if (length <= 0) return ''
  25. const cryptoApi = globalThis.crypto
  26. if (!cryptoApi?.getRandomValues) {
  27. throw new Error('Web Crypto API is not available')
  28. }
  29. const byteCount = Math.ceil(length / 2)
  30. const bytes = new Uint8Array(byteCount)
  31. cryptoApi.getRandomValues(bytes)
  32. return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'))
  33. .join('')
  34. .slice(0, length)
  35. }
  36. const randomId = (prefix: string) => `${prefix}-${secureRandomHex(8)}`
  37. const randomUuid = () => {
  38. const cryptoApi = globalThis.crypto
  39. if (cryptoApi?.randomUUID) return cryptoApi.randomUUID()
  40. return [
  41. secureRandomHex(8),
  42. secureRandomHex(4),
  43. `4${secureRandomHex(3)}`,
  44. `${((parseInt(secureRandomHex(2), 16) & 0x3f) | 0x80).toString(16)}${secureRandomHex(2)}`,
  45. secureRandomHex(12),
  46. ].join('-')
  47. }
  48. const generateSigningSecret = () => `whsec_${secureRandomHex(16)}`
  49. const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value))
  50. const toHeaders = (
  51. headers: UpsertWebhookEndpointInput['customHeaders'],
  52. options?: UpdateEndpointOptions
  53. ) => {
  54. const headerIdFactory = options?.headerIdFactory ?? (() => randomId('header'))
  55. return headers
  56. .map((header) => ({
  57. id: headerIdFactory(),
  58. key: header.key.trim(),
  59. value: header.value.trim(),
  60. }))
  61. .filter((header) => header.key.length > 0 && header.value.length > 0)
  62. }
  63. const normalizeSearch = (value: string) => value.trim().toLowerCase()
  64. export const createInitialPlatformWebhooksState = (scope: WebhookScope): PlatformWebhooksState => {
  65. const seed = PLATFORM_WEBHOOKS_MOCK_DATA[scope]
  66. return {
  67. endpoints: deepClone(seed.endpoints),
  68. deliveries: deepClone(seed.deliveries),
  69. }
  70. }
  71. const persistedMockStateByScope: Partial<Record<WebhookScope, PlatformWebhooksState>> = {}
  72. const getPersistedMockState = (scope: WebhookScope) => {
  73. const persistedState = persistedMockStateByScope[scope]
  74. if (persistedState) return persistedState
  75. const initialState = createInitialPlatformWebhooksState(scope)
  76. persistedMockStateByScope[scope] = initialState
  77. return initialState
  78. }
  79. // Test-only helper to avoid cross-test state leakage in hook tests.
  80. export const resetPlatformWebhooksMockStateForTests = (scope?: WebhookScope) => {
  81. if (scope) {
  82. delete persistedMockStateByScope[scope]
  83. return
  84. }
  85. for (const key of Object.keys(persistedMockStateByScope) as WebhookScope[]) {
  86. delete persistedMockStateByScope[key]
  87. }
  88. }
  89. export const createWebhookEndpoint = (
  90. state: PlatformWebhooksState,
  91. input: UpsertWebhookEndpointInput,
  92. options?: CreateEndpointOptions
  93. ): { state: PlatformWebhooksState; endpoint: WebhookEndpoint; signingSecret: string } => {
  94. const endpointId = options?.endpointId ?? randomUuid()
  95. const signingSecret = options?.signingSecret ?? generateSigningSecret()
  96. const endpoint: WebhookEndpoint = {
  97. id: endpointId,
  98. name: input.name.trim(),
  99. url: input.url.trim(),
  100. description: input.description.trim(),
  101. enabled: input.enabled,
  102. eventTypes: input.eventTypes.length > 0 ? input.eventTypes : ['*'],
  103. customHeaders: toHeaders(input.customHeaders),
  104. createdBy: options?.createdBy ?? 'mock-user@supabase.io',
  105. createdAt: options?.now ?? new Date().toISOString(),
  106. }
  107. return {
  108. endpoint,
  109. signingSecret,
  110. state: {
  111. ...state,
  112. endpoints: [endpoint, ...state.endpoints],
  113. },
  114. }
  115. }
  116. export const updateWebhookEndpoint = (
  117. state: PlatformWebhooksState,
  118. endpointId: string,
  119. input: UpsertWebhookEndpointInput,
  120. options?: UpdateEndpointOptions
  121. ) => {
  122. return {
  123. ...state,
  124. endpoints: state.endpoints.map((endpoint) =>
  125. endpoint.id === endpointId
  126. ? {
  127. ...endpoint,
  128. name: input.name.trim(),
  129. url: input.url.trim(),
  130. description: input.description.trim(),
  131. enabled: input.enabled,
  132. eventTypes: input.eventTypes.length > 0 ? input.eventTypes : ['*'],
  133. customHeaders: toHeaders(input.customHeaders, options),
  134. }
  135. : endpoint
  136. ),
  137. }
  138. }
  139. export const deleteWebhookEndpoint = (state: PlatformWebhooksState, endpointId: string) => {
  140. return {
  141. endpoints: state.endpoints.filter((endpoint) => endpoint.id !== endpointId),
  142. deliveries: state.deliveries.filter((delivery) => delivery.endpointId !== endpointId),
  143. }
  144. }
  145. export const toggleWebhookEndpoint = (
  146. state: PlatformWebhooksState,
  147. endpointId: string,
  148. enabled?: boolean
  149. ) => {
  150. return {
  151. ...state,
  152. endpoints: state.endpoints.map((endpoint) =>
  153. endpoint.id === endpointId
  154. ? { ...endpoint, enabled: enabled === undefined ? !endpoint.enabled : enabled }
  155. : endpoint
  156. ),
  157. }
  158. }
  159. export const regenerateWebhookEndpointSecret = (
  160. state: PlatformWebhooksState,
  161. endpointId: string,
  162. secret?: string
  163. ) => {
  164. const endpointExists = state.endpoints.some((endpoint) => endpoint.id === endpointId)
  165. if (!endpointExists) return { state, signingSecret: null }
  166. return {
  167. state: { ...state },
  168. signingSecret: secret ?? generateSigningSecret(),
  169. }
  170. }
  171. export const retryWebhookDelivery = (
  172. state: PlatformWebhooksState,
  173. deliveryId: string,
  174. options?: RetryDeliveryOptions
  175. ) => {
  176. const delivery = state.deliveries.find((item) => item.id === deliveryId)
  177. if (!delivery || delivery.status === 'success') return state
  178. const now = options?.now ?? new Date().toISOString()
  179. return {
  180. ...state,
  181. deliveries: state.deliveries.map<WebhookDelivery>((delivery) => {
  182. if (delivery.id !== deliveryId) return delivery
  183. return {
  184. ...delivery,
  185. status: 'pending',
  186. responseCode: undefined,
  187. attemptAt: now,
  188. }
  189. }),
  190. }
  191. }
  192. export const filterWebhookEndpoints = (endpoints: WebhookEndpoint[], search: string) => {
  193. const normalizedSearch = normalizeSearch(search)
  194. if (normalizedSearch.length === 0) return endpoints
  195. return endpoints.filter((endpoint) => {
  196. const haystack =
  197. `${getWebhookEndpointDisplayName(endpoint)} ${endpoint.url} ${endpoint.description}`.toLowerCase()
  198. return haystack.includes(normalizedSearch)
  199. })
  200. }
  201. export const filterWebhookDeliveries = (
  202. deliveries: WebhookDelivery[],
  203. endpointId: string,
  204. search: string
  205. ) => {
  206. const normalizedSearch = normalizeSearch(search)
  207. return deliveries
  208. .filter((delivery) => delivery.endpointId === endpointId)
  209. .filter((delivery) => {
  210. if (normalizedSearch.length === 0) return true
  211. const haystack =
  212. `${delivery.eventType} ${delivery.status} ${delivery.responseCode ?? ''}`.toLowerCase()
  213. return haystack.includes(normalizedSearch)
  214. })
  215. .sort((a, b) => new Date(b.attemptAt).getTime() - new Date(a.attemptAt).getTime())
  216. }
  217. export const usePlatformWebhooksMockStore = (scope: WebhookScope) => {
  218. const [state, setState] = useState<PlatformWebhooksState>(() =>
  219. deepClone(getPersistedMockState(scope))
  220. )
  221. useEffect(() => {
  222. setState(deepClone(getPersistedMockState(scope)))
  223. }, [scope])
  224. const applyStateUpdate = (
  225. updater: (previous: PlatformWebhooksState) => PlatformWebhooksState
  226. ) => {
  227. setState((previous) => {
  228. const next = updater(previous)
  229. persistedMockStateByScope[scope] = next
  230. return next
  231. })
  232. }
  233. return {
  234. ...state,
  235. createEndpoint: (input: UpsertWebhookEndpointInput) => {
  236. const endpointId = randomUuid()
  237. const now = new Date().toISOString()
  238. const signingSecret = generateSigningSecret()
  239. const createdBy = 'mock-user@supabase.io'
  240. let createdSecret = signingSecret
  241. applyStateUpdate((prev) => {
  242. const next = createWebhookEndpoint(prev, input, {
  243. endpointId,
  244. now,
  245. signingSecret,
  246. createdBy,
  247. })
  248. createdSecret = next.signingSecret
  249. return next.state
  250. })
  251. return { endpointId, signingSecret: createdSecret }
  252. },
  253. updateEndpoint: (endpointId: string, input: UpsertWebhookEndpointInput) => {
  254. applyStateUpdate((prev) => updateWebhookEndpoint(prev, endpointId, input))
  255. },
  256. deleteEndpoint: (endpointId: string) => {
  257. applyStateUpdate((prev) => deleteWebhookEndpoint(prev, endpointId))
  258. },
  259. toggleEndpoint: (endpointId: string, enabled?: boolean) => {
  260. applyStateUpdate((prev) => toggleWebhookEndpoint(prev, endpointId, enabled))
  261. },
  262. regenerateSecret: (endpointId: string) => {
  263. const currentState = persistedMockStateByScope[scope] ?? state
  264. const next = regenerateWebhookEndpointSecret(currentState, endpointId)
  265. if (!next.signingSecret) return null
  266. applyStateUpdate(() => next.state)
  267. return next.signingSecret
  268. },
  269. retryDelivery: (deliveryId: string) => {
  270. applyStateUpdate((prev) => retryWebhookDelivery(prev, deliveryId))
  271. },
  272. }
  273. }