@fragno-dev/api-fragment 0.0.0-stage → 0.0.1
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/LICENSE.md +16 -0
- package/README.md +249 -2
- package/dist/browser/client/client-3ZV9sw3i.js +2580 -0
- package/dist/browser/client/client-3ZV9sw3i.js.map +1 -0
- package/dist/browser/client/client-DPjmoaJQ.d.ts +711 -0
- package/dist/browser/client/client-DPjmoaJQ.d.ts.map +1 -0
- package/dist/browser/client/client.d.ts +1645 -0
- package/dist/browser/client/client.d.ts.map +1 -0
- package/dist/browser/client/client.js +2 -0
- package/dist/browser/client/react.d.ts +1665 -0
- package/dist/browser/client/react.d.ts.map +1 -0
- package/dist/browser/client/react.js +194 -0
- package/dist/browser/client/react.js.map +1 -0
- package/dist/browser/client/solid.d.ts +1668 -0
- package/dist/browser/client/solid.d.ts.map +1 -0
- package/dist/browser/client/solid.js +129 -0
- package/dist/browser/client/solid.js.map +1 -0
- package/dist/browser/client/svelte.d.ts +1668 -0
- package/dist/browser/client/svelte.d.ts.map +1 -0
- package/dist/browser/client/svelte.js +131 -0
- package/dist/browser/client/svelte.js.map +1 -0
- package/dist/browser/client/vanilla.d.ts +1695 -0
- package/dist/browser/client/vanilla.d.ts.map +1 -0
- package/dist/browser/client/vanilla.js +159 -0
- package/dist/browser/client/vanilla.js.map +1 -0
- package/dist/browser/client/vue.d.ts +1668 -0
- package/dist/browser/client/vue.d.ts.map +1 -0
- package/dist/browser/client/vue.js +127 -0
- package/dist/browser/client/vue.js.map +1 -0
- package/dist/browser/client-D4NnY-zV.d.ts +713 -0
- package/dist/browser/client-D4NnY-zV.d.ts.map +1 -0
- package/dist/browser/client-DQE0c7GI.d.ts +711 -0
- package/dist/browser/client-DQE0c7GI.d.ts.map +1 -0
- package/dist/browser/index.d.ts +3130 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/src-BrYPVbAN.js +2404 -0
- package/dist/browser/src-BrYPVbAN.js.map +1 -0
- package/dist/browser/src-t-4wp9-f.js +2400 -0
- package/dist/browser/src-t-4wp9-f.js.map +1 -0
- package/dist/node/api-types.d.ts +731 -0
- package/dist/node/api-types.d.ts.map +1 -0
- package/dist/node/api-types.js +281 -0
- package/dist/node/api-types.js.map +1 -0
- package/dist/node/crypto.d.ts +6 -0
- package/dist/node/crypto.d.ts.map +1 -0
- package/dist/node/crypto.js +103 -0
- package/dist/node/crypto.js.map +1 -0
- package/dist/node/definition.d.ts +638 -0
- package/dist/node/definition.d.ts.map +1 -0
- package/dist/node/definition.js +315 -0
- package/dist/node/definition.js.map +1 -0
- package/dist/node/oauth.js +124 -0
- package/dist/node/oauth.js.map +1 -0
- package/dist/node/routes.d.ts +1585 -0
- package/dist/node/routes.d.ts.map +1 -0
- package/dist/node/routes.js +1100 -0
- package/dist/node/routes.js.map +1 -0
- package/dist/node/schema.d.ts +99 -0
- package/dist/node/schema.d.ts.map +1 -0
- package/dist/node/schema.js +16 -0
- package/dist/node/schema.js.map +1 -0
- package/dist/node/server.d.ts +1585 -0
- package/dist/node/server.d.ts.map +1 -0
- package/dist/node/server.js +12 -0
- package/dist/node/server.js.map +1 -0
- package/dist/node/services.d.ts +10 -0
- package/dist/node/services.d.ts.map +1 -0
- package/dist/node/services.js +304 -0
- package/dist/node/services.js.map +1 -0
- package/dist/node/webhooks/auth.d.ts +133 -0
- package/dist/node/webhooks/auth.d.ts.map +1 -0
- package/dist/node/webhooks/auth.js +268 -0
- package/dist/node/webhooks/auth.js.map +1 -0
- package/dist/node/webhooks/verification.d.ts +102 -0
- package/dist/node/webhooks/verification.d.ts.map +1 -0
- package/dist/node/webhooks/verification.js +119 -0
- package/dist/node/webhooks/verification.js.map +1 -0
- package/package.json +136 -3
|
@@ -0,0 +1,1100 @@
|
|
|
1
|
+
import { apiSchema } from "./schema.js";
|
|
2
|
+
import { sha256Base64Url, utf8Bytes } from "./crypto.js";
|
|
3
|
+
import { getSensitiveWebhookAuthValues, getWebhookAuthSecretRefs, verifyWebhookAuth } from "./webhooks/auth.js";
|
|
4
|
+
import { createWebhookRequestValues, evaluateWebhookVerification } from "./webhooks/verification.js";
|
|
5
|
+
import { API_OAUTH_REDIRECT_URI_QUERY_PARAMETER, apiAuthStatusSchema, apiConnectionOutputSchema, apiConnectionSlugSchema, apiConnectionsPageSchema, apiOAuthPendingSchema, apiRequestInputSchema, apiRequestOutputSchema, createApiConnectionInputSchema, createWebhookEndpointInputSchema, oauthRedirectUriSchema, oauthStartInputSchema, tokenAuthInputSchema, updateWebhookEndpointInputSchema, webhookEndpointOutputSchema, webhookRejectionCodeSchema } from "./api-types.js";
|
|
6
|
+
import { assertAllowedBaseUrl, isApiAuthReady, pendingOAuthLink, projectApiAuthStatus } from "./services.js";
|
|
7
|
+
import { apiFragmentDefinition } from "./definition.js";
|
|
8
|
+
import { defineRoutes } from "@fragno-dev/core";
|
|
9
|
+
import { isUniqueConstraintError } from "@fragno-dev/db";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { decodeCursor } from "@fragno-dev/db/cursor";
|
|
12
|
+
//#region src/routes.ts
|
|
13
|
+
const CONNECTIONS_PAGE_SIZE = 50;
|
|
14
|
+
const webhookEndpointsOutputSchema = z.object({ endpoints: z.array(webhookEndpointOutputSchema) });
|
|
15
|
+
const oauthStartOutputSchema = z.object({
|
|
16
|
+
authorizationUrl: z.string(),
|
|
17
|
+
state: z.string()
|
|
18
|
+
});
|
|
19
|
+
const oauthCallbackOutputSchema = z.object({
|
|
20
|
+
authenticated: z.boolean(),
|
|
21
|
+
mode: z.string()
|
|
22
|
+
});
|
|
23
|
+
const logApiRouteError = (message, err) => {
|
|
24
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
25
|
+
console.error(message, detail, err);
|
|
26
|
+
};
|
|
27
|
+
function publicConnection(connection) {
|
|
28
|
+
return {
|
|
29
|
+
slug: connection.id.toString(),
|
|
30
|
+
name: connection.name ?? null,
|
|
31
|
+
baseUrl: connection.baseUrl,
|
|
32
|
+
authMode: connection.authMode,
|
|
33
|
+
status: connection.status,
|
|
34
|
+
createdAt: connection.createdAt,
|
|
35
|
+
updatedAt: connection.updatedAt
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function connectionHookSnapshot(connection) {
|
|
39
|
+
return {
|
|
40
|
+
slug: connection.id.toString(),
|
|
41
|
+
name: connection.name ?? null,
|
|
42
|
+
baseUrl: connection.baseUrl,
|
|
43
|
+
authMode: connection.authMode,
|
|
44
|
+
status: connection.status
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function webhookSecretId(endpointId, ref) {
|
|
48
|
+
return `${endpointId}:${ref}`;
|
|
49
|
+
}
|
|
50
|
+
function webhookEndpointAuthStorage(auth) {
|
|
51
|
+
if (auth.type === "none") return {
|
|
52
|
+
authConfig: { type: "none" },
|
|
53
|
+
secrets: []
|
|
54
|
+
};
|
|
55
|
+
if (auth.type === "bearer") return {
|
|
56
|
+
authConfig: {
|
|
57
|
+
type: "bearer",
|
|
58
|
+
tokenRef: "token"
|
|
59
|
+
},
|
|
60
|
+
secrets: [{
|
|
61
|
+
ref: "token",
|
|
62
|
+
payload: auth.token
|
|
63
|
+
}]
|
|
64
|
+
};
|
|
65
|
+
if (auth.type === "apiKey") return {
|
|
66
|
+
authConfig: {
|
|
67
|
+
type: "apiKey",
|
|
68
|
+
location: auth.location,
|
|
69
|
+
name: auth.name,
|
|
70
|
+
secretRef: "secret"
|
|
71
|
+
},
|
|
72
|
+
secrets: [{
|
|
73
|
+
ref: "secret",
|
|
74
|
+
payload: auth.secret
|
|
75
|
+
}]
|
|
76
|
+
};
|
|
77
|
+
if (auth.type === "basic") return {
|
|
78
|
+
authConfig: {
|
|
79
|
+
type: "basic",
|
|
80
|
+
usernameRef: "username",
|
|
81
|
+
passwordRef: "password"
|
|
82
|
+
},
|
|
83
|
+
secrets: [{
|
|
84
|
+
ref: "username",
|
|
85
|
+
payload: auth.username
|
|
86
|
+
}, {
|
|
87
|
+
ref: "password",
|
|
88
|
+
payload: auth.password
|
|
89
|
+
}]
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
authConfig: {
|
|
93
|
+
type: "hmac",
|
|
94
|
+
secretRef: "secret",
|
|
95
|
+
algorithm: auth.algorithm,
|
|
96
|
+
signature: auth.signature,
|
|
97
|
+
signedPayload: auth.signedPayload
|
|
98
|
+
},
|
|
99
|
+
secrets: [{
|
|
100
|
+
ref: "secret",
|
|
101
|
+
payload: auth.secret
|
|
102
|
+
}]
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function parseWebhookEndpointStatus(status) {
|
|
106
|
+
if (status === "draft" || status === "active" || status === "disabled") return status;
|
|
107
|
+
throw new Error(`Unexpected webhook endpoint status: ${status}`);
|
|
108
|
+
}
|
|
109
|
+
function webhookEndpointHookSnapshot(endpoint) {
|
|
110
|
+
return {
|
|
111
|
+
name: endpoint.name,
|
|
112
|
+
status: parseWebhookEndpointStatus(endpoint.status),
|
|
113
|
+
authConfig: endpoint.authConfig,
|
|
114
|
+
verification: endpoint.verification,
|
|
115
|
+
deliveryIdentity: endpoint.deliveryIdentity,
|
|
116
|
+
secretRefs: [...getWebhookAuthSecretRefs(endpoint.authConfig)]
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function recordFromSearchParams(query, sensitiveQueryNames) {
|
|
120
|
+
const result = {};
|
|
121
|
+
for (const [name, value] of query.entries()) result[name] = sensitiveQueryNames.has(name) ? "[redacted]" : value;
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
function recordFromHeaders(headers, sensitiveHeaderNames) {
|
|
125
|
+
const result = {};
|
|
126
|
+
for (const [name, value] of headers.entries()) result[name] = sensitiveHeaderNames.has(name.toLowerCase()) ? "[redacted]" : value;
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
function extractWebhookDeliveryId(input) {
|
|
130
|
+
const value = input.requestValues.read(input.identity);
|
|
131
|
+
if (!value.ok) return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: value.reason === "missing" ? "missing" : "invalid_value"
|
|
134
|
+
};
|
|
135
|
+
return normalizeWebhookDeliveryId(value.value);
|
|
136
|
+
}
|
|
137
|
+
function normalizeWebhookDeliveryId(value) {
|
|
138
|
+
if (typeof value !== "string" && typeof value !== "number") return value == null ? {
|
|
139
|
+
ok: false,
|
|
140
|
+
reason: "missing"
|
|
141
|
+
} : {
|
|
142
|
+
ok: false,
|
|
143
|
+
reason: "invalid_value"
|
|
144
|
+
};
|
|
145
|
+
const deliveryId = `${value}`.trim();
|
|
146
|
+
if (!deliveryId) return {
|
|
147
|
+
ok: false,
|
|
148
|
+
reason: "missing"
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
deliveryId
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
async function webhookHookId(endpointId, deliveryId) {
|
|
156
|
+
return `webhook_${await sha256Base64Url(`${endpointId}\0${deliveryId}`)}`;
|
|
157
|
+
}
|
|
158
|
+
function webhookVerificationResponse(body) {
|
|
159
|
+
const bytes = utf8Bytes(body);
|
|
160
|
+
return new Response(bytes, {
|
|
161
|
+
status: 200,
|
|
162
|
+
headers: {
|
|
163
|
+
"content-type": "text/plain; charset=utf-8",
|
|
164
|
+
"content-length": `${bytes.byteLength}`,
|
|
165
|
+
"x-content-type-options": "nosniff"
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/** Configuration writes reset delivery evidence, which then covers only the new configuration. */
|
|
170
|
+
const noWebhookDeliveryEvidence = {
|
|
171
|
+
lastAcceptedAt: null,
|
|
172
|
+
lastRejectedAt: null,
|
|
173
|
+
lastRejectionCode: null
|
|
174
|
+
};
|
|
175
|
+
function publicWebhookEndpoint(endpoint) {
|
|
176
|
+
return {
|
|
177
|
+
id: endpoint.id.toString(),
|
|
178
|
+
name: endpoint.name,
|
|
179
|
+
status: parseWebhookEndpointStatus(endpoint.status),
|
|
180
|
+
authConfig: endpoint.authConfig,
|
|
181
|
+
verification: endpoint.verification,
|
|
182
|
+
deliveryIdentity: endpoint.deliveryIdentity,
|
|
183
|
+
secretRefs: [...getWebhookAuthSecretRefs(endpoint.authConfig)],
|
|
184
|
+
deliveries: {
|
|
185
|
+
lastAcceptedAt: endpoint.lastAcceptedAt?.toISOString() ?? null,
|
|
186
|
+
lastRejection: endpoint.lastRejectedAt === null ? null : {
|
|
187
|
+
receivedAt: endpoint.lastRejectedAt.toISOString(),
|
|
188
|
+
code: webhookRejectionCodeSchema.parse(endpoint.lastRejectionCode)
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
createdAt: endpoint.createdAt,
|
|
192
|
+
updatedAt: endpoint.updatedAt
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Records the outcome of a receive request; unknown endpoints have no row to record on. Writes skip
|
|
197
|
+
* the version check, so concurrent deliveries never conflict and the last one wins.
|
|
198
|
+
*/
|
|
199
|
+
function recordWebhookRequestOutcome(uow, endpointId, rejection) {
|
|
200
|
+
if (rejection === "WEBHOOK_ENDPOINT_NOT_FOUND") return;
|
|
201
|
+
uow.update("webhookEndpoint", endpointId, (b) => rejection === null ? b.set({ lastAcceptedAt: b.now() }) : b.set({
|
|
202
|
+
lastRejectedAt: b.now(),
|
|
203
|
+
lastRejectionCode: rejection
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
const apiRoutesFactory = defineRoutes(apiFragmentDefinition).create(({ services, defineRoute, config }) => [
|
|
207
|
+
defineRoute({
|
|
208
|
+
method: "PUT",
|
|
209
|
+
path: "/webhooks/endpoints/:endpointId",
|
|
210
|
+
inputSchema: createWebhookEndpointInputSchema,
|
|
211
|
+
outputSchema: webhookEndpointOutputSchema,
|
|
212
|
+
handler: async function({ input, pathParams }, { json }) {
|
|
213
|
+
const body = await input.valid();
|
|
214
|
+
const authStorage = webhookEndpointAuthStorage(body.auth);
|
|
215
|
+
const result = await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("webhookEndpoint", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.endpointId))).find("webhookSecret", (b) => b.whereIndex("idx_webhook_secret_endpoint_ref", (eb) => eb("endpointId", "=", pathParams.endpointId)))).mutate(({ forSchema, retrieveResult: [existing, existingSecrets] }) => {
|
|
216
|
+
const uow = forSchema(apiSchema);
|
|
217
|
+
const endpoint = {
|
|
218
|
+
id: pathParams.endpointId,
|
|
219
|
+
name: body.name,
|
|
220
|
+
status: body.status,
|
|
221
|
+
authConfig: authStorage.authConfig,
|
|
222
|
+
verification: body.verification,
|
|
223
|
+
deliveryIdentity: body.deliveryIdentity,
|
|
224
|
+
...noWebhookDeliveryEvidence,
|
|
225
|
+
createdAt: existing?.createdAt
|
|
226
|
+
};
|
|
227
|
+
if (existing) uow.update("webhookEndpoint", existing.id, (b) => b.set({
|
|
228
|
+
name: endpoint.name,
|
|
229
|
+
status: endpoint.status,
|
|
230
|
+
authConfig: endpoint.authConfig,
|
|
231
|
+
verification: endpoint.verification,
|
|
232
|
+
deliveryIdentity: endpoint.deliveryIdentity,
|
|
233
|
+
...noWebhookDeliveryEvidence,
|
|
234
|
+
updatedAt: b.now()
|
|
235
|
+
}).check());
|
|
236
|
+
else uow.create("webhookEndpoint", {
|
|
237
|
+
id: endpoint.id,
|
|
238
|
+
name: endpoint.name,
|
|
239
|
+
status: endpoint.status,
|
|
240
|
+
authConfig: endpoint.authConfig,
|
|
241
|
+
verification: endpoint.verification,
|
|
242
|
+
deliveryIdentity: endpoint.deliveryIdentity
|
|
243
|
+
});
|
|
244
|
+
const nextSecretRefs = new Set(authStorage.secrets.map((secret) => secret.ref));
|
|
245
|
+
const existingSecretsByRef = new Map(existingSecrets.map((secret) => [secret.ref, secret]));
|
|
246
|
+
for (const secret of existingSecrets) if (!nextSecretRefs.has(secret.ref)) uow.delete("webhookSecret", secret.id);
|
|
247
|
+
for (const secret of authStorage.secrets) {
|
|
248
|
+
const existingSecret = existingSecretsByRef.get(secret.ref);
|
|
249
|
+
if (existingSecret) uow.update("webhookSecret", existingSecret.id, (b) => b.set({
|
|
250
|
+
payload: secret.payload,
|
|
251
|
+
updatedAt: b.now()
|
|
252
|
+
}).check());
|
|
253
|
+
else uow.create("webhookSecret", {
|
|
254
|
+
id: webhookSecretId(pathParams.endpointId, secret.ref),
|
|
255
|
+
endpointId: pathParams.endpointId,
|
|
256
|
+
ref: secret.ref,
|
|
257
|
+
payload: secret.payload
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
uow.triggerHook("onWebhookEndpointChanged", existing ? {
|
|
261
|
+
change: "updated",
|
|
262
|
+
endpointId: pathParams.endpointId,
|
|
263
|
+
endpoint: webhookEndpointHookSnapshot(endpoint),
|
|
264
|
+
previousStatus: parseWebhookEndpointStatus(existing.status)
|
|
265
|
+
} : {
|
|
266
|
+
change: "created",
|
|
267
|
+
endpointId: pathParams.endpointId,
|
|
268
|
+
endpoint: webhookEndpointHookSnapshot(endpoint)
|
|
269
|
+
});
|
|
270
|
+
return {
|
|
271
|
+
created: !existing,
|
|
272
|
+
endpoint
|
|
273
|
+
};
|
|
274
|
+
}).execute();
|
|
275
|
+
return json(publicWebhookEndpoint(result.endpoint), result.created ? 201 : 200);
|
|
276
|
+
}
|
|
277
|
+
}),
|
|
278
|
+
defineRoute({
|
|
279
|
+
method: "GET",
|
|
280
|
+
path: "/webhooks/endpoints",
|
|
281
|
+
outputSchema: webhookEndpointsOutputSchema,
|
|
282
|
+
handler: async function(_, { json }) {
|
|
283
|
+
const [endpoints] = await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).find("webhookEndpoint", (b) => b.whereIndex("primary"))).execute();
|
|
284
|
+
return json({ endpoints: endpoints.map(publicWebhookEndpoint) });
|
|
285
|
+
}
|
|
286
|
+
}),
|
|
287
|
+
defineRoute({
|
|
288
|
+
method: "GET",
|
|
289
|
+
path: "/webhooks/endpoints/:endpointId",
|
|
290
|
+
outputSchema: webhookEndpointOutputSchema,
|
|
291
|
+
errorCodes: ["WEBHOOK_ENDPOINT_NOT_FOUND"],
|
|
292
|
+
handler: async function({ pathParams }, { json, error }) {
|
|
293
|
+
const [endpoint] = await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("webhookEndpoint", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.endpointId)))).execute();
|
|
294
|
+
if (!endpoint) return error({
|
|
295
|
+
code: "WEBHOOK_ENDPOINT_NOT_FOUND",
|
|
296
|
+
message: "Webhook endpoint not found"
|
|
297
|
+
}, 404);
|
|
298
|
+
return json(publicWebhookEndpoint(endpoint));
|
|
299
|
+
}
|
|
300
|
+
}),
|
|
301
|
+
defineRoute({
|
|
302
|
+
method: "GET",
|
|
303
|
+
path: "/webhooks/endpoints/:endpointId/events",
|
|
304
|
+
errorCodes: [
|
|
305
|
+
"WEBHOOK_ENDPOINT_NOT_FOUND",
|
|
306
|
+
"WEBHOOK_ENDPOINT_DISABLED",
|
|
307
|
+
"WEBHOOK_ENDPOINT_DRAFT",
|
|
308
|
+
"WEBHOOK_AUTH_FAILED",
|
|
309
|
+
"WEBHOOK_VERIFICATION_NOT_MATCHED",
|
|
310
|
+
"WEBHOOK_VERIFICATION_RESPONSE_INVALID"
|
|
311
|
+
],
|
|
312
|
+
handler: async function({ pathParams, headers, query, rawBody, request }, { error }) {
|
|
313
|
+
const result = await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("webhookEndpoint", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.endpointId))).find("webhookSecret", (b) => b.whereIndex("idx_webhook_secret_endpoint_ref", (eb) => eb("endpointId", "=", pathParams.endpointId)))).transformRetrieve(async ([endpoint, secrets]) => {
|
|
314
|
+
if (!endpoint) return {
|
|
315
|
+
ok: false,
|
|
316
|
+
code: "WEBHOOK_ENDPOINT_NOT_FOUND"
|
|
317
|
+
};
|
|
318
|
+
if (endpoint.status === "draft") return {
|
|
319
|
+
ok: false,
|
|
320
|
+
code: "WEBHOOK_ENDPOINT_DRAFT"
|
|
321
|
+
};
|
|
322
|
+
if (endpoint.status !== "active") return {
|
|
323
|
+
ok: false,
|
|
324
|
+
code: "WEBHOOK_ENDPOINT_DISABLED"
|
|
325
|
+
};
|
|
326
|
+
if (!request) throw new Error("Webhook receive route requires a web-standard Request");
|
|
327
|
+
const secretValues = new Map(secrets.map((secret) => [secret.ref, secret.payload]));
|
|
328
|
+
if (!(await verifyWebhookAuth({
|
|
329
|
+
config: endpoint.authConfig,
|
|
330
|
+
request,
|
|
331
|
+
secrets: { get: async (ref) => secretValues.get(ref) }
|
|
332
|
+
})).ok) return {
|
|
333
|
+
ok: false,
|
|
334
|
+
code: "WEBHOOK_AUTH_FAILED"
|
|
335
|
+
};
|
|
336
|
+
const verification = evaluateWebhookVerification({
|
|
337
|
+
config: endpoint.verification,
|
|
338
|
+
method: "GET",
|
|
339
|
+
requestValues: createWebhookRequestValues({
|
|
340
|
+
headers,
|
|
341
|
+
query,
|
|
342
|
+
rawBody
|
|
343
|
+
})
|
|
344
|
+
});
|
|
345
|
+
if (verification.type === "not_verification") return {
|
|
346
|
+
ok: false,
|
|
347
|
+
code: "WEBHOOK_VERIFICATION_NOT_MATCHED"
|
|
348
|
+
};
|
|
349
|
+
if (verification.type === "invalid_response") return {
|
|
350
|
+
ok: false,
|
|
351
|
+
code: "WEBHOOK_VERIFICATION_RESPONSE_INVALID"
|
|
352
|
+
};
|
|
353
|
+
return {
|
|
354
|
+
ok: true,
|
|
355
|
+
body: verification.body
|
|
356
|
+
};
|
|
357
|
+
}).mutate(({ forSchema, retrieveResult }) => {
|
|
358
|
+
recordWebhookRequestOutcome(forSchema(apiSchema), pathParams.endpointId, retrieveResult.ok ? null : retrieveResult.code);
|
|
359
|
+
return retrieveResult;
|
|
360
|
+
}).execute();
|
|
361
|
+
if (!result.ok) {
|
|
362
|
+
if (result.code === "WEBHOOK_ENDPOINT_NOT_FOUND") return error({
|
|
363
|
+
code: result.code,
|
|
364
|
+
message: "Webhook endpoint not found"
|
|
365
|
+
}, 404);
|
|
366
|
+
if (result.code === "WEBHOOK_ENDPOINT_DISABLED") return error({
|
|
367
|
+
code: result.code,
|
|
368
|
+
message: "Webhook endpoint is disabled"
|
|
369
|
+
}, 409);
|
|
370
|
+
if (result.code === "WEBHOOK_ENDPOINT_DRAFT") return error({
|
|
371
|
+
code: result.code,
|
|
372
|
+
message: "Webhook endpoint is not configured yet"
|
|
373
|
+
}, 409);
|
|
374
|
+
if (result.code === "WEBHOOK_AUTH_FAILED") return error({
|
|
375
|
+
code: result.code,
|
|
376
|
+
message: "Webhook authentication failed"
|
|
377
|
+
}, 401);
|
|
378
|
+
if (result.code === "WEBHOOK_VERIFICATION_RESPONSE_INVALID") return error({
|
|
379
|
+
code: result.code,
|
|
380
|
+
message: "Webhook challenge is invalid"
|
|
381
|
+
}, 400);
|
|
382
|
+
return error({
|
|
383
|
+
code: result.code,
|
|
384
|
+
message: "Webhook verification request not found"
|
|
385
|
+
}, 404);
|
|
386
|
+
}
|
|
387
|
+
return webhookVerificationResponse(result.body);
|
|
388
|
+
}
|
|
389
|
+
}),
|
|
390
|
+
defineRoute({
|
|
391
|
+
method: "POST",
|
|
392
|
+
path: "/webhooks/endpoints/:endpointId/events",
|
|
393
|
+
errorCodes: [
|
|
394
|
+
"WEBHOOK_ENDPOINT_NOT_FOUND",
|
|
395
|
+
"WEBHOOK_ENDPOINT_DISABLED",
|
|
396
|
+
"WEBHOOK_ENDPOINT_DRAFT",
|
|
397
|
+
"WEBHOOK_AUTH_FAILED",
|
|
398
|
+
"WEBHOOK_VERIFICATION_RESPONSE_INVALID",
|
|
399
|
+
"WEBHOOK_BODY_INVALID",
|
|
400
|
+
"WEBHOOK_DELIVERY_ID_MISSING",
|
|
401
|
+
"WEBHOOK_DELIVERY_ID_INVALID"
|
|
402
|
+
],
|
|
403
|
+
handler: async function({ pathParams, headers, query, rawBody, request }, { json, error }) {
|
|
404
|
+
let result;
|
|
405
|
+
try {
|
|
406
|
+
result = await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("webhookEndpoint", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.endpointId))).find("webhookSecret", (b) => b.whereIndex("idx_webhook_secret_endpoint_ref", (eb) => eb("endpointId", "=", pathParams.endpointId)))).transformRetrieve(async ([endpoint, secrets]) => {
|
|
407
|
+
if (!endpoint) return {
|
|
408
|
+
ok: false,
|
|
409
|
+
code: "WEBHOOK_ENDPOINT_NOT_FOUND"
|
|
410
|
+
};
|
|
411
|
+
if (endpoint.status === "draft") return {
|
|
412
|
+
ok: false,
|
|
413
|
+
code: "WEBHOOK_ENDPOINT_DRAFT"
|
|
414
|
+
};
|
|
415
|
+
if (endpoint.status !== "active") return {
|
|
416
|
+
ok: false,
|
|
417
|
+
code: "WEBHOOK_ENDPOINT_DISABLED"
|
|
418
|
+
};
|
|
419
|
+
if (!request) throw new Error("Webhook receive route requires a web-standard Request");
|
|
420
|
+
const authConfig = endpoint.authConfig;
|
|
421
|
+
const secretValues = new Map(secrets.map((secret) => [secret.ref, secret.payload]));
|
|
422
|
+
if (!(await verifyWebhookAuth({
|
|
423
|
+
config: authConfig,
|
|
424
|
+
request,
|
|
425
|
+
secrets: { get: async (ref) => secretValues.get(ref) }
|
|
426
|
+
})).ok) return {
|
|
427
|
+
ok: false,
|
|
428
|
+
code: "WEBHOOK_AUTH_FAILED"
|
|
429
|
+
};
|
|
430
|
+
const requestValues = createWebhookRequestValues({
|
|
431
|
+
headers,
|
|
432
|
+
query,
|
|
433
|
+
rawBody
|
|
434
|
+
});
|
|
435
|
+
const verification = evaluateWebhookVerification({
|
|
436
|
+
config: endpoint.verification,
|
|
437
|
+
method: "POST",
|
|
438
|
+
requestValues
|
|
439
|
+
});
|
|
440
|
+
if (verification.type === "invalid_response") return {
|
|
441
|
+
ok: false,
|
|
442
|
+
code: "WEBHOOK_VERIFICATION_RESPONSE_INVALID"
|
|
443
|
+
};
|
|
444
|
+
if (verification.type === "response") return {
|
|
445
|
+
ok: true,
|
|
446
|
+
type: "verification",
|
|
447
|
+
body: verification.body
|
|
448
|
+
};
|
|
449
|
+
const body = requestValues.jsonBody();
|
|
450
|
+
if (!body.ok) return {
|
|
451
|
+
ok: false,
|
|
452
|
+
code: "WEBHOOK_BODY_INVALID"
|
|
453
|
+
};
|
|
454
|
+
const deliveryId = extractWebhookDeliveryId({
|
|
455
|
+
identity: endpoint.deliveryIdentity,
|
|
456
|
+
requestValues
|
|
457
|
+
});
|
|
458
|
+
if (!deliveryId.ok) return {
|
|
459
|
+
ok: false,
|
|
460
|
+
code: deliveryId.reason === "missing" ? "WEBHOOK_DELIVERY_ID_MISSING" : "WEBHOOK_DELIVERY_ID_INVALID"
|
|
461
|
+
};
|
|
462
|
+
return {
|
|
463
|
+
ok: true,
|
|
464
|
+
type: "delivery",
|
|
465
|
+
authConfig,
|
|
466
|
+
deliveryId: deliveryId.deliveryId,
|
|
467
|
+
hookId: await webhookHookId(pathParams.endpointId, deliveryId.deliveryId),
|
|
468
|
+
body: body.body
|
|
469
|
+
};
|
|
470
|
+
}).mutate(({ forSchema, retrieveResult }) => {
|
|
471
|
+
const uow = forSchema(apiSchema);
|
|
472
|
+
recordWebhookRequestOutcome(uow, pathParams.endpointId, retrieveResult.ok ? null : retrieveResult.code);
|
|
473
|
+
if (!retrieveResult.ok || retrieveResult.type === "verification") return retrieveResult;
|
|
474
|
+
const sensitiveValues = getSensitiveWebhookAuthValues(retrieveResult.authConfig);
|
|
475
|
+
const sensitiveHeaderNames = new Set(sensitiveValues.flatMap((value) => value.location === "header" ? [value.name.toLowerCase()] : []));
|
|
476
|
+
const sensitiveQueryNames = new Set(sensitiveValues.flatMap((value) => value.location === "query" ? [value.name] : []));
|
|
477
|
+
uow.triggerHook("onWebhookReceived", {
|
|
478
|
+
endpointId: pathParams.endpointId,
|
|
479
|
+
deliveryId: retrieveResult.deliveryId,
|
|
480
|
+
hookId: retrieveResult.hookId,
|
|
481
|
+
receivedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
482
|
+
headers: recordFromHeaders(headers, sensitiveHeaderNames),
|
|
483
|
+
query: recordFromSearchParams(query, sensitiveQueryNames),
|
|
484
|
+
rawBody: rawBody ?? "",
|
|
485
|
+
body: retrieveResult.body,
|
|
486
|
+
contentType: headers.get("content-type")
|
|
487
|
+
}, { id: retrieveResult.hookId });
|
|
488
|
+
return {
|
|
489
|
+
ok: true,
|
|
490
|
+
type: "delivery"
|
|
491
|
+
};
|
|
492
|
+
}).execute();
|
|
493
|
+
} catch (err) {
|
|
494
|
+
if (isUniqueConstraintError(err)) return json({ accepted: true }, 202);
|
|
495
|
+
throw err;
|
|
496
|
+
}
|
|
497
|
+
if (!result.ok) {
|
|
498
|
+
if (result.code === "WEBHOOK_ENDPOINT_NOT_FOUND") return error({
|
|
499
|
+
code: result.code,
|
|
500
|
+
message: "Webhook endpoint not found"
|
|
501
|
+
}, 404);
|
|
502
|
+
if (result.code === "WEBHOOK_ENDPOINT_DISABLED") return error({
|
|
503
|
+
code: result.code,
|
|
504
|
+
message: "Webhook endpoint is disabled"
|
|
505
|
+
}, 409);
|
|
506
|
+
if (result.code === "WEBHOOK_ENDPOINT_DRAFT") return error({
|
|
507
|
+
code: result.code,
|
|
508
|
+
message: "Webhook endpoint is not configured yet"
|
|
509
|
+
}, 409);
|
|
510
|
+
if (result.code === "WEBHOOK_VERIFICATION_RESPONSE_INVALID") return error({
|
|
511
|
+
code: result.code,
|
|
512
|
+
message: "Webhook challenge is invalid"
|
|
513
|
+
}, 400);
|
|
514
|
+
if (result.code === "WEBHOOK_BODY_INVALID") return error({
|
|
515
|
+
code: result.code,
|
|
516
|
+
message: "Webhook body must be a JSON object"
|
|
517
|
+
}, 400);
|
|
518
|
+
if (result.code === "WEBHOOK_DELIVERY_ID_MISSING") return error({
|
|
519
|
+
code: result.code,
|
|
520
|
+
message: "Webhook delivery ID is missing"
|
|
521
|
+
}, 400);
|
|
522
|
+
if (result.code === "WEBHOOK_DELIVERY_ID_INVALID") return error({
|
|
523
|
+
code: result.code,
|
|
524
|
+
message: "Webhook delivery ID is invalid"
|
|
525
|
+
}, 400);
|
|
526
|
+
return error({
|
|
527
|
+
code: result.code,
|
|
528
|
+
message: "Webhook authentication failed"
|
|
529
|
+
}, 401);
|
|
530
|
+
}
|
|
531
|
+
if (result.type === "verification") return webhookVerificationResponse(result.body);
|
|
532
|
+
return json({ accepted: true }, 202);
|
|
533
|
+
}
|
|
534
|
+
}),
|
|
535
|
+
defineRoute({
|
|
536
|
+
method: "PATCH",
|
|
537
|
+
path: "/webhooks/endpoints/:endpointId",
|
|
538
|
+
inputSchema: updateWebhookEndpointInputSchema,
|
|
539
|
+
outputSchema: webhookEndpointOutputSchema,
|
|
540
|
+
errorCodes: ["WEBHOOK_ENDPOINT_NOT_FOUND"],
|
|
541
|
+
handler: async function({ input, pathParams }, { json, error }) {
|
|
542
|
+
const body = await input.valid();
|
|
543
|
+
const authStorage = body.auth ? webhookEndpointAuthStorage(body.auth) : null;
|
|
544
|
+
const result = await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("webhookEndpoint", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.endpointId))).find("webhookSecret", (b) => b.whereIndex("idx_webhook_secret_endpoint_ref", (eb) => eb("endpointId", "=", pathParams.endpointId)))).mutate(({ forSchema, retrieveResult: [endpoint, secrets] }) => {
|
|
545
|
+
if (!endpoint) return { found: false };
|
|
546
|
+
const uow = forSchema(apiSchema);
|
|
547
|
+
const authConfig = authStorage?.authConfig ?? endpoint.authConfig;
|
|
548
|
+
const verification = body.verification ?? endpoint.verification;
|
|
549
|
+
const deliveryIdentity = body.deliveryIdentity ?? endpoint.deliveryIdentity;
|
|
550
|
+
const next = {
|
|
551
|
+
id: endpoint.id,
|
|
552
|
+
name: body.name ?? endpoint.name,
|
|
553
|
+
status: body.status ?? endpoint.status,
|
|
554
|
+
authConfig,
|
|
555
|
+
verification,
|
|
556
|
+
deliveryIdentity,
|
|
557
|
+
...noWebhookDeliveryEvidence,
|
|
558
|
+
createdAt: endpoint.createdAt
|
|
559
|
+
};
|
|
560
|
+
uow.update("webhookEndpoint", endpoint.id, (b) => b.set({
|
|
561
|
+
name: next.name,
|
|
562
|
+
status: next.status,
|
|
563
|
+
authConfig,
|
|
564
|
+
verification,
|
|
565
|
+
deliveryIdentity,
|
|
566
|
+
...noWebhookDeliveryEvidence,
|
|
567
|
+
updatedAt: b.now()
|
|
568
|
+
}).check());
|
|
569
|
+
if (authStorage) {
|
|
570
|
+
for (const secret of secrets) uow.delete("webhookSecret", secret.id);
|
|
571
|
+
for (const secret of authStorage.secrets) uow.create("webhookSecret", {
|
|
572
|
+
id: webhookSecretId(pathParams.endpointId, secret.ref),
|
|
573
|
+
endpointId: pathParams.endpointId,
|
|
574
|
+
ref: secret.ref,
|
|
575
|
+
payload: secret.payload
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
uow.triggerHook("onWebhookEndpointChanged", {
|
|
579
|
+
change: "updated",
|
|
580
|
+
endpointId: pathParams.endpointId,
|
|
581
|
+
endpoint: webhookEndpointHookSnapshot(next),
|
|
582
|
+
previousStatus: parseWebhookEndpointStatus(endpoint.status)
|
|
583
|
+
});
|
|
584
|
+
return {
|
|
585
|
+
found: true,
|
|
586
|
+
endpoint: next
|
|
587
|
+
};
|
|
588
|
+
}).execute();
|
|
589
|
+
if (!result.found) return error({
|
|
590
|
+
code: "WEBHOOK_ENDPOINT_NOT_FOUND",
|
|
591
|
+
message: "Webhook endpoint not found"
|
|
592
|
+
}, 404);
|
|
593
|
+
return json(publicWebhookEndpoint(result.endpoint));
|
|
594
|
+
}
|
|
595
|
+
}),
|
|
596
|
+
defineRoute({
|
|
597
|
+
method: "DELETE",
|
|
598
|
+
path: "/webhooks/endpoints/:endpointId",
|
|
599
|
+
handler: async function({ pathParams }, { empty }) {
|
|
600
|
+
await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("webhookEndpoint", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.endpointId))).find("webhookSecret", (b) => b.whereIndex("idx_webhook_secret_endpoint_ref", (eb) => eb("endpointId", "=", pathParams.endpointId)))).mutate(({ forSchema, retrieveResult: [endpoint, secrets] }) => {
|
|
601
|
+
if (!endpoint) return { deleted: false };
|
|
602
|
+
const uow = forSchema(apiSchema);
|
|
603
|
+
for (const secret of secrets) uow.delete("webhookSecret", secret.id);
|
|
604
|
+
uow.delete("webhookEndpoint", endpoint.id);
|
|
605
|
+
uow.triggerHook("onWebhookEndpointChanged", {
|
|
606
|
+
change: "deleted",
|
|
607
|
+
endpointId: pathParams.endpointId
|
|
608
|
+
});
|
|
609
|
+
return { deleted: true };
|
|
610
|
+
}).execute();
|
|
611
|
+
return empty(204);
|
|
612
|
+
}
|
|
613
|
+
}),
|
|
614
|
+
defineRoute({
|
|
615
|
+
method: "PUT",
|
|
616
|
+
path: "/connections/:slug",
|
|
617
|
+
inputSchema: createApiConnectionInputSchema,
|
|
618
|
+
outputSchema: apiConnectionOutputSchema,
|
|
619
|
+
errorCodes: [
|
|
620
|
+
"CONNECTION_EXISTS",
|
|
621
|
+
"BASE_URL_NOT_ALLOWED",
|
|
622
|
+
"INVALID_SLUG"
|
|
623
|
+
],
|
|
624
|
+
handler: async function({ input, pathParams }, { json, error }) {
|
|
625
|
+
const slug = apiConnectionSlugSchema.safeParse(pathParams.slug);
|
|
626
|
+
if (!slug.success) return error({
|
|
627
|
+
code: "INVALID_SLUG",
|
|
628
|
+
message: slug.error.issues[0].message
|
|
629
|
+
}, 400);
|
|
630
|
+
const body = await input.valid();
|
|
631
|
+
try {
|
|
632
|
+
assertAllowedBaseUrl(body.baseUrl, config);
|
|
633
|
+
} catch (err) {
|
|
634
|
+
logApiRouteError("API connection base URL rejected", err);
|
|
635
|
+
return error({
|
|
636
|
+
code: "BASE_URL_NOT_ALLOWED",
|
|
637
|
+
message: "API base URL is not allowed"
|
|
638
|
+
}, 400);
|
|
639
|
+
}
|
|
640
|
+
const payload = body.auth.type === "none" ? void 0 : JSON.stringify(body.auth);
|
|
641
|
+
if ((await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("api_connection", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.slug)))).mutate(({ forSchema, retrieveResult: [existing] }) => {
|
|
642
|
+
if (existing) return { exists: true };
|
|
643
|
+
const uow = forSchema(apiSchema);
|
|
644
|
+
uow.create("api_connection", {
|
|
645
|
+
id: pathParams.slug,
|
|
646
|
+
name: body.name ?? null,
|
|
647
|
+
baseUrl: body.baseUrl,
|
|
648
|
+
authMode: body.auth.type,
|
|
649
|
+
status: "active"
|
|
650
|
+
});
|
|
651
|
+
if (payload) uow.create("secret", {
|
|
652
|
+
id: `${pathParams.slug}:auth`,
|
|
653
|
+
connectionId: pathParams.slug,
|
|
654
|
+
kind: "auth",
|
|
655
|
+
payload,
|
|
656
|
+
expiresAt: null
|
|
657
|
+
});
|
|
658
|
+
uow.triggerHook("onConnectionChanged", {
|
|
659
|
+
connectionId: pathParams.slug,
|
|
660
|
+
connection: connectionHookSnapshot({
|
|
661
|
+
id: pathParams.slug,
|
|
662
|
+
name: body.name ?? null,
|
|
663
|
+
baseUrl: body.baseUrl,
|
|
664
|
+
authMode: body.auth.type,
|
|
665
|
+
status: "active"
|
|
666
|
+
})
|
|
667
|
+
});
|
|
668
|
+
uow.triggerHook("onConnectionReadinessChanged", {
|
|
669
|
+
connectionId: pathParams.slug,
|
|
670
|
+
ready: isApiAuthReady(body.auth.type, payload === void 0 ? void 0 : {
|
|
671
|
+
payload,
|
|
672
|
+
expiresAt: null
|
|
673
|
+
})
|
|
674
|
+
});
|
|
675
|
+
return { exists: false };
|
|
676
|
+
}).execute()).exists) return error({
|
|
677
|
+
code: "CONNECTION_EXISTS",
|
|
678
|
+
message: "API connection already exists"
|
|
679
|
+
}, 409);
|
|
680
|
+
return json(publicConnection({
|
|
681
|
+
id: pathParams.slug,
|
|
682
|
+
name: body.name ?? null,
|
|
683
|
+
baseUrl: body.baseUrl,
|
|
684
|
+
authMode: body.auth.type,
|
|
685
|
+
status: "active"
|
|
686
|
+
}), 201);
|
|
687
|
+
}
|
|
688
|
+
}),
|
|
689
|
+
defineRoute({
|
|
690
|
+
method: "GET",
|
|
691
|
+
path: "/connections",
|
|
692
|
+
queryParameters: ["cursor"],
|
|
693
|
+
outputSchema: apiConnectionsPageSchema,
|
|
694
|
+
errorCodes: ["INVALID_CURSOR"],
|
|
695
|
+
handler: async function({ query }, { json, error }) {
|
|
696
|
+
let cursor = null;
|
|
697
|
+
const rawCursor = query.get("cursor");
|
|
698
|
+
if (rawCursor) try {
|
|
699
|
+
cursor = decodeCursor(rawCursor);
|
|
700
|
+
if (cursor.indexName !== "_primary" || cursor.orderDirection !== "asc" || cursor.pageSize !== CONNECTIONS_PAGE_SIZE || typeof cursor.indexValues["id"] !== "string") throw new Error("Connection cursor does not match this listing");
|
|
701
|
+
} catch {
|
|
702
|
+
return error({
|
|
703
|
+
code: "INVALID_CURSOR",
|
|
704
|
+
message: "Invalid connection cursor"
|
|
705
|
+
}, 400);
|
|
706
|
+
}
|
|
707
|
+
const [page] = await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findWithCursor("api_connection", (b) => {
|
|
708
|
+
const ordered = b.whereIndex("primary").orderByIndex("primary", "asc").pageSize(CONNECTIONS_PAGE_SIZE);
|
|
709
|
+
return cursor ? ordered.after(cursor) : ordered;
|
|
710
|
+
})).execute();
|
|
711
|
+
return json({
|
|
712
|
+
connections: page.items.map(publicConnection),
|
|
713
|
+
cursor: page.cursor?.encode() ?? null
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}),
|
|
717
|
+
defineRoute({
|
|
718
|
+
method: "GET",
|
|
719
|
+
path: "/connections/:slug",
|
|
720
|
+
outputSchema: apiConnectionOutputSchema,
|
|
721
|
+
errorCodes: ["CONNECTION_NOT_FOUND"],
|
|
722
|
+
handler: async function({ pathParams }, { json, error }) {
|
|
723
|
+
const [connection] = await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("api_connection", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.slug)))).execute();
|
|
724
|
+
if (!connection) return error({
|
|
725
|
+
code: "CONNECTION_NOT_FOUND",
|
|
726
|
+
message: "API connection not found"
|
|
727
|
+
}, 404);
|
|
728
|
+
return json(publicConnection(connection));
|
|
729
|
+
}
|
|
730
|
+
}),
|
|
731
|
+
defineRoute({
|
|
732
|
+
method: "PUT",
|
|
733
|
+
path: "/connections/:slug/configuration",
|
|
734
|
+
inputSchema: createApiConnectionInputSchema,
|
|
735
|
+
outputSchema: apiConnectionOutputSchema,
|
|
736
|
+
errorCodes: ["CONNECTION_NOT_FOUND", "BASE_URL_NOT_ALLOWED"],
|
|
737
|
+
handler: async function({ input, pathParams }, { json, error }) {
|
|
738
|
+
const body = await input.valid();
|
|
739
|
+
try {
|
|
740
|
+
assertAllowedBaseUrl(body.baseUrl, config);
|
|
741
|
+
} catch (err) {
|
|
742
|
+
logApiRouteError("API connection base URL rejected", err);
|
|
743
|
+
return error({
|
|
744
|
+
code: "BASE_URL_NOT_ALLOWED",
|
|
745
|
+
message: "API base URL is not allowed"
|
|
746
|
+
}, 400);
|
|
747
|
+
}
|
|
748
|
+
const payload = body.auth.type === "none" ? null : JSON.stringify(body.auth);
|
|
749
|
+
const result = await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("api_connection", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.slug))).find("secret", (b) => b.whereIndex("idx_secret_connection_kind", (eb) => eb("connectionId", "=", pathParams.slug))).find("oauthState", (b) => b.whereIndex("idx_oauth_state_connection", (eb) => eb("connectionId", "=", pathParams.slug)))).mutate(({ forSchema, retrieveResult: [connection, secrets, states] }) => {
|
|
750
|
+
if (!connection) return { connection: null };
|
|
751
|
+
const uow = forSchema(apiSchema);
|
|
752
|
+
const replaced = {
|
|
753
|
+
...connection,
|
|
754
|
+
name: body.name ?? null,
|
|
755
|
+
baseUrl: body.baseUrl,
|
|
756
|
+
authMode: body.auth.type
|
|
757
|
+
};
|
|
758
|
+
uow.update("api_connection", connection.id, (b) => b.set({
|
|
759
|
+
name: replaced.name,
|
|
760
|
+
baseUrl: replaced.baseUrl,
|
|
761
|
+
authMode: replaced.authMode,
|
|
762
|
+
updatedAt: b.now()
|
|
763
|
+
}).check());
|
|
764
|
+
const authSecret = secrets.find((secret) => secret.kind === "auth");
|
|
765
|
+
for (const secret of secrets) if (secret !== authSecret || payload === null) uow.delete("secret", secret.id);
|
|
766
|
+
if (payload !== null) if (authSecret) uow.update("secret", authSecret.id, (b) => b.set({
|
|
767
|
+
payload,
|
|
768
|
+
expiresAt: null,
|
|
769
|
+
updatedAt: b.now()
|
|
770
|
+
}).check());
|
|
771
|
+
else uow.create("secret", {
|
|
772
|
+
id: `${pathParams.slug}:auth`,
|
|
773
|
+
connectionId: pathParams.slug,
|
|
774
|
+
kind: "auth",
|
|
775
|
+
payload,
|
|
776
|
+
expiresAt: null
|
|
777
|
+
});
|
|
778
|
+
for (const state of states) uow.delete("oauthState", state.id);
|
|
779
|
+
uow.triggerHook("onConnectionChanged", {
|
|
780
|
+
connectionId: pathParams.slug,
|
|
781
|
+
connection: connectionHookSnapshot(replaced)
|
|
782
|
+
});
|
|
783
|
+
const ready = isApiAuthReady(replaced.authMode, payload === null ? void 0 : {
|
|
784
|
+
payload,
|
|
785
|
+
expiresAt: null
|
|
786
|
+
});
|
|
787
|
+
if (ready !== isApiAuthReady(connection.authMode, authSecret)) uow.triggerHook("onConnectionReadinessChanged", {
|
|
788
|
+
connectionId: pathParams.slug,
|
|
789
|
+
ready
|
|
790
|
+
});
|
|
791
|
+
return { connection: replaced };
|
|
792
|
+
}).execute();
|
|
793
|
+
if (!result.connection) return error({
|
|
794
|
+
code: "CONNECTION_NOT_FOUND",
|
|
795
|
+
message: "API connection not found"
|
|
796
|
+
}, 404);
|
|
797
|
+
return json(publicConnection(result.connection));
|
|
798
|
+
}
|
|
799
|
+
}),
|
|
800
|
+
defineRoute({
|
|
801
|
+
method: "DELETE",
|
|
802
|
+
path: "/connections/:slug",
|
|
803
|
+
errorCodes: ["CONNECTION_NOT_FOUND"],
|
|
804
|
+
handler: async function({ pathParams }, { empty, error }) {
|
|
805
|
+
if (!(await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("api_connection", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.slug))).find("secret", (b) => b.whereIndex("idx_secret_connection_kind", (eb) => eb("connectionId", "=", pathParams.slug))).find("oauthState", (b) => b.whereIndex("idx_oauth_state_connection", (eb) => eb("connectionId", "=", pathParams.slug)))).mutate(({ forSchema, retrieveResult: [connection, secrets, states] }) => {
|
|
806
|
+
if (!connection) return { deleted: false };
|
|
807
|
+
const uow = forSchema(apiSchema);
|
|
808
|
+
for (const secret of secrets) uow.delete("secret", secret.id);
|
|
809
|
+
for (const state of states) uow.delete("oauthState", state.id);
|
|
810
|
+
uow.delete("api_connection", connection.id);
|
|
811
|
+
uow.triggerHook("onConnectionDeleted", {
|
|
812
|
+
connectionId: pathParams.slug,
|
|
813
|
+
previous: connectionHookSnapshot(connection)
|
|
814
|
+
});
|
|
815
|
+
return { deleted: true };
|
|
816
|
+
}).execute()).deleted) return error({
|
|
817
|
+
code: "CONNECTION_NOT_FOUND",
|
|
818
|
+
message: "API connection not found"
|
|
819
|
+
}, 404);
|
|
820
|
+
return empty(204);
|
|
821
|
+
}
|
|
822
|
+
}),
|
|
823
|
+
defineRoute({
|
|
824
|
+
method: "GET",
|
|
825
|
+
path: "/connections/:slug/auth/status",
|
|
826
|
+
outputSchema: apiAuthStatusSchema,
|
|
827
|
+
errorCodes: ["CONNECTION_NOT_FOUND"],
|
|
828
|
+
handler: async function({ pathParams }, { json, error }) {
|
|
829
|
+
const [connection, secret, pendingState] = await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("api_connection", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.slug))).findFirst("secret", (b) => b.whereIndex("idx_secret_connection_kind", (eb) => eb.and(eb("connectionId", "=", pathParams.slug), eb("kind", "=", "auth")))).findFirst("oauthState", (b) => b.whereIndex("idx_oauth_state_pending", (eb) => eb.and(eb("connectionId", "=", pathParams.slug), eb.isNull("consumedAt"), eb("expiresAt", ">", eb.now()))).orderByIndex("idx_oauth_state_pending", "desc"))).execute();
|
|
830
|
+
if (!connection) return error({
|
|
831
|
+
code: "CONNECTION_NOT_FOUND",
|
|
832
|
+
message: "API connection not found"
|
|
833
|
+
}, 404);
|
|
834
|
+
return json(projectApiAuthStatus({
|
|
835
|
+
authMode: connection.authMode,
|
|
836
|
+
authSecret: secret ?? void 0,
|
|
837
|
+
hasPendingOAuth: pendingOAuthLink(pendingState) !== null
|
|
838
|
+
}));
|
|
839
|
+
}
|
|
840
|
+
}),
|
|
841
|
+
defineRoute({
|
|
842
|
+
method: "GET",
|
|
843
|
+
path: "/connections/:slug/auth/oauth/pending",
|
|
844
|
+
outputSchema: apiOAuthPendingSchema,
|
|
845
|
+
errorCodes: ["CONNECTION_NOT_FOUND"],
|
|
846
|
+
handler: async function({ pathParams }, { json, error }) {
|
|
847
|
+
const [connection, pendingState] = await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("api_connection", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.slug))).findFirst("oauthState", (b) => b.whereIndex("idx_oauth_state_pending", (eb) => eb.and(eb("connectionId", "=", pathParams.slug), eb.isNull("consumedAt"), eb("expiresAt", ">", eb.now()))).orderByIndex("idx_oauth_state_pending", "desc"))).execute();
|
|
848
|
+
if (!connection) return error({
|
|
849
|
+
code: "CONNECTION_NOT_FOUND",
|
|
850
|
+
message: "API connection not found"
|
|
851
|
+
}, 404);
|
|
852
|
+
return json({ pending: pendingOAuthLink(pendingState) });
|
|
853
|
+
}
|
|
854
|
+
}),
|
|
855
|
+
defineRoute({
|
|
856
|
+
method: "POST",
|
|
857
|
+
path: "/connections/:slug/auth/token",
|
|
858
|
+
inputSchema: tokenAuthInputSchema,
|
|
859
|
+
outputSchema: apiAuthStatusSchema,
|
|
860
|
+
errorCodes: ["CONNECTION_NOT_FOUND"],
|
|
861
|
+
handler: async function({ input, pathParams }, { json, error }) {
|
|
862
|
+
const { token } = await input.valid();
|
|
863
|
+
const payload = JSON.stringify({
|
|
864
|
+
type: "bearer",
|
|
865
|
+
token
|
|
866
|
+
});
|
|
867
|
+
if (!(await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("api_connection", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.slug))).findFirst("secret", (b) => b.whereIndex("idx_secret_connection_kind", (eb) => eb.and(eb("connectionId", "=", pathParams.slug), eb("kind", "=", "auth"))))).mutate(({ forSchema, retrieveResult: [connection, secret] }) => {
|
|
868
|
+
if (!connection) return { found: false };
|
|
869
|
+
const uow = forSchema(apiSchema);
|
|
870
|
+
uow.update("api_connection", connection.id, (b) => b.set({
|
|
871
|
+
authMode: "bearer",
|
|
872
|
+
updatedAt: b.now()
|
|
873
|
+
}).check());
|
|
874
|
+
if (secret) uow.update("secret", secret.id, (b) => b.set({
|
|
875
|
+
payload,
|
|
876
|
+
expiresAt: null,
|
|
877
|
+
updatedAt: b.now()
|
|
878
|
+
}).check());
|
|
879
|
+
else uow.create("secret", {
|
|
880
|
+
id: `${pathParams.slug}:auth`,
|
|
881
|
+
connectionId: pathParams.slug,
|
|
882
|
+
kind: "auth",
|
|
883
|
+
payload,
|
|
884
|
+
expiresAt: null
|
|
885
|
+
});
|
|
886
|
+
const changedConnection = connectionHookSnapshot({
|
|
887
|
+
...connection,
|
|
888
|
+
authMode: "bearer"
|
|
889
|
+
});
|
|
890
|
+
uow.triggerHook("onConnectionChanged", {
|
|
891
|
+
connectionId: pathParams.slug,
|
|
892
|
+
connection: changedConnection
|
|
893
|
+
});
|
|
894
|
+
if (!isApiAuthReady(connection.authMode, secret ?? void 0)) uow.triggerHook("onConnectionReadinessChanged", {
|
|
895
|
+
connectionId: pathParams.slug,
|
|
896
|
+
ready: true
|
|
897
|
+
});
|
|
898
|
+
return { found: true };
|
|
899
|
+
}).execute()).found) return error({
|
|
900
|
+
code: "CONNECTION_NOT_FOUND",
|
|
901
|
+
message: "API connection not found"
|
|
902
|
+
}, 404);
|
|
903
|
+
return json({
|
|
904
|
+
mode: "bearer",
|
|
905
|
+
credentials: "present"
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
}),
|
|
909
|
+
defineRoute({
|
|
910
|
+
method: "POST",
|
|
911
|
+
path: "/connections/:slug/auth/oauth/start",
|
|
912
|
+
inputSchema: oauthStartInputSchema,
|
|
913
|
+
outputSchema: oauthStartOutputSchema,
|
|
914
|
+
queryParameters: [API_OAUTH_REDIRECT_URI_QUERY_PARAMETER],
|
|
915
|
+
errorCodes: [
|
|
916
|
+
"INVALID_OAUTH_REDIRECT_URI",
|
|
917
|
+
"OAUTH_REDIRECT_URI_NOT_ALLOWED",
|
|
918
|
+
"CONNECTION_NOT_FOUND",
|
|
919
|
+
"AUTH_NOT_CONFIGURED",
|
|
920
|
+
"AUTH_NOT_OAUTH",
|
|
921
|
+
"OAUTH_ERROR"
|
|
922
|
+
],
|
|
923
|
+
handler: async function({ input, pathParams, query }, { json, error }) {
|
|
924
|
+
const body = await input.valid();
|
|
925
|
+
const redirectUri = oauthRedirectUriSchema.safeParse(query.get(API_OAUTH_REDIRECT_URI_QUERY_PARAMETER));
|
|
926
|
+
if (!redirectUri.success) return error({
|
|
927
|
+
code: "INVALID_OAUTH_REDIRECT_URI",
|
|
928
|
+
message: "OAuth redirect URI must be an HTTP or HTTPS URL"
|
|
929
|
+
}, 400);
|
|
930
|
+
const oauthRedirectUri = new URL(redirectUri.data);
|
|
931
|
+
if (!config.allowedOAuthRedirectUris?.(oauthRedirectUri)) return error({
|
|
932
|
+
code: "OAUTH_REDIRECT_URI_NOT_ALLOWED",
|
|
933
|
+
message: "OAuth redirect URI is not allowed by the fragment configuration"
|
|
934
|
+
}, 400);
|
|
935
|
+
const state = `${pathParams.slug}:${crypto.randomUUID()}`;
|
|
936
|
+
try {
|
|
937
|
+
const [result] = await this.handlerTx().withServiceCalls(() => [services.startOAuth({
|
|
938
|
+
connectionId: pathParams.slug,
|
|
939
|
+
stateId: state,
|
|
940
|
+
redirectUri: oauthRedirectUri.toString(),
|
|
941
|
+
scopes: body.scopes,
|
|
942
|
+
extraAuthorizationParams: body.extraAuthorizationParams,
|
|
943
|
+
discardTokens: body.discardTokens
|
|
944
|
+
})]).execute();
|
|
945
|
+
if (!result.found) {
|
|
946
|
+
if (result.reason === "connection_not_found") return error({
|
|
947
|
+
code: "CONNECTION_NOT_FOUND",
|
|
948
|
+
message: "API connection not found"
|
|
949
|
+
}, 404);
|
|
950
|
+
if (result.reason === "auth_not_configured") return error({
|
|
951
|
+
code: "AUTH_NOT_CONFIGURED",
|
|
952
|
+
message: "API connection auth is not configured"
|
|
953
|
+
}, 400);
|
|
954
|
+
return error({
|
|
955
|
+
code: "AUTH_NOT_OAUTH",
|
|
956
|
+
message: "API connection auth is not OAuth"
|
|
957
|
+
}, 400);
|
|
958
|
+
}
|
|
959
|
+
return json({
|
|
960
|
+
authorizationUrl: result.authorizationUrl,
|
|
961
|
+
state
|
|
962
|
+
});
|
|
963
|
+
} catch (err) {
|
|
964
|
+
logApiRouteError("API OAuth start failed", err);
|
|
965
|
+
return error({
|
|
966
|
+
code: "OAUTH_ERROR",
|
|
967
|
+
message: "An authentication error occurred"
|
|
968
|
+
}, 502);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}),
|
|
972
|
+
defineRoute({
|
|
973
|
+
method: "GET",
|
|
974
|
+
path: "/oauth/callback",
|
|
975
|
+
outputSchema: oauthCallbackOutputSchema,
|
|
976
|
+
errorCodes: [
|
|
977
|
+
"CONNECTION_NOT_FOUND",
|
|
978
|
+
"AUTH_NOT_CONFIGURED",
|
|
979
|
+
"AUTH_NOT_OAUTH",
|
|
980
|
+
"OAUTH_ERROR",
|
|
981
|
+
"INVALID_OAUTH_STATE"
|
|
982
|
+
],
|
|
983
|
+
handler: async function({ query }, { json, error }) {
|
|
984
|
+
const code = query.get("code");
|
|
985
|
+
const stateId = query.get("state");
|
|
986
|
+
if (!code || !stateId) return error({
|
|
987
|
+
code: "INVALID_OAUTH_STATE",
|
|
988
|
+
message: "Missing OAuth code or state"
|
|
989
|
+
}, 400);
|
|
990
|
+
try {
|
|
991
|
+
const [result] = await this.handlerTx().withServiceCalls(() => [services.completeOAuthCallback({
|
|
992
|
+
stateId,
|
|
993
|
+
code
|
|
994
|
+
})]).execute();
|
|
995
|
+
if (!result.found) {
|
|
996
|
+
if (result.reason === "connection_not_found") return error({
|
|
997
|
+
code: "CONNECTION_NOT_FOUND",
|
|
998
|
+
message: "API connection not found"
|
|
999
|
+
}, 404);
|
|
1000
|
+
if (result.reason === "auth_not_configured") return error({
|
|
1001
|
+
code: "AUTH_NOT_CONFIGURED",
|
|
1002
|
+
message: "API connection auth is not configured"
|
|
1003
|
+
}, 400);
|
|
1004
|
+
if (result.reason === "auth_not_oauth") return error({
|
|
1005
|
+
code: "AUTH_NOT_OAUTH",
|
|
1006
|
+
message: "API connection auth is not OAuth"
|
|
1007
|
+
}, 400);
|
|
1008
|
+
return error({
|
|
1009
|
+
code: "INVALID_OAUTH_STATE",
|
|
1010
|
+
message: "Invalid OAuth state"
|
|
1011
|
+
}, 400);
|
|
1012
|
+
}
|
|
1013
|
+
return json({
|
|
1014
|
+
authenticated: true,
|
|
1015
|
+
mode: "oauth"
|
|
1016
|
+
});
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
logApiRouteError("API OAuth callback failed", err);
|
|
1019
|
+
return error({
|
|
1020
|
+
code: "OAUTH_ERROR",
|
|
1021
|
+
message: "An authentication error occurred"
|
|
1022
|
+
}, 502);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}),
|
|
1026
|
+
defineRoute({
|
|
1027
|
+
method: "DELETE",
|
|
1028
|
+
path: "/connections/:slug/auth",
|
|
1029
|
+
outputSchema: apiAuthStatusSchema,
|
|
1030
|
+
errorCodes: ["CONNECTION_NOT_FOUND"],
|
|
1031
|
+
handler: async function({ pathParams }, { json, error }) {
|
|
1032
|
+
const result = await this.handlerTx().retrieve(({ forSchema }) => forSchema(apiSchema).findFirst("api_connection", (b) => b.whereIndex("primary", (eb) => eb("id", "=", pathParams.slug))).find("secret", (b) => b.whereIndex("idx_secret_connection_kind", (eb) => eb("connectionId", "=", pathParams.slug))).find("oauthState", (b) => b.whereIndex("idx_oauth_state_connection", (eb) => eb("connectionId", "=", pathParams.slug)))).mutate(({ forSchema, retrieveResult: [connection, secrets, states] }) => {
|
|
1033
|
+
if (!connection) return { found: false };
|
|
1034
|
+
const uow = forSchema(apiSchema);
|
|
1035
|
+
for (const secret of secrets) uow.delete("secret", secret.id);
|
|
1036
|
+
for (const state of states) uow.delete("oauthState", state.id);
|
|
1037
|
+
uow.update("api_connection", connection.id, (b) => b.set({ updatedAt: b.now() }).check());
|
|
1038
|
+
uow.triggerHook("onConnectionChanged", {
|
|
1039
|
+
connectionId: pathParams.slug,
|
|
1040
|
+
connection: connectionHookSnapshot(connection)
|
|
1041
|
+
});
|
|
1042
|
+
const authSecret = secrets.find((secret) => secret.kind === "auth");
|
|
1043
|
+
const ready = isApiAuthReady(connection.authMode, void 0);
|
|
1044
|
+
if (ready !== isApiAuthReady(connection.authMode, authSecret)) uow.triggerHook("onConnectionReadinessChanged", {
|
|
1045
|
+
connectionId: pathParams.slug,
|
|
1046
|
+
ready
|
|
1047
|
+
});
|
|
1048
|
+
return {
|
|
1049
|
+
found: true,
|
|
1050
|
+
status: projectApiAuthStatus({
|
|
1051
|
+
authMode: connection.authMode,
|
|
1052
|
+
authSecret: void 0,
|
|
1053
|
+
hasPendingOAuth: false
|
|
1054
|
+
})
|
|
1055
|
+
};
|
|
1056
|
+
}).execute();
|
|
1057
|
+
if (!result.found) return error({
|
|
1058
|
+
code: "CONNECTION_NOT_FOUND",
|
|
1059
|
+
message: "API connection not found"
|
|
1060
|
+
}, 404);
|
|
1061
|
+
return json(result.status);
|
|
1062
|
+
}
|
|
1063
|
+
}),
|
|
1064
|
+
defineRoute({
|
|
1065
|
+
method: "POST",
|
|
1066
|
+
path: "/connections/:slug/request",
|
|
1067
|
+
inputSchema: apiRequestInputSchema,
|
|
1068
|
+
outputSchema: apiRequestOutputSchema,
|
|
1069
|
+
handler: async function({ input, pathParams }, { json }) {
|
|
1070
|
+
const request = await input.valid();
|
|
1071
|
+
const [result] = await this.handlerTx().withServiceCalls(() => [services.executeApiRequest({
|
|
1072
|
+
connectionId: pathParams.slug,
|
|
1073
|
+
request
|
|
1074
|
+
})]).execute();
|
|
1075
|
+
if (!result.found) {
|
|
1076
|
+
if (result.reason === "connection_disabled") return json({
|
|
1077
|
+
ok: false,
|
|
1078
|
+
response: null,
|
|
1079
|
+
error: {
|
|
1080
|
+
code: "CONNECTION_DISABLED",
|
|
1081
|
+
message: "API connection is disabled"
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
return json({
|
|
1085
|
+
ok: false,
|
|
1086
|
+
response: null,
|
|
1087
|
+
error: {
|
|
1088
|
+
code: "CONNECTION_NOT_FOUND",
|
|
1089
|
+
message: "API connection not found"
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
return json(result.response);
|
|
1094
|
+
}
|
|
1095
|
+
})
|
|
1096
|
+
]);
|
|
1097
|
+
//#endregion
|
|
1098
|
+
export { apiRoutesFactory };
|
|
1099
|
+
|
|
1100
|
+
//# sourceMappingURL=routes.js.map
|