* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' Sovereign plan WS3 PR3: ships dark, nothing changes for hosted. - lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync, skatteverket, org_lookup, migration (services Accounted operates that a self-hosted instance cannot provide itself) + isConnectorCapability(). Separate from PAID_CAPABILITIES and outside the trial-seed trigger on purpose: a hosted company can never hold a connector grant. - lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key). Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, else the grant lookup). Self-host: local capabilities always on (FORCE_PAYWALL included, as the existing test demands); connector capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the grant lookup where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host: local paid keys + active connector keys, state 'paid' with an active connector grant else 'none' (never the hosted trial copy). - Migration 20260820122000: capability_grants.source CHECK gains 'connector', found through pg_constraint (the CHECK was declared inline and auto-named; Postgres stores IN as = ANY, matched accordingly). pg-real test: connector accepted, unknown source rejected, upsert on the (scope, key, source) identity, trial seed writes no connector rows. - Tests: self-hosted connector matrix (local all-on without DB, connector gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector keys only, bulk resolution, entitlements shape); two pre-existing tests that asserted the old "self-host holds connector keys" contract updated to the new one. Verified: full unit suite green, pg-real suite for lib/entitlements green against a local supabase/postgres with every migration applied, lint ratchet, guards. Deferred to the instance-wiring PR: adding the connector extensions to the self-host Docker preset (dead-end upsells until a key can be issued). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(entitlements): fold the self-host branch into the existing grants query One .or(scopeFilter), not two: the duplicated helper pushed the no-phantom-columns unresolvable-expression count to 380/379. Behaviour is unchanged; the self-host matrix tests still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(entitlements): self-host connector gate honors only source=connector grants The trial-seed trigger (seed_trial_capability_grants) writes 30-day source='trial' rows for bank_sync and skatteverket on every company insert, self-hosts included. The partitioned self-host gate read every active grant, so a fresh self-host company held every connector capability for a month with no connector key (CodeRabbit finding on #1747, verified against migration 20260818170000). hasCapability and getCompanyIdsWithCapability now add .eq('source', 'connector') on a self-host; getCompanyEntitlements skips non-connector rows there. Hosted reads every source exactly as before (covered by a test asserting no source filter is applied). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(entitlements): pin the early-grants wave on the self-host partition, fix stale docstrings Address the review round on the readGrants() merge resolution: - Add two recording-mock tests for getCompanyEntitlements with teamId (the dashboard layout path): on a self-host the early grants read must narrow to the connector keys and carry source = 'connector'; on hosted it reads every paid key with no source filter. The in-loop source check masked a lost narrowing, so this pins the query itself. - Move the getCompanyEntitlements docstring back above the function and replace "Self-hosted holds everything" with the actual partition. - keys.ts: say that lib/connect/instance arrives with stack PR #1748 so a reader on main does not chase a module that is not there yet. - DECISIONS.md: describe the InvoiceInboxWorkspace.tsx merge resolution accurately (set to the #1753 blob origin/main carried at push time, not the merged parent's) and note that stack children must merge this branch forward. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NdxH4DGnggvqHBCmif6B16 * chore(connector): merge origin/main, re-version connector migration to 20260831170000 Migration 20260820122000 predates versions already applied to prod (latest 20260831150000); renamed to keep Supabase branching history monotonic. Test reference updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(entitlements): own-credentials seam so self-hosts running their own EB/SKV are never connector-gated Forward-ports the connector-mode seam's own-credentials half from the instance-wiring layer: a self-host with its own Enable Banking or Skatteverket credentials holds that capability outright, exactly like every other local capability. Without this, upgrading an own-credentials self-host silently killed working bank sync and SKV integrations and showed a hosted subscription upsell whose remedy does not exist for a self-host. capability_blocked copy gains a self-host variant naming GNUBOK_CONNECTOR_KEY; stale all-on comments updated at the two gate call sites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(entitlements): route the pending-op capability block through capabilityBlockedError for self-host copy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(entitlements): PR #1747 review batch: connector-grant expiry CHECK, test cleanup, DECISIONS correction - Migration 20260831180000: CHECK (source <> 'connector' OR expires_at IS NOT NULL); connector grants are a short-lived offline cache, a NULL expiry would be a permanent unlock nothing revokes. pg test added. - beforeEach vi.clearAllMocks() in the three entitlements test files. - DECISIONS entry corrected: the trial seed DOES write trial rows for bank_sync/skatteverket; what it never writes is source='connector' or the connector-only keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com>
166 lines
8.0 KiB
TypeScript
166 lines
8.0 KiB
TypeScript
/**
|
|
* Capability keys: the single namespace behind the SaaS paywall AND the
|
|
* per-tenant modularity / marketplace vision. Each key names one gateable
|
|
* feature; a company "has" it when an unexpired capability_grant exists
|
|
* (entitlement) and it isn't explicitly disabled (enablement).
|
|
*
|
|
* These keys are a STABLE CONTRACT: grant rows, the future marketplace catalog,
|
|
* and per-tenant module toggles all reference them. Add keys; never rename one.
|
|
*/
|
|
export const CAPABILITY = {
|
|
/** AI assistant chat, onboarding composer, and document field extraction (Anthropic/Bedrock). */
|
|
ai: 'ai',
|
|
/** Bank sync / PSD2 (Enable Banking). Freeze-and-retain: tokens are NOT revoked on downgrade. */
|
|
bank_sync: 'bank_sync',
|
|
/** Skatteverket filing/sync (VAT, AGI, skattekonto) via BankID. */
|
|
skatteverket: 'skatteverket',
|
|
/** Outbound transactional email: invoices, reminders, payslips (Resend). Auth/account email is never gated. */
|
|
email_send: 'email_send',
|
|
/** Org-number lookup / enrichment (TIC). NOT gated: identity/lookup is always free. */
|
|
org_lookup: 'org_lookup',
|
|
/** EU VAT-number validation (VIES). NOT gated: identity/lookup is always free. */
|
|
vat_validation: 'vat_validation',
|
|
/** Riksbanken FX auto-fetch. NOT gated at launch (kept free); manual rate entry is always allowed. */
|
|
currency_rates: 'currency_rates',
|
|
/** Cloud backup to Google Drive. NOT gated at launch (kept free: never hold a customer's data hostage). */
|
|
cloud_backup: 'cloud_backup',
|
|
/** Migration import from other systems (Fortnox/Visma/Bokio/BL/Briox). Kept open so new payers can migrate IN. */
|
|
migration: 'migration',
|
|
/** Bolagsverket iXBRL årsredovisning filing. Reserved (extension not yet enabled). */
|
|
bolagsverket: 'bolagsverket',
|
|
/** Stripe Connect: auto payment links on invoices + payment/payout sync. */
|
|
stripe_payments: 'stripe_payments',
|
|
/** WooCommerce store sync: orders/refunds imported as a transaction feed. */
|
|
woocommerce_sync: 'woocommerce_sync',
|
|
/** Shopify store sync: orders/refunds imported as a transaction feed. */
|
|
shopify_sync: 'shopify_sync',
|
|
/**
|
|
* Invoice email from the company's own verified sending domain (Resend
|
|
* domain per company). Opt-in: granted manually per company, NOT part of
|
|
* PAID_CAPABILITIES, so it is never trial-seeded or written by the Stripe
|
|
* subscription sync. Without the grant the settings section is hidden and
|
|
* mail keeps leaving from the platform sender.
|
|
*/
|
|
custom_sender_domain: 'custom_sender_domain',
|
|
} as const
|
|
|
|
export type CapabilityKey = (typeof CAPABILITY)[keyof typeof CAPABILITY]
|
|
|
|
/**
|
|
* The set actually withheld from non-payers (manual tier) at the 2026-07-07
|
|
* cutover. Founder decision (2026-06-28): gate the high-value recurring external
|
|
* services only.
|
|
*
|
|
* KEPT FREE on purpose:
|
|
* - identity & lookup: TIC org_lookup, VIES vat_validation, BankID login:
|
|
* they aid onboarding/data quality; gating them is friction in the wrong place.
|
|
* - currency_rates (FX auto-fetch) and cloud_backup.
|
|
* Internal bookkeeping is always fully usable on the manual tier.
|
|
*
|
|
* NOTE: bank_sync and skatteverket stay PAID even though their flows use BankID
|
|
* as an auth step: what's charged for is the bank data sync and the VAT/AGI
|
|
* filing service, not the identity check.
|
|
*/
|
|
export const PAID_CAPABILITIES: readonly CapabilityKey[] = [
|
|
CAPABILITY.ai,
|
|
CAPABILITY.bank_sync,
|
|
CAPABILITY.skatteverket,
|
|
CAPABILITY.email_send,
|
|
CAPABILITY.stripe_payments,
|
|
CAPABILITY.woocommerce_sync,
|
|
CAPABILITY.shopify_sync,
|
|
] as const
|
|
|
|
/**
|
|
* Capabilities that a SELF-HOSTED instance cannot provide on its own because
|
|
* they run on services Accounted operates (the PSD2/AISP bank connection,
|
|
* the Skatteverket API client, the TIC lookup contract, the migration
|
|
* gateway). On hosted these follow the normal paywall (bank_sync and
|
|
* skatteverket are in PAID_CAPABILITIES; org_lookup and migration are free).
|
|
* On a self-host every other capability is always on, and exactly these fall
|
|
* through to the grant lookup: the hourly connector sync writes
|
|
* `source = 'connector'` grants for them from the instance's connector key
|
|
* (lib/connect/instance, arriving with the connector-keys stack PR #1748).
|
|
* A self-host serving an upstream from its OWN credentials holds that
|
|
* capability outright (see own-credentials.ts): only keyless-and-credential-
|
|
* less connector capabilities are withheld. Deliberately NOT part of
|
|
* PAID_CAPABILITIES and NOT seeded by the trial trigger: hosted companies
|
|
* never receive connector grants.
|
|
*/
|
|
export const CONNECTOR_CAPABILITIES: readonly CapabilityKey[] = [
|
|
CAPABILITY.bank_sync,
|
|
CAPABILITY.skatteverket,
|
|
CAPABILITY.org_lookup,
|
|
CAPABILITY.migration,
|
|
] as const
|
|
|
|
export function isConnectorCapability(key: CapabilityKey): boolean {
|
|
return (CONNECTOR_CAPABILITIES as readonly string[]).includes(key)
|
|
}
|
|
|
|
/**
|
|
* Paid MCP tools → required capability. The MCP/agent path is a paid chokepoint
|
|
* just like the HTTP routes, so the dispatcher gates these the same way it gates
|
|
* API-key scope (see mcp-server `tools/call`). External-service WRITE tools
|
|
* appear here: send_invoice (email) and the two Skatteverket submissions. The
|
|
* read/local SKV tools (generate_agi, vat_declaration_validate/status, agi_status)
|
|
* stay free: the §4 carve-out forbids blocking a statutory filing obligation.
|
|
*
|
|
* The document upload tools invoke AI (Bedrock document OCR via
|
|
* extractInvoiceFields), so they are gated on CAPABILITY.ai: the same paywall
|
|
* the HTTP inbox upload/attach/retry paths enforce. Without these entries a
|
|
* free-tier API key could trigger paid AI extraction. bank_sync gates only
|
|
* gnubok_connect_bank (the onboarding connect link); the sync itself is
|
|
* cron/HTTP only.
|
|
*/
|
|
export const MCP_TOOL_CAPABILITY_MAP: Readonly<Partial<Record<string, CapabilityKey>>> = {
|
|
gnubok_send_invoice: CAPABILITY.email_send,
|
|
gnubok_vat_declaration_submit: CAPABILITY.skatteverket,
|
|
gnubok_agi_submit: CAPABILITY.skatteverket,
|
|
// Onboarding connect-link tools (issue #1814): gated like the links' targets.
|
|
gnubok_connect_bank: CAPABILITY.bank_sync,
|
|
gnubok_connect_skatteverket: CAPABILITY.skatteverket,
|
|
// AI document OCR (Bedrock): the inbox's paid extraction, reachable via MCP.
|
|
gnubok_create_document_upload: CAPABILITY.ai,
|
|
gnubok_complete_document_upload: CAPABILITY.ai,
|
|
gnubok_upload_document: CAPABILITY.ai,
|
|
} as const
|
|
|
|
/**
|
|
* Paid pending-operation types → required capability. Keyed by
|
|
* `pending_operations.operation_type`. This is the commit-time twin of
|
|
* MCP_TOOL_CAPABILITY_MAP: it gates the actual external-service call inside
|
|
* commitPendingOperation, so an operation staged during the trial cannot be
|
|
* committed once the grant has expired, regardless of caller (MCP approve tool
|
|
* or the UI approval path). Keep the values in sync with MCP_TOOL_CAPABILITY_MAP.
|
|
*/
|
|
export const PAID_OPERATION_CAPABILITY_MAP: Readonly<Partial<Record<string, CapabilityKey>>> = {
|
|
send_invoice: CAPABILITY.email_send,
|
|
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}`]
|
|
}
|