fix(entitlements): gate the AI-only invoice-inbox for non-payers (#924)
The Dokumentinkorg (invoice-inbox) leaked past the paywall: visible in the sidebar, command palette, and home "Att gora" list, its page directly reachable, and every non-AI HTTP route open. Its whole value is AI field extraction (Claude Sonnet 4.6 via Bedrock), already the paid chokepoint elsewhere, so gate the whole surface on CAPABILITY.ai. - EXTENSION_REQUIRED_CAPABILITY map + resolvers (keys.ts, sectors.ts) as the single source the nav item, the page, and the API dispatcher all read. - Hide the sidebar item, command-palette entry, and home inbox row for non-payers; subtract inbox_document from the "Att gora" total via one shared visibleWorklistTotal helper (KPI tile + header cannot drift), clamped to >= 0. - Block the /e/[sector]/[slug] page (fail-closed) with an upsell EmptyState. - Enforce the capability in the extension API dispatcher (the single chokepoint that already enforces MFA), so every company-context inbox route 403s. The skipAuth /inbound webhook stays open (freeze-and-retain). - FORCE_PAYWALL=true override so the real gate is exercisable in local dev. - Tests: gating resolver, FORCE_PAYWALL, dispatcher 403/allow/webhook-exempt, visibleWorklistTotal, and enable-banking /connect + /sync 403. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a566a42aec
commit
19cbb0094b
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
CAPABILITY,
|
||||
EXTENSION_REQUIRED_CAPABILITY,
|
||||
requiredCapabilityForExtension,
|
||||
} from '../keys'
|
||||
|
||||
describe('requiredCapabilityForExtension', () => {
|
||||
it('gates the invoice-inbox workspace on the AI capability', () => {
|
||||
// The inbox exists to run AI field extraction; the sidebar item and the
|
||||
// /e/[sector]/[slug] page both read this to hide/block for non-payers.
|
||||
expect(requiredCapabilityForExtension('general', 'invoice-inbox')).toBe(CAPABILITY.ai)
|
||||
})
|
||||
|
||||
it('returns undefined for extensions that stay open', () => {
|
||||
expect(requiredCapabilityForExtension('general', 'enable-banking')).toBeUndefined()
|
||||
expect(requiredCapabilityForExtension('general', 'tic')).toBeUndefined()
|
||||
expect(requiredCapabilityForExtension('general', 'does-not-exist')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keys the map by `${sector}/${slug}` so page and nav resolve identically', () => {
|
||||
expect(EXTENSION_REQUIRED_CAPABILITY['general/invoice-inbox']).toBe(CAPABILITY.ai)
|
||||
})
|
||||
})
|
||||
@@ -48,6 +48,30 @@ describe('hasCapability', () => {
|
||||
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(true)
|
||||
})
|
||||
|
||||
it('development bypasses the gate (all-on) so gated features are testable without a subscription', async () => {
|
||||
// This is WHY a lapsed company still sees paid surfaces under `npm run dev`.
|
||||
vi.stubEnv('NODE_ENV', 'development')
|
||||
const supabase = makeSupabase({}) // no grant: would be false if the gate ran
|
||||
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(true)
|
||||
})
|
||||
|
||||
it('FORCE_PAYWALL=true activates the real gate in development (fail-closed on an expired grant)', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development') // would otherwise bypass
|
||||
vi.stubEnv('FORCE_PAYWALL', 'true')
|
||||
const supabase = makeSupabase({
|
||||
companies: { data: { team_id: null } },
|
||||
capability_grants: { data: [{ expires_at: iso(-60_000) }] }, // expired
|
||||
})
|
||||
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(false)
|
||||
})
|
||||
|
||||
it('FORCE_PAYWALL never overrides self-hosted (stays all-on)', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
||||
vi.stubEnv('FORCE_PAYWALL', 'true')
|
||||
const supabase = makeSupabase({}) // would resolve null/false if queried
|
||||
expect(await hasCapability(supabase, '11111111-1111-4111-8111-111111111111', CAPABILITY.ai)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for an unexpired company-scoped grant', async () => {
|
||||
const supabase = makeSupabase({
|
||||
companies: { data: { team_id: null } },
|
||||
|
||||
@@ -33,8 +33,15 @@ function isSelfHosted(): boolean {
|
||||
* production build. Never set this in a hosted environment.
|
||||
*/
|
||||
function isPaywallBypassed(): boolean {
|
||||
// Self-hosted is genuinely all-on: never gate it.
|
||||
if (isSelfHosted()) return true
|
||||
// Escape hatch to exercise the REAL gate in local dev, where the paywall is
|
||||
// otherwise all-on so every paid feature is testable without a subscription.
|
||||
// Set FORCE_PAYWALL=true to see the paid/non-paid UX (nav hiding, page upsells)
|
||||
// exactly as a non-payer would. Fail-safe: it can only make gating stricter, so
|
||||
// it is harmless if it ever leaks into a hosted env. Wins over the dev bypass.
|
||||
if (process.env.FORCE_PAYWALL === 'true') return false
|
||||
return (
|
||||
isSelfHosted() ||
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.DISABLE_PAYWALL === 'true'
|
||||
)
|
||||
|
||||
@@ -89,3 +89,27 @@ export const PAID_OPERATION_CAPABILITY_MAP: Readonly<Partial<Record<string, Capa
|
||||
submit_vat_declaration: CAPABILITY.skatteverket,
|
||||
submit_agi: CAPABILITY.skatteverket,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Extension workspace → required capability, keyed by `sector/slug`. This is the
|
||||
* page/nav twin of the API-route gates: an extension whose entire value is a
|
||||
* paid service should not just 403 its writes but be hidden from the sidebar and
|
||||
* blocked at the page so a non-payer never lands on a dead workspace.
|
||||
*
|
||||
* invoice-inbox is fully gated on `ai`: its reason to exist is the AI field
|
||||
* extraction (extractInvoiceFields / gnubok_upload_document), already the paid
|
||||
* chokepoint on every other surface (HTTP upload/attach/retry, the MCP tool).
|
||||
* Both the sidebar item and the /e/[sector]/[slug] page read this map so the two
|
||||
* surfaces can never drift apart.
|
||||
*/
|
||||
export const EXTENSION_REQUIRED_CAPABILITY: Readonly<Partial<Record<string, CapabilityKey>>> = {
|
||||
'general/invoice-inbox': CAPABILITY.ai,
|
||||
} as const
|
||||
|
||||
/** Which paid capability (if any) an extension workspace requires to be usable. */
|
||||
export function requiredCapabilityForExtension(
|
||||
sector: string,
|
||||
slug: string,
|
||||
): CapabilityKey | undefined {
|
||||
return EXTENSION_REQUIRED_CAPABILITY[`${sector}/${slug}`]
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"icon": "Inbox",
|
||||
"dataPattern": "both",
|
||||
"description": "Vidarebefordra leverantörsfakturor till en unik adress: dokumenten landar här med extraherade fält",
|
||||
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum läses av med AI (kräver AI-funktionen). Utan AI lagras dokumentet ändå och fälten fylls i manuellt.",
|
||||
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum läses av med AI. Kräver AI-funktionen i din prenumeration.",
|
||||
"readsCoreTables": [
|
||||
"document_attachments",
|
||||
"suppliers"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Sector, SectorSlug, ExtensionDefinition } from './types'
|
||||
import { EXTENSION_DEFINITIONS } from './_generated/sector-definitions'
|
||||
import { WORKSPACES } from './_generated/workspace-map'
|
||||
import { requiredCapabilityForExtension } from '@/lib/entitlements/keys'
|
||||
import type { CapabilityKey } from '@/lib/entitlements/keys'
|
||||
|
||||
// ============================================================
|
||||
// Sector & Extension Registry
|
||||
@@ -40,6 +42,17 @@ export function getExtensionDefinition(sectorSlug: string, extensionSlug: string
|
||||
return sector?.extensions.find(e => e.slug === extensionSlug)
|
||||
}
|
||||
|
||||
/**
|
||||
* Paid capability an extension requires, resolved by its registry id (== slug).
|
||||
* The API-route dispatcher keys off this so a paid extension is gated at the
|
||||
* request chokepoint, reusing the same EXTENSION_REQUIRED_CAPABILITY map that
|
||||
* hides its sidebar item and blocks its page (lib/entitlements/keys).
|
||||
*/
|
||||
export function requiredCapabilityForExtensionId(extensionId: string): CapabilityKey | undefined {
|
||||
const ext = getAllExtensions().find(e => e.slug === extensionId)
|
||||
return ext ? requiredCapabilityForExtension(ext.sector, ext.slug) : undefined
|
||||
}
|
||||
|
||||
export function getAllExtensions(): ExtensionDefinition[] {
|
||||
return SECTORS.flatMap(s => s.extensions)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { visibleWorklistTotal, visibleWorklistTotalFrom } from '../visible-total'
|
||||
import type { WorklistCounts } from '../types'
|
||||
|
||||
describe('visibleWorklistTotal', () => {
|
||||
it('subtracts inbox documents for non-payers so the count matches the hidden row', () => {
|
||||
expect(visibleWorklistTotal({ total: 5, inboxDocumentCount: 3, hasAi: false })).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps inbox documents for payers (the row is shown)', () => {
|
||||
expect(visibleWorklistTotal({ total: 5, inboxDocumentCount: 3, hasAi: true })).toBe(5)
|
||||
})
|
||||
|
||||
it('adds dashboard-only extras (expiring bank connections)', () => {
|
||||
expect(visibleWorklistTotal({ total: 5, inboxDocumentCount: 3, hasAi: false, extra: 2 })).toBe(4)
|
||||
})
|
||||
|
||||
it('clamps to 0 rather than rendering a negative count on a count skew', () => {
|
||||
expect(visibleWorklistTotal({ total: 1, inboxDocumentCount: 3, hasAi: false })).toBe(0)
|
||||
})
|
||||
|
||||
it('from() reads total + inbox_document off the worklist object', () => {
|
||||
const worklist = { total: 4, counts: { inbox_document: 1 } } as unknown as WorklistCounts
|
||||
expect(visibleWorklistTotalFrom(worklist, false, 1)).toBe(4) // 4 + 1 - 1
|
||||
expect(visibleWorklistTotalFrom(worklist, true, 1)).toBe(5) // 4 + 1 - 0
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './types'
|
||||
export * from './categories'
|
||||
export * from './aggregate'
|
||||
export * from './visible-total'
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { WorklistCounts } from './types'
|
||||
|
||||
/**
|
||||
* The "Att göra" total as the user actually sees it. The raw worklist total
|
||||
* counts inbox_document, but the Dokumentinkorg is a paid (AI) surface hidden
|
||||
* from non-payers, so its documents must not inflate the count either, else the
|
||||
* dashboard tile shows "N att göra" over a section that renders no such row.
|
||||
*
|
||||
* Single source for the KPI tile (DashboardContent) and the section header
|
||||
* (AttGoraSection): both must agree, and a mismatch here was a real bug. `extra`
|
||||
* carries dashboard-only additions (expiring bank connections) that are not a
|
||||
* lib/worklist category.
|
||||
*/
|
||||
export function visibleWorklistTotal(params: {
|
||||
total: number
|
||||
inboxDocumentCount: number
|
||||
hasAi: boolean
|
||||
extra?: number
|
||||
}): number {
|
||||
const { total, inboxDocumentCount, hasAi, extra = 0 } = params
|
||||
// total already includes inbox_document, so the subtraction is >= 0 in normal
|
||||
// operation; clamp anyway so a transient count skew can never render a
|
||||
// nonsense negative "N att gora" on the dashboard tile.
|
||||
return Math.max(0, total + extra - (hasAi ? 0 : inboxDocumentCount))
|
||||
}
|
||||
|
||||
/** Convenience overload taking the whole counts object. */
|
||||
export function visibleWorklistTotalFrom(
|
||||
worklist: WorklistCounts,
|
||||
hasAi: boolean,
|
||||
extra = 0,
|
||||
): number {
|
||||
return visibleWorklistTotal({
|
||||
total: worklist.total,
|
||||
inboxDocumentCount: worklist.counts.inbox_document,
|
||||
hasAi,
|
||||
extra,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user