Skip to content
Open
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
10 changes: 6 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
# Resolves Infrastructure and Dashboard API + E2B SDK configuration
NEXT_PUBLIC_E2B_DOMAIN=e2b.dev

### KV database configuration
KV_REST_API_TOKEN=
KV_REST_API_URL=

### =================================
### REQUIRED CLIENT ENVIRONMENT VARIABLES
### =================================
Expand All @@ -37,6 +33,12 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
### ZeroBounce API key for email validation
# ZEROBOUNCE_API_KEY=

### Optional KV database configuration
### Required only for KV-backed capabilities such as alternate-email warning dedupe.
### Must be Vercel/Upstash Redis REST compatible; raw redis://localhost:6379 is not supported by @vercel/kv.
# KV_REST_API_TOKEN=
# KV_REST_API_URL=

### Plain API key for customer support integration
# (Required if NEXT_PUBLIC_INCLUDE_REPORT_ISSUE=1)
# PLAIN_API_KEY=
Expand Down
37 changes: 19 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,7 @@ cp .env.example .env.local

4. Set up required services:

#### a. Key-Value Store Setup
This project requires a Redis-compatible key-value store. You'll need to:

1. Set up a Redis instance (self-hosted or using a cloud provider)
2. Configure the following environment variables in your `.env.local` file:
```
KV_URL=your_redis_connection_string
KV_REST_API_URL=your_redis_rest_api_url
KV_REST_API_TOKEN=your_redis_api_write_token
KV_REST_API_READ_ONLY_TOKEN=your_redis_api_read_token
```

> **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.

#### b. Supabase Setup
#### a. Supabase Setup
1. Create a new Supabase project
2. Go to Project Settings > API
3. Copy the `anon key` & `service_role key` to populate `.env.local`
Expand All @@ -103,18 +89,33 @@ This project requires a Redis-compatible key-value store. You'll need to:
{{ .SiteURL }}/api/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}&confirmation_url={{ .ConfirmationURL }}
```

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

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

#### e. Start the development server
5. Optional services:

#### a. Key-Value Store Setup
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.

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:
```
KV_REST_API_URL=your_redis_rest_api_url
KV_REST_API_TOKEN=your_redis_api_write_token
```

> **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.

> **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.

6. Start the development server:
```bash
# Using Bun (recommended)
bun run dev
Expand Down
7 changes: 7 additions & 0 deletions scripts/check-app-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@

const schema = serverSchema
.merge(clientSchema)
.refine(
(data) => Boolean(data.KV_REST_API_URL) === Boolean(data.KV_REST_API_TOKEN),
{
message: 'KV_REST_API_URL and KV_REST_API_TOKEN must be set together',
path: ['KV_REST_API_URL'],
}
)

Check warning on line 15 in scripts/check-app-env.ts

View check run for this annotation

Claude / Claude Code Review

Misleading path attribution in KV refine

The new KV `.refine` block sets `path: ['KV_REST_API_URL']` only, but validates a two-variable invariant. When an operator sets `KV_REST_API_URL` but forgets `KV_REST_API_TOKEN` (or vice versa), Zod's `prettifyError` will attribute the failure to `KV_REST_API_URL` — potentially the var that was actually set correctly. The two other refines in this same file (billing, captcha) list both fields in the path; fix is `path: ['KV_REST_API_URL', 'KV_REST_API_TOKEN']`.
Comment on lines +9 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new KV .refine block sets path: ['KV_REST_API_URL'] only, but validates a two-variable invariant. When an operator sets KV_REST_API_URL but forgets KV_REST_API_TOKEN (or vice versa), Zod's prettifyError will attribute the failure to KV_REST_API_URL — potentially the var that was actually set correctly. The two other refines in this same file (billing, captcha) list both fields in the path; fix is path: ['KV_REST_API_URL', 'KV_REST_API_TOKEN'].

Extended reasoning...

What the bug is

In scripts/check-app-env.ts:9-15, the new refine validates the together-or-neither invariant for KV configuration:

.refine(
  (data) => Boolean(data.KV_REST_API_URL) === Boolean(data.KV_REST_API_TOKEN),
  {
    message: 'KV_REST_API_URL and KV_REST_API_TOKEN must be set together',
    path: ['KV_REST_API_URL'],
  }
)

The check inspects two environment variables, but the issue path references only one. When the predicate fails, the path Zod surfaces points at KV_REST_API_URL regardless of which side of the pair was actually omitted.

Why this is inconsistent

The two other paired-variable refines added to this same file follow the opposite convention — they list both fields in path:

  • Billing refine (lines 26-30): path: ['BILLING_API_URL', 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY']
  • Captcha refine (lines 42-46): path: ['NEXT_PUBLIC_TURNSTILE_SITE_KEY', 'TURNSTILE_SECRET_KEY']

So the new KV refine deviates from a convention already established in the same file.

Concrete proof / step-by-step

  1. Operator sets KV_REST_API_URL=https://kv.example.com in .env.local but forgets KV_REST_API_TOKEN.
  2. CI runs bun run check:env (or whatever invokes this script).
  3. Boolean(data.KV_REST_API_URL) === Boolean(data.KV_REST_API_TOKEN)true === falsefalse. Refine emits an issue with path: ['KV_REST_API_URL'].
  4. validateEnv calls z.prettifyError(parsed.error) (see src/lib/env.ts:73), which formats issues using their path as the field label.
  5. The output points the operator at KV_REST_API_URL — the variable they did set correctly — even though the missing one is KV_REST_API_TOKEN.

The full message text still names both vars, so a careful reader can recover. But the path label is misleading in CI/build logs, and exactly inverts what's useful when half the pair is set.

Addressing the refutation

One verifier objected that listing two strings in path is itself an imprecise use of Zod's API (since path semantically represents a nested object path, not a list of offending fields), so the new code isn't strictly worse — just different. That's a fair critique of the broader pattern, but the practical prettifyError output for a top-level schema renders path joined as a label, so listing both fields does produce a more useful pointer to the operator than listing just one. More importantly, the value here is consistency within the file: the rest of the file uses the two-element form, so a future reader scanning the refines will expect the same shape. The fix is one line and aligns with the established pattern.

Severity & fix

Nit. The script still fails the build with a message naming both vars, so no operator is truly stuck. Fix:

path: ['KV_REST_API_URL', 'KV_REST_API_TOKEN'],

.refine(
(data) => {
if (data.NEXT_PUBLIC_INCLUDE_BILLING === '1') {
Expand Down
88 changes: 57 additions & 31 deletions src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,24 @@
import { NextResponse } from 'next/server'
import { api } from '@/core/shared/clients/api'
import { kv } from '@/core/shared/clients/kv'
import { pingKv } from '@/core/shared/clients/kv'
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'

export const maxDuration = 10

export async function GET() {
const checks = {
kv: false,
dashboardApi: false,
}

async function checkDashboardApi(): Promise<boolean> {
try {
await kv.ping()
checks.kv = true
} catch (error) {
const { error } = await api.GET('/health', {})
if (!error) {
return true
}

l.error(
{
key: 'health_check:kv_error',
error: serializeErrorForLog(error),
key: 'health_check:dashboard_api_error',
error,
},
'KV health check failed'
'Dashboard API health check failed'
)
}

try {
const { error } = await api.GET('/health', {})
if (!error) {
checks.dashboardApi = true
} else {
l.error(
{
key: 'health_check:dashboard_api_error',
error,
},
'Dashboard API health check failed'
)
}
} catch (error) {
l.error(
{
Expand All @@ -47,15 +29,59 @@ export async function GET() {
)
}

const allHealthy = checks.kv && checks.dashboardApi
return false
}

export async function GET() {
const [kvStatus, dashboardApi] = await Promise.all([
pingKv(),
checkDashboardApi(),
])

const checks: { kv?: boolean; dashboardApi: boolean } = { dashboardApi }

if (kvStatus.configured) {
checks.kv = kvStatus.available
}

if (kvStatus.status === 'misconfigured') {
// Surface misconfiguration in the response body so it's visible
// without scraping logs.
checks.kv = false
l.error(
{
key: 'health_check:kv_misconfigured',
},
'KV health check is misconfigured'
)
}

if (kvStatus.status === 'error') {
l.error(
{
key: 'health_check:kv_error',
error: serializeErrorForLog(kvStatus.error),
},
'KV health check failed'
)
}

// KV is required *only when configured*. If an operator has wired it up,
// they expect it to work — so failure or misconfiguration must degrade
// the overall status. When KV is intentionally absent (not_configured),
// it contributes nothing to the health check.
const kvRequiredAndHealthy =
kvStatus.status === 'not_configured' || kvStatus.status === 'ok'

const allRequiredHealthy = dashboardApi && kvRequiredAndHealthy

return NextResponse.json(
{
status: allHealthy ? 'ok' : 'degraded',
status: allRequiredHealthy ? 'ok' : 'degraded',
checks,
},
{
status: allHealthy ? 200 : 503,
status: allRequiredHealthy ? 200 : 503,
headers: {
// vercel infra respects this to cache on cdn
'Cache-Control': 'public, max-age=30, must-revalidate',
Expand Down
46 changes: 42 additions & 4 deletions src/core/server/functions/auth/validate-email.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { kv } from '@vercel/kv'
import { KV_KEYS } from '@/configs/keys'
import { getKvValue, setKvValue } from '@/core/shared/clients/kv'
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'

/**
Expand Down Expand Up @@ -102,16 +102,54 @@ export const shouldWarnAboutAlternateEmail = async (
validationResult: EmailValidationResponse
): Promise<boolean> => {
if (validationResult.sub_status === 'alternate') {
const warnedAlternateEmail = await kv.get(
const warnedAlternateEmail = await getKvValue<boolean>(
KV_KEYS.WARNED_ALTERNATE_EMAIL(validationResult.address)
)

if (!warnedAlternateEmail) {
await kv.set(
if (!warnedAlternateEmail.ok) {
l.warn(
{
key: 'validate_email:alternate_email_kv_unavailable',
error:
warnedAlternateEmail.reason === 'error'
? serializeErrorForLog(warnedAlternateEmail.error)
: undefined,
context: {
email: validationResult.address,
reason: warnedAlternateEmail.reason,
},
Comment on lines +117 to +120
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Agentic Security Review
Severity: MEDIUM

The new KV-unavailable warning path logs raw user emails via context.email (validationResult.address). This introduces direct PII logging for signup attempts whenever KV is not configured or temporarily failing.

Impact: Email addresses can be exposed through centralized log pipelines and wider operator access beyond the primary auth data path.

},
'Skipping alternate email warning because KV is unavailable'
)

return false
}

if (!warnedAlternateEmail.value) {
const setResult = await setKvValue(
KV_KEYS.WARNED_ALTERNATE_EMAIL(validationResult.address),
true
)

if (!setResult.ok) {
l.warn(
{
key: 'validate_email:alternate_email_kv_set_unavailable',
error:
setResult.reason === 'error'
? serializeErrorForLog(setResult.error)
: undefined,
context: {
email: validationResult.address,
reason: setResult.reason,
},
},
'Skipping alternate email warning because KV could not persist state'
)

return false
}

return true
}
}
Expand Down
84 changes: 83 additions & 1 deletion src/core/shared/clients/kv.ts
Original file line number Diff line number Diff line change
@@ -1 +1,83 @@
export { kv } from '@vercel/kv'
import { kv } from '@vercel/kv'

export type OptionalKvResult<T> =
| { ok: true; configured: true; value: T }
| {
ok: false
configured: false
reason: 'not_configured' | 'misconfigured'
}
| { ok: false; configured: true; reason: 'error'; error: unknown }

export type KvCapabilityStatus =
| { configured: false; available: false; status: 'not_configured' }
| { configured: false; available: false; status: 'misconfigured' }
| { configured: true; available: true; status: 'ok' }
| { configured: true; available: false; status: 'error'; error: unknown }

function getKvConfigStatus() {
const hasUrl = Boolean(process.env.KV_REST_API_URL)
const hasToken = Boolean(process.env.KV_REST_API_TOKEN)

if (!(hasUrl || hasToken)) {
return 'not_configured'
}

if (hasUrl && hasToken) {
return 'configured'
}

return 'misconfigured'
}

export function isKvConfigured() {
return getKvConfigStatus() === 'configured'
}

export async function pingKv(): Promise<KvCapabilityStatus> {
const configStatus = getKvConfigStatus()

if (configStatus !== 'configured') {
return { configured: false, available: false, status: configStatus }
}

try {
await kv.ping()
return { configured: true, available: true, status: 'ok' }
} catch (error) {
return { configured: true, available: false, status: 'error', error }
}
}

export async function getKvValue<T>(
key: string
): Promise<OptionalKvResult<T | null>> {
const configStatus = getKvConfigStatus()

if (configStatus !== 'configured') {
return { ok: false, configured: false, reason: configStatus }
}

try {
return { ok: true, configured: true, value: await kv.get<T>(key) }
} catch (error) {
return { ok: false, configured: true, reason: 'error', error }
}
}

export async function setKvValue(
key: string,
value: unknown
): Promise<OptionalKvResult<unknown>> {
const configStatus = getKvConfigStatus()

if (configStatus !== 'configured') {
return { ok: false, configured: false, reason: configStatus }
}

try {
return { ok: true, configured: true, value: await kv.set(key, value) }
} catch (error) {
return { ok: false, configured: true, reason: 'error', error }
}
}
4 changes: 2 additions & 2 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { z } from 'zod'

export const serverSchema = z.object({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
KV_REST_API_TOKEN: z.string().min(1),
KV_REST_API_URL: z.url(),
KV_REST_API_TOKEN: z.string().min(1).optional(),
Comment on lines 4 to +5
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat blank KV env vars as absent

When a deployment or local .env.local still contains the old KV_REST_API_URL= / KV_REST_API_TOKEN= entries with blank values, these fields are present as empty strings, so serverSchema rejects them before the new optional KV logic can run. That makes bun run dev/prebuild fail even though the runtime helper treats empty KV values as not_configured, so existing environments copied from the previous .env.example cannot actually opt out without deleting the variables entirely.

Useful? React with 👍 / 👎.

KV_REST_API_URL: z.url().optional(),

ENABLE_USER_BOOTSTRAP: z.string().optional(),
DASHBOARD_API_ADMIN_TOKEN: z.string().min(1).optional(),
Expand Down
Loading
Loading