Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit9ecf5a4unknown_key

feat(BLK-016): rename Crontech integration to Vapron, reconnect to vapron.ai

feat(BLK-016): rename Crontech integration to Vapron, reconnect to vapron.ai

Owner-directed rename (Vapron is Crontech's new name, live at
https://vapron.ai). Locked-block BLK-016 change authorised in-session.

- Outbound deploy webhook now defaults to
  https://vapron.ai/api/webhooks/gluecron-push; trigger repo default is
  ccantynz-alt/vapron. triggerCrontechDeploy -> triggerVapronDeploy.
- New env names VAPRON_DEPLOY_URL / VAPRON_REPO / VAPRON_HMAC_SECRET /
  VAPRON_EVENT_TOKEN. Every legacy CRONTECH_* name still works as a
  fallback, so already-configured deployments don't break.
- Bug fix: the /admin/integrations HMAC-secret field (stored as
  CRONTECH_HMAC_SECRET) was never read by the signer, which only used
  GLUECRON_WEBHOOK_SECRET. Signing now resolves VAPRON_HMAC_SECRET ->
  CRONTECH_HMAC_SECRET -> GLUECRON_WEBHOOK_SECRET, so the admin-page
  value takes effect.
- Inbound /api/events/deploy bearer accepts VAPRON_EVENT_TOKEN (legacy
  CRONTECH_EVENT_TOKEN honored); event source recorded as 'vapron'.
- New deployments.target rows write 'vapron'; old rows keep 'crontech'.
- Admin integrations page gains a 'Vapron inbound event token' field;
  diagnose checks, pr-stage message, bootstrap copy, landing/vs-github
  text, .env.example, CLAUDE.md, BUILD_BIBLE all updated.
- Tests: crontech-deploy.test.ts renamed to vapron-deploy.test.ts; +4
  tests covering VAPRON_*-wins, legacy fallback, and default URL.

Verified: 2894 tests pass, typecheck clean, /admin/integrations renders
the three Vapron fields against a live local server.

https://claude.ai/code/session_01BCaibgckcvsuGmByyn8d8L
Claude committed on June 10, 2026Parent: 5214d1a
23 files changed+2341219ecf5a4eb18701a5603ee8d7ed0bf7ad2d43c7b7
23 changed files+234−121
Modified.env.example+16−12View fileUnifiedSplit
2929# GLUECRON_BASE_URL=https://gluecron.com
3030# GLUECRON_API_TOKEN=<paste from /admin/ops, shown once>
3131# ─────────────────────────────────────────────────────────────────────────
32CRONTECH_DEPLOY_URL=https://crontech.ai/api/webhooks/gluecron-push
33# BLK-016 — only fire the Crontech deploy webhook for pushes to this
34# `<owner>/<name>` (default `ccantynz-alt/crontech`). All other repo pushes
32# Vapron (formerly Crontech) deploy integration. Legacy CRONTECH_* names
33# are still honored as fallbacks for already-configured deployments.
34VAPRON_DEPLOY_URL=https://vapron.ai/api/webhooks/gluecron-push
35# BLK-016 — only fire the Vapron deploy webhook for pushes to this
36# `<owner>/<name>` (default `ccantynz-alt/vapron`). All other repo pushes
3537# are ignored.
36CRONTECH_REPO=ccantynz-alt/crontech
37# Shared HMAC secret used to sign the outbound Crontech deploy webhook.
38VAPRON_REPO=ccantynz-alt/vapron
39# Shared HMAC secret used to sign the outbound Vapron deploy webhook.
3840# Sent as `X-Gluecron-Signature: sha256=<hex>` of the JSON body. Must match
39# the `GLUECRON_WEBHOOK_SECRET` configured on the Crontech deploy-agent
40# (Vultr box). Unset → header omitted and Crontech rejects with 401.
41GLUECRON_WEBHOOK_SECRET=
42# Inbound bearer token Crontech MUST present on deploy.succeeded /
41# the secret configured on the Vapron deploy-agent. Unset → header omitted
42# and Vapron rejects with 401. (Legacy fallbacks: CRONTECH_HMAC_SECRET,
43# GLUECRON_WEBHOOK_SECRET.) Prefer setting this on /admin/integrations.
44VAPRON_HMAC_SECRET=
45# Inbound bearer token Vapron MUST present on deploy.succeeded /
4346# deploy.failed callbacks to POST /api/events/deploy (Signal Bus P1 — E3/E4).
4447# Generate with: openssl rand -hex 32. Unset → endpoint refuses every call.
45CRONTECH_EVENT_TOKEN=
48# (Legacy fallback: CRONTECH_EVENT_TOKEN.)
49VAPRON_EVENT_TOKEN=
4650ANTHROPIC_API_KEY=
4751# Email (Block A8). Provider=log just writes to stderr (safe default).
4852# Switch to "resend" in prod and set RESEND_API_KEY.
5155RESEND_API_KEY=
5256# Used to build absolute URLs in outbound emails + webhooks.
5357APP_BASE_URL=http://localhost:3000
54# Cross-product platform-status endpoints (Crontech / Gluecron / GateTest).
58# Cross-product platform-status endpoints (Vapron / Gluecron / GateTest).
5559# Used by the /admin/platform widget. Defaults point at production hosts.
56CRONTECH_STATUS_URL=https://crontech.ai/api/platform-status
60VAPRON_STATUS_URL=https://vapron.ai/api/platform-status
5761GLUECRON_STATUS_URL=https://gluecron.com/api/platform-status
5862GATETEST_STATUS_URL=https://gatetest.io/api/platform-status
5963# Error tracking (optional). If unset, errors are logged to stderr only.
ModifiedBUILD_BIBLE.md+8−0View fileUnifiedSplit
847847- `src/lib/demo-seed.ts` + tests — idempotent `ensureDemoContent()` seeds `demo` user + 3 public sample repos + seeded issues/PR. Boot flag `DEMO_SEED_ON_BOOT=1`. Site-admin reseed button on `/admin` (`POST /admin/demo/reseed`). Public `/demo` convenience redirect to `/demo/hello-python`.
848848- Doc sync: `README.md`, `DEPLOY.md`, `LAUNCH_TODAY.md` aligned with current reality.
849849
850**BLK-016 update (2026-06-10, owner-directed): Crontech renamed to Vapron.**
851- Owner confirmed Vapron (formerly Crontech) is live at `https://vapron.ai` and directed the rename + reconnect in-session (locked-block change authorised).
852- Sender renamed `triggerCrontechDeploy``triggerVapronDeploy`; endpoint default is now `https://vapron.ai/api/webhooks/gluecron-push`; trigger repo default `ccantynz-alt/vapron`.
853- Env names renamed to `VAPRON_DEPLOY_URL` / `VAPRON_REPO` / `VAPRON_HMAC_SECRET` / `VAPRON_EVENT_TOKEN`; all legacy `CRONTECH_*` names (and `GLUECRON_WEBHOOK_SECRET` for the HMAC) still honored as fallbacks.
854- Fixed a latent bug: the `/admin/integrations` "HMAC secret" field (stored as `CRONTECH_HMAC_SECRET`) was never read by the signer, which only used `GLUECRON_WEBHOOK_SECRET`. The signer now resolves `VAPRON_HMAC_SECRET → CRONTECH_HMAC_SECRET → GLUECRON_WEBHOOK_SECRET`, so the admin-page value works.
855- New `deployments.target` rows write `"vapron"`; pre-rename rows keep `"crontech"`.
856- Tests live in `src/__tests__/vapron-deploy.test.ts` (renamed; +3 legacy-fallback tests).
857
850858**BLK-016 Crontech-Gluecron deploy wire — Gluecron sender rewritten (pending live verification):**
851859- `src/hooks/post-receive.ts triggerCrontechDeploy` now matches the Crontech receiver at `apps/api/src/webhooks/gluecron-push.ts`:
852860 - Endpoint default `POST https://crontech.ai/api/webhooks/gluecron-push` (was `/api/hooks/gluecron/push`).
ModifiedCLAUDE.md+1−1View fileUnifiedSplit
126126## Integrations
127127
128128- **GateTest (optional):** third-party security scanner. When `GATETEST_URL` is set, `git push` POSTs to it; inbound results accepted at `POST /api/hooks/gatetest`.
129- **Outbound deploy webhook (optional):** when `CRONTECH_DEPLOY_URL` is set, pushes to the default branch POST there.
129- **Outbound deploy webhook (optional):** when `VAPRON_DEPLOY_URL` is set (legacy `CRONTECH_DEPLOY_URL` honored), pushes to the default branch POST to Vapron (formerly Crontech).
130130- **Webhooks:** POST to user-registered URLs on push/issue/PR/star events with HMAC signatures.
131131
132132## Environment Variables
Modifiedsrc/__tests__/admin-health.test.ts+1−1View fileUnifiedSplit
1010 *
1111 * Added 2026-05-16 as part of the reliability sweep (Level 2Self-
1212 * monitoring). New checks (autopilot, recent deploy, workflow queue,
13 * crontech webhook) are exercised by the JSON endpoint.
13 * vapron webhook) are exercised by the JSON endpoint.
1414 */
1515
1616import { describe, it, expect } from "bun:test";
Modifiedsrc/__tests__/admin-integrations.test.ts+3−2View fileUnifiedSplit
274274 "GATETEST_URL",
275275 "GATETEST_API_KEY",
276276 "DEPLOY_EVENT_TOKEN",
277 "CRONTECH_DEPLOY_URL",
278 "CRONTECH_HMAC_SECRET",
277 "VAPRON_DEPLOY_URL",
278 "VAPRON_HMAC_SECRET",
279 "VAPRON_EVENT_TOKEN",
279280 ]) {
280281 expect(keys).toContain(expected);
281282 }
Modifiedsrc/__tests__/ai-incident.test.ts+1−1View fileUnifiedSplit
6262 deploymentId: "00000000-0000-0000-0000-000000000000",
6363 ref: "refs/heads/main",
6464 commitSha: "0".repeat(40),
65 target: "crontech",
65 target: "vapron",
6666 errorMessage: "HTTP 500",
6767 });
6868 expect(result).toBeDefined();
Modifiedsrc/__tests__/deploy-events.test.ts+8−1View fileUnifiedSplit
2323const VALID_SHA = "a".repeat(40);
2424
2525const origToken = process.env.CRONTECH_EVENT_TOKEN;
26const origVapronToken = process.env.VAPRON_EVENT_TOKEN;
2627
2728function makePayload(
2829 overrides: Partial<Record<string, unknown>> = {}
5556
5657beforeAll(() => {
5758 process.env.CRONTECH_EVENT_TOKEN = "unit-bearer-fixture";
59 delete process.env.VAPRON_EVENT_TOKEN;
5860});
5961
6062afterAll(() => {
6163 if (origToken === undefined) delete process.env.CRONTECH_EVENT_TOKEN;
6264 else process.env.CRONTECH_EVENT_TOKEN = origToken;
65 if (origVapronToken === undefined) delete process.env.VAPRON_EVENT_TOKEN;
66 else process.env.VAPRON_EVENT_TOKEN = origVapronToken;
6367});
6468
6569// ---------------------------------------------------------------------------
8488 expect(body.ok).toBe(false);
8589 });
8690
87 it("rejects with 401 when CRONTECH_EVENT_TOKEN is unset (refuse-by-default)", async () => {
91 it("rejects with 401 when no event token is set (refuse-by-default)", async () => {
8892 const saved = process.env.CRONTECH_EVENT_TOKEN;
93 const savedV = process.env.VAPRON_EVENT_TOKEN;
8994 delete process.env.CRONTECH_EVENT_TOKEN;
95 delete process.env.VAPRON_EVENT_TOKEN;
9096 try {
9197 const res = await post(makePayload(), {
9298 authorization: "Bearer anything",
96102 expect(String(body.error).toLowerCase()).toContain("not configured");
97103 } finally {
98104 if (saved !== undefined) process.env.CRONTECH_EVENT_TOKEN = saved;
105 if (savedV !== undefined) process.env.VAPRON_EVENT_TOKEN = savedV;
99106 }
100107 });
101108});
Modifiedsrc/__tests__/github-oauth.test.ts+1−1View fileUnifiedSplit
33 *
44 * Pure network helpers (`buildGithubAuthorizeUrl`, `exchangeGithubCode`,
55 * `fetchGithubUserinfo`, `fetchGithubPrimaryEmail`) drive an injected
6 * fetch — see K2's `crontech-deploy.test.ts` for the DI pattern these
6 * fetch — see K2's `vapron-deploy.test.ts` for the DI pattern these
77 * mirror — so tests never touch the real GitHub API.
88 *
99 * `findOrCreateUserFromGithub` is exercised indirectly: we assert the
Modifiedsrc/__tests__/self-host.test.ts+1−1View fileUnifiedSplit
195195// onPostReceive calls into autoRepair / analyzePush / computeHealthScore
196196// which each touch the DB. The mocked DB is intentionally empty, so each
197197// helper logs an error and continues. We assert *only* on the self-host
198// spawn — the existing crontech/intelligence behaviour is covered by
198// spawn — the existing vapron/intelligence behaviour is covered by
199199// other test files and untouched here.
200200// ---------------------------------------------------------------------------
201201
Renamedsrc/__tests__/crontech-deploy.test.tssrc/__tests__/vapron-deploy.test.ts+83−28View fileUnifiedSplit
11/**
2 * BLK-016Crontech deploy webhook sender.
2 * BLK-016 — Vapron deploy webhook sender.
33 *
4 * Asserts that `triggerCrontechDeploy` (in `src/hooks/post-receive.ts`)
4 * Asserts that `triggerVapronDeploy` (in `src/hooks/post-receive.ts`)
55 * matches the wire contract documented at the top of that helper, which
6 * is the inbound contract for Crontech's
6 * is the inbound contract for Vapron's
77 * `apps/api/src/webhooks/gluecron-push.ts` receiver:
88 *
9 * POST https://crontech.ai/api/webhooks/gluecron-push
9 * POST https://vapron.ai/api/webhooks/gluecron-push
1010 * Content-Type: application/json
1111 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, secret))>
1212 *
2727import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2828import { createHmac } from "crypto";
2929import { __test } from "../hooks/post-receive";
30import { config } from "../lib/config";
3031
31const { triggerCrontechDeploy, signBody } = __test;
32const { triggerVapronDeploy, signBody } = __test;
3233
3334interface CapturedCall {
3435 url: string;
3637}
3738
3839const origSecret = process.env.GLUECRON_WEBHOOK_SECRET;
39const origUrl = process.env.CRONTECH_DEPLOY_URL;
40const origRepo = process.env.CRONTECH_REPO;
40const origUrl = process.env.VAPRON_DEPLOY_URL;
41const origRepo = process.env.VAPRON_REPO;
4142
4243const NULL_REPO_ID = "00000000-0000-0000-0000-000000000000";
4344const ZERO_SHA = "0000000000000000000000000000000000000000";
5354}> = {}) {
5455 return {
5556 owner: "ccantynz-alt",
56 repo: "crontech",
57 repo: "vapron",
5758 before: ZERO_SHA,
5859 after: "a".repeat(40),
5960 ref: "refs/heads/Main",
110111 });
111112});
112113
113describe("hooks/post-receive — triggerCrontechDeploy (BLK-016 sender)", () => {
114describe("hooks/post-receive — triggerVapronDeploy (BLK-016 sender)", () => {
114115 beforeEach(() => {
115116 delete process.env.GLUECRON_WEBHOOK_SECRET;
117 delete process.env.VAPRON_DEPLOY_URL;
118 delete process.env.VAPRON_REPO;
119 delete process.env.VAPRON_HMAC_SECRET;
120 // legacy names must not leak into the default-URL assertions
116121 delete process.env.CRONTECH_DEPLOY_URL;
117122 delete process.env.CRONTECH_REPO;
123 delete process.env.CRONTECH_HMAC_SECRET;
118124 });
119125
120126 afterEach(() => {
121127 if (origSecret === undefined) delete process.env.GLUECRON_WEBHOOK_SECRET;
122128 else process.env.GLUECRON_WEBHOOK_SECRET = origSecret;
123 if (origUrl === undefined) delete process.env.CRONTECH_DEPLOY_URL;
124 else process.env.CRONTECH_DEPLOY_URL = origUrl;
125 if (origRepo === undefined) delete process.env.CRONTECH_REPO;
126 else process.env.CRONTECH_REPO = origRepo;
129 if (origUrl === undefined) delete process.env.VAPRON_DEPLOY_URL;
130 else process.env.VAPRON_DEPLOY_URL = origUrl;
131 if (origRepo === undefined) delete process.env.VAPRON_REPO;
132 else process.env.VAPRON_REPO = origRepo;
127133 });
128134
129135 it("is exported from __test", () => {
130 expect(typeof triggerCrontechDeploy).toBe("function");
136 expect(typeof triggerVapronDeploy).toBe("function");
131137 });
132138
133 it("POSTs to /api/webhooks/gluecron-push (matches Crontech receiver path)", async () => {
139 it("POSTs to /api/webhooks/gluecron-push (matches Vapron receiver path)", async () => {
134140 const { calls, fn } = captureFetch();
135141
136 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
142 await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
137143
138144 expect(calls.length).toBe(1);
139145 expect(calls[0]!.url).toBe(
140 "https://crontech.ai/api/webhooks/gluecron-push"
146 "https://vapron.ai/api/webhooks/gluecron-push"
141147 );
142148 expect(calls[0]!.url).not.toContain("/api/hooks/gluecron/push");
143149 expect(calls[0]!.init.method).toBe("POST");
148154 const before = "c".repeat(40);
149155 const { calls, fn } = captureFetch();
150156
151 await triggerCrontechDeploy(
157 await triggerVapronDeploy(
152158 makeArgs({
153159 owner: "acme",
154160 repo: "api",
179185 process.env.GLUECRON_WEBHOOK_SECRET = "shared-vultr-secret";
180186 const { calls, fn } = captureFetch();
181187
182 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
188 await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
183189
184190 const headers = calls[0]!.init.headers as Record<string, string>;
185191 const sentBody = String(calls[0]!.init.body);
195201 it("omits X-Gluecron-Signature when no secret is configured", async () => {
196202 const { calls, fn } = captureFetch();
197203
198 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
204 await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
199205
200206 const headers = calls[0]!.init.headers as Record<string, string>;
201207 expect(headers["X-Gluecron-Signature"]).toBeUndefined();
204210 it("attaches X-Gluecron-Event=push and a non-empty X-Gluecron-Delivery id", async () => {
205211 const { calls, fn } = captureFetch();
206212
207 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
213 await triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
208214
209215 const headers = calls[0]!.init.headers as Record<string, string>;
210216 expect(headers["X-Gluecron-Event"]).toBe("push");
215221 it("ref carries the actual case of the branch (Main, not main)", async () => {
216222 const { calls, fn } = captureFetch();
217223
218 await triggerCrontechDeploy(
224 await triggerVapronDeploy(
219225 makeArgs({ ref: "refs/heads/Main", branch: "Main" }),
220226 { fetchImpl: fn, sleep: noSleep }
221227 );
234240 const { calls, fn } = captureFetch((i) => responses[i]!);
235241 const sleeps: number[] = [];
236242
237 await triggerCrontechDeploy(makeArgs(), {
243 await triggerVapronDeploy(makeArgs(), {
238244 fetchImpl: fn,
239245 sleep: async (ms) => { sleeps.push(ms); },
240246 retryDelaysMs: [10, 20, 30, 40, 50],
249255 const { calls, fn } = captureFetch(() => new Response("", { status: 500 }));
250256 const sleeps: number[] = [];
251257
252 await triggerCrontechDeploy(makeArgs(), {
258 await triggerVapronDeploy(makeArgs(), {
253259 fetchImpl: fn,
254260 sleep: async (ms) => { sleeps.push(ms); },
255261 retryDelaysMs: [1, 2, 3, 4, 5],
264270 const { calls, fn } = captureFetch(() => new Response("", { status: 401 }));
265271 const sleeps: number[] = [];
266272
267 await triggerCrontechDeploy(makeArgs(), {
273 await triggerVapronDeploy(makeArgs(), {
268274 fetchImpl: fn,
269275 sleep: async (ms) => { sleeps.push(ms); },
270276 retryDelaysMs: [1, 2, 3, 4, 5],
282288 ];
283289 const { calls, fn } = captureFetch((i) => responses[i]!);
284290
285 await triggerCrontechDeploy(makeArgs(), {
291 await triggerVapronDeploy(makeArgs(), {
286292 fetchImpl: fn,
287293 sleep: noSleep,
288294 retryDelaysMs: [1, 2, 3, 4, 5],
299305 return new Response("", { status: 200 });
300306 }) as unknown as typeof fetch;
301307
302 await triggerCrontechDeploy(makeArgs(), {
308 await triggerVapronDeploy(makeArgs(), {
303309 fetchImpl: fn,
304310 sleep: noSleep,
305311 retryDelaysMs: [1, 2, 3, 4, 5],
311317 it("does not throw when receiver responds 401 (unconfigured-secret path)", async () => {
312318 const { fn } = captureFetch(() => new Response("", { status: 401 }));
313319 await expect(
314 triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep })
320 triggerVapronDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep })
315321 ).resolves.toBeUndefined();
316322 });
317323
319325 expect(__test.RETRY_DELAYS_MS).toEqual([1_000, 4_000, 16_000, 64_000, 256_000]);
320326 });
321327});
328
329describe("vapron config — legacy CRONTECH_* env fallback", () => {
330 const KEYS = [
331 "VAPRON_DEPLOY_URL", "CRONTECH_DEPLOY_URL",
332 "VAPRON_REPO", "CRONTECH_REPO",
333 "VAPRON_HMAC_SECRET", "CRONTECH_HMAC_SECRET", "GLUECRON_WEBHOOK_SECRET",
334 ] as const;
335 const saved: Record<string, string | undefined> = {};
336 beforeEach(() => {
337 for (const k of KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
338 });
339 afterEach(() => {
340 for (const k of KEYS) {
341 if (saved[k] === undefined) delete process.env[k];
342 else process.env[k] = saved[k]!;
343 }
344 });
345
346 it("defaults to the vapron.ai webhook URL and ccantynz-alt/vapron repo", () => {
347 expect(config.vapronDeployUrl).toBe("https://vapron.ai/api/webhooks/gluecron-push");
348 expect(config.vapronRepo).toBe("ccantynz-alt/vapron");
349 });
350
351 it("VAPRON_* wins over legacy CRONTECH_*", () => {
352 process.env.VAPRON_DEPLOY_URL = "https://vapron.ai/hook-a";
353 process.env.CRONTECH_DEPLOY_URL = "https://crontech.ai/hook-b";
354 process.env.VAPRON_REPO = "o/new";
355 process.env.CRONTECH_REPO = "o/old";
356 process.env.VAPRON_HMAC_SECRET = "new-secret";
357 process.env.CRONTECH_HMAC_SECRET = "old-secret";
358 expect(config.vapronDeployUrl).toBe("https://vapron.ai/hook-a");
359 expect(config.vapronRepo).toBe("o/new");
360 expect(config.vapronHmacSecret).toBe("new-secret");
361 });
362
363 it("legacy CRONTECH_* still works when VAPRON_* is unset", () => {
364 process.env.CRONTECH_DEPLOY_URL = "https://crontech.ai/hook-b";
365 process.env.CRONTECH_REPO = "o/old";
366 process.env.CRONTECH_HMAC_SECRET = "old-secret";
367 expect(config.vapronDeployUrl).toBe("https://crontech.ai/hook-b");
368 expect(config.vapronRepo).toBe("o/old");
369 expect(config.vapronHmacSecret).toBe("old-secret");
370 });
371
372 it("HMAC secret falls back to GLUECRON_WEBHOOK_SECRET last", () => {
373 process.env.GLUECRON_WEBHOOK_SECRET = "oldest-secret";
374 expect(config.vapronHmacSecret).toBe("oldest-secret");
375 });
376});
Modifiedsrc/app.tsx+2−2View fileUnifiedSplit
234234});
235235
236236// Cache-Control middleware — sets sensible defaults on public, anonymous
237// requests. Crontech (when wired as our edge layer) and downstream
237// requests. Vapron (when wired as our edge layer) and downstream
238238// browsers will honor these. Auth'd users get private,no-store
239239// automatically — we never cache responses tied to a session.
240240app.use("*", async (c, next) => {
291291 return;
292292 }
293293 // Public repo browse pages — cache aggressively. Edge invalidates
294 // on push via the post-receive hook (future Crontech surge purge).
294 // on push via the post-receive hook (future Vapron surge purge).
295295 if (/^\/[^/]+\/[^/]+(\/.*)?$/.test(p) && c.req.method === "GET") {
296296 c.res.headers.set(
297297 "cache-control",
Modifiedsrc/db/schema.ts+2−2View fileUnifiedSplit
575575);
576576
577577/**
578 * Deployments — tracks every deploy to downstream systems (Crontech, etc).
578 * Deployments — tracks every deploy to downstream systems (Vapron, etc).
579579 * Each deploy is gated on ALL green gates passing.
580580 */
581581export const deployments = pgTable(
590590 ref: text("ref").notNull(),
591591 status: text("status").notNull(), // pending, running, success, failed, blocked, waiting_timer
592592 blockedReason: text("blocked_reason"),
593 target: text("target"), // e.g. "crontech", "fly.io"
593 target: text("target"), // e.g. "vapron" (legacy rows: "crontech"), "fly.io"
594594 triggeredBy: uuid("triggered_by").references(() => users.id),
595595 /**
596596 * Set when an approved deploy is held by `environments.wait_timer_minutes`.
Modifiedsrc/hooks/post-receive.ts+20−19View fileUnifiedSplit
66 * 2. Push analysis — detect breaking changes, security issues
77 * 3. Health score — recompute repo health
88 * 4. GateTest scan — external security scanning
9 * 5. Crontech deploy — auto-deploy on push to main
9 * 5. Vapron deploy — auto-deploy on push to main
1010 * 6. Webhooks — fire registered webhook URLs
1111 */
1212
171171 console.warn("[repo-onboarding] dispatch error:", err)
172172 );
173173
174 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
175 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
174 // 5. Vapron deploy (BLK-016) — only fires for the configured Vapron repo
175 // (VAPRON_REPO, default `ccantynz-alt/vapron`; legacy CRONTECH_REPO honored)
176 // on a push to its
176177 // default branch. The branch case (`Main` vs `main`) is determined by
177178 // the bare repo's HEAD, not hardcoded.
178 if (`${owner}/${repo}` === config.crontechRepo) {
179 if (`${owner}/${repo}` === config.vapronRepo) {
179180 let defaultBranch =
180181 (await getDefaultBranch(owner, repo).catch((err) => {
181182 console.warn(
202203 /* ignore */
203204 }
204205 if (repositoryId) {
205 triggerCrontechDeploy({
206 triggerVapronDeploy({
206207 owner,
207208 repo,
208209 before: deployPush.oldSha,
210211 ref: targetRef,
211212 branch: defaultBranch,
212213 repositoryId,
213 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
214 }).catch((err: unknown) => console.error(`[vapron] error:`, err));
214215 }
215216 }
216217 }
283284}
284285
285286/**
286 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
287 * BLK-016 — outbound deploy webhook for Vapron's deploy-agent (formerly Crontech).
287288 *
288 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
289 * Wire contract (matches Vapron's `apps/api/src/webhooks/gluecron-push.ts`):
289290 *
290 * POST https://crontech.ai/api/webhooks/gluecron-push
291 * POST https://vapron.ai/api/webhooks/gluecron-push
291292 * Content-Type: application/json
292293 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
293294 *
294295 * {
295296 * "event": "push",
296 * "repository": { "full_name": "ccantynz-alt/crontech" },
297 * "repository": { "full_name": "ccantynz-alt/vapron" },
297298 * "ref": "refs/heads/Main",
298299 * "after": "<40-hex commit SHA>",
299300 * "before": "<40-hex previous SHA>",
305306 *
306307 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
307308 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
308 * is unset the signature header is omitted and Crontech is expected to reject —
309 * is unset the signature header is omitted and Vapron is expected to reject —
309310 * we still record the deploy row as failed.
310311 */
311312const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
376377 };
377378}
378379
379async function triggerCrontechDeploy(
380async function triggerVapronDeploy(
380381 args: TriggerArgs,
381382 opts: TriggerOptions = {}
382383): Promise<void> {
395396 commitSha: args.after,
396397 ref: args.ref,
397398 status: "pending",
398 target: "crontech",
399 target: "vapron",
399400 })
400401 .returning();
401402 deployId = row?.id || "";
405406
406407 const { payload } = await buildPayload(args, now());
407408 const body = JSON.stringify(payload);
408 const signature = signBody(body, config.gluecronWebhookSecret);
409 const signature = signBody(body, config.vapronHmacSecret);
409410
410411 const headers: Record<string, string> = {
411412 "Content-Type": "application/json",
423424 const totalAttempts = delays.length + 1;
424425 for (let attempt = 0; attempt < totalAttempts; attempt++) {
425426 try {
426 const response = await fetchImpl(config.crontechDeployUrl, {
427 const response = await fetchImpl(config.vapronDeployUrl, {
427428 method: "POST",
428429 headers,
429430 body,
430431 });
431432 lastStatus = response.status;
432433 console.log(
433 `[crontech] attempt ${attempt + 1}/${totalAttempts}${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
434 `[vapron] attempt ${attempt + 1}/${totalAttempts}${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
434435 );
435436 if (response.ok) {
436437 success = true;
444445 } catch (err) {
445446 lastError = err instanceof Error ? err.message : String(err);
446447 console.error(
447 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
448 `[vapron] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
448449 );
449450 }
450451 const nextDelay = delays[attempt];
476477 deploymentId: deployId,
477478 ref: args.ref,
478479 commitSha: args.after,
479 target: "crontech",
480 target: "vapron",
480481 errorMessage: lastError || `HTTP ${lastStatus}`,
481482 }).catch((e) => console.error("[ai-incident]", e));
482483 }
837838
838839/** Test-only access to internal helpers. */
839840export const __test = {
840 triggerCrontechDeploy,
841 triggerVapronDeploy,
841842 signBody,
842843 buildPayload,
843844 RETRY_DELAYS_MS,
Modifiedsrc/lib/ai-incident.ts+1−1View fileUnifiedSplit
33 *
44 * When a deployment fails, this module automatically opens an issue with an
55 * AI-generated root-cause analysis. Invoked by the post-receive hook (from
6 * `triggerCrontechDeploy`) whenever the Crontech deploy call returns a non-2xx
6 * `triggerVapronDeploy`) whenever the Vapron deploy call returns a non-2xx
77 * response or throws. Also retriggerable via the deployments route.
88 *
99 * Everything here degrades gracefully:
Modifiedsrc/lib/config.ts+29−9View fileUnifiedSplit
1616 get gatetestApiKey() {
1717 return process.env.GATETEST_API_KEY || "";
1818 },
19 get crontechDeployUrl() {
19 get vapronDeployUrl() {
2020 return (
21 process.env.CRONTECH_DEPLOY_URL ||
22 "https://crontech.ai/api/webhooks/gluecron-push"
21 process.env.VAPRON_DEPLOY_URL ||
22 process.env.CRONTECH_DEPLOY_URL || // legacy name (pre-rename)
23 "https://vapron.ai/api/webhooks/gluecron-push"
2324 );
2425 },
2526 /**
26 * BLK-016 — only fire the Crontech deploy webhook for pushes to this
27 * BLK-016 — only fire the Vapron deploy webhook for pushes to this
2728 * `<owner>/<name>`. Every other repo's push is ignored. Override per
28 * environment via `CRONTECH_REPO`.
29 * environment via `VAPRON_REPO` (legacy `CRONTECH_REPO` still honored).
2930 */
30 get crontechRepo() {
31 return process.env.CRONTECH_REPO || "ccantynz-alt/crontech";
31 get vapronRepo() {
32 return (
33 process.env.VAPRON_REPO ||
34 process.env.CRONTECH_REPO || // legacy name (pre-rename)
35 "ccantynz-alt/vapron"
36 );
37 },
38 /**
39 * HMAC secret for signing the outbound Vapron deploy webhook
40 * (`X-Gluecron-Signature: sha256=<hex>`). Resolution order: the
41 * VAPRON_HMAC_SECRET set on /admin/integrations (or env), the legacy
42 * CRONTECH_HMAC_SECRET, then GLUECRON_WEBHOOK_SECRET (the original
43 * env-only name the signer used before the admin field existed).
44 */
45 get vapronHmacSecret() {
46 return (
47 process.env.VAPRON_HMAC_SECRET ||
48 process.env.CRONTECH_HMAC_SECRET ||
49 process.env.GLUECRON_WEBHOOK_SECRET ||
50 ""
51 );
3252 },
3353 /**
34 * Shared HMAC secret for the outbound deploy webhook to Crontech's
54 * Shared HMAC secret for the outbound deploy webhook to Vapron's
3555 * `POST /api/webhooks/gluecron-push` endpoint. Used to compute the
3656 * `X-Gluecron-Signature: sha256=<hex>` header on every fire. Default
37 * empty → header is omitted and Crontech will reject with 401 (treated
57 * empty → header is omitted and Vapron will reject with 401 (treated
3858 * as a failed deploy).
3959 */
4060 get gluecronWebhookSecret() {
Modifiedsrc/lib/pr-stage.ts+1−1View fileUnifiedSplit
448448 await postPrComment(
449449 job.prId,
450450 "<!-- cmd:stage -->\n\n**Preview not available** — Docker-based projects require a configured deployment provider. " +
451 "Set `CRONTECH_DEPLOY_URL` in repo settings to enable staging."
451 "Set `VAPRON_DEPLOY_URL` in repo settings to enable staging."
452452 );
453453 job.status = "failed";
454454 job.error = "Docker projects require an external deploy provider";
Modifiedsrc/lib/repo-bootstrap.ts+1−1View fileUnifiedSplit
4646- **Automated merge conflict resolution** when conflicts arise
4747- **AI auto-repair** — failing gates trigger a fix attempt before a human is pinged
4848- **Branch protection** on \`main\` — PR required, all gates green, AI approval required
49- **Auto-deploy** to Crontech on every passing push to \`main\`
49- **Auto-deploy** to Vapron on every passing push to \`main\`
5050- **AI commit messages, PR summaries, and release changelogs** on demand
5151
5252You can toggle any of this in **Settings → Gates & Auto-repair**. The safe defaults are on.
Modifiedsrc/lib/system-config.ts+19−8View fileUnifiedSplit
251251 group: "observability",
252252 },
253253 {
254 key: "CRONTECH_DEPLOY_URL",
255 envFallback: "CRONTECH_DEPLOY_URL",
256 label: "Crontech webhook URL",
257 helper: "Optional: notify Crontech on every push to the canonical repo.",
254 key: "VAPRON_DEPLOY_URL",
255 envFallback: "VAPRON_DEPLOY_URL",
256 label: "Vapron webhook URL",
257 helper:
258 "Optional: notify Vapron (formerly Crontech) on every push to the canonical repo. Default: https://vapron.ai/api/webhooks/gluecron-push",
258259 isSecret: false,
259260 group: "webhook",
260261 },
261262 {
262 key: "CRONTECH_HMAC_SECRET",
263 envFallback: "CRONTECH_HMAC_SECRET",
264 label: "Crontech HMAC secret",
265 helper: "Used to sign outbound Crontech webhook payloads.",
263 key: "VAPRON_HMAC_SECRET",
264 envFallback: "VAPRON_HMAC_SECRET",
265 label: "Vapron HMAC secret",
266 helper:
267 "Signs outbound Vapron webhook payloads (X-Gluecron-Signature). Must match the secret configured on Vapron's side.",
268 isSecret: true,
269 group: "webhook",
270 },
271 {
272 key: "VAPRON_EVENT_TOKEN",
273 envFallback: "VAPRON_EVENT_TOKEN",
274 label: "Vapron inbound event token",
275 helper:
276 "Bearer token Vapron presents on deploy.succeeded/deploy.failed callbacks to /api/events/deploy.",
266277 isSecret: true,
267278 group: "webhook",
268279 },
Modifiedsrc/routes/admin-diagnose.tsx+17−13View fileUnifiedSplit
623623}
624624
625625/**
626 * Crontech deploy webhook secret — without it, the webhook POSTs
627 * unsigned and Crontech rejects with 401, but our hook side never sees
626 * Vapron deploy webhook secret — without it, the webhook POSTs
627 * unsigned and Vapron rejects with 401, but our hook side never sees
628628 * the rejection because the request is fire-and-forget.
629629 */
630function checkCrontechWebhook(): CheckResult {
631 const url = process.env.CRONTECH_DEPLOY_URL;
632 const secret = process.env.CRONTECH_HMAC_SECRET;
630function checkVapronWebhook(): CheckResult {
631 const url =
632 process.env.VAPRON_DEPLOY_URL || process.env.CRONTECH_DEPLOY_URL;
633 const secret =
634 process.env.VAPRON_HMAC_SECRET ||
635 process.env.CRONTECH_HMAC_SECRET ||
636 process.env.GLUECRON_WEBHOOK_SECRET;
633637 if (!url) {
634638 return {
635 category: "Crontech",
639 category: "Vapron",
636640 name: "Deploy webhook",
637641 status: "yellow",
638 detail: "CRONTECH_DEPLOY_URL unset — pushes to the Crontech repo don't notify the deploy pipeline.",
639 fix: "Optional integration. Set CRONTECH_DEPLOY_URL + CRONTECH_HMAC_SECRET if you want push-triggered Crontech deploys.",
642 detail: "VAPRON_DEPLOY_URL unset — pushes to the Vapron repo don't notify the deploy pipeline.",
643 fix: "Optional integration. Set VAPRON_DEPLOY_URL + VAPRON_HMAC_SECRET (admin → integrations) if you want push-triggered Vapron deploys.",
640644 };
641645 }
642646 if (!secret) {
643647 return {
644 category: "Crontech",
648 category: "Vapron",
645649 name: "Deploy webhook",
646650 status: "red",
647 detail: "CRONTECH_DEPLOY_URL set but CRONTECH_HMAC_SECRET empty — webhook will be rejected as unsigned.",
648 fix: "Add CRONTECH_HMAC_SECRET to /etc/gluecron.env (match the value configured on Crontech's side).",
651 detail: "Webhook URL set but no HMAC secret — webhook will be rejected as unsigned.",
652 fix: "Set VAPRON_HMAC_SECRET on /admin/integrations (match the value configured on Vapron's side).",
649653 };
650654 }
651655 return {
652 category: "Crontech",
656 category: "Vapron",
653657 name: "Deploy webhook",
654658 status: "green",
655659 detail: `Configured (POST to ${url}).`,
10801084 checkAutopilot(),
10811085 await checkRecentDeploy(),
10821086 await checkWorkflowQueue(),
1083 checkCrontechWebhook(),
1087 checkVapronWebhook(),
10841088 ];
10851089}
10861090
Modifiedsrc/routes/admin-server-targets.tsx+2−2View fileUnifiedSplit
200200 <h1 style="margin:0 0 16px;font-size:22px">New server target</h1>
201201 <form method="post" action="/admin/servers" style={card}>
202202 <label style={label}>Name (unique identifier)</label>
203 <input name="name" required pattern="[a-z0-9-]+" placeholder="crontech-prod-1" style={inputStyle} />
203 <input name="name" required pattern="[a-z0-9-]+" placeholder="vapron-prod-1" style={inputStyle} />
204204
205205 <label style={label}>Host</label>
206 <input name="host" required placeholder="1.2.3.4 or box.crontech.ai" style={inputStyle} />
206 <input name="host" required placeholder="1.2.3.4 or box.vapron.ai" style={inputStyle} />
207207
208208 <div style="display:flex;gap:14px">
209209 <div style="flex:1">
Modifiedsrc/routes/events.ts+11−9View fileUnifiedSplit
11/**
2 * Inbound deploy-event receiver for Crontech (Signal Bus P1E3/E4).
2 * Inbound deploy-event receiver for Vapron, formerly Crontech (Signal Bus P1 — E3/E4).
33 *
4 * Wire contract reference: chat-defined spec for CrontechGluecron deploy
4 * Wire contract reference: chat-defined spec for Vapron → Gluecron deploy
55 * 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
6 * types from Vapron. If the contract is renegotiated, update this comment
77 * and the validation below in lock-step.
88 *
99 * POST /api/events/deploy
10 * Authorization: Bearer ${CRONTECH_EVENT_TOKEN}
10 * Authorization: Bearer ${VAPRON_EVENT_TOKEN} (legacy CRONTECH_EVENT_TOKEN honored)
1111 * Content-Type: application/json
1212 *
1313 * {
1616 * "repository": "owner/name",
1717 * "sha": "<40-hex>",
1818 * "environment": "production",
19 * "deploymentId": "<crontech-id>",
19 * "deploymentId": "<vapron-id>",
2020 * "durationMs": <int>, // optional
2121 * "errorCategory": "build|runtime|timeout|config", // required on failed
2222 * "errorSummary": "<string ≤500>", // required on failed
6161const events = new Hono();
6262
6363// ---------------------------------------------------------------------------
64// Bearer auth — timing-safe comparison against CRONTECH_EVENT_TOKEN.
64// Bearer auth — timing-safe comparison against VAPRON_EVENT_TOKEN
65// (legacy CRONTECH_EVENT_TOKEN still honored).
6566// ---------------------------------------------------------------------------
6667
6768function constantTimeEq(a: string, b: string): boolean {
7677}
7778
7879function verifyBearer(c: any): { ok: boolean; error?: string } {
79 const expected = process.env.CRONTECH_EVENT_TOKEN || "";
80 const expected =
81 process.env.VAPRON_EVENT_TOKEN || process.env.CRONTECH_EVENT_TOKEN || "";
8082 if (!expected) {
8183 // Refuse by default — an unset secret must NOT allow anonymous writes.
8284 return {
8385 ok: false,
8486 error:
85 "Event endpoint not configured: set CRONTECH_EVENT_TOKEN in the environment",
87 "Event endpoint not configured: set VAPRON_EVENT_TOKEN in the environment",
8688 };
8789 }
8890 const auth = c.req.header("authorization") || "";
303305 await db.insert(processedEvents).values({
304306 eventId: payload.eventId,
305307 eventType: payload.event,
306 source: "crontech",
308 source: "vapron",
307309 payload: payload as unknown as Record<string, unknown>,
308310 });
309311 } catch (err) {
Modifiedsrc/routes/vs-github.tsx+1−1View fileUnifiedSplit
10881088 </div>
10891089 </div>
10901090 <div class="vsg-social-card">
1091 <div class="vsg-social-card-name">Crontech</div>
1091 <div class="vsg-social-card-name">Vapron</div>
10921092 <div class="vsg-social-card-quote">
10931093 Production deploy automation. Every push lands here first.
10941094 </div>
Modifiedsrc/views/landing.tsx+5−5View fileUnifiedSplit
375375 </div>
376376 </section>
377377
378 {/* ---------- Capability strip — uppercase tracked grid (crontech-style) ---------- */}
378 {/* ---------- Capability strip — uppercase tracked grid (vapron-style) ---------- */}
379379 <section class="landing-caps">
380380 <div class="landing-caps-grid">
381381 <span class="landing-cap">Claude-powered AI</span>
393393 </div>
394394 </section>
395395
396 {/* ---------- Big stat row (crontech-style hero closer) ---------- */}
396 {/* ---------- Big stat row (vapron-style hero closer) ---------- */}
397397 <section class="landing-bigstats">
398398 <div class="landing-bigstats-grid">
399399 <div class="landing-bigstat">
504504 <WalkStep n="01" title="Push" desc="git push to gluecron — Smart-HTTP, SSH, or via the web editor." />
505505 <WalkStep n="02" title="Gate" desc="GateTest runs. Secret scanner runs. AI security review posts inline comments." />
506506 <WalkStep n="03" title="Repair" desc="If a gate fails, auto-repair tries to fix it. New commit gets re-gated." />
507 <WalkStep n="04" title="Ship" desc="Green push to default branch fires deploy webhook. Crontech, Fly, your prod." />
507 <WalkStep n="04" title="Ship" desc="Green push to default branch fires deploy webhook. Vapron, Fly, your prod." />
508508 </div>
509509 </section>
510510
22722272 }
22732273 .landing-stats-sep { opacity: 0.4; }
22742274
2275 /* ---------- Capability grid (crontech-style uppercase tracked) ---------- */
2275 /* ---------- Capability grid (vapron-style uppercase tracked) ---------- */
22762276 .landing-caps {
22772277 margin: var(--s-12) auto var(--s-16);
22782278 max-width: 1080px;
23032303 .landing-caps-grid { grid-template-columns: 1fr; }
23042304 }
23052305
2306 /* ---------- Big stat row (crontech-style hero closer) ---------- */
2306 /* ---------- Big stat row (vapron-style hero closer) ---------- */
23072307 .landing-bigstats {
23082308 margin: var(--s-10) auto var(--s-20);
23092309 max-width: 1180px;
23102310