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:
co-authored by
Claude Fable 5
Jakob Wennberg
Emil
parent
1d63e0f72b
commit
cfce2de925
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user