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
@@ -1140,6 +1140,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-20] Reconciliation opens on the FULL year and keeps its own range-preset memory, separate from the shared report-family key. Inheriting a "Denna manad" preset last used on Resultatrapport would show an alarming difference for a window the user never chose on this page, and a part-year reconciliation window answers a question nobody asked.
|
||||
[2026-08-20] The matcher now runs automatically once per window+account when there is unmatched work, instead of waiting for a button many users never found. It is a dry run: nothing is written, and Tillampa still requires an explicit click. ?autorun=1 keeps a distinct meaning (run even on a clean window) so the transactions-inbox deep link still produces a result rather than silence.
|
||||
[2026-08-20] Unmatched bank rows that no voucher on the account could settle (direction-compatible and equal to the ore) get "Bokfor" linking to /transactions?highlight=<id> instead of a match picker. They are unbooked affarshandelser, not reconciliation work, and the picker held nothing for them. The rule is deliberately strict: a false negative offers booking on a pairable row (a legitimate outcome), a false positive sends the user into an empty picker.
|
||||
[2026-08-20] Entitlement partition for the sovereign self-host (plan WS3 PR3, ships dark): isPaywallBypassed() became isBypassedFor(key). Hosted behaviour is byte-identical (dev/DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, otherwise the grant lookup). On a self-host every LOCAL capability stays always-on, FORCE_PAYWALL included (an AGPL operator's own instance is never gated on what it runs itself, which is also why the existing "FORCE_PAYWALL never overrides self-hosted" test still holds), and only the four CONNECTOR_CAPABILITIES (bank_sync, skatteverket, org_lookup, migration: services Accounted operates) fall through to the grant lookup, where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host reports state 'paid' when a connector grant is active and 'none' otherwise, never 'trial_expired' (that copy talks about a hosted trial). CONNECTOR_CAPABILITIES is deliberately separate from PAID_CAPABILITIES; the trial-seed trigger does seed 30-day source='trial' rows for bank_sync/skatteverket (they are PAID keys) but never writes source='connector' and never seeds the connector-only keys (org_lookup, migration), and on a self-host only source='connector' rows unlock a connector capability, so a hosted company can never hold a connector grant. The capability_grants.source CHECK gains 'connector' by looking the inline auto-named constraint up through pg_constraint. Adding the connector extensions to the self-host Docker preset was deferred to the instance-wiring PR: until a connector key can actually be issued, shipping those extensions in the image would only show dead-end upsells.
|
||||
[2026-08-20] Sovereign package docs (plan WS2 PR1): docs/SOVEREIGN.md is written as regulatory-risk elimination with a per-provider fact sheet checked on the vendors' own pages (Elastx CaaS/DBaaS/3 Stockholm AZs/ISO 27001:2022; GleSYS VPS + S3, no managed k8s, EU-owned not Swedish-owned; Safespring S3 with Object Lock COMPLIANCE/GOVERNANCE; Berget api.berget.ai/v1 with gemma-4-31B-it vision and an SLA that excludes serverless; evroc Think Models EU-only), never as "US cloud is illegal", and it leads with the MCP server as the agent surface that needs no AI provider at all (alignment rule R5). The connector subscription is described as planned and not yet available rather than documented as if it shipped. Vercel Speed Insights is now gated behind !isSelfHosted() in app/layout.tsx (the last ungated hosted-only telemetry; read via lib/env/public-flags per the folded-flag rule). Backup/restore ship as scripts/self-host/{backup,restore}.sh (pg_dump custom format + storage volume tar + optional db-config volume for the pgsodium root key, SHA-256 manifest, AWS CLI v2 against any S3-compatible endpoint, optional COMPLIANCE-mode Object Lock) with a bash -n + refusal-path test, because self-hosted Supabase has no managed backups and BFL 7 kap needs a credible 7-year archive. Stale self-host docs fixed: the 4-of-23 cron table replaced by a pointer to the generated crontab and the pgvector line corrected (nothing stores embeddings).
|
||||
[2026-08-20] Vercel build heap is raised through vercel.json `buildCommand` (`NODE_OPTIONS=--max-old-space-size=6144 npm run build`), not a project env var and not `build.env`: a project-level NODE_OPTIONS also reaches function runtime (V8 sizes the heap against a limit the function does not have), and `build.env` is marked deprecated in the vercel.json schema; `buildCommand` scopes the flag to the build exactly like core-build.yml's 8192 does for CI. 6144 fits the standard 4-core/8 GB build machine next to the main next process; the type-check needs ~4.5 GB and was hanging at V8's ~4 GB default ceiling (4 production timeouts 2026-08-14..20).
|
||||
[2026-08-20] The production build type-checks tsconfig.build.json (tsconfig.json minus tests and mocks) via typescript.tsconfigPath; tsconfig.json stays the editor/ESLint view of the whole repo. Why: Next 16.3 runs the project-local tsc CLI by default (prerequisite for TypeScript 7's native checker, which has no JS API) and that checker checks the complete project it is given, whereas the old API checker silently dropped diagnostics from __tests__/*.test.* files. ~490 real type errors live in test files today (mostly route handlers called without the ctx argument); vitest never type-checks them, so nothing caught them. Excluding tests from the build keeps that debt where it was (invisible) instead of turning it into a red deploy; a separate tsc job for tests is the follow-up that makes it visible. Measured: tests are ~10% of the check's memory, so this is correctness, not the memory fix (that is the vercel.json heap bump).
|
||||
@@ -1271,6 +1272,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent.
|
||||
[2026-08-26] Support-dialog attachments use the existing email delivery path without storage or schema changes: this keeps the feature scoped to the contact form. The budget is 5 files / 4 MB total under the 4.5 MB hosted request-body ceiling, with client-side image shrinking when needed.
|
||||
[2026-08-26] RFC 9728 protected-resource metadata is served at THREE locations (root, path-based /.well-known/oauth-protected-resource/<mcp path>, and <mcp url>/.well-known/oauth-protected-resource): Claude.ai's connector setup derives the metadata URL from the server URL and fetches it before any 401, so the root document our WWW-Authenticate header points at was not enough ('Authorization with Accounted failed' with only 404s in the logs). One builder, three routes; the path-based route answers 404 for any path other than the MCP endpoint so no phantom resource is advertised.
|
||||
[2026-08-26] Self-host connector gate honors only capability_grants rows with source='connector' (hasCapability, getCompanyIdsWithCapability, getCompanyEntitlements): the trial-seed trigger grants bank_sync/skatteverket to every new company for 30 days, self-hosts included, which must not unlock connectors without a hosted key. Hosted reads every source as before.
|
||||
[2026-08-26] npm publishing of packages/accounted-mcp and packages/gnubok-mcp is gated on "package.json version not on the registry" (push to main touching packages/*/package.json), not on git tags: the repo's v*.*.* tags belong to the Docker image, the bridges version independently, and a version gate makes re-runs and unrelated package.json edits no-ops. Auth is an NPM_TOKEN secret rather than npm trusted publishing (OIDC) because accounted-mcp has never been published and npm cannot bind a trusted publisher to a package that does not exist yet; --provenance still attaches the Sigstore attestation, which is why gnubok-mcp's repository.url had to move from erp-mafia/gnubok to erp-mafia/accounted (the registry rejects a mismatch).
|
||||
[2026-08-26] Archived customers/suppliers hidden via archived_at IS NULL on every non-v1 list/picker (not is_active): customers have no is_active column and v1 already treats archived_at as canonical; is_active on suppliers stays a legacy mirror. MCP list tools got a bare include_archived boolean and the tools/list ceiling moved 60.7K to 60.8K instead of trimming unrelated tool prose: main had ~6 tokens of headroom, so any contract at all crossed.
|
||||
[2026-08-26] MCP serverInfo.version, extension version and /api/health version reuse currentAppVersion() (12-char SHA, '1.0.0' fallback) instead of a new 7-char slice: one identifier across behandlingshistorik, health and MCP so a support thread can match a deploy by a single string; gnubok_get_vacation_balance got a real estimated_liability_sek by exporting semesterberedning's dayValueSek rather than dropping the description's promise, with descriptions trimmed to stay under the tools/list ceiling.
|
||||
@@ -1343,6 +1345,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review).
|
||||
[2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring.
|
||||
[2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card.
|
||||
[2026-08-29] Merge of main (#1946 readGrants/earlyGrants) into feat/connector-entitlements resolved by giving readGrants() a keys parameter and applying the source='connector' filter inside it, rather than re-inlining the grants query: the early-grants wave (teamId known) and the fallback read must narrow to the same key list or the self-host partition silently reads every paid key on the layout path. The in-loop source check in getCompanyEntitlements stays as a second line of defence (the unit mocks return rows regardless of filter, so it is also what the tests pin). Hosted query shape is unchanged: connectorGrantsOnly() is false there.
|
||||
[2026-08-29] components/extensions/general/InvoiceInboxWorkspace.tsx set to the #1753 inline-proxy version in the feat/connector-entitlements merge (blob bdd7668c1, which origin/main carried at push time as 7e76961da; the merged parent 338ac4e91 still had the pre-#1753 <iframe> variant): the branch's earlier merge da71d38e2 had silently kept the old preview code (signed URL + <embed>) over main's fix, and git carried that mis-resolution forward as the PR's intent. The file is outside this PR's scope, so any diff against main there is a regression, not a change to keep. Stack children (#1748 onward) do not contain this merge and must merge feat/connector-entitlements forward before their CI is trusted.
|
||||
[2026-08-29] Inbox underlag lock-state read names the FK (fiscal_periods!journal_entries_fiscal_period_id_fkey) instead of the bare embed: fiscal_periods has two FKs back to journal_entries (closing_entry_id, opening_balance_entry_id), so PostgREST rejected the embed as ambiguous and the first underlag-reconcile cron run (2026-08-29) classified 34 locked-period items as retryable and hit the period-lock trigger on every one; the select string is pinned in the unit test since mocks cannot see PostgREST ambiguity.
|
||||
[2026-08-29] Issue #1661 resolved as option (a): a private marking (is_business=false) STAYS a real booking (eget uttag/insättning on 2013/2018, 2893 for an AB) and therefore stays blocked in a locked or closed period; the actionable path for a row that is no affärshändelse (PSD2 ghost row, duplicate from a reconnect, never-executed transfer) is ignore, which writes no verifikat and is allowed in any period. No ledger or schema semantics changed. A "no-booking private flag" (option b) was rejected: an owner withdrawal IS an affärshändelse under BFL 5 kap 1 § and must be booked, so skipping the verifikat for unbooked private rows would let real withdrawals vanish from a period with nothing in the ledger; the 2026-07-26 lock-guard entry already judged ignore the legal escape hatch and the DB CHECK transactions_is_ignored_no_journal_entry guarantees an ignored row is unbooked. Shipped as: TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED (400, details.suggested_action 'ignore') on all four categorize surfaces instead of a bare PERIOD_LOCKED, a v1 ignore verb (POST/DELETE /transactions/{id}/ignore), a staged MCP tool gnubok_ignore_transaction (op type ignore_transaction, risk low), and an "Ignorera i stället" toast action on the Transaktioner page. The regex-matched phrases in period-service.ts lock/close messages are untouched.
|
||||
[2026-08-29] gnubok_ignore_transaction ships in the DEFAULT MCP catalog (payload ceiling 64.4K to 65K with a ledger entry, measured 64 863), not with catalogVisibility 'search' as the #1661 plan preferred: gnubok_call_tool bridges READ tools only, so a search-only WRITE is uncallable on Claude.ai (the 2026-08-26 connect-tools and 2026-08-27 update_customer entries), and the TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED remediation, three workflow loadouts and the reconcile-month skill instruct agents to CALL it; a search-only tool would reproduce exactly the dead end those entries fixed. Extending gnubok_categorize_transaction with an ignore flag was rejected: it would muddle a booking tool with triage (the error came from that very call), still needs the new op type, and the dashboard and v1 doors have a distinct verb. The read-demotion rule in the bench is skipped for the same reason as the set_run_salary entry: picking the read needs prod usage data.
|
||||
@@ -1397,3 +1401,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-31] Own-company-as-supplier guard nulls the supplier block instead of flagging or substituting the issuer: an empty LEVERANTOR is always safe, a guessed issuer is not; BYO/agent-supplied extraction paths are deliberately exempt (explicit input, not a model misread).
|
||||
[2026-08-31] gnubok-home-ok cache cookie is user-scoped (userId~host) instead of cleared on sign-out: sign-out happens client-side via supabase.auth.signOut so no server surface reliably sees it, while a value bound to the session's user makes any inherited verdict miss the cache by construction. Separator ~ because it is unreserved under encodeURIComponent AND a legal raw cookie octet, so the value round-trips identically whether or not the cookie layer percent-encodes. Old host-only cookies never match and self-heal; found via the amnas account-switch repro (two logins 9 s apart shared the verdict).
|
||||
[2026-08-31] Bookkeeping digest email is per-user per-COMPANY per-day (not one aggregated mail across companies): notification_log.company_id anchors the claim, subject lines stay unambiguous, and most users have one company; consultants can opt in and get one short mail per client. Window is a fixed last-24h (cron cadence) rather than tracking last-sent state. Settings toggle stays hardcoded Swedish like the rest of the push-notifications extension UI (no next-intl wiring in extension components); revisit if that surface is ever translated.
|
||||
[2026-08-31] Own-credentials seam forward-ported into the entitlement partition (skeptic refutation on PR #1747): a self-host serving bank_sync/skatteverket from its OWN env credentials (the same vars the extensions activate on) counts those as local capabilities, so upgrading an own-credentials self-host never dark-launches the connector gate against a working integration (the 2026-08-17 folded-flag incident shape). lib/entitlements/own-credentials.ts mirrors the connector-mode seam arriving in the instance-wiring PR (connector mode = key AND no own creds) and must stay in sync with it. org_lookup/migration have no own-credentials form. capability_blocked copy now has a self-host variant naming GNUBOK_CONNECTOR_KEY instead of the hosted subscription upsell, which misled operators toward a product they cannot buy for a self-host.
|
||||
|
||||
@@ -21185,8 +21185,9 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
|
||||
// Enforce the capability paywall: the MCP/agent path is a paid chokepoint
|
||||
// just like the HTTP routes (send_invoice → email_send, the two SKV
|
||||
// submissions → skatteverket). Fail-closed; self-hosted short-circuits to
|
||||
// all-on inside hasCapability. Blocks before any pending op is staged.
|
||||
// submissions → skatteverket). Fail-closed; self-hosted is all-on inside
|
||||
// hasCapability except connector capabilities without own credentials
|
||||
// (see lib/entitlements). Blocks before any pending op is staged.
|
||||
const requiredCapability = MCP_TOOL_CAPABILITY_MAP[toolName]
|
||||
if (requiredCapability && !(await hasCapability(supabase, tenantId, requiredCapability))) {
|
||||
const capError = { error: capabilityBlockedError(requiredCapability) }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- capability_grants.source gains 'connector'.
|
||||
--
|
||||
-- A self-hosted instance cannot provide the connector capabilities
|
||||
-- (bank_sync, skatteverket, org_lookup, migration: see CONNECTOR_CAPABILITIES
|
||||
-- in lib/entitlements/keys.ts) on its own; they run on services Accounted
|
||||
-- operates. The instance's hourly connector sync validates its connector key
|
||||
-- against the hosted service and writes these grants with source =
|
||||
-- 'connector' and a short expiry (min(now + 72h, period_end + 3d)), so the
|
||||
-- grant rows double as the offline cache: the existing expiry check is the
|
||||
-- grace period, no new cache code. 401/403 from the hosted service deletes
|
||||
-- them (freeze-and-retain); a network error leaves them.
|
||||
--
|
||||
-- The CHECK was declared inline in 20260628140000 and auto-named, so it is
|
||||
-- looked up by definition rather than by name. Hosted companies never
|
||||
-- receive connector grants: the trial-seed trigger hardcodes the PAID keys
|
||||
-- (20260818170000) and the Stripe writer uses source = 'stripe'.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_constraint text;
|
||||
BEGIN
|
||||
SELECT c.conname
|
||||
INTO v_constraint
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND t.relname = 'capability_grants'
|
||||
AND c.contype = 'c'
|
||||
-- Postgres stores `source IN (...)` as `(source = ANY (ARRAY[...]))`,
|
||||
-- so match the column reference at the start of the CHECK body.
|
||||
AND pg_get_constraintdef(c.oid) ~ '\(source\s+(=|IN)\s';
|
||||
|
||||
IF v_constraint IS NOT NULL THEN
|
||||
EXECUTE format('ALTER TABLE public.capability_grants DROP CONSTRAINT %I', v_constraint);
|
||||
END IF;
|
||||
|
||||
ALTER TABLE public.capability_grants
|
||||
ADD CONSTRAINT capability_grants_source_check
|
||||
CHECK (source IN ('trial', 'stripe', 'manual', 'comp', 'connector'));
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN public.capability_grants.source IS
|
||||
'trial | stripe | manual | comp | connector. connector = written by a self-hosted instance''s connector sync from its hosted connector key; expiry doubles as the offline grace.';
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Require an expiry on every source='connector' capability grant.
|
||||
--
|
||||
-- Connector grants are the self-host instance's offline cache: the hourly
|
||||
-- connector sync writes them with expires_at = min(now + 72h, period_end + 3d)
|
||||
-- and deletes them when the subscription lapses. Both the TS gate
|
||||
-- (lib/entitlements/has-capability.ts grantIsActive) and the SQL helper
|
||||
-- (public.company_has_capability) treat a NULL expires_at as active forever,
|
||||
-- so a hand-written connector row without an expiry would be a permanent
|
||||
-- unlock that no sync ever revokes. No writer legitimately produces that
|
||||
-- shape; make the database refuse it (CodeRabbit finding on PR #1747,
|
||||
-- defense in depth on top of the sync's own arithmetic).
|
||||
--
|
||||
-- Safe to validate inline: 'connector' entered the source CHECK only in
|
||||
-- 20260831170000 (this same PR) and no code path has written a connector row
|
||||
-- yet, so no existing row can violate the constraint. The table is small
|
||||
-- (a handful of grant rows per company); the ACCESS EXCLUSIVE lock is brief.
|
||||
|
||||
ALTER TABLE public.capability_grants
|
||||
ADD CONSTRAINT capability_grants_connector_expiry_check
|
||||
CHECK (source <> 'connector' OR expires_at IS NOT NULL);
|
||||
|
||||
COMMENT ON CONSTRAINT capability_grants_connector_expiry_check
|
||||
ON public.capability_grants IS
|
||||
'Connector grants are a short-lived offline cache written by the instance connector sync; a NULL expiry would be a permanent unlock nothing revokes.';
|
||||
Reference in New Issue
Block a user