feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' (#1747)

* 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>
This commit is contained in:
Jakob Wennberg
2026-08-31 17:55:29 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg Emil
parent 1d63e0f72b
commit cfce2de925
11 changed files with 883 additions and 39 deletions
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { getPool } from '../../../tests/pg/setup'
import { seedCompany } from '../../../tests/pg/fixtures'
import { CONNECTOR_CAPABILITIES, PAID_CAPABILITIES } from '../keys'
// pg-real coverage for migration 20260831170000 (capability_grants.source
// accepts 'connector') and the invariant that the trial-seed trigger never
// hands a hosted company a connector grant.
beforeEach(() => {
vi.clearAllMocks()
})
const future = () => new Date(Date.now() + 86_400_000).toISOString()
async function clearGrants(companyId: string): Promise<void> {
await getPool().query(`DELETE FROM public.capability_grants WHERE company_id = $1`, [companyId])
}
async function rpc(companyId: string, key: string): Promise<boolean> {
const { rows } = await getPool().query<{ ok: boolean }>(
`SELECT public.company_has_capability($1, $2) AS ok`,
[companyId, key],
)
return rows[0].ok
}
describe('capability_grants.source = connector', () => {
it('accepts connector and still rejects an unknown source', async () => {
const { companyId } = await seedCompany()
await clearGrants(companyId)
await getPool().query(
`INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at)
VALUES ($1, 'bank_sync', 'connector', $2)`,
[companyId, future()],
)
expect(await rpc(companyId, 'bank_sync')).toBe(true)
await expect(
getPool().query(
`INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at)
VALUES ($1, 'bank_sync', 'bogus', $2)`,
[companyId, future()],
),
).rejects.toThrow(/capability_grants_source_check/)
})
it('upserts on the (scope, key, source) identity like the stripe writer does', async () => {
const { companyId } = await seedCompany()
await clearGrants(companyId)
const first = future()
const later = new Date(Date.now() + 2 * 86_400_000).toISOString()
for (const exp of [first, later]) {
await getPool().query(
`INSERT INTO public.capability_grants (company_id, team_id, capability_key, source, expires_at)
VALUES ($1, NULL, 'skatteverket', 'connector', $2)
ON CONFLICT (company_id, team_id, capability_key, source) DO UPDATE SET expires_at = EXCLUDED.expires_at`,
[companyId, exp],
)
}
const { rows } = await getPool().query<{ n: string; expires_at: Date }>(
`SELECT count(*)::text AS n, max(expires_at) AS expires_at FROM public.capability_grants
WHERE company_id = $1 AND capability_key = 'skatteverket' AND source = 'connector'`,
[companyId],
)
expect(rows[0].n).toBe('1')
expect(new Date(rows[0].expires_at).toISOString()).toBe(later)
})
// Hosted companies must never be handed connector grants: the trial seed
// covers the PAID keys only, and the connector-only keys get nothing.
it('the trial-seed trigger writes no connector-source rows and nothing for connector-only keys', async () => {
const { companyId } = await seedCompany()
const { rows } = await getPool().query<{ capability_key: string; source: string }>(
`SELECT capability_key, source FROM public.capability_grants WHERE company_id = $1`,
[companyId],
)
expect(rows.some((r) => r.source === 'connector')).toBe(false)
const connectorOnly = CONNECTOR_CAPABILITIES.filter((k) => !PAID_CAPABILITIES.includes(k))
expect(connectorOnly.length).toBeGreaterThan(0)
for (const key of connectorOnly) {
expect(rows.some((r) => r.capability_key === key), `no trial grant for ${key}`).toBe(false)
}
})
// Migration 20260831180000: connector grants are a short-lived offline
// cache; a NULL expiry would be a permanent unlock nothing revokes.
it('rejects a connector grant without an expiry; other sources keep NULL expiry', async () => {
const { companyId } = await seedCompany()
await clearGrants(companyId)
await expect(
getPool().query(
`INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at)
VALUES ($1, 'bank_sync', 'connector', NULL)`,
[companyId],
),
).rejects.toThrow(/capability_grants_connector_expiry_check/)
await getPool().query(
`INSERT INTO public.capability_grants (company_id, capability_key, source, expires_at)
VALUES ($1, 'bank_sync', 'manual', NULL)`,
[companyId],
)
expect(await rpc(companyId, 'bank_sync')).toBe(true)
})
})
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
MCP_TOOL_CAPABILITY_MAP,
PAID_OPERATION_CAPABILITY_MAP,
@@ -12,6 +12,10 @@ import {
* 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)
@@ -78,3 +82,18 @@ describe('PAID_OPERATION_CAPABILITY_MAP', () => {
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)
})
})
@@ -1,4 +1,4 @@
import { describe, it, expect, afterEach, vi } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
hasCapability,
@@ -7,7 +7,7 @@ import {
getCompanyIdsWithCapability,
getCompanyEntitlements,
} from '../has-capability'
import { CAPABILITY, PAID_CAPABILITIES } from '../keys'
import { CAPABILITY, CONNECTOR_CAPABILITIES, PAID_CAPABILITIES } from '../keys'
/**
* Per-table mock: each table resolves to its own configured result, so a
@@ -37,8 +37,42 @@ function makeSupabase(byTable: Record<string, TableResult>): SupabaseClient {
return { from: (t: string) => chainFor(t) } as unknown as SupabaseClient
}
/**
* Same per-table mock, but every chained call is recorded so a test can
* assert WHICH filters a query applied (the mock itself ignores them).
*/
type RecordedCall = { table: string; method: string; args: unknown[] }
function makeRecordingSupabase(byTable: Record<string, TableResult>, calls: RecordedCall[]): SupabaseClient {
const chainFor = (table: string) => {
const result = byTable[table] ?? { data: null, error: null }
const chain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
}
return (...args: unknown[]) => {
calls.push({ table, method: String(prop), args })
return chain
}
},
},
)
return chain
}
return { from: (t: string) => chainFor(t) } as unknown as SupabaseClient
}
const sourceFilters = (calls: RecordedCall[]) =>
calls.filter((c) => c.table === 'capability_grants' && c.method === 'eq' && c.args[0] === 'source')
const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString()
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.unstubAllEnvs()
})
@@ -172,14 +206,17 @@ describe('getCompanyIdsWithCapability', () => {
expect([...result].sort()).toEqual([directCompanyId, firmCompanyId].sort())
})
it('returns every valid requested company when the paywall is bypassed', async () => {
it('returns every valid requested company when the paywall is bypassed (self-hosted, local capability)', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const supabase = makeSupabase({})
// A local capability: self-hosted is all-on without touching the DB. The
// connector capabilities (e.g. skatteverket) are covered in the
// self-hosted connector block below: they need a grant even here.
const result = await getCompanyIdsWithCapability(
supabase,
[directCompanyId, directCompanyId, 'not-a-uuid'],
CAPABILITY.skatteverket,
CAPABILITY.ai,
)
expect([...result]).toEqual([directCompanyId])
@@ -344,11 +381,24 @@ describe('getCompanyEntitlements', () => {
expect(result.capabilities).toEqual([])
})
it('bypass (self-hosted) holds everything with no trial countdown', async () => {
it('self-hosted holds every local paid capability with no trial countdown; connector keys need a grant', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const supabase = makeSupabase({})
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: { data: [] },
company_capability_config: { data: [] },
})
const result = await getCompanyEntitlements(supabase, companyId)
expect(result.trialEndsAt).toBeNull()
expect(result.capabilities).toEqual(PAID_CAPABILITIES.filter((k) => !CONNECTOR_CAPABILITIES.includes(k)))
// No trial exists on a self-host: 'none' until a connector grant is active.
expect(result.entitlementState).toBe('none')
})
it('dev bypass on a self-host still holds everything (connector keys included)', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
vi.stubEnv('NODE_ENV', 'development')
const result = await getCompanyEntitlements(makeSupabase({}), companyId)
expect(result.capabilities).toEqual([...PAID_CAPABILITIES])
expect(result.entitlementState).toBe('paid')
})
@@ -365,3 +415,401 @@ describe('capabilityBlockedResponse', () => {
expect(body.capability).toBe(CAPABILITY.bank_sync)
})
})
/**
* Sovereign self-host partition (plan WS3 PR3). Local capabilities stay all-on
* on a self-host; the CONNECTOR_CAPABILITIES (bank sync, Skatteverket, org
* lookup, migration: services Accounted operates) fall through to the grant
* lookup, where the connector sync writes source='connector' rows. Hosted
* behaviour is untouched by construction (the tests above still pass).
*/
describe('self-hosted connector capabilities', () => {
const COMPANY = '11111111-1111-4111-8111-111111111111'
it('keeps every local capability all-on without touching the DB', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const supabase = makeSupabase({})
for (const key of PAID_CAPABILITIES.filter((k) => !CONNECTOR_CAPABILITIES.includes(k))) {
expect(await hasCapability(supabase, COMPANY, key), key).toBe(true)
}
})
it('gates a connector capability on a grant (true with an active connector grant)', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: { data: [{ expires_at: iso(60_000) }] },
company_capability_config: { data: null },
})
expect(await hasCapability(supabase, COMPANY, CAPABILITY.bank_sync)).toBe(true)
})
it('fails closed for a connector capability without a grant, and once the grant expired (offline grace over)', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
expect(
await hasCapability(
makeSupabase({ companies: { data: { team_id: null } }, capability_grants: { data: [] } }),
COMPANY,
CAPABILITY.skatteverket,
),
).toBe(false)
expect(
await hasCapability(
makeSupabase({
companies: { data: { team_id: null } },
capability_grants: { data: [{ expires_at: iso(-60_000) }] },
}),
COMPANY,
CAPABILITY.org_lookup,
),
).toBe(false)
})
it('keeps the dev bypass all-on on a self-host, connector capabilities included', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
vi.stubEnv('NODE_ENV', 'development')
expect(await hasCapability(makeSupabase({}), COMPANY, CAPABILITY.migration)).toBe(true)
})
it('lets FORCE_PAYWALL run the real gate for connector capabilities but never for local ones', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
vi.stubEnv('NODE_ENV', 'development')
vi.stubEnv('FORCE_PAYWALL', 'true')
const noGrant = makeSupabase({ companies: { data: { team_id: null } }, capability_grants: { data: [] } })
expect(await hasCapability(noGrant, COMPANY, CAPABILITY.bank_sync)).toBe(false)
expect(await hasCapability(noGrant, COMPANY, CAPABILITY.ai)).toBe(true)
})
it('bulk resolution honours the same partition', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const supabase = makeSupabase({
companies: { data: [{ id: COMPANY, team_id: null }] },
company_capability_config: { data: [] },
capability_grants: { data: [] },
})
expect(await getCompanyIdsWithCapability(supabase, [COMPANY], CAPABILITY.ai)).toEqual(new Set([COMPANY]))
expect(await getCompanyIdsWithCapability(supabase, [COMPANY], CAPABILITY.bank_sync)).toEqual(new Set())
})
it('getCompanyEntitlements reports local paid keys plus active connector keys, state paid/none, no trial copy', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const localPaid = PAID_CAPABILITIES.filter((k) => !CONNECTOR_CAPABILITIES.includes(k))
const without = await getCompanyEntitlements(
makeSupabase({ companies: { data: { team_id: null } }, capability_grants: { data: [] }, company_capability_config: { data: [] } }),
COMPANY,
)
expect(without.capabilities).toEqual(localPaid)
expect(without.entitlementState).toBe('none')
expect(without.trialEndsAt).toBeNull()
expect(without.trialExpiredAt).toBeNull()
const withGrant = await getCompanyEntitlements(
makeSupabase({
companies: { data: { team_id: null } },
capability_grants: { data: [{ capability_key: 'bank_sync', expires_at: iso(60_000), source: 'connector' }] },
company_capability_config: { data: [] },
}),
COMPANY,
)
expect(withGrant.capabilities).toEqual(PAID_CAPABILITIES.filter((k) => localPaid.includes(k) || k === CAPABILITY.bank_sync))
expect(withGrant.entitlementState).toBe('paid')
})
// The dashboard layout passes teamId, which moves the grants read into the
// early wave (#1946). That call site and the fallback read must narrow to
// the same key list and carry the same source filter, or the self-host
// partition silently reads every paid key on the layout path. The in-loop
// source check masks a lost narrowing, so this pins the query itself.
it('getCompanyEntitlements with teamId narrows the early grants read to connector keys and source=connector on a self-host', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const calls: RecordedCall[] = []
const supabase = makeRecordingSupabase(
{
// Deliberately wrong: the lookup must not run when teamId is supplied.
companies: { data: { team_id: null } },
capability_grants: { data: [] },
company_capability_config: { data: [] },
company_subscriptions: { data: null },
},
calls,
)
const result = await getCompanyEntitlements(supabase, COMPANY, { teamId: null })
const keyFilters = calls.filter((c) => c.table === 'capability_grants' && c.method === 'in')
expect(keyFilters.map((c) => c.args)).toEqual([
['capability_key', PAID_CAPABILITIES.filter((k) => CONNECTOR_CAPABILITIES.includes(k))],
])
expect(sourceFilters(calls).map((c) => c.args)).toEqual([['source', 'connector']])
expect(calls.map((c) => c.table)).not.toContain('companies')
expect(result.capabilities).toEqual(PAID_CAPABILITIES.filter((k) => !CONNECTOR_CAPABILITIES.includes(k)))
expect(result.entitlementState).toBe('none')
})
it('getCompanyEntitlements with teamId reads every paid key and every source on hosted', async () => {
const TEAM = '55555555-5555-4555-8555-555555555555'
const calls: RecordedCall[] = []
const supabase = makeRecordingSupabase(
{
companies: { data: { team_id: null } },
capability_grants: { data: [] },
company_capability_config: { data: [] },
company_subscriptions: { data: null },
},
calls,
)
await getCompanyEntitlements(supabase, COMPANY, { teamId: TEAM })
const keyFilters = calls.filter((c) => c.table === 'capability_grants' && c.method === 'in')
expect(keyFilters.map((c) => c.args)).toEqual([['capability_key', PAID_CAPABILITIES]])
expect(sourceFilters(calls)).toHaveLength(0)
expect(calls.map((c) => c.table)).not.toContain('companies')
})
// The trial-seed trigger writes source='trial' rows for bank_sync and
// skatteverket on every company insert, self-hosts included. Only the
// connector sync's own rows may unlock a connector capability there.
it('reads only source=connector grants on a self-host, so the trial seed cannot unlock a connector', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const calls: RecordedCall[] = []
const supabase = makeRecordingSupabase(
{
companies: { data: { team_id: null } },
capability_grants: { data: [{ expires_at: iso(60_000) }] },
company_capability_config: { data: null },
},
calls,
)
expect(await hasCapability(supabase, COMPANY, CAPABILITY.bank_sync)).toBe(true)
expect(sourceFilters(calls).map((c) => c.args)).toEqual([['source', 'connector']])
})
it('bulk resolution applies the source=connector filter to both the company and the firm grant reads', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const TEAM = '55555555-5555-4555-8555-555555555555'
const calls: RecordedCall[] = []
const supabase = makeRecordingSupabase(
{
companies: { data: [{ id: COMPANY, team_id: TEAM }] },
company_capability_config: { data: [] },
capability_grants: { data: [] },
},
calls,
)
await getCompanyIdsWithCapability(supabase, [COMPANY], CAPABILITY.skatteverket)
expect(sourceFilters(calls).map((c) => c.args)).toEqual([
['source', 'connector'],
['source', 'connector'],
])
})
it('hosted keeps reading grants of every source (trial, stripe, comp, manual): no connector filter', async () => {
const calls: RecordedCall[] = []
const supabase = makeRecordingSupabase(
{
companies: { data: { team_id: null } },
capability_grants: { data: [{ expires_at: iso(60_000) }] },
company_capability_config: { data: null },
},
calls,
)
expect(await hasCapability(supabase, COMPANY, CAPABILITY.bank_sync)).toBe(true)
expect(sourceFilters(calls)).toHaveLength(0)
const bulkCalls: RecordedCall[] = []
await getCompanyIdsWithCapability(
makeRecordingSupabase(
{
companies: { data: [{ id: COMPANY, team_id: null }] },
company_capability_config: { data: [] },
capability_grants: { data: [] },
},
bulkCalls,
),
[COMPANY],
CAPABILITY.bank_sync,
)
expect(sourceFilters(bulkCalls)).toHaveLength(0)
})
it('getCompanyEntitlements ignores trial-seeded connector rows on a self-host', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const localPaid = PAID_CAPABILITIES.filter((k) => !CONNECTOR_CAPABILITIES.includes(k))
const seededOnly = await getCompanyEntitlements(
makeSupabase({
companies: { data: { team_id: null } },
capability_grants: {
data: [
{ capability_key: 'bank_sync', expires_at: iso(60_000), source: 'trial' },
{ capability_key: 'skatteverket', expires_at: iso(60_000), source: 'trial' },
],
},
company_capability_config: { data: [] },
}),
COMPANY,
)
expect(seededOnly.capabilities).toEqual(localPaid)
expect(seededOnly.entitlementState).toBe('none')
expect(seededOnly.trialEndsAt).toBeNull()
expect(seededOnly.trialExpiredAt).toBeNull()
const mixed = await getCompanyEntitlements(
makeSupabase({
companies: { data: { team_id: null } },
capability_grants: {
data: [
{ capability_key: 'bank_sync', expires_at: iso(60_000), source: 'trial' },
{ capability_key: 'skatteverket', expires_at: iso(60_000), source: 'connector' },
],
},
company_capability_config: { data: [] },
}),
COMPANY,
)
expect(mixed.capabilities).toEqual(
PAID_CAPABILITIES.filter((k) => localPaid.includes(k) || k === CAPABILITY.skatteverket),
)
expect(mixed.entitlementState).toBe('paid')
})
})
/**
* Own-credentials seam: a self-host that serves an upstream from its OWN
* credentials (its own Enable Banking registration, its own Skatteverket
* client) is never connector-gated for it. Without this, upgrading an
* own-credentials self-host would silently kill working bank/SKV integrations
* (the 2026-08-17 folded-flag incident, recreated). Hosted never reaches the
* seam: isBypassedFor checks isSelfHosted() first.
*/
describe('self-hosted own-credentials seam', () => {
const COMPANY = '11111111-1111-4111-8111-111111111111'
const CRED_VARS = [
'ENABLE_BANKING_APP_ID',
'ENABLE_BANKING_APP_ID_PRODUCTION',
'ENABLE_BANKING_PRIVATE_KEY',
'ENABLE_BANKING_PRIVATE_KEY_PRODUCTION',
'SKATTEVERKET_OAUTH2_CLIENT_ID',
'SKATTEVERKET_APIGW_CLIENT_ID',
] as const
const stubNoOwnCredentials = () => {
for (const v of CRED_VARS) vi.stubEnv(v, '')
}
it('own EB credentials keep bank_sync all-on without touching the DB (FORCE_PAYWALL included)', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
vi.stubEnv('FORCE_PAYWALL', 'true')
stubNoOwnCredentials()
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
const supabase = makeSupabase({}) // would resolve null/false if queried
expect(await hasCapability(supabase, COMPANY, CAPABILITY.bank_sync)).toBe(true)
// skatteverket has no own credentials here: still gated.
expect(
await hasCapability(
makeSupabase({ companies: { data: { team_id: null } }, capability_grants: { data: [] } }),
COMPANY,
CAPABILITY.skatteverket,
),
).toBe(false)
})
it('own SKV credentials keep skatteverket all-on (either client id variant)', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
stubNoOwnCredentials()
vi.stubEnv('SKATTEVERKET_APIGW_CLIENT_ID', 'own-client')
expect(await hasCapability(makeSupabase({}), COMPANY, CAPABILITY.skatteverket)).toBe(true)
vi.stubEnv('SKATTEVERKET_APIGW_CLIENT_ID', '')
vi.stubEnv('SKATTEVERKET_OAUTH2_CLIENT_ID', 'own-oauth-client')
expect(await hasCapability(makeSupabase({}), COMPANY, CAPABILITY.skatteverket)).toBe(true)
})
it('own credentials never unlock org_lookup or migration (no own-credentials form exists)', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
stubNoOwnCredentials()
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
vi.stubEnv('SKATTEVERKET_APIGW_CLIENT_ID', 'own-client')
const noGrant = makeSupabase({ companies: { data: { team_id: null } }, capability_grants: { data: [] } })
expect(await hasCapability(noGrant, COMPANY, CAPABILITY.org_lookup)).toBe(false)
expect(await hasCapability(noGrant, COMPANY, CAPABILITY.migration)).toBe(false)
})
it('hosted behaviour is unchanged by credential env vars (gate still runs)', async () => {
stubNoOwnCredentials()
vi.stubEnv('ENABLE_BANKING_APP_ID', 'hosted-always-has-these')
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: { data: [{ expires_at: iso(-60_000) }] }, // expired
})
expect(await hasCapability(supabase, COMPANY, CAPABILITY.bank_sync)).toBe(false)
})
it('bulk resolution honours the seam', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
stubNoOwnCredentials()
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
const supabase = makeSupabase({
companies: { data: [{ id: COMPANY, team_id: null }] },
company_capability_config: { data: [] },
capability_grants: { data: [] },
})
expect(await getCompanyIdsWithCapability(supabase, [COMPANY], CAPABILITY.bank_sync)).toEqual(new Set([COMPANY]))
expect(await getCompanyIdsWithCapability(supabase, [COMPANY], CAPABILITY.skatteverket)).toEqual(new Set())
})
it('getCompanyEntitlements counts own-credential keys as local paid and narrows the grants read', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
stubNoOwnCredentials()
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
const calls: RecordedCall[] = []
const result = await getCompanyEntitlements(
makeRecordingSupabase(
{
companies: { data: { team_id: null } },
capability_grants: { data: [] },
company_capability_config: { data: [] },
company_subscriptions: { data: null },
},
calls,
),
COMPANY,
)
expect(result.capabilities).toContain(CAPABILITY.bank_sync)
expect(result.capabilities).not.toContain(CAPABILITY.skatteverket)
expect(result.entitlementState).toBe('none') // no connector grant; touchpoint renders nothing
const grantKeyFilters = calls.filter(
(c) => c.table === 'capability_grants' && c.method === 'in' && c.args[0] === 'capability_key',
)
expect(grantKeyFilters).toHaveLength(1)
expect(grantKeyFilters[0].args[1]).toEqual([CAPABILITY.skatteverket])
})
it('getCompanyEntitlements skips the grants read entirely when every connector upstream has own credentials', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
stubNoOwnCredentials()
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
vi.stubEnv('SKATTEVERKET_APIGW_CLIENT_ID', 'own-client')
const calls: RecordedCall[] = []
const result = await getCompanyEntitlements(
makeRecordingSupabase(
{
companies: { data: { team_id: null } },
company_capability_config: { data: [] },
company_subscriptions: { data: null },
},
calls,
),
COMPANY,
{ teamId: null },
)
expect(result.capabilities).toEqual([...PAID_CAPABILITIES])
expect(calls.filter((c) => c.table === 'capability_grants')).toHaveLength(0)
})
it('self-host block copy names the connector key, hosted copy keeps the subscription upsell', async () => {
stubNoOwnCredentials()
const hosted = await capabilityBlockedResponse(CAPABILITY.bank_sync).json()
expect(hosted.error).toContain('prenumeration')
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const selfHost = await capabilityBlockedResponse(CAPABILITY.bank_sync).json()
expect(selfHost.error).toContain('GNUBOK_CONNECTOR_KEY')
expect(selfHost.error_en).toContain('GNUBOK_CONNECTOR_KEY')
})
})
+150 -27
View File
@@ -1,7 +1,8 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { isSelfHosted } from '@/lib/env/public-flags'
import { PAID_CAPABILITIES, type CapabilityKey } from './keys'
import { PAID_CAPABILITIES, isConnectorCapability, type CapabilityKey } from './keys'
import { hasOwnCredentialsFor } from './own-credentials'
/**
* Entitlement gate: the single primitive behind the paywall ("non-payer loses
@@ -20,7 +21,12 @@ import { PAID_CAPABILITIES, type CapabilityKey } from './keys'
*/
/**
* Self-hosted deployments are all-on: the gate never withholds anything.
* Self-hosted deployments are all-on for everything the instance runs itself:
* the gate never withholds a local feature. The one exception is the
* CONNECTOR_CAPABILITIES (bank sync, Skatteverket, org lookup, migration):
* those run on services Accounted operates, so on a self-host they fall
* through to the normal grant lookup, where the hourly connector sync writes
* `source = 'connector'` grants from the instance's connector key.
*
* Read through lib/env/public-flags: comparing process.env.NEXT_PUBLIC_* in
* place gets constant-folded out of the Docker build, which is exactly how
@@ -36,9 +42,7 @@ import { PAID_CAPABILITIES, type CapabilityKey } from './keys'
* - DISABLE_PAYWALL === 'true': explicit escape hatch for a local
* 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
function isDevBypass(): boolean {
// 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)
@@ -51,6 +55,45 @@ function isPaywallBypassed(): boolean {
)
}
/**
* Whether the gate is bypassed for ONE capability.
*
* hosted : dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins (unchanged).
* self-hosted : local capabilities are always on (FORCE_PAYWALL included:
* an AGPL operator's own instance is never gated on what it
* runs itself); connector capabilities served from the
* instance's OWN credentials count as local (the operator
* runs that upstream themselves; see own-credentials.ts);
* the remaining connector capabilities behave like hosted
* (dev bypass, FORCE_PAYWALL, otherwise the grant lookup).
*/
function isBypassedFor(key: CapabilityKey): boolean {
if (isSelfHosted() && (!isConnectorCapability(key) || hasOwnCredentialsFor(key))) return true
return isDevBypass()
}
/**
* Whether only the connector sync's own grants may unlock a capability.
*
* 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, and a self-host has no trial: without this
* predicate a fresh self-host company would hold every connector capability
* for a month with no connector key. Hosted keeps reading every source.
*/
function connectorGrantsOnly(): boolean {
return isSelfHosted()
}
/**
* Whether the gate is bypassed for EVERY capability at once (the bulk
* entitlement shape). True on hosted dev; on a self-host only under the dev
* bypass, since connector capabilities otherwise need the grant lookup.
*/
function isPaywallBypassed(): boolean {
return isDevBypass()
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* Only server-resolved UUIDs may be interpolated into the PostgREST `.or()`
@@ -91,7 +134,7 @@ export async function getCompanyIdsWithCapability(
): Promise<Set<string>> {
const validCompanyIds = [...new Set(companyIds.filter(isUuid))]
if (validCompanyIds.length === 0) return new Set()
if (isPaywallBypassed()) return new Set(validCompanyIds)
if (isBypassedFor(key)) return new Set(validCompanyIds)
type CompanyScope = { id: string; team_id: string | null }
type GrantScope = {
@@ -124,23 +167,28 @@ export async function getCompanyIdsWithCapability(
const teamIds = [...new Set(companies.map(company => company.team_id).filter((id): id is string => !!id))]
const grants: GrantScope[] = []
const onlyConnectorGrants = connectorGrantsOnly()
for (const chunk of chunksOf(validCompanyIds, CAPABILITY_SCOPE_CHUNK_SIZE)) {
const { data, error } = await supabase
let companyGrantsQuery = supabase
.from('capability_grants')
.select('company_id, team_id, expires_at')
.eq('capability_key', key)
.in('company_id', chunk)
if (onlyConnectorGrants) companyGrantsQuery = companyGrantsQuery.eq('source', 'connector')
const { data, error } = await companyGrantsQuery
if (error) throw new Error(`Failed to resolve company capability grants: ${error.message}`)
grants.push(...((data ?? []) as GrantScope[]))
}
for (const chunk of chunksOf(teamIds, CAPABILITY_SCOPE_CHUNK_SIZE)) {
const { data, error } = await supabase
let firmGrantsQuery = supabase
.from('capability_grants')
.select('company_id, team_id, expires_at')
.eq('capability_key', key)
.in('team_id', chunk)
if (onlyConnectorGrants) firmGrantsQuery = firmGrantsQuery.eq('source', 'connector')
const { data, error } = await firmGrantsQuery
if (error) throw new Error(`Failed to resolve firm capability grants: ${error.message}`)
grants.push(...((data ?? []) as GrantScope[]))
}
@@ -171,7 +219,7 @@ export async function hasCapability(
companyId: string,
key: CapabilityKey,
): Promise<boolean> {
if (isPaywallBypassed()) return true
if (isBypassedFor(key)) return true
if (!isUuid(companyId)) return false // fail-closed: never interpolate a non-UUID
// Resolve the company's firm/team (firm-scoped grants cascade to clients).
@@ -187,11 +235,13 @@ export async function hasCapability(
const scopeFilter = teamId
? `company_id.eq.${companyId},team_id.eq.${teamId}`
: `company_id.eq.${companyId}`
const { data: grants, error: grantsError } = await supabase
let grantsQuery = supabase
.from('capability_grants')
.select('expires_at')
.eq('capability_key', key)
.or(scopeFilter)
if (connectorGrantsOnly()) grantsQuery = grantsQuery.eq('source', 'connector')
const { data: grants, error: grantsError } = await grantsQuery
if (grantsError) return false // fail-closed on any read error
const now = Date.now()
@@ -219,6 +269,23 @@ export const CAPABILITY_BLOCKED_MESSAGE_SV =
export const CAPABILITY_BLOCKED_MESSAGE_EN =
'This feature requires a paid subscription. Upgrade to keep using external services.'
/**
* Self-host variant: the remedy there is a connector key (or the instance's
* own upstream credentials), never a hosted subscription, so the hosted
* upsell copy would mislead the operator.
*/
export const CAPABILITY_BLOCKED_MESSAGE_SELF_HOSTED_SV =
'Den här funktionen kräver en connector-nyckel från Accounted (GNUBOK_CONNECTOR_KEY) eller instansens egna API-uppgifter för tjänsten.'
export const CAPABILITY_BLOCKED_MESSAGE_SELF_HOSTED_EN =
'This feature requires an Accounted connector key (GNUBOK_CONNECTOR_KEY) or the instance\'s own API credentials for the service.'
function blockedMessageSv(): string {
return isSelfHosted() ? CAPABILITY_BLOCKED_MESSAGE_SELF_HOSTED_SV : CAPABILITY_BLOCKED_MESSAGE_SV
}
function blockedMessageEn(): string {
return isSelfHosted() ? CAPABILITY_BLOCKED_MESSAGE_SELF_HOSTED_EN : CAPABILITY_BLOCKED_MESSAGE_EN
}
/**
* Standard bilingual 403 for a capability-blocked endpoint. Matches the
* sandbox/guard envelope so the UI surfaces the upsell consistently.
@@ -226,8 +293,8 @@ export const CAPABILITY_BLOCKED_MESSAGE_EN =
export function capabilityBlockedResponse(key: CapabilityKey): NextResponse {
return NextResponse.json(
{
error: CAPABILITY_BLOCKED_MESSAGE_SV,
error_en: CAPABILITY_BLOCKED_MESSAGE_EN,
error: blockedMessageSv(),
error_en: blockedMessageEn(),
capability_blocked: true,
capability: key,
},
@@ -254,8 +321,8 @@ export function capabilityBlockedError(key: CapabilityKey): CapabilityBlockedErr
code: 'capability_blocked',
capability_blocked: true,
capability: key,
message_sv: CAPABILITY_BLOCKED_MESSAGE_SV,
message_en: CAPABILITY_BLOCKED_MESSAGE_EN,
message_sv: blockedMessageSv(),
message_en: blockedMessageEn(),
}
}
@@ -315,25 +382,33 @@ export interface CompanyEntitlements {
/** company_subscriptions.status values that count as a live subscription. */
const PAYING_SUBSCRIPTION_STATUSES = ['active', 'trialing', 'past_due']
/**
* Resolve which PAID capabilities a company currently holds (entitled AND
* enabled) plus its trial state, in two queries. Used to seed the client
* CompanyContext so the UI can hide/disable/upsell gated features.
* Self-hosted holds everything.
*/
function normalizeTeamId(raw: string | null | undefined): string | null {
return raw && isUuid(raw) ? raw : null
}
function readGrants(supabase: SupabaseClient, companyId: string, teamId: string | null) {
/**
* The grants read behind getCompanyEntitlements. `keys` is the paid-key list
* the caller wants resolved from grants (every paid key on hosted; only the
* connector keys on a self-host, where the local ones are held outright).
* On a self-host only the connector sync's own rows count; see
* connectorGrantsOnly().
*/
function readGrants(
supabase: SupabaseClient,
companyId: string,
teamId: string | null,
keys: readonly CapabilityKey[],
) {
const scopeFilter = teamId
? `company_id.eq.${companyId},team_id.eq.${teamId}`
: `company_id.eq.${companyId}`
return supabase
let grantsQuery = supabase
.from('capability_grants')
.select('capability_key, expires_at, source')
.in('capability_key', PAID_CAPABILITIES as unknown as string[])
.in('capability_key', keys as unknown as string[])
.or(scopeFilter)
if (connectorGrantsOnly()) grantsQuery = grantsQuery.eq('source', 'connector')
return grantsQuery
}
export interface GetCompanyEntitlementsOptions {
@@ -347,6 +422,14 @@ export interface GetCompanyEntitlementsOptions {
teamId?: string | null
}
/**
* Resolve which PAID capabilities a company currently holds (entitled AND
* enabled) plus its trial state, in two queries. Used to seed the client
* CompanyContext so the UI can hide/disable/upsell gated features.
* Self-hosted holds every local capability outright; the
* CONNECTOR_CAPABILITIES are read from `source = 'connector'` grants only
* (see connectorGrantsOnly()).
*/
export async function getCompanyEntitlements(
supabase: SupabaseClient,
companyId: string,
@@ -364,6 +447,17 @@ export async function getCompanyEntitlements(
if (!isUuid(companyId)) {
return { capabilities: [], trialEndsAt: null, entitlementState: 'none', trialExpiredAt: null }
}
// Self-hosted: every local capability is held outright; only the connector
// capabilities among the paid keys are read from grants (written with
// `source = 'connector'` by the instance's connector sync). Same query
// below, narrowed to those keys.
const selfHosted = isSelfHosted()
// Own-credentials connector keys count as local: the operator runs that
// upstream themselves (see own-credentials.ts), so they are held outright
// and never read from grants.
const selfHostLocal = (k: CapabilityKey) => !isConnectorCapability(k) || hasOwnCredentialsFor(k)
const localPaid = selfHosted ? PAID_CAPABILITIES.filter(selfHostLocal) : []
const queriedKeys = selfHosted ? PAID_CAPABILITIES.filter((k) => !selfHostLocal(k)) : PAID_CAPABILITIES
// The disabled-config subtraction and the subscription-status read only
// need companyId, so they run in parallel with the team lookup: this
@@ -387,22 +481,35 @@ export async function getCompanyEntitlements(
.select('status')
.eq('company_id', companyId)
.maybeSingle(),
// With the team known up front the grants read joins this wave.
knownTeam ? readGrants(supabase, companyId, normalizeTeamId(options.teamId)) : Promise.resolve(null),
// With the team known up front the grants read joins this wave. A
// self-host serving every connector upstream from its own credentials has
// nothing to read from grants: skip the query (`in.()` on an empty list
// is not a valid PostgREST filter).
knownTeam && queriedKeys.length > 0
? readGrants(supabase, companyId, normalizeTeamId(options.teamId), queriedKeys)
: Promise.resolve(null),
])
const teamId = normalizeTeamId((company as { team_id: string | null } | null)?.team_id ?? null)
const { data: grants } = earlyGrants ?? (await readGrants(supabase, companyId, teamId))
const { data: grants } =
earlyGrants ??
(queriedKeys.length > 0
? await readGrants(supabase, companyId, teamId, queriedKeys)
: { data: [] })
const now = Date.now()
const entitled = new Set<string>()
const entitled = new Set<string>(localPaid)
// Latest trial expiry across ALL trial rows, expired ones included: this is
// what tells the UI the trial ENDED (ISO strings from the same column
// compare lexically).
let latestTrialExpiry: string | null = null
let hasActiveNonTrialGrant = false
let hasActiveConnectorGrant = false
for (const g of grants ?? []) {
const row = g as { capability_key: string; expires_at: string | null; source: string | null }
// Self-host: a trial-seeded (or any non-connector) row never unlocks a
// connector capability; see connectorGrantsOnly().
if (selfHosted && row.source !== 'connector') continue
if (
row.source === 'trial' &&
row.expires_at &&
@@ -414,6 +521,22 @@ export async function getCompanyEntitlements(
if (!active) continue
entitled.add(row.capability_key)
if (row.source !== 'trial') hasActiveNonTrialGrant = true
if (row.source === 'connector') hasActiveConnectorGrant = true
}
if (selfHosted) {
// No trial on a self-host: 'paid' while a connector grant is active,
// 'none' otherwise (never the hosted trial copy). Explicit disables still
// apply.
for (const c of configs ?? []) {
entitled.delete((c as { capability_key: string }).capability_key)
}
return {
capabilities: PAID_CAPABILITIES.filter((k) => entitled.has(k)),
trialEndsAt: null,
entitlementState: hasActiveConnectorGrant ? 'paid' : 'none',
trialExpiredAt: null,
}
}
// Paying/comped companies are not "on trial" even if the seeded trial rows
// haven't expired yet: the countdown would nag someone who already converted.
+27
View File
@@ -71,6 +71,33 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [
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
+45
View File
@@ -0,0 +1,45 @@
import { CAPABILITY, type CapabilityKey } from './keys'
/**
* Own-credentials seam for the connector partition, forward-ported from the
* instance-wiring layer (lib/connect/instance/upstreams.ts in the connector
* stack) so it lands WITH the partition, not four PRs later.
*
* A self-host that runs an upstream on its OWN credentials (its own Enable
* Banking AISP registration, its own Skatteverket API client) provides that
* service itself: it must never be connector-gated for it, exactly like every
* other local capability. Without this seam, upgrading an own-credentials
* self-host would silently kill working bank sync / SKV integrations (the
* 2026-08-17 folded-flag incident, recreated deliberately).
*
* The env-var sets are the same ones the extensions activate on
* (enable-banking manifest / jwt.ts; skatteverket manifest / api-client.ts)
* and must stay in sync with the connector-mode seam when the instance-wiring
* PR lands: connector mode = key present AND no own credentials.
*/
/** True when the instance would use its own Enable Banking credentials. */
export function hasOwnEnableBankingCredentials(): boolean {
return !!(
process.env.ENABLE_BANKING_PRIVATE_KEY_PRODUCTION ||
process.env.ENABLE_BANKING_PRIVATE_KEY ||
process.env.ENABLE_BANKING_APP_ID_PRODUCTION ||
process.env.ENABLE_BANKING_APP_ID
)
}
/** True when the instance would use its own Skatteverket OAuth client. */
export function hasOwnSkatteverketCredentials(): boolean {
return !!(process.env.SKATTEVERKET_OAUTH2_CLIENT_ID || process.env.SKATTEVERKET_APIGW_CLIENT_ID)
}
/**
* Whether this instance provides the given connector capability from its own
* credentials. org_lookup and migration have no own-credentials form: they
* run exclusively on services Accounted operates.
*/
export function hasOwnCredentialsFor(key: CapabilityKey): boolean {
if (key === CAPABILITY.bank_sync) return hasOwnEnableBankingCredentials()
if (key === CAPABILITY.skatteverket) return hasOwnSkatteverketCredentials()
return false
}
+6 -3
View File
@@ -112,7 +112,7 @@ import {
import { PartialCommitError } from '@/lib/pending-operations/errors'
import { getEmailService } from '@/lib/email/service'
import { resolveInvoiceSender } from '@/lib/email/invoice-sender'
import { hasCapability, CAPABILITY_BLOCKED_MESSAGE_SV } from '@/lib/entitlements/has-capability'
import { hasCapability, capabilityBlockedError } from '@/lib/entitlements/has-capability'
import { PAID_OPERATION_CAPABILITY_MAP } from '@/lib/entitlements/keys'
import { exceedsUnattendedLimit } from './unattended-limit'
import {
@@ -6643,12 +6643,15 @@ async function commitPendingOperationInner(
// the trial then approved AFTER the grant expired, regardless of caller
// (MCP approve tool or the UI approval path). Checked BEFORE the atomic
// claim so a blocked op stays 'pending' and is re-approvable once the
// company subscribes. Self-hosted short-circuits to all-on in hasCapability.
// company subscribes. Self-hosted is all-on in hasCapability except the
// connector capabilities without own credentials (see lib/entitlements).
const requiredCapability = PAID_OPERATION_CAPABILITY_MAP[pendingOp.operation_type]
if (requiredCapability && !(await hasCapability(supabase, companyId, requiredCapability))) {
return {
status: 'failed',
error: CAPABILITY_BLOCKED_MESSAGE_SV,
// Via capabilityBlockedError so the self-host variant applies (the
// hosted constant upsells a subscription a self-host cannot buy).
error: capabilityBlockedError(requiredCapability).message_sv,
http_status: 403,
code: 'capability_blocked',
operation_status: 'pending',