Skip to content

feat: POST /consent/log — persist invite-page consent records (ENG-568) - #498

Merged
islandbitcoin merged 8 commits into
mainfrom
feat/consent-log-endpoint
Sep 3, 2026
Merged

feat: POST /consent/log — persist invite-page consent records (ENG-568)#498
islandbitcoin merged 8 commits into
mainfrom
feat/consent-log-endpoint

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Resolves ENG-568 (decision: build the endpoint).

Why

The getflash.io/invite landing page has displayed a transactional/marketing consent flow since launch and POSTed the record to https://api.flashapp.me/consent/log — an endpoint that never existed. The call is fail-open on the page, so nothing broke visibly, but every consent record was silently dropped while the page displayed a compliance promise. Surfaced during the ENG-567 invite-page incident; flash-site#28 deliberately left the page's call in place pending this decision.

What

  • POST /consent/log (src/servers/consent-log.ts) — mounted on the public server only (type === "main"), before the JWT middleware. 204 on success, no internals leaked on failure.
  • Admission gate (src/domain/consent-log) — pure validator for anonymous web input: required version + both consent legs with boolean optedIn, every string bounded, unknown fields discarded. Accepts the page's current field names (page, timestamp) as sent.
  • Storage (src/services/mongoose/models/consent-log.ts) — append-only evidence records; invite token stored only as a hash (same rule as the invites collection), client timestamp kept verbatim as an untrusted assertion with receivedAt as the trustworthy ordering field.
  • Abuse bounds — 8kb JSON body cap + per-IP rate limit (20/hr with 1h block; new consent_log_ip prefix wired through the standard RateLimitConfig machinery). A rate-limiter store fault returns 503 rather than failing open on a public write.

Not in scope

The page currently sends the raw invite token inside the consent record; server-side hashing makes that safe at rest. A follow-up flash-site change could stop sending the token and let the server correlate by hash — noted on ENG-568.

Verification

  • New spec test/flash/unit/domain/consent-log.spec.ts (14 tests: happy path, minimal record, unknown-field discard, 10 rejection cases)
  • Full unit suite 224/224 green (two authentication specs' partial @config mocks gained the new getter)
  • tsc + eslint clean
  • No ingress change needed: api.flashapp.me routes the whole host to this service (verified live via /healthz)

🤖 Generated with Claude Code

https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV

The getflash.io/invite page has collected transactional/marketing consent
since launch and POSTed it to /consent/log — an endpoint that never
existed, so every record was silently dropped while the page displayed a
compliance promise. Decision on ENG-568: build the endpoint.

Public unauthenticated write, so deliberately narrow: 8kb body cap,
strict bounded-field validation (unknown fields discarded), per-IP rate
limit (20/hr, new consent_log_ip config), and the invite token persisted
only as a hash — the same rule the invites collection follows. Records
are append-only evidence: version, both consent legs, source URL, UA,
client timestamp (verbatim, untrusted), IP, receivedAt.

Mounted on the public server only (type === "main"), before the JWT
middleware. No ingress change needed — api.flashapp.me routes the whole
host to this service (verified via /healthz).
@linear

linear Bot commented Sep 1, 2026

Copy link
Copy Markdown

ENG-568

bobodread876 and others added 7 commits September 1, 2026 12:36
…post-log mount, route tests

Review fixes for the /consent/log endpoint (ENG-568):

- Add cors({ origin: "https://getflash.io" }) to the consent router so the
  cross-origin browser POST from the invite page passes preflight instead of
  being silently dropped by the fail-open page call.
- Resolve client IP from x-real-ip with x-forwarded-for fallback (matching
  graphql-main-server), and fail closed with 503 when no IP is resolvable
  instead of collapsing all traffic into one shared rate-limit bucket.
- Add a router-level JSON error handler so body-parser failures answer
  400/413 JSON instead of Express's default HTML page (stack traces outside
  production).
- Mount the consent router after PinoHttp (still before the /graphql JWT
  middleware) so the public write path appears in access logs.
- Add test/flash/unit/servers/consent-log.spec.ts covering 204/400/429/503/500
  branches, the hash-only token invariant, IP fallback, the error handler,
  and the CORS preflight.

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

Review round-2 fixes for the POST /consent/log endpoint (ENG-568):

