* 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>
292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { getMultiUserState, isMultiUserEnforced } from '@/lib/entitlements/multi-user'
|
|
import { isMembershipDormant, MULTI_USER_GRACE_DAYS } from '@/lib/entitlements/multi-user-state'
|
|
|
|
/**
|
|
* Active-company resolution with no Next.js request-scope dependency.
|
|
*
|
|
* Split out of lib/company/context.ts (which imports `next/headers` for the
|
|
* legacy company cookie) so that modules on the API-key path, notably
|
|
* lib/auth/api-keys.ts, can resolve a user's company without dragging
|
|
* `next/headers` into every bundle that validates a key. context.ts
|
|
* re-exports everything here; import from there unless the cookie import is
|
|
* the problem.
|
|
*/
|
|
|
|
/**
|
|
* Thrown by setActiveCompany so callers can tell a permissions problem
|
|
* ('not_member') apart from a failed/unverified database write
|
|
* ('persist_failed'), and by getActiveCompanyId when a resolution query
|
|
* fails ('resolution_failed': the active company is unknown right now,
|
|
* which is NOT the same as the user having no companies).
|
|
*/
|
|
export class CompanyContextError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly code: 'not_member' | 'persist_failed' | 'resolution_failed' | 'company_locked'
|
|
) {
|
|
super(message)
|
|
this.name = 'CompanyContextError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the active company ID for the authenticated user.
|
|
*
|
|
* Resolution order: user_preferences → first non-archived membership.
|
|
*
|
|
* `user_preferences.active_company_id` is the authoritative source. The
|
|
* cookie `gnubok-company-id` is written as a hint for backwards-compat but
|
|
* is no longer READ as a source of truth, because Postgres RLS (via
|
|
* `current_active_company_id()`) can only read the database, not cookies.
|
|
* Having Next.js and RLS both read from `user_preferences` keeps them
|
|
* perfectly in sync.
|
|
*
|
|
* RPC-first: tries `resolve_active_company()` (one round trip, semantically
|
|
* identical to the query path and to `current_active_company_id()`), falling
|
|
* back to the original query path when the function is not deployed
|
|
* (PGRST202), the caller lacks EXECUTE (42501: service-role clients), or the
|
|
* RPC returns zero rows (NULL auth.uid(), also service-role clients).
|
|
*
|
|
* Returns null only when the user positively has no non-archived companies.
|
|
* Throws CompanyContextError('resolution_failed') when a query fails: a
|
|
* transient failure must never read as "no companies", because callers
|
|
* redirect that state to the onboarding wizard (issue #1053).
|
|
*/
|
|
export async function getActiveCompanyId(
|
|
supabase: SupabaseClient,
|
|
userId: string
|
|
): Promise<string | null> {
|
|
// Multi-user seat gate (lib/entitlements/multi-user.ts): when enforced,
|
|
// resolution must skip memberships in companies frozen for this user
|
|
// (non-owner, multi_user lapsed past its grace window). The gated RPC is
|
|
// the zero-arg one plus exactly that predicate; self-hosted and dev keep
|
|
// calling the ungated function so the gate can never bite there.
|
|
const enforced = isMultiUserEnforced()
|
|
const { data, error } = enforced
|
|
? await supabase.rpc('resolve_active_company_gated', {
|
|
p_grace_days: MULTI_USER_GRACE_DAYS,
|
|
})
|
|
: await supabase.rpc('resolve_active_company')
|
|
|
|
if (error) {
|
|
// PGRST202: function not in the schema cache (self-hosted instance not
|
|
// migrated yet, or a deploy racing the branch merge).
|
|
// 42501: EXECUTE is granted to `authenticated` only, so a service-role
|
|
// client is refused. These fallbacks are LOAD-BEARING, not defensive:
|
|
// app/api/mcp-oauth/token/route.ts, app/api/events/route.ts (API-key
|
|
// branch) and lib/auth/api-keys.ts call this with
|
|
// createServiceClientNoCookies(), and must silently resolve via the query
|
|
// path or the OAuth token flow breaks.
|
|
//
|
|
// The two fallbacks differ in seat-gate polarity ON PURPOSE:
|
|
// PGRST202 = the gated function does not exist here, i.e. the paywall
|
|
// migration has not reached this database (deploy race, self-host
|
|
// mid-migration). There are no multi_user rows either, so the gated
|
|
// query path would freeze every non-owner: fail OPEN via the ungated
|
|
// path, matching the middleware's own PGRST202 handling.
|
|
// 42501 / zero rows = a migrated database reached with a service-role
|
|
// client (API keys, MCP, OAuth): the gated query path enforces there.
|
|
if (error.code === 'PGRST202') {
|
|
return getActiveCompanyIdViaQueriesUngated(supabase, userId)
|
|
}
|
|
if (error.code === '42501') {
|
|
return getActiveCompanyIdViaQueries(supabase, userId)
|
|
}
|
|
throw new CompanyContextError(
|
|
`Active company resolution failed: ${error.message}`,
|
|
'resolution_failed'
|
|
)
|
|
}
|
|
|
|
const row = (Array.isArray(data) ? data[0] : data) as
|
|
| { company_id: string | null; locale: string | null; used_fallback: boolean }
|
|
| undefined
|
|
| null
|
|
|
|
if (!row) {
|
|
// Zero rows = NULL auth.uid() inside the RPC, i.e. a service-role client
|
|
// (same call sites as the 42501 branch above). The query path filters by
|
|
// the explicit userId param and still resolves correctly.
|
|
return getActiveCompanyIdViaQueries(supabase, userId)
|
|
}
|
|
|
|
return row.company_id ?? null
|
|
}
|
|
|
|
/**
|
|
* Query-path resolution: the pre-RPC implementation, kept as the fallback for
|
|
* getActiveCompanyId (see the fallback conditions there). With the seat gate
|
|
* enforced it routes to the gated variant below, because every service-role
|
|
* caller (API keys, MCP, OAuth token flow) lands on this path on EVERY
|
|
* request: leaving it ungated would make the API surface a paywall bypass.
|
|
*/
|
|
async function getActiveCompanyIdViaQueries(
|
|
supabase: SupabaseClient,
|
|
userId: string
|
|
): Promise<string | null> {
|
|
if (isMultiUserEnforced()) {
|
|
return getActiveCompanyIdViaQueriesGated(supabase, userId)
|
|
}
|
|
return getActiveCompanyIdViaQueriesUngated(supabase, userId)
|
|
}
|
|
|
|
/**
|
|
* Gated query path: same resolution order (validated preference, else first
|
|
* membership by created_at) restricted to memberships the seat gate lets
|
|
* through: owner role, or a company whose multi_user grant is active or
|
|
* within its 20-day grace window. Mirrors resolve_active_company_gated().
|
|
*
|
|
* Fail-open on the grants read specifically: a transient capability_grants
|
|
* failure must never lock people out of their bookkeeping. The membership
|
|
* and preference reads keep the fail-loud behavior of the ungated path.
|
|
*/
|
|
async function getActiveCompanyIdViaQueriesGated(
|
|
supabase: SupabaseClient,
|
|
userId: string
|
|
): Promise<string | null> {
|
|
const [prefsRes, membershipsRes] = await Promise.all([
|
|
supabase
|
|
.from('user_preferences')
|
|
.select('active_company_id')
|
|
.eq('user_id', userId)
|
|
.maybeSingle(),
|
|
supabase
|
|
.from('company_members')
|
|
.select('company_id, role, created_at, companies!inner(archived_at, team_id)')
|
|
.eq('user_id', userId)
|
|
.is('companies.archived_at', null)
|
|
.order('created_at', { ascending: true }),
|
|
])
|
|
|
|
const resolutionError = prefsRes.error ?? membershipsRes.error
|
|
if (resolutionError) {
|
|
throw new CompanyContextError(
|
|
`Active company resolution failed: ${resolutionError.message}`,
|
|
'resolution_failed'
|
|
)
|
|
}
|
|
|
|
type MembershipRow = {
|
|
company_id: string
|
|
role: string
|
|
companies: { team_id: string | null }
|
|
}
|
|
const memberships = (membershipsRes.data ?? []) as unknown as MembershipRow[]
|
|
if (memberships.length === 0) return null
|
|
|
|
const dormant = await resolveDormantCompanyIds(supabase, memberships)
|
|
|
|
const accessible = memberships.filter((m) => !dormant.has(m.company_id) || m.role === 'owner')
|
|
const preferred = prefsRes.data?.active_company_id
|
|
if (preferred && accessible.some((m) => m.company_id === preferred)) {
|
|
return preferred
|
|
}
|
|
return accessible[0]?.company_id ?? null
|
|
}
|
|
|
|
/**
|
|
* Which of these memberships' companies are dormant FOR THE MEMBER ROLE
|
|
* (owner rows are exempt by the caller): companies whose multi_user grants
|
|
* (company- or team-scoped) are all lapsed past the grace window. Shared by
|
|
* the gated query fallback here and the switcher's locked-state computation
|
|
* in the dashboard layout.
|
|
*
|
|
* Resolved per company through getMultiUserState (RPC-first): the
|
|
* capability_grants SELECT policy hides team-scoped rows from users outside
|
|
* the team, so a direct grants read through a user-scoped client would mark
|
|
* every team-covered (byrå) company dormant. Fail-open by construction:
|
|
* getMultiUserState answers 'entitled' on any read failure.
|
|
*/
|
|
export async function resolveDormantCompanyIds(
|
|
supabase: SupabaseClient,
|
|
memberships: readonly { company_id: string; role: string; companies: { team_id: string | null } }[]
|
|
): Promise<Set<string>> {
|
|
const nonOwner = memberships.filter((m) => m.role !== 'owner')
|
|
if (nonOwner.length === 0 || !isMultiUserEnforced()) return new Set()
|
|
|
|
const companyIds = [...new Set(nonOwner.map((m) => m.company_id))]
|
|
const states = await Promise.all(
|
|
companyIds.map((companyId) => getMultiUserState(supabase, companyId))
|
|
)
|
|
const dormant = new Set<string>()
|
|
companyIds.forEach((companyId, index) => {
|
|
if (isMembershipDormant('member', states[index].state)) dormant.add(companyId)
|
|
})
|
|
return dormant
|
|
}
|
|
|
|
/**
|
|
* Ungated query-path resolution: the pre-RPC implementation, kept verbatim.
|
|
*/
|
|
async function getActiveCompanyIdViaQueriesUngated(
|
|
supabase: SupabaseClient,
|
|
userId: string
|
|
): Promise<string | null> {
|
|
// user_preferences (authoritative) + first membership, fetched in parallel:
|
|
// the fallback query result doubles as validation when the preferred
|
|
// company happens to be the first membership, which is the common
|
|
// single-company case. Most requests pay one round trip instead of two
|
|
// sequential ones. This runs on every withRouteContext API request and
|
|
// every dashboard layout render, so the sequential version was pure
|
|
// wall-clock cost. Mirrors resolveCompanyForMiddleware, minus the
|
|
// write-back (read paths shouldn't write).
|
|
const [prefsRes, firstRes] = await Promise.all([
|
|
supabase
|
|
.from('user_preferences')
|
|
.select('active_company_id')
|
|
.eq('user_id', userId)
|
|
.maybeSingle(),
|
|
supabase
|
|
.from('company_members')
|
|
.select('company_id, companies!inner(archived_at)')
|
|
.eq('user_id', userId)
|
|
.is('companies.archived_at', null)
|
|
.order('created_at', { ascending: true })
|
|
.limit(1)
|
|
.maybeSingle(),
|
|
])
|
|
|
|
const resolutionError = prefsRes.error ?? firstRes.error
|
|
if (resolutionError) {
|
|
throw new CompanyContextError(
|
|
`Active company resolution failed: ${resolutionError.message}`,
|
|
'resolution_failed'
|
|
)
|
|
}
|
|
|
|
const prefs = prefsRes.data
|
|
const firstCompany = firstRes.data
|
|
|
|
if (prefs?.active_company_id) {
|
|
if (firstCompany && prefs.active_company_id === firstCompany.company_id) {
|
|
return firstCompany.company_id
|
|
}
|
|
|
|
// Preference points at a different company than the first membership:
|
|
// validate it still resolves to a non-archived company the user is a
|
|
// member of before trusting it.
|
|
const { data: membership, error: membershipError } = await supabase
|
|
.from('company_members')
|
|
.select('company_id, companies!inner(archived_at)')
|
|
.eq('company_id', prefs.active_company_id)
|
|
.eq('user_id', userId)
|
|
.is('companies.archived_at', null)
|
|
.maybeSingle()
|
|
|
|
// Falling back to the first membership on a FAILED validation would
|
|
// silently switch a multi-company user's active company: fail loudly.
|
|
if (membershipError) {
|
|
throw new CompanyContextError(
|
|
`Active company validation failed: ${membershipError.message}`,
|
|
'resolution_failed'
|
|
)
|
|
}
|
|
|
|
if (membership) return membership.company_id
|
|
}
|
|
|
|
// Fallback: first non-archived membership by created_at (already fetched)
|
|
return firstCompany?.company_id ?? null
|
|
}
|