aabddb592f
* feat(billing): multi-user seat gate: multi_user capability, 20-day grace, owner-only dormancy Multiple people in one company becomes a paid capability (multi_user, the eighth PAID key). Derived at access time from capability_grants, no status column, no enforcement cron: - entitled: active grant (trial/stripe/team/manual/comp), everyone works - grace: newest grant expired < 20 days ago; countdown banner for everyone in companies with > 1 user; invites still allowed - frozen: only role=owner resolves; other memberships go dormant (rows untouched, paying reactivates instantly); invites 403 with paid-plan upsell Enforcement: new resolve_active_company_gated RPC (zero-arg RPC and RLS twin untouched: they also run on self-hosts, where the gate never bites), gated query fallback for service-role/API-key paths, setActiveCompany guard, MCP company-access check, invite route. Middleware routes all-frozen users to a new /paused page; the switcher greys locked companies. Migration 20260901081417 (applied to staging): trial trigger seeds multi_user, backfills for mid-trial companies, active Stripe subs, team agreements, and a grandfather grant (expires now, i.e. grace = deploy + 20 days) for existing unpaid multi-member companies. Daily cron mails owners at grace start and last day. Strings in sv+en; pg-real + unit tests included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): multi-user seat gate hardening from skeptic review - Stripe cancel now EXPIRES the multi_user stripe grant instead of deleting it: the 20-day grace window hangs on an expired row, so a deleted one froze churned payers' staff instantly with no banner and no mail. Other stripe grants keep the freeze-and-retain delete. - New SECURITY DEFINER company_multi_user_state() RPC (migration 20260901083726, applied to staging) and RPC-first getMultiUserState: capability_grants RLS hides team-scoped rows from non-team users, so user-client reads misread byra-covered companies as frozen (switch refusal, wrong switcher locks). - Byra-kind teams get a standing team-scoped multi_user grant (backfill + teams trigger): byra client companies have no company-scoped trial by design, so a grantless byra team would freeze every consultant and client user. - Comped/manual companies with active PAID-key grants extend to multi_user (a comped company must not read as paying while locking out user two). - /api/v1 gets the same dormancy gate as MCP (frozen non-owner -> 403). - PGRST202 on resolution fails OPEN (pre-migration DB has zero multi_user rows; the gated fallback would have frozen every non-owner mid-deploy). - Grace cron: covers team-scoped lapses (byra agreement ending) and skips the start mail for the hand-mailed grandfather cohort. - Tests updated/added across all touched surfaces; pg tests for the new RPC and byra trigger; trial-suppression pg test extended to 8 keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): decouple seat-gate env check and fail open on gate read throws CI round 1 on #2099: - isMultiUserEnforced no longer imports has-capability: several route test suites partially mock that module and the vitest mock guard threw from inside the v1 seat gate, turning expected 4xx responses into 500s. multi_user is never a connector capability, so the bypass reduces to the same env reads, now inlined. - getMultiUserState wraps its resolution in a fail-open try/catch: a client without .rpc or a thrown network error must never lock users out. - no-phantom-columns ceiling 391 -> 393 with reasons: the seat gate's .or() scope filter (server-resolved UUIDs) and the Stripe cancel expiry update's timestamp .or(); all columns in both strings are literals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP * fix(billing): membership-guard the multi-user entitlement RPCs (Superagent P3) company_multi_user_ok and company_multi_user_state are SECURITY DEFINER and were granted to authenticated with a caller-supplied company UUID: any logged-in user could probe an arbitrary company's billing state and grace deadline across tenants. Migration 20260901091752 (applied to staging) requires an auth.uid() membership in the target company when a JWT is present, keeps service-role/definer contexts unrestricted, and clamps the grace window to [0, 20] days. pg tests: stranger gets false/NULL, member reads normally, oversized p_grace_days cannot widen the probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
141 lines
5.5 KiB
TypeScript
141 lines
5.5 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { isSelfHosted } from '@/lib/env/public-flags'
|
|
import { CAPABILITY } from './keys'
|
|
import {
|
|
computeMultiUserState,
|
|
isMembershipDormant,
|
|
MULTI_USER_GRACE_DAYS,
|
|
type MultiUserAccess,
|
|
type MultiUserGrantRow,
|
|
} from './multi-user-state'
|
|
|
|
export {
|
|
MULTI_USER_GRACE_DAYS,
|
|
isMembershipDormant,
|
|
computeMultiUserState,
|
|
type MultiUserAccess,
|
|
type MultiUserGrantRow,
|
|
} from './multi-user-state'
|
|
export type { MultiUserState } from './multi-user-state'
|
|
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
|
|
/**
|
|
* Whether the owner-only dormancy rule is enforced at all in this
|
|
* environment. False on self-hosted instances (multi_user is a local
|
|
* capability: an AGPL operator's own instance is never seat-gated) and under
|
|
* the dev bypass; FORCE_PAYWALL flips it on in dev like every other gate.
|
|
* Callers use this to pick the gated resolution RPC vs the plain one.
|
|
*
|
|
* Deliberately NOT delegated to has-capability's isBypassedFor: multi_user is
|
|
* never a connector capability, so the logic reduces to these env reads, and
|
|
* standing alone keeps this module import-light (it is called from the Edge
|
|
* middleware on every request, and several test suites partially mock
|
|
* has-capability without expecting resolution paths to pull it in).
|
|
*/
|
|
export function isMultiUserEnforced(): boolean {
|
|
if (isSelfHosted()) return false
|
|
if (process.env.FORCE_PAYWALL === 'true') return true
|
|
const bypassed =
|
|
process.env.NODE_ENV === 'development' || process.env.DISABLE_PAYWALL === 'true'
|
|
return !bypassed
|
|
}
|
|
|
|
/**
|
|
* Resolve a company's multi-user access state (entitled / grace / frozen)
|
|
* from its multi_user grants, company- and team-scoped alike. Fail-open on
|
|
* read errors: a transient grants failure must never lock people out of
|
|
* their bookkeeping (the opposite polarity of hasCapability, which guards
|
|
* paid external services and fails closed).
|
|
*
|
|
* RPC-FIRST: the company_multi_user_state() SECURITY DEFINER function is the
|
|
* primary path, because the capability_grants SELECT policy hides
|
|
* team-scoped rows from users who are not on the team: a byrå client company
|
|
* read through a user-scoped client would misread as frozen when its only
|
|
* coverage is the byrå team's grant. The raw grants read below is only the
|
|
* fallback for a database that does not have the function yet (deploy race,
|
|
* self-host mid-migration), where it fails toward access.
|
|
*/
|
|
export async function getMultiUserState(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
options: { teamId?: string | null } = {},
|
|
): Promise<MultiUserAccess> {
|
|
if (!isMultiUserEnforced()) return { state: 'entitled', graceEndsAt: null }
|
|
if (!UUID_RE.test(companyId)) return { state: 'frozen', graceEndsAt: null }
|
|
try {
|
|
return await resolveMultiUserState(supabase, companyId, options)
|
|
} catch {
|
|
// Fail OPEN on ANY unexpected throw (a client without .rpc, a network
|
|
// exception): a broken read must never lock people out of their books.
|
|
return { state: 'entitled', graceEndsAt: null }
|
|
}
|
|
}
|
|
|
|
async function resolveMultiUserState(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
options: { teamId?: string | null },
|
|
): Promise<MultiUserAccess> {
|
|
const { data: rpcData, error: rpcError } = await supabase.rpc('company_multi_user_state', {
|
|
p_company_id: companyId,
|
|
p_grace_days: MULTI_USER_GRACE_DAYS,
|
|
})
|
|
if (!rpcError) {
|
|
const row = (Array.isArray(rpcData) ? rpcData[0] : rpcData) as
|
|
| { state: string; grace_ends_at: string | null }
|
|
| null
|
|
| undefined
|
|
if (row?.state === 'entitled' || row?.state === 'grace' || row?.state === 'frozen') {
|
|
return { state: row.state, graceEndsAt: row.grace_ends_at ?? null }
|
|
}
|
|
} else if (rpcError.code === 'PGRST202') {
|
|
// Function absent = the paywall migration (and its backfills) has not
|
|
// reached this database yet: there are no multi_user rows to judge by, so
|
|
// the grants fallback would freeze every non-owner. Fail OPEN for the
|
|
// deploy-race window; the gate arms itself when the migration lands.
|
|
return { state: 'entitled', graceEndsAt: null }
|
|
}
|
|
|
|
let teamId = options.teamId
|
|
if (teamId === undefined) {
|
|
const { data: company, error } = await supabase
|
|
.from('companies')
|
|
.select('team_id')
|
|
.eq('id', companyId)
|
|
.maybeSingle()
|
|
if (error) return { state: 'entitled', graceEndsAt: null } // fail-open
|
|
teamId = (company as { team_id: string | null } | null)?.team_id ?? null
|
|
}
|
|
const validTeamId = teamId && UUID_RE.test(teamId) ? teamId : null
|
|
|
|
const scopeFilter = validTeamId
|
|
? `company_id.eq.${companyId},team_id.eq.${validTeamId}`
|
|
: `company_id.eq.${companyId}`
|
|
const { data: grants, error: grantsError } = await supabase
|
|
.from('capability_grants')
|
|
.select('expires_at')
|
|
.eq('capability_key', CAPABILITY.multi_user)
|
|
.or(scopeFilter)
|
|
if (grantsError) return { state: 'entitled', graceEndsAt: null } // fail-open
|
|
|
|
return computeMultiUserState((grants ?? []) as MultiUserGrantRow[], Date.now())
|
|
}
|
|
|
|
/**
|
|
* Whether THIS membership may enter the company right now: the dormancy rule
|
|
* applied to a resolved (companyId, role) pair. Owners always pass without a
|
|
* grants read.
|
|
*/
|
|
export async function isMembershipActive(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
role: string,
|
|
options: { teamId?: string | null } = {},
|
|
): Promise<boolean> {
|
|
if (role === 'owner') return true
|
|
if (!isMultiUserEnforced()) return true
|
|
const access = await getMultiUserState(supabase, companyId, options)
|
|
return !isMembershipDormant(role, access.state)
|
|
}
|