From 5ca7ee6e29b38bf7e658b86bc9338740cfa199d7 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sun, 17 May 2026 03:06:32 -0700 Subject: [PATCH] feat(billing): P1c Stripe dep + server client + vault-backed config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin stripe@22.1.1 (exact). lib/stripe/client.ts: per-request getStripe with the pinned apiVersion (server-only, never global/client). Helpers in lib/stripe/config.ts (plain server module — they take the admin client so they must NOT be 'use server' exports): store/read the secret key + webhook signing secret in the AES-256-GCM secrets vault (door pattern), getStripeConfig. lib/actions/stripe.ts (actions only): getStripeSettings (status only, never returns secrets) + saveStripeSettings (write-only secrets, integrations.config upsert). Zod stripeSettingsSchema. --- lib/actions/index.ts | 3 ++ lib/actions/stripe.ts | 94 +++++++++++++++++++++++++++++++++++++++++++ lib/stripe/client.ts | 10 +++++ lib/stripe/config.ts | 88 ++++++++++++++++++++++++++++++++++++++++ lib/validations.ts | 11 +++++ package.json | 1 + pnpm-lock.yaml | 16 ++++++++ 7 files changed, 223 insertions(+) create mode 100644 lib/actions/stripe.ts create mode 100644 lib/stripe/client.ts create mode 100644 lib/stripe/config.ts diff --git a/lib/actions/index.ts b/lib/actions/index.ts index 7d7a277..c42c43e 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -65,3 +65,6 @@ export * from './door' // Presence & attendance (check-in / check-out / hosting) export * from './presence' + +// Stripe recurring dues (product spine Phase 1) +export * from './stripe' diff --git a/lib/actions/stripe.ts b/lib/actions/stripe.ts new file mode 100644 index 0000000..67c04bc --- /dev/null +++ b/lib/actions/stripe.ts @@ -0,0 +1,94 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/admin' +import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers' +import { ADMIN_ROLES } from '@/lib/permissions' +import { stripeSettingsSchema } from '@/lib/validations' +import { isStripeConfigured } from '@/lib/stripe-logic' +import { getStripeConfig, storeStripeSecret, type StripeConfig } from '@/lib/stripe/config' + +// Admin-facing settings view. NEVER returns secret values, only whether they +// are set, so the config screen can show status without exposing keys. +export async function getStripeSettings() { + const supabase = await createClient() + const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required') + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const cfg = (await getStripeConfig(createAdminClient(), member.space_id)) ?? {} + return { + data: { + mode: cfg.mode ?? 'test', + publishable_key: cfg.publishable_key ?? '', + grace_days: cfg.grace_days ?? 7, + prices: cfg.prices ?? {}, + hasSecretKey: !!cfg.secret_key_ref, + hasWebhookSecret: !!cfg.webhook_secret_ref, + configured: isStripeConfigured(cfg), + }, + } +} + +export async function saveStripeSettings(input: unknown) { + const supabase = await createClient() + const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required') + if (!auth.ok) return { error: auth.error } + const { member } = auth + const { + data: { user }, + } = await supabase.auth.getUser() + + const v = parseInput(stripeSettingsSchema, input) + if (!v.ok) return { error: v.error } + const s = v.data + + const admin = createAdminClient() + const existing = (await getStripeConfig(admin, member.space_id)) ?? {} + + // Secret fields are write-only: a provided non-blank value rotates the + // stored secret; blank/omitted keeps the existing ref. + let secretKeyRef = existing.secret_key_ref + if (s.secret_key && s.secret_key.trim()) { + const id = await storeStripeSecret(admin, member.space_id, 'Stripe secret key', s.secret_key.trim(), user?.id ?? null) + if (!id) return { error: 'Could not store the Stripe secret key.' } + secretKeyRef = id + } + let webhookRef = existing.webhook_secret_ref + if (s.webhook_secret && s.webhook_secret.trim()) { + const id = await storeStripeSecret(admin, member.space_id, 'Stripe webhook signing secret', s.webhook_secret.trim(), user?.id ?? null) + if (!id) return { error: 'Could not store the Stripe webhook secret.' } + webhookRef = id + } + + const prices: Record = {} + for (const [k, val] of Object.entries(s.prices ?? {})) { + if (typeof val === 'string' && val.trim()) prices[k] = val.trim() + } + + const config: StripeConfig = { + mode: s.mode, + publishable_key: s.publishable_key?.trim() || undefined, + secret_key_ref: secretKeyRef, + webhook_secret_ref: webhookRef, + grace_days: s.grace_days ?? 7, + prices, + } + + const { error } = await admin.from('integrations').upsert( + { + space_id: member.space_id, + platform: 'stripe', + name: 'Stripe', + is_connected: isStripeConfigured(config), + config, + updated_at: new Date().toISOString(), + }, + { onConflict: 'space_id,platform' }, + ) + if (error) return { error: error.message } + + revalidatePath('/settings') + return { data: { ok: true, configured: isStripeConfigured(config) } } +} diff --git a/lib/stripe/client.ts b/lib/stripe/client.ts new file mode 100644 index 0000000..b82ce0b --- /dev/null +++ b/lib/stripe/client.ts @@ -0,0 +1,10 @@ +// Server-only. Per-space OWN keys (NOT Connect): a fresh client is created +// per request with that space's decrypted secret key. Never memoize a global +// client and never expose the key to the browser. The API version is pinned +// in lib/stripe-logic so an SDK/API bump is a deliberate, reviewed change. +import Stripe from 'stripe' +import { STRIPE_API_VERSION } from '@/lib/stripe-logic' + +export function getStripe(secretKey: string): Stripe { + return new Stripe(secretKey, { apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion }) +} diff --git a/lib/stripe/config.ts b/lib/stripe/config.ts new file mode 100644 index 0000000..8c0bc7a --- /dev/null +++ b/lib/stripe/config.ts @@ -0,0 +1,88 @@ +// Server-only helpers (NOT a 'use server' module — these take a Supabase +// admin client and are imported by server actions + the webhook route). +// Per-space Stripe config lives in integrations.config; the secret key and +// webhook signing secret live in the AES-256-GCM secrets vault, referenced +// by id from config. +import type { createAdminClient } from '@/lib/supabase/admin' +import { encryptSecret, decryptSecret, encryptionAvailable } from '@/lib/secrets/crypto' + +type Admin = ReturnType + +export type StripeConfig = { + mode?: 'test' | 'live' + publishable_key?: string + secret_key_ref?: string + webhook_secret_ref?: string + grace_days?: number + prices?: Record +} + +// Store a Stripe credential in the encrypted secrets vault (same layout the +// door subsystem uses). Returns the secret row id to keep in config. +export async function storeStripeSecret( + admin: Admin, + spaceId: string, + title: string, + value: string, + createdBy: string | null, +): Promise { + const row: Record = { + space_id: spaceId, + title, + label: title, + category: 'integration', + created_by: createdBy, + } + if (encryptionAvailable()) { + const { ciphertext, version } = encryptSecret(value) + row.encrypted_value = ciphertext + row.encryption_version = version + row.value = '' + } else { + row.value = value + row.encryption_version = 0 + } + const { data, error } = await admin.from('secrets').insert(row).select('id').single() + if (error || !data) return null + return data.id as string +} + +// Read + decrypt a stored Stripe credential. Server-only; never returned to +// a client. +export async function readStripeSecret( + admin: Admin, + spaceId: string, + secretRef: string | null | undefined, +): Promise { + if (!secretRef) return null + const { data } = await admin + .from('secrets') + .select('encryption_version, encrypted_value, value') + .eq('id', secretRef) + .eq('space_id', spaceId) + .maybeSingle() + if (!data) return null + if (data.encryption_version === 1 && data.encrypted_value) { + const raw = data.encrypted_value as unknown + const buf = + typeof raw === 'string' + ? Buffer.from((raw as string).replace(/^\\x/, ''), 'hex') + : Buffer.from(raw as Uint8Array) + try { + return decryptSecret(buf, 1) + } catch { + return null + } + } + return (data.value as string | null) ?? null +} + +export async function getStripeConfig(admin: Admin, spaceId: string): Promise { + const { data } = await admin + .from('integrations') + .select('config') + .eq('space_id', spaceId) + .eq('platform', 'stripe') + .maybeSingle() + return (data?.config as StripeConfig | null) ?? null +} diff --git a/lib/validations.ts b/lib/validations.ts index 9f86eb6..5b62af2 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -1006,6 +1006,17 @@ export const bulkMemberIdsSchema = z .min(1, 'Select at least one member') .max(1000) +// Stripe dues config (admin). Secret fields are write-only: omitted/blank +// means "keep the stored one". prices maps a membership tier -> Stripe Price. +export const stripeSettingsSchema = z.object({ + mode: z.enum(['test', 'live']), + publishable_key: z.string().trim().max(255).optional().nullable(), + secret_key: z.string().trim().max(255).optional().nullable(), + webhook_secret: z.string().trim().max(255).optional().nullable(), + grace_days: z.number().int().min(0).max(90).optional().default(7), + prices: z.record(z.string().trim().max(255)).optional().default({}), +}) + // Helper type exports export type CreateTaskInput = z.infer export type CreateProjectInput = z.infer diff --git a/package.json b/package.json index b8720b5..1df30dd 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "sonner": "^1.7.1", + "stripe": "22.1.1", "tailwind-merge": "^3.3.1", "vaul": "^1.1.2", "zod": "^3.24.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db6c958..d5a9352 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: sonner: specifier: ^1.7.1 version: 1.7.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + stripe: + specifier: ^22.1.1 + version: 22.1.1(@types/node@22.19.11) tailwind-merge: specifier: ^3.3.1 version: 3.4.0 @@ -4010,6 +4013,15 @@ packages: strip-literal@2.1.1: resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + stripe@22.1.1: + resolution: {integrity: sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -8535,6 +8547,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@22.1.1(@types/node@22.19.11): + optionalDependencies: + '@types/node': 22.19.11 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14