Commit Graph
7 Commits
Author SHA1 Message Date
Jakob WennbergandClaude Opus 4.7 db592d922d feat(api): v1 REST API foundation — auth wrapper, scopes, registry, smoke endpoints (#450)
* feat(api): v1 REST API foundation — auth wrapper, scopes, registry, smoke endpoints

Lay the substrate for the public REST API at /api/v1/*: Bearer-auth wrapper
that reuses the existing api_keys + idempotency machinery, an extended scope
catalogue (companies, events, webhooks, operations, documents, compliance),
v1 response envelopes (data + meta with request_id, api_version, audit block,
cursor pagination), an error envelope with recovery_hint / docs_url /
valid_alternatives derived from the existing structured-error registry, and
a Zod schema registry that generates the OpenAPI 3.1 spec with x-action-risk
/ x-idempotent / x-reversible / x-dry-run-supported extensions.

Ships discovery routes (/llms.txt, /.well-known/skills/index.json) and three
smoke endpoints (GET /api/v1/health, /api/v1/companies, /api/v1/openapi.json)
so the wrapper is exercised end-to-end. Includes the api_keys.mode (test|live)
migration and 41 unit tests covering auth, scope, company-membership,
idempotency replay, dry-run, pagination, response shape, and scope resolution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): harden v1 foundation — cursor validation, security headers, forensic logs

Address compliance-swarm findings on PR #450:

- OWASP V2.3: decodeDefaultCursor now validates the cursor's ts as ISO 8601
  and id as UUID. A crafted cursor previously could inject untyped strings
  into a query's .gt(field, value); PostgREST would have rejected them, but
  validating here keeps the failure mode predictable (stale cursor → reset)
  rather than 400-ing.
- OWASP V3.4: public discovery routes (llms.txt, .well-known/skills,
  openapi.json) now stamp X-Content-Type-Options: nosniff, Referrer-Policy,
  X-Frame-Options: DENY. New lib/api/v1/security-headers.ts helper.
- OWASP V16: security event logs (missing token, validation failure,
  insufficient scope, company-membership deny) now include source IP
  (x-forwarded-for / x-real-ip) and User-Agent for forensic correlation.
- OWASP V8.2.1 / ISO A.8.3: GET /api/v1/companies emits a warn log when the
  PostgREST archived_at filter unexpectedly returns a row with a null
  company join, surfacing silent data-integrity regressions instead of
  hiding them behind the existing pickCompany() === null filter.

Pushing back on (not changed):
- GDPR Art.32 cursor HMAC signing — cursors only paginate within a user's
  own user_id scope; cross-tenant probe surface doesn't exist yet.
- GDPR Art.25 org_number in list — Bolagsverket public-record data, removing
  forces N+1 fetches to make the response useful.
- SOC 2 CC6.3 service-role bypasses RLS — defense-in-depth IS the design;
  the wrapper's company_members membership check is the technical control.
- ISO A.8.12 public OpenAPI spec — intentional, mirrors Stripe/Twilio.

5 new pagination tests cover the cursor validators. 46/46 v1 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): detect Supabase duplicate-signup obfuscation on register

Supabase obfuscates duplicate signups to prevent user enumeration: when an
email already belongs to a confirmed account, signUp returns data.user with
identities: [] and no error, and sends no email. Without detecting this case
we showed the "check your email" screen to the user, who then waited for a
mail that never arrived.

Detect the empty-identities response and surface it via duplicateEmail state
so the UI can branch on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): second-pass hardening — CSP, IP truncation, cursor scope comment

Address the second compliance-swarm sweep on PR #450:

- OWASP V3.2: PUBLIC_SECURITY_HEADERS now includes Content-Security-Policy
  default-src 'none'; frame-ancestors 'none'. Free win for JSON/text-only
  public routes (no script, style, image, or form contexts).
- GDPR Art.5(1)(f): truncate IPs before logging — IPv4 to /24, IPv6 to /48.
  Preserves diagnostic value (ASN, abuse-pattern correlation, city-level
  geolocation) while eliminating point-of-presence identification. Standard
  pattern used by Google Analytics anonymize_ip. Exported truncateIp() so
  other surfaces can adopt it.
- OWASP V8.2.1: explicit comment in GET /api/v1/companies documenting that
  the cursor's joined_at is applied AFTER user_id filter, so a tampered
  cursor can only reorder rows the caller already owns. Cursors deliberately
  unsigned; trade-off documented.

Pushing back on second-pass findings (not changed):
- ISO A.8.12 / SOC 2 CC6.3 health/llms.txt/skills exposing service name +
  API version + MCP URL — these are intentional disclosures for a public
  3rd-party developer API; hiding them is theatre.
- GDPR Art.32 logging granted scopes on INSUFFICIENT_SCOPE — diagnostic
  value during incident response outweighs the theoretical privilege-profile
  leak; an attacker who already breached the log store has bigger problems.
- OWASP V2.2 route-level Zod for cursor — decodeDefaultCursor already
  validates strictly; route-level Zod is stylistic.
- GDPR Art.25(2) org_number/entity_type in list — Bolagsverket-public data;
  entity_type materially affects which API calls make sense.
- ISO A.8.15 x-forwarded-for trusted-proxy CIDR — overkill behind Vercel's
  edge which rewrites the leftmost value.

50/50 v1 tests pass (4 new for truncateIp). Build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): third-pass hardening — Host header injection, anon client, HSTS

Address the third compliance-swarm sweep on PR #450:

- SOC 2 CC6.1 (3× high): llms.txt, openapi.json, and .well-known/skills
  built URLs from the inbound Host header. A spoofed Host could poison
  agent discovery with attacker-controlled endpoints. New
  lib/api/v1/base-url.ts centralises canonical base-URL derivation via
  NEXT_PUBLIC_APP_URL (already a required env var per CLAUDE.md).
- ISO A.8.2 / A.8.5 (2× high): the wrapper's public-scope code path now
  uses an anon-key Supabase client (RLS-respecting) instead of the
  service-role client. A future accidental DB call from a public handler
  is constrained to anon-accessible rows. Least-privilege at the
  infrastructure layer.
- OWASP V3.2 (medium): PUBLIC_SECURITY_HEADERS now includes
  Strict-Transport-Security: max-age=31536000; includeSubDomains.
- GDPR Art.5(1)(f) (medium): truncateIp now logs a warn when a non-empty
  x-forwarded-for / x-real-ip payload fails to parse, surfacing spoofed
  or unexpected proxy values to security monitoring instead of silently
  dropping them. The raw value is never logged.
- CC2.3 (low): llms.txt now links the SECURITY.md disclosure policy with
  the security@arcim.io reporting address so agents have a clear
  responsible-disclosure path.

Pushing back on third-pass findings (not changed):
- Cursor HMAC signing — user_id filter is the authorisation boundary;
  cursor scope is bounded to within-user rows. Documented in code.
- org_number in companies list — Bolagsverket public data; the swarm's
  "could be enskild firma personnummer" framing isn't accurate (enskild
  firma org_number IS the personnummer, but it's already in the public
  Bolagsverket business register).
- Health endpoint information disclosure — intentional for a public
  developer API; matches Stripe/Twilio convention.
- llms.txt / skills index MCP URL disclosure — that's the file's purpose.
- Cache-Control public on discovery routes — content is by definition
  public; getCanonicalBaseUrl() removes the previous spoof concern.
- Duplicate-email screen — user's own input; out of scope for this PR.

50/50 v1 tests pass; @supabase/supabase-js#createClient mocked so the
public-path tests don't need real env vars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): widen validateApiKey result assertions to include mode field

The core-only CI job failed on two pre-existing api-keys.test.ts assertions
that used strict toEqual matching against the old (userId, companyId, scopes)
shape. The wrapper migration in this PR widened that shape with mode,
apiKeyId, and apiKeyName.

Update both existing assertions to match the current shape and add a third
test that exercises the mode='test' path. 3027/3027 vitest tests now pass
locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): fourth-pass hardening — env guards, IP range check, headers on wrapped routes

Address the fourth compliance-swarm sweep on PR #450:

- ISO A.5.17 / SOC 2 CC6.1 (high): createAnonClient now fails closed with
  an explicit Error if NEXT_PUBLIC_SUPABASE_URL or _ANON_KEY are missing,
  surfacing misconfiguration on the first request instead of throwing
  deeper in the handler with no context.
- GDPR Art.5(1)(f): truncateIp now rejects IPv4 with out-of-range octets
  (>255). '999.999.999.999' now returns undefined instead of a pseudo-IP
  that would pollute abuse-pattern analysis. Edge octets (0, 255) still
  accepted. 2 new tests.
- OWASP V3.2 / V3.3: the wrapper's stampHeaders step now applies the full
  security header set to every wrapped v1 response (CSP, HSTS, X-Frame,
  Referrer-Policy, X-Content-Type-Options) PLUS X-Robots-Tag: noai,
  noimageai so authenticated payloads are excluded from AI training sets.
  Public discovery routes (llms.txt, skills index, openapi.json)
  deliberately omit X-Robots-Tag — being AI-discoverable is the whole
  point of those surfaces.
- New WRAPPED_RESPONSE_HEADERS export separates the two contexts.

Pushing back on:
- SOC 2 CC6.1 medium "API key prefix in public docs aids brute force" —
  inverted logic. Every public API publishes its key prefix specifically
  so secret scanners (GitHub Advanced Security, GitLeaks) can detect
  leaks. Stripe (sk_live_), GitHub (ghp_), OpenAI (sk-) all do this.
- SOC 2 CC6.3 medium "formal risk register for unsigned cursors" — org
  -level documentation, outside this PR. Code-comment already documents
  the trade-off.
- SOC 2 CC2.3 low "llms.txt hardcodes security@arcim.io" — same address
  as SECURITY.md; no drift risk.

Flagged separately (not changed): the register-page duplicate-email
detection in this branch defeats Supabase's user-enumeration obfuscation
(GDPR Art.5(1)(c) × 2, ISO A.8.11). Substantive product decision: UX (no
infinite-wait for non-existent accounts) vs security (no enumeration).
GitHub and Stripe Atlas pick UX; some pick security. Owner's call.

3029/3029 vitest tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): address Greptile review on PR #450

- P1 (companies/route.ts): keyset pagination was missing its tiebreaker.
  The cursor encoded (joined_at, id) but the filter only applied
  .gt('joined_at', ts) — same-joined_at rows on a page boundary could be
  skipped or duplicated. Also the encoded id was companies.id while the
  sort was on company_members, mismatched. Fixed: select + sort + encode
  on company_members.id, apply compound
  joined_at.gt.{ts} OR (joined_at.eq.{ts} AND id.gt.{cursor_id}) via .or().
  Side benefit — eliminates the broken-cursor-on-null-join case (#2)
  because company_members.id is always present, no null guard needed.
- P2 (registry.ts): ZodUnion branch had a dead ternary
  (['x','y','z','w'].length > 0 ? undefined : 'object') that always
  yielded undefined. Removed; emit { oneOf: [...] } without top-level
  type (correct JSON Schema for a union).
- P2 (with-api-v1.ts): public-endpoint path was short-circuiting before
  Bearer-token validation, contradicting the JSDoc and PR description.
  Now opportunistically validates a supplied token for rate-limit
  attribution + key tracking; missing/invalid token silently falls back
  to anon (the route is public by definition, so we don't 401). Two
  new tests cover both branches.

3031/3031 vitest tests pass; build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:47:13 +02:00
Mattsson c03582b5c7 Fix/percistent mcp connection (#392)
* feat(oauth): add support for refresh tokens in OAuth flow and update database schema

* feat(prompts): add MCP prompts and corresponding functionality for prompt retrieval

* feat(auth): enhance error handling for refresh token operations and validation
2026-05-05 13:48:53 +02:00
Jakob WennbergandClaude Opus 4.6 b387a77bfd chore: remove Sentry, consolidate migrations, add test coverage (#244)
* chore: remove Sentry, consolidate migrations, add test coverage

Remove @sentry/nextjs and all Sentry integration code — error tracking
now handled by Recapt. Consolidate 22 incremental migrations into a
single schema sync migration. Add 6 new test suites (auth, invoice
matching, VAT rules, opening balances) and extend report tests with
edge cases. Update Docker image name to gnubok, sync crontabs and
extension presets, fix CSP missing space, simplify journal entry
missing-document dialog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove viewer bank import migration never applied to production

20260413150000_viewer_bank_import_permissions.sql (PR #234) was merged
to main but never applied to the production database. It references
current_active_company_id() which does not exist in production either.
This breaks fresh installs and Supabase preview branches because the
migration runs before the consolidated schema sync.

Remove it so the migration chain matches production. The viewer bank
import RLS policies should be re-added in a future migration alongside
the helper functions they depend on.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct delete policies for tables without company_id column

Seven tables in the generic delete-policy loop don't have a direct
company_id column, causing fresh installs to fail with "column
company_id does not exist". Fix by moving them out of the loop:

- invoice_items, journal_entry_lines, receipt_line_items,
  supplier_invoice_items → join through parent table
- extension_toggles, notification_settings, push_subscriptions →
  user-scoped (auth.uid() = user_id)

All policies match their existing production definitions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:52:00 +02:00
Mattsson cd376e1cad feat: implement viewer role permissions for bank transaction imports and connections (#234) 2026-04-13 19:43:28 +02:00
Mattsson a1a816b4a5 Delete features (#218)
* Implement company and account deletion features

- Add event types for company and account deletion to CoreEvent.
- Enhance Supabase middleware to handle company context resolution and cookie management for archived companies.
- Create API routes for deleting accounts and companies, including necessary validations and event emissions.
- Implement tests for account and company deletion endpoints to ensure proper functionality and error handling.
- Add retention notice component to inform users about bookkeeping data retention during destructive actions.
- Create database migrations to support soft deletion of companies and anonymization of user accounts, ensuring compliance with retention laws.

* feat: enhance account deletion process and update user notifications

* Add service client for onboarding completion check and update escape hatch visibility

* Enhance invite flow and email handling for company members

* Refactor company context and RLS policies for active company isolation

- Update `switchCompany` to remove unnecessary revalidation as client handles navigation.
- Revise `getActiveCompanyId` to prioritize `user_preferences` and validate against non-archived memberships.
- Modify `setActiveCompany` to ensure `user_preferences` is the authoritative source while maintaining cookie compatibility.
- Enhance middleware to resolve active company using `user_preferences` and fallback to first non-archived membership.
- Introduce new API route `/api/company/current` to fetch the active company ID for cross-tab synchronization.
- Implement `CompanyTabSync` component for real-time active company enforcement across tabs.
- Create migration for RLS policies to enforce single-active-company isolation using `current_active_company_id()`.

* feat: implement viewer role enforcement for write permissions

- Added `useCanWrite` hook to determine if the current user has write permissions based on their role in the active company.
- Updated various components (JournalEntryForm, CustomerForm, DeadlineForm, etc.) to disable write actions and show a lock icon with a tooltip for users without write permissions.
- Introduced `requireWritePermission` function to enforce write permissions at the API level, returning a 403 response for viewers.
- Created tests to verify the behavior of the viewer role and write permissions.
- Added database migration to enforce read-only access for viewers at the database level.
2026-04-11 17:06:32 +02:00
Jakob WennbergandClaude Opus 4.6 6d75b9a1bf feat: BankID authentication via TIC Identity API (#192)
* feat: add BankID authentication via TIC Identity API

Integrate BankID as a login/signup method using the TIC Identity API.
Users can authenticate with BankID QR codes (desktop) or deep links (mobile),
link BankID to existing accounts, and skip TOTP MFA when BankID is linked.
Removes Step 0 (role choice) from onboarding for all users. Adds enrichment
data support for pre-filling company details from Bolagsverket during signup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — server-side rate limit, unlink clears MFA bypass

- Add per-IP rate limit (5s cooldown) on /bankid/start to prevent
  unbounded billable TIC sessions from unauthenticated callers
- Add /bankid/unlink endpoint that deletes bankid_identities AND clears
  app_metadata.bankid_linked so MFA enforcement resumes after unlink
- Update BankIdSettings to call server-side unlink instead of client-side delete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move rate limiter to module scope, add BankID logo and year-end skill

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:44:14 +02:00
MattssonandClaude Opus 4.6 550cadcb06 fix: harden auth, cron secrets, and provider flows (GNU-17) (#148)
- Replace === with crypto.timingSafeEqual in all 7 cron routes via shared lib/auth/cron.ts
- Add in-memory rate limiting (60 req/min) and expires_at support to calendar feed
- Add exponential backoff on MFA verify after 3 failed attempts
- Add 60s cooldown on password reset requests
- Validate bank callback auth code format before API call
- Redact session IDs from bank sync and callback logs
- Validate OAuth redirect_uris against allowlist (claude.ai, claude.com, localhost)
- Remove excessive PII/debug console logging from login page

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:49:09 +02:00