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>
This commit is contained in:
Mattsson
2026-09-01 11:29:12 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent ca12b1855e
commit aabddb592f
44 changed files with 2573 additions and 49 deletions
+80
View File
@@ -36,6 +36,19 @@ vi.mock('@supabase/supabase-js', async () => {
}
})
// Multi-user seat gate: mocked so the membership stub stays single-purpose;
// the gate's own logic is covered in lib/entitlements/__tests__/multi-user.test.ts.
const getMultiUserStateMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/entitlements/multi-user', async () => {
const actual = await vi.importActual<typeof import('@/lib/entitlements/multi-user')>(
'@/lib/entitlements/multi-user',
)
return {
...actual,
getMultiUserState: (...args: unknown[]) => getMultiUserStateMock(...args),
}
})
vi.mock('@/lib/api/idempotency', async () => {
const actual = await vi.importActual<typeof import('@/lib/api/idempotency')>(
'@/lib/api/idempotency',
@@ -102,6 +115,7 @@ function companyParams(companyId: string) {
beforeEach(() => {
vi.clearAllMocks()
mockServiceClient.mockReturnValue(makeSupabaseStub(null))
getMultiUserStateMock.mockResolvedValue({ state: 'entitled', graceEndsAt: null })
})
describe('withApiV1: auth', () => {
@@ -277,6 +291,72 @@ describe('withApiV1: company membership', () => {
const body = await res.json()
expect(body.data.companyId).toBe('company-1')
})
it('refuses a NON-OWNER membership in a frozen company with 403 (multi-user seat gate)', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['companies:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'admin' }))
getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null })
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
'companies.get',
async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }),
{ requireScope: 'companies:read' },
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/company-1', {
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
companyParams('company-1'),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error.code).toBe('FORBIDDEN')
expect(body.error.details.capability).toBe('multi_user')
})
it('OWNERS pass the seat gate without a state read; grace passes for non-owners', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['companies:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' }))
getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null })
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
'companies.get',
async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }),
{ requireScope: 'companies:read' },
)
const ownerRes = await handler(
makeRequest('https://x.test/api/v1/companies/company-1', {
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
companyParams('company-1'),
)
expect(ownerRes.status).toBe(200)
expect(getMultiUserStateMock).not.toHaveBeenCalled()
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'member' }))
getMultiUserStateMock.mockResolvedValue({
state: 'grace',
graceEndsAt: new Date(Date.now() + 5 * 86_400_000).toISOString(),
})
const graceRes = await handler(
makeRequest('https://x.test/api/v1/companies/company-1', {
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
companyParams('company-1'),
)
expect(graceRes.status).toBe(200)
})
})
describe('withApiV1: static (non-dynamic) route params', () => {
+23
View File
@@ -58,6 +58,7 @@ import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
// idempotent (guarded by a module-level boolean).
ensureInitialized()
import { resolveRequiredScope } from '@/lib/auth/scopes'
import { getMultiUserState, isMembershipDormant } from '@/lib/entitlements/multi-user'
import { getEndpointByConcretePath } from './registry'
import {
checkIdempotencyKey,
@@ -412,6 +413,28 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
details: { companyId },
})
}
// Multi-user seat gate: the API-key surface is a chokepoint like the
// cookie routes and MCP. A non-owner membership in a frozen company
// (multi_user lapsed past its 20-day grace) is refused here so an old
// key cannot keep working the books after the freeze. Owners pass
// without the extra read; the service client sees team-scoped grants.
if ((membership as { role?: string }).role !== 'owner') {
const access = await getMultiUserState(supabase, companyId)
if (isMembershipDormant((membership as { role: string }).role, access.state)) {
userLog.warn('multi-user seat gate refused frozen membership', { companyId, ...forensic })
return await v1ErrorResponseFromCode('FORBIDDEN', userLog, {
requestId,
status: 403,
reason: 'multi_user_frozen',
details: {
companyId,
capability: 'multi_user',
message: 'Company is paused for this account: multiple users require a paid plan. Ask the company owner to upgrade.',
},
})
}
}
}
// 6. Idempotency. Mandatory for state-changing methods when the route
+95 -1
View File
@@ -6,6 +6,26 @@ vi.mock('next/headers', () => ({
cookies: vi.fn(async () => ({ set: mockCookieSet })),
}))
// Multi-user seat gate seam. Default: NOT enforced and every membership
// active, so the pre-existing tests keep documenting the ungated resolution
// (the self-hosted / dev behavior, and the pre-gate semantics). The gated
// tests at the bottom flip these; the gate's own logic is covered in
// lib/entitlements/__tests__/multi-user.test.ts.
const multiUserSeam = vi.hoisted(() => ({
enforced: false,
membershipActive: vi.fn().mockResolvedValue(true),
}))
vi.mock('@/lib/entitlements/multi-user', async () => {
const actual = await vi.importActual<typeof import('@/lib/entitlements/multi-user')>(
'@/lib/entitlements/multi-user',
)
return {
...actual,
isMultiUserEnforced: () => multiUserSeam.enforced,
isMembershipActive: (...args: unknown[]) => multiUserSeam.membershipActive(...args),
}
})
import { setActiveCompany, CompanyContextError, getCompanyDisplayName, getActiveCompanyId } from '../context'
type CapturedCall = { table: string; method: string; args: unknown[] }
@@ -37,7 +57,7 @@ function buildSupabase(
function makeChain(table: string) {
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'is', 'order', 'limit', 'maybeSingle', 'single', 'insert', 'upsert', 'delete', 'update']
const methods = ['select', 'eq', 'is', 'or', 'order', 'limit', 'maybeSingle', 'single', 'insert', 'upsert', 'delete', 'update']
for (const m of methods) {
chain[m] = (...args: unknown[]) => {
calls.push({ table, method: m, args })
@@ -66,6 +86,8 @@ function buildSupabase(
beforeEach(() => {
vi.clearAllMocks()
multiUserSeam.enforced = false
multiUserSeam.membershipActive.mockResolvedValue(true)
})
describe('setActiveCompany', () => {
@@ -322,6 +344,78 @@ describe('getActiveCompanyId via resolve_active_company RPC', () => {
})
})
describe('multi-user seat gate', () => {
it('setActiveCompany refuses a switch into a company frozen for the member', async () => {
multiUserSeam.membershipActive.mockResolvedValue(false)
const { supabase, calls } = buildSupabase({
company_members: { single: { data: { company_id: 'company-2', role: 'member' } } },
})
const err = await setActiveCompany(supabase as never, 'user-1', 'company-2').catch((e) => e)
expect(err).toBeInstanceOf(CompanyContextError)
expect(err.code).toBe('company_locked')
// The preference write must never land: a persisted frozen preference
// would silently bounce every later resolution.
expect(calls.find((c) => c.table === 'user_preferences')).toBeUndefined()
expect(mockCookieSet).not.toHaveBeenCalled()
})
it('getActiveCompanyId calls the GATED rpc when enforcement is on', async () => {
multiUserSeam.enforced = true
const { supabase } = buildSupabase(
{},
{ data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }] },
)
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
expect(supabase.rpc).toHaveBeenCalledWith('resolve_active_company_gated', {
p_grace_days: 20,
})
})
it('gated query fallback resolves the first ACCESSIBLE membership (dormant skipped)', async () => {
multiUserSeam.enforced = true
const memberships = [
// Frozen for the user: non-owner and no grant rows will match below.
{ company_id: 'frozen-co', role: 'member', created_at: '2026-01-01', companies: { team_id: null } },
{ company_id: 'owned-co', role: 'owner', created_at: '2026-02-01', companies: { team_id: null } },
]
const { supabase } = buildSupabase(
{
user_preferences: { maybeSingle: { data: { active_company_id: 'frozen-co' } } },
// The gated path awaits the list query (no maybeSingle): seed the
// chain's `order` terminal.
company_members: { order: { data: memberships } },
// No multi_user grants at all -> frozen-co is frozen for the member
// (the shared rpc mock returns zero rows for company_multi_user_state
// too, so getMultiUserState falls through to this grants read).
capability_grants: { or: { data: [] } },
},
// Zero RPC rows = NULL auth.uid() (service-role client): routes to the
// GATED query path, unlike PGRST202 (migration absent), which fails open.
{ data: [] },
)
// Preference points at the frozen company: resolution must skip it and
// land on the owned company instead of locking the user out.
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('owned-co')
})
it('PGRST202 on the gated rpc fails OPEN via the ungated query path (deploy race)', async () => {
multiUserSeam.enforced = true
// Default rpcResult is PGRST202. Pre-migration there are no multi_user
// rows, so the gated path would freeze this non-owner: the fallback must
// be the UNGATED path and still resolve their membership.
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
})
})
describe('getCompanyDisplayName', () => {
it('returns company_settings.company_name and never reads companies when set', async () => {
const { supabase, calls } = buildSupabase({
+5
View File
@@ -35,6 +35,11 @@ export async function switchCompany(companyId: string): Promise<{ error?: string
if (err instanceof CompanyContextError && err.code === 'not_member') {
return { error: 'not_member' }
}
if (err instanceof CompanyContextError && err.code === 'company_locked') {
// Multi-user seat gate: the company is frozen for this (non-owner)
// membership until someone pays. Translated by the caller.
return { error: 'company_locked' }
}
// persist_failed and anything unexpected: a retryable failure, not a
// permissions problem: don't tell the user they lack access.
return { error: 'persist_failed' }
+130 -5
View File
@@ -1,4 +1,6 @@
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.
@@ -21,7 +23,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
export class CompanyContextError extends Error {
constructor(
message: string,
readonly code: 'not_member' | 'persist_failed' | 'resolution_failed'
readonly code: 'not_member' | 'persist_failed' | 'resolution_failed' | 'company_locked'
) {
super(message)
this.name = 'CompanyContextError'
@@ -55,7 +57,17 @@ export async function getActiveCompanyId(
supabase: SupabaseClient,
userId: string
): Promise<string | null> {
const { data, error } = await supabase.rpc('resolve_active_company')
// 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
@@ -66,7 +78,19 @@ export async function getActiveCompanyId(
// 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.
if (error.code === 'PGRST202' || error.code === '42501') {
//
// 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(
@@ -91,12 +115,113 @@ export async function getActiveCompanyId(
}
/**
* Query-path resolution: the pre-RPC implementation, kept verbatim as the
* fallback for getActiveCompanyId (see the fallback conditions there).
* 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
+14 -1
View File
@@ -3,6 +3,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { cookies } from 'next/headers'
import type { EntityType } from '@/types'
import { CompanyContextError, getActiveCompanyId } from '@/lib/company/active-company'
import { isMembershipActive } from '@/lib/entitlements/multi-user'
// The resolver and its error class live in active-company.ts (no
// `next/headers` there) so the API-key path can use them; re-exported here so
@@ -129,7 +130,7 @@ export async function setActiveCompany(
// Validate membership
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.select('company_id, role')
.eq('company_id', companyId)
.eq('user_id', userId)
.single()
@@ -138,6 +139,18 @@ export async function setActiveCompany(
throw new CompanyContextError('User is not a member of this company', 'not_member')
}
// Multi-user seat gate: a non-owner may not switch INTO a company frozen
// for them (multi_user lapsed past its grace window). Without this check
// the preference write would stick and every subsequent gated resolution
// would silently bounce them elsewhere, which reads as a broken switch.
const seatGateOk = await isMembershipActive(supabase, companyId, membership.role)
if (!seatGateOk) {
throw new CompanyContextError(
'Company is frozen for this membership: multi-user access requires a paid plan',
'company_locked'
)
}
// Update user_preferences: this is the authoritative value RLS reads.
// The write MUST be verified: an UPDATE filtered out by RLS affects zero
// rows without raising an error, which previously made failed switches
@@ -0,0 +1,248 @@
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)
})
})
+39 -4
View File
@@ -1,8 +1,13 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { isSelfHosted } from '@/lib/env/public-flags'
import { PAID_CAPABILITIES, isConnectorCapability, type CapabilityKey } from './keys'
import { CAPABILITY, PAID_CAPABILITIES, isConnectorCapability, type CapabilityKey } from './keys'
import { hasOwnCredentialsFor } from './own-credentials'
import {
computeMultiUserState,
type MultiUserAccess,
type MultiUserGrantRow,
} from './multi-user-state'
/**
* Entitlement gate: the single primitive behind the paywall ("non-payer loses
@@ -377,6 +382,14 @@ export interface CompanyEntitlements {
* entitlementState is 'trial_expired'. Drives the expired-trial notice.
*/
trialExpiredAt: string | null
/**
* Multi-user access state (entitled / grace / frozen) derived from the same
* multi_user grant rows, with the end of the 20-day grace window while in
* grace. Drives the countdown banner and the invite upsell; the server-side
* dormancy enforcement recomputes it independently (lib/entitlements/
* multi-user.ts and the resolve_active_company_gated RPC).
*/
multiUser: MultiUserAccess
}
/** company_subscriptions.status values that count as a live subscription. */
@@ -441,11 +454,18 @@ export async function getCompanyEntitlements(
trialEndsAt: null,
entitlementState: 'paid',
trialExpiredAt: null,
multiUser: { state: 'entitled', graceEndsAt: null },
}
}
// Fail-closed: never interpolate a non-UUID.
if (!isUuid(companyId)) {
return { capabilities: [], trialEndsAt: null, entitlementState: 'none', trialExpiredAt: null }
return {
capabilities: [],
trialEndsAt: null,
entitlementState: 'none',
trialExpiredAt: null,
multiUser: { state: 'frozen', graceEndsAt: null },
}
}
// Self-hosted: every local capability is held outright; only the connector
// capabilities among the paid keys are read from grants (written with
@@ -505,11 +525,18 @@ export async function getCompanyEntitlements(
let latestTrialExpiry: string | null = null
let hasActiveNonTrialGrant = false
let hasActiveConnectorGrant = false
// multi_user rows feed the derived grace/frozen state below; the rows are
// already in this read (multi_user is a PAID key), so the state costs no
// extra query. Self-host never reaches this (multi_user is held outright).
const multiUserRows: MultiUserGrantRow[] = []
for (const g of grants ?? []) {
const row = g as { capability_key: string; expires_at: string | null; source: string | null }
// Self-host: a trial-seeded (or any non-connector) row never unlocks a
// connector capability; see connectorGrantsOnly().
if (selfHosted && row.source !== 'connector') continue
if (row.capability_key === CAPABILITY.multi_user) {
multiUserRows.push({ expires_at: row.expires_at })
}
if (
row.source === 'trial' &&
row.expires_at &&
@@ -536,6 +563,8 @@ export async function getCompanyEntitlements(
trialEndsAt: null,
entitlementState: hasActiveConnectorGrant ? 'paid' : 'none',
trialExpiredAt: null,
// multi_user is a local capability: a self-host is never seat-gated.
multiUser: { state: 'entitled', graceEndsAt: null },
}
}
// Paying/comped companies are not "on trial" even if the seeded trial rows
@@ -560,11 +589,16 @@ export async function getCompanyEntitlements(
entitlementState = 'none'
}
const multiUser = computeMultiUserState(multiUserRows, now)
if (entitled.size === 0) {
return { capabilities: [], trialEndsAt: null, entitlementState, trialExpiredAt }
return { capabilities: [], trialEndsAt: null, entitlementState, trialExpiredAt, multiUser }
}
// Subtract any explicitly-disabled (enablement axis).
// Subtract any explicitly-disabled (enablement axis). multi_user is exempt
// from the config axis by design (see lib/entitlements/multi-user-state.ts),
// but it is also never written to company_capability_config, so the plain
// subtraction stays correct for the capabilities list.
for (const c of configs ?? []) {
entitled.delete((c as { capability_key: string }).capability_key)
}
@@ -574,6 +608,7 @@ export async function getCompanyEntitlements(
trialEndsAt,
entitlementState,
trialExpiredAt,
multiUser,
}
}
+13
View File
@@ -34,6 +34,15 @@ export const CAPABILITY = {
woocommerce_sync: 'woocommerce_sync',
/** Shopify store sync: orders/refunds imported as a transaction feed. */
shopify_sync: 'shopify_sync',
/**
* Multiple people working in one company. Without it only the OWNER can
* enter the company: every other membership goes dormant (never deleted)
* after a 20-day post-lapse grace window, and new invites are blocked.
* See lib/entitlements/multi-user.ts for the derived entitled/grace/frozen
* state; company_capability_config does NOT apply to this key (it gates
* member access, not a feature surface).
*/
multi_user: 'multi_user',
/**
* Invoice email from the company's own verified sending domain (Resend
* domain per company). Opt-in: granted manually per company, NOT part of
@@ -69,6 +78,10 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [
CAPABILITY.stripe_payments,
CAPABILITY.woocommerce_sync,
CAPABILITY.shopify_sync,
// Founder decision (2026-09-01): multiple users per company is paid.
// Trial-seeded and Stripe-synced like the rest; enforcement is the
// owner-only dormancy rule in lib/entitlements/multi-user.ts.
CAPABILITY.multi_user,
] as const
/**
+75
View File
@@ -0,0 +1,75 @@
/**
* Pure multi-user access model: no I/O, no env reads, imported by both
* has-capability.ts (which derives the state from grant rows it already
* fetched) and multi-user.ts (the async lookup helpers). Keep it that way:
* this module must never import from either of them.
*
* The `multi_user` capability gates PEOPLE, not a feature surface, so its
* lapse is softened by a grace window (founder decision 2026-09-01):
*
* entitled : an active multi_user grant (trial/stripe/team/manual/comp).
* Everyone in the company works normally.
* grace : the newest grant expired less than MULTI_USER_GRACE_DAYS ago.
* Everyone still works; the countdown banner shows.
* frozen : expired at least MULTI_USER_GRACE_DAYS ago, or never granted.
* Only role = 'owner' memberships resolve; every other
* membership is dormant (rows untouched: paying reactivates
* them instantly), and new invites are blocked.
*
* The 20-day grace is the PERMANENT rule for every lapse (trial end,
* subscription cancel, comp expiry), not a one-off migration affordance:
* the grandfather backfill simply inserts a grant expiring at deploy time
* so existing multi-member free companies enter the same window.
*
* company_capability_config is deliberately ignored for this key: a config
* disable has no expiry to hang the grace window on, and freezing people
* out of their bookkeeping must never happen through a side channel. The
* DB twin (public.company_multi_user_ok) implements the same rule.
*/
export const MULTI_USER_GRACE_DAYS = 20
const GRACE_MS = MULTI_USER_GRACE_DAYS * 86_400_000
export type MultiUserState = 'entitled' | 'grace' | 'frozen'
export interface MultiUserAccess {
state: MultiUserState
/** End of the post-lapse grace window (ISO); set only while state === 'grace'. */
graceEndsAt: string | null
}
export interface MultiUserGrantRow {
expires_at: string | null
}
/** Derive the access state from the company's multi_user grant rows. */
export function computeMultiUserState(
rows: readonly MultiUserGrantRow[],
now: number,
): MultiUserAccess {
let newestExpiryMs: number | null = null
for (const row of rows) {
if (row.expires_at === null) return { state: 'entitled', graceEndsAt: null }
const expiryMs = new Date(row.expires_at).getTime()
if (Number.isNaN(expiryMs)) continue
if (expiryMs > now) return { state: 'entitled', graceEndsAt: null }
if (newestExpiryMs === null || expiryMs > newestExpiryMs) newestExpiryMs = expiryMs
}
if (newestExpiryMs !== null && newestExpiryMs + GRACE_MS > now) {
return { state: 'grace', graceEndsAt: new Date(newestExpiryMs + GRACE_MS).toISOString() }
}
return { state: 'frozen', graceEndsAt: null }
}
/**
* The dormancy rule, in one place. Owners always keep access (a company must
* never lock out the person who can pay); everyone else needs the company to
* be entitled or in grace. Membership `source` is deliberately NOT consulted
* (founder decision 2026-09-01, superseding the earlier byrå exemption):
* byrå consultants stay in through the company's or their team's grant, not
* through a role carve-out.
*/
export function isMembershipDormant(role: string, state: MultiUserState): boolean {
return state === 'frozen' && role !== 'owner'
}
+140
View File
@@ -0,0 +1,140 @@
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)
}
+258
View File
@@ -0,0 +1,258 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { CAPABILITY } from '@/lib/entitlements/keys'
import {
computeMultiUserState,
MULTI_USER_GRACE_DAYS,
type MultiUserGrantRow,
} from '@/lib/entitlements/multi-user-state'
import { isMultiUserEnforced } from '@/lib/entitlements/multi-user'
import { getEmailService } from '@/lib/email/service'
import { getSenderForCompany, getBaseUrlForBrand } from '@/lib/email/brand-sender'
import { getBranding } from '@/lib/branding/service'
import { formatDate } from '@/lib/utils'
import { createLogger } from '@/lib/logger'
const logger = createLogger('notifications/multi-user-grace')
/**
* Multi-user grace reminders: mails company OWNERS when the 20-day grace
* window opens (their extra members will pause) and again on its last day.
*
* Windowing keeps the daily cron idempotent without a sent-log: with the
* newest multi_user expiry E and grace end G = E + 20d,
* "start" mail : E in (now - 24h, now] (the lapse happened since the
* previous daily run)
* "final" mail : G in (now, now + 24h] (the freeze lands before the
* next daily run)
* The two windows can only both hit for a sub-24h grace, which the 20-day
* constant rules out. Companies with no non-owner members are skipped:
* single-person companies must never hear about the seat gate.
*
* The launch cohort's mail is sent by hand (founder decision 2026-09-01);
* this cron owns every FUTURE lapse: trial ends, subscription cancellations.
*/
const DAY_MS = 86_400_000
export interface MultiUserGraceSummary {
companiesInGrace: number
startMails: number
finalMails: number
errors: number
skipped?: 'not_enforced' | 'email_not_configured'
}
interface GrantRow {
company_id: string | null
team_id: string | null
expires_at: string | null
metadata: Record<string, unknown> | null
}
interface TrackedGrant extends MultiUserGrantRow {
metadata: Record<string, unknown> | null
}
export async function runMultiUserGraceReminders(
supabase: SupabaseClient,
now: Date,
): Promise<MultiUserGraceSummary> {
const summary: MultiUserGraceSummary = {
companiesInGrace: 0,
startMails: 0,
finalMails: 0,
errors: 0,
}
if (!isMultiUserEnforced()) {
summary.skipped = 'not_enforced'
return summary
}
const emailService = getEmailService()
if (!emailService.isConfigured()) {
summary.skipped = 'email_not_configured'
return summary
}
// All multi_user grants in one read: the key has at most a handful of rows
// per company, and the whole-table scan on one capability_key is what the
// idx_capability_grants_key index exists for.
const { data: grantRows, error: grantsError } = await supabase
.from('capability_grants')
.select('company_id, team_id, expires_at, metadata')
.eq('capability_key', CAPABILITY.multi_user)
if (grantsError) {
throw new Error(`multi-user grace: grants read failed: ${grantsError.message}`)
}
const grants = (grantRows ?? []) as GrantRow[]
const companyGrants = new Map<string, TrackedGrant[]>()
const teamGrants = new Map<string, TrackedGrant[]>()
for (const g of grants) {
if (g.company_id) {
const list = companyGrants.get(g.company_id) ?? []
list.push({ expires_at: g.expires_at, metadata: g.metadata })
companyGrants.set(g.company_id, list)
}
if (g.team_id) {
const list = teamGrants.get(g.team_id) ?? []
list.push({ expires_at: g.expires_at, metadata: g.metadata })
teamGrants.set(g.team_id, list)
}
}
if (companyGrants.size === 0 && teamGrants.size === 0) return summary
// Candidate companies: company-scoped grant holders PLUS every company of
// a team that holds grants (a byrå whose team agreement lapses has client
// companies with zero company-scoped rows, and their owners must be mailed
// like anyone else's).
const companies = new Map<string, { id: string; name: string; team_id: string | null }>()
const companyIds = [...companyGrants.keys()]
for (let i = 0; i < companyIds.length; i += 200) {
const chunk = companyIds.slice(i, i + 200)
const { data, error } = await supabase
.from('companies')
.select('id, name, team_id, archived_at')
.in('id', chunk)
.is('archived_at', null)
if (error) throw new Error(`multi-user grace: companies read failed: ${error.message}`)
for (const c of (data ?? []) as { id: string; name: string; team_id: string | null }[]) {
companies.set(c.id, c)
}
}
const teamIds = [...teamGrants.keys()]
for (let i = 0; i < teamIds.length; i += 200) {
const chunk = teamIds.slice(i, i + 200)
const { data, error } = await supabase
.from('companies')
.select('id, name, team_id, archived_at')
.in('team_id', chunk)
.is('archived_at', null)
if (error) throw new Error(`multi-user grace: team companies read failed: ${error.message}`)
for (const c of (data ?? []) as { id: string; name: string; team_id: string | null }[]) {
companies.set(c.id, c)
}
}
const nowMs = now.getTime()
for (const company of companies.values()) {
const rows = [
...(companyGrants.get(company.id) ?? []),
...(company.team_id ? (teamGrants.get(company.team_id) ?? []) : []),
]
const access = computeMultiUserState(rows, nowMs)
if (access.state !== 'grace' || !access.graceEndsAt) continue
summary.companiesInGrace += 1
const graceEndMs = new Date(access.graceEndsAt).getTime()
const lapseMs = graceEndMs - MULTI_USER_GRACE_DAYS * DAY_MS
// The launch cohort's start mail is sent by hand (founder decision
// 2026-09-01): the grandfather backfill row expires at deploy time, so
// without this check the first cron run after deploy would re-mail the
// whole cohort. The day-19 final reminder still goes out.
const anchorIsGrandfather = rows.some(
(r) =>
r.expires_at !== null &&
new Date(r.expires_at).getTime() === lapseMs &&
(r.metadata as { reason?: string } | null)?.reason === 'multi_user_grandfather',
)
const isStart = !anchorIsGrandfather && lapseMs > nowMs - DAY_MS && lapseMs <= nowMs
const isFinal = graceEndMs > nowMs && graceEndMs <= nowMs + DAY_MS
if (!isStart && !isFinal) continue
try {
const sent = await sendGraceMail(supabase, {
companyId: company.id,
companyName: company.name,
kind: isFinal ? 'final' : 'start',
graceEndsAt: access.graceEndsAt,
})
if (sent === 0) continue
if (isFinal) summary.finalMails += sent
else summary.startMails += sent
} catch (err) {
summary.errors += 1
logger.error('multi-user grace mail failed', err as Error, { companyId: company.id })
}
}
return summary
}
async function sendGraceMail(
supabase: SupabaseClient,
args: { companyId: string; companyName: string; kind: 'start' | 'final'; graceEndsAt: string },
): Promise<number> {
// Sandbox/demo companies have no billing: never mail them.
const { data: settings } = await supabase
.from('company_settings')
.select('is_sandbox, company_name')
.eq('company_id', args.companyId)
.maybeSingle()
if ((settings as { is_sandbox?: boolean } | null)?.is_sandbox === true) return 0
const companyName =
(settings as { company_name?: string | null } | null)?.company_name || args.companyName
const { data: members } = await supabase
.from('company_members')
.select('user_id, role')
.eq('company_id', args.companyId)
const memberRows = (members ?? []) as { user_id: string; role: string }[]
const affected = memberRows.filter((m) => m.role !== 'owner')
// Single-person companies never hear about the seat gate.
if (affected.length === 0) return 0
const owners = memberRows.filter((m) => m.role === 'owner')
if (owners.length === 0) return 0
const { data: profiles } = await supabase
.from('profiles')
.select('id, email')
.in('id', memberRows.map((m) => m.user_id))
const emailById = new Map(
((profiles ?? []) as { id: string; email: string | null }[]).map((p) => [p.id, p.email]),
)
const ownerEmails = owners.map((o) => emailById.get(o.user_id)).filter((e): e is string => !!e)
const affectedEmails = affected
.map((a) => emailById.get(a.user_id))
.filter((e): e is string => !!e)
if (ownerEmails.length === 0) return 0
const sender = await getSenderForCompany(args.companyId)
const appUrl = sender.brand ? getBaseUrlForBrand(sender.brand) : getBranding().appUrl
const billingUrl = `${appUrl}/settings/billing`
const freezeDate = formatDate(args.graceEndsAt)
const affectedList = affectedEmails.join(', ')
const subject =
args.kind === 'final'
? `Imorgon pausas fler användare i ${companyName}`
: `Fler användare i ${companyName} kräver betald plan`
const intro =
args.kind === 'final'
? `Imorgon (${freezeDate}) pausas följande konton från ${companyName}: ${affectedList}.`
: `Från och med den ${freezeDate} ingår flera användare endast i den betalda planen. Då pausas följande konton från ${companyName}: ${affectedList}.`
const outro =
'Ingen data försvinner och inga användare tas bort. Uppgraderar ni, nu eller senare, ' +
'får alla tillbaka sin åtkomst direkt.'
const body = `Hej!\n\n${intro}\n\n${outro}\n\nUppgradera här: ${billingUrl}\n`
const html =
`<p>Hej!</p><p>${intro}</p><p>${outro}</p>` +
`<p><a href="${billingUrl}">Uppgradera till betald plan</a></p>`
const emailService = getEmailService()
let sent = 0
for (const to of ownerEmails) {
const result = await emailService.sendEmail({
to,
subject,
html,
text: body,
fromName: sender.fromName ?? undefined,
fromAddress: sender.fromAddress ?? undefined,
replyTo: sender.replyTo ?? undefined,
})
if (result.success) sent += 1
else logger.warn('multi-user grace mail send failed', { companyId: args.companyId })
}
return sent
}
+32 -7
View File
@@ -13,7 +13,7 @@ afterEach(() => vi.unstubAllEnvs())
// can assert what applySubscriptionState wrote, without a real DB.
interface RecordedOp {
table: string
op: 'upsert' | 'delete' | null
op: 'upsert' | 'delete' | 'update' | null
payload: unknown
conflict: string | undefined
filters: Array<[string, unknown]>
@@ -36,10 +36,24 @@ function recordingSupabase() {
calls.push(ctx)
return chain
},
update(payload: unknown) {
ctx.op = 'update'
ctx.payload = payload
calls.push(ctx)
return chain
},
eq(col: string, val: unknown) {
ctx.filters.push([col, val])
return chain
},
neq(col: string, val: unknown) {
ctx.filters.push([`neq:${col}`, val])
return chain
},
or(filter: string) {
ctx.filters.push(['or', filter])
return chain
},
then(resolve: (v: { data: null; error: null }) => void) {
resolve({ data: null, error: null })
},
@@ -115,11 +129,11 @@ describe('applySubscriptionState', () => {
const grantUpsert = calls.find((c) => c.table === 'capability_grants')
expect(grantUpsert?.op).toBe('upsert')
const rows = grantUpsert?.payload as Array<{ capability_key: string; source: string }>
expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'shopify_sync', 'skatteverket', 'stripe_payments', 'woocommerce_sync'])
expect(rows.map((r) => r.capability_key).sort()).toEqual(['ai', 'bank_sync', 'email_send', 'multi_user', 'shopify_sync', 'skatteverket', 'stripe_payments', 'woocommerce_sync'])
expect(rows.every((r) => r.source === 'stripe')).toBe(true)
})
it('removes only the stripe grants when canceled (freeze-and-retain)', async () => {
it('removes the stripe grants when canceled but EXPIRES multi_user (grace anchor)', async () => {
const { supabase, calls } = recordingSupabase()
await applySubscriptionState(supabase, {
companyId: 'co_1',
@@ -129,9 +143,20 @@ describe('applySubscriptionState', () => {
plan: null,
currentPeriodEnd: null,
})
const grantOp = calls.find((c) => c.table === 'capability_grants')
expect(grantOp?.op).toBe('delete')
expect(grantOp?.filters).toContainEqual(['company_id', 'co_1'])
expect(grantOp?.filters).toContainEqual(['source', 'stripe'])
const grantOps = calls.filter((c) => c.table === 'capability_grants')
// Freeze-and-retain deletes every external-service grant, except
// multi_user, whose 20-day grace window hangs on an EXPIRED row: a
// deleted row would freeze the churned payer's staff instantly.
const deleteOp = grantOps.find((c) => c.op === 'delete')
expect(deleteOp?.filters).toContainEqual(['company_id', 'co_1'])
expect(deleteOp?.filters).toContainEqual(['source', 'stripe'])
expect(deleteOp?.filters).toContainEqual(['neq:capability_key', 'multi_user'])
const updateOp = grantOps.find((c) => c.op === 'update')
expect(updateOp?.filters).toContainEqual(['capability_key', 'multi_user'])
const payload = updateOp?.payload as { expires_at: string }
expect(new Date(payload.expires_at).getTime()).toBeLessThanOrEqual(Date.now())
// Only a still-active row is expired: a re-delivered cancel event must
// not slide the grace anchor forward (the .or filter scopes the update).
expect(updateOp?.filters.some(([col]) => col === 'or')).toBe(true)
})
})
+18 -1
View File
@@ -1,6 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type Stripe from 'stripe'
import { PAID_CAPABILITIES } from '@/lib/entitlements/keys'
import { CAPABILITY, PAID_CAPABILITIES } from '@/lib/entitlements/keys'
import type { BillingPlan } from './client'
/**
@@ -104,11 +104,28 @@ export async function applySubscriptionState(
} else {
// Freeze-and-retain: drop only the stripe grants. Trial/comp grants (if any)
// are untouched; data and tokens are never deleted.
//
// multi_user is the one exception to the delete: its 20-day grace window
// hangs on "the newest grant EXPIRED less than 20 days ago"
// (lib/entitlements/multi-user-state.ts), so a deleted row would freeze a
// churned payer's non-owner members instantly, with no countdown banner
// and no owner mail. Expiring the row NOW anchors the grace exactly at
// the cancellation, and the grace cron's start-mail window picks it up.
// Only a still-active row is expired: a re-delivered cancel event days
// later must not slide the grace anchor (and the mail window) forward.
await supabase
.from('capability_grants')
.delete()
.eq('company_id', state.companyId)
.eq('source', 'stripe')
.neq('capability_key', CAPABILITY.multi_user)
await supabase
.from('capability_grants')
.update({ expires_at: new Date().toISOString() })
.eq('company_id', state.companyId)
.eq('source', 'stripe')
.eq('capability_key', CAPABILITY.multi_user)
.or(`expires_at.is.null,expires_at.gt.${new Date().toISOString()}`)
}
}
+48 -14
View File
@@ -11,6 +11,8 @@ import {
type ProxyTimings,
} from '@/lib/supabase/proxy-timing'
import { shouldEnforceMfa } from '@/lib/auth/mfa'
import { isMultiUserEnforced } from '@/lib/entitlements/multi-user'
import { MULTI_USER_GRACE_DAYS } from '@/lib/entitlements/multi-user-state'
import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from '@/i18n/config'
import { userHasPassword } from '@/lib/auth/has-password'
@@ -435,6 +437,7 @@ async function updateSessionInner(
companyId: string | null
locale: string | null
degraded: boolean
allLocked: boolean
} | null = null
const resolveCompanyOnce = async () =>
(resolvedCompany ??= await timed(timing, 'companyMs', () =>
@@ -485,7 +488,7 @@ async function updateSessionInner(
// Company context resolution
const cookieCompanyId = request.cookies.get('gnubok-company-id')?.value
const { companyId, locale: dbLocale, degraded } = await resolveCompanyOnce()
const { companyId, locale: dbLocale, degraded, allLocked } = await resolveCompanyOnce()
// If the cookie pointed at a company we can no longer resolve (e.g.
// archived), clear it so the browser stops sending it. Never on degraded
@@ -516,7 +519,10 @@ async function updateSessionInner(
pathname.startsWith('/select-company') ||
pathname.startsWith('/settings/account') ||
pathname.startsWith('/api/account/') ||
pathname.startsWith('/api/company')
pathname.startsWith('/api/company') ||
// Multi-user seat gate: the paused page IS the destination for a user
// whose every membership is frozen, so it must render in that state.
pathname.startsWith('/paused')
// No companies: redirect to the picker if we have BankID enrichment for
// this user, otherwise the manual wizard. Either way, allow the escape-hatch
@@ -565,6 +571,18 @@ async function updateSessionInner(
return NextResponse.redirect(new URL('/byra', request.url))
}
// Multi-user seat gate: memberships exist but every one is frozen for
// this (non-owner) user. This is NOT the no-company state: sending them
// to onboarding would walk a locked-out colleague into creating a
// pointless company. The paused page explains and names the companies.
// API requests pass through so routes answer JSON, not an HTML redirect.
if (allLocked) {
if (pathname.startsWith('/api/')) {
return supabaseResponse
}
return NextResponse.redirect(new URL('/paused', request.url))
}
// Enrichment lives in the user-keyed `bankid_enrichment` table (migration
// 20260506160000), it cannot live in extension_data, which is
// company-scoped, and the user has no company yet on this path.
@@ -965,19 +983,32 @@ async function resolveCompanyForMiddleware(
supabase: ReturnType<typeof createServerClient>,
userId: string,
_request: NextRequest
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
const { data, error } = await supabase.rpc('resolve_active_company')
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean; allLocked: boolean }> {
// Multi-user seat gate: the gated RPC skips memberships frozen for this
// user (non-owner, multi_user lapsed past its 20-day grace) and reports
// has_locked_membership when NOTHING resolved because of that, which is
// what routes the user to /paused instead of onboarding. Self-hosted and
// dev call the ungated function: the gate never bites 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) {
if (error.code === 'PGRST202') {
// Function not deployed here: use the query path.
// Function not deployed here (self-host not migrated yet, or a deploy
// racing the branch merge): use the ungated query path. The race
// window fails OPEN for the seat gate on purpose: never lock people
// out because a deploy is mid-flight.
return resolveCompanyForMiddlewareViaQueries(supabase, userId, _request)
}
// Issue #1053: a FAILED call degrades (fail open), never reads as "no
// companies". locale null is fine because the degraded flag already
// suppresses the locale-cookie sync at the call site.
console.error('[middleware] resolve_active_company rpc failed', error)
return { companyId: null, locale: null, degraded: true }
return { companyId: null, locale: null, degraded: true, allLocked: false }
}
const row = Array.isArray(data) ? data[0] : data
@@ -985,7 +1016,7 @@ async function resolveCompanyForMiddleware(
// Zero rows = NULL auth.uid(); impossible for the cookie-auth middleware
// client, so treat as degraded rather than redirecting to onboarding.
console.error('[middleware] resolve_active_company returned no row for authenticated user')
return { companyId: null, locale: null, degraded: true }
return { companyId: null, locale: null, degraded: true, allLocked: false }
}
if (row.company_id && row.used_fallback) {
@@ -1008,6 +1039,9 @@ async function resolveCompanyForMiddleware(
companyId: row.company_id ?? null,
locale: row.locale ?? null,
degraded: false,
// Only the gated RPC carries the column; the ungated one leaves it
// undefined, which correctly reads as false.
allLocked: row.has_locked_membership === true,
}
}
@@ -1020,7 +1054,7 @@ async function resolveCompanyForMiddlewareViaQueries(
supabase: ReturnType<typeof createServerClient>,
userId: string,
_request: NextRequest
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean }> {
): Promise<{ companyId: string | null; locale: string | null; degraded: boolean; allLocked: boolean }> {
// 1. 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
@@ -1055,12 +1089,12 @@ async function resolveCompanyForMiddlewareViaQueries(
'[middleware] company resolution query failed',
prefsRes.error ?? firstRes.error
)
return { companyId: null, locale, degraded: true }
return { companyId: null, locale, degraded: true, allLocked: false }
}
if (prefs?.active_company_id) {
if (prefs.active_company_id === firstCompany?.company_id) {
return { companyId: firstCompany.company_id, locale, degraded: false }
return { companyId: firstCompany.company_id, locale, degraded: false, allLocked: false }
}
const { data: membership, error: membershipError } = await supabase
@@ -1075,14 +1109,14 @@ async function resolveCompanyForMiddlewareViaQueries(
// first membership (wrong company for consultants): degrade instead.
if (membershipError) {
console.error('[middleware] company preference validation failed', membershipError)
return { companyId: null, locale, degraded: true }
return { companyId: null, locale, degraded: true, allLocked: false }
}
if (membership) return { companyId: membership.company_id, locale, degraded: false }
if (membership) return { companyId: membership.company_id, locale, degraded: false, allLocked: false }
}
// 2. Fallback: first non-archived membership (already fetched above)
if (!firstCompany) return { companyId: null, locale, degraded: false }
if (!firstCompany) return { companyId: null, locale, degraded: false, allLocked: false }
// Write the fallback back to user_preferences so future RLS lookups
// see the same active company without needing this fallback scan.
@@ -1100,5 +1134,5 @@ async function resolveCompanyForMiddlewareViaQueries(
console.error('[middleware] active company write-back failed', writeBackError)
}
return { companyId: firstCompany.company_id, locale, degraded: false }
return { companyId: firstCompany.company_id, locale, degraded: false, allLocked: false }
}