Files
Moheeb Zara 504586fdcd fix(security): vault integration secrets instead of plaintext config (R1)
PayPal client_secret (and any secret-named integration field) was stored
plaintext in integrations.config, unlike Stripe/door which use the
AES-256-GCM vault. A DB read or backup leaked it in cleartext.

- lib/secrets/vault.ts: canonical generic storeSecret/readSecret +
  isSecretConfigField (the proven Stripe vault logic, generalized).
- saveIntegration: secret-named fields go to the vault, only a *_ref is
  kept in config; blank submit preserves the ref (write-only like
  Stripe); legacy plaintext auto-migrates into the vault on next save.
- paypal/sync reads client_secret from the vault, transitional fallback
  to legacy plaintext until the next save.
- Docs updated (API_REFERENCE, ARCHITECTURE 10.3). 502 tests.
2026-05-18 13:08:17 -07:00

201 lines
6.9 KiB
TypeScript

'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { storeSecret, isSecretConfigField } from '@/lib/secrets/vault'
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import {
updateSpaceSettingsSchema,
saveIntegrationSchema,
updateSpaceVisibilitySchema,
} from '@/lib/validations'
const ADMIN_ONLY = ['admin'] as const
export async function updateSpaceSettings(updates: {
name?: string
slug?: string
city?: string | null
require_approval?: boolean
public_member_directory?: boolean
mission_statement?: string | null
}) {
const v = parseInput(updateSpaceSettingsSchema, updates)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { error } = await supabase
.from('spaces')
.update({ ...v.data, updated_at: new Date().toISOString() })
.eq('id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true as const }
}
export async function rotateWebhookSecret() {
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const bytes = crypto.getRandomValues(new Uint8Array(24))
const newSecret =
'whsec_' +
Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('')
const { error } = await supabase
.from('spaces')
.update({ webhook_secret: newSecret, updated_at: new Date().toISOString() })
.eq('id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
return { secret: newSecret }
}
export async function saveIntegration(platform: string, config: Record<string, string>) {
const v = parseInput(saveIntegrationSchema, { platform, config })
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const {
data: { user },
} = await supabase.auth.getUser()
// Existing config: lets us carry a stored secret ref forward when the admin
// leaves a credential field blank (write-only, like Stripe), and lets us
// auto-migrate any legacy plaintext secret into the vault on the next save.
const { data: existingRow } = await supabase
.from('integrations')
.select('config')
.eq('space_id', member.space_id)
.eq('platform', v.data.platform)
.maybeSingle()
const existing = (existingRow?.config as Record<string, string> | null) ?? {}
const admin = createAdminClient()
// Credentials NEVER land in integrations.config in plaintext: secret-named
// fields go to the AES-256-GCM vault and only a *_ref id is kept. Non-secret
// fields are stored verbatim. *_set flags let the UI show "connected"
// without the value being readable.
const safeConfig: Record<string, string> = {}
let connected = false
for (const [key, value] of Object.entries(v.data.config)) {
if (isSecretConfigField(key)) continue // handled below
safeConfig[key] = value ?? ''
safeConfig[`${key}_set`] = value && value.length > 0 ? 'true' : 'false'
if (value && value.length > 0) connected = true
}
// Determine the secret fields this platform may carry: those provided now
// plus any already on file (so a blank submit preserves / migrates them).
const secretKeys = new Set<string>()
for (const k of Object.keys(v.data.config)) if (isSecretConfigField(k)) secretKeys.add(k)
for (const k of Object.keys(existing)) {
const base = k.replace(/_ref$/, '')
if (isSecretConfigField(base)) secretKeys.add(base)
}
for (const key of secretKeys) {
const provided = v.data.config[key]
let ref: string | null = existing[`${key}_ref`] ?? null
if (provided && provided.length > 0) {
ref = await storeSecret(admin, member.space_id, `${v.data.platform} ${key}`, provided, user?.id ?? null)
if (!ref) return { error: `Could not securely store ${key}.` }
} else if (!ref && existing[key] && existing[key].length > 0) {
// Legacy plaintext on file (pre-vault). Migrate it now, then drop it.
ref = await storeSecret(admin, member.space_id, `${v.data.platform} ${key}`, existing[key], user?.id ?? null)
if (!ref) return { error: `Could not migrate ${key} into the vault.` }
}
if (ref) {
safeConfig[`${key}_ref`] = ref
safeConfig[`${key}_set`] = 'true'
connected = true
} else {
safeConfig[`${key}_set`] = 'false'
}
}
const { error } = await supabase.from('integrations').upsert(
{
space_id: member.space_id,
platform: v.data.platform,
name: v.data.platform,
is_connected: connected,
config: safeConfig,
updated_at: new Date().toISOString(),
},
{ onConflict: 'space_id,platform' },
)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true as const }
}
/**
* Per-space visibility flags. Lets admins decide whether financial data is
* visible to all members, board only, or treasurer-only; and whether the
* member directory is fully visible, count-only, or board-only.
*/
export async function updateSpaceVisibility(updates: {
financial_visibility?: string
member_directory_visibility?: string
}) {
const v = parseInput(updateSpaceVisibilitySchema, updates)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { error } = await supabase
.from('spaces')
.update({ ...v.data, updated_at: new Date().toISOString() })
.eq('id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
revalidatePath('/financials')
revalidatePath('/members')
return { success: true as const }
}
export async function disconnectIntegration(platform: string) {
if (typeof platform !== 'string' || platform.length === 0 || platform.length > 50) {
return { error: 'Invalid platform' }
}
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { error } = await supabase
.from('integrations')
.update({ is_connected: false, config: {}, updated_at: new Date().toISOString() })
.eq('space_id', member.space_id)
.eq('platform', platform)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true as const }
}