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.
This commit is contained in:
Moheeb Zara
2026-05-18 13:08:17 -07:00
parent 7844c262f8
commit 504586fdcd
6 changed files with 176 additions and 8 deletions
+10 -1
View File
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { readSecret } from '@/lib/secrets/vault'
// PayPal Transaction Search API (v1)
// Docs: https://developer.paypal.com/docs/api/transaction-search/v1/
@@ -72,7 +74,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'PayPal not connected' }, { status: 400 })
}
const { client_id, client_secret, sandbox } = integration.config as Record<string, string>
const cfg = integration.config as Record<string, string>
const { client_id, sandbox } = cfg
// client_secret lives in the AES-256-GCM vault (referenced by client_secret_ref).
// Legacy fallback: integrations saved before vaulting still have a plaintext
// client_secret; it is auto-migrated on the next save in Settings.
const client_secret = cfg.client_secret_ref
? await readSecret(createAdminClient(), member.space_id, cfg.client_secret_ref)
: (cfg.client_secret ?? null)
if (!client_id || !client_secret) {
return NextResponse.json({ error: 'PayPal credentials incomplete' }, { status: 400 })
}