Skip to content

Commit 71068a7

Browse files
committed
refactor: make kv redis optional
1 parent 5fa0e6c commit 71068a7

11 files changed

Lines changed: 547 additions & 64 deletions

File tree

.env.example

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@ SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
99
# Resolves Infrastructure and Dashboard API + E2B SDK configuration
1010
NEXT_PUBLIC_E2B_DOMAIN=e2b.dev
1111

12-
### KV database configuration
13-
KV_REST_API_TOKEN=
14-
KV_REST_API_URL=
15-
1612
### =================================
1713
### REQUIRED CLIENT ENVIRONMENT VARIABLES
1814
### =================================
@@ -37,6 +33,12 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
3733
### ZeroBounce API key for email validation
3834
# ZEROBOUNCE_API_KEY=
3935

36+
### Optional KV database configuration
37+
### Required only for KV-backed capabilities such as alternate-email warning dedupe.
38+
### Must be Vercel/Upstash Redis REST compatible; raw redis://localhost:6379 is not supported by @vercel/kv.
39+
# KV_REST_API_TOKEN=
40+
# KV_REST_API_URL=
41+
4042
### Plain API key for customer support integration
4143
# (Required if NEXT_PUBLIC_INCLUDE_REPORT_ISSUE=1)
4244
# PLAIN_API_KEY=

README.md

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -63,21 +63,7 @@ cp .env.example .env.local
6363

6464
4. Set up required services:
6565

66-
#### a. Key-Value Store Setup
67-
This project requires a Redis-compatible key-value store. You'll need to:
68-
69-
1. Set up a Redis instance (self-hosted or using a cloud provider)
70-
2. Configure the following environment variables in your `.env.local` file:
71-
```
72-
KV_URL=your_redis_connection_string
73-
KV_REST_API_URL=your_redis_rest_api_url
74-
KV_REST_API_TOKEN=your_redis_api_write_token
75-
KV_REST_API_READ_ONLY_TOKEN=your_redis_api_read_token
76-
```
77-
78-
> **Note**: For production deployments, we use Vercel KV Storage integration, which provides a managed Redis-compatible store and automatically configures these environment variables. You can add this integration through the Vercel dashboard when deploying your project.
79-
80-
#### b. Supabase Setup
66+
#### a. Supabase Setup
8167
1. Create a new Supabase project
8268
2. Go to Project Settings > API
8369
3. Copy the `anon key` & `service_role key` to populate `.env.local`
@@ -103,18 +89,33 @@ This project requires a Redis-compatible key-value store. You'll need to:
10389
{{ .SiteURL }}/api/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}&confirmation_url={{ .ConfirmationURL }}
10490
```
10591

106-
#### c. Database Setup
92+
#### b. Database Setup
10793
1. Apply the database migrations manually:
10894
- Navigate to the `/migrations` folder in the project
10995
- Execute each SQL migration file in sequential order against your Supabase database
11096
- You can run these migrations using the Supabase SQL Editor or a PostgreSQL client
11197
- Make sure to apply migrations in the correct order based on their timestamp prefixes
11298

113-
#### d. Supabase Storage Setup
99+
#### c. Supabase Storage Setup
114100
1. Go to Storage > Buckets
115101
2. Create a new **public** bucket named `profile-pictures`
116102

117-
#### e. Start the development server
103+
5. Optional services:
104+
105+
#### a. Key-Value Store Setup
106+
Redis/KV is optional for standard dashboard deployments, including local, enterprise, and on-prem environments. The dashboard can boot and run core auth and dashboard workflows without KV configured.
107+
108+
KV is currently used for optional capability checks and for deduplicating ZeroBounce alternate-email warnings. If you need those capabilities, configure a Vercel/Upstash Redis REST-compatible store:
109+
```
110+
KV_REST_API_URL=your_redis_rest_api_url
111+
KV_REST_API_TOKEN=your_redis_api_write_token
112+
```
113+
114+
> **Note**: `@vercel/kv` expects a Redis REST API. A raw Redis server such as `redis://localhost:6379` is not compatible without an Upstash-compatible REST proxy.
115+
116+
> **Health check**: When `KV_REST_API_URL` and `KV_REST_API_TOKEN` are set, `/api/health` will report `503 degraded` if KV is unreachable. Leave both unset to opt out of the KV health check entirely.
117+
118+
6. Start the development server:
118119
```bash
119120
# Using Bun (recommended)
120121
bun run dev

scripts/check-app-env.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ loadEnvConfig(projectDir)
66

77
const schema = serverSchema
88
.merge(clientSchema)
9+
.refine(
10+
(data) => Boolean(data.KV_REST_API_URL) === Boolean(data.KV_REST_API_TOKEN),
11+
{
12+
message: 'KV_REST_API_URL and KV_REST_API_TOKEN must be set together',
13+
path: ['KV_REST_API_URL'],
14+
}
15+
)
916
.refine(
1017
(data) => {
1118
if (data.NEXT_PUBLIC_INCLUDE_BILLING === '1') {

src/app/api/health/route.ts

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,24 @@
11
import { NextResponse } from 'next/server'
22
import { api } from '@/core/shared/clients/api'
3-
import { kv } from '@/core/shared/clients/kv'
3+
import { pingKv } from '@/core/shared/clients/kv'
44
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
55

66
export const maxDuration = 10
77

8-
export async function GET() {
9-
const checks = {
10-
kv: false,
11-
dashboardApi: false,
12-
}
13-
8+
async function checkDashboardApi(): Promise<boolean> {
149
try {
15-
await kv.ping()
16-
checks.kv = true
17-
} catch (error) {
10+
const { error } = await api.GET('/health', {})
11+
if (!error) {
12+
return true
13+
}
14+
1815
l.error(
1916
{
20-
key: 'health_check:kv_error',
21-
error: serializeErrorForLog(error),
17+
key: 'health_check:dashboard_api_error',
18+
error,
2219
},
23-
'KV health check failed'
20+
'Dashboard API health check failed'
2421
)
25-
}
26-
27-
try {
28-
const { error } = await api.GET('/health', {})
29-
if (!error) {
30-
checks.dashboardApi = true
31-
} else {
32-
l.error(
33-
{
34-
key: 'health_check:dashboard_api_error',
35-
error,
36-
},
37-
'Dashboard API health check failed'
38-
)
39-
}
4022
} catch (error) {
4123
l.error(
4224
{
@@ -47,15 +29,59 @@ export async function GET() {
4729
)
4830
}
4931

50-
const allHealthy = checks.kv && checks.dashboardApi
32+
return false
33+
}
34+
35+
export async function GET() {
36+
const [kvStatus, dashboardApi] = await Promise.all([
37+
pingKv(),
38+
checkDashboardApi(),
39+
])
40+
41+
const checks: { kv?: boolean; dashboardApi: boolean } = { dashboardApi }
42+
43+
if (kvStatus.configured) {
44+
checks.kv = kvStatus.available
45+
}
46+
47+
if (kvStatus.status === 'misconfigured') {
48+
// Surface misconfiguration in the response body so it's visible
49+
// without scraping logs.
50+
checks.kv = false
51+
l.error(
52+
{
53+
key: 'health_check:kv_misconfigured',
54+
},
55+
'KV health check is misconfigured'
56+
)
57+
}
58+
59+
if (kvStatus.status === 'error') {
60+
l.error(
61+
{
62+
key: 'health_check:kv_error',
63+
error: serializeErrorForLog(kvStatus.error),
64+
},
65+
'KV health check failed'
66+
)
67+
}
68+
69+
// KV is required *only when configured*. If an operator has wired it up,
70+
// they expect it to work — so failure or misconfiguration must degrade
71+
// the overall status. When KV is intentionally absent (not_configured),
72+
// it contributes nothing to the health check.
73+
const kvRequiredAndHealthy =
74+
kvStatus.status === 'not_configured' || kvStatus.status === 'ok'
75+
76+
const allRequiredHealthy = dashboardApi && kvRequiredAndHealthy
5177

5278
return NextResponse.json(
5379
{
54-
status: allHealthy ? 'ok' : 'degraded',
80+
status: allRequiredHealthy ? 'ok' : 'degraded',
5581
checks,
5682
},
5783
{
58-
status: allHealthy ? 200 : 503,
84+
status: allRequiredHealthy ? 200 : 503,
5985
headers: {
6086
// vercel infra respects this to cache on cdn
6187
'Cache-Control': 'public, max-age=30, must-revalidate',

src/core/server/functions/auth/validate-email.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { kv } from '@vercel/kv'
21
import { KV_KEYS } from '@/configs/keys'
2+
import { getKvValue, setKvValue } from '@/core/shared/clients/kv'
33
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
44

55
/**
@@ -102,16 +102,54 @@ export const shouldWarnAboutAlternateEmail = async (
102102
validationResult: EmailValidationResponse
103103
): Promise<boolean> => {
104104
if (validationResult.sub_status === 'alternate') {
105-
const warnedAlternateEmail = await kv.get(
105+
const warnedAlternateEmail = await getKvValue<boolean>(
106106
KV_KEYS.WARNED_ALTERNATE_EMAIL(validationResult.address)
107107
)
108108

109-
if (!warnedAlternateEmail) {
110-
await kv.set(
109+
if (!warnedAlternateEmail.ok) {
110+
l.warn(
111+
{
112+
key: 'validate_email:alternate_email_kv_unavailable',
113+
error:
114+
warnedAlternateEmail.reason === 'error'
115+
? serializeErrorForLog(warnedAlternateEmail.error)
116+
: undefined,
117+
context: {
118+
email: validationResult.address,
119+
reason: warnedAlternateEmail.reason,
120+
},
121+
},
122+
'Skipping alternate email warning because KV is unavailable'
123+
)
124+
125+
return false
126+
}
127+
128+
if (!warnedAlternateEmail.value) {
129+
const setResult = await setKvValue(
111130
KV_KEYS.WARNED_ALTERNATE_EMAIL(validationResult.address),
112131
true
113132
)
114133

134+
if (!setResult.ok) {
135+
l.warn(
136+
{
137+
key: 'validate_email:alternate_email_kv_set_unavailable',
138+
error:
139+
setResult.reason === 'error'
140+
? serializeErrorForLog(setResult.error)
141+
: undefined,
142+
context: {
143+
email: validationResult.address,
144+
reason: setResult.reason,
145+
},
146+
},
147+
'Skipping alternate email warning because KV could not persist state'
148+
)
149+
150+
return false
151+
}
152+
115153
return true
116154
}
117155
}

src/core/shared/clients/kv.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,83 @@
1-
export { kv } from '@vercel/kv'
1+
import { kv } from '@vercel/kv'
2+
3+
export type OptionalKvResult<T> =
4+
| { ok: true; configured: true; value: T }
5+
| {
6+
ok: false
7+
configured: false
8+
reason: 'not_configured' | 'misconfigured'
9+
}
10+
| { ok: false; configured: true; reason: 'error'; error: unknown }
11+
12+
export type KvCapabilityStatus =
13+
| { configured: false; available: false; status: 'not_configured' }
14+
| { configured: false; available: false; status: 'misconfigured' }
15+
| { configured: true; available: true; status: 'ok' }
16+
| { configured: true; available: false; status: 'error'; error: unknown }
17+
18+
function getKvConfigStatus() {
19+
const hasUrl = Boolean(process.env.KV_REST_API_URL)
20+
const hasToken = Boolean(process.env.KV_REST_API_TOKEN)
21+
22+
if (!(hasUrl || hasToken)) {
23+
return 'not_configured'
24+
}
25+
26+
if (hasUrl && hasToken) {
27+
return 'configured'
28+
}
29+
30+
return 'misconfigured'
31+
}
32+
33+
export function isKvConfigured() {
34+
return getKvConfigStatus() === 'configured'
35+
}
36+
37+
export async function pingKv(): Promise<KvCapabilityStatus> {
38+
const configStatus = getKvConfigStatus()
39+
40+
if (configStatus !== 'configured') {
41+
return { configured: false, available: false, status: configStatus }
42+
}
43+
44+
try {
45+
await kv.ping()
46+
return { configured: true, available: true, status: 'ok' }
47+
} catch (error) {
48+
return { configured: true, available: false, status: 'error', error }
49+
}
50+
}
51+
52+
export async function getKvValue<T>(
53+
key: string
54+
): Promise<OptionalKvResult<T | null>> {
55+
const configStatus = getKvConfigStatus()
56+
57+
if (configStatus !== 'configured') {
58+
return { ok: false, configured: false, reason: configStatus }
59+
}
60+
61+
try {
62+
return { ok: true, configured: true, value: await kv.get<T>(key) }
63+
} catch (error) {
64+
return { ok: false, configured: true, reason: 'error', error }
65+
}
66+
}
67+
68+
export async function setKvValue(
69+
key: string,
70+
value: unknown
71+
): Promise<OptionalKvResult<unknown>> {
72+
const configStatus = getKvConfigStatus()
73+
74+
if (configStatus !== 'configured') {
75+
return { ok: false, configured: false, reason: configStatus }
76+
}
77+
78+
try {
79+
return { ok: true, configured: true, value: await kv.set(key, value) }
80+
} catch (error) {
81+
return { ok: false, configured: true, reason: 'error', error }
82+
}
83+
}

src/lib/env.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { z } from 'zod'
22

33
export const serverSchema = z.object({
44
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
5-
KV_REST_API_TOKEN: z.string().min(1),
6-
KV_REST_API_URL: z.url(),
5+
KV_REST_API_TOKEN: z.string().min(1).optional(),
6+
KV_REST_API_URL: z.url().optional(),
77

88
ENABLE_USER_BOOTSTRAP: z.string().optional(),
99
DASHBOARD_API_ADMIN_TOKEN: z.string().min(1).optional(),

0 commit comments

Comments
 (0)