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
15 changes: 14 additions & 1 deletion docs/operations/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: "Konfiguration och skillnader mellan lokal utveckling, staging och
category: operations
tags: [environments, vercel, supabase, ios, config]
status: active
last_updated: 2026-05-06
last_updated: 2026-05-08
related:
- deployment.md
- staging-environment-setup.md
Expand Down Expand Up @@ -163,8 +163,21 @@ Autentiseras med `CRON_SECRET` (Bearer token).
| `UPSTASH_REDIS_REST_URL` | (tom = in-memory) | Prod Redis | Prod Redis | Rate limiting |
| `RESEND_API_KEY` | (tom = konsol-logg) | -- | Resend API-nyckel | E-post |
| `CRON_SECRET` | Valfri | -- | Stark slumpad | Cron-autentisering |
| `DISABLE_CRONS` | -- | `true` (i isolerat staging-projekt) | -- (FAR ALDRIG vara satt) | Skip-flagga for cron-jobb. Pre-build-guard avvisar prod-deploy om DISABLE_CRONS=true. |
| `SUBSCRIPTION_PROVIDER` | `mock` | `mock` | `stripe` | Betalning |

### DISABLE_CRONS — anvandning

`DISABLE_CRONS=true` far cron-routes (`/api/cron/*`) att returnera `200 { skipped: true, reason: "DISABLE_CRONS" }` istallet for att exekvera. Anvands i isolerade staging/preview-projekt dar Vercel klassar staging-branch som production (vilket annars triggar duplicerade cron-jobb mot staging-DB).

**Belt-and-suspenders i staging-projekt:**
1. `DISABLE_CRONS=true` (explicit guard, syns i kod)
2. `CRON_SECRET` satts INTE (om guarden av nagon anledning hoppas over → `verifyCronAuth` returnerar 401, ingen exekvering)

**Skydd mot misstag:** `scripts/check-prod-env.ts` har en `checkCronsEnabled`-funktion som kor i pre-build pa Vercel Production. Om `DISABLE_CRONS=true` upptäcks pa production faller bygget med tydligt felmeddelande.

**Sla av guarden i staging:** ta bort env-flaggan (eller satt `false`) → cron-routes kor som vanligt vid nasta deploy. Reversibelt utan kodandring.

---

## Deploy-ordning vid schemaandring
Expand Down
38 changes: 37 additions & 1 deletion scripts/check-prod-env.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { checkProdEnv, REQUIRED_PROD_VARS } from './check-prod-env'
import { checkProdEnv, checkCronsEnabled, REQUIRED_PROD_VARS } from './check-prod-env'

const allVarsPresent = Object.fromEntries(REQUIRED_PROD_VARS.map(v => [v, 'value']))

Expand Down Expand Up @@ -35,6 +35,42 @@ describe('checkProdEnv', () => {
})

it('exports the full list of required vars', () => {
expect(REQUIRED_PROD_VARS).toBeDefined()
})
})

describe('checkCronsEnabled', () => {
it('returns ok when DISABLE_CRONS is unset', () => {
const result = checkCronsEnabled({})
expect(result.ok).toBe(true)
expect(result.reason).toBeUndefined()
})

it('returns ok when DISABLE_CRONS is empty', () => {
const result = checkCronsEnabled({ DISABLE_CRONS: '' })
expect(result.ok).toBe(true)
})

it('returns ok when DISABLE_CRONS is "false"', () => {
const result = checkCronsEnabled({ DISABLE_CRONS: 'false' })
expect(result.ok).toBe(true)
})

it('returns FAIL when DISABLE_CRONS is "true"', () => {
const result = checkCronsEnabled({ DISABLE_CRONS: 'true' })
expect(result.ok).toBe(false)
expect(result.reason).toMatch(/DISABLE_CRONS=true/)
expect(result.reason).toMatch(/skipped/)
})

it('treats non-"true" string as enabled (truthy strings other than "true" do not skip)', () => {
const result = checkCronsEnabled({ DISABLE_CRONS: '1' })
expect(result.ok).toBe(true)
})
})

describe('REQUIRED_PROD_VARS', () => {
it('is the canonical list of required vars', () => {
const expectedVars = [
'APP_URL',
'DATABASE_URL',
Expand Down
24 changes: 21 additions & 3 deletions scripts/check-prod-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,35 @@ export function checkProdEnv(env: Record<string, string | undefined>): { missing
return { missing }
}

export function checkCronsEnabled(env: Record<string, string | undefined>): { ok: boolean; reason?: string } {
if (env.DISABLE_CRONS === 'true') {
return {
ok: false,
reason: 'DISABLE_CRONS=true is set on production. Cron jobs would be silently skipped (no reminders sent, no data retention).',
}
}
return { ok: true }
}

// CLI entry point — only runs when VERCEL_ENV=production
if (process.env.VERCEL_ENV === 'production') {
const { missing } = checkProdEnv(process.env as Record<string, string | undefined>)
const env = process.env as Record<string, string | undefined>
const { missing } = checkProdEnv(env)
if (missing.length > 0) {
console.error(`\n[check-prod-env] FAIL: Missing required environment variables:`)
for (const v of missing) {
console.error(` - ${v}`)
}
console.error(`\nFix: Set these variables in Vercel Project Settings > Environment Variables (Production).\n`)
process.exit(1)
} else {
console.log('[check-prod-env] OK: All required production environment variables are set.')
}

const cronCheck = checkCronsEnabled(env)
if (!cronCheck.ok) {
console.error(`\n[check-prod-env] FAIL: ${cronCheck.reason}`)
console.error(`\nFix: Remove DISABLE_CRONS or set it to 'false' in production. DISABLE_CRONS is meant for staging projects only.\n`)
process.exit(1)
}

console.log('[check-prod-env] OK: All required production environment variables are set and crons are enabled.')
}
29 changes: 29 additions & 0 deletions src/app/api/cron/booking-reminders/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import { GET } from "./route"
describe("GET /api/cron/booking-reminders", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllEnvs()
process.env.CRON_SECRET = "test-secret"
delete process.env.DISABLE_CRONS
mockProcessAll.mockResolvedValue(3)
})

it("returns 200 and processes reminders with valid CRON_SECRET", async () => {
Expand Down Expand Up @@ -52,6 +55,32 @@ describe("GET /api/cron/booking-reminders", () => {
expect(response.status).toBe(401)
})

it("skips with 200 when DISABLE_CRONS=true (skip wins before auth)", async () => {
vi.stubEnv("DISABLE_CRONS", "true")

const request = new Request("http://localhost/api/cron/booking-reminders", {
headers: { authorization: "Bearer test-secret" },
})

const response = await GET(request as never)
const body = await response.json()

expect(response.status).toBe(200)
expect(body).toEqual({ skipped: true, reason: "DISABLE_CRONS" })
expect(mockProcessAll).not.toHaveBeenCalled()
})

it("skips with 200 when DISABLE_CRONS=true even without valid auth", async () => {
vi.stubEnv("DISABLE_CRONS", "true")

const request = new Request("http://localhost/api/cron/booking-reminders")

const response = await GET(request as never)

expect(response.status).toBe(200)
expect(mockProcessAll).not.toHaveBeenCalled()
})

it("returns 500 on database error", async () => {
mockProcessAll.mockRejectedValueOnce(new Error("DB down"))

Expand Down
7 changes: 7 additions & 0 deletions src/app/api/cron/booking-reminders/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import { verifyCronAuth } from "@/lib/cron-auth"
* Protected by CRON_SECRET (Bearer + HMAC signature).
*/
export async function GET(request: NextRequest) {
if (process.env.DISABLE_CRONS === "true") {
return NextResponse.json(
{ skipped: true, reason: "DISABLE_CRONS" },
{ status: 200 }
)
}

const auth = verifyCronAuth(
request.headers.get("authorization"),
process.env.CRON_SECRET,
Expand Down
15 changes: 15 additions & 0 deletions src/app/api/cron/data-retention/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ function makeRequest(): NextRequest {
describe("GET /api/cron/data-retention", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllEnvs()
delete process.env.DISABLE_CRONS
mockVerifyCronAuth.mockReturnValue({ ok: true })
mockIsFeatureEnabled.mockResolvedValue(true)
mockProcessRetention.mockResolvedValue({
Expand Down Expand Up @@ -100,4 +102,17 @@ describe("GET /api/cron/data-retention", () => {

expect(res.status).toBe(500)
})

it("skips with 200 when DISABLE_CRONS=true (skip wins before auth and feature flag)", async () => {
vi.stubEnv("DISABLE_CRONS", "true")

const res = await GET(makeRequest())
const body = await res.json()

expect(res.status).toBe(200)
expect(body).toEqual({ skipped: true, reason: "DISABLE_CRONS" })
expect(mockVerifyCronAuth).not.toHaveBeenCalled()
expect(mockIsFeatureEnabled).not.toHaveBeenCalled()
expect(mockProcessRetention).not.toHaveBeenCalled()
})
})
7 changes: 7 additions & 0 deletions src/app/api/cron/data-retention/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import { logger } from "@/lib/logger"
* 3. Notified 30+ days ago -> delete/anonymize account
*/
export async function GET(request: NextRequest) {
if (process.env.DISABLE_CRONS === "true") {
return NextResponse.json(
{ skipped: true, reason: "DISABLE_CRONS" },
{ status: 200 }
)
}

const auth = verifyCronAuth(
request.headers.get("authorization"),
process.env.CRON_SECRET,
Expand Down
53 changes: 53 additions & 0 deletions src/app/api/cron/send-reminders/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe("GET /api/cron/send-reminders", () => {

beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllEnvs()
vi.stubEnv("CRON_SECRET", CRON_SECRET)
})

Expand Down Expand Up @@ -65,6 +66,58 @@ describe("GET /api/cron/send-reminders", () => {
expect(mockProcessAll).not.toHaveBeenCalled()
})

it("skips with 200 when DISABLE_CRONS=true (skip wins before auth)", async () => {
vi.stubEnv("DISABLE_CRONS", "true")

const request = new NextRequest(
"http://localhost:3000/api/cron/send-reminders",
{
headers: {
authorization: `Bearer ${CRON_SECRET}`,
},
}
)

const response = await GET(request)
const data = await response.json()

expect(response.status).toBe(200)
expect(data).toEqual({ skipped: true, reason: "DISABLE_CRONS" })
expect(mockProcessAll).not.toHaveBeenCalled()
})

it("skips with 200 when DISABLE_CRONS=true even without valid auth (skip is first guard)", async () => {
vi.stubEnv("DISABLE_CRONS", "true")

const request = new NextRequest(
"http://localhost:3000/api/cron/send-reminders"
)

const response = await GET(request)

expect(response.status).toBe(200)
expect(mockProcessAll).not.toHaveBeenCalled()
})

it("processes normally when DISABLE_CRONS is empty string", async () => {
vi.stubEnv("DISABLE_CRONS", "")
mockProcessAll.mockResolvedValue(0)

const request = new NextRequest(
"http://localhost:3000/api/cron/send-reminders",
{
headers: {
authorization: `Bearer ${CRON_SECRET}`,
},
}
)

const response = await GET(request)

expect(response.status).toBe(200)
expect(mockProcessAll).toHaveBeenCalledTimes(1)
})

it("should return 500 when processing fails", async () => {
mockProcessAll.mockRejectedValue(new Error("Database error"))

Expand Down
7 changes: 7 additions & 0 deletions src/app/api/cron/send-reminders/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import { verifyCronAuth } from "@/lib/cron-auth"
* Protected by CRON_SECRET (Bearer + HMAC signature).
*/
export async function GET(request: NextRequest) {
if (process.env.DISABLE_CRONS === "true") {
return NextResponse.json(
{ skipped: true, reason: "DISABLE_CRONS" },
{ status: 200 }
)
}

const auth = verifyCronAuth(
request.headers.get("authorization"),
process.env.CRON_SECRET,
Expand Down