Skip to content
File

Blob: src/worker/api/private/projects/webhook-handlers.ts

typescript157 lines
1import {
2 CreateWebhookRequest,
3 type GetProjectWebhooksResponse,
4 type RotateWebhookSecretResponse,
5 type UpsertWebhookResponse,
6} from "@/contracts";
7import { requireOwnedProject } from "@/worker/api/private/shared";
8import type { AppContext } from "@/worker/hono";
9import { HttpError, parseJson } from "@/worker/http";
10import { serializeWebhookSummary } from "@/worker/presentation/serializers";
11import { encryptSecret, type EncryptedSecret } from "@/worker/security/secrets";
12import { generateOpaqueToken } from "@/worker/services";
13import { getProjectStub, logger, normalizeWebhookConfigIfPresent, requireWebhookProviderParam } from "./shared";
14 
15const assertWebhookSecret = (secret: string): string => {
16 if (secret.length === 0) {
17 throw new HttpError(400, "invalid_webhook_secret", "Webhook secret cannot be empty.");
18 }
19 
20 return secret;
21};
22 
23const issueWebhookSecret = async (
24 env: Env,
25 secret = generateOpaqueToken(32),
26): Promise<{
27 secret: string;
28 encryptedSecret: EncryptedSecret;
29}> => ({
30 secret,
31 encryptedSecret: await encryptSecret(env, secret),
32});
33 
34export const handleGetProjectWebhooks = async (c: AppContext): Promise<Response> => {
35 const { projectId } = await requireOwnedProject(c);
36 const webhooks = await getProjectStub(c.env, projectId).listProjectWebhooks(projectId);
37 
38 const response: GetProjectWebhooksResponse = {
39 webhooks: webhooks.map(serializeWebhookSummary),
40 };
41 
42 return c.json(response, 200);
43};
44 
45export const handleUpsertProjectWebhook = async (c: AppContext): Promise<Response> => {
46 const { projectId } = await requireOwnedProject(c);
47 const provider = requireWebhookProviderParam(c);
48 const now = Date.now();
49 
50 const payload = await parseJson(c.req.raw, CreateWebhookRequest);
51 const projectStub = getProjectStub(c.env, projectId);
52 const existingWebhook = await projectStub.getWebhookVerificationMaterial(projectId, provider);
53 let generatedSecret: string | null = null;
54 let encryptedSecret: EncryptedSecret | undefined;
55 const creating = existingWebhook === null;
56 
57 // Secret is encrypted here even on update; the DO rejects it (secret_not_allowed).
58 // Intentional: the DO is the authoritative guard; skipping encryption here would lose defense-in-depth.
59 if (payload.secret !== undefined) {
60 ({ encryptedSecret } = await issueWebhookSecret(c.env, assertWebhookSecret(payload.secret)));
61 } else if (creating) {
62 ({ secret: generatedSecret, encryptedSecret } = await issueWebhookSecret(c.env));
63 }
64 
65 const normalizedConfig = normalizeWebhookConfigIfPresent(payload.config);
66 
67 const result = await projectStub.upsertProjectWebhook({
68 projectId,
69 provider,
70 enabled: payload.enabled,
71 config: normalizedConfig,
72 encryptedSecret,
73 creating,
74 now,
75 });
76 
77 switch (result.kind) {
78 case "conflict":
79 throw new HttpError(
80 409,
81 "webhook_create_conflict",
82 "Webhook already exists. Retry without a secret or rotate the secret instead.",
83 );
84 case "rejected":
85 throw new HttpError(
86 400,
87 "invalid_webhook_secret",
88 "Webhook secret cannot be updated inline. Rotate the secret instead.",
89 );
90 case "invalid":
91 throw new HttpError(result.status as 400 | 404 | 409 | 500, result.code, result.message);
92 case "not_found":
93 throw new HttpError(404, "webhook_not_found", "Webhook was not found.");
94 case "applied":
95 break;
96 }
97 
98 logger.info("project_webhook_upserted", {
99 projectId,
100 provider,
101 created: result.created,
102 });
103 
104 const response: UpsertWebhookResponse = {
105 webhook: serializeWebhookSummary(result.webhook),
106 generatedSecret: result.created ? generatedSecret : null,
107 };
108 
109 return c.json(response, result.created ? 201 : 200);
110};
111 
112export const handleRotateProjectWebhookSecret = async (c: AppContext): Promise<Response> => {
113 const { projectId } = await requireOwnedProject(c);
114 const provider = requireWebhookProviderParam(c);
115 const { secret, encryptedSecret } = await issueWebhookSecret(c.env);
116 
117 const webhook = await getProjectStub(c.env, projectId).rotateProjectWebhookSecret({
118 projectId,
119 provider,
120 encryptedSecret,
121 now: Date.now(),
122 });
123 
124 if (!webhook) {
125 throw new HttpError(404, "webhook_not_found", "Webhook was not found.");
126 }
127 
128 logger.info("project_webhook_secret_rotated", {
129 projectId,
130 provider,
131 });
132 
133 const response: RotateWebhookSecretResponse = { secret };
134 return c.json(response, 200);
135};
136 
137export const handleDeleteProjectWebhook = async (c: AppContext): Promise<Response> => {
138 const { projectId } = await requireOwnedProject(c);
139 const provider = requireWebhookProviderParam(c);
140 
141 const deleted = await getProjectStub(c.env, projectId).deleteProjectWebhook({
142 projectId,
143 provider,
144 });
145 
146 if (!deleted) {
147 throw new HttpError(404, "webhook_not_found", "Webhook was not found.");
148 }
149 
150 logger.info("project_webhook_deleted", {
151 projectId,
152 provider,
153 });
154 
155 return new Response(null, { status: 204 });
156};