- pino-http re-evaluates customProps at response finish, after the consent
  router's express.json() has populated req.body — so the access log's
  'request completed' line carried the raw invite token on every status
  path. The body field now goes through redactConsentBodyForLog(), which
  replaces /consent request bodies with a redaction marker, keeping the
  access-log line while honoring the raw-token-never-touches-disk rule.
- CORS now allows both https://getflash.io and https://www.getflash.io so
  www visitors' preflights don't silently drop consent records again.
- Tests: redaction helper (consent vs non-consent paths), www preflight,
  and a negative test that arbitrary origins get no allow-origin header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
…uter-mutated url

Express 4 strips the mount path off req.url inside a router mounted at
/consent (url becomes "/log") and never restores it — the handler ends
the response, so the restoring next() never runs. pino-http re-evaluates
customProps at response-finish against that mutated req, the one moment
req.body actually holds the token-bearing payload, so keying the
redaction on req.url passed the raw invite token into the access log on
every status path. Keyed on originalUrl (Express never mutates it);
tests now model the finish-time shape ({url: "/log", originalUrl:
"/consent/log"}) and pin the url-only failure mode.

Empirically verified against the repo's express + pino-http: real POST,
grep of the emitted line — new helper redacts, url-only helper leaks.
…t canonical clientTimestamp

- Move the consentLog rate-limit block below fygaroTopupAllowance so each
  comment sits directly above the config it describes.
- Accept the canonical clientTimestamp field alongside the page's timestamp,
  mirroring the sourceUrl/page convergence pattern, with a domain-spec case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
…ction path match

Two code-review fixes on PR #498:

- The per-IP rate limiter was only consumed inside the /log route handler,
  which runs after express.json() has already parsed the body. A body that
  fails to parse (oversized or malformed JSON) is routed straight to the
  router's error handler by Express, skipping the route handler and the
  limiter entirely — so an attacker could flood the endpoint at wire speed
  with exactly that traffic shape and never be rate limited. Moved IP
  resolution + limiter consumption into router-level middleware mounted
  before express.json(), so every request reaching this router is charged
  against the bucket regardless of whether its body goes on to parse. Added
  an end-to-end regression test that runs the actual (unmocked) body-parser
  through the real middleware chain with an oversized and a malformed body,
  asserting the limiter is still consumed.

- redactConsentBodyForLog matched with startsWith("/consent"), which would
  also swallow any future unrelated route mounted alongside this router
  (e.g. /consent-status). Tightened to startsWith("/consent/") so redaction
  stays scoped to this router's actual mount point, with a test pinning the
  non-match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
Express dispatches into the /consent mount case-insensitively (no
case-sensitive-routing setting on the app-level express()), so a request to
/CONSENT/log still reaches the /log route and persists a real submission,
but its originalUrl fails the case-sensitive startsWith("/consent/") check
in redactConsentBodyForLog — leaking the raw invite token into the access
log via pino-http's response-finish customProps re-evaluation.

Lowercase both sides of the comparison and add regression tests pinning a
mixed-case originalUrl (/CONSENT/log, /Consent/Log) to the redacted marker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
…ate limiter's own IP

Round-3 review finding on #498: the trailing-slash tightening added alongside
the round-2 case-insensitivity fix (matching "/consent/" instead of a bare
"/consent" prefix, to stop over-matching the sibling "/consent-status" route)
reintroduced the same token-in-logs bug through a third URL shape. A bare
"POST /consent" (no trailing slash, no "/log") still dispatches into this
router — Express matches the "/consent" mount before route matching happens,
so enforceConsentLogRateLimit and express.json() both run, populating
req.body with a real invite token — before falling through to a 404 because
nothing matches "/" against the registered "/log" route. pino-http
re-evaluates customProps at response finish regardless of the 404, and
"/consent".toLowerCase().startsWith("/consent/") is false, so the body went
through unredacted.

This is the second distinct URL-shape bypass in as many rounds (req.url vs
originalUrl, then the trailing-slash boundary), so stop matching on the URL
at all: redactConsentBodyForLog now gates on res.locals.consentLogIp, which
enforceConsentLogRateLimit sets unconditionally for every request Express
dispatches into this router, independent of path shape or whether any route
inside ultimately matches. Callers outside the router (e.g. /graphql) never
set it, so their bodies still pass through untouched.

Tests rewritten for the new signature, including a regression case pinning
the bare-mount-path bypass this fix closes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
@islandbitcoin
islandbitcoin merged commit d944ee9 into main Sep 3, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants