File
Blob: src/worker/api/private/projects/webhook-handlers.ts
| 1 | import { |
| 2 | CreateWebhookRequest, |
| 3 | type GetProjectWebhooksResponse, |
| 4 | type RotateWebhookSecretResponse, |
| 5 | type UpsertWebhookResponse, |
| 6 | } from "@/contracts"; |
| 7 | import { requireOwnedProject } from "@/worker/api/private/shared"; |
| 8 | import type { AppContext } from "@/worker/hono"; |
| 9 | import { HttpError, parseJson } from "@/worker/http"; |
| 10 | import { serializeWebhookSummary } from "@/worker/presentation/serializers"; |
| 11 | import { encryptSecret, type EncryptedSecret } from "@/worker/security/secrets"; |
| 12 | import { generateOpaqueToken } from "@/worker/services"; |
| 13 | import { getProjectStub, logger, normalizeWebhookConfigIfPresent, requireWebhookProviderParam } from "./shared"; |
| 14 | |
| 15 | const 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 | |
| 23 | const 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 | |
| 34 | export 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 | |
| 45 | export 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 | |
| 112 | export 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 | |
| 137 | export 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 | }; |