From 588d46a9ea8e4d07b88853a26704fab470e2be1b Mon Sep 17 00:00:00 2001 From: Jabir Khan <98869091+captain-jack-sparrow909@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:44:35 +0400 Subject: [PATCH] liveness fix --- api-gateway/.env.example | 3 + api-gateway/.env.production.example | 1 + api-gateway/README.md | 1 + api-gateway/package.json | 2 +- api-gateway/src/env.ts | 8 +++ api-gateway/src/lib/keepalive.test.ts | 20 ++++++ api-gateway/src/lib/keepalive.ts | 15 +++++ api-gateway/src/plugins/rate-limit.ts | 1 + api-gateway/src/routes/health.ts | 66 +++++++++++++++++++ docs/DEPLOYMENT.md | 31 ++++++++- render.yaml | 2 + .../migrations/20260731_000007_keepalive.sql | 44 +++++++++++++ 12 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 api-gateway/src/lib/keepalive.test.ts create mode 100644 api-gateway/src/lib/keepalive.ts create mode 100644 supabase/migrations/20260731_000007_keepalive.sql diff --git a/api-gateway/.env.example b/api-gateway/.env.example index f539ed2..7a44d57 100644 --- a/api-gateway/.env.example +++ b/api-gateway/.env.example @@ -31,6 +31,9 @@ UPSTASH_REDIS_REST_TOKEN= SENTRY_DSN= SENTRY_ENVIRONMENT=development +# Optional locally; required for POST /v1/keepalive (minimum 32 characters) +KEEPALIVE_CRON_SECRET= + # Paddle PADDLE_ENV=sandbox PADDLE_API_KEY= diff --git a/api-gateway/.env.production.example b/api-gateway/.env.production.example index 9e02f04..48f2f8a 100644 --- a/api-gateway/.env.production.example +++ b/api-gateway/.env.production.example @@ -14,6 +14,7 @@ ARTIFACT_RETENTION_DAYS=30 ENABLE_GENERIC_JOB_API=false SENTRY_DSN= SENTRY_ENVIRONMENT=production +KEEPALIVE_CRON_SECRET= # Frontend origin(s) for CORS (comma-separated) APP_URL=https://rontgenai.dev diff --git a/api-gateway/README.md b/api-gateway/README.md index a832b5d..9a64f0e 100644 --- a/api-gateway/README.md +++ b/api-gateway/README.md @@ -10,6 +10,7 @@ Deploy separately (Render). Not part of the Next.js app. |--------|------|------|-------------| | GET | `/health`, `/v1/health` | — | Liveness | | GET | `/ready` | — | Database, Redis, and execution-mode readiness | +| POST | `/v1/keepalive` | Cron bearer secret | Wake Render and atomically toggle a dedicated Supabase maintenance row | | GET | `/v1/me` | Bearer | Profile + plan + usage (syncs Clerk → Supabase) | | GET | `/v1/usage` | Bearer | Monthly usage snapshot | | POST | `/v1/usage` | Bearer | Record usage (enforces limits) | diff --git a/api-gateway/package.json b/api-gateway/package.json index fbc55ce..208f1e3 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -10,7 +10,7 @@ "worker": "node dist/worker.js", "worker:tsx": "node --import tsx src/worker.ts", "start:tsx": "node --import tsx src/index.ts", - "test": "NODE_ENV=test CLERK_SECRET_KEY=test SUPABASE_URL=https://example.supabase.co SUPABASE_SERVICE_ROLE_KEY=test node --import tsx --test src/lib/atlas/analyze.test.ts src/lib/blueprint/review.test.ts src/lib/forge/discover.test.ts src/lib/forge/plan.test.ts src/lib/pulse/parse.test.ts src/lib/sentinel/review.test.ts src/lib/radar/investigate.test.ts src/lib/relay/analyze.test.ts src/plugins/rate-limit.test.ts", + "test": "NODE_ENV=test CLERK_SECRET_KEY=test SUPABASE_URL=https://example.supabase.co SUPABASE_SERVICE_ROLE_KEY=test node --import tsx --test src/lib/atlas/analyze.test.ts src/lib/blueprint/review.test.ts src/lib/forge/discover.test.ts src/lib/forge/plan.test.ts src/lib/keepalive.test.ts src/lib/pulse/parse.test.ts src/lib/sentinel/review.test.ts src/lib/radar/investigate.test.ts src/lib/relay/analyze.test.ts src/plugins/rate-limit.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "engines": { diff --git a/api-gateway/src/env.ts b/api-gateway/src/env.ts index 20d071d..a399f74 100644 --- a/api-gateway/src/env.ts +++ b/api-gateway/src/env.ts @@ -1,6 +1,12 @@ import "dotenv/config"; import { z } from "zod"; +const optionalKeepaliveSecret = z.preprocess( + (value) => + typeof value === "string" && value.trim() === "" ? undefined : value, + z.string().trim().min(32).max(512).optional(), +); + const envSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), PORT: z.coerce.number().default(8000), @@ -15,6 +21,7 @@ const envSchema = z.object({ ARTIFACT_RETENTION_DAYS: z.coerce.number().int().min(1).max(3650).default(30), SENTRY_DSN: z.string().url().optional(), SENTRY_ENVIRONMENT: z.string().max(100).optional(), + KEEPALIVE_CRON_SECRET: optionalKeepaliveSecret, ENABLE_GENERIC_JOB_API: z.enum(["true", "false"]).default("false").transform((value) => value === "true"), APP_URL: z.string().url().default("http://localhost:3000"), /** Comma-separated allowlist, e.g. https://rontgenai.dev,https://www.rontgenai.dev */ @@ -94,6 +101,7 @@ function loadEnv(): Env { ARTIFACT_RETENTION_DAYS: process.env.ARTIFACT_RETENTION_DAYS, SENTRY_DSN: process.env.SENTRY_DSN, SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT, + KEEPALIVE_CRON_SECRET: process.env.KEEPALIVE_CRON_SECRET, ENABLE_GENERIC_JOB_API: process.env.ENABLE_GENERIC_JOB_API, APP_URL: process.env.APP_URL ?? process.env.NEXT_PUBLIC_APP_URL, CORS_ORIGINS: process.env.CORS_ORIGINS ?? process.env.NEXT_PUBLIC_APP_URL, diff --git a/api-gateway/src/lib/keepalive.test.ts b/api-gateway/src/lib/keepalive.test.ts new file mode 100644 index 0000000..d94e694 --- /dev/null +++ b/api-gateway/src/lib/keepalive.test.ts @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { hasValidKeepaliveAuthorization } from "./keepalive.js"; + +const secret = "a-secure-keepalive-secret-with-32-chars"; + +test("accepts the configured keepalive bearer secret", () => { + assert.equal( + hasValidKeepaliveAuthorization(`Bearer ${secret}`, secret), + true, + ); +}); + +test("rejects missing, malformed, or incorrect keepalive credentials", () => { + assert.equal(hasValidKeepaliveAuthorization(undefined, secret), false); + assert.equal(hasValidKeepaliveAuthorization(`Basic ${secret}`, secret), false); + assert.equal(hasValidKeepaliveAuthorization("Bearer wrong", secret), false); + assert.equal(hasValidKeepaliveAuthorization(`Bearer ${secret} extra`, secret), false); + assert.equal(hasValidKeepaliveAuthorization(`Bearer ${secret}`, undefined), false); +}); diff --git a/api-gateway/src/lib/keepalive.ts b/api-gateway/src/lib/keepalive.ts new file mode 100644 index 0000000..03383fe --- /dev/null +++ b/api-gateway/src/lib/keepalive.ts @@ -0,0 +1,15 @@ +import { timingSafeEqual } from "node:crypto"; + +export function hasValidKeepaliveAuthorization( + authorization: string | undefined, + expectedSecret: string | undefined, +): boolean { + if (!authorization || !expectedSecret) return false; + + const match = /^Bearer ([^\s]+)$/.exec(authorization); + if (!match) return false; + + const supplied = Buffer.from(match[1], "utf8"); + const expected = Buffer.from(expectedSecret, "utf8"); + return supplied.length === expected.length && timingSafeEqual(supplied, expected); +} diff --git a/api-gateway/src/plugins/rate-limit.ts b/api-gateway/src/plugins/rate-limit.ts index b05f732..75b67c5 100644 --- a/api-gateway/src/plugins/rate-limit.ts +++ b/api-gateway/src/plugins/rate-limit.ts @@ -74,6 +74,7 @@ export const rateLimitPlugin: FastifyPluginAsync = async (app) => { request.url === "/health" || request.url === "/ready" || request.url === "/v1/health" || + request.url === "/v1/keepalive" || request.url.startsWith("/v1/webhooks/") ) { return; diff --git a/api-gateway/src/routes/health.ts b/api-gateway/src/routes/health.ts index fd1a98b..ca3d62a 100644 --- a/api-gateway/src/routes/health.ts +++ b/api-gateway/src/routes/health.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsync } from "fastify"; import { env } from "../env.js"; +import { hasValidKeepaliveAuthorization } from "../lib/keepalive.js"; import { getRedis } from "../lib/redis.js"; import { getSupabase } from "../lib/supabase.js"; @@ -17,6 +18,71 @@ export const healthRoutes: FastifyPluginAsync = async (app) => { ts: new Date().toISOString(), })); + app.post("/v1/keepalive", async (request, reply) => { + reply + .header("Cache-Control", "no-store, max-age=0") + .header("Pragma", "no-cache") + .header("Expires", "0"); + + if (!env.KEEPALIVE_CRON_SECRET) { + request.log.error("keepalive endpoint called without KEEPALIVE_CRON_SECRET"); + return reply.status(503).send({ + ok: false, + error: "Keepalive endpoint is not configured", + requestId: request.id, + }); + } + + if ( + !hasValidKeepaliveAuthorization( + request.headers.authorization, + env.KEEPALIVE_CRON_SECRET, + ) + ) { + return reply.status(401).send({ + ok: false, + error: "Unauthorized", + requestId: request.id, + }); + } + + const startedAt = Date.now(); + const { data, error } = await getSupabase().rpc("toggle_keepalive_pulse"); + if (error) { + request.log.error( + { err: error, requestId: request.id }, + "database keepalive failed", + ); + return reply.status(503).send({ + ok: false, + error: "Database keepalive failed", + requestId: request.id, + }); + } + + const result = Array.isArray(data) ? data[0] : data; + const action = result?.action; + if (action !== "inserted" && action !== "deleted") { + request.log.error( + { result, requestId: request.id }, + "database keepalive returned an invalid result", + ); + return reply.status(503).send({ + ok: false, + error: "Database keepalive returned an invalid result", + requestId: request.id, + }); + } + + return reply.send({ + ok: true, + service: "api-gateway", + database: { action }, + latencyMs: Date.now() - startedAt, + ts: new Date().toISOString(), + }); + }); + app.get("/ready", async (_request, reply) => { const startedAt = Date.now(); const { error: databaseError } = await getSupabase() diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index d058e1e..9936833 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -69,6 +69,7 @@ CLERK_SECRET_KEY=sk_live_... SUPABASE_URL=https://xxxx.supabase.co SUPABASE_SERVICE_ROLE_KEY=... DEEPSEEK_API_KEY=... +KEEPALIVE_CRON_SECRET= ``` **Required for hardened production:** a separate worker, R2, Upstash, Sentry, @@ -95,6 +96,34 @@ curl https://api.rontgenai.dev/ready # HTTP 200 with database/Redis checks ``` +### 1.5 Keep the free API and database active + +After applying `supabase/migrations/20260731_000007_keepalive.sql`, create a +cron-job.org job with these settings: + +| Field | Value | +|-------|-------| +| URL | `https://api.rontgenai.dev/v1/keepalive` | +| Schedule | Every 13 minutes (`*/13 * * * *`) | +| Method | `POST` | +| Header | `Authorization: Bearer ` | + +Use the same secret in cron-job.org and the Render web service, then redeploy +the service. Do not put the secret in the URL. A successful response alternates +between `database.action: "inserted"` and `database.action: "deleted"`: + +```bash +# Generate once, then save this value in both Render and cron-job.org. +openssl rand -hex 32 + +curl --fail --request POST \ + --header "Authorization: Bearer $KEEPALIVE_CRON_SECRET" \ + https://api.rontgenai.dev/v1/keepalive +``` + +The maintenance table is isolated from product data, protected by RLS, and the +toggle runs under a database transaction lock so concurrent calls remain safe. + --- ## 2. Deploy Web (Vercel) @@ -186,7 +215,7 @@ https://rontgenai.dev/app | Service | Caveat | |---------|--------| -| **Render free** | Spins down after idle; first request ~30–60s cold start | +| **Render free** | Spins down after idle unless the authenticated keepalive cron is enabled; first request after sleep has a cold start | | **Vercel hobby** | Fine for launch traffic | | **Supabase free** | Watch DB size / egress | | **DeepSeek** | Pay-as-you-go — set plan limits in app | diff --git a/render.yaml b/render.yaml index aa31aea..daef0d9 100644 --- a/render.yaml +++ b/render.yaml @@ -33,6 +33,8 @@ services: sync: false - key: SUPABASE_SERVICE_ROLE_KEY sync: false + - key: KEEPALIVE_CRON_SECRET + sync: false - key: DEEPSEEK_API_KEY sync: false - key: DEEPSEEK_BASE_URL diff --git a/supabase/migrations/20260731_000007_keepalive.sql b/supabase/migrations/20260731_000007_keepalive.sql new file mode 100644 index 0000000..2bf0e25 --- /dev/null +++ b/supabase/migrations/20260731_000007_keepalive.sql @@ -0,0 +1,44 @@ +-- A dedicated maintenance row used by the authenticated Render keepalive route. +-- Its presence is the state: one call inserts it and the next call deletes it. + +create table if not exists public.keepalive_pulses ( + id text primary key check (id = 'render-cron'), + created_at timestamptz not null default now() +); + +comment on table public.keepalive_pulses is + 'Single maintenance row toggled by the authenticated API keepalive endpoint'; + +alter table public.keepalive_pulses enable row level security; +revoke all on table public.keepalive_pulses from public, anon, authenticated; + +create or replace function public.toggle_keepalive_pulse() +returns table(action text, performed_at timestamptz) +language plpgsql +volatile +security definer +set search_path = pg_catalog, public +as $$ +declare + pulse_time timestamptz := clock_timestamp(); +begin + -- Serialize calls so overlapping cron requests still alternate correctly. + perform pg_advisory_xact_lock( + hashtextextended('rontgenai:render-cron-keepalive', 0) + ); + + if exists ( + select 1 from public.keepalive_pulses where id = 'render-cron' + ) then + delete from public.keepalive_pulses where id = 'render-cron'; + return query select 'deleted'::text, pulse_time; + else + insert into public.keepalive_pulses (id, created_at) + values ('render-cron', pulse_time); + return query select 'inserted'::text, pulse_time; + end if; +end; +$$; + +revoke all on function public.toggle_keepalive_pulse() from public, anon, authenticated; +grant execute on function public.toggle_keepalive_pulse() to service_role;