|
| 1 | +import { db } from '@sim/db' |
| 2 | +import { organization } from '@sim/db/schema' |
| 3 | +import { createLogger } from '@sim/logger' |
| 4 | +import type { drizzleAdapter } from 'better-auth/adapters/drizzle' |
| 5 | +import { eq } from 'drizzle-orm' |
| 6 | +import { isOrgPlan } from '@/lib/billing/plan-helpers' |
| 7 | + |
| 8 | +const logger = createLogger('StripeAdapterGuard') |
| 9 | + |
| 10 | +type BetterAuthAdapter = ReturnType<ReturnType<typeof drizzleAdapter>> |
| 11 | + |
| 12 | +type SubscriptionWriteSurface = Pick< |
| 13 | + BetterAuthAdapter, |
| 14 | + 'create' | 'update' | 'updateMany' | 'findOne' | 'findMany' |
| 15 | +> |
| 16 | + |
| 17 | +/** |
| 18 | + * The Better Auth Stripe plugin persists webhook state through the raw |
| 19 | + * database adapter BEFORE invoking our subscription callbacks — including the |
| 20 | + * `plan` column resolved from the Stripe price. That makes the adapter the |
| 21 | + * only in-process seam that can enforce the billing invariant that |
| 22 | + * organization-referenced subscriptions hold Team/Enterprise plans: by the |
| 23 | + * time `syncSubscriptionPlan` runs in a callback, the plugin's write has |
| 24 | + * already landed. |
| 25 | + * |
| 26 | + * Checkout admission blocks user-driven violations; this guard blocks the |
| 27 | + * remaining vector — an operator swapping an org subscription onto a personal |
| 28 | + * price in the Stripe dashboard. It never repairs state: an invalid `plan` |
| 29 | + * write is refused (update: the field is stripped so status/period/seat sync |
| 30 | + * still lands; create: the whole insert is rejected) and an error is logged |
| 31 | + * so operators fix the price in Stripe. |
| 32 | + * |
| 33 | + * Transactions are wrapped recursively so writes inside |
| 34 | + * `adapter.transaction(...)` callbacks go through the same guard. |
| 35 | + */ |
| 36 | +export function guardSubscriptionPlanWrites(adapter: BetterAuthAdapter): BetterAuthAdapter { |
| 37 | + const guarded: BetterAuthAdapter = { |
| 38 | + ...adapter, |
| 39 | + ...guardWriteSurface(adapter), |
| 40 | + } |
| 41 | + |
| 42 | + const transaction = adapter.transaction |
| 43 | + if (typeof transaction === 'function') { |
| 44 | + guarded.transaction = (callback) => |
| 45 | + transaction((trx) => callback({ ...trx, ...guardWriteSurface(trx) })) |
| 46 | + } |
| 47 | + |
| 48 | + return guarded |
| 49 | +} |
| 50 | + |
| 51 | +function guardWriteSurface<TAdapter extends SubscriptionWriteSurface>( |
| 52 | + adapter: TAdapter |
| 53 | +): SubscriptionWriteSurface { |
| 54 | + return { |
| 55 | + findOne: adapter.findOne, |
| 56 | + findMany: adapter.findMany, |
| 57 | + create: async (data) => { |
| 58 | + if (data.model === 'subscription') { |
| 59 | + const values = data.data as Record<string, unknown> |
| 60 | + const plan = values.plan |
| 61 | + const referenceId = values.referenceId |
| 62 | + if ( |
| 63 | + typeof plan === 'string' && |
| 64 | + !isOrgPlan(plan) && |
| 65 | + typeof referenceId === 'string' && |
| 66 | + (await isOrganizationReference(referenceId)) |
| 67 | + ) { |
| 68 | + logger.error( |
| 69 | + 'Blocked creating an organization-referenced subscription with a non-org plan — fix the plan or reference in Stripe', |
| 70 | + { referenceId, rejectedPlan: plan } |
| 71 | + ) |
| 72 | + throw new Error( |
| 73 | + `Organization-referenced subscriptions must hold a Team or Enterprise plan (got '${plan}')` |
| 74 | + ) |
| 75 | + } |
| 76 | + } |
| 77 | + return adapter.create(data) |
| 78 | + }, |
| 79 | + update: async (data) => { |
| 80 | + if (data.model === 'subscription' && hasNonOrgPlanWrite(data.update)) { |
| 81 | + const row = await adapter.findOne<SubscriptionRowSlice>({ |
| 82 | + model: 'subscription', |
| 83 | + where: data.where, |
| 84 | + }) |
| 85 | + const sanitized = await stripPlanWhenOrgReferenced( |
| 86 | + row ? [row] : [], |
| 87 | + data.update as Record<string, unknown> |
| 88 | + ) |
| 89 | + if (sanitized.blockedAll) return row as never |
| 90 | + return adapter.update({ ...data, update: sanitized.update as never }) |
| 91 | + } |
| 92 | + return adapter.update(data) |
| 93 | + }, |
| 94 | + updateMany: async (data) => { |
| 95 | + if (data.model === 'subscription' && hasNonOrgPlanWrite(data.update)) { |
| 96 | + const rows = await adapter.findMany<SubscriptionRowSlice>({ |
| 97 | + model: 'subscription', |
| 98 | + where: data.where, |
| 99 | + }) |
| 100 | + const sanitized = await stripPlanWhenOrgReferenced(rows, data.update) |
| 101 | + if (sanitized.blockedAll) return 0 |
| 102 | + return adapter.updateMany({ ...data, update: sanitized.update }) |
| 103 | + } |
| 104 | + return adapter.updateMany(data) |
| 105 | + }, |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +interface SubscriptionRowSlice { |
| 110 | + id: string |
| 111 | + referenceId: string |
| 112 | + plan: string |
| 113 | +} |
| 114 | + |
| 115 | +function hasNonOrgPlanWrite(update: unknown): boolean { |
| 116 | + if (!update || typeof update !== 'object' || !('plan' in update)) return false |
| 117 | + const plan = (update as { plan: unknown }).plan |
| 118 | + return typeof plan === 'string' && !isOrgPlan(plan) |
| 119 | +} |
| 120 | + |
| 121 | +async function isOrganizationReference(referenceId: string): Promise<boolean> { |
| 122 | + const [referencedOrganization] = await db |
| 123 | + .select({ id: organization.id }) |
| 124 | + .from(organization) |
| 125 | + .where(eq(organization.id, referenceId)) |
| 126 | + .limit(1) |
| 127 | + return Boolean(referencedOrganization) |
| 128 | +} |
| 129 | + |
| 130 | +interface SanitizedSubscriptionUpdate { |
| 131 | + update: Record<string, unknown> |
| 132 | + /** True when stripping the invalid plan left nothing to write. */ |
| 133 | + blockedAll: boolean |
| 134 | +} |
| 135 | + |
| 136 | +/** |
| 137 | + * Strip a non-org `plan` (and its derived `limits`) from an update when ANY |
| 138 | + * targeted row is organization-referenced. The remaining fields (status, |
| 139 | + * periods, seats, cancellation state) are legitimate Stripe state and still |
| 140 | + * sync. Evaluating every targeted row keeps multi-row updates safe: a mixed |
| 141 | + * personal/org target set must not leak the invalid plan onto the org rows. |
| 142 | + */ |
| 143 | +async function stripPlanWhenOrgReferenced( |
| 144 | + rows: SubscriptionRowSlice[], |
| 145 | + update: Record<string, unknown> |
| 146 | +): Promise<SanitizedSubscriptionUpdate> { |
| 147 | + let organizationRow: SubscriptionRowSlice | null = null |
| 148 | + for (const row of rows) { |
| 149 | + if (await isOrganizationReference(row.referenceId)) { |
| 150 | + organizationRow = row |
| 151 | + break |
| 152 | + } |
| 153 | + } |
| 154 | + if (!organizationRow) { |
| 155 | + return { update, blockedAll: false } |
| 156 | + } |
| 157 | + |
| 158 | + logger.error( |
| 159 | + 'Blocked writing a non-org plan onto an organization-referenced subscription — fix the price in Stripe', |
| 160 | + { |
| 161 | + subscriptionId: organizationRow.id, |
| 162 | + organizationId: organizationRow.referenceId, |
| 163 | + currentPlan: organizationRow.plan, |
| 164 | + rejectedPlan: update.plan, |
| 165 | + } |
| 166 | + ) |
| 167 | + |
| 168 | + const { plan: _plan, limits: _limits, ...rest } = update |
| 169 | + return { update: rest, blockedAll: Object.keys(rest).length === 0 } |
| 170 | +} |
0 commit comments