* 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>
100 lines
4.1 KiB
TypeScript
100 lines
4.1 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import {
|
|
MCP_TOOL_CAPABILITY_MAP,
|
|
PAID_OPERATION_CAPABILITY_MAP,
|
|
PAID_CAPABILITIES,
|
|
CAPABILITY,
|
|
} from '../keys'
|
|
|
|
/**
|
|
* These maps are the contract that gates the paid MCP/agent path (dispatch +
|
|
* commit). Locking the exact entries is the guard against a future paid
|
|
* external-service tool silently bypassing the paywall: mirrors the
|
|
* TOOL_SCOPE_MAP assertions in the mcp-server tests.
|
|
*/
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
/**
|
|
* MCP tools that invoke a paid capability directly (no stage→commit round-trip),
|
|
* so they are gated at DISPATCH only and have no commit-time (operation-map)
|
|
* counterpart. The document upload tools run Bedrock OCR inline via
|
|
* extractInvoiceFields: they never stage a pending_operation.
|
|
*/
|
|
const DISPATCH_ONLY_MCP_TOOLS = new Set<string>([
|
|
'gnubok_upload_document',
|
|
'gnubok_create_document_upload',
|
|
'gnubok_complete_document_upload',
|
|
// Onboarding connect-link tools: read status + hand out a browser link; no commit counterpart.
|
|
'gnubok_connect_bank',
|
|
'gnubok_connect_skatteverket',
|
|
])
|
|
|
|
describe('MCP_TOOL_CAPABILITY_MAP', () => {
|
|
it('gates exactly the paid MCP tools (3 external-service staging tools + the AI OCR tools)', () => {
|
|
expect(MCP_TOOL_CAPABILITY_MAP).toEqual({
|
|
gnubok_send_invoice: CAPABILITY.email_send,
|
|
gnubok_vat_declaration_submit: CAPABILITY.skatteverket,
|
|
gnubok_agi_submit: CAPABILITY.skatteverket,
|
|
gnubok_connect_bank: CAPABILITY.bank_sync,
|
|
gnubok_connect_skatteverket: CAPABILITY.skatteverket,
|
|
// Dispatch-only AI tools: inline Bedrock OCR, no staged operation. The
|
|
// signed-URL pair is gated at create AND complete so a free-tier key can
|
|
// neither reserve nor finalize a paid extraction.
|
|
gnubok_upload_document: CAPABILITY.ai,
|
|
gnubok_create_document_upload: CAPABILITY.ai,
|
|
gnubok_complete_document_upload: CAPABILITY.ai,
|
|
})
|
|
})
|
|
|
|
it('only maps tools to PAID capabilities', () => {
|
|
for (const key of Object.values(MCP_TOOL_CAPABILITY_MAP)) {
|
|
expect(PAID_CAPABILITIES).toContain(key)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('PAID_OPERATION_CAPABILITY_MAP', () => {
|
|
it('gates exactly the three paid pending-operation types', () => {
|
|
expect(PAID_OPERATION_CAPABILITY_MAP).toEqual({
|
|
send_invoice: CAPABILITY.email_send,
|
|
submit_vat_declaration: CAPABILITY.skatteverket,
|
|
submit_agi: CAPABILITY.skatteverket,
|
|
})
|
|
})
|
|
|
|
it('only maps operations to PAID capabilities', () => {
|
|
for (const key of Object.values(PAID_OPERATION_CAPABILITY_MAP)) {
|
|
expect(PAID_CAPABILITIES).toContain(key)
|
|
}
|
|
})
|
|
|
|
it('covers the same capabilities as the STAGING MCP tools (dispatch ↔ commit parity)', () => {
|
|
// Parity applies to staging tools only: an op that can be staged via MCP OR
|
|
// approved in the UI must be gated on both transports. Dispatch-only tools
|
|
// (inline AI OCR) have no commit counterpart and are excluded.
|
|
const stagingMcpCaps = new Set(
|
|
Object.entries(MCP_TOOL_CAPABILITY_MAP)
|
|
.filter(([tool]) => !DISPATCH_ONLY_MCP_TOOLS.has(tool))
|
|
.map(([, cap]) => cap),
|
|
)
|
|
expect(new Set(Object.values(PAID_OPERATION_CAPABILITY_MAP))).toEqual(stagingMcpCaps)
|
|
})
|
|
})
|
|
|
|
describe('CONNECTOR_CAPABILITIES', () => {
|
|
it('names only real capability keys, with the two connector-only keys outside the paid set', async () => {
|
|
const { CAPABILITY, CONNECTOR_CAPABILITIES, PAID_CAPABILITIES, isConnectorCapability } = await import('../keys')
|
|
const all = new Set(Object.values(CAPABILITY))
|
|
for (const key of CONNECTOR_CAPABILITIES) expect(all.has(key), key).toBe(true)
|
|
expect(CONNECTOR_CAPABILITIES).toEqual(['bank_sync', 'skatteverket', 'org_lookup', 'migration'])
|
|
// org_lookup and migration stay free on hosted (not PAID) but still need
|
|
// Accounted's services, hence connector-gated on a self-host.
|
|
expect(PAID_CAPABILITIES).not.toContain('org_lookup')
|
|
expect(PAID_CAPABILITIES).not.toContain('migration')
|
|
expect(isConnectorCapability('ai')).toBe(false)
|
|
expect(isConnectorCapability('bank_sync')).toBe(true)
|
|
})
|
|
})
|