Skip to content
File

Blob: src/worker/durable/project-do/webhooks/config.ts

typescript237 lines
1import type { ProjectId, WebhookProvider } from "@/contracts";
2import { validateWebhookConfigForUpsert } from "@/lib/webhooks";
3 
4import type { ProjectDoContext, ProjectStore } from "../types";
5import { getProjectConfigRow } from "../repo";
6import {
7 deleteProjectWebhookRow,
8 insertProjectWebhookRow,
9 getProjectWebhookRow,
10 listProjectWebhookRows,
11 listRecentWebhookDeliveryRows,
12 parseStoredWebhook,
13 parseWebhookVerificationMaterial,
14 pruneWebhookDeliveries,
15 rotateProjectWebhookSecretRow,
16 touchProjectWebhookRows,
17 updateProjectWebhookSettingsRow,
18} from "./repo";
19import {
20 MAX_WEBHOOK_RECENT_DELIVERIES,
21 type DeleteProjectWebhookInput,
22 type RotateProjectWebhookSecretInput,
23 type StoredProjectWebhook,
24 type TouchProjectWebhookVersionsInput,
25 type UpsertProjectWebhookInput,
26 type UpsertProjectWebhookResult,
27 WEBHOOK_DELIVERY_RETENTION_MS,
28 type WebhookVerificationMaterial,
29} from "./types";
30 
31const loadStoredProjectWebhook = (
32 tx: ProjectStore,
33 projectId: ProjectId,
34 provider: WebhookProvider,
35): StoredProjectWebhook | null => {
36 const row = getProjectWebhookRow(tx, projectId, provider);
37 if (!row) {
38 return null;
39 }
40 
41 const recentDeliveries = listRecentWebhookDeliveryRows(tx, projectId, provider, MAX_WEBHOOK_RECENT_DELIVERIES);
42 
43 return parseStoredWebhook(row, recentDeliveries);
44};
45 
46const transitionUpsertProjectWebhook = (
47 tx: ProjectStore,
48 input: UpsertProjectWebhookInput,
49): UpsertProjectWebhookResult => {
50 const existingWebhook = loadStoredProjectWebhook(tx, input.projectId, input.provider);
51 
52 if (input.creating) {
53 if (existingWebhook) {
54 return {
55 kind: "conflict",
56 reason: "create_conflict",
57 };
58 }
59 
60 if (!input.encryptedSecret) {
61 throw new Error(`Missing encrypted secret for new webhook ${input.provider}.`);
62 }
63 
64 const projectConfigRow = getProjectConfigRow(tx, input.projectId);
65 if (!projectConfigRow) {
66 throw new Error(`Project config ${input.projectId} is missing during webhook create.`);
67 }
68 
69 const config = validateWebhookConfigForUpsert({
70 provider: input.provider,
71 projectRepoUrl: projectConfigRow.repoUrl,
72 incomingConfig: input.config,
73 existingConfig: null,
74 creating: true,
75 });
76 if (!config.ok) {
77 return {
78 kind: "invalid",
79 status: config.status,
80 code: config.code,
81 message: config.message,
82 };
83 }
84 
85 insertProjectWebhookRow(tx, {
86 projectId: input.projectId,
87 provider: input.provider,
88 enabled: input.enabled,
89 config: config.config,
90 encryptedSecret: input.encryptedSecret,
91 now: input.now,
92 });
93 
94 const createdWebhook = loadStoredProjectWebhook(tx, input.projectId, input.provider);
95 if (!createdWebhook) {
96 throw new Error(`Webhook ${input.provider} for project ${input.projectId} is missing after create.`);
97 }
98 
99 return {
100 kind: "applied",
101 created: true,
102 webhook: createdWebhook,
103 };
104 }
105 
106 if (!existingWebhook) {
107 return {
108 kind: "not_found",
109 };
110 }
111 
112 if (input.encryptedSecret) {
113 return {
114 kind: "rejected",
115 reason: "secret_not_allowed",
116 };
117 }
118 
119 const projectConfigRow = getProjectConfigRow(tx, input.projectId);
120 if (!projectConfigRow) {
121 throw new Error(`Project config ${input.projectId} is missing during webhook update.`);
122 }
123 
124 const resolvedConfig = validateWebhookConfigForUpsert({
125 provider: input.provider,
126 projectRepoUrl: projectConfigRow.repoUrl,
127 incomingConfig: input.config,
128 existingConfig: existingWebhook.config,
129 creating: false,
130 });
131 if (!resolvedConfig.ok) {
132 return {
133 kind: "invalid",
134 status: resolvedConfig.status,
135 code: resolvedConfig.code,
136 message: resolvedConfig.message,
137 };
138 }
139 
140 updateProjectWebhookSettingsRow(tx, {
141 rowId: existingWebhook.id,
142 previousUpdatedAt: existingWebhook.updatedAt,
143 enabled: input.enabled,
144 config: input.config === undefined ? undefined : resolvedConfig.config,
145 now: input.now,
146 });
147 
148 const updatedWebhook = loadStoredProjectWebhook(tx, input.projectId, input.provider);
149 if (!updatedWebhook) {
150 throw new Error(`Webhook ${input.provider} for project ${input.projectId} is missing after update.`);
151 }
152 
153 return {
154 kind: "applied",
155 created: false,
156 webhook: updatedWebhook,
157 };
158};
159 
160export const getWebhookVerificationMaterial = async (
161 context: ProjectDoContext,
162 projectId: ProjectId,
163 provider: WebhookProvider,
164): Promise<WebhookVerificationMaterial | null> => {
165 const row = getProjectWebhookRow(context.db, projectId, provider);
166 return row ? parseWebhookVerificationMaterial(row) : null;
167};
168 
169export const listProjectWebhooks = async (
170 context: ProjectDoContext,
171 projectId: ProjectId,
172): Promise<StoredProjectWebhook[]> => {
173 const rows = listProjectWebhookRows(context.db, projectId);
174 return rows.map((row) =>
175 parseStoredWebhook(
176 row,
177 listRecentWebhookDeliveryRows(
178 context.db,
179 projectId,
180 row.provider as WebhookProvider,
181 MAX_WEBHOOK_RECENT_DELIVERIES,
182 ),
183 ),
184 );
185};
186 
187export const upsertProjectWebhook = async (
188 context: ProjectDoContext,
189 input: UpsertProjectWebhookInput,
190): Promise<UpsertProjectWebhookResult> => {
191 return context.db.transaction((tx) => {
192 pruneWebhookDeliveries(tx, input.projectId, input.now - WEBHOOK_DELIVERY_RETENTION_MS);
193 return transitionUpsertProjectWebhook(tx, input);
194 });
195};
196 
197export const rotateProjectWebhookSecret = async (
198 context: ProjectDoContext,
199 input: RotateProjectWebhookSecretInput,
200): Promise<StoredProjectWebhook | null> => {
201 return context.db.transaction((tx) => {
202 const rotated = rotateProjectWebhookSecretRow(tx, input);
203 if (!rotated) {
204 return null;
205 }
206 
207 const storedWebhook = loadStoredProjectWebhook(tx, input.projectId, input.provider);
208 if (!storedWebhook) {
209 throw new Error(`Webhook ${input.provider} for project ${input.projectId} is missing after rotate.`);
210 }
211 
212 return storedWebhook;
213 });
214};
215 
216export const deleteProjectWebhook = async (
217 context: ProjectDoContext,
218 input: DeleteProjectWebhookInput,
219): Promise<boolean> =>
220 context.db.transaction((tx) => {
221 const deleted = deleteProjectWebhookRow(tx, input);
222 if (!deleted) {
223 return false;
224 }
225 
226 return true;
227 });
228 
229export const touchProjectWebhookVersions = async (
230 context: ProjectDoContext,
231 input: TouchProjectWebhookVersionsInput,
232): Promise<void> => {
233 context.db.transaction((tx) => {
234 touchProjectWebhookRows(tx, input);
235 });
236};