Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

hooks.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

hooks.tsBlame714 lines · 1 contributor
ad6d4adClaude1/**
2 * Inbound API hooks — endpoints that external systems (GateTest, CI runners,
3 * Crontech deploy) call into to report async results.
4 *
5 * Security: every hook is authenticated via a shared-secret Bearer token OR
6 * HMAC signature over the raw body. Configure secrets via env vars:
7 * GATETEST_CALLBACK_SECRET — bearer token GateTest must present
8 * GATETEST_HMAC_SECRET — optional HMAC-SHA256 secret over the raw body
9 *
10 * Endpoints:
11 * POST /api/hooks/gatetest — GateTest scan result callback
12 * POST /api/hooks/gatetest/status — periodic status / heartbeat (optional)
13 *
14 * The GateTest service should POST JSON of shape:
15 * {
16 * "repository": "owner/repo",
17 * "sha": "<full-commit-sha>",
18 * "ref": "refs/heads/main",
19 * "pullRequestNumber": 42, // optional
20 * "status": "passed" | "failed" | "error",
21 * "summary": "12 tests passed, 0 failed",
22 * "details": { ... } // optional, persisted as JSON
23 * }
24 *
25 * Response: 200 OK with {ok:true, gateRunId} on success, 401 on auth failure,
26 * 400 on malformed payload, 404 if the repo is unknown.
27 */
28
29import { Hono } from "hono";
30import { and, desc, eq } from "drizzle-orm";
31import { createHmac, timingSafeEqual } from "crypto";
32import { db } from "../db";
33import {
34 apiTokens,
35 gateRuns,
34e63b9Claude36 prComments,
ad6d4adClaude37 pullRequests,
38 repositories,
39 users,
6682dbeClaude40 flywheelTelemetry,
ad6d4adClaude41} from "../db/schema";
42import { notify, audit } from "../lib/notify";
eead172Claude43import {
44 generatePatchForGateTestFinding,
45 severityAtOrAboveMedium,
46 type GateTestFinding,
47} from "../lib/ai-patch-generator";
34e63b9Claude48import { triggerCiAutofix, applyAutofix } from "../lib/ci-autofix";
eead172Claude49import { config } from "../lib/config";
ad6d4adClaude50
51const hooks = new Hono();
52
eead172Claude53/**
54 * Best-effort extraction of an array of GateTest findings from a
55 * webhook payload's `details` blob. GateTest's schema isn't pinned in
56 * code yet, so we accept a handful of common shapes:
57 * - `details.findings: [...]`
58 * - `details.issues: [...]`
59 * - `details.results: [...]`
60 * - `details: [...]` (raw array)
61 */
62function extractFindings(details: unknown): GateTestFinding[] {
63 if (!details) return [];
64 if (Array.isArray(details)) return details as GateTestFinding[];
65 if (typeof details === "object") {
66 const d = details as Record<string, unknown>;
67 for (const key of ["findings", "issues", "results", "violations"]) {
68 const v = d[key];
69 if (Array.isArray(v)) return v as GateTestFinding[];
70 }
71 }
72 return [];
73}
74
ad6d4adClaude75interface GateTestPayload {
76 repository?: string;
77 sha?: string;
78 ref?: string;
79 pullRequestNumber?: number;
80 status?: "passed" | "failed" | "error" | "success";
81 summary?: string;
82 details?: unknown;
83 durationMs?: number;
84}
85
86function constantTimeEq(a: string, b: string): boolean {
87 const A = Buffer.from(a);
88 const B = Buffer.from(b);
89 if (A.length !== B.length) return false;
90 try {
91 return timingSafeEqual(A, B);
92 } catch {
93 return false;
94 }
95}
96
97function verifyGateTestAuth(c: any, rawBody: string): { ok: boolean; error?: string } {
98 const bearerSecret = process.env.GATETEST_CALLBACK_SECRET || "";
99 const hmacSecret = process.env.GATETEST_HMAC_SECRET || "";
100
101 // If no secret is configured, refuse by default — do NOT allow anonymous writes.
102 if (!bearerSecret && !hmacSecret) {
103 return {
104 ok: false,
105 error:
106 "Callback endpoint not configured: set GATETEST_CALLBACK_SECRET or GATETEST_HMAC_SECRET",
107 };
108 }
109
110 // Bearer auth (simpler, preferred for server-to-server)
111 if (bearerSecret) {
112 const auth = c.req.header("authorization") || "";
113 if (auth.startsWith("Bearer ")) {
114 const token = auth.slice(7).trim();
115 if (constantTimeEq(token, bearerSecret)) return { ok: true };
116 }
117 // Alternative header for tools that don't send Authorization
118 const xTok = c.req.header("x-gatetest-token") || "";
119 if (xTok && constantTimeEq(xTok, bearerSecret)) return { ok: true };
120 }
121
122 // HMAC signature over the raw body
123 if (hmacSecret) {
124 const sigHeader =
125 c.req.header("x-gatetest-signature") ||
126 c.req.header("x-signature-sha256") ||
127 "";
128 if (sigHeader) {
129 const expected =
130 "sha256=" +
131 createHmac("sha256", hmacSecret).update(rawBody).digest("hex");
132 if (constantTimeEq(sigHeader, expected)) return { ok: true };
133 }
134 }
135
136 return { ok: false, error: "Invalid or missing GateTest credentials" };
137}
138
139async function resolveRepo(full: string): Promise<{ id: string; ownerId: string; name: string } | null> {
140 if (!full || !full.includes("/")) return null;
141 const [owner, name] = full.split("/", 2);
142 try {
143 const [row] = await db
144 .select({
145 id: repositories.id,
146 ownerId: repositories.ownerId,
147 name: repositories.name,
148 })
149 .from(repositories)
150 .innerJoin(users, eq(repositories.ownerId, users.id))
151 .where(and(eq(users.username, owner), eq(repositories.name, name)))
152 .limit(1);
153 return row || null;
154 } catch {
155 return null;
156 }
157}
158
159async function resolvePullRequestId(
160 repositoryId: string,
161 number: number | undefined
162): Promise<string | null> {
163 if (!number) return null;
164 try {
165 const [row] = await db
166 .select({ id: pullRequests.id })
167 .from(pullRequests)
168 .where(
169 and(
170 eq(pullRequests.repositoryId, repositoryId),
171 eq(pullRequests.number, number)
172 )
173 )
174 .limit(1);
175 return row?.id || null;
176 } catch {
177 return null;
178 }
179}
180
181/**
182 * POST /api/hooks/gatetest
183 * Async scan result callback from GateTest.
184 */
185hooks.post("/api/hooks/gatetest", async (c) => {
186 const rawBody = await c.req.text();
187
188 const auth = verifyGateTestAuth(c, rawBody);
189 if (!auth.ok) {
190 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
191 }
192
193 let payload: GateTestPayload;
194 try {
195 payload = JSON.parse(rawBody) as GateTestPayload;
196 } catch {
197 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
198 }
199
200 if (!payload.repository || !payload.sha || !payload.status) {
201 return c.json(
202 {
203 ok: false,
204 error: "Required fields: repository (owner/name), sha, status",
205 },
206 400
207 );
208 }
209
210 const repo = await resolveRepo(payload.repository);
211 if (!repo) {
212 return c.json(
213 { ok: false, error: `Unknown repository: ${payload.repository}` },
214 404
215 );
216 }
217
218 const normalisedStatus =
219 payload.status === "passed" || payload.status === "success"
220 ? "passed"
221 : payload.status === "failed"
222 ? "failed"
223 : "failed"; // treat "error" as a hard fail
224
225 const pullRequestId = await resolvePullRequestId(
226 repo.id,
227 payload.pullRequestNumber
228 );
229
230 let gateRunId: string | null = null;
231 try {
232 const [row] = await db
233 .insert(gateRuns)
234 .values({
235 repositoryId: repo.id,
236 pullRequestId: pullRequestId || undefined,
237 commitSha: payload.sha,
238 ref: payload.ref || "refs/heads/main",
239 gateName: "GateTest",
240 status: normalisedStatus,
241 summary: payload.summary || `GateTest reported ${normalisedStatus}`,
242 details: payload.details ? JSON.stringify(payload.details) : null,
243 durationMs: payload.durationMs,
244 completedAt: new Date(),
245 })
246 .returning({ id: gateRuns.id });
247 gateRunId = row?.id || null;
248 } catch (err) {
249 console.error("[hooks/gatetest] insert failed:", err);
250 return c.json({ ok: false, error: "Failed to record gate run" }, 500);
251 }
252
253 // Notify the repo owner on failure
254 if (normalisedStatus === "failed") {
255 try {
256 await notify(repo.ownerId, {
257 kind: "gate_failed",
258 title: `GateTest failed on ${payload.repository}`,
259 body: payload.summary || "GateTest reported failure via callback",
260 repositoryId: repo.id,
261 });
262 } catch (err) {
263 console.error("[hooks/gatetest] notify failed:", err);
264 }
265 }
266
267 try {
268 await audit({
269 userId: null,
270 action: "gate_callback",
271 repositoryId: repo.id,
272 metadata: {
273 gateName: "GateTest",
274 sha: payload.sha,
275 status: normalisedStatus,
276 source: "gatetest-callback",
277 },
278 });
279 } catch {
280 /* swallow */
281 }
282
eead172Claude283 // AI patch generator — if the gate failed AND the env flag is on AND
284 // an Anthropic key is configured, fire-and-forget a patch PR for the
285 // first actionable medium+ severity finding. Never blocks the
286 // webhook response.
287 if (
288 normalisedStatus === "failed" &&
289 process.env.AI_PATCH_GENERATOR_ENABLED === "1" &&
290 config.anthropicApiKey
291 ) {
292 const findings = extractFindings(payload.details).filter((f) =>
293 severityAtOrAboveMedium(f.severity)
294 );
295 if (findings.length > 0) {
296 generatePatchForGateTestFinding({
297 repositoryId: repo.id,
298 baseSha: payload.sha,
299 findings,
300 reportUrl:
301 typeof (payload.details as Record<string, unknown> | null)?.reportUrl === "string"
302 ? ((payload.details as Record<string, string>).reportUrl)
303 : null,
304 }).catch((err) =>
305 console.error(
306 "[hooks/gatetest] AI patch generator crashed:",
307 err instanceof Error ? err.message : err
308 )
309 );
310 }
311 }
312
34e63b9Claude313 // CI auto-fix — if the gate failed on a PR, post a ready-to-apply patch
314 // comment. Fire-and-forget; never blocks the webhook response.
315 if (normalisedStatus === "failed" && gateRunId && pullRequestId) {
316 triggerCiAutofix(gateRunId).catch((err) =>
317 console.error(
318 "[hooks/gatetest] ci-autofix crashed:",
319 err instanceof Error ? err.message : err
320 )
321 );
322 }
323
ad6d4adClaude324 return c.json({ ok: true, gateRunId });
325});
326
327/**
328 * GET /api/hooks/gatetest/recent
329 * Small read-only endpoint GateTest can hit to verify connectivity +
330 * see the 10 most recent gate runs for sanity checks.
331 */
332hooks.get("/api/hooks/gatetest/recent", async (c) => {
333 const rawBody = "";
334 const auth = verifyGateTestAuth(c, rawBody);
335 if (!auth.ok) {
336 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
337 }
338
339 try {
340 const rows = await db
341 .select({
342 id: gateRuns.id,
343 gateName: gateRuns.gateName,
344 status: gateRuns.status,
345 summary: gateRuns.summary,
346 createdAt: gateRuns.createdAt,
347 })
348 .from(gateRuns)
349 .orderBy(desc(gateRuns.createdAt))
350 .limit(10);
351 return c.json({ ok: true, runs: rows });
352 } catch (err) {
353 console.error("[hooks/gatetest] recent failed:", err);
354 return c.json({ ok: false, error: "DB error" }, 500);
355 }
356});
357
358/**
359 * GET /api/hooks/ping
360 * Unauthenticated liveness — for GateTest to probe reachability without
361 * needing credentials. Returns 200 + version info.
362 */
363hooks.get("/api/hooks/ping", (c) => {
364 return c.json({
365 ok: true,
366 service: "gluecron",
367 hooks: ["gatetest", "gatetest/recent", "api/v1/gate-runs (backup)"],
368 timestamp: new Date().toISOString(),
369 });
370});
371
372// ---------------------------------------------------------------------------
373// Backup API: personal-access-token path
374//
375// If for any reason the shared-secret callback path is unavailable,
376// GateTest can authenticate with a standard GlueCron personal access token
377// and POST the same payload to /api/v1/gate-runs.
378//
379// Advantages of the backup path:
380// - no new secrets to provision (reuses existing PAT infra)
381// - scoped per-user (audit trail points at a real account)
382// - revocable from /settings/tokens
383//
384// Trade-off: slightly heavier auth (DB lookup per call), so prefer
385// /api/hooks/gatetest for high-volume traffic.
386// ---------------------------------------------------------------------------
387
388async function hashPat(token: string): Promise<string> {
389 const data = new TextEncoder().encode(token);
390 const hash = await crypto.subtle.digest("SHA-256", data);
391 return Array.from(new Uint8Array(hash))
392 .map((b) => b.toString(16).padStart(2, "0"))
393 .join("");
394}
395
396async function verifyPatAuth(
397 c: any
398): Promise<{ ok: boolean; userId?: string; error?: string }> {
399 const auth = c.req.header("authorization") || "";
400 const rawToken =
401 (auth.startsWith("Bearer ") ? auth.slice(7) : "").trim() ||
402 (c.req.header("x-api-token") || "").trim();
403 if (!rawToken) {
404 return { ok: false, error: "Missing Bearer token" };
405 }
406 if (!rawToken.startsWith("glc_")) {
407 return { ok: false, error: "Invalid token format" };
408 }
409 try {
410 const hashed = await hashPat(rawToken);
411 const [row] = await db
412 .select({ id: apiTokens.id, userId: apiTokens.userId, expiresAt: apiTokens.expiresAt })
413 .from(apiTokens)
414 .where(eq(apiTokens.tokenHash, hashed))
415 .limit(1);
416 if (!row) return { ok: false, error: "Unknown token" };
417 if (row.expiresAt && row.expiresAt < new Date()) {
418 return { ok: false, error: "Token expired" };
419 }
a28cedeClaude420 // Update last-used timestamp (fire and forget). Log persistent
421 // failures so DB issues surface (was silent .catch(() => {}).)
ad6d4adClaude422 db.update(apiTokens)
423 .set({ lastUsedAt: new Date() })
424 .where(eq(apiTokens.id, row.id))
a28cedeClaude425 .catch((err) => {
426 console.warn(
427 "[hooks] PAT lastUsedAt update failed:",
428 err instanceof Error ? err.message : err
429 );
430 });
ad6d4adClaude431 return { ok: true, userId: row.userId };
432 } catch {
433 return { ok: false, error: "Auth lookup failed" };
434 }
435}
436
437/**
438 * POST /api/v1/gate-runs
439 * Backup path — accepts a personal access token and records a gate run.
440 * Identical payload shape to /api/hooks/gatetest.
441 */
442hooks.post("/api/v1/gate-runs", async (c) => {
443 const rawBody = await c.req.text();
444
445 const auth = await verifyPatAuth(c);
446 if (!auth.ok) {
447 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
448 }
449
450 let payload: GateTestPayload & { gateName?: string };
451 try {
452 payload = JSON.parse(rawBody);
453 } catch {
454 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
455 }
456
457 if (!payload.repository || !payload.sha || !payload.status) {
458 return c.json(
459 { ok: false, error: "Required: repository, sha, status" },
460 400
461 );
462 }
463
464 const repo = await resolveRepo(payload.repository);
465 if (!repo) {
466 return c.json(
467 { ok: false, error: `Unknown repository: ${payload.repository}` },
468 404
469 );
470 }
471
472 // Scope check: the PAT's owner must own the repo OR have admin/write scope.
473 // For MVP, require the token owner to match the repo owner.
474 if (repo.ownerId !== auth.userId) {
475 return c.json(
476 { ok: false, error: "Token does not own this repository" },
477 403
478 );
479 }
480
481 const normalisedStatus =
482 payload.status === "passed" || payload.status === "success"
483 ? "passed"
484 : payload.status === "failed"
485 ? "failed"
486 : "failed";
487
488 const pullRequestId = await resolvePullRequestId(
489 repo.id,
490 payload.pullRequestNumber
491 );
492
493 let gateRunId: string | null = null;
494 try {
495 const [row] = await db
496 .insert(gateRuns)
497 .values({
498 repositoryId: repo.id,
499 pullRequestId: pullRequestId || undefined,
500 commitSha: payload.sha,
501 ref: payload.ref || "refs/heads/main",
502 gateName: payload.gateName || "GateTest",
503 status: normalisedStatus,
504 summary: payload.summary || `${payload.gateName || "GateTest"} reported ${normalisedStatus}`,
505 details: payload.details ? JSON.stringify(payload.details) : null,
506 durationMs: payload.durationMs,
507 completedAt: new Date(),
508 })
509 .returning({ id: gateRuns.id });
510 gateRunId = row?.id || null;
511 } catch (err) {
512 console.error("[hooks/backup] insert failed:", err);
513 return c.json({ ok: false, error: "Failed to record gate run" }, 500);
514 }
515
516 if (normalisedStatus === "failed") {
517 try {
518 await notify(repo.ownerId, {
519 kind: "gate_failed",
520 title: `${payload.gateName || "GateTest"} failed on ${payload.repository}`,
521 body: payload.summary || "Gate failure reported via backup API",
522 repositoryId: repo.id,
523 });
524 } catch {
525 /* swallow */
526 }
527 }
528
529 try {
530 await audit({
531 userId: auth.userId,
532 action: "gate_callback_backup",
533 repositoryId: repo.id,
534 metadata: {
535 gateName: payload.gateName || "GateTest",
536 sha: payload.sha,
537 status: normalisedStatus,
538 source: "pat-api",
539 },
540 });
541 } catch {
542 /* swallow */
543 }
544
eead172Claude545 // Same fire-and-forget AI patch hook as the primary callback path.
546 if (
547 normalisedStatus === "failed" &&
548 process.env.AI_PATCH_GENERATOR_ENABLED === "1" &&
549 config.anthropicApiKey
550 ) {
551 const findings = extractFindings(payload.details).filter((f) =>
552 severityAtOrAboveMedium(f.severity)
553 );
554 if (findings.length > 0) {
555 generatePatchForGateTestFinding({
556 repositoryId: repo.id,
557 baseSha: payload.sha,
558 findings,
559 reportUrl:
560 typeof (payload.details as Record<string, unknown> | null)?.reportUrl === "string"
561 ? ((payload.details as Record<string, string>).reportUrl)
562 : null,
563 }).catch((err) =>
564 console.error(
565 "[hooks/backup] AI patch generator crashed:",
566 err instanceof Error ? err.message : err
567 )
568 );
569 }
570 }
571
34e63b9Claude572 // CI auto-fix — post a ready-to-apply patch comment on the PR.
573 if (normalisedStatus === "failed" && gateRunId && pullRequestId) {
574 triggerCiAutofix(gateRunId).catch((err) =>
575 console.error(
576 "[hooks/backup] ci-autofix crashed:",
577 err instanceof Error ? err.message : err
578 )
579 );
580 }
581
ad6d4adClaude582 return c.json({ ok: true, gateRunId });
583});
584
585/**
586 * GET /api/v1/gate-runs?repository=owner/name&limit=20
587 * Backup read path — list recent gate runs for a repo (PAT-authed).
588 */
589hooks.get("/api/v1/gate-runs", async (c) => {
590 const auth = await verifyPatAuth(c);
591 if (!auth.ok) {
592 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
593 }
594
595 const repoFull = c.req.query("repository") || "";
596 const limit = Math.min(100, Math.max(1, Number(c.req.query("limit") || 20)));
597 if (!repoFull) {
598 return c.json({ ok: false, error: "Query param 'repository' required" }, 400);
599 }
600
601 const repo = await resolveRepo(repoFull);
602 if (!repo) return c.json({ ok: false, error: "Unknown repository" }, 404);
603 if (repo.ownerId !== auth.userId) {
604 return c.json({ ok: false, error: "Forbidden" }, 403);
605 }
606
607 try {
608 const rows = await db
609 .select()
610 .from(gateRuns)
611 .where(eq(gateRuns.repositoryId, repo.id))
612 .orderBy(desc(gateRuns.createdAt))
613 .limit(limit);
614 return c.json({ ok: true, runs: rows });
615 } catch {
616 return c.json({ ok: false, error: "DB error" }, 500);
617 }
618});
619
34e63b9Claude620/**
621 * POST /api/pr-comments/:commentId/apply-autofix
622 *
623 * Applies the patch embedded in a CI autofix comment to a new branch, then
624 * redirects to the compare view. Authenticated via a personal access token
625 * (same mechanism as /api/v1/gate-runs).
626 *
627 * Response on success (JSON): { ok: true, branchName, compareUrl }
628 * Response on failure (JSON): { ok: false, error }
629 */
630hooks.post("/api/pr-comments/:commentId/apply-autofix", async (c) => {
631 const auth = await verifyPatAuth(c);
632 if (!auth.ok || !auth.userId) {
633 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
634 }
635
636 const { commentId } = c.req.param();
637 if (!commentId) {
638 return c.json({ ok: false, error: "commentId param required" }, 400);
639 }
640
641 let result: { branchName: string };
642 try {
643 result = await applyAutofix(commentId, auth.userId);
644 } catch (err) {
645 const msg = err instanceof Error ? err.message : "Unknown error";
646 return c.json({ ok: false, error: msg }, 400);
647 }
648
649 // Look up the PR to build the compare URL
650 const commentRows = await db
651 .select({ pullRequestId: prComments.pullRequestId })
652 .from(prComments)
653 .where(eq(prComments.id, commentId))
654 .limit(1);
655
656 let compareUrl: string | null = null;
657 if (commentRows[0]) {
658 const prRows = await db
659 .select({
660 repositoryId: pullRequests.repositoryId,
661 number: pullRequests.number,
662 baseBranch: pullRequests.baseBranch,
663 })
664 .from(pullRequests)
665 .where(eq(pullRequests.id, commentRows[0].pullRequestId))
666 .limit(1);
667
668 if (prRows[0]) {
669 const repoRows = await db
670 .select({ name: repositories.name, ownerUsername: users.username })
671 .from(repositories)
672 .innerJoin(users, eq(repositories.ownerId, users.id))
673 .where(eq(repositories.id, prRows[0].repositoryId))
674 .limit(1);
675
676 if (repoRows[0]) {
677 compareUrl = `/${repoRows[0].ownerUsername}/${repoRows[0].name}/compare/${result.branchName}`;
678 }
679 }
680 }
681
682 return c.json({ ok: true, branchName: result.branchName, compareUrl });
683});
684
6682dbeClaude685// ---------------------------------------------------------------------------
686// Phase 2: Flywheel telemetry feedback endpoint
687// POST /api/flywheel-telemetry/feedback
688// Body: gateRunId, verdict ("helpful" | "hallucination")
689// ---------------------------------------------------------------------------
690hooks.post("/flywheel-telemetry/feedback", async (c) => {
691 const body = await c.req.parseBody();
692 const gateRunId = String(body.gateRunId ?? "").trim();
693 const verdict = String(body.verdict ?? "").trim();
694
695 if (!gateRunId) return c.json({ error: "gateRunId required" }, 400);
696 if (verdict !== "helpful" && verdict !== "hallucination") {
697 return c.json({ error: "verdict must be 'helpful' or 'hallucination'" }, 400);
698 }
699
700 const reworkRateStatus = verdict === "helpful" ? "confirmed_helpful" : "hallucination_flagged";
701
702 try {
703 await db
704 .update(flywheelTelemetry)
705 .set({ reworkRateStatus })
706 .where(eq(flywheelTelemetry.gateRunId, gateRunId));
707 return c.json({ ok: true, reworkRateStatus });
708 } catch (err) {
709 console.error("[flywheel-feedback] db error:", err);
710 return c.json({ error: "db error" }, 500);
711 }
712});
713
ad6d4adClaude714export default hooks;