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
+23 -8
View File
@@ -25,6 +25,8 @@ import {
type LucideIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { requiredCapabilityForExtension } from '@/lib/entitlements/keys'
type Entry = {
id: string
@@ -79,6 +81,19 @@ export default function CommandPalette() {
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const { capabilities } = useCompany()
// Drop entries that jump to a paywalled extension workspace the active
// company can't reach (e.g. the AI-only Dokumentinkorg). The page itself is
// gated server-side; this keeps ⌘K from offering a dead destination.
const allowedByCapability = useMemo(() => {
return (entry: Entry) => {
const m = entry.href.match(/^\/e\/([^/]+)\/([^/?#]+)/)
if (!m) return true
const required = requiredCapabilityForExtension(m[1], m[2])
return !required || capabilities.includes(required)
}
}, [capabilities])
function handleOpenChange(next: boolean) {
setOpen(next)
@@ -108,14 +123,14 @@ export default function CommandPalette() {
const q = query.trim().toLowerCase()
const filteredActions = useMemo(
() => (q ? ACTION_ENTRIES.filter(e => matches(e, q)) : ACTION_ENTRIES),
[q],
)
const filteredPages = useMemo(
() => (q ? PAGE_ENTRIES.filter(e => matches(e, q)) : PAGE_ENTRIES.slice(0, 6)),
[q],
)
const filteredActions = useMemo(() => {
const visible = ACTION_ENTRIES.filter(allowedByCapability)
return q ? visible.filter(e => matches(e, q)) : visible
}, [q, allowedByCapability])
const filteredPages = useMemo(() => {
const visible = PAGE_ENTRIES.filter(allowedByCapability)
return q ? visible.filter(e => matches(e, q)) : visible.slice(0, 6)
}, [q, allowedByCapability])
const annaFallback: Entry | null = q && filteredActions.length === 0 && filteredPages.length === 0
? {