f266c386f3
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
139 lines
5.4 KiB
TypeScript
139 lines
5.4 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'
|
|
import { UUID_RE } from '@/lib/invariants/uuid'
|
|
|
|
/**
|
|
* 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)
|
|
}
|