import { Seam } from '../seam.js' import { type ActorContext } from '../db/actor.js' /** * The entitlement assertion helper. Required by #20 decision 3. * * Entitlement is resolved once into actor context and asserted per feature at * each feature's own entry — not as row visibility, and not at one chokepoint. * Writing a document is free; writing it FOR REUSE is paid. The same INSERT * serves both, so an operation deny-list would have to encode intent it * cannot see. * * ── THE GOVERNING PRINCIPLE ───────────────────────────────────────────── * * Entitlement gates what a vendor may newly undertake, * never what a retailer has already come to rely on. * * Restated by #34 when the free tier was removed. Concretely: expiry state is * computed for every vendor regardless of billing; eligibility never depends * on subscription state; and on lapse, live participations run to completion. * ──────────────────────────────────────────────────────────────────────── * * Useful side effect: the call sites of `require` ARE the paid-feature list. * The pricing page is derived from the code rather than maintained alongside it. */ export type Feature = | 'ai.draft' | 'ai.matching' | 'ai.go_no_go' | 'ai.response_completeness' | 'integration.api' | 'sso' export const FEATURE_TIER: Record = { 'ai.draft': 'pro', 'ai.matching': 'pro', 'ai.go_no_go': 'pro', 'ai.response_completeness': 'pro', 'integration.api': 'enterprise', sso: 'enterprise', } export interface Entitled extends ActorContext { tier: 'base' | 'pro' | 'enterprise' lapsed: boolean } export class NotEntitled extends Error { constructor(readonly feature: Feature, readonly required: string) { super(`${feature} requires ${required}`) this.name = 'NotEntitled' } } /** * Assert at a feature entry. Throwing is the API — a missing entitlement is * never a silent empty result, because that is what RLS denial looks like and * the two must stay distinguishable. */ export function require(ctx: Entitled, feature: Feature): void { throw new Seam('#35', `entitlement resolution for ${feature} (${ctx.tier})`) } /** * Visible-but-locked is the DEFAULT rendering for a missing entitlement. * Absent is a bug — Pro has no upgrade moment if the affordance is invisible * (#34, carried into #23). */ export function affordance(ctx: Entitled, feature: Feature): 'enabled' | 'locked' { return ctx.tier === FEATURE_TIER[feature] || ctx.tier === 'enterprise' ? 'enabled' : 'locked' }