feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly (#1748)

* 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>

* feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly

Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the
entitlement partition (#1747). Nothing is purchasable yet; this is the
plumbing both ends need before the first manually issued key.

Hosted side:
- Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix,
  org_number, pinned instance_url, scopes, status, Stripe ids,
  current_period_end, per-minute rate limit, active_company_count,
  last_seen/synced) and connector_usage_events (per-request metering,
  separate from metered_events whose company_id references hosted
  companies). RLS on, NO policies: service role only. RPC
  validate_and_increment_connector_key copies the api_keys pattern (FOR
  UPDATE, minute window, suspended reported not counted, revoked = no row)
  and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role.
  pg-real test covers validate/count, unknown+revoked, suspended, rate
  limit, execute privileges per role, RLS invisibility, usage cascade.
- lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts
  (generate/hash/validate -> 401/403/429 mapping),
  with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per
  request, 500 envelope on handler throw), /api/connect/entitlements GET +
  POST (records active_company_count, pins instance_url on first report,
  never moves a pinned one), scripts/issue-connector-key.ts (dry run unless
  --confirm, prints the key once + the .env lines).

Instance side:
- lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL
  default https://app.gnubok.se), sync.ts: reports the active company count
  and writes source='connector' grants for every company x covered scope,
  expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status
  deletes them (freeze-and-retain); network/5xx/429 leave them alone.
  /api/connector/sync/cron (hourly) runs it; not_configured without a key.
- Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json,
  with reasons) + drift tests; docker/crontab.self-hosted regenerated with
  the hourly sync. Docs (SELF-HOSTING connector section, env templates),
  DECISIONS.

Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes
and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests
run locally against supabase/postgres with every migration applied.
no-phantom-columns ceiling +1 with a reason (the bulk grant upsert).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(connect): update pg test to re-versioned migration 20260831190000

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb

* fix(connect): RPC errors answer 503 not 401; X-Connector-Key wins over Authorization

A hosted DB error mapped to 401 made the instance sync treat a pooler
blip as key revocation and delete its entire connector grant cache,
zeroing the 72h offline grace. 503 lands in the sync's keep-grants
branch (already test-pinned). Bearer-first extraction hashed the
upstream token on dual-header proxied calls, 401ing the exact shape
X-Connector-Key exists for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb

* fix(connect): sync deletes grants only on a body-proven connector rejection, never bare 401/403

A WAF challenge page, edge deployment protection, or an egress proxy
answers 401/403 without the hosted app ever running; trusting status
alone wiped the instance's 72h offline grant cache within the hour.
Deletion now requires the hosted route's own rejection code in the
JSON body; codeless 401/403 keeps grants (server_error branch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb

* fix(connect): PR #1748 review batch: https-only connect URL, atomic pin, prefix-gated Bearer, deferred metering, entitlements validation, integer months

- GNUBOK_CONNECT_URL must be https (http only for loopback); invalid or
  plaintext URLs disable the connector instead of sending the key.
- instance_url pin update filters on IS NULL; a lost race re-reads and
  reports the winner's pin.
- extractConnectorKey: a Bearer is the connector credential only with
  the gnubok_ck_ prefix; upstream Bearer falls through to X-Connector-Key.
- Usage metering runs via after() off the response path (inline outside
  a request scope).
- Sync validates entitlements shape: unknown status or malformed
  current_period_end keeps grants (server_error), never deletes.
- issue-connector-key rejects fractional --months.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
Jakob Wennberg
2026-08-31 19:51:17 +01:00
committed by GitHub
parent cfce2de925
commit 0ff1b05553
24 changed files with 1949 additions and 12 deletions
+6
View File
@@ -40,6 +40,12 @@ NEXT_PUBLIC_SELF_HOSTED=true
# (http:// by ws://). Set only if Realtime is served from another origin.
# NEXT_PUBLIC_SUPABASE_WS_URL=wss://your-project.supabase.co
# Optional: connector subscription for this self-hosted instance (bank sync,
# Skatteverket API, company lookup, migration run on services Accounted
# operates). Key issued by Accounted; the hourly connector sync writes the
# capability grants. See docs/SELF-HOSTING.md "Connector subscription".
# GNUBOK_CONNECTOR_KEY=
# GNUBOK_CONNECT_URL=https://app.gnubok.se
# Optional: AI features (document extraction, in-app assistant). The image
# includes the extraction extensions; set ONE backend. See
# docs/SELF-HOSTING.md "AI Features" for the full list of model overrides.
+3
View File
@@ -108,6 +108,9 @@ RECEIPT_HUNT_COMPANY_IDS=
# AI_STRICT_JSON=false # openai-compatible only: response_format json_schema when the provider enforces it
# AI_EXTRACTION_MAX_TOKENS=8192 # output cap for document extraction (legacy BEDROCK_MAX_TOKENS)
# AI_PROVIDER=
# Self-hosted connector subscription (instance side). Leave unset on hosted.
# GNUBOK_CONNECTOR_KEY=
# GNUBOK_CONNECT_URL=https://app.gnubok.se
# Outbound email (invoices, reminders). Pick one provider. Resend is what
# hosted runs; SMTP is for self-hosts that want mail on their own relay.
# EMAIL_PROVIDER=resend|smtp # optional; RESEND_API_KEY wins, else SMTP_HOST
+4
View File
@@ -1140,6 +1140,8 @@ 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 and outside the trial-seed trigger, 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] Connector-key infrastructure (plan WS3 PR4) ships the hosted registry + the instance sync, nothing a customer can buy yet: connector_keys / connector_usage_events are service-role-only tables (RLS on, no policies) with an atomic validate_and_increment_connector_key RPC that copies the api_keys pattern (SHA-256 at rest, FOR UPDATE row lock, per-minute window) and is REVOKEd from PUBLIC, anon and authenticated before anyone can call it (the SECURITY DEFINER exposure lesson applied up front); /api/connect/entitlements behind withConnectorAuth (Bearer or X-Connector-Key, 401/403/429, one usage row per request); keys issued by scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once). The instance side writes source='connector' grants expiring at min(now+72h, period_end+3d) on every hourly sync, deletes them on 401/403 or a non-active status, and leaves them alone on network/5xx errors: the grant rows ARE the offline cache, no new cache code. The hourly job lives only in docker/crontab.self-hosted through a new EXTRA_JOBS table in the crontab generator (with its own drift tests), because vercel.json is the hosted schedule and hosted has no connector key. connector_usage_events is a separate table because metered_events.company_id references hosted companies and a connector key belongs to an instance, not a company here. Deferred: the proxy routes (bank/skv/org/migration: founder legal check with Enable Banking/SKV/TIC is the launch blocker), a connect.gnubok.se host rewrite (the instance calls app.gnubok.se/api/connect directly; a dedicated host is a later DNS decision), the self-host Docker preset change and the settings row.
[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).
@@ -1402,3 +1404,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[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.
[2026-08-31] Connector key validation maps a database/RPC error to 503 CONNECTOR_VALIDATION_UNAVAILABLE, never 401 (skeptic refutation on PR #1748): the instance sync deletes its entire connector grant cache on 401/403 (revocation semantics), so the api-keys fail-closed-to-401 precedent would let a transient hosted DB blip destroy a paying instance's 72h offline grace; 503 lands in the sync's keep-grants branch. In the same pass X-Connector-Key now wins over Authorization in extractConnectorKey: the header exists solely for proxied calls where Authorization carries an upstream token (the SKV data proxy sends both), and Bearer-first hashed the upstream token and 401'd exactly that shape.
[2026-08-31] Connector sync deletes its grant cache only on a 401/403 whose JSON body carries a connector rejection code (CONNECTOR_KEY_MISSING/INVALID/SUSPENDED), never on status alone (second skeptic refutation on PR #1748, same failure class as the RPC-error mapping one layer up): a Vercel WAF challenge page, edge deployment protection, or a self-host egress proxy all answer 401/403 without the hosted app running, and status-trusting deletion let any of them wipe a paying instance's 72h offline grace within the hour. A codeless 401/403 now lands in the keep-grants server_error branch and the cache expires naturally if the condition persists.
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
const KEY = {
id: '11111111-1111-4111-8111-111111111111',
orgNumber: '5561234567',
instanceUrl: null as string | null,
scopes: ['bank_sync', 'skatteverket', 'org_lookup', 'migration'],
status: 'active' as const,
currentPeriodEnd: '2027-01-01T00:00:00.000Z',
}
type UpdateResult = { error: unknown; data: Array<{ instance_url: string | null }> | null }
const updateResults: UpdateResult[] = []
const updateSelect = vi.fn(() => Promise.resolve(updateResults.shift() ?? { error: null, data: [{ instance_url: null }] }))
const updateIs = vi.fn(() => ({ select: updateSelect }))
const updateEq = vi.fn(() => ({ is: updateIs, select: updateSelect }))
const update = vi.fn((_payload: Record<string, unknown>) => ({ eq: updateEq }))
const from = vi.fn(() => ({ update }))
const logWarn = vi.fn()
// The wrapper is exercised in its own test; here it is replaced by a
// pass-through that injects the validated key so the handler logic is what
// gets tested.
vi.mock('@/lib/connect/hosted/with-connector-auth', () => ({
withConnectorAuth: (_op: string, handler: (req: Request, ctx: unknown) => Promise<Response>) => (req: Request) =>
handler(req, {
requestId: 'conn_test',
log: { info: vi.fn(), warn: logWarn, error: vi.fn() },
supabase: { from },
key: { ...KEY, instanceUrl: currentInstanceUrl },
}),
}))
let currentInstanceUrl: string | null = null
import { GET, POST } from '../route'
beforeEach(() => {
vi.clearAllMocks()
currentInstanceUrl = null
updateResults.length = 0
})
describe('GET /api/connect/entitlements', () => {
it('returns the key entitlements', async () => {
const res = await GET(createMockRequest('/api/connect/entitlements'))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(body.data).toMatchObject({
status: 'active',
scopes: KEY.scopes,
current_period_end: KEY.currentPeriodEnd,
org_number: '5561234567',
instance_url: null,
})
expect(typeof body.data.server_time).toBe('string')
})
})
describe('POST /api/connect/entitlements', () => {
it('400 on an invalid report', async () => {
const res = await POST(createMockRequest('/api/connect/entitlements', { method: 'POST', body: { active_company_count: -1 } }))
expect(res.status).toBe(400)
expect(update).not.toHaveBeenCalled()
})
it('records the active company count and pins instance_url on first report', async () => {
const res = await POST(
createMockRequest('/api/connect/entitlements', {
method: 'POST',
body: { active_company_count: 12, instance_url: 'https://bokforing.example.se', app_version: '1.2.3' },
}),
)
const { status, body } = await parseJsonResponse<{ data: { instance_url: string | null } }>(res)
expect(status).toBe(200)
expect(from).toHaveBeenCalledWith('connector_keys')
const payload = update.mock.calls[0][0]
expect(payload).toMatchObject({ active_company_count: 12, instance_url: 'https://bokforing.example.se' })
expect(typeof payload.last_synced_at).toBe('string')
expect(updateEq).toHaveBeenCalledWith('id', KEY.id)
expect(body.data.instance_url).toBe('https://bokforing.example.se')
})
// A leaked key must not be able to re-home the subscription.
it('never moves a pinned instance_url; logs the mismatch', async () => {
currentInstanceUrl = 'https://bokforing.example.se'
const res = await POST(
createMockRequest('/api/connect/entitlements', {
method: 'POST',
body: { active_company_count: 3, instance_url: 'https://evil.example.com' },
}),
)
expect(res.status).toBe(200)
const payload = update.mock.calls[0][0]
expect(payload.instance_url).toBeUndefined()
expect(logWarn).toHaveBeenCalled()
const { body } = await parseJsonResponse<{ data: { instance_url: string | null } }>(res)
expect(body.data.instance_url).toBe('https://bokforing.example.se')
})
it('500 when the update fails', async () => {
updateResults.push({ error: { message: 'boom' }, data: null })
const res = await POST(createMockRequest('/api/connect/entitlements', { method: 'POST', body: { active_company_count: 1 } }))
expect(res.status).toBe(500)
})
// Two concurrent FIRST reports: the pinning update is conditional on
// instance_url IS NULL, so the loser affects no row and must surface the
// winner's pin instead of its own URL.
it('losing the pin race keeps the first pin and reports it back', async () => {
updateResults.push({ error: null, data: [] }) // conditional pin: no row matched
updateResults.push({ error: null, data: [{ instance_url: 'https://first.example.se' }] }) // counter-only fallback
const res = await POST(
createMockRequest('/api/connect/entitlements', {
method: 'POST',
body: { active_company_count: 2, instance_url: 'https://second.example.se' },
}),
)
const { status, body } = await parseJsonResponse<{ data: { instance_url: string | null } }>(res)
expect(status).toBe(200)
expect(body.data.instance_url).toBe('https://first.example.se')
expect(logWarn).toHaveBeenCalled()
expect(update).toHaveBeenCalledTimes(2)
expect(update.mock.calls[1][0]).not.toHaveProperty('instance_url')
})
})
+99
View File
@@ -0,0 +1,99 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
import { withConnectorAuth, type ConnectorContext } from '@/lib/connect/hosted/with-connector-auth'
import type { ConnectorEntitlements } from '@/lib/connect/contract'
/**
* /api/connect/entitlements: what a self-hosted instance's connector key
* entitles it to. The instance's hourly sync (lib/connect/instance/sync.ts)
* POSTs its active company count (quantity billing input) and gets back
* status, scopes and the paid period; it then writes source='connector'
* capability grants that expire at min(now + 72h, period_end + 3d), so the
* grant rows are the offline cache and the hosted service only has to be
* reachable once every three days.
*
* GET -> entitlements (no side effects beyond last_seen_at)
* POST -> { active_company_count, instance_url?, app_version? } -> entitlements;
* records the count and pins instance_url on first report.
*/
const SyncReportSchema = z.object({
active_company_count: z.number().int().min(0).max(1_000_000),
instance_url: z.string().url().max(512).optional(),
app_version: z.string().max(64).optional(),
})
function entitlementsOf(ctx: ConnectorContext, instanceUrl: string | null): ConnectorEntitlements {
return {
status: ctx.key.status,
scopes: ctx.key.scopes,
current_period_end: ctx.key.currentPeriodEnd,
org_number: ctx.key.orgNumber,
instance_url: instanceUrl,
server_time: new Date().toISOString(),
}
}
export const GET = withConnectorAuth('connect.entitlements', async (_request, ctx) => {
return NextResponse.json({ data: entitlementsOf(ctx, ctx.key.instanceUrl) })
})
export const POST = withConnectorAuth('connect.entitlements', async (request, ctx) => {
const parsed = await validateBody(request, SyncReportSchema, { log: ctx.log, operation: 'connect.entitlements' })
if (!parsed.success) return parsed.response
const report = parsed.data
// instance_url is pinned: the first report claims it, later reports that
// disagree are logged but never move it (a leaked key cannot re-home the
// subscription to another instance).
let instanceUrl = ctx.key.instanceUrl
const pinNow = !!report.instance_url && !instanceUrl
if (report.instance_url && instanceUrl && instanceUrl !== report.instance_url) {
ctx.log.warn('sync reported a different instance_url than the pinned one', {
pinned: instanceUrl,
reported: report.instance_url,
})
}
if (pinNow) instanceUrl = report.instance_url ?? null
const lastSyncedAt = new Date().toISOString()
// Two literal payloads rather than one built object: the no-phantom-columns
// scanner resolves literals only. The pinning update additionally filters
// on instance_url IS NULL so two concurrent first reports cannot both pin:
// the loser's conditional update matches no row and re-reads the winner's
// URL below (first-report-wins, later reports never move the pin).
const { error, data: pinned } = pinNow
? await ctx.supabase
.from('connector_keys')
.update({
active_company_count: report.active_company_count,
last_synced_at: lastSyncedAt,
instance_url: instanceUrl,
})
.eq('id', ctx.key.id)
.is('instance_url', null)
.select('instance_url')
: await ctx.supabase
.from('connector_keys')
.update({ active_company_count: report.active_company_count, last_synced_at: lastSyncedAt })
.eq('id', ctx.key.id)
.select('instance_url')
if (error) {
ctx.log.error('failed to record connector sync', error)
return NextResponse.json({ error: 'Failed to record sync', code: 'INTERNAL_ERROR' }, { status: 500 })
}
if (pinNow && (pinned ?? []).length === 0) {
// Lost the pin race: record the counters and surface the winner's pin.
const { data: existing } = await ctx.supabase
.from('connector_keys')
.update({ active_company_count: report.active_company_count, last_synced_at: lastSyncedAt })
.eq('id', ctx.key.id)
.select('instance_url')
instanceUrl = (existing?.[0] as { instance_url: string | null } | undefined)?.instance_url ?? null
ctx.log.warn('instance_url pin race lost; keeping the first pin', {
reported: report.instance_url,
pinned: instanceUrl,
})
}
return NextResponse.json({ data: entitlementsOf(ctx, instanceUrl) })
})
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const h = vi.hoisted(() => ({
logInfo: vi.fn(),
logError: vi.fn(),
sync: vi.fn(),
}))
vi.mock('@/lib/api/with-cron-context', () => ({
withCronContext:
(_name: string, handler: (req: Request, ctx: unknown) => Promise<Response>) =>
(req: Request) =>
handler(req, { log: { info: h.logInfo, error: h.logError, warn: vi.fn() }, requestId: 'cron_test' }),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => ({ from: vi.fn() }),
}))
vi.mock('@/lib/connect/instance/sync', () => ({
syncConnectorEntitlements: (...args: unknown[]) => h.sync(...args),
}))
import { GET } from '../route'
beforeEach(() => vi.clearAllMocks())
afterEach(() => vi.unstubAllEnvs())
describe('GET /api/connector/sync/cron', () => {
it('answers not_configured without a key and never calls the sync', async () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', '')
const res = await GET(new Request('http://localhost:3000/api/connector/sync/cron'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ data: { outcome: 'not_configured' } })
expect(h.sync).not.toHaveBeenCalled()
})
it('runs the sync with the instance origin and returns its result', async () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://bokforing.example.se')
h.sync.mockResolvedValue({ outcome: 'synced', companies: 2, grantsUpserted: 8, grantsDeleted: 0 })
const res = await GET(new Request('http://localhost:3000/api/connector/sync/cron'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ data: { outcome: 'synced', companies: 2, grantsUpserted: 8, grantsDeleted: 0 } })
expect(h.sync).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ instanceUrl: 'https://bokforing.example.se' }))
expect(h.logInfo).toHaveBeenCalled()
})
it('logs and returns an error envelope when the sync throws', async () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
h.sync.mockRejectedValue(new Error('Failed to upsert connector grants: boom'))
const res = await GET(new Request('http://localhost:3000/api/connector/sync/cron'))
expect(res.status).toBeGreaterThanOrEqual(500)
expect(h.logError).toHaveBeenCalled()
})
})
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server'
import { withCronContext } from '@/lib/api/with-cron-context'
import { createServiceClient } from '@/lib/supabase/server'
import { getConnectorConfig } from '@/lib/connect/instance/config'
import { syncConnectorEntitlements } from '@/lib/connect/instance/sync'
import { errorResponse } from '@/lib/errors/get-structured-error'
/**
* GET /api/connector/sync/cron: hourly on SELF-HOSTED deployments only
* (docker/crontab.self-hosted via EXTRA_JOBS in scripts/generate-crontabs.ts;
* deliberately not in vercel.json, hosted has no connector key).
*
* Validates GNUBOK_CONNECTOR_KEY against the hosted connector service,
* reports the active company count, and refreshes the source='connector'
* capability grants for every company on this instance. Grant expiry
* (min(now + 72h, period_end + 3d)) is the offline grace: a hosted outage
* shorter than that changes nothing here. An instance without a key answers
* 200 with outcome not_configured, so the schedule costs one cheap request.
*/
export const maxDuration = 60
export const GET = withCronContext('cron.connector_sync', async (_request, ctx) => {
if (!getConnectorConfig()) {
return NextResponse.json({ data: { outcome: 'not_configured' } })
}
try {
const result = await syncConnectorEntitlements(createServiceClient(), {
instanceUrl: process.env.NEXT_PUBLIC_APP_URL?.trim() || null,
appVersion: process.env.npm_package_version ?? null,
})
ctx.log.info('connector sync run', { ...result })
return NextResponse.json({ data: result })
} catch (err) {
ctx.log.error('connector sync failed', err)
return errorResponse(err, ctx.log, { requestId: ctx.requestId })
}
})
+3
View File
@@ -50,3 +50,6 @@
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/notifications/bookkeeping-digest/cron
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
5,20,35,50 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/outbound/status/cron
# self-hosted-only jobs, not in vercel.json: see EXTRA_JOBS in scripts/generate-crontabs.ts
17 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/connector/sync/cron
+17
View File
@@ -337,6 +337,23 @@ AUTH_SIGNUPS_DISABLED=true
Set this when you have turned public signup off in GoTrue (`disable_signup`). The invite route then provisions the invitee's account through the auth admin API before writing the invitation, and GoTrue mails its own set-password link via the Supabase SMTP settings; the in-app accept link is still returned to the inviter.
### Connector subscription (self-hosted instances)
Everything a self-hosted instance runs itself is free (AGPL). Four capabilities depend on services only Accounted operates and are therefore gated on a self-host: bank sync (our PSD2/AISP credentials), Skatteverket API submission and skattekonto sync (our API client registration), company lookup (TIC) and migration from Fortnox/Visma/Bokio/Björn Lundén (the migration gateway). A **connector key** unlocks them for every company on the instance; it is priced per active company at parity with hosted and is issued manually by Accounted for now (self-serve later).
```bash
GNUBOK_CONNECTOR_KEY=gnubok_ck_... # issued by Accounted, shown once
# GNUBOK_CONNECT_URL=https://app.gnubok.se # default: the hosted connector service
```
The cron sidecar calls `/api/connector/sync/cron` hourly (it is listed in `docker/crontab.self-hosted` only): the instance reports its active company count, the hosted service answers with the key's status and scopes, and the instance writes `capability_grants` rows with `source = 'connector'` that expire after **72 hours** (or three days past the paid period, whichever is sooner). Those rows are the offline cache: a hosted outage shorter than that changes nothing, a revoked or lapsed key freezes the connector capabilities within days, and nothing in the instance phones home for permission to run the bookkeeping. An instance without a key answers `not_configured` and stays unaffected. To run the sync once by hand after pasting the key:
```bash
curl -sf -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/connector/sync/cron
```
The proxied connector services themselves (bank, Skatteverket, lookup, migration through `app.gnubok.se/api/connect/*`) ship in a following release; until then the key is validated and the grants are written, nothing more.
### Push Notifications
```bash
+45
View File
@@ -0,0 +1,45 @@
/**
* The wire contract between a self-hosted Accounted instance and the hosted
* connector service (app.gnubok.se/api/connect/*). Shared by both sides so
* the instance sync and the hosted endpoint cannot drift apart.
*
* Background: a self-hosted instance runs everything itself except the
* services only Accounted can operate (bank sync via our PSD2/AISP
* credentials, the Skatteverket API client, TIC org lookup, the migration
* gateway). A connector key (`gnubok_ck_...`) is the subscription token for
* those; this is the Nabu Casa model: everything local is free AGPL, the key
* buys access to the hosted connectors, and enforcement is key auth at the
* hosted proxy, never a licence check inside the instance.
*/
export const CONNECTOR_KEY_PREFIX = 'gnubok_ck_'
/** Header alternative to `Authorization: Bearer`, for proxied calls where Authorization carries an upstream token. */
export const CONNECTOR_KEY_HEADER = 'x-connector-key'
export const CONNECTOR_ENTITLEMENTS_PATH = '/api/connect/entitlements'
/** Default hosted origin. app.gnubok.se stays the machine-facing host for API traffic. */
export const DEFAULT_CONNECT_BASE_URL = 'https://app.gnubok.se'
export type ConnectorKeyStatus = 'active' | 'suspended' | 'revoked'
/** What the hosted service tells an instance about its key. */
export interface ConnectorEntitlements {
status: ConnectorKeyStatus
/** Capability keys the subscription covers (subset of CONNECTOR_CAPABILITIES). */
scopes: string[]
/** End of the paid period, ISO; null for an open-ended (manually issued) key. */
current_period_end: string | null
org_number: string
/** The instance origin this key is pinned to; null until the first sync claims it. */
instance_url: string | null
server_time: string
}
/** What an instance reports on every sync (quantity billing input). */
export interface ConnectorSyncReport {
active_company_count: number
instance_url?: string
app_version?: string
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateConnectorKey, hashConnectorKey, isConnectorKeyFormat, validateConnectorKey } from '../keys'
function supabaseWithRpc(result: { data?: unknown; error?: unknown }): { supabase: SupabaseClient; rpc: ReturnType<typeof vi.fn> } {
const rpc = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
return { supabase: { rpc } as unknown as SupabaseClient, rpc }
}
const ROW = {
connector_key_id: '11111111-1111-4111-8111-111111111111',
org_number: '5561234567',
instance_url: 'https://bokforing.example.se',
scopes: ['bank_sync', 'skatteverket'],
status: 'active',
current_period_end: '2027-01-01T00:00:00.000Z',
rate_limited: false,
}
describe('connector key primitives', () => {
it('generates a gnubok_ck_ key with a display prefix and a SHA-256 hash', () => {
const { key, hash, prefix } = generateConnectorKey()
expect(key.startsWith('gnubok_ck_')).toBe(true)
expect(key.length).toBeGreaterThan(40)
expect(prefix).toBe(key.slice(0, 18))
expect(hash).toBe(hashConnectorKey(key))
expect(hash).toMatch(/^[0-9a-f]{64}$/)
expect(generateConnectorKey().key).not.toBe(key)
})
it('recognises the key format', () => {
expect(isConnectorKeyFormat(generateConnectorKey().key)).toBe(true)
expect(isConnectorKeyFormat('gnubok_sk_abcdefghijklmnopqrstuvwxyz')).toBe(false)
expect(isConnectorKeyFormat('gnubok_ck_short')).toBe(false)
})
})
describe('validateConnectorKey', () => {
it('rejects a malformed key without touching the database', async () => {
const { supabase, rpc } = supabaseWithRpc({ data: [ROW] })
expect(await validateConnectorKey('nope', supabase)).toMatchObject({ ok: false, status: 401, code: 'CONNECTOR_KEY_INVALID' })
expect(rpc).not.toHaveBeenCalled()
})
it('hashes the key and calls the atomic RPC', async () => {
const { key, hash } = generateConnectorKey()
const { supabase, rpc } = supabaseWithRpc({ data: [ROW] })
const result = await validateConnectorKey(key, supabase)
expect(rpc).toHaveBeenCalledWith('validate_and_increment_connector_key', { p_key_hash: hash })
expect(result).toEqual({
ok: true,
key: {
id: ROW.connector_key_id,
orgNumber: '5561234567',
instanceUrl: 'https://bokforing.example.se',
scopes: ['bank_sync', 'skatteverket'],
status: 'active',
currentPeriodEnd: '2027-01-01T00:00:00.000Z',
},
})
})
it('maps no row (unknown/revoked) to 401, but an RPC error to 503', async () => {
const { key } = generateConnectorKey()
expect(await validateConnectorKey(key, supabaseWithRpc({ data: [] }).supabase)).toMatchObject({ ok: false, status: 401 })
// NEVER 401 on a database error: the instance sync deletes its whole
// connector grant cache on 401/403, so a hosted pooler blip answered as
// 401 would destroy a paying instance's 72h offline grace. 503 lands in
// the sync's keep-grants branch.
expect(await validateConnectorKey(key, supabaseWithRpc({ error: { message: 'boom' } }).supabase)).toMatchObject({
ok: false,
status: 503,
code: 'CONNECTOR_VALIDATION_UNAVAILABLE',
})
})
it('maps a suspended key to 403 and a rate-limited one to 429', async () => {
const { key } = generateConnectorKey()
expect(
await validateConnectorKey(key, supabaseWithRpc({ data: [{ ...ROW, status: 'suspended' }] }).supabase),
).toMatchObject({ ok: false, status: 403, code: 'CONNECTOR_KEY_SUSPENDED' })
expect(
await validateConnectorKey(key, supabaseWithRpc({ data: [{ ...ROW, rate_limited: true }] }).supabase),
).toMatchObject({ ok: false, status: 429, code: 'CONNECTOR_RATE_LIMITED' })
})
})
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
const validateMock = vi.fn()
vi.mock('../keys', () => ({
validateConnectorKey: (...args: unknown[]) => validateMock(...args),
}))
const usageInsert = vi.fn()
const from = vi.fn(() => ({ insert: usageInsert }))
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: () => ({ from }),
}))
import { extractConnectorKey, withConnectorAuth } from '../with-connector-auth'
const VALID = {
ok: true,
key: {
id: '11111111-1111-4111-8111-111111111111',
orgNumber: '5561234567',
instanceUrl: null,
scopes: ['bank_sync'],
status: 'active',
currentPeriodEnd: null,
},
}
function req(headers: Record<string, string> = {}): Request {
return new Request('https://app.gnubok.se/api/connect/entitlements', { headers })
}
beforeEach(() => {
vi.clearAllMocks()
usageInsert.mockResolvedValue({ error: null })
})
describe('extractConnectorKey', () => {
it('takes a connector-prefixed Bearer, else X-Connector-Key, else the raw Bearer for the format-check 401', () => {
expect(extractConnectorKey(req({ authorization: 'Bearer gnubok_ck_a' }))).toBe('gnubok_ck_a')
expect(extractConnectorKey(req({ 'x-connector-key': 'gnubok_ck_b' }))).toBe('gnubok_ck_b')
// A connector-prefixed Bearer is unambiguous and wins.
expect(extractConnectorKey(req({ authorization: 'Bearer gnubok_ck_a', 'x-connector-key': 'gnubok_ck_b' }))).toBe('gnubok_ck_a')
// The proxied-call shape (SKV data proxy): Authorization is the user's
// upstream SKV token, X-Connector-Key authenticates the instance. The
// connector key MUST win or every such request 401s on a hashed upstream
// token.
expect(extractConnectorKey(req({ authorization: 'Bearer upstream-skv-token', 'x-connector-key': 'gnubok_ck_b' }))).toBe('gnubok_ck_b')
// Non-prefixed Bearer alone still reaches the format check (401).
expect(extractConnectorKey(req({ authorization: 'Bearer not-a-connector-key' }))).toBe('not-a-connector-key')
expect(extractConnectorKey(req())).toBeNull()
expect(extractConnectorKey(req({ authorization: 'Basic xyz' }))).toBeNull()
})
})
describe('withConnectorAuth', () => {
const handler = vi.fn(async (_req: Request, _ctx: { key: { id: string } }) => NextResponse.json({ data: 'ok' }))
const wrapped = withConnectorAuth('connect.entitlements', handler)
it('401 without a key, and never calls the handler', async () => {
const res = await wrapped(req())
expect(res.status).toBe(401)
expect(await res.json()).toMatchObject({ code: 'CONNECTOR_KEY_MISSING' })
expect(handler).not.toHaveBeenCalled()
expect(validateMock).not.toHaveBeenCalled()
})
it('passes the validation failure through (401/403/429) with its code', async () => {
for (const failure of [
{ ok: false, status: 401, code: 'CONNECTOR_KEY_INVALID', error: 'Invalid connector key' },
{ ok: false, status: 403, code: 'CONNECTOR_KEY_SUSPENDED', error: 'Connector key is suspended' },
{ ok: false, status: 429, code: 'CONNECTOR_RATE_LIMITED', error: 'Rate limit exceeded' },
]) {
validateMock.mockResolvedValueOnce(failure)
const res = await wrapped(req({ authorization: 'Bearer gnubok_ck_x' }))
expect(res.status).toBe(failure.status)
expect(await res.json()).toEqual({ error: failure.error, code: failure.code })
}
expect(handler).not.toHaveBeenCalled()
})
it('runs the handler with the validated key and records one usage event', async () => {
validateMock.mockResolvedValueOnce(VALID)
const res = await wrapped(req({ authorization: 'Bearer gnubok_ck_x' }))
expect(res.status).toBe(200)
expect(res.headers.get('X-Request-Id')).toMatch(/^conn_/)
expect(handler).toHaveBeenCalledTimes(1)
expect(handler.mock.calls[0][1].key.id).toBe(VALID.key.id)
expect(from).toHaveBeenCalledWith('connector_usage_events')
expect(usageInsert).toHaveBeenCalledWith({
connector_key_id: VALID.key.id,
service: 'entitlements',
endpoint: '/api/connect/entitlements',
status_code: 200,
})
})
it('turns a throwing handler into a 500 envelope and still meters it', async () => {
validateMock.mockResolvedValueOnce(VALID)
const boom = withConnectorAuth('connect.entitlements', async () => {
throw new Error('db down')
})
const res = await boom(req({ authorization: 'Bearer gnubok_ck_x' }))
expect(res.status).toBe(500)
expect(await res.json()).toEqual({ error: 'Internal error', code: 'INTERNAL_ERROR' })
expect(usageInsert).toHaveBeenCalledWith(expect.objectContaining({ status_code: 500 }))
})
})
+101
View File
@@ -0,0 +1,101 @@
import crypto from 'node:crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { CONNECTOR_KEY_PREFIX, type ConnectorKeyStatus } from '../contract'
/**
* Hosted-side connector key primitives. Mirrors lib/auth/api-keys.ts for
* `gnubok_sk_` API keys: 32 CSPRNG bytes, SHA-256 at rest (the hash is the
* lookup; a slow KDF adds nothing on a 256-bit random secret and would sit on
* the hot path of every proxied request), atomic validate + rate-limit in a
* SECURITY DEFINER RPC that only service_role may execute.
*/
export function generateConnectorKey(): { key: string; hash: string; prefix: string } {
const random = crypto.randomBytes(32).toString('base64url')
const key = `${CONNECTOR_KEY_PREFIX}${random}`
return { key, hash: hashConnectorKey(key), prefix: key.slice(0, CONNECTOR_KEY_PREFIX.length + 8) }
}
export function hashConnectorKey(key: string): string {
return crypto.createHash('sha256').update(key).digest('hex')
}
export function isConnectorKeyFormat(key: string): boolean {
return key.startsWith(CONNECTOR_KEY_PREFIX) && key.length > CONNECTOR_KEY_PREFIX.length + 16
}
export interface ValidatedConnectorKey {
id: string
orgNumber: string
instanceUrl: string | null
scopes: string[]
status: ConnectorKeyStatus
currentPeriodEnd: string | null
}
export type ConnectorKeyValidation =
| { ok: true; key: ValidatedConnectorKey }
| { ok: false; status: 401 | 403 | 429; code: 'CONNECTOR_KEY_INVALID' | 'CONNECTOR_KEY_SUSPENDED' | 'CONNECTOR_RATE_LIMITED'; error: string }
| { ok: false; status: 503; code: 'CONNECTOR_VALIDATION_UNAVAILABLE'; error: string }
/**
* Validate a presented key: format check, RPC lookup (atomic rate-limit
* increment), status mapping. Never throws on a bad key.
*
* A database/RPC error maps to 503, NEVER 401: the instance-side sync treats
* 401/403 as key revocation and deletes its entire connector grant cache
* (lib/connect/instance/sync.ts), so answering a hosted pooler blip with 401
* would let a transient hosted incident destroy a paying instance's 72h
* offline grace. 503 lands in the sync's keep-grants branch. Only a genuine
* empty result (unknown or revoked key) is 401. This deliberately diverges
* from the api-keys precedent, where a spurious 401 costs one request.
*/
export async function validateConnectorKey(
key: string,
supabase: SupabaseClient = createServiceClientNoCookies(),
): Promise<ConnectorKeyValidation> {
if (!isConnectorKeyFormat(key)) {
return { ok: false, status: 401, code: 'CONNECTOR_KEY_INVALID', error: 'Invalid connector key' }
}
const { data, error } = await supabase.rpc('validate_and_increment_connector_key', {
p_key_hash: hashConnectorKey(key),
})
if (error) {
return {
ok: false,
status: 503,
code: 'CONNECTOR_VALIDATION_UNAVAILABLE',
error: 'Connector key validation temporarily unavailable',
}
}
if (!data || (Array.isArray(data) && data.length === 0)) {
return { ok: false, status: 401, code: 'CONNECTOR_KEY_INVALID', error: 'Invalid connector key' }
}
const row = (Array.isArray(data) ? data[0] : data) as {
connector_key_id: string
org_number: string
instance_url: string | null
scopes: string[] | null
status: string
current_period_end: string | null
rate_limited: boolean
}
if (row.status !== 'active') {
return { ok: false, status: 403, code: 'CONNECTOR_KEY_SUSPENDED', error: 'Connector key is suspended' }
}
if (row.rate_limited) {
return { ok: false, status: 429, code: 'CONNECTOR_RATE_LIMITED', error: 'Rate limit exceeded' }
}
return {
ok: true,
key: {
id: row.connector_key_id,
orgNumber: row.org_number,
instanceUrl: row.instance_url,
scopes: row.scopes ?? [],
status: 'active',
currentPeriodEnd: row.current_period_end,
},
}
}
+115
View File
@@ -0,0 +1,115 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { NextResponse, after } from 'next/server'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { createLogger, type Logger } from '@/lib/logger'
import { CONNECTOR_KEY_HEADER, CONNECTOR_KEY_PREFIX } from '../contract'
import { validateConnectorKey, type ValidatedConnectorKey } from './keys'
/**
* Wrapper for the hosted connector endpoints (app/api/connect/*), the
* connector twin of lib/api/v1/with-api-v1.ts.
*
* 1. Extracts the key from `Authorization: Bearer gnubok_ck_...` or, for
* proxied calls where Authorization carries an upstream token, from
* `X-Connector-Key`.
* 2. Validates it through the atomic RPC (rate-limited): 401 unknown /
* revoked, 403 suspended, 429 over the per-minute limit.
* 3. Runs the handler with a service-role client and the validated key.
* 4. Records one connector_usage_events row (metering, never blocking).
*
* These routes are reached by self-hosted instances with a key, never by a
* browser session, so there is no cookie/MFA handling here; the proxy
* middleware already lets /api/* through for bearer callers.
*/
export interface ConnectorContext {
requestId: string
log: Logger
supabase: SupabaseClient
key: ValidatedConnectorKey
}
type ConnectorHandler = (request: Request, ctx: ConnectorContext) => Promise<NextResponse | Response>
export function extractConnectorKey(request: Request): string | null {
// A Bearer value is the connector credential only when it looks like one
// (gnubok_ck_ prefix); otherwise it is an UPSTREAM token on a proxied call
// and the connector key rides in X-Connector-Key. Hashing the upstream
// token instead would 401 every such request. A non-prefixed Bearer with
// no X-Connector-Key still falls through to the format check's 401.
const auth = request.headers.get('authorization')
const bearer = auth?.startsWith('Bearer ') ? auth.slice(7).trim() || null : null
if (bearer?.startsWith(CONNECTOR_KEY_PREFIX)) return bearer
const header = request.headers.get(CONNECTOR_KEY_HEADER)
if (header?.trim()) return header.trim()
return bearer
}
export function withConnectorAuth(
operation: string,
handler: ConnectorHandler,
options: { service?: string } = {},
): (request: Request) => Promise<Response> {
const service = options.service ?? operation.split('.')[1] ?? operation
return async function wrapped(request: Request): Promise<Response> {
const requestId = `conn_${crypto.randomUUID()}`
const log = createLogger(`connect/${operation}`, { requestId, operation })
const supabase = createServiceClientNoCookies()
const presented = extractConnectorKey(request)
if (!presented) {
return NextResponse.json(
{ error: 'Missing connector key', code: 'CONNECTOR_KEY_MISSING' },
{ status: 401, headers: { 'X-Request-Id': requestId } },
)
}
const validation = await validateConnectorKey(presented, supabase)
if (!validation.ok) {
log.warn('connector auth rejected', { code: validation.code })
return NextResponse.json(
{ error: validation.error, code: validation.code },
{ status: validation.status, headers: { 'X-Request-Id': requestId } },
)
}
let response: Response
try {
response = await handler(request, { requestId, log, supabase, key: validation.key })
} catch (err) {
log.error('connector handler failed', err)
response = NextResponse.json(
{ error: 'Internal error', code: 'INTERNAL_ERROR' },
{ status: 500 },
)
}
response.headers.set('X-Request-Id', requestId)
// Metering: one row per request, never on the critical path.
const endpoint = (() => {
try {
return new URL(request.url).pathname
} catch {
return null
}
})()
const recordUsage = async (): Promise<void> => {
const { error: usageError } = await supabase.from('connector_usage_events').insert({
connector_key_id: validation.key.id,
service,
endpoint,
status_code: response.status,
})
if (usageError) log.warn('usage event not recorded', { err: usageError.message })
}
try {
// Off the response path: the caller should not wait on metering.
// Same pattern as lib/webhooks/dispatch-kick.ts.
after(() => recordUsage())
} catch {
// Outside a request scope (unit tests): record inline.
await recordUsage()
}
return response
}
}
@@ -0,0 +1,37 @@
import { describe, it, expect, afterEach, vi } from 'vitest'
import { getConnectorConfig, isConnectorConfigured } from '../config'
afterEach(() => vi.unstubAllEnvs())
describe('getConnectorConfig', () => {
it('is null without a key (hosted, or a self-host without a subscription)', () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', '')
expect(getConnectorConfig()).toBeNull()
expect(isConnectorConfigured()).toBe(false)
})
it('defaults the hosted origin to app.gnubok.se and strips trailing slashes from an override', () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
vi.stubEnv('GNUBOK_CONNECT_URL', '')
expect(getConnectorConfig()).toEqual({ key: 'gnubok_ck_x', baseUrl: 'https://app.gnubok.se' })
vi.stubEnv('GNUBOK_CONNECT_URL', 'https://connect.example.se/')
expect(getConnectorConfig()?.baseUrl).toBe('https://connect.example.se')
})
it('rejects non-https and malformed GNUBOK_CONNECT_URL (fail closed: the key is never sent in plaintext)', () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
for (const bad of ['http://connect.example.se', 'ftp://connect.example.se', 'not a url', 'connect.example.se']) {
vi.stubEnv('GNUBOK_CONNECT_URL', bad)
expect(getConnectorConfig(), bad).toBeNull()
expect(isConnectorConfigured(), bad).toBe(false)
}
})
it('allows plain http for loopback development hosts only', () => {
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
for (const ok of ['http://localhost:3000', 'http://127.0.0.1:3000']) {
vi.stubEnv('GNUBOK_CONNECT_URL', ok)
expect(getConnectorConfig()?.baseUrl, ok).toBe(ok)
}
})
})
+254
View File
@@ -0,0 +1,254 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { connectorGrantExpiry, syncConnectorEntitlements, CONNECTOR_GRANT_TTL_MS } from '../sync'
/**
* A purpose-built Supabase mock: `companies` answers the paginated read,
* `capability_grants` records upserts and deletes (with their filters), so the
* grant arithmetic can be asserted exactly.
*/
function makeSupabase(companyIds: string[]) {
const upserts: Array<{ rows: unknown[]; opts: unknown }> = []
const deletes: Array<{ filters: Array<[string, ...unknown[]]> }> = []
let deleteCount = 0
const grantsChain = () => {
const deleteRecord: { filters: Array<[string, ...unknown[]]> } = { filters: [] }
const chain: Record<string, unknown> = {}
chain.upsert = (rows: unknown[], opts: unknown) => {
upserts.push({ rows, opts })
return Promise.resolve({ error: null })
}
chain.delete = () => {
deletes.push(deleteRecord)
const dchain: Record<string, unknown> = {
eq: (...a: unknown[]) => {
deleteRecord.filters.push(['eq', ...a])
return dchain
},
not: (...a: unknown[]) => {
deleteRecord.filters.push(['not', ...a])
return dchain
},
then: (resolve: (v: unknown) => void) => resolve({ error: null, count: deleteCount }),
}
return dchain
}
return chain
}
const companiesChain = () => {
let rangeFrom = 0
const chain: Record<string, unknown> = {
select: () => chain,
is: () => chain,
order: () => chain,
range: (from: number) => {
rangeFrom = from
return chain
},
then: (resolve: (v: unknown) => void) =>
resolve({ data: companyIds.slice(rangeFrom, rangeFrom + 1000).map((id) => ({ id })), error: null }),
}
return chain
}
const supabase = {
from: (table: string) => (table === 'companies' ? companiesChain() : grantsChain()),
} as unknown as SupabaseClient
return {
supabase,
upserts,
deletes,
setDeleteCount: (n: number) => {
deleteCount = n
},
}
}
const CONFIG = { key: 'gnubok_ck_test', baseUrl: 'https://app.gnubok.se' }
const NOW = new Date('2026-08-20T12:00:00.000Z')
const C1 = '11111111-1111-4111-8111-111111111111'
const C2 = '22222222-2222-4222-8222-222222222222'
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
}
beforeEach(() => vi.clearAllMocks())
describe('connectorGrantExpiry', () => {
it('is now + 72h without a period end, and the earlier of that and period_end + 3d otherwise', () => {
expect(connectorGrantExpiry(NOW, null)).toBe(new Date(NOW.getTime() + CONNECTOR_GRANT_TTL_MS).toISOString())
// period ends in 10 days: 72h wins
expect(connectorGrantExpiry(NOW, '2026-08-30T00:00:00.000Z')).toBe('2026-08-23T12:00:00.000Z')
// period ended yesterday: period_end + 3d wins (grace), earlier than 72h
expect(connectorGrantExpiry(NOW, '2026-08-19T12:00:00.000Z')).toBe('2026-08-22T12:00:00.000Z')
})
})
describe('syncConnectorEntitlements', () => {
it('is a no-op without a key', async () => {
const { supabase, upserts } = makeSupabase([C1])
const fetchImpl = vi.fn()
const result = await syncConnectorEntitlements(supabase, { config: null, fetchImpl })
expect(result.outcome).toBe('not_configured')
expect(fetchImpl).not.toHaveBeenCalled()
expect(upserts).toHaveLength(0)
})
it('reports the active company count with the key and writes one connector grant per company and scope', async () => {
const { supabase, upserts, deletes } = makeSupabase([C1, C2])
const fetchImpl = vi.fn().mockResolvedValue(
jsonResponse(200, {
data: {
status: 'active',
scopes: ['bank_sync', 'skatteverket', 'not_a_connector_scope'],
current_period_end: '2027-01-01T00:00:00.000Z',
org_number: '5561234567',
instance_url: 'https://bokforing.example.se',
server_time: NOW.toISOString(),
},
}),
)
const result = await syncConnectorEntitlements(supabase, {
config: CONFIG,
fetchImpl,
now: NOW,
instanceUrl: 'https://bokforing.example.se',
appVersion: '1.0.0',
})
expect(fetchImpl).toHaveBeenCalledTimes(1)
const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://app.gnubok.se/api/connect/entitlements')
expect(init.method).toBe('POST')
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer gnubok_ck_test')
expect(JSON.parse(String(init.body))).toEqual({
active_company_count: 2,
instance_url: 'https://bokforing.example.se',
app_version: '1.0.0',
})
expect(result).toMatchObject({ outcome: 'synced', companies: 2, grantsUpserted: 4, scopes: ['bank_sync', 'skatteverket'] })
expect(result.expiresAt).toBe('2026-08-23T12:00:00.000Z')
expect(upserts).toHaveLength(1)
expect(upserts[0].opts).toEqual({ onConflict: 'company_id,team_id,capability_key,source' })
expect(upserts[0].rows).toEqual(
expect.arrayContaining([
{ company_id: C1, team_id: null, capability_key: 'bank_sync', source: 'connector', expires_at: '2026-08-23T12:00:00.000Z' },
{ company_id: C2, team_id: null, capability_key: 'skatteverket', source: 'connector', expires_at: '2026-08-23T12:00:00.000Z' },
]),
)
// scopes no longer covered are dropped: delete source=connector NOT IN (kept)
expect(deletes).toHaveLength(1)
expect(deletes[0].filters).toEqual([
['eq', 'source', 'connector'],
['not', 'capability_key', 'in', '(bank_sync,skatteverket)'],
])
})
it('removes every connector grant on a 401/403 carrying a connector rejection code (freeze-and-retain)', async () => {
for (const [status, code] of [[401, 'CONNECTOR_KEY_INVALID'], [403, 'CONNECTOR_KEY_SUSPENDED'], [401, 'CONNECTOR_KEY_MISSING']] as const) {
const { supabase, upserts, deletes, setDeleteCount } = makeSupabase([C1])
setDeleteCount(4)
const result = await syncConnectorEntitlements(supabase, {
config: CONFIG,
fetchImpl: vi.fn().mockResolvedValue(jsonResponse(status, { error: 'rejected', code })),
now: NOW,
})
expect(result).toMatchObject({ outcome: 'revoked', httpStatus: status, grantsDeleted: 4 })
expect(upserts).toHaveLength(0)
expect(deletes[0].filters).toEqual([['eq', 'source', 'connector']])
}
})
it('keeps every grant on a 401/403 WITHOUT a connector rejection code (WAF challenge, edge protection, egress proxy)', async () => {
// A bare-status 401/403 can come from layers where the hosted app never
// ran. Only the hosted route's own rejection code proves revocation;
// anything else must not destroy the 72h offline cache.
const bodies: Array<[number, () => Response]> = [
[403, () => new Response('<html>Attack challenge</html>', { status: 403, headers: { 'content-type': 'text/html' } })],
[401, () => new Response('Authentication Required', { status: 401 })],
[401, () => jsonResponse(401, { error: 'edge auth', code: 'SOME_OTHER_CODE' })],
[403, () => jsonResponse(403, { message: 'forbidden by proxy' })],
]
for (const [status, make] of bodies) {
const { supabase, upserts, deletes } = makeSupabase([C1])
const result = await syncConnectorEntitlements(supabase, {
config: CONFIG,
fetchImpl: vi.fn().mockResolvedValue(make()),
now: NOW,
})
expect(result).toMatchObject({ outcome: 'server_error', httpStatus: status, grantsDeleted: 0 })
expect(upserts).toHaveLength(0)
expect(deletes).toHaveLength(0)
}
})
it('keeps grants on an UNKNOWN status or malformed current_period_end (contract drift lands in server_error, never delete)', async () => {
const shapes = [
{ status: 'past_due', scopes: ['bank_sync'], current_period_end: null, org_number: 'x', instance_url: null, server_time: 'x' },
{ status: 'active', scopes: ['bank_sync'], current_period_end: 'not-a-date', org_number: 'x', instance_url: null, server_time: 'x' },
{ status: 'active', scopes: [42], current_period_end: null, org_number: 'x', instance_url: null, server_time: 'x' },
]
for (const data of shapes) {
const { supabase, upserts, deletes } = makeSupabase([C1])
const result = await syncConnectorEntitlements(supabase, {
config: CONFIG,
fetchImpl: vi.fn().mockResolvedValue(jsonResponse(200, { data })),
now: NOW,
})
expect(result.outcome, JSON.stringify(data)).toBe('server_error')
expect(upserts).toHaveLength(0)
expect(deletes).toHaveLength(0)
}
})
it('removes every connector grant when the key is not active', async () => {
const { supabase, deletes } = makeSupabase([C1])
const result = await syncConnectorEntitlements(supabase, {
config: CONFIG,
fetchImpl: vi.fn().mockResolvedValue(
jsonResponse(200, { data: { status: 'suspended', scopes: ['bank_sync'], current_period_end: null, org_number: 'x', instance_url: null, server_time: 'x' } }),
),
now: NOW,
})
expect(result).toMatchObject({ outcome: 'revoked', status: 'suspended' })
expect(deletes).toHaveLength(1)
})
// The offline grace: a hosted outage must not touch the grants.
it('leaves grants alone on a network error, a 5xx and a 429', async () => {
const { supabase: s1, upserts: u1, deletes: d1 } = makeSupabase([C1])
const r1 = await syncConnectorEntitlements(s1, { config: CONFIG, fetchImpl: vi.fn().mockRejectedValue(new Error('ECONNREFUSED')), now: NOW })
expect(r1.outcome).toBe('network_error')
expect(u1).toHaveLength(0)
expect(d1).toHaveLength(0)
for (const status of [500, 503, 429]) {
const { supabase, upserts, deletes } = makeSupabase([C1])
const r = await syncConnectorEntitlements(supabase, { config: CONFIG, fetchImpl: vi.fn().mockResolvedValue(jsonResponse(status, {})), now: NOW })
expect(r).toMatchObject({ outcome: 'server_error', httpStatus: status })
expect(upserts).toHaveLength(0)
expect(deletes).toHaveLength(0)
}
})
it('treats an unreadable 200 payload as a server error, not a revocation', async () => {
const { supabase, deletes } = makeSupabase([C1])
const r = await syncConnectorEntitlements(supabase, { config: CONFIG, fetchImpl: vi.fn().mockResolvedValue(jsonResponse(200, { data: { nope: true } })), now: NOW })
expect(r.outcome).toBe('server_error')
expect(deletes).toHaveLength(0)
})
it('with an empty scope list writes nothing and drops every connector grant', async () => {
const { supabase, upserts, deletes } = makeSupabase([C1])
const r = await syncConnectorEntitlements(supabase, {
config: CONFIG,
fetchImpl: vi.fn().mockResolvedValue(jsonResponse(200, { data: { status: 'active', scopes: [], current_period_end: null, org_number: 'x', instance_url: null, server_time: 'x' } })),
now: NOW,
})
expect(r).toMatchObject({ outcome: 'synced', grantsUpserted: 0 })
expect(upserts).toHaveLength(0)
expect(deletes[0].filters).toEqual([['eq', 'source', 'connector']])
})
})
+52
View File
@@ -0,0 +1,52 @@
import { DEFAULT_CONNECT_BASE_URL } from '../contract'
import { createLogger } from '@/lib/logger'
const log = createLogger('connect/config')
/**
* Instance-side connector configuration (a self-hosted deployment).
*
* GNUBOK_CONNECTOR_KEY the `gnubok_ck_...` key issued for this instance
* GNUBOK_CONNECT_URL hosted origin, default https://app.gnubok.se
*
* Unset on hosted and on a self-host without a subscription: then the
* connector sync is a no-op and the connector capabilities stay gated.
*
* GNUBOK_CONNECT_URL must be https: the hourly sync sends the long-lived
* connector key as a Bearer header to this origin, so an http:// typo would
* ship the credential in plaintext. Plain http is allowed only for loopback
* hosts (local development against a dev server). An invalid or non-https
* URL disables the connector entirely (fail closed, nothing is sent).
*/
export interface ConnectorConfig {
key: string
baseUrl: string
}
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]'])
export function getConnectorConfig(): ConnectorConfig | null {
const key = process.env.GNUBOK_CONNECTOR_KEY?.trim()
if (!key) return null
const raw = (process.env.GNUBOK_CONNECT_URL?.trim() || DEFAULT_CONNECT_BASE_URL).replace(/\/+$/, '')
let url: URL
try {
url = new URL(raw)
} catch {
log.warn('GNUBOK_CONNECT_URL is not a valid URL; connector disabled', { value: raw })
return null
}
const loopback = LOOPBACK_HOSTS.has(url.hostname) || LOOPBACK_HOSTS.has(`[${url.hostname}]`)
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
log.warn('GNUBOK_CONNECT_URL must be https (http only for loopback); connector disabled', {
protocol: url.protocol,
host: url.hostname,
})
return null
}
return { key, baseUrl: raw }
}
export function isConnectorConfigured(): boolean {
return getConnectorConfig() !== null
}
+236
View File
@@ -0,0 +1,236 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { CONNECTOR_CAPABILITIES } from '@/lib/entitlements/keys'
import { createLogger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
CONNECTOR_ENTITLEMENTS_PATH,
type ConnectorEntitlements,
type ConnectorSyncReport,
} from '../contract'
import { getConnectorConfig, type ConnectorConfig } from './config'
const log = createLogger('connector-sync')
/**
* Grant rows ARE the offline cache. Each sync re-stamps the connector grants
* to expire at min(now + 72h, period_end + 3d): the hosted service only has
* to be reachable once in three days, and a lapsed subscription freezes the
* connector capabilities within days even if the sync keeps running. Uses
* the existing expiry check in lib/entitlements, zero new cache code.
*/
export const CONNECTOR_GRANT_TTL_MS = 72 * 60 * 60 * 1000
export const CONNECTOR_PERIOD_GRACE_MS = 3 * 24 * 60 * 60 * 1000
const UPSERT_CHUNK = 500
export type ConnectorSyncOutcome =
| 'not_configured'
| 'synced'
| 'revoked'
| 'network_error'
| 'server_error'
export interface ConnectorSyncResult {
outcome: ConnectorSyncOutcome
companies: number
grantsUpserted: number
grantsDeleted: number
status?: string
httpStatus?: number
scopes?: string[]
expiresAt?: string
message?: string
}
export interface ConnectorSyncOptions {
fetchImpl?: typeof fetch
now?: Date
config?: ConnectorConfig | null
instanceUrl?: string | null
appVersion?: string | null
}
export function connectorGrantExpiry(now: Date, currentPeriodEnd: string | null): string {
const ttl = now.getTime() + CONNECTOR_GRANT_TTL_MS
if (!currentPeriodEnd) return new Date(ttl).toISOString()
const periodGrace = new Date(currentPeriodEnd).getTime() + CONNECTOR_PERIOD_GRACE_MS
return new Date(Math.min(ttl, periodGrace)).toISOString()
}
async function deleteConnectorGrants(supabase: SupabaseClient, keepKeys: string[] | null): Promise<number> {
let query = supabase.from('capability_grants').delete({ count: 'exact' }).eq('source', 'connector')
if (keepKeys && keepKeys.length > 0) {
query = query.not('capability_key', 'in', `(${keepKeys.join(',')})`)
}
const { error, count } = await query
if (error) throw new Error(`Failed to delete connector grants: ${error.message}`)
return count ?? 0
}
/**
* One sync run: report the active company count to the hosted service, then
* translate the answer into source='connector' capability grants for every
* company on this instance.
*
* 200 active -> upsert grants for scopes, drop grants for scopes no
* longer covered (synced)
* 200 non-active -> delete all connector grants (freeze-and-retain) (revoked)
* 401 / 403 -> delete all connector grants (revoked)
* network error,
* 429, 5xx, other -> leave grants alone, they expire on their own (network_error / server_error)
*/
export async function syncConnectorEntitlements(
supabase: SupabaseClient,
options: ConnectorSyncOptions = {},
): Promise<ConnectorSyncResult> {
const config = options.config === undefined ? getConnectorConfig() : options.config
if (!config) return { outcome: 'not_configured', companies: 0, grantsUpserted: 0, grantsDeleted: 0 }
const now = options.now ?? new Date()
const fetchImpl = options.fetchImpl ?? fetch
const companies = await fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
.from('companies')
.select('id')
.is('archived_at', null)
.order('id', { ascending: true })
.range(from, to),
)
const companyIds = companies.map((c) => c.id)
const report: ConnectorSyncReport = {
active_company_count: companyIds.length,
...(options.instanceUrl ? { instance_url: options.instanceUrl } : {}),
...(options.appVersion ? { app_version: options.appVersion } : {}),
}
let response: Response
try {
response = await fetchImpl(`${config.baseUrl}${CONNECTOR_ENTITLEMENTS_PATH}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${config.key}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(report),
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
log.warn('connector sync: hosted service unreachable, keeping existing grants', { message })
return { outcome: 'network_error', companies: companyIds.length, grantsUpserted: 0, grantsDeleted: 0, message }
}
if (response.status === 401 || response.status === 403) {
// Delete the grant cache ONLY on a genuine connector rejection, proven by
// the hosted route's own JSON body code (with-connector-auth always sends
// one). A bare-status 401/403 also comes from layers where the app never
// ran: a Vercel WAF challenge page, edge deployment protection, an egress
// proxy at the self-host. Trusting the status alone let any of those wipe
// a paying instance's 72h offline grace within the hour: the same failure
// class as the RPC-error-to-401 mapping, one layer up. Anything without a
// known rejection code keeps the grants and expires naturally.
let rejectionCode: string | null = null
try {
const body = (await response.clone().json()) as { code?: string }
if (typeof body.code === 'string') rejectionCode = body.code
} catch {
// Non-JSON body (challenge/error page): not a connector rejection.
}
const isConnectorRejection =
rejectionCode === 'CONNECTOR_KEY_MISSING' ||
rejectionCode === 'CONNECTOR_KEY_INVALID' ||
rejectionCode === 'CONNECTOR_KEY_SUSPENDED'
if (isConnectorRejection) {
const grantsDeleted = await deleteConnectorGrants(supabase, null)
log.warn('connector sync: key rejected by hosted service, connector grants removed', {
httpStatus: response.status,
code: rejectionCode,
grantsDeleted,
})
return { outcome: 'revoked', companies: companyIds.length, grantsUpserted: 0, grantsDeleted, httpStatus: response.status }
}
log.warn('connector sync: 401/403 without a connector rejection code (edge/proxy?), keeping existing grants', {
httpStatus: response.status,
code: rejectionCode,
})
return { outcome: 'server_error', companies: companyIds.length, grantsUpserted: 0, grantsDeleted: 0, httpStatus: response.status }
}
if (!response.ok) {
log.warn('connector sync: hosted service error, keeping existing grants', { httpStatus: response.status })
return { outcome: 'server_error', companies: companyIds.length, grantsUpserted: 0, grantsDeleted: 0, httpStatus: response.status }
}
let entitlements: ConnectorEntitlements
try {
const body = (await response.json()) as { data?: ConnectorEntitlements }
// Full shape validation, not just presence: an UNKNOWN status string must
// land in keep-grants (server_error), not fall through to the
// "status !== 'active'" delete below (contract drift or a tampering
// middlebox must never wipe the cache), and a malformed
// current_period_end would make connectorGrantExpiry throw mid-write.
if (
!body.data ||
!['active', 'suspended', 'revoked'].includes(body.data.status as string) ||
!Array.isArray(body.data.scopes) ||
!body.data.scopes.every((s) => typeof s === 'string') ||
!(
body.data.current_period_end === null ||
body.data.current_period_end === undefined ||
(typeof body.data.current_period_end === 'string' &&
Number.isFinite(new Date(body.data.current_period_end).getTime()))
)
) {
throw new Error('unexpected entitlements payload')
}
entitlements = body.data
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
log.warn('connector sync: unreadable entitlements payload, keeping existing grants', { message })
return { outcome: 'server_error', companies: companyIds.length, grantsUpserted: 0, grantsDeleted: 0, httpStatus: response.status, message }
}
if (entitlements.status !== 'active') {
const grantsDeleted = await deleteConnectorGrants(supabase, null)
log.warn('connector sync: key not active, connector grants removed', { status: entitlements.status, grantsDeleted })
return { outcome: 'revoked', companies: companyIds.length, grantsUpserted: 0, grantsDeleted, status: entitlements.status }
}
const scopes = entitlements.scopes.filter((s) => (CONNECTOR_CAPABILITIES as readonly string[]).includes(s))
const expiresAt = connectorGrantExpiry(now, entitlements.current_period_end)
let grantsUpserted = 0
if (scopes.length > 0 && companyIds.length > 0) {
const rows = companyIds.flatMap((companyId) =>
scopes.map((capabilityKey) => ({
company_id: companyId,
team_id: null,
capability_key: capabilityKey,
source: 'connector',
expires_at: expiresAt,
})),
)
for (let i = 0; i < rows.length; i += UPSERT_CHUNK) {
const chunk = rows.slice(i, i + UPSERT_CHUNK)
const { error } = await supabase
.from('capability_grants')
.upsert(chunk, { onConflict: 'company_id,team_id,capability_key,source' })
if (error) throw new Error(`Failed to upsert connector grants: ${error.message}`)
grantsUpserted += chunk.length
}
}
// Scopes the subscription no longer covers (or none at all): drop them.
const grantsDeleted = await deleteConnectorGrants(supabase, scopes.length > 0 ? scopes : null)
log.info('connector sync complete', { companies: companyIds.length, scopes, grantsUpserted, grantsDeleted, expiresAt })
return {
outcome: 'synced',
companies: companyIds.length,
grantsUpserted,
grantsDeleted,
status: entitlements.status,
scopes,
expiresAt,
httpStatus: response.status,
}
}
+75 -9
View File
@@ -7,6 +7,7 @@ import {
readVercelCrons,
scheduleFor,
EXCLUDED_PATHS,
EXTRA_JOBS,
SCHEDULE_OVERRIDES,
VARIANTS,
type CrontabVariant,
@@ -41,18 +42,29 @@ function parseCrontab(text: string): { path: string; schedule: string }[] {
}
const expectedPaths = crons.map((c) => c.path).filter((p) => !(p in EXCLUDED_PATHS))
const expectedPathsFor = (variant: CrontabVariant) => [
...expectedPaths,
...EXTRA_JOBS[variant].map((job) => job.path),
]
describe('docker crontabs mirror vercel.json', () => {
it.each(VARIANTS)('crontab.%s covers exactly the vercel.json path set minus exclusions', (variant) => {
it.each(VARIANTS)('crontab.%s covers exactly the vercel.json path set minus exclusions, plus its EXTRA_JOBS', (variant) => {
const actual = parseCrontab(crontabText(variant)).map((job) => job.path)
// Sorted comparison gives a readable diff of what is missing / extra;
// the order assertion below covers sequence separately.
expect([...actual].sort()).toEqual([...expectedPaths].sort())
expect([...actual].sort()).toEqual([...expectedPathsFor(variant)].sort())
})
it.each(VARIANTS)('crontab.%s keeps vercel.json order', (variant) => {
expect(parseCrontab(crontabText(variant)).map((job) => job.path)).toEqual(expectedPaths)
it.each(VARIANTS)('crontab.%s keeps vercel.json order, EXTRA_JOBS last', (variant) => {
expect(parseCrontab(crontabText(variant)).map((job) => job.path)).toEqual(expectedPathsFor(variant))
})
it.each(VARIANTS)('crontab.%s runs its EXTRA_JOBS on their declared cadence', (variant) => {
const actual = new Map(parseCrontab(crontabText(variant)).map((job) => [job.path, job.schedule]))
for (const job of EXTRA_JOBS[variant]) {
expect(actual.get(job.path), `schedule for extra job ${job.path}`).toBe(job.schedule)
}
})
it.each(VARIANTS)('crontab.%s runs every path on its vercel.json cadence', (variant) => {
@@ -80,14 +92,41 @@ describe('docker crontabs mirror vercel.json', () => {
}
})
it('keeps the two variants identical apart from the variant header line', () => {
it('keeps the two variants identical apart from the variant header line and the EXTRA_JOBS tail', () => {
const hosted = crontabText('hosted').split('\n')
const selfHosted = crontabText('self-hosted').split('\n')
const differing = hosted.filter((line, i) => line !== selfHosted[i])
const shared = Math.min(hosted.length, selfHosted.length)
const differing = hosted.slice(0, shared).filter((line, i) => line !== selfHosted[i])
// Any real divergence must come from SCHEDULE_OVERRIDES, which is empty
// today. If that changes, widen this expectation deliberately.
// Any real divergence in the shared prefix must come from
// SCHEDULE_OVERRIDES, which is empty today. If that changes, widen this
// expectation deliberately.
expect(differing).toEqual([expect.stringContaining('# Variant: hosted')])
// The self-hosted file may only be longer by its EXTRA_JOBS block: one
// blank line, one comment line, one line per extra job.
const extraLines = EXTRA_JOBS['self-hosted'].length
const hostedExtraLines = EXTRA_JOBS.hosted.length
expect(selfHosted.length - hosted.length).toBe(
(extraLines > 0 ? extraLines + 2 : 0) - (hostedExtraLines > 0 ? hostedExtraLines + 2 : 0),
)
})
})
describe('EXTRA_JOBS', () => {
it('names real cron routes that vercel.json does not schedule, each with a reason', () => {
const scheduled = new Set(crons.map((c) => c.path))
for (const variant of VARIANTS) {
for (const job of EXTRA_JOBS[variant]) {
expect(job.reason.trim().length, `${job.path} needs a reason`).toBeGreaterThan(0)
expect(job.schedule.trim().split(/\s+/).length, `${job.path} needs a 5-field schedule`).toBe(5)
expect(scheduled.has(job.path), `${job.path} is now in vercel.json: drop the extra entry`).toBe(false)
expect(
existsSync(join(ROOT, 'app', ...job.path.split('/').filter(Boolean), 'route.ts')),
`${job.path} has no route.ts`,
).toBe(true)
}
}
})
})
@@ -119,6 +158,7 @@ describe('exclusion and override tables', () => {
const rendered = buildCrontab(sample, 'self-hosted', {
excluded: { '/api/drop/cron': 'vercel-only, cannot work self-hosted' },
overrides: { hosted: {}, 'self-hosted': { '/api/keep/cron': '*/30 * * * *' } },
extraJobs: { hosted: [], 'self-hosted': [] },
})
const jobs = parseCrontab(rendered)
@@ -126,6 +166,29 @@ describe('exclusion and override tables', () => {
expect(rendered).not.toContain('/api/drop/cron')
})
it('renders extra jobs after the vercel.json jobs under their own comment line', () => {
const rendered = buildCrontab([{ path: '/api/keep/cron', schedule: '0 1 * * *' }], 'self-hosted', {
excluded: {},
overrides: { hosted: {}, 'self-hosted': {} },
extraJobs: {
hosted: [],
'self-hosted': [{ path: '/api/only-here/cron', schedule: '17 * * * *', reason: 'test' }],
},
})
expect(parseCrontab(rendered)).toEqual([
{ path: '/api/keep/cron', schedule: '0 1 * * *' },
{ path: '/api/only-here/cron', schedule: '17 * * * *' },
])
expect(rendered).toContain('# self-hosted-only jobs, not in vercel.json')
// hosted gets no tail at all when it has no extra jobs
const hosted = buildCrontab([{ path: '/api/keep/cron', schedule: '0 1 * * *' }], 'hosted', {
excluded: {},
overrides: { hosted: {}, 'self-hosted': {} },
extraJobs: { hosted: [], 'self-hosted': [] },
})
expect(hosted).not.toContain('not in vercel.json')
})
it('renders the curl invocation with unexpanded shell variables', () => {
const rendered = buildCrontab([{ path: '/api/x/cron', schedule: '0 1 * * *' }], 'hosted', {
excluded: {},
@@ -196,7 +259,10 @@ function findCronRoutes(dir: string, urlPrefix: string): string[] {
describe('every cron route has a schedule', () => {
it('leaves no unscheduled cron route undocumented', () => {
const routes = findCronRoutes(join(ROOT, 'app', 'api'), '/api')
const scheduled = new Set(crons.map((c) => c.path))
const scheduled = new Set([
...crons.map((c) => c.path),
...VARIANTS.flatMap((variant) => EXTRA_JOBS[variant].map((job) => job.path)),
])
const orphans = routes.filter((r) => !scheduled.has(r) && !(r in INTENTIONALLY_UNSCHEDULED))
expect(
+53 -2
View File
@@ -111,6 +111,37 @@ export const SCHEDULE_OVERRIDES: Readonly<
'self-hosted': {},
}
/**
* Jobs that exist in ONE variant only and therefore have no vercel.json entry
* (vercel.json is the hosted schedule). Each carries its reason, the same
* discipline as EXCLUDED_PATHS: a self-hosted-only endpoint that silently
* lacked a schedule would be dead code that looks alive.
*
* Rendered after the vercel.json jobs under their own comment line. The
* crontab drift test checks both halves: the vercel.json mirror AND that
* every EXTRA_JOBS path is a real cron route that vercel.json does NOT
* schedule (the moment it does, the entry must go).
*/
export interface ExtraJob {
path: string
schedule: string
reason: string
}
export const EXTRA_JOBS: Readonly<Record<CrontabVariant, readonly ExtraJob[]>> = {
hosted: [],
'self-hosted': [
{
path: '/api/connector/sync/cron',
schedule: '17 * * * *',
reason:
'Self-hosted only: refreshes the source=connector capability grants from the instance\'s ' +
'GNUBOK_CONNECTOR_KEY (hourly; grants carry a 72h offline grace). Hosted has no connector ' +
'key, so the route is not in vercel.json; an instance without a key answers not_configured.',
},
],
}
/** Read and shape-check the `crons` array. */
export function readVercelCrons(vercelJson: string): VercelCron[] {
const parsed = JSON.parse(vercelJson) as { crons?: VercelCron[] }
@@ -178,6 +209,7 @@ export function buildCrontab(
options: {
excluded?: Readonly<Record<string, string>>
overrides?: Readonly<Record<CrontabVariant, Readonly<Record<string, string>>>>
extraJobs?: Readonly<Record<CrontabVariant, readonly ExtraJob[]>>
} = {},
): string {
const excluded = options.excluded ?? EXCLUDED_PATHS
@@ -190,13 +222,22 @@ export function buildCrontab(
schedule: overrides[variant][cron.path] ?? cron.schedule,
}))
const extraJobs = (options.extraJobs ?? EXTRA_JOBS)[variant]
// Align the commands: pad to the widest schedule plus two spaces, the same
// column convention the hand-written files used.
const width = jobs.reduce((max, job) => Math.max(max, job.schedule.length), 0) + 2
const width = [...jobs, ...extraJobs].reduce((max, job) => Math.max(max, job.schedule.length), 0) + 2
const lines = [
...buildHeader(variant),
...jobs.map((job) => `${job.schedule.padEnd(width)}${CURL_PREFIX}${job.path}`),
...(extraJobs.length > 0
? [
'',
`# ${variant}-only jobs, not in vercel.json: see EXTRA_JOBS in scripts/generate-crontabs.ts`,
...extraJobs.map((job) => `${job.schedule.padEnd(width)}${CURL_PREFIX}${job.path}`),
]
: []),
]
return `${lines.join('\n')}\n`
@@ -220,10 +261,20 @@ function main(): void {
}
}
for (const variant of VARIANTS) {
for (const job of EXTRA_JOBS[variant]) {
if (crons.some((cron) => cron.path === job.path)) {
throw new Error(
`EXTRA_JOBS.${variant} lists ${job.path}, which vercel.json now schedules. Remove the extra entry.`,
)
}
}
}
for (const variant of VARIANTS) {
const target = join(DOCKER_DIR, `crontab.${variant}`)
writeFileSync(target, buildCrontab(crons, variant), 'utf8')
const emitted = crons.filter((cron) => !(cron.path in EXCLUDED_PATHS)).length
const emitted = crons.filter((cron) => !(cron.path in EXCLUDED_PATHS)).length + EXTRA_JOBS[variant].length
const overridden = Object.keys(SCHEDULE_OVERRIDES[variant]).length
console.log(
`Wrote docker/crontab.${variant}: ${emitted} jobs` +
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env npx tsx
/**
* Issue a connector key for a self-hosted instance (manual sales, v1).
*
* Writes a connector_keys row through the service role and prints the key
* ONCE together with the .env lines the operator pastes into their instance.
* The key is never stored: only its SHA-256.
*
* Usage:
* npx tsx scripts/issue-connector-key.ts --org 5561234567 --name "Byrå AB" \
* --instance https://bokforing.byra.se [--months 12] \
* [--scopes bank_sync,skatteverket,org_lookup,migration] [--notes "..."] --confirm
*
* Reads .env.local (which points at PRODUCTION in this repo: the script
* refuses to write without --confirm and prints the target host first).
*/
import { config } from 'dotenv'
import { resolve } from 'node:path'
config({ path: resolve(process.cwd(), '.env.local') })
import { createServiceRoleClient } from '../lib/supabase/service-client'
import { CONNECTOR_CAPABILITIES } from '../lib/entitlements/keys'
import { generateConnectorKey } from '../lib/connect/hosted/keys'
function arg(name: string): string | undefined {
const idx = process.argv.indexOf(`--${name}`)
if (idx === -1) return undefined
const value = process.argv[idx + 1]
return value && !value.startsWith('--') ? value : ''
}
function flag(name: string): boolean {
return process.argv.includes(`--${name}`)
}
async function main(): Promise<void> {
const org = (arg('org') ?? '').replace(/\D/g, '')
const name = arg('name') ?? ''
const instance = arg('instance') ?? ''
const months = Number(arg('months') ?? '12')
const scopes = (arg('scopes') ?? CONNECTOR_CAPABILITIES.join(',')).split(',').map((s) => s.trim()).filter(Boolean)
const notes = arg('notes') ?? null
const problems: string[] = []
if (!/^\d{10}$/.test(org)) problems.push('--org must be a 10-digit Swedish organisation number')
if (!name) problems.push('--name is required (licensee name)')
try {
const u = new URL(instance)
if (u.protocol !== 'https:') problems.push('--instance must be an https:// origin')
} catch {
problems.push('--instance must be a valid https:// URL')
}
if (!Number.isInteger(months) || months <= 0 || months > 120) problems.push('--months must be an integer 1..120')
const unknown = scopes.filter((s) => !(CONNECTOR_CAPABILITIES as readonly string[]).includes(s))
if (unknown.length) problems.push(`unknown scopes: ${unknown.join(', ')} (allowed: ${CONNECTOR_CAPABILITIES.join(', ')})`)
if (problems.length) {
console.error(problems.map((p) => ` x ${p}`).join('\n'))
process.exit(2)
}
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !serviceKey) {
console.error('NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required (.env.local)')
process.exit(2)
}
const periodEnd = new Date()
periodEnd.setUTCMonth(periodEnd.getUTCMonth() + months)
console.log(`Target: ${new URL(url).host}`)
console.log(`Licensee: ${name} (${org})`)
console.log(`Instance: ${new URL(instance).origin}`)
console.log(`Scopes: ${scopes.join(', ')}`)
console.log(`Period: until ${periodEnd.toISOString().slice(0, 10)} (${months} months)`)
if (!flag('confirm')) {
console.log('\nDry run. Re-run with --confirm to issue the key.')
return
}
const { key, hash, prefix } = generateConnectorKey()
const supabase = createServiceRoleClient(url, serviceKey)
const { data, error } = await supabase
.from('connector_keys')
.insert({
key_hash: hash,
key_prefix: prefix,
org_number: org,
licensee_name: name,
instance_url: new URL(instance).origin,
scopes,
status: 'active',
current_period_end: periodEnd.toISOString(),
notes,
})
.select('id')
.single()
if (error || !data) {
console.error('insert failed:', error?.message)
process.exit(1)
}
console.log(`\nIssued connector key ${prefix}… (id ${(data as { id: string }).id}). Shown ONCE, not stored:\n`)
console.log(` ${key}\n`)
console.log('Paste into the instance .env, then restart the app and cron containers:')
console.log(` GNUBOK_CONNECTOR_KEY=${key}`)
console.log(' # GNUBOK_CONNECT_URL=https://app.gnubok.se (default)')
console.log('\nThe hourly connector sync writes the capability grants; run it once by hand to check:')
console.log(' curl -sf -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/connector/sync/cron')
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : String(err))
process.exit(1)
})
@@ -0,0 +1,175 @@
-- Connector keys: the hosted side of the sovereign self-host subscription.
--
-- A self-hosted Accounted instance runs everything itself except the services
-- only Accounted can operate (bank sync via our PSD2/AISP credentials, the
-- Skatteverket API client, TIC org lookup, the migration gateway). A
-- connector key (`gnubok_ck_...`) is what an instance presents to the hosted
-- proxy endpoints under /api/connect/*; this table is the registry of those
-- keys, the proxy's rate-limit state, and the billing hook for the
-- per-active-company subscription. The instance never stores accounting data
-- here: bank/SKV tokens and the ledger stay in the instance's own database,
-- the proxy is stateless. Enforcement is key auth at the proxy, never a
-- call-home licence check inside the AGPL code.
--
-- Service-role only: RLS is enabled with NO policies, so neither anon nor
-- authenticated can read a row (the hosted UI has no connector-key surface;
-- keys are issued by scripts/issue-connector-key.ts and, later, the Stripe
-- webhook). The validate RPC is SECURITY DEFINER and executable by
-- service_role alone (see the REVOKE/GRANT at the end): this is the lesson
-- of the SECURITY DEFINER exposure audit, applied up front.
CREATE TABLE public.connector_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- SHA-256 of the key. The key itself is shown once at issue time and never
-- stored: the hash IS the credential lookup (same reasoning as api_keys).
key_hash text NOT NULL UNIQUE,
-- Display prefix: 'gnubok_ck_' + 8 chars, safe to show in lists/logs.
key_prefix text NOT NULL,
-- The licensee. Ten digits, no hyphen.
org_number text NOT NULL,
licensee_name text,
-- The self-hosted instance this key belongs to (pinned: callbacks and
-- usage are only ever honoured for this origin). NULL until first sync
-- pins it for a manually issued key.
instance_url text,
-- Capability keys the subscription covers. Subset of
-- CONNECTOR_CAPABILITIES in lib/entitlements/keys.ts.
scopes text[] NOT NULL DEFAULT ARRAY['bank_sync', 'skatteverket', 'org_lookup', 'migration'],
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'revoked')),
-- Billing hooks (Stripe self-serve comes later; manual keys leave these NULL).
stripe_customer_id text,
stripe_subscription_id text,
-- End of the paid period. The instance's grants expire at
-- min(now + 72h, current_period_end + 3d): a lapsed period freezes the
-- connector capabilities within days even if the sync keeps running.
current_period_end timestamptz,
-- Proxy rate limit, per key (requests per minute), same window scheme as
-- api_keys.
rate_limit_rpm integer NOT NULL DEFAULT 600,
request_count integer NOT NULL DEFAULT 0,
rate_limit_window_start timestamptz,
-- Quantity billing input: the instance reports its active company count on
-- every sync.
active_company_count integer NOT NULL DEFAULT 0,
last_seen_at timestamptz,
last_synced_at timestamptz,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz
);
CREATE INDEX idx_connector_keys_org_number ON public.connector_keys (org_number);
CREATE INDEX idx_connector_keys_status ON public.connector_keys (status) WHERE revoked_at IS NULL;
ALTER TABLE public.connector_keys ENABLE ROW LEVEL SECURITY;
-- No policies on purpose: service role only.
CREATE TRIGGER set_updated_at_connector_keys
BEFORE UPDATE ON public.connector_keys
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- Usage metering per key: one row per proxied request. Kept separate from
-- metered_events on purpose: that table's company_id references HOSTED
-- companies, and a connector key belongs to an instance, not a company here.
CREATE TABLE public.connector_usage_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
connector_key_id uuid NOT NULL REFERENCES public.connector_keys(id) ON DELETE CASCADE,
-- Which hosted service answered: entitlements | bank | skatteverket | org | migration.
service text NOT NULL,
endpoint text,
status_code integer,
occurred_at timestamptz NOT NULL DEFAULT now(),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX idx_connector_usage_events_key_time
ON public.connector_usage_events (connector_key_id, occurred_at DESC);
ALTER TABLE public.connector_usage_events ENABLE ROW LEVEL SECURITY;
-- No policies on purpose: service role only.
-- Atomic validate + rate-limit, the connector twin of
-- validate_and_increment_api_key (20260621130000). Returns no row for an
-- unknown or revoked key (caller answers 401), the row with its status for a
-- suspended key (caller answers 403), and rate_limited = true when the
-- per-minute window is exhausted (caller answers 429).
CREATE OR REPLACE FUNCTION public.validate_and_increment_connector_key(p_key_hash text)
RETURNS TABLE(
connector_key_id uuid,
org_number text,
instance_url text,
scopes text[],
status text,
current_period_end timestamptz,
rate_limited boolean
)
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_id uuid;
v_org_number text;
v_instance_url text;
v_scopes text[];
v_status text;
v_period_end timestamptz;
v_rate_limit_rpm integer;
v_request_count integer;
v_window_start timestamptz;
BEGIN
SELECT ck.id, ck.org_number, ck.instance_url, ck.scopes, ck.status, ck.current_period_end,
ck.rate_limit_rpm, ck.request_count, ck.rate_limit_window_start
INTO v_id, v_org_number, v_instance_url, v_scopes, v_status, v_period_end,
v_rate_limit_rpm, v_request_count, v_window_start
FROM public.connector_keys ck
WHERE ck.key_hash = p_key_hash
AND ck.revoked_at IS NULL
AND ck.status <> 'revoked'
FOR UPDATE;
IF v_id IS NULL THEN
RETURN; -- unknown or revoked: caller answers 401
END IF;
-- A suspended key is reported, not rate-counted: the caller answers 403.
IF v_status <> 'active' THEN
UPDATE public.connector_keys SET last_seen_at = now() WHERE id = v_id;
RETURN QUERY SELECT v_id, v_org_number, v_instance_url, v_scopes, v_status, v_period_end, false;
RETURN;
END IF;
IF v_window_start IS NULL OR v_window_start < now() - interval '1 minute' THEN
UPDATE public.connector_keys
SET request_count = 1,
rate_limit_window_start = now(),
last_seen_at = now()
WHERE id = v_id;
RETURN QUERY SELECT v_id, v_org_number, v_instance_url, v_scopes, v_status, v_period_end, false;
RETURN;
END IF;
IF v_request_count >= v_rate_limit_rpm THEN
RETURN QUERY SELECT v_id, v_org_number, v_instance_url, v_scopes, v_status, v_period_end, true;
RETURN;
END IF;
UPDATE public.connector_keys
SET request_count = request_count + 1,
last_seen_at = now()
WHERE id = v_id;
RETURN QUERY SELECT v_id, v_org_number, v_instance_url, v_scopes, v_status, v_period_end, false;
END;
$$;
-- Service role only. Supabase grants EXECUTE on new public functions to PUBLIC
-- and explicitly to anon/authenticated; anon is a member of PUBLIC, so both
-- must be revoked for the revoke to bite.
REVOKE ALL ON FUNCTION public.validate_and_increment_connector_key(text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.validate_and_increment_connector_key(text) TO service_role;
COMMENT ON TABLE public.connector_keys IS
'Hosted registry of gnubok_ck_ connector keys presented by self-hosted instances at /api/connect/*. Service-role only.';
COMMENT ON TABLE public.connector_usage_events IS
'Per-request metering for connector keys (entitlements, bank, skatteverket, org, migration). Service-role only.';
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect } from 'vitest'
import { createHash, randomBytes } from 'node:crypto'
import { getPool, withUserContext } from './setup'
import { insertAuthUser } from './fixtures'
// pg-real coverage for migration 20260831190000 (connector_keys,
// connector_usage_events, validate_and_increment_connector_key): the atomic
// validate + rate-limit path, the suspended/revoked answers, and the
// service-role-only exposure (no RLS policy, RPC not executable by anon or
// authenticated). Required by .claude/rules/database.md for RPC/RLS changes.
function hashOf(key: string): string {
return createHash('sha256').update(key).digest('hex')
}
async function insertKey(p: {
status?: string
rpm?: number
revoked?: boolean
scopes?: string[]
periodEnd?: string | null
}): Promise<{ id: string; key: string; hash: string }> {
const key = `gnubok_ck_${randomBytes(32).toString('base64url')}`
const hash = hashOf(key)
const { rows } = await getPool().query<{ id: string }>(
`INSERT INTO public.connector_keys
(key_hash, key_prefix, org_number, licensee_name, instance_url, scopes, status, rate_limit_rpm, current_period_end, revoked_at)
VALUES ($1, $2, '5561234567', 'Byrå AB', 'https://bokforing.example.se', $3, $4, $5, $6, $7)
RETURNING id`,
[
hash,
key.slice(0, 18),
p.scopes ?? ['bank_sync', 'skatteverket', 'org_lookup', 'migration'],
p.status ?? 'active',
p.rpm ?? 600,
p.periodEnd === undefined ? null : p.periodEnd,
p.revoked ? new Date().toISOString() : null,
],
)
return { id: rows[0].id, key, hash }
}
type RpcRow = {
connector_key_id: string
org_number: string
instance_url: string | null
scopes: string[]
status: string
current_period_end: Date | null
rate_limited: boolean
}
async function rpc(hash: string): Promise<RpcRow[]> {
const { rows } = await getPool().query<RpcRow>(
`SELECT * FROM public.validate_and_increment_connector_key($1)`,
[hash],
)
return rows
}
describe('validate_and_increment_connector_key', () => {
it('returns the key row and counts the request', async () => {
const { id, hash } = await insertKey({ periodEnd: '2027-01-01T00:00:00Z' })
const rows = await rpc(hash)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
connector_key_id: id,
org_number: '5561234567',
instance_url: 'https://bokforing.example.se',
status: 'active',
rate_limited: false,
})
expect(rows[0].scopes).toEqual(['bank_sync', 'skatteverket', 'org_lookup', 'migration'])
const { rows: after } = await getPool().query<{ request_count: number; last_seen_at: Date | null }>(
`SELECT request_count, last_seen_at FROM public.connector_keys WHERE id = $1`,
[id],
)
expect(after[0].request_count).toBe(1)
expect(after[0].last_seen_at).not.toBeNull()
})
it('returns nothing for an unknown hash and for a revoked key', async () => {
expect(await rpc(hashOf('gnubok_ck_nope'))).toEqual([])
const revoked = await insertKey({ revoked: true, status: 'revoked' })
expect(await rpc(revoked.hash)).toEqual([])
})
it('reports a suspended key without counting it', async () => {
const { id, hash } = await insertKey({ status: 'suspended' })
const rows = await rpc(hash)
expect(rows[0]).toMatchObject({ status: 'suspended', rate_limited: false })
const { rows: after } = await getPool().query<{ request_count: number }>(
`SELECT request_count FROM public.connector_keys WHERE id = $1`,
[id],
)
expect(after[0].request_count).toBe(0)
})
it('rate-limits inside the minute window', async () => {
const { hash } = await insertKey({ rpm: 2 })
expect((await rpc(hash))[0].rate_limited).toBe(false)
expect((await rpc(hash))[0].rate_limited).toBe(false)
expect((await rpc(hash))[0].rate_limited).toBe(true)
})
it('is executable by service_role only', async () => {
const { rows } = await getPool().query<{ role: string; ok: boolean }>(
`SELECT r.role, has_function_privilege(r.role, 'public.validate_and_increment_connector_key(text)', 'execute') AS ok
FROM (VALUES ('anon'), ('authenticated'), ('service_role')) AS r(role)`,
)
const byRole = Object.fromEntries(rows.map((r) => [r.role, r.ok]))
expect(byRole).toEqual({ anon: false, authenticated: false, service_role: true })
})
})
describe('connector tables are service-role only', () => {
it('an authenticated user sees no connector keys or usage rows', async () => {
await insertKey({})
const userId = await insertAuthUser()
await withUserContext(userId, async (client) => {
const keys = await client.query(`SELECT id FROM public.connector_keys`)
expect(keys.rowCount).toBe(0)
const usage = await client.query(`SELECT id FROM public.connector_usage_events`)
expect(usage.rowCount).toBe(0)
})
})
it('usage rows cascade with their key', async () => {
const { id } = await insertKey({})
await getPool().query(
`INSERT INTO public.connector_usage_events (connector_key_id, service, endpoint, status_code)
VALUES ($1, 'entitlements', '/api/connect/entitlements', 200)`,
[id],
)
await getPool().query(`DELETE FROM public.connector_keys WHERE id = $1`, [id])
const { rows } = await getPool().query(
`SELECT count(*)::int AS n FROM public.connector_usage_events WHERE connector_key_id = $1`,
[id],
)
expect(rows[0].n).toBe(0)
})
})
+6 -1
View File
@@ -131,7 +131,12 @@ const KNOWN_STALE_ON_CONFLICT: Record<string, string> = {}
* combination is not viable. The field set is pinned by validatePatch and
* covered by payroll-executors.test.ts; both selects around it are literals.
*/
const UNRESOLVED_CEILING = 389
// 2026-08-20: +1 for lib/connect/instance/sync.ts, whose capability_grants
// upsert is a per-company x per-scope row array built at runtime (one chunked
// bulk write); the columns it writes are the same five the Stripe grant writer
// uses literally, so the literal guard already covers them. Merged with main
// at 389: 390.
const UNRESOLVED_CEILING = 390
/**
* Floor on statically resolved column references. Guards the guard: if a change