PlatformWebhooks.store.ts313 lines · main
1import { useEffect, useState } from 'react'
2
3import { PLATFORM_WEBHOOKS_MOCK_DATA } from './PlatformWebhooks.mock'
4import type {
5 PlatformWebhooksState,
6 UpsertWebhookEndpointInput,
7 WebhookDelivery,
8 WebhookEndpoint,
9 WebhookScope,
10} from './PlatformWebhooks.types'
11import { getWebhookEndpointDisplayName } from './PlatformWebhooks.utils'
12
13interface CreateEndpointOptions {
14 now?: string
15 endpointId?: string
16 createdBy?: string
17 signingSecret?: string
18}
19
20interface UpdateEndpointOptions {
21 headerIdFactory?: () => string
22}
23
24interface RetryDeliveryOptions {
25 now?: string
26}
27
28const 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
45const randomId = (prefix: string) => `${prefix}-${secureRandomHex(8)}`
46const 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
59const generateSigningSecret = () => `whsec_${secureRandomHex(16)}`
60
61const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value))
62
63const 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
77const normalizeSearch = (value: string) => value.trim().toLowerCase()
78
79export 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
87const persistedMockStateByScope: Partial<Record<WebhookScope, PlatformWebhooksState>> = {}
88
89const 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.
99export 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
110export 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
139export 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
163export 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
170export 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
185export 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
199export 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
224export 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
235export 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
253export 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}