122bcbbcc12f5017a48d71dccbcbb66f877d75dd
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cd96e5ec26 |
feat(api): v1 invoice :mark-paid + :credit action verbs (Phase 2 PR-B-2b combined) (#455)
* feat(api): v1 invoice :mark-paid + :credit action verbs (Phase 2 PR-B-2b combined)
Bigger PR per the user's request. Lands the remaining two journal-entry-
centric action verbs together — they share the same lifecycle pattern
established in :mark-sent (idempotent, dry-runnable, scope-gated,
warnings on partial-state failures).
POST /api/v1/companies/:companyId/invoices/:id/mark-paid
- Books a payment against a sent / overdue invoice. Updates status to
paid (or partially_paid when remaining_amount > 0). Three booking paths:
- Faktureringsmetoden (accrual default): Debit 1930 / Credit 1510 via
createInvoicePaymentJournalEntry — settles AR.
- Kontantmetoden (cash): Debit 1930 / Credit revenue + Credit VAT via
createInvoiceCashEntry — revenue recognition happens HERE under cash.
- Custom lines (partial payment): caller-supplied balanced journal lines
via createJournalEntry directly. Validated for balance (sum debits ==
sum credits, both > 0) → 400 INVOICE_PAID_LINES_UNBALANCED otherwise.
- Optional body: { payment_date?, exchange_rate_difference?, lines? }
- Race-condition guard: status update matches .in(['sent','overdue',
'partially_paid']) so a concurrent payment returns 409 INVOICE_PAID_RACE.
- Emits invoice.paid (new event type, added to lib/events/types.ts with
paymentAmount + paymentDate in the payload).
POST /api/v1/companies/:companyId/invoices/:id/credit
- Issues a kreditfaktura against a sent / paid / overdue invoice
(ML 17 kap 22–23§). Creates a NEW invoice row with:
- invoice_number = "KR-<original>"
- credited_invoice_id = original id
- status = 'sent'
- All amounts negated (subtotal, vat_amount, total, items quantities/totals)
- Items mirror the original with negated values; inserted in a separate
step with company-scoped rollback DELETE on failure.
- Flips original invoice to status='credited'. Warns ORIGINAL_NOT_FLIPPED
if the flip fails (the credit note still exists; operator reconciles).
- Posts reverse journal entry via createCreditNoteJournalEntry (accrual
only; cash basis defers to refund time).
- Emits credit_note.created (existing event in the bus).
Both endpoints:
- Use the established wrapper + Idempotency-Key + dry-run + warnings
pattern from :mark-sent.
- Validate document_type (no delivery_notes), credited_invoice_id (no
recursive credits), and status before any mutation.
- Use explicit column projections (no SELECT *).
- Sanitize pg_message from client responses (kept in logs).
- Emit error-level logs on partial-state failures + surface warnings to
the caller via meta.warnings.
Event types union (lib/events/types.ts) gains invoice.paid; credit
uses the existing credit_note.created event.
URL convention: plain /verb subpaths (e.g. /invoices/:id/mark-paid),
consistent with :mark-sent. Stripe/QuickBooks pattern, not the
AIP-style :verb that Next.js routing fights.
17 new tests covering happy paths (accrual + cash for mark-paid),
custom-lines balance validation, dry-run preview, document-shape
guards, scope, idempotency, race conditions, and credit-of-credit /
delivery-note rejection.
3194/3194 vitest pass; build clean; lint clean on v1 paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #455 review + include password-recovery fixes
PR #455 review fixes:
- Greptile P1 (CLAUDE.md architecture rule): API routes that emit events
via eventBus must call ensureInitialized() at module level to wire
extension event handlers. Neither :mark-paid (invoice.paid) nor :credit
(credit_note.created) had it — nor did the already-merged :mark-sent,
POST /invoices, POST /customers, etc. Fixed once at the wrapper layer:
ensureInitialized() now runs at module import of lib/api/v1/with-api-v1.ts,
so EVERY v1 route gets the init at import time. Single source of truth
prevents future routes from forgetting (idempotent guard makes the
repeated call safe). Cleaner than per-route copy of the call.
- Swarm PI1.3 (low): 0.005 epsilon in mark-paid was undocumented. Added
a comment explaining: after rounding to 2 decimals, newRemaining is in
steps of 0.01; values ≤ half-an-öre only arise from float artefacts.
Pushing back (recurring triage, consistent with prior PRs):
- V8.2.1 + CC6.3 × 4 "ctx.companyId vs params.companyId mismatch" —
impossible by construction. The wrapper sets ctx.companyId FROM the URL
params after the membership check. They are guaranteed equal.
- V2.3 + A.8.15 + A.8.28 atomicity / floating-point / partial-failure
alerts — same architectural / cross-surface deferred work as prior PRs;
matches internal /api/invoices pattern precisely.
- V4.5 account_number allowlist — engine validates it.
- V2.4 idempotency TOCTOU — wrapper handles via DB unique constraint.
- Art.5(1)(f) PII in logs, A.8.11 dry-run preview scope, A.8.15 partial-
failure naming, test scope coverage — all recurring triage.
Password-recovery flow fixes (included per request — pre-existing
working-tree changes the user authored):
- app/(auth)/auth/callback/route.ts: when the callback exchanges a
recovery token (type='recovery' or next='/reset-password'), redirect
directly to /reset-password instead of running onboarding/MFA/
dashboard checks. Previously users clicking the password-reset email
got bounced through onboarding.
- lib/supabase/middleware.ts: /reset-password no longer bounces
authenticated users to / (the recovery flow lands here with a fresh
session by design — the user is *supposed* to call updateUser({
password }) on this page).
- app/(auth)/login/page.tsx: shows an error banner when ?error=auth_error
is set (expired/used recovery link), with a button to request a new
one. Wrapped the page in <Suspense> because useSearchParams() now
forces dynamic rendering (Next.js 16 static-prerender bail-out
otherwise).
- app/(auth)/auth/callback/__tests__/route.test.ts: new test file
covering the recovery callback path.
3197/3197 vitest pass (3194 prior + 3 from the new auth-callback tests).
Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): mark-paid uses remaining_amount as default payment, not total
Real correctness fix from Swedish-compliance review on PR #455. When no
customLines is supplied, mark-paid previously defaulted paymentAmount to
typed.total. Combined with the race-condition guard that allows the
status UPDATE to flip a partially_paid invoice to paid, this could
over-credit AR in a race scenario:
1. Invoice in 'sent' status, total=12500, remaining=12500.
2. Concurrent partial payment lands first → status='partially_paid',
remaining=7500.
3. The full-payment request's pre-flight saw 'sent' and passed; its
UPDATE matches partially_paid (race guard allows it). With the old
logic the journal entry was for total=12500 against an AR balance
of only 7500 — a 5000 over-credit.
Using remaining_amount as the default eliminates this. Same end state
in the common case (no prior partial); correct booking in the race.
3197/3197 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
d29b87fb80 |
feat(api): v1 customer writes (Phase 2 PR-B-1) (#452)
* feat(api): v1 customer writes (Phase 2 PR-B-1)
First slice of Phase 2 PR-B from the agent-native v1 plan. Customer writes
are the simplest write surface — no journal entries, no PDF, no email —
which makes them the right place to validate the dry-run + idempotency
pipeline before applying it to invoice flows in PR-B-2.
New endpoints:
- POST /api/v1/companies/:companyId/customers (idempotent, dry-runnable)
- PATCH /api/v1/companies/:companyId/customers/:id (idempotent, dry-runnable)
- DELETE /api/v1/companies/:companyId/customers/:id (soft-delete via
archived_at; idempotent re-archiving; dry-runnable; 204)
All three require customers:write scope (added to V1_ENDPOINT_SCOPES). All
three require Idempotency-Key (mandatory — wrapper option
requireIdempotencyKey: true). All three accept ?dry_run=true or
X-Dry-Run: true and return a 200 OK preview with X-Dry-Run header set.
New shared infrastructure:
- lib/api/v1/dry-run.ts — dryRunPreview() (validation-only, no staging) and
dryRunStaged() (financial writes; populated in later phases). Defines the
preview response shape that POST/PATCH/DELETE share. Future financial
writes (invoices, journal entries) will reuse the staged variant.
Pre-existing PR-A bugs fixed:
- CustomerType enum in customers/route.ts had wrong values ('business',
'eu_individual', 'non_eu'). Canonical enum is ['individual',
'swedish_business', 'eu_business', 'non_eu_business']. Fixed.
- INDIVIDUAL_TYPES masking referenced non-existent 'eu_individual'.
Only 'individual' refers to a natural person (Swedish sole trader where
org_number = personnummer).
Wrapper bug fix:
- The idempotency body-hashing flow was consuming the original request's
body before passing it to the handler. In Node's vitest environment the
cloned request's body became empty, so the handler's request.json()
returned {}. Fix: read body from a clone for hashing, leave the
original intact for the handler.
VIES re-validation on PATCH preserves existing best-effort behaviour.
Customer.created event emission on POST so future webhook delivery
(Phase 2 PR-C) can subscribe.
23 new tests covering happy path, dry-run preview, idempotency-key
enforcement, scope checks, UUID validation, duplicate-org conflict,
soft-delete semantics. 3150/3150 vitest pass; build clean; lint clean
on v1 paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #452 review (Greptile + swarm + Swedish compliance)
Real bugs (all reviewers agreed):
- POST registerEndpoint example used the old customer_type 'business'
enum value that this PR was already fixing. Now uses 'swedish_business'
consistently between code, schema, and docs.
- DELETE docstring claimed "Refuses to delete if open invoices remain"
but the handler unconditionally archived. Swedish compliance flagged
this as a real ML 17 kap 24§ concern (archived customer + open
invoice can block kreditfaktura issuance). Added the pre-flight check:
DELETE now returns 409 CUSTOMER_HAS_INVOICES with the open count when
any open invoice (sent / partially_paid / overdue) references the
customer. Docstring updated to match.
- PATCH advertised archived_at: null for un-archive but did not actually
apply it (field missing from updateData iteration). New
V1PatchCustomerSchema extends UpdateCustomerSchema with archived_at
restricted to literal null — agents can un-archive but cannot fake
archive timestamps. The PATCH allowlist now includes archived_at.
Defensive cleanups:
- POST: VIES validation now resolves BEFORE the insert so
vat_number_validated is set atomically in the primary write. Eliminates
the stale-response window where the response could show
vat_number_validated=false even though the secondary update succeeded.
- PATCH: same pattern — VIES re-validation folded into the primary
update payload. Single round-trip; response always reflects committed
DB state.
- CUSTOMER_RESPONSE_COLUMNS dropped vat_number_validated_at (internal
timestamp; not declared in the CustomerCreated or CustomerDetail Zod
schemas; no documented consumer).
Pushing back on:
- V8.2.1 cross-tenant membership check — false positive. The wrapper
performs the company_members check before invoking the handler
(lib/api/v1/with-api-v1.ts ~232). The swarm read handlers in isolation.
- A.8.11 PATCH response masking for individual customer_types —
deliberate detail-endpoint carve-out per PR-A. List masks, detail
doesn't; that's the design.
- CC6.3 separate customers:delete scope — every accounting API
(Stripe, QuickBooks, Fortnox) conflates write + archive. Splitting
violates principle of least surprise.
- V4.5 schema allowlist enforcement — Zod already strips unknown keys;
defense-in-depth at the DB-write layer is redundant.
- A.5.34 eu_individual masking documentation — the value never existed
in canonical CustomerTypeSchema; PR-A's enum was a hallucination this
PR corrects. Nothing to document beyond the code comment that's now
in place.
- Swedish: country default 'Sweden' wrong for non-Swedish customer types —
breaking schema change; defer.
- Swedish: VAT-format regex pre-check before VIES — internal
/api/customers route doesn't either; consistency over micro-validation.
- Swedish: flag existing reverse-charge invoices when VIES turns
vat_number_validated=false — substantial cross-resource workflow;
defer to PR-B-2 or a dedicated compliance-tooling PR.
- CC7.2 customer.updated / customer.archived event emission — adding new
event types touches lib/events/types.ts AND the event-log-handler
allowlist; defer to PR-C where webhooks will be the consumer.
3 new tests cover: archive-blocked-by-open-invoices, PATCH archived_at
un-archive, PATCH archived_at rejects non-null. 3153/3153 vitest pass;
build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): second-pass review on PR #452 — defense-in-depth tweaks
Two small adjustments after the second compliance-swarm sweep (13 →
expected 11 findings, 0 blocking after this commit):
- GDPR Art.5(1)(c) defense-in-depth: re-add 'eu_individual' to the
INDIVIDUAL_TYPES masking set in the customer LIST handler. The value
is not in the canonical CustomerTypeSchema (so new customers can never
have it), but the `customer_type` DB column carries no CHECK constraint,
so legacy rows from earlier schema iterations could in principle hold
it. Masking is free when the value never appears and protective if it
ever does. Adding 'eu_individual' as a first-class customer_type for
EU natural persons remains a separate product decision the Swedish
compliance review surfaced.
- ISO 27001:2022 A.8.33: test bootstrap now asserts NODE_ENV === 'test'.
Supabase clients are fully mocked, but if a future test refactor
accidentally bypassed the mock, this guard fails the run rather than
letting fixtures reach production.
Stale comments from prior sweep — no action needed, fixes already in
commit 5bb63489:
- Greptile P1 "DELETE doesn't check open invoices" — handler now does
(lines ~430-440 of [id]/route.ts); the inline comment is pinned to
the original file lines and hasn't auto-resolved.
- Greptile P1 "PATCH archived_at silently ignored" — V1PatchCustomerSchema
now accepts archived_at: z.null().optional() and the field is in the
iteration list.
- Greptile P1 "example uses old enum" — updated to 'swedish_business'.
False positive called out:
- OWASP V2.4 "dry-run DELETE skips the open-invoice pre-check" — the
pre-flight runs BEFORE the dry-run branch ([id]/route.ts ~430-445),
so dry-run DELETE on a customer with open invoices DOES return 409.
Pushing back (consistent with first-pass triage):
- V8.2.1 × 3 cross-tenant check — wrapper does it (with-api-v1.ts ~232);
false positive.
- V2.3 / CC6.3 archive scope split — every accounting API conflates
write + archive.
- V4.5 customer_type cross-field invariants — schema-level cross-field
validation; defer.
- V16 transactional outbox — architectural; defer to PR-C webhooks.
- Art.25 + A.5.12 + A.5.34 PATCH/dry-run preview masking — single-record
detail context, deliberate carve-out per PR-A.
- Swedish 'disputed' status — not in canonical InvoiceStatus enum.
- Swedish 3-state VIES validation, personnummer-format check, mandatory
country for non-SE types — all schema-level / cross-field; defer.
3153/3153 vitest pass; build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): don't echo org_number in 409 conflict response (GDPR Art.5(1)(c))
For customer_type='individual', org_number IS the Swedish personnummer.
The 409 CUSTOMER_DUPLICATE_ORG_NUMBER error detail previously included
the submitted value, transmitting it through:
- The HTTP response body
- Server / observability logs
- Any HTTP intermediary recording bodies
The caller already knows what they submitted; the error code + a
{ field: 'org_number' } hint is enough. Drops the value from the detail
in both POST /customers and PATCH /customers/:id.
3153/3153 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |