Commit Graph

2 Commits

Author SHA1 Message Date
Jakob Wennberg 32ad6da28c feat(api): v1 invoice + customer reads (Phase 2 PR-A) (#451)
* feat(api): v1 invoice + customer read endpoints (Phase 2 PR-A)

First slice of the Phase 2 invoices vertical. Read-only endpoints landing
in this PR; writes + webhooks land in PR-B and PR-C. After all three a
developer can ship an end-to-end invoicing integration.

New endpoints (all wrapped, scoped, cursor-paginated):
- GET /api/v1/companies/:companyId/invoices       — list, filters: status, customer_id, document_type, currency. Cursor on (invoice_date DESC, id DESC). Customer name embedded inline; ?expand=customer for full record, ?expand=items for line items.
- GET /api/v1/companies/:companyId/invoices/:id   — detail with embedded customer. ?expand=items,payments.
- GET /api/v1/companies/:companyId/customers      — list, filters: customer_type, search (name/org_number prefix), include_archived. Cursor on (created_at ASC, id ASC).
- GET /api/v1/companies/:companyId/customers/:id  — detail. ?expand=invoices embeds open invoices in a single round-trip.

Shared infra:
- lib/api/v1/expand.ts — parseExpand() validates ?expand=a,b,c against a per-endpoint allowlist; unknown keys yield VALIDATION_ERROR with the full invalid list and the allowlist (agent-friendly).
- All four routes register with the Zod schema registry so they show up in /api/v1/openapi.json with x-action-risk and use-when / do-not-use-for metadata.
- Compound keyset filter on both list endpoints (per Greptile review on PR #450) — no skipped or duplicated rows on page boundaries.

Tests:
- lib/api/v1/__tests__/expand.test.ts (8 tests)
- app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts (10 tests)
- app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts (8 tests)

Full repo suite green (3127/3127), build clean, lint clean on v1 paths.

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

* fix(api): address PR #451 review (Greptile + compliance swarm)

- Greptile P1 (customers search) + OWASP V1.2.5: customer search term now
  escapes both PostgREST .or() delimiters (,()) AND SQL LIKE wildcards
  (% _ \). '100%' searches for the literal string instead of any
  customer containing '100'.
- OWASP V8.2.1 + V16.1: detail endpoints now UUID-validate the :id path
  param before touching the database, and no longer echo the raw id in
  the NOT_FOUND response details. Adds a structured warn log on 404 with
  the queried (id, companyId) for audit purposes.
- V4.5 + Art.25(1) + A.8.3 / A.8.11 + CC6.3 + PI1.3 (~12 findings): every
  select('*') replaced with explicit column lists per the documented Zod
  schemas. Includes joined sub-queries — customer:customers(...),
  items:invoice_items(...), payments:invoice_payments(...). Future
  schema migrations adding sensitive columns must now update these
  projections before the field becomes visible on the public API.
- A.8.5: hardcoded 'Bearer gnubok_sk_x' in test fixtures replaced with
  'Bearer test-fixture-not-a-real-key' to avoid false-positive secret
  scanner alerts. Fixture UUIDs upgraded to valid v4 format (Zod 4's
  .uuid() enforces version+variant digits).
- Art.5(1)(f): customer-invoices expansion soft-degrade now logs only
  the error code + message rather than the full Supabase error object.

Pushing back on:
- Art.5(1)(b) org_number in customer list — Bolagsverket-public data
  (same triage as PR #450; required by integration use case)
- Art.25(2) customer_name always-joined — denormalising via trigger is
  a real schema migration for a marginal data-flow gain
- A.8.15 _partial flag on soft-degrade — ?expand is documented as a hint

50/50 v1 tests; 3131/3131 full suite; build clean.

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

* fix(api): second-pass review on PR #451 — partial_expansions + fake fixtures

Address the residual compliance-swarm findings after the first fix round:

- CC6.1 (medium): the customer-detail handler now sets
  meta.partial_expansions=['invoices'] when the ?expand=invoices subquery
  fails, signalling the degraded response to the caller without escalating
  to error-level logs (alert fatigue). The primary resource still returns
  with an empty invoices array. New ResponseOptions.partialExpansions
  threaded through buildMeta(). 1 new test for the failure path, plus
  a happy-path assertion that the flag is absent.
- A.8.33 (low): SAMPLE_CUSTOMER fixture's org_number and vat_number
  replaced with 'TEST-0000-0001' / 'SETEST00000001' — cannot be confused
  with real Bolagsverket entries or pass external VIES validation.

Pushing back on:
- CC6.3 (medium) — separate scope for ?expand=items on invoices: every
  accounting API I know (Stripe, QuickBooks, Fortnox) treats line items
  as part of the invoice resource. Splitting would violate principle of
  least surprise for integrators; the plan deliberately treats
  invoices:read as covering the full invoice including items.

3132/3132 vitest pass; build clean.

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

* fix(api): third-pass review on PR #451 — PII minimisation refinements

Third compliance-swarm sweep (6 → 0 highs, 4 medium, 2 low). Addressed:

- Art.5(1)(c) personnummer leakage: customer LIST response now masks
  org_number AND vat_number for customer_type IN ('individual',
  'eu_individual') — for sole traders (enskild firma) org_number IS the
  personnummer. Business customers' Bolagsverket-public org_numbers stay
  visible. Detail endpoint (deliberate single-record fetch) unchanged.
- Art.5(1)(c) over-broad invoice-list expansion: ?expand=customer on
  the invoice LIST endpoint now uses a new CUSTOMER_LIST_CONTEXT_COLUMNS
  projection (id, name, customer_type, email, country, archived_at) —
  full address/phone/notes/vat_number stay on the customer DETAIL
  endpoint. Drops PII transmitted in bulk-list contexts by ~60%.
- A.8.15 permission-error differentiation: customer-detail soft-degrade
  for ?expand=invoices now bumps Postgres error class 42 (insufficient
  privilege, RLS denial) to error-level log so Sentry alerts on
  misconfigurations. Transient errors stay at warn.
- PI1.1 ISO-4217 currency: invoice list ?currency now requires
  /^[A-Z]{3}$/ instead of accepting any 3-8 char string. Two new tests.

Pushing back on:
- Art.5(1)(f) UUID logging on 404 — UUIDs have 122 bits of entropy; you
  cannot enumerate the space, so the "log scraping = enumeration" framing
  doesn't hold. Operational audit value > theoretical risk.
- Art.25(1) notes-by-default in customer DETAIL — kept inline. Detail
  is a deliberate single-record fetch; the dashboard shows notes inline;
  agents calling /customers/{id} reasonably expect them. Notes are
  already excluded from the LIST endpoint AND from the invoice-list
  ?expand=customer projection (above).

3135/3135 vitest pass; build clean.

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 21:49:22 +02:00
Jakob Wennberg 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