Skip to content

Commit d6b8c08

Browse files
authored
refactor: make kv redis optional (#326)
**@vercel/kv** redis is now optional for the dashboard. The app boots and runs core auth + dashboard flows without `KV_REST_API_URL` / `KV_REST_API_TOKEN` configured.
1 parent 82c1fe4 commit d6b8c08

11 files changed

Lines changed: 541 additions & 62 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: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,7 @@ cp .env.example .env.local
5757

5858
4. Set up required services:
5959

60-
#### a. Key-Value Store Setup
61-
This project requires a Redis-compatible key-value store. You'll need to:
62-
63-
1. Set up a Redis instance (self-hosted or using a cloud provider)
64-
2. Configure the following environment variables in your `.env.local` file:
65-
```
66-
KV_URL=your_redis_connection_string
67-
KV_REST_API_URL=your_redis_rest_api_url
68-
KV_REST_API_TOKEN=your_redis_api_write_token
69-
KV_REST_API_READ_ONLY_TOKEN=your_redis_api_read_token
70-
```
71-
72-
> **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.
73-
74-
#### b. Supabase Setup
60+
#### a. Supabase Setup
7561
1. Create a new Supabase project
7662
2. Go to Project Settings > API
7763
3. Copy the `anon key` & `service_role key` to populate `.env.local`
@@ -97,10 +83,23 @@ This project requires a Redis-compatible key-value store. You'll need to:
9783
{{ .SiteURL }}/api/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}&confirmation_url={{ .ConfirmationURL }}
9884
```
9985

100-
#### c. Supabase Storage Setup
86+
#### b. Supabase Storage Setup
10187
1. Go to Storage > Buckets
10288
2. Create a new **public** bucket named `profile-pictures`
10389

90+
#### c. Key-Value Store Setup (Optional)
91+
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.
92+
93+
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:
94+
```
95+
KV_REST_API_URL=your_redis_rest_api_url
96+
KV_REST_API_TOKEN=your_redis_api_write_token
97+
```
98+
99+
> **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.
100+
101+
> **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.
102+
104103
#### d. Start the development server
105104
```bash
106105
# Using Bun (recommended)

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', 'KV_REST_API_TOKEN'],
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: 40 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,52 @@ 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+
reason: warnedAlternateEmail.reason,
119+
},
120+
},
121+
'Skipping alternate email warning because KV is unavailable'
122+
)
123+
124+
return false
125+
}
126+
127+
if (!warnedAlternateEmail.value) {
128+
const setResult = await setKvValue(
111129
KV_KEYS.WARNED_ALTERNATE_EMAIL(validationResult.address),
112130
true
113131
)
114132

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

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)