Files
accounted/lib/entitlements/multi-user.ts
T
Mattsson aabddb592f feat(billing): multi-user paywall: multi_user capability, 20-day grace, owner-only dormancy (#2099)
* 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>
2026-09-01 11:29:12 +02:00

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)
}