Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api-gateway/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions api-gateway/.env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions api-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion api-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions api-gateway/src/env.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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 */
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions api-gateway/src/lib/keepalive.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
15 changes: 15 additions & 0 deletions api-gateway/src/lib/keepalive.ts
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions api-gateway/src/plugins/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions api-gateway/src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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()
Expand Down
31 changes: 30 additions & 1 deletion docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<random-secret-of-at-least-32-characters>
```

**Required for hardened production:** a separate worker, R2, Upstash, Sentry,
Expand All @@ -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 <KEEPALIVE_CRON_SECRET>` |

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)
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions supabase/migrations/20260731_000007_keepalive.sql
Original file line number Diff line number Diff line change
@@ -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;