Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

events.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.

events.tsBlame706 lines · 1 contributor
9e1e93aClaude1/**
2 * Inbound deploy-event receiver for Crontech (Signal Bus P1 — E3/E4).
3 *
4 * Wire contract reference: chat-defined spec for Crontech → Gluecron deploy
5 * events. Gluecron's OWN copy per HTTP-only coupling rule — do NOT import any
6 * types from Crontech. If the contract is renegotiated, update this comment
7 * and the validation below in lock-step.
8 *
9 * POST /api/events/deploy
10 * Authorization: Bearer ${CRONTECH_EVENT_TOKEN}
11 * Content-Type: application/json
12 *
13 * {
14 * "event": "deploy.succeeded" | "deploy.failed",
15 * "eventId": "<uuid-v4>", // idempotency key
16 * "repository": "owner/name",
17 * "sha": "<40-hex>",
18 * "environment": "production",
19 * "deploymentId": "<crontech-id>",
20 * "durationMs": <int>, // optional
21 * "errorCategory": "build|runtime|timeout|config", // required on failed
22 * "errorSummary": "<string ≤500>", // required on failed
23 * "logsUrl": "<string>", // optional
24 * "timestamp": "<ISO-8601>"
25 * }
26 *
27 * → 200 { ok: true, duplicate: false }
28 * → 200 { ok: true, duplicate: true }
29 * → 401 invalid bearer
30 * → 400 malformed payload
31 *
32 * Idempotency: an incoming `eventId` is first looked up in `processed_events`.
33 * On hit we return { duplicate: true } immediately — no side-effects. On miss
34 * we INSERT the idempotency record BEFORE performing the side-effect update so
35 * that a retry after a crash between steps sees the record and short-circuits.
36 *
37 * Side-effect: look up the matching `deployments` row by
38 * (repository_id, commit_sha, environment) — `deployments` has no
39 * `crontech_deployment_id` column so we key off the tuple that
40 * `triggerCrontechDeploy` writes on the way out.
41 *
42 * E3 deploy.succeeded → status='success', completedAt=now
43 * E4 deploy.failed → status='failed', blockedReason=errorSummary,
44 * completedAt=now, notify(owner, 'deploy_failed')
45 */
46
47import { Hono } from "hono";
48import { and, desc, eq } from "drizzle-orm";
49import { timingSafeEqual } from "crypto";
50import { db } from "../db";
51import { deployments, repositories, users } from "../db/schema";
52import { processedEvents } from "../db/schema-events";
f764c07Claude53import { platformDeploys } from "../db/schema-deploys";
9e1e93aClaude54import { notify } from "../lib/notify";
f764c07Claude55import { publish } from "../lib/sse";
9e1e93aClaude56
57const events = new Hono();
58
59// ---------------------------------------------------------------------------
60// Bearer auth — timing-safe comparison against CRONTECH_EVENT_TOKEN.
61// ---------------------------------------------------------------------------
62
63function constantTimeEq(a: string, b: string): boolean {
64 const A = Buffer.from(a);
65 const B = Buffer.from(b);
66 if (A.length !== B.length) return false;
67 try {
68 return timingSafeEqual(A, B);
69 } catch {
70 return false;
71 }
72}
73
74function verifyBearer(c: any): { ok: boolean; error?: string } {
75 const expected = process.env.CRONTECH_EVENT_TOKEN || "";
76 if (!expected) {
77 // Refuse by default — an unset secret must NOT allow anonymous writes.
78 return {
79 ok: false,
80 error:
81 "Event endpoint not configured: set CRONTECH_EVENT_TOKEN in the environment",
82 };
83 }
84 const auth = c.req.header("authorization") || "";
85 if (!auth.startsWith("Bearer ")) {
86 return { ok: false, error: "Missing Bearer token" };
87 }
88 const token = auth.slice(7).trim();
89 if (!constantTimeEq(token, expected)) {
90 return { ok: false, error: "Invalid bearer token" };
91 }
92 return { ok: true };
93}
94
95// ---------------------------------------------------------------------------
96// Payload validation — no zod in this repo, use manual checks mirroring the
97// existing hooks.ts style. Keep error messages specific enough to diagnose a
98// mis-built emitter without leaking internals.
99// ---------------------------------------------------------------------------
100
101type DeployEvent = "deploy.succeeded" | "deploy.failed";
102type ErrorCategory = "build" | "runtime" | "timeout" | "config";
103
104interface DeployEventPayload {
105 event: DeployEvent;
106 eventId: string;
107 repository: string;
108 sha: string;
109 environment: string;
110 deploymentId: string;
111 durationMs?: number;
112 errorCategory?: ErrorCategory;
113 errorSummary?: string;
114 logsUrl?: string;
115 timestamp: string;
116}
117
118const UUID_V4_RE =
119 /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
120const SHA_RE = /^[0-9a-f]{40}$/i;
121const VALID_EVENTS: ReadonlySet<string> = new Set([
122 "deploy.succeeded",
123 "deploy.failed",
124]);
125const VALID_CATEGORIES: ReadonlySet<string> = new Set([
126 "build",
127 "runtime",
128 "timeout",
129 "config",
130]);
131
132function validatePayload(raw: unknown): {
133 ok: true;
134 payload: DeployEventPayload;
135} | { ok: false; error: string } {
136 if (!raw || typeof raw !== "object") {
137 return { ok: false, error: "Body must be a JSON object" };
138 }
139 const p = raw as Record<string, unknown>;
140
141 if (typeof p.event !== "string" || !VALID_EVENTS.has(p.event)) {
142 return {
143 ok: false,
144 error: "event must be 'deploy.succeeded' or 'deploy.failed'",
145 };
146 }
147 if (typeof p.eventId !== "string" || !UUID_V4_RE.test(p.eventId)) {
148 return { ok: false, error: "eventId must be a uuid-v4 string" };
149 }
150 if (typeof p.repository !== "string" || !p.repository.includes("/")) {
151 return { ok: false, error: "repository must be '<owner>/<name>'" };
152 }
153 if (typeof p.sha !== "string" || !SHA_RE.test(p.sha)) {
154 return { ok: false, error: "sha must be a 40-hex commit id" };
155 }
156 if (typeof p.environment !== "string" || p.environment.length === 0) {
157 return { ok: false, error: "environment must be a non-empty string" };
158 }
159 if (typeof p.deploymentId !== "string" || p.deploymentId.length === 0) {
160 return { ok: false, error: "deploymentId must be a non-empty string" };
161 }
162 if (typeof p.timestamp !== "string" || Number.isNaN(Date.parse(p.timestamp))) {
163 return { ok: false, error: "timestamp must be an ISO-8601 string" };
164 }
165 if (p.durationMs !== undefined) {
166 if (
167 typeof p.durationMs !== "number" ||
168 !Number.isFinite(p.durationMs) ||
169 p.durationMs < 0
170 ) {
171 return { ok: false, error: "durationMs must be a non-negative number" };
172 }
173 }
174 if (p.logsUrl !== undefined && typeof p.logsUrl !== "string") {
175 return { ok: false, error: "logsUrl must be a string when present" };
176 }
177
178 if (p.event === "deploy.failed") {
179 if (
180 typeof p.errorCategory !== "string" ||
181 !VALID_CATEGORIES.has(p.errorCategory)
182 ) {
183 return {
184 ok: false,
185 error:
186 "errorCategory is required for deploy.failed and must be one of build|runtime|timeout|config",
187 };
188 }
189 if (
190 typeof p.errorSummary !== "string" ||
191 p.errorSummary.length === 0 ||
192 p.errorSummary.length > 500
193 ) {
194 return {
195 ok: false,
196 error:
197 "errorSummary is required for deploy.failed and must be 1-500 chars",
198 };
199 }
200 }
201
202 return { ok: true, payload: p as unknown as DeployEventPayload };
203}
204
205// ---------------------------------------------------------------------------
206// Helpers
207// ---------------------------------------------------------------------------
208
209async function resolveRepo(
210 full: string
211): Promise<{ id: string; ownerId: string } | null> {
212 if (!full.includes("/")) return null;
213 const [owner, name] = full.split("/", 2);
214 try {
215 const [row] = await db
216 .select({
217 id: repositories.id,
218 ownerId: repositories.ownerId,
219 })
220 .from(repositories)
221 .innerJoin(users, eq(repositories.ownerId, users.id))
222 .where(and(eq(users.username, owner), eq(repositories.name, name)))
223 .limit(1);
224 return row || null;
225 } catch {
226 return null;
227 }
228}
229
230async function findTargetDeployment(
231 repositoryId: string,
232 commitSha: string,
233 environment: string
234): Promise<{ id: string } | null> {
235 try {
236 const [row] = await db
237 .select({ id: deployments.id })
238 .from(deployments)
239 .where(
240 and(
241 eq(deployments.repositoryId, repositoryId),
242 eq(deployments.commitSha, commitSha),
243 eq(deployments.environment, environment)
244 )
245 )
246 .orderBy(desc(deployments.createdAt))
247 .limit(1);
248 return row || null;
249 } catch {
250 return null;
251 }
252}
253
254// ---------------------------------------------------------------------------
255// POST /api/events/deploy
256// ---------------------------------------------------------------------------
257
258events.post("/deploy", async (c) => {
259 const auth = verifyBearer(c);
260 if (!auth.ok) {
261 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
262 }
263
264 let raw: unknown;
265 try {
266 raw = await c.req.json();
267 } catch {
268 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
269 }
270
271 const validated = validatePayload(raw);
272 if (!validated.ok) {
273 return c.json({ ok: false, error: validated.error }, 400);
274 }
275 const payload = validated.payload;
276
277 // --- Idempotency check ---------------------------------------------------
278 // If we've already processed this eventId, return duplicate:true without
279 // firing any side-effects.
280 try {
281 const [existing] = await db
282 .select({ id: processedEvents.id })
283 .from(processedEvents)
284 .where(eq(processedEvents.eventId, payload.eventId))
285 .limit(1);
286 if (existing) {
287 return c.json({ ok: true, duplicate: true });
288 }
289 } catch (err) {
290 console.error("[events/deploy] idempotency lookup failed:", err);
291 // Fall through — better to process than to wedge on a transient DB blip.
292 }
293
294 // --- Record the idempotency token BEFORE side-effects --------------------
295 // Race: two simultaneous deliveries of the same eventId. The UNIQUE
296 // constraint on event_id makes the losing insert throw; we catch that and
297 // return duplicate:true to keep behaviour stable.
298 try {
299 await db.insert(processedEvents).values({
300 eventId: payload.eventId,
301 eventType: payload.event,
302 source: "crontech",
303 payload: payload as unknown as Record<string, unknown>,
304 });
305 } catch (err) {
306 const msg = err instanceof Error ? err.message : String(err);
307 if (msg.includes("unique") || msg.includes("duplicate")) {
308 return c.json({ ok: true, duplicate: true });
309 }
310 console.error("[events/deploy] processed_events insert failed:", err);
311 return c.json({ ok: false, error: "Failed to persist event" }, 500);
312 }
313
314 // --- Side-effect: update the matching deployments row --------------------
315 const repo = await resolveRepo(payload.repository);
316 if (!repo) {
317 // The idempotency row is already committed; we accept the event so we
318 // don't invite infinite retries for a repo that genuinely doesn't exist
319 // on this side of the wire. Log for operator follow-up.
320 console.warn(
321 `[events/deploy] unknown repository ${payload.repository} — event ${payload.eventId} accepted but no deployment update applied`
322 );
323 return c.json({ ok: true, duplicate: false });
324 }
325
326 const target = await findTargetDeployment(
327 repo.id,
328 payload.sha,
329 payload.environment
330 );
331
332 if (target) {
333 try {
334 if (payload.event === "deploy.succeeded") {
335 await db
336 .update(deployments)
337 .set({
338 status: "success",
339 completedAt: new Date(),
340 })
341 .where(eq(deployments.id, target.id));
342 } else {
343 await db
344 .update(deployments)
345 .set({
346 status: "failed",
347 blockedReason: payload.errorSummary,
348 completedAt: new Date(),
349 })
350 .where(eq(deployments.id, target.id));
351 }
352 } catch (err) {
353 console.error("[events/deploy] deployments update failed:", err);
354 }
355 } else {
356 console.warn(
357 `[events/deploy] no deployments row found for ${payload.repository}@${payload.sha} env=${payload.environment}`
358 );
359 }
360
361 // --- On failure: notify the repo owner ----------------------------------
362 if (payload.event === "deploy.failed") {
363 try {
364 await notify(repo.ownerId, {
365 kind: "deploy_failed",
366 title: `Deploy failed on ${payload.repository}`,
367 body:
368 payload.errorSummary ||
369 `Crontech reported deploy failure (${payload.errorCategory || "unknown"})`,
370 url: payload.logsUrl,
371 repositoryId: repo.id,
372 });
373 } catch (err) {
374 console.error("[events/deploy] notify failed:", err);
375 }
376 }
377
378 return c.json({ ok: true, duplicate: false });
379});
380
f764c07Claude381// ---------------------------------------------------------------------------
382// Block N3 — Platform deploy timeline ingest.
383//
384// These endpoints are FOR THIS SITE. The Hetzner deploy workflow posts a
385// 'started' event when SSH begins and a 'finished' event on success/failure.
386// They power the admin status pill in `src/views/layout.tsx` and the
387// `/admin/deploys` timeline. NEW endpoints — they do NOT touch the Crontech
388// `/deploy` receiver above (which §4.6 locks the semantics of).
389//
390// POST /api/events/deploy/started
391// Authorization: Bearer ${DEPLOY_EVENT_TOKEN}
392// Body: { sha: "<40-hex>", run_id: "<string>", source: "<string>" }
393// 200 { ok: true, duplicate: false | true }
394// 401 invalid bearer
395// 400 malformed payload
396//
397// POST /api/events/deploy/finished
398// Authorization: Bearer ${DEPLOY_EVENT_TOKEN}
399// Body: { run_id, status: "succeeded"|"failed",
400// duration_ms?: number, error?: string }
401//
402// Idempotency: keyed on run_id via the UNIQUE constraint in migration 0046.
403// A duplicate 'started' POST is a no-op. A 'finished' POST without a prior
404// 'started' INSERTs a fresh row (so the timeline still records the deploy
405// even if the started-step silently dropped its packet).
406// ---------------------------------------------------------------------------
407
408const SHORT_SHA_RE = /^[0-9a-f]{7,64}$/i;
409const VALID_DEPLOY_STATUS: ReadonlySet<string> = new Set([
410 "succeeded",
411 "failed",
412]);
413
414function verifyDeployBearer(c: any): { ok: boolean; error?: string } {
415 const expected = process.env.DEPLOY_EVENT_TOKEN || "";
416 if (!expected) {
417 return {
418 ok: false,
419 error:
420 "Deploy event endpoint not configured: set DEPLOY_EVENT_TOKEN in the environment",
421 };
422 }
423 const auth = c.req.header("authorization") || "";
424 if (!auth.startsWith("Bearer ")) {
425 return { ok: false, error: "Missing Bearer token" };
426 }
427 const token = auth.slice(7).trim();
428 if (!constantTimeEq(token, expected)) {
429 return { ok: false, error: "Invalid bearer token" };
430 }
431 return { ok: true };
432}
433
434interface DeployStartedPayload {
435 sha: string;
436 run_id: string;
437 source: string;
438}
439
440interface DeployFinishedPayload {
441 run_id: string;
442 sha?: string;
443 status: "succeeded" | "failed";
444 duration_ms?: number;
445 error?: string;
446}
447
448function validateStarted(raw: unknown):
449 | { ok: true; payload: DeployStartedPayload }
450 | { ok: false; error: string } {
451 if (!raw || typeof raw !== "object") {
452 return { ok: false, error: "Body must be a JSON object" };
453 }
454 const p = raw as Record<string, unknown>;
455 if (typeof p.sha !== "string" || !SHORT_SHA_RE.test(p.sha)) {
456 return { ok: false, error: "sha must be a hex commit id (7-64 chars)" };
457 }
458 if (typeof p.run_id !== "string" || p.run_id.length === 0 || p.run_id.length > 128) {
459 return { ok: false, error: "run_id must be a non-empty string (≤128 chars)" };
460 }
461 if (typeof p.source !== "string" || p.source.length === 0 || p.source.length > 64) {
462 return { ok: false, error: "source must be a non-empty string (≤64 chars)" };
463 }
464 return {
465 ok: true,
466 payload: { sha: p.sha, run_id: p.run_id, source: p.source },
467 };
468}
469
470function validateFinished(raw: unknown):
471 | { ok: true; payload: DeployFinishedPayload }
472 | { ok: false; error: string } {
473 if (!raw || typeof raw !== "object") {
474 return { ok: false, error: "Body must be a JSON object" };
475 }
476 const p = raw as Record<string, unknown>;
477 if (typeof p.run_id !== "string" || p.run_id.length === 0 || p.run_id.length > 128) {
478 return { ok: false, error: "run_id must be a non-empty string (≤128 chars)" };
479 }
480 if (typeof p.status !== "string" || !VALID_DEPLOY_STATUS.has(p.status)) {
481 return {
482 ok: false,
483 error: "status must be 'succeeded' or 'failed'",
484 };
485 }
486 if (p.sha !== undefined && (typeof p.sha !== "string" || !SHORT_SHA_RE.test(p.sha))) {
487 return { ok: false, error: "sha must be a hex commit id when provided" };
488 }
489 if (p.duration_ms !== undefined) {
490 if (
491 typeof p.duration_ms !== "number" ||
492 !Number.isFinite(p.duration_ms) ||
493 p.duration_ms < 0
494 ) {
495 return { ok: false, error: "duration_ms must be a non-negative number" };
496 }
497 }
498 if (p.error !== undefined && typeof p.error !== "string") {
499 return { ok: false, error: "error must be a string when provided" };
500 }
501 return {
502 ok: true,
503 payload: {
504 run_id: p.run_id,
505 sha: typeof p.sha === "string" ? p.sha : undefined,
506 status: p.status as "succeeded" | "failed",
507 duration_ms:
508 typeof p.duration_ms === "number" ? p.duration_ms : undefined,
509 // Cap error text at 8 KB so a misbehaving emitter can't blow up
510 // the DB row (the workflow already truncates to 1 KB on its end).
511 error:
512 typeof p.error === "string" ? p.error.slice(0, 8 * 1024) : undefined,
513 },
514 };
515}
516
517const PLATFORM_DEPLOYS_TOPIC = "platform:deploys";
518
519events.post("/deploy/started", async (c) => {
520 const auth = verifyDeployBearer(c);
521 if (!auth.ok) {
522 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
523 }
524
525 let raw: unknown;
526 try {
527 raw = await c.req.json();
528 } catch {
529 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
530 }
531
532 const validated = validateStarted(raw);
533 if (!validated.ok) {
534 return c.json({ ok: false, error: validated.error }, 400);
535 }
536 const { sha, run_id, source } = validated.payload;
537
538 // INSERT-or-no-op on UNIQUE(run_id). If the row already exists we treat the
539 // call as a duplicate and don't republish — the original publish carried
540 // the canonical started-at timestamp.
541 let inserted: { id: string; startedAt: Date } | null = null;
542 try {
543 const rows = await db
544 .insert(platformDeploys)
545 .values({
546 runId: run_id,
547 sha,
548 source,
549 status: "in_progress",
550 })
551 .onConflictDoNothing({ target: platformDeploys.runId })
552 .returning({ id: platformDeploys.id, startedAt: platformDeploys.startedAt });
553 inserted = rows[0] ?? null;
554 } catch (err) {
555 console.error("[events/deploy/started] insert failed:", err);
556 return c.json({ ok: false, error: "Failed to persist deploy event" }, 500);
557 }
558
559 if (!inserted) {
560 return c.json({ ok: true, duplicate: true });
561 }
562
563 publish(PLATFORM_DEPLOYS_TOPIC, {
564 event: "deploy.started",
565 data: {
566 id: inserted.id,
567 run_id,
568 sha,
569 source,
570 status: "in_progress",
571 started_at: inserted.startedAt.toISOString(),
572 },
573 });
574
575 return c.json({ ok: true, duplicate: false });
576});
577
578events.post("/deploy/finished", async (c) => {
579 const auth = verifyDeployBearer(c);
580 if (!auth.ok) {
581 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
582 }
583
584 let raw: unknown;
585 try {
586 raw = await c.req.json();
587 } catch {
588 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
589 }
590
591 const validated = validateFinished(raw);
592 if (!validated.ok) {
593 return c.json({ ok: false, error: validated.error }, 400);
594 }
595 const payload = validated.payload;
596
597 const finishedAt = new Date();
598
599 let row:
600 | {
601 id: string;
602 runId: string;
603 sha: string;
604 source: string;
605 status: string;
606 startedAt: Date;
607 finishedAt: Date | null;
608 durationMs: number | null;
609 error: string | null;
610 }
611 | null = null;
612
613 try {
614 const updated = await db
615 .update(platformDeploys)
616 .set({
617 status: payload.status,
618 finishedAt,
619 durationMs: payload.duration_ms ?? null,
620 error: payload.error ?? null,
621 })
622 .where(eq(platformDeploys.runId, payload.run_id))
623 .returning({
624 id: platformDeploys.id,
625 runId: platformDeploys.runId,
626 sha: platformDeploys.sha,
627 source: platformDeploys.source,
628 status: platformDeploys.status,
629 startedAt: platformDeploys.startedAt,
630 finishedAt: platformDeploys.finishedAt,
631 durationMs: platformDeploys.durationMs,
632 error: platformDeploys.error,
633 });
634 row = updated[0] ?? null;
635 } catch (err) {
636 console.error("[events/deploy/finished] update failed:", err);
637 return c.json({ ok: false, error: "Failed to persist deploy event" }, 500);
638 }
639
640 // No matching started row — record a finished-only entry so the timeline
641 // still reflects the deploy. Source/sha fall back to defaults; this is the
642 // "started packet got dropped" recovery path.
643 if (!row) {
644 try {
645 const inserted = await db
646 .insert(platformDeploys)
647 .values({
648 runId: payload.run_id,
649 sha: payload.sha ?? "unknown",
650 source: "hetzner-deploy",
651 status: payload.status,
652 finishedAt,
653 durationMs: payload.duration_ms ?? null,
654 error: payload.error ?? null,
655 })
656 .returning({
657 id: platformDeploys.id,
658 runId: platformDeploys.runId,
659 sha: platformDeploys.sha,
660 source: platformDeploys.source,
661 status: platformDeploys.status,
662 startedAt: platformDeploys.startedAt,
663 finishedAt: platformDeploys.finishedAt,
664 durationMs: platformDeploys.durationMs,
665 error: platformDeploys.error,
666 });
667 row = inserted[0] ?? null;
668 } catch (err) {
669 console.error("[events/deploy/finished] backfill insert failed:", err);
670 return c.json({ ok: false, error: "Failed to persist deploy event" }, 500);
671 }
672 }
673
674 if (row) {
675 publish(PLATFORM_DEPLOYS_TOPIC, {
676 event: "deploy.finished",
677 data: {
678 id: row.id,
679 run_id: row.runId,
680 sha: row.sha,
681 source: row.source,
682 status: row.status,
683 started_at: row.startedAt.toISOString(),
684 finished_at: row.finishedAt ? row.finishedAt.toISOString() : null,
685 duration_ms: row.durationMs,
686 error: row.error,
687 },
688 });
689 }
690
691 return c.json({ ok: true });
692});
693
9e1e93aClaude694// Test-only access for unit tests that want to exercise helpers directly.
695export const __test = {
696 constantTimeEq,
697 validatePayload,
698 resolveRepo,
699 findTargetDeployment,
f764c07Claude700 validateStarted,
701 validateFinished,
702 verifyDeployBearer,
703 PLATFORM_DEPLOYS_TOPIC,
9e1e93aClaude704};
705
706export default events;