// KV storage with Upstash Redis — falls back to in-memory if KV not configured
import { Redis } from '@upstash/redis'
interface SubscriptionData {
plan: 'pro' | 'team'
status: 'active' | 'canceled'
email?: string
licenseKey: string
subscriptionId: string
}
interface LicenseData {
customerId: string
plan: 'pro' | 'team'
status: 'active' | 'canceled'
}
// In-memory fallback stores
const memSubs = new Map<string, SubscriptionData>()
const memLicenses = new Map<string, LicenseData>()
const memRateLimits = new Map<string, number>()
let kvStatusLogged = false
let redis: Redis | null = null
function getRedis(): Redis | null {
if (redis) return redis
if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) {
redis = new Redis({
url: process.env.KV_REST_API_URL,
token: process.env.KV_REST_API_TOKEN,
})
return redis
}
return null
}
function kvAvailable(): boolean {
return !!getRedis()
}
export function isKvConnected(): boolean {
return kvAvailable()
}
function logKvStatus() {
if (!kvStatusLogged) {
kvStatusLogged = true
console.log(kvAvailable() ? 'KV: connected' : 'KV: fallback (in-memory)')
}
}
// --- Subscription CRUD ---
export async function setSubscription(customerId: string, data: SubscriptionData): Promise<void> {
logKvStatus()
const r = getRedis()
if (r) {
try {
await r.set(`sub:${customerId}`, JSON.stringify(data))
await r.set(`license:${data.licenseKey}`, JSON.stringify({
customerId,
plan: data.plan,
status: data.status,
} satisfies LicenseData))
// Email reverse lookup — lets customers retrieve their key by email
if (data.email) {
const emailKey = `email:${data.email.toLowerCase().trim()}:customerId`
await r.set(emailKey, customerId)
}
} catch (err) {
console.error('[KV] setSubscription failed, falling back to memory:', err)
memSubs.set(customerId, data)
memLicenses.set(data.licenseKey, { customerId, plan: data.plan, status: data.status })
}
} else {
memSubs.set(customerId, data)
memLicenses.set(data.licenseKey, { customerId, plan: data.plan, status: data.status })
}
}
export async function getSubscriptionByEmail(email: string): Promise<SubscriptionData | null> {
logKvStatus()
const r = getRedis()
const normalizedEmail = email.toLowerCase().trim()
if (r) {
try {
const customerId = await r.get<string>(`email:${normalizedEmail}:customerId`)
if (!customerId) return null
return getSubscriptionByCustomer(typeof customerId === 'string' ? customerId : String(customerId))
} catch (err) {
console.error('[KV] getSubscriptionByEmail failed:', err)
return null
}
}
// Memory fallback: scan subscriptions for matching email
for (const [, sub] of memSubs.entries()) {
if (sub.email?.toLowerCase().trim() === normalizedEmail) return sub
}
return null
}
export async function getSubscriptionByCustomer(customerId: string): Promise<SubscriptionData | null> {
logKvStatus()
const r = getRedis()
if (r) {
try {
const raw = await r.get<string>(`sub:${customerId}`)
return raw ? (typeof raw === 'string' ? JSON.parse(raw) : raw as unknown as SubscriptionData) : null
} catch (err) {
console.error('[KV] getSubscriptionByCustomer failed:', err)
return memSubs.get(customerId) ?? null
}
}
return memSubs.get(customerId) ?? null
}
export async function getLicenseData(licenseKey: string): Promise<LicenseData | null> {
logKvStatus()
const r = getRedis()
if (r) {
try {
const raw = await r.get<string>(`license:${licenseKey}`)
return raw ? (typeof raw === 'string' ? JSON.parse(raw) : raw as unknown as LicenseData) : null
} catch (err) {
console.error('[KV] getLicenseData failed:', err)
return memLicenses.get(licenseKey) ?? null
}
}
return memLicenses.get(licenseKey) ?? null
}
export async function cancelSubscriptionByCustomer(customerId: string): Promise<void> {
logKvStatus()
const sub = await getSubscriptionByCustomer(customerId)
if (!sub) return
sub.status = 'canceled'
const r = getRedis()
if (r) {
try {
await r.set(`sub:${customerId}`, JSON.stringify(sub))
await r.set(`license:${sub.licenseKey}`, JSON.stringify({
customerId,
plan: sub.plan,
status: 'canceled',
} satisfies LicenseData))
} catch (err) {
console.error('[KV] cancelSubscriptionByCustomer failed:', err)
memSubs.set(customerId, sub)
memLicenses.set(sub.licenseKey, { customerId, plan: sub.plan, status: 'canceled' })
}
} else {
memSubs.set(customerId, sub)
memLicenses.set(sub.licenseKey, { customerId, plan: sub.plan, status: 'canceled' })
}
}
export async function cancelSubscriptionById(subscriptionId: string): Promise<void> {
const r = getRedis()
if (!r) {
for (const [cid, sub] of memSubs.entries()) {
if (sub.subscriptionId === subscriptionId) {
await cancelSubscriptionByCustomer(cid)
return
}
}
return
}
console.warn('cancelSubscriptionById with KV requires customer lookup — skipping')
}
// --- Rate Limiting (KV-backed) ---
export async function checkRateLimitKV(ip: string, limit: number, prefix = 'ratelimit'): Promise<{ count: number; allowed: boolean }> {
logKvStatus()
const today = new Date().toISOString().slice(0, 10)
const key = `${prefix}:${ip}:${today}`
const r = getRedis()
if (r) {
try {
const count = await r.incr(key)
if (count === 1) {
await r.expire(key, 86400)
}
return { count, allowed: count <= limit }
} catch (err) {
console.error('[KV] checkRateLimitKV failed, using memory fallback:', err)
}
}
const memKey = `${ip}:${today}`
const current = (memRateLimits.get(memKey) ?? 0) + 1
memRateLimits.set(memKey, current)
return { count: current, allowed: current <= limit }
}
// --- Debug / Diagnostic ---
export async function debugKvRoundTrip(): Promise<{ kvConnected: boolean; sampleReadWriteWorking: boolean }> {
const r = getRedis()
if (!r) {
return { kvConnected: false, sampleReadWriteWorking: false }
}
try {
await r.set('debug:ping', 'pong')
const val = await r.get<string>('debug:ping')
return { kvConnected: true, sampleReadWriteWorking: val === 'pong' }
} catch (err) {
console.error('[KV] debug round-trip failed:', err)
return { kvConnected: true, sampleReadWriteWorking: false }
}
}