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