c0ecf2fa3bebd46bdfd0169efd73b89653d1dfed
9 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0ff1b05553 |
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> |
||
|
|
79edee659f |
docs(self-host): sovereign Sverige guide, backup/restore scripts, Speed Insights gate (#1744)
docs/SOVEREIGN.md (run Accounted on Swedish infrastructure: providers, self-hosted Supabase gotchas, backup/restore runbook, honest dependency list), scripts/self-host/backup.sh + restore.sh (pg_dump custom format, storage tar, SHA-256 manifest, S3-compatible upload; ACLs are preserved through the restore and re-verified against an acl-manifest including sequences; the resume hook always runs after a failed quiesce and the hooks must be configured as a pair), Vercel Speed Insights gated off for self-hosted, and stale self-host docs corrected (assistant Q&A and categorization run on BYO OpenAI-compatible models; SMTP via EMAIL_PROVIDER=smtp after #1746; connector subscription described as proposed only). |
||
|
|
523fba0419 |
feat(email): SMTP mailer behind the EmailService seam (EMAIL_PROVIDER=smtp); Resend stays the hosted default (#1746)
SmtpEmailService (nodemailer 9.0.5, exact-pinned) behind the existing EmailService seam. Provider resolution: EMAIL_PROVIDER wins, else RESEND_API_KEY selects Resend (hosted byte-identical), else SMTP_HOST selects SMTP. From header is built exactly like the Resend service after #1956 (no 'via <app>', fromAddress honored, platform-sender retry). STARTTLS is required by default (requireTLS) with SMTP_REQUIRE_TLS=false as an explicit opt-out for a plaintext LAN relay. Docs, env examples and the generated extension registry updated. |
||
|
|
328ccda10d |
fix: protect public Auth flows from automated abuse (#1904)
* fix: add Turnstile to public auth flows * test: isolate Turnstile auth tests |
||
|
|
11995b1b0c |
feat(auth): make automatic logout an opt-in per-user setting (#1536)
* feat(auth): make automatic logout an opt-in per-user setting Session timeouts (30 min idle / 12 h absolute on hosted) now apply only to users who enable "Automatic logout" in Settings > Security. Default is off: sessions live for the full Supabase refresh-token lifetime, the behavior from before the 2026-07 session hardening. - user_preferences.auto_logout (migration, default false), toggled via the extended /api/user/preferences route - The opt-in is snapshotted into the signed timeout cookie at mint, so enforcement stays DB-read-free per request; the preferences route clears the cookie on change so a toggle takes effect immediately - Pre-toggle cookies are authentic-but-stale: re-minted preserving their timers, never routed down the tamper path, so the rollout does not log anyone out - NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true enforces timeouts for every user regardless of preference (emergency lever, also plumbed through the Docker image); self-hosted stays disabled by default Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): resolve PR #1536 review findings - Replace the spread upsert in /api/user/preferences with one literal payload per field: the phantom-column schema guard cannot resolve spread payloads (Unit tests 3/4 ceiling failure) - Map the preferences 500 through getErrorMessage so the user-facing text is Swedish (CodeRabbit) - fetchAutoLogoutPreference now returns null on a FAILED read instead of a fail-open false: callers skip minting so an unknown preference is never persisted into the year-long signed cookie, and the next request retries; failures log at error level, distinct from the normal opt-out path (compliance swarm GDPR Art.32(1)(b) / ISO A.8.5) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): write multi-field preference updates as one atomic upsert A request carrying both hide_assistant_fab and auto_logout previously issued two sequential writes, so a failure of the second returned 500 after half the request had persisted (CodeRabbit, PR #1536). One literal upsert per accepted field combination keeps the write atomic and stays resolvable for the phantom-column schema guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2296c0cd59 |
fix(auth): provision invitees server-side when signups are disabled (#1404)
* fix(auth): provision invitees server-side when signups are disabled Self-hosted installations with GoTrue disable_signup broke the invite flow silently: invitees without an account were routed to /register, where supabase.auth.signUp fails with "Signups not allowed for this instance", surfaced only as a generic toast. New server-only env flag AUTH_SIGNUPS_DISABLED (documented in .env.example) mirrors the GoTrue setting. When true, POST /api/company/members/invite checks check_email_exists and, for invitees without an account, provisions one via auth.admin.inviteUserByEmail with a redirect back to /invite/<token>, before the Resend email and before the invitation row is written so a provisioning failure leaves nothing half-created and the admin can retry. The response now carries user_provisioned alongside email_sent, and a provisioning failure returns 502 with a Swedish message mapped through getErrorMessage instead of a silently-successful invite. /auth/callback now routes type=invite verifications to /reset-password (the existing set-password surface) instead of dropping the passwordless user on the dashboard, and preserves the invite token from next=/invite/<token> as the pre-auth invite cookie so the existing reset-password invite handoff accepts the membership right after the password is saved. getErrorMessage learns two GoTrue patterns: "Signups not allowed" (account creation closed on this installation, contact your inviter or administrator) so the /register dead end is explained even for flows that bypass provisioning, and "Error sending ... email" (GoTrue SMTP not configured) so the 502 above is actionable. Hosted is untouched: the flag is unset there and every new code path is gated on it. Fixes #1335 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): restore check_email_exists RPC and harden self-host invite config Adversarial review of #1404 found that the check_email_exists function the invite flow depends on does not exist anywhere: it shipped in PR #229 and was lost in the #244 migration consolidation before ever reaching prod (verified missing on the hosted production database directly). Today app/api/team/accept destructures only { data } from the RPC call, so alreadyHasAccount is silently null on every deployment and the invite page routes even existing-account invitees toward /register. - New migration 20260804140000 restores the function exactly as originally shipped: SECURITY DEFINER over auth.users, EXECUTE revoked from PUBLIC, anon and authenticated, granted to service_role only (prevents email enumeration). Fixes hosted prod behavior too once applied. - New tests/pg/check-email-exists.pg.test.ts locks in existence, case-insensitive matching, false-for-unknown, and the role grants. - .env.docker.example gains the AUTH_SIGNUPS_DISABLED block self-hosters actually use; both env templates now note that the GoTrue redirect URI allow-list must include /invite/* or the invite email redirect silently falls back to SITE_URL. - Invite route test for the existsError branch: RPC failure logs a warning and provisioning proceeds anyway (GoTrue is authoritative). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): mask invitee email in provisioning-failure log (#1335) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1c9d378df8 |
feat(auth): enforce session idle and absolute timeouts (#1387)
* feat(auth): enforce session idle and absolute timeouts Hosted browser sessions now carry an HMAC-signed, HttpOnly cookie holding session start, last activity and sign-in method, bound to the Supabase session. Middleware enforces a 30 min idle and 12 h absolute limit (reason-coded redirects to /login), a heartbeat route advances idle activity from real user input, and a client controller warns 2 minutes before expiry. BankID users are routed back to BankID on re-auth via a short-lived method hint. API-key and MCP bearer surfaces are exempt; self-hosted installs default off and can opt in via env vars. Fixes #362 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): derive session-timeout signing key via HKDF The HMAC key is now HKDF-derived with a purpose-bound info string, so the SUPABASE_SERVICE_ROLE_KEY fallback never uses the privileged credential directly as a signing key. Addresses the security review finding on PR #1387. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): back signature bytes with a plain ArrayBuffer crypto.subtle.verify requires a BufferSource; Uint8Array.from is typed over ArrayBufferLike, which the Vercel TypeScript build rejects. Decode base64url into a Uint8Array constructed over a fresh ArrayBuffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): address session-timeout review findings - signSessionTimeoutState returns null on signing failure instead of throwing, so a missing secret degrades the timeout feature in line with verifySessionTimeoutState rather than crashing authenticated requests; middleware and heartbeat skip the cookie write when null - heartbeat initializes a fresh signed state for a missing or session-mismatched cookie, mirroring middleware, instead of returning SESSION_EXPIRED during normal initialization - sessionStateMatchesUser treats an unresolved current session id as a mismatch for session-bound state so another session's cookie is never accepted on the userId fallback alone - drop aria-live from the countdown DialogDescription so screen readers are not interrupted every second Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
15e5dc1a01 |
fix(csp): allow self-hosted Supabase Realtime WebSocket in connect-src (#954)
connect-src listed the https Supabase origin plus wss://*.supabase.co, but never the wss variant of a self-hosted Supabase URL. Supabase Realtime opens wss://<host>/realtime/v1/websocket, which CSP blocked; WebKit throws synchronously on a CSP-blocked new WebSocket(), so Safari unmounted the dashboard into the error boundary (Chromium only logs). - next.config.ts: add supabaseWsUrl (NEXT_PUBLIC_SUPABASE_WS_URL, or the Supabase URL with https to wss / http to ws) to connect-src - Dockerfile: bake a __NEXT_PUBLIC_SUPABASE_WS_URL__ sentinel, since the CSP is fixed at build time and only sed-substituted at runtime - docker-entrypoint.sh: derive the wss origin from NEXT_PUBLIC_SUPABASE_URL unless overridden, substitute the sentinel - .env.docker.example: document the optional override Hosted is unaffected: the wss form of *.supabase.co was already allowlisted, so the added token is redundant there. Fixes #893 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
122bcbbcc1 |
chore: add Docker env example and un-ignore it (#597)
Add .env.docker.example as a template for Docker-based local development, and add a negation pattern to .gitignore so the example file is tracked while real .env* files stay ignored. Signed-off-by: Fabian Mossberg <fabian@hejfaktura.se> Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> |