@automate.ax/integration-contracts 0.105.0 → 0.109.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.
- package/dist/airtable/api.js +6 -4
- package/dist/airtable/index.d.ts +997 -19
- package/dist/airtable/index.js +30 -2
- package/dist/airtable/schemas.d.ts +2034 -44
- package/dist/airtable/schemas.js +231 -47
- package/dist/brevo/api.d.ts +41 -0
- package/dist/brevo/api.js +74 -4
- package/dist/close/events.d.ts +13 -13
- package/dist/close/schemas.d.ts +9 -9
- package/dist/cloudflare/api.d.ts +93 -0
- package/dist/cloudflare/api.js +250 -0
- package/dist/cloudflare/events.d.ts +1340 -0
- package/dist/cloudflare/events.js +178 -0
- package/dist/cloudflare/index.d.ts +3 -0
- package/dist/cloudflare/index.js +3 -0
- package/dist/cloudflare/schemas.d.ts +608 -0
- package/dist/cloudflare/schemas.js +340 -0
- package/dist/linear/api.d.ts +1 -1
- package/dist/linear/api.js +9 -6
- package/dist/linear/index.d.ts +3479 -782
- package/dist/linear/index.js +249 -13
- package/dist/linear/schemas.d.ts +2724 -31
- package/dist/linear/schemas.js +515 -2
- package/dist/notion/schemas.d.ts +112 -112
- package/dist/outlook/schemas.d.ts +2 -2
- package/dist/resend/action-schemas.d.ts +9 -9
- package/dist/resend/index.d.ts +36 -36
- package/dist/resend/schemas.d.ts +40 -40
- package/dist/teams/schemas.d.ts +5 -5
- package/dist/triggers.d.ts +2 -1
- package/dist/whatsapp/index.d.ts +2 -2
- package/dist/whatsapp/schemas.d.ts +5 -5
- package/package.json +8 -2
- package/src/airtable/api.ts +7 -4
- package/src/airtable/index.ts +36 -1
- package/src/airtable/schemas.ts +294 -63
- package/src/brevo/api.ts +103 -8
- package/src/cloudflare/api.ts +340 -0
- package/src/cloudflare/events.ts +212 -0
- package/src/cloudflare/index.ts +3 -0
- package/src/cloudflare/schemas.ts +359 -0
- package/src/linear/api.ts +10 -6
- package/src/linear/index.ts +249 -26
- package/src/linear/schemas.ts +688 -2
- package/src/triggers.ts +2 -0
package/src/brevo/api.ts
CHANGED
|
@@ -10,6 +10,64 @@ const BREVO_ERROR_SCHEMA = z.looseObject({
|
|
|
10
10
|
message: z.string().optional(),
|
|
11
11
|
})
|
|
12
12
|
|
|
13
|
+
/** Provider rate-limit values returned with a Brevo response. */
|
|
14
|
+
export interface BrevoRateLimitMetadata {
|
|
15
|
+
/** Maximum requests allowed in the current window. */
|
|
16
|
+
limit?: number
|
|
17
|
+
|
|
18
|
+
/** Requests remaining in the current window. */
|
|
19
|
+
remaining?: number
|
|
20
|
+
|
|
21
|
+
/** Seconds until the current window resets. */
|
|
22
|
+
reset?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Structured failure returned by the Brevo API. */
|
|
26
|
+
export class BrevoApiError extends Error {
|
|
27
|
+
/** Provider error classification. */
|
|
28
|
+
readonly code?: string
|
|
29
|
+
|
|
30
|
+
/** API path that failed. */
|
|
31
|
+
readonly path: string
|
|
32
|
+
|
|
33
|
+
/** Provider rate-limit metadata. */
|
|
34
|
+
readonly rateLimit: BrevoRateLimitMetadata
|
|
35
|
+
|
|
36
|
+
/** Delay in seconds before the request should be retried. */
|
|
37
|
+
readonly retryAfter?: number
|
|
38
|
+
|
|
39
|
+
/** HTTP status returned by Brevo. */
|
|
40
|
+
readonly status: number
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Creates a structured Brevo provider error.
|
|
44
|
+
*
|
|
45
|
+
* @param options - Provider and transport failure details.
|
|
46
|
+
* @param options.code - Provider error classification.
|
|
47
|
+
* @param options.message - Human-readable provider failure.
|
|
48
|
+
* @param options.path - API path that failed.
|
|
49
|
+
* @param options.rateLimit - Provider rate-limit metadata.
|
|
50
|
+
* @param options.retryAfter - Provider retry delay in seconds.
|
|
51
|
+
* @param options.status - HTTP status.
|
|
52
|
+
*/
|
|
53
|
+
constructor(options: {
|
|
54
|
+
code?: string
|
|
55
|
+
message: string
|
|
56
|
+
path: string
|
|
57
|
+
rateLimit: BrevoRateLimitMetadata
|
|
58
|
+
retryAfter?: number
|
|
59
|
+
status: number
|
|
60
|
+
}) {
|
|
61
|
+
super(`Brevo ${options.path} failed: ${options.message}`)
|
|
62
|
+
this.name = "BrevoApiError"
|
|
63
|
+
this.code = options.code
|
|
64
|
+
this.path = options.path
|
|
65
|
+
this.rateLimit = options.rateLimit
|
|
66
|
+
this.retryAfter = options.retryAfter
|
|
67
|
+
this.status = options.status
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
13
71
|
/** Options accepted by the authenticated Brevo request helper. */
|
|
14
72
|
export interface BrevoRequestOptions extends RequestInit {
|
|
15
73
|
/** Query parameters appended to the request URL. */
|
|
@@ -63,14 +121,25 @@ export function getBrevoApi(secret: Record<string, unknown>) {
|
|
|
63
121
|
const response = await fetch(url, { ...requestInit, headers })
|
|
64
122
|
if (response.ok) return response
|
|
65
123
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
124
|
+
const responseText = await response.text()
|
|
125
|
+
const error = BREVO_ERROR_SCHEMA.safeParse(parseJson(responseText))
|
|
126
|
+
throw new BrevoApiError({
|
|
127
|
+
code: error.success ? error.data.code : undefined,
|
|
128
|
+
message:
|
|
129
|
+
(error.success ? error.data.message : undefined) ??
|
|
130
|
+
(responseText.trim() || `HTTP ${response.status}`),
|
|
131
|
+
path: `${url.pathname}${url.search}`,
|
|
132
|
+
rateLimit: {
|
|
133
|
+
limit: parseNumberHeader(response.headers, "x-sib-ratelimit-limit"),
|
|
134
|
+
remaining: parseNumberHeader(
|
|
135
|
+
response.headers,
|
|
136
|
+
"x-sib-ratelimit-remaining",
|
|
137
|
+
),
|
|
138
|
+
reset: parseNumberHeader(response.headers, "x-sib-ratelimit-reset"),
|
|
139
|
+
},
|
|
140
|
+
retryAfter: parseNumberHeader(response.headers, "retry-after"),
|
|
141
|
+
status: response.status,
|
|
142
|
+
})
|
|
74
143
|
},
|
|
75
144
|
}
|
|
76
145
|
}
|
|
@@ -87,3 +156,29 @@ export async function parseBrevoResponse<T extends z.ZodType>(
|
|
|
87
156
|
): Promise<z.output<T>> {
|
|
88
157
|
return schema.parse(await response.json())
|
|
89
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parses provider JSON without hiding non-JSON error bodies.
|
|
162
|
+
*
|
|
163
|
+
* @param value - Provider response text.
|
|
164
|
+
*/
|
|
165
|
+
function parseJson(value: string): unknown {
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(value)
|
|
168
|
+
} catch {
|
|
169
|
+
return undefined
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Parses an optional numeric HTTP header.
|
|
175
|
+
*
|
|
176
|
+
* @param headers - Provider response headers.
|
|
177
|
+
* @param name - Case-insensitive header name.
|
|
178
|
+
*/
|
|
179
|
+
function parseNumberHeader(headers: Headers, name: string) {
|
|
180
|
+
const value = headers.get(name)
|
|
181
|
+
if (value === null) return undefined
|
|
182
|
+
const parsed = Number(value)
|
|
183
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
184
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import type { Encodable } from "@automate.ax/codec"
|
|
2
|
+
import * as z from "zod"
|
|
3
|
+
|
|
4
|
+
const CLOUDFLARE_API_BASE_URL = "https://api.cloudflare.com/client/v4/"
|
|
5
|
+
const CLOUDFLARE_API_ORIGIN = new URL(CLOUDFLARE_API_BASE_URL).origin
|
|
6
|
+
const CLOUDFLARE_API_TOKEN_SECRET_SCHEMA = z.object({
|
|
7
|
+
apiKey: z.string().min(1),
|
|
8
|
+
})
|
|
9
|
+
const CLOUDFLARE_OAUTH_SECRET_SCHEMA = z.object({
|
|
10
|
+
accessToken: z.string().min(1),
|
|
11
|
+
})
|
|
12
|
+
const CLOUDFLARE_RESPONSE_INFO_SCHEMA = z.looseObject({
|
|
13
|
+
code: z.number().optional(),
|
|
14
|
+
documentation_url: z.string().optional(),
|
|
15
|
+
message: z.string(),
|
|
16
|
+
source: z.object({ pointer: z.string().optional() }).optional(),
|
|
17
|
+
})
|
|
18
|
+
const CLOUDFLARE_ENVELOPE_SCHEMA = z.looseObject({
|
|
19
|
+
errors: CLOUDFLARE_RESPONSE_INFO_SCHEMA.array().default([]),
|
|
20
|
+
messages: CLOUDFLARE_RESPONSE_INFO_SCHEMA.array().default([]),
|
|
21
|
+
result: z.unknown().optional(),
|
|
22
|
+
result_info: z
|
|
23
|
+
.looseObject({
|
|
24
|
+
count: z.number().int().nonnegative().optional(),
|
|
25
|
+
page: z.number().int().positive().optional(),
|
|
26
|
+
per_page: z.number().int().positive().optional(),
|
|
27
|
+
total_count: z.number().int().nonnegative().optional(),
|
|
28
|
+
total_pages: z.number().int().nonnegative().optional(),
|
|
29
|
+
})
|
|
30
|
+
.optional(),
|
|
31
|
+
success: z.boolean(),
|
|
32
|
+
})
|
|
33
|
+
const CLOUDFLARE_RESULT_INFO_SCHEMA = z.looseObject({
|
|
34
|
+
count: z.number().int().nonnegative().optional(),
|
|
35
|
+
page: z.number().int().positive().optional(),
|
|
36
|
+
perPage: z.number().int().positive().optional(),
|
|
37
|
+
totalCount: z.number().int().nonnegative().optional(),
|
|
38
|
+
totalPages: z.number().int().nonnegative().optional(),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** Scalar query value supported by the Cloudflare client. */
|
|
42
|
+
type CloudflareQueryValue = boolean | number | string | undefined
|
|
43
|
+
|
|
44
|
+
/** Resolved Cloudflare account accepted by the shared API client. */
|
|
45
|
+
export interface CloudflareResolvedAccount {
|
|
46
|
+
connectionMethodId: string
|
|
47
|
+
secret: Record<string, unknown>
|
|
48
|
+
serviceId: "cloudflare"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Options for one authenticated Cloudflare REST request. */
|
|
52
|
+
export interface CloudflareRequestOptions<TSchema extends z.ZodType> {
|
|
53
|
+
/** Public camelCase JSON body. */
|
|
54
|
+
body?: Encodable
|
|
55
|
+
|
|
56
|
+
/** HTTP verb. Defaults to `GET`. */
|
|
57
|
+
method?: "DELETE" | "GET" | "PATCH" | "POST" | "PUT"
|
|
58
|
+
|
|
59
|
+
/** Public camelCase query parameters. */
|
|
60
|
+
query?: Record<string, CloudflareQueryValue>
|
|
61
|
+
|
|
62
|
+
/** Schema for the normalized provider result. */
|
|
63
|
+
responseSchema: TSchema
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One normalized Cloudflare result page. */
|
|
67
|
+
export interface CloudflarePage<T> {
|
|
68
|
+
result: T
|
|
69
|
+
resultInfo?: {
|
|
70
|
+
count?: number
|
|
71
|
+
page?: number
|
|
72
|
+
perPage?: number
|
|
73
|
+
totalCount?: number
|
|
74
|
+
totalPages?: number
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Structured Cloudflare REST error. */
|
|
79
|
+
export class CloudflareApiError extends Error {
|
|
80
|
+
/** Provider error records. */
|
|
81
|
+
readonly errors: z.output<typeof CLOUDFLARE_RESPONSE_INFO_SCHEMA>[]
|
|
82
|
+
|
|
83
|
+
/** Parsed Cloudflare rate-limit header, when present. */
|
|
84
|
+
readonly rateLimit?: string
|
|
85
|
+
|
|
86
|
+
/** Parsed Cloudflare rate-limit policy header, when present. */
|
|
87
|
+
readonly rateLimitPolicy?: string
|
|
88
|
+
|
|
89
|
+
/** Retry delay in seconds, when Cloudflare returned one. */
|
|
90
|
+
readonly retryAfter?: number
|
|
91
|
+
|
|
92
|
+
/** HTTP status returned by Cloudflare. */
|
|
93
|
+
readonly status: number
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Creates a structured error from one Cloudflare response.
|
|
97
|
+
*
|
|
98
|
+
* @param options - Response status, provider errors, and limit metadata.
|
|
99
|
+
* @param options.errors - Provider error records.
|
|
100
|
+
* @param options.rateLimit - Current rate-limit header.
|
|
101
|
+
* @param options.rateLimitPolicy - Current rate-limit policy header.
|
|
102
|
+
* @param options.retryAfter - Retry delay in seconds.
|
|
103
|
+
* @param options.status - HTTP response status.
|
|
104
|
+
*/
|
|
105
|
+
constructor(options: {
|
|
106
|
+
errors?: z.output<typeof CLOUDFLARE_RESPONSE_INFO_SCHEMA>[]
|
|
107
|
+
rateLimit?: string
|
|
108
|
+
rateLimitPolicy?: string
|
|
109
|
+
retryAfter?: number
|
|
110
|
+
status: number
|
|
111
|
+
}) {
|
|
112
|
+
const errors = options.errors ?? []
|
|
113
|
+
super(
|
|
114
|
+
errors[0]?.message
|
|
115
|
+
? `Cloudflare API error (${options.status}): ${errors[0].message}`
|
|
116
|
+
: `Cloudflare API request failed with status ${options.status}.`,
|
|
117
|
+
)
|
|
118
|
+
this.name = "CloudflareApiError"
|
|
119
|
+
this.errors = errors
|
|
120
|
+
this.rateLimit = options.rateLimit
|
|
121
|
+
this.rateLimitPolicy = options.rateLimitPolicy
|
|
122
|
+
this.retryAfter = options.retryAfter
|
|
123
|
+
this.status = options.status
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Creates an authenticated Cloudflare REST client.
|
|
129
|
+
*
|
|
130
|
+
* @param account - Resolved OAuth or API-token account.
|
|
131
|
+
* @throws When the connection method or secret shape is unsupported.
|
|
132
|
+
*/
|
|
133
|
+
export function getCloudflareApi(account: CloudflareResolvedAccount) {
|
|
134
|
+
const accessToken =
|
|
135
|
+
account.connectionMethodId === "oauth"
|
|
136
|
+
? CLOUDFLARE_OAUTH_SECRET_SCHEMA.parse(account.secret).accessToken
|
|
137
|
+
: account.connectionMethodId === "api-token"
|
|
138
|
+
? CLOUDFLARE_API_TOKEN_SECRET_SCHEMA.parse(account.secret).apiKey
|
|
139
|
+
: undefined
|
|
140
|
+
if (!accessToken) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Unsupported Cloudflare connection method: ${account.connectionMethodId}`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Runs one request and returns its normalized result.
|
|
148
|
+
*
|
|
149
|
+
* @param path - Cloudflare API path relative to the v4 root.
|
|
150
|
+
* @param options - Method, parameters, and response schema.
|
|
151
|
+
*/
|
|
152
|
+
async function request<TSchema extends z.ZodType>(
|
|
153
|
+
path: string,
|
|
154
|
+
options: CloudflareRequestOptions<TSchema>,
|
|
155
|
+
): Promise<z.output<TSchema>> {
|
|
156
|
+
return (await requestPage(path, options)).result
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Runs one request and preserves Cloudflare page metadata.
|
|
161
|
+
*
|
|
162
|
+
* @param path - Cloudflare API path relative to the v4 root.
|
|
163
|
+
* @param options - Method, parameters, and response schema.
|
|
164
|
+
*/
|
|
165
|
+
async function requestPage<TSchema extends z.ZodType>(
|
|
166
|
+
path: string,
|
|
167
|
+
options: CloudflareRequestOptions<TSchema>,
|
|
168
|
+
): Promise<CloudflarePage<z.output<TSchema>>> {
|
|
169
|
+
const url = new URL(path.replace(/^\//, ""), CLOUDFLARE_API_BASE_URL)
|
|
170
|
+
if (url.origin !== CLOUDFLARE_API_ORIGIN) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"Cloudflare API paths must use the Cloudflare API origin.",
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
for (const [name, value] of Object.entries(options.query ?? {})) {
|
|
176
|
+
if (value !== undefined)
|
|
177
|
+
url.searchParams.set(toSnakeCase(name), String(value))
|
|
178
|
+
}
|
|
179
|
+
const response = await fetch(url, {
|
|
180
|
+
body:
|
|
181
|
+
options.body === undefined
|
|
182
|
+
? undefined
|
|
183
|
+
: JSON.stringify(toCloudflare(options.body)),
|
|
184
|
+
headers: {
|
|
185
|
+
Accept: "application/json",
|
|
186
|
+
Authorization: `Bearer ${accessToken}`,
|
|
187
|
+
...(options.body === undefined
|
|
188
|
+
? {}
|
|
189
|
+
: { "Content-Type": "application/json" }),
|
|
190
|
+
},
|
|
191
|
+
method: options.method ?? "GET",
|
|
192
|
+
})
|
|
193
|
+
const parsed = CLOUDFLARE_ENVELOPE_SCHEMA.safeParse(
|
|
194
|
+
parseJson(await response.text()),
|
|
195
|
+
)
|
|
196
|
+
if (!response.ok || !parsed.success || !parsed.data.success) {
|
|
197
|
+
throw new CloudflareApiError({
|
|
198
|
+
errors: parsed.success ? parsed.data.errors : undefined,
|
|
199
|
+
rateLimit: response.headers.get("Ratelimit") ?? undefined,
|
|
200
|
+
rateLimitPolicy: response.headers.get("Ratelimit-Policy") ?? undefined,
|
|
201
|
+
retryAfter: parseFiniteNumber(response.headers.get("Retry-After")),
|
|
202
|
+
status: response.status,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
result: options.responseSchema.parse(fromCloudflare(parsed.data.result)),
|
|
207
|
+
...(parsed.data.result_info && {
|
|
208
|
+
resultInfo: CLOUDFLARE_RESULT_INFO_SCHEMA.parse(
|
|
209
|
+
fromCloudflare(parsed.data.result_info),
|
|
210
|
+
),
|
|
211
|
+
}),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { request, requestPage }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Converts public camelCase JSON to Cloudflare wire keys.
|
|
220
|
+
*
|
|
221
|
+
* @param value - Codec-safe public value.
|
|
222
|
+
*/
|
|
223
|
+
export function toCloudflare(value: Encodable): Encodable {
|
|
224
|
+
return convertToCloudflare(value, false)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Converts one value while optionally preserving object keys.
|
|
229
|
+
*
|
|
230
|
+
* @param value - Public value to convert.
|
|
231
|
+
* @param preserveKeys - Whether this object is a provider-defined map.
|
|
232
|
+
*/
|
|
233
|
+
function convertToCloudflare(
|
|
234
|
+
value: Encodable,
|
|
235
|
+
preserveKeys: boolean,
|
|
236
|
+
): Encodable {
|
|
237
|
+
if (value instanceof Date) return value.toISOString()
|
|
238
|
+
if (Array.isArray(value))
|
|
239
|
+
return value.map((item) => convertToCloudflare(item, preserveKeys))
|
|
240
|
+
if (!isPlainObject(value)) return value
|
|
241
|
+
return Object.fromEntries(
|
|
242
|
+
Object.entries(value).map(([key, item]) => [
|
|
243
|
+
preserveKeys ? key : toSnakeCase(key),
|
|
244
|
+
convertToCloudflare(item, key === "header" || key === "headers"),
|
|
245
|
+
]),
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Converts Cloudflare wire JSON to public camelCase keys.
|
|
251
|
+
*
|
|
252
|
+
* @param value - Untrusted provider JSON value.
|
|
253
|
+
*/
|
|
254
|
+
export function fromCloudflare(value: unknown): Encodable {
|
|
255
|
+
return convertFromCloudflare(value, false)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Converts one provider value while optionally preserving object keys.
|
|
260
|
+
*
|
|
261
|
+
* @param value - Provider value to convert.
|
|
262
|
+
* @param preserveKeys - Whether this object is a provider-defined map.
|
|
263
|
+
*/
|
|
264
|
+
function convertFromCloudflare(
|
|
265
|
+
value: unknown,
|
|
266
|
+
preserveKeys: boolean,
|
|
267
|
+
): Encodable {
|
|
268
|
+
if (Array.isArray(value))
|
|
269
|
+
return value.map((item) => convertFromCloudflare(item, preserveKeys))
|
|
270
|
+
if (!isPlainObject(value)) return CLOUDFLARE_VALUE_SCHEMA.parse(value)
|
|
271
|
+
return Object.fromEntries(
|
|
272
|
+
Object.entries(value).map(([key, item]) => [
|
|
273
|
+
preserveKeys ? key : toCamelCase(key),
|
|
274
|
+
convertFromCloudflare(item, key === "header" || key === "headers"),
|
|
275
|
+
]),
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const CLOUDFLARE_VALUE_SCHEMA = z.union([
|
|
280
|
+
z.boolean(),
|
|
281
|
+
z.number(),
|
|
282
|
+
z.string(),
|
|
283
|
+
z.null(),
|
|
284
|
+
z.undefined(),
|
|
285
|
+
])
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Returns whether a value is a plain JSON object.
|
|
289
|
+
*
|
|
290
|
+
* @param value - Candidate value.
|
|
291
|
+
*/
|
|
292
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
293
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Parses JSON without obscuring the eventual structured API error.
|
|
298
|
+
*
|
|
299
|
+
* @param value - Raw response text.
|
|
300
|
+
*/
|
|
301
|
+
function parseJson(value: string): unknown {
|
|
302
|
+
try {
|
|
303
|
+
return JSON.parse(value)
|
|
304
|
+
} catch {
|
|
305
|
+
return undefined
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Parses a finite numeric response header.
|
|
311
|
+
*
|
|
312
|
+
* @param value - Header value.
|
|
313
|
+
*/
|
|
314
|
+
function parseFiniteNumber(value: string | null) {
|
|
315
|
+
if (value === null) return undefined
|
|
316
|
+
const number = Number(value)
|
|
317
|
+
return Number.isFinite(number) ? number : undefined
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Converts one Cloudflare snake_case key to camelCase.
|
|
322
|
+
*
|
|
323
|
+
* @param value - Provider key.
|
|
324
|
+
*/
|
|
325
|
+
function toCamelCase(value: string) {
|
|
326
|
+
return value.replace(/_([a-z0-9])/g, (_match, character: string) =>
|
|
327
|
+
character.toUpperCase(),
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Converts one public camelCase key to snake_case.
|
|
333
|
+
*
|
|
334
|
+
* @param value - Public key.
|
|
335
|
+
*/
|
|
336
|
+
function toSnakeCase(value: string) {
|
|
337
|
+
return value
|
|
338
|
+
.replace(/([a-zA-Z])([0-9])/g, "$1_$2")
|
|
339
|
+
.replace(/[A-Z]/g, (character) => `_${character.toLowerCase()}`)
|
|
340
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import * as z from "zod"
|
|
2
|
+
import { encodableSchema } from "@automate.ax/codec"
|
|
3
|
+
import { fromCloudflare } from "./api"
|
|
4
|
+
import {
|
|
5
|
+
CLOUDFLARE_ALERT_TYPE_SCHEMA,
|
|
6
|
+
CLOUDFLARE_NOTIFICATION_FILTERS_SCHEMA,
|
|
7
|
+
} from "./schemas"
|
|
8
|
+
|
|
9
|
+
const PROVIDER_EVENT_SCHEMA = z.looseObject({
|
|
10
|
+
account_id: z.string().optional(),
|
|
11
|
+
alert_correlation_id: z.string().optional(),
|
|
12
|
+
alert_event: z.string().optional(),
|
|
13
|
+
alert_type: CLOUDFLARE_ALERT_TYPE_SCHEMA.optional(),
|
|
14
|
+
data: z.record(z.string(), z.unknown()).optional(),
|
|
15
|
+
name: z.string().optional(),
|
|
16
|
+
policy_id: z.string().optional(),
|
|
17
|
+
policy_name: z.string().optional(),
|
|
18
|
+
text: z.string().optional(),
|
|
19
|
+
ts: z.union([z.number(), z.string()]).optional(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export const CLOUDFLARE_EVENT_SCHEMA = z.object({
|
|
23
|
+
accountId: z.string().optional(),
|
|
24
|
+
alertCorrelationId: z.string().optional(),
|
|
25
|
+
alertEvent: z.string().optional(),
|
|
26
|
+
alertType: CLOUDFLARE_ALERT_TYPE_SCHEMA,
|
|
27
|
+
data: z.record(z.string(), encodableSchema).prefault({}),
|
|
28
|
+
name: z.string().optional(),
|
|
29
|
+
policyId: z.string().optional(),
|
|
30
|
+
policyName: z.string().optional(),
|
|
31
|
+
text: z.string().optional(),
|
|
32
|
+
timestamp: z.union([z.number(), z.string()]).optional(),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const LAYER_4_DDOS_DATA_SCHEMA = z.object({
|
|
36
|
+
accountName: z.string().optional(),
|
|
37
|
+
accountTag: z.string().optional(),
|
|
38
|
+
action: z.string().optional(),
|
|
39
|
+
attackId: z.string().optional(),
|
|
40
|
+
attackVector: z.string().optional(),
|
|
41
|
+
dashboardLink: z.string().optional(),
|
|
42
|
+
maxRate: z.string().optional(),
|
|
43
|
+
megabitsPerSecond: z.number().optional(),
|
|
44
|
+
mitigation: z.string().optional(),
|
|
45
|
+
packetsPerSecond: z.number().optional(),
|
|
46
|
+
protocol: z.string().optional(),
|
|
47
|
+
ruleDescription: z.string().optional(),
|
|
48
|
+
ruleId: z.string().optional(),
|
|
49
|
+
ruleName: z.string().optional(),
|
|
50
|
+
rulesetId: z.string().optional(),
|
|
51
|
+
rulesetOverrideId: z.string().optional(),
|
|
52
|
+
startTime: z.string().optional(),
|
|
53
|
+
targetId: z.string().optional(),
|
|
54
|
+
targetIp: z.string().optional(),
|
|
55
|
+
targetPort: z.number().optional(),
|
|
56
|
+
})
|
|
57
|
+
const LAYER_7_DDOS_DATA_SCHEMA = z.object({
|
|
58
|
+
accountName: z.string().optional(),
|
|
59
|
+
accountTag: z.string().optional(),
|
|
60
|
+
action: z.string().optional(),
|
|
61
|
+
attackId: z.string().optional(),
|
|
62
|
+
attackType: z.string().optional(),
|
|
63
|
+
dashboardLink: z.string().optional(),
|
|
64
|
+
maxRate: z.string().optional(),
|
|
65
|
+
mitigation: z.string().optional(),
|
|
66
|
+
requestsPerSecond: z.number().optional(),
|
|
67
|
+
ruleDescription: z.string().optional(),
|
|
68
|
+
ruleId: z.string().optional(),
|
|
69
|
+
ruleLink: z.string().optional(),
|
|
70
|
+
rulesetId: z.string().optional(),
|
|
71
|
+
rulesetOverrideId: z.string().optional(),
|
|
72
|
+
startTime: z.string().optional(),
|
|
73
|
+
targetHostname: z.string().optional(),
|
|
74
|
+
zoneName: z.string().optional(),
|
|
75
|
+
zoneTag: z.string().optional(),
|
|
76
|
+
})
|
|
77
|
+
const SSL_DATA_SCHEMA = z.object({
|
|
78
|
+
accountName: z.string().optional(),
|
|
79
|
+
accountTag: z.string().optional(),
|
|
80
|
+
certificateId: z.string().optional(),
|
|
81
|
+
certificatePackId: z.string().optional(),
|
|
82
|
+
certificateStatus: z.string().optional(),
|
|
83
|
+
eventType: z.string().optional(),
|
|
84
|
+
hostnames: z.string().optional(),
|
|
85
|
+
packCa: z.string().optional(),
|
|
86
|
+
packId: z.string().optional(),
|
|
87
|
+
packStatus: z.string().optional(),
|
|
88
|
+
packValidation: z.string().optional(),
|
|
89
|
+
zoneName: z.string().optional(),
|
|
90
|
+
zoneTag: z.string().optional(),
|
|
91
|
+
})
|
|
92
|
+
const HEALTH_CHECK_DATA_SCHEMA = z.object({
|
|
93
|
+
accountName: z.string().optional(),
|
|
94
|
+
accountTag: z.string().optional(),
|
|
95
|
+
failingRegions: z.string().optional(),
|
|
96
|
+
healthCheckId: z.string().optional(),
|
|
97
|
+
healthCheckName: z.string().optional(),
|
|
98
|
+
newHealthStatus: z.string().optional(),
|
|
99
|
+
newStatus: z.string().optional(),
|
|
100
|
+
oldStatus: z.string().optional(),
|
|
101
|
+
originIp: z.string().optional(),
|
|
102
|
+
reason: z.string().optional(),
|
|
103
|
+
statusChangeTime: z.string().optional(),
|
|
104
|
+
timeSinceLastFailure: z.string().optional(),
|
|
105
|
+
zoneName: z.string().optional(),
|
|
106
|
+
zoneTag: z.string().optional(),
|
|
107
|
+
})
|
|
108
|
+
const ACCESS_CERTIFICATE_DATA_SCHEMA = z.object({
|
|
109
|
+
accountName: z.string().optional(),
|
|
110
|
+
accountTag: z.string().optional(),
|
|
111
|
+
certificateId: z.string().optional(),
|
|
112
|
+
daysTilExpiration: z.number().optional(),
|
|
113
|
+
hostnames: z.string().optional(),
|
|
114
|
+
zoneName: z.string().optional(),
|
|
115
|
+
zoneTag: z.string().optional(),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Builds one alert-type-specific Cloudflare event schema.
|
|
120
|
+
*
|
|
121
|
+
* @param alertType - Exact Cloudflare policy alert type.
|
|
122
|
+
* @param data - Stable documented alert data schema.
|
|
123
|
+
*/
|
|
124
|
+
function semanticEvent<
|
|
125
|
+
TType extends z.ZodLiteral<string>,
|
|
126
|
+
TData extends z.ZodType,
|
|
127
|
+
>(alertType: TType, data: TData) {
|
|
128
|
+
return CLOUDFLARE_EVENT_SCHEMA.extend({ alertType, data })
|
|
129
|
+
}
|
|
130
|
+
export const CLOUDFLARE_LAYER_4_DDOS_EVENT_SCHEMA = semanticEvent(
|
|
131
|
+
z.literal("advanced_ddos_attack_l4_alert"),
|
|
132
|
+
LAYER_4_DDOS_DATA_SCHEMA,
|
|
133
|
+
)
|
|
134
|
+
export const CLOUDFLARE_LAYER_7_DDOS_EVENT_SCHEMA = semanticEvent(
|
|
135
|
+
z.literal("advanced_ddos_attack_l7_alert"),
|
|
136
|
+
LAYER_7_DDOS_DATA_SCHEMA,
|
|
137
|
+
)
|
|
138
|
+
export const CLOUDFLARE_SSL_CERTIFICATE_EVENT_SCHEMA = semanticEvent(
|
|
139
|
+
z.literal("dedicated_ssl_certificate_event_type"),
|
|
140
|
+
SSL_DATA_SCHEMA,
|
|
141
|
+
)
|
|
142
|
+
export const CLOUDFLARE_ORIGIN_HEALTH_CHECK_EVENT_SCHEMA = semanticEvent(
|
|
143
|
+
z.literal("health_check_status_notification"),
|
|
144
|
+
HEALTH_CHECK_DATA_SCHEMA,
|
|
145
|
+
)
|
|
146
|
+
export const CLOUDFLARE_ACCESS_CERTIFICATE_EVENT_SCHEMA = semanticEvent(
|
|
147
|
+
z.literal("access_custom_certificate_expiration_type"),
|
|
148
|
+
ACCESS_CERTIFICATE_DATA_SCHEMA,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
export const CLOUDFLARE_TRIGGER_CONFIG_SCHEMA = z.object({
|
|
152
|
+
accountId: z.string().trim().min(1),
|
|
153
|
+
alertTypes: CLOUDFLARE_ALERT_TYPE_SCHEMA.array()
|
|
154
|
+
.min(1)
|
|
155
|
+
.refine(
|
|
156
|
+
(alertTypes) => new Set(alertTypes).size === alertTypes.length,
|
|
157
|
+
"Choose each alert type once.",
|
|
158
|
+
),
|
|
159
|
+
filters: CLOUDFLARE_NOTIFICATION_FILTERS_SCHEMA.optional(),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
export const cloudflareTriggerContracts = {
|
|
163
|
+
"cloudflare.accessCertificate.expiring": {
|
|
164
|
+
configSchema: CLOUDFLARE_TRIGGER_CONFIG_SCHEMA,
|
|
165
|
+
eventSchema: CLOUDFLARE_ACCESS_CERTIFICATE_EVENT_SCHEMA,
|
|
166
|
+
},
|
|
167
|
+
"cloudflare.layer4DdosAttack": {
|
|
168
|
+
configSchema: CLOUDFLARE_TRIGGER_CONFIG_SCHEMA,
|
|
169
|
+
eventSchema: CLOUDFLARE_LAYER_4_DDOS_EVENT_SCHEMA,
|
|
170
|
+
},
|
|
171
|
+
"cloudflare.layer7DdosAttack": {
|
|
172
|
+
configSchema: CLOUDFLARE_TRIGGER_CONFIG_SCHEMA,
|
|
173
|
+
eventSchema: CLOUDFLARE_LAYER_7_DDOS_EVENT_SCHEMA,
|
|
174
|
+
},
|
|
175
|
+
"cloudflare.notification": {
|
|
176
|
+
configSchema: CLOUDFLARE_TRIGGER_CONFIG_SCHEMA,
|
|
177
|
+
eventSchema: CLOUDFLARE_EVENT_SCHEMA,
|
|
178
|
+
},
|
|
179
|
+
"cloudflare.originHealthCheckStatusChanged": {
|
|
180
|
+
configSchema: CLOUDFLARE_TRIGGER_CONFIG_SCHEMA,
|
|
181
|
+
eventSchema: CLOUDFLARE_ORIGIN_HEALTH_CHECK_EVENT_SCHEMA,
|
|
182
|
+
},
|
|
183
|
+
"cloudflare.sslCertificate.event": {
|
|
184
|
+
configSchema: CLOUDFLARE_TRIGGER_CONFIG_SCHEMA,
|
|
185
|
+
eventSchema: CLOUDFLARE_SSL_CERTIFICATE_EVENT_SCHEMA,
|
|
186
|
+
},
|
|
187
|
+
} as const
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Normalizes an authenticated Cloudflare notification webhook.
|
|
191
|
+
*
|
|
192
|
+
* @param value - Untrusted provider webhook payload.
|
|
193
|
+
* @param expectedAlertType - Alert type owned by the authenticated source.
|
|
194
|
+
*/
|
|
195
|
+
export function normalizeCloudflareEvent(
|
|
196
|
+
value: unknown,
|
|
197
|
+
expectedAlertType?: z.output<typeof CLOUDFLARE_ALERT_TYPE_SCHEMA>,
|
|
198
|
+
) {
|
|
199
|
+
const event = PROVIDER_EVENT_SCHEMA.parse(value)
|
|
200
|
+
return CLOUDFLARE_EVENT_SCHEMA.parse({
|
|
201
|
+
accountId: event.account_id,
|
|
202
|
+
alertCorrelationId: event.alert_correlation_id,
|
|
203
|
+
alertEvent: event.alert_event,
|
|
204
|
+
alertType: event.alert_type ?? expectedAlertType,
|
|
205
|
+
data: fromCloudflare(event.data ?? {}),
|
|
206
|
+
name: event.name,
|
|
207
|
+
policyId: event.policy_id,
|
|
208
|
+
policyName: event.policy_name,
|
|
209
|
+
text: event.text,
|
|
210
|
+
timestamp: event.ts,
|
|
211
|
+
})
|
|
212
|
+
}
|