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:
Jakob Wennberg
2026-07-07 23:20:39 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a566a42aec
commit 19cbb0094b
20 changed files with 480 additions and 25 deletions
@@ -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 } },
+8 -1
View File
@@ -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'
)
+24
View File
@@ -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"
+13
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export * from './types'
export * from './categories'
export * from './aggregate'
export * from './visible-total'
+39
View File
@@ -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,
})
}