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>
249 lines
9.0 KiB
TypeScript
249 lines
9.0 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
computeMultiUserState,
|
|
isMembershipDormant,
|
|
MULTI_USER_GRACE_DAYS,
|
|
} from '../multi-user-state'
|
|
import { getMultiUserState, isMembershipActive, isMultiUserEnforced } from '../multi-user'
|
|
|
|
const DAY_MS = 86_400_000
|
|
const COMPANY = '11111111-1111-4111-8111-111111111111'
|
|
|
|
const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString()
|
|
|
|
type TableResult = { data: unknown; error?: unknown }
|
|
/**
|
|
* Per-table mock plus an rpc seam. getMultiUserState is RPC-first
|
|
* (company_multi_user_state); the default rpc result is a TRANSIENT error so
|
|
* the tests below exercise the grants-read fallback path unless they seed an
|
|
* rpc answer explicitly.
|
|
*/
|
|
function makeSupabase(
|
|
byTable: Record<string, TableResult>,
|
|
rpcResult: TableResult = { data: null, error: { code: '57014', message: 'statement timeout' } },
|
|
): SupabaseClient {
|
|
const chainFor = (table: string) => {
|
|
const result = byTable[table] ?? { data: null, error: null }
|
|
const chain: unknown = new Proxy(
|
|
{},
|
|
{
|
|
get(_t, prop) {
|
|
if (prop === 'then') {
|
|
return (resolve: (v: unknown) => void) =>
|
|
resolve({ data: result.data ?? null, error: result.error ?? null })
|
|
}
|
|
return () => chain
|
|
},
|
|
},
|
|
)
|
|
return chain
|
|
}
|
|
return {
|
|
from: (t: string) => chainFor(t),
|
|
rpc: async () => ({ data: rpcResult.data ?? null, error: rpcResult.error ?? null }),
|
|
} as unknown as SupabaseClient
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
describe('computeMultiUserState', () => {
|
|
const now = Date.now()
|
|
|
|
it('is entitled on a never-expiring grant', () => {
|
|
expect(computeMultiUserState([{ expires_at: null }], now).state).toBe('entitled')
|
|
})
|
|
|
|
it('is entitled on an unexpired grant even when an older one lapsed', () => {
|
|
const access = computeMultiUserState(
|
|
[{ expires_at: iso(-40 * DAY_MS) }, { expires_at: iso(60_000) }],
|
|
now,
|
|
)
|
|
expect(access.state).toBe('entitled')
|
|
expect(access.graceEndsAt).toBeNull()
|
|
})
|
|
|
|
it('is in grace right after expiry, with graceEndsAt = expiry + 20 days', () => {
|
|
const expiry = iso(-60_000)
|
|
const access = computeMultiUserState([{ expires_at: expiry }], now)
|
|
expect(access.state).toBe('grace')
|
|
expect(new Date(access.graceEndsAt!).getTime()).toBe(
|
|
new Date(expiry).getTime() + MULTI_USER_GRACE_DAYS * DAY_MS,
|
|
)
|
|
})
|
|
|
|
it('is still in grace on day 19 after the lapse', () => {
|
|
const access = computeMultiUserState([{ expires_at: iso(-19 * DAY_MS) }], now)
|
|
expect(access.state).toBe('grace')
|
|
})
|
|
|
|
it('is frozen once the lapse is 20 full days old', () => {
|
|
// Derive the expiry from the SAME clock the check uses: iso() reads
|
|
// Date.now() at call time, which sits a few ms after `now` and would
|
|
// land the boundary case back inside the grace window.
|
|
const expiry = new Date(now - MULTI_USER_GRACE_DAYS * DAY_MS - 1000).toISOString()
|
|
const access = computeMultiUserState([{ expires_at: expiry }], now)
|
|
expect(access.state).toBe('frozen')
|
|
expect(access.graceEndsAt).toBeNull()
|
|
})
|
|
|
|
it('the NEWEST expiry drives the grace window (a fresh manual grant extends it)', () => {
|
|
// Grandfather shape: trial lapsed long ago, backfill grant expired "now".
|
|
const access = computeMultiUserState(
|
|
[{ expires_at: iso(-56 * DAY_MS) }, { expires_at: iso(-1000) }],
|
|
now,
|
|
)
|
|
expect(access.state).toBe('grace')
|
|
})
|
|
|
|
it('is frozen when never granted', () => {
|
|
expect(computeMultiUserState([], now).state).toBe('frozen')
|
|
})
|
|
})
|
|
|
|
describe('isMembershipDormant', () => {
|
|
it('owners are never dormant, frozen company or not', () => {
|
|
expect(isMembershipDormant('owner', 'frozen')).toBe(false)
|
|
})
|
|
it('non-owners are dormant only in the frozen state', () => {
|
|
expect(isMembershipDormant('admin', 'frozen')).toBe(true)
|
|
expect(isMembershipDormant('member', 'frozen')).toBe(true)
|
|
expect(isMembershipDormant('viewer', 'frozen')).toBe(true)
|
|
expect(isMembershipDormant('member', 'grace')).toBe(false)
|
|
expect(isMembershipDormant('member', 'entitled')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('getMultiUserState', () => {
|
|
it('is entitled on self-hosted without touching the DB', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
const supabase = makeSupabase({}) // would be frozen if the gate ran
|
|
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
|
|
})
|
|
|
|
it('is entitled under the dev bypass', async () => {
|
|
vi.stubEnv('NODE_ENV', 'development')
|
|
const supabase = makeSupabase({})
|
|
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
|
|
})
|
|
|
|
it('FORCE_PAYWALL activates the real gate in development', async () => {
|
|
vi.stubEnv('NODE_ENV', 'development')
|
|
vi.stubEnv('FORCE_PAYWALL', 'true')
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [] },
|
|
})
|
|
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('frozen')
|
|
})
|
|
|
|
it('is frozen for a non-UUID company id', async () => {
|
|
const supabase = makeSupabase({})
|
|
expect((await getMultiUserState(supabase, 'not-a-uuid')).state).toBe('frozen')
|
|
})
|
|
|
|
it('reads grants and derives grace from the newest expiry', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ expires_at: iso(-DAY_MS) }] },
|
|
})
|
|
const access = await getMultiUserState(supabase, COMPANY)
|
|
expect(access.state).toBe('grace')
|
|
expect(access.graceEndsAt).not.toBeNull()
|
|
})
|
|
|
|
it('a known teamId skips the companies lookup and still resolves', async () => {
|
|
const supabase = makeSupabase({
|
|
// companies deliberately absent: querying it would resolve null and be harmless,
|
|
// but the team grant below is what entitles.
|
|
capability_grants: { data: [{ expires_at: null }] },
|
|
})
|
|
const access = await getMultiUserState(supabase, COMPANY, {
|
|
teamId: '22222222-2222-4222-8222-222222222222',
|
|
})
|
|
expect(access.state).toBe('entitled')
|
|
})
|
|
|
|
it('fails OPEN (entitled) on a grants read error', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: null, error: { message: 'boom' } },
|
|
})
|
|
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
|
|
})
|
|
|
|
it('prefers the SECURITY DEFINER state RPC over the grants read (team grants hidden by RLS)', async () => {
|
|
// The tables would say frozen (no visible rows: the byrå-client shape);
|
|
// the RPC sees the team grant and must win.
|
|
const supabase = makeSupabase(
|
|
{
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [] },
|
|
},
|
|
{ data: [{ state: 'entitled', grace_ends_at: null }] },
|
|
)
|
|
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
|
|
})
|
|
|
|
it('passes the RPC grace deadline through', async () => {
|
|
const graceEnd = iso(5 * DAY_MS)
|
|
const supabase = makeSupabase({}, { data: [{ state: 'grace', grace_ends_at: graceEnd }] })
|
|
const access = await getMultiUserState(supabase, COMPANY)
|
|
expect(access.state).toBe('grace')
|
|
expect(access.graceEndsAt).toBe(graceEnd)
|
|
})
|
|
|
|
it('fails OPEN when the state RPC does not exist yet (deploy race, PGRST202)', async () => {
|
|
// Pre-migration there are no multi_user rows either, so the grants
|
|
// fallback would freeze every non-owner: PGRST202 must short-circuit to
|
|
// entitled instead of reaching the table path.
|
|
const supabase = makeSupabase(
|
|
{
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [] }, // would read as frozen
|
|
},
|
|
{ data: null, error: { code: 'PGRST202', message: 'function not found' } },
|
|
)
|
|
expect((await getMultiUserState(supabase, COMPANY)).state).toBe('entitled')
|
|
})
|
|
})
|
|
|
|
describe('isMembershipActive', () => {
|
|
it('owner passes without a grants read', async () => {
|
|
const supabase = makeSupabase({}) // would be frozen if consulted
|
|
expect(await isMembershipActive(supabase, COMPANY, 'owner')).toBe(true)
|
|
})
|
|
|
|
it('non-owner in a frozen company is inactive', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [] },
|
|
})
|
|
expect(await isMembershipActive(supabase, COMPANY, 'member')).toBe(false)
|
|
})
|
|
|
|
it('non-owner in grace stays active', async () => {
|
|
const supabase = makeSupabase({
|
|
companies: { data: { team_id: null } },
|
|
capability_grants: { data: [{ expires_at: iso(-DAY_MS) }] },
|
|
})
|
|
expect(await isMembershipActive(supabase, COMPANY, 'member')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('isMultiUserEnforced', () => {
|
|
it('is enforced in the test environment (NODE_ENV=test, hosted)', () => {
|
|
expect(isMultiUserEnforced()).toBe(true)
|
|
})
|
|
it('is not enforced on self-hosted', () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
|
expect(isMultiUserEnforced()).toBe(false)
|
|
})
|
|
})
|