Aller au contenu

Story CP-005: Link TTL (72 h) + attempt counter + rate limiting (prod hardening)

Module: customer-portal Slice: CP-005 (architecture §11) Side: [CUSTOMER] + [BACKEND] Version target: [V0.1.0] Priority: 7 Depends on: CP-002b merged (uses payment_attempts); AUTH 72 h enforcement available Can develop concurrently with: CP-003, CP-004 (all build on CP-002) Merge order: After CP-002b Estimated complexity: M PRD User Stories: CP-11 (TTL leg), CP-NFR (rate-limit hardening) Design screen: CP-S10 (Lien expiré — AUTH-owned copy, CP provides the broker skin only) — see docs/design/customer-portal-design.md §2 screen inventory Wireframe: docs/wireframes/customer-portal/CP-005/ (not yet generated — run /wireframe; ref AUTH "lien expiré" screen)

[LEGAL-REVIEW] BLOQUANT — Magic-link opposable consent = INCERTAIN - avocat requis. Blocks production ship. Renouvellement à l'identique only.


Objective

Production hardening for v0.1.0: enforce the 72 h signed-link TTL on the CP session (expires_at), render the attempt counter in audit, and add reverse-proxy rate limiting on the mutating endpoints. Until this slice, TTL was not enforced (resumability-first during early V0). AUTH owns the link expiry; CP cooperates by honouring expires_at and deferring the "lien expiré" screen to AUTH.


Backend Scope

Entities

Uses customer_sessions.expires_at (= signed_link.expires_at) + payment_attempts.attempt_index (both frozen in CP-001). No new tables.

Migrations

None (schema frozen).

Service Layer

  • Session expiry enforcement: every /api/portail/session/* request checks expires_at; an expired session short-circuits — CP defers to AUTH's verifier on the next /{token} hit which owns the "lien expiré" copy (R-CP-032: on 401/403 the page hard-reloads its parent route). The cp_session cookie Max-Age is bounded to 259200 s (72 h ceiling).
  • Attempt counter: attempt_index increments per payment attempt on a session (informational, no platform cap per CP-TV-03); rendered in the audit stream for operator observation.
  • Rate limiting: per-IP + per-link counters on POST /payments and POST /consent configured at the reverse proxy (Coolify nginx). Exceeding the limit returns HTTP 429 + an audit entry. (v0.0.x deferred to PSP rate limits; this slice adds the explicit gate.)

API Endpoints

No new endpoints. Behavior changes on existing ones: | Method | Path | Change | |--------|------|--------| | POST | /api/portail/session/payments | reverse-proxy rate limit → 429 on exceed; expiry check | | POST | /api/portail/session/consent | reverse-proxy rate limit → 429 on exceed; expiry check | | GET/PUT | /api/portail/session/* | expiry check (expired → defer to AUTH on next /{token}) |

Validation Rules

  • expires_at honoured server-side; expired session cannot mutate.
  • Rate-limit thresholds configured per IP + per link; 429 on exceed.

Multi-Tenant Considerations

expires_at denormalised on the session from the AUTH signed link. Rate-limit counters are per IP + per link (not tenant-global) so one tenant's traffic never throttles another. Tenant isolation unchanged.

Audit & Logging

  • Attempt counter surfaced via existing payment events (attempt_index already a field).
  • Rate-limit hit → an audit entry (e.g. customer_payment_initiated is NOT emitted; a rate-limit/abuse signal is recorded for operator observation).

Frontend Scope

Pages & Routes

  • No new CP screen. On 401/403/expiry, the page hard-reloads its parent route and AUTH renders "lien expiré" (CP does not own that copy).

Components

  • Handle 429 gracefully: a non-intrusive notice + retry-after affordance.

UI States (ALL REQUIRED)

State Behavior French Copy
Loading Existing loading states apply
Empty N/A
Error 429 rate-limited → notice + retry later « Trop de tentatives. Veuillez réessayer dans quelques instants. »
Offline / low network Existing offline banner « Connexion instable. Vos choix sont enregistrés. »
Success Within TTL + under limit → flow proceeds normally

Expired link (AUTH-owned copy, shown after hard reload): « Ce lien a expiré. » (rendered by AUTH, not CP).

Responsive Behavior

Unchanged (mobile-first 360 px). 429 notice fits 360 width.

Connectivity Behavior (CUSTOMER stories only)

  • TTL is 72 h; a reload within the window still resumes; past the window AUTH owns the "lien expiré" screen.
  • Rate limiting must not punish legitimate flaky-network retries disproportionately — thresholds tuned for low-bandwidth conditions.

Interactions

  • On 429, show the retry notice; do not silently drop the action.

Acceptance Criteria

AC-005.1: Expired link routes to the AUTH expired screen
Given a signed link past its 72 h TTL
When I click it or make any session XHR
Then the CP session is treated as expired, the page hard-reloads its parent route, and AUTH renders the "lien expiré" screen (CP does not override it)
AC-005.2: Attempt counter increments and is audited
Given I make multiple payment attempts on the same session
When each attempt is created
Then attempt_index increments (informational, no platform cap) and is rendered in the audit stream for operator observation
AC-005.3: Rate limiting returns 429 with an audit signal
Given excessive requests to POST /payments or POST /consent from one IP or link
When the per-IP/per-link threshold is exceeded
Then the reverse proxy returns HTTP 429, the UI shows « Trop de tentatives. Veuillez réessayer dans quelques instants. », and an audit/abuse signal is recorded

Compliance Rules

  • Magic-link opposable consent = INCERTAIN - avocat requis ([LEGAL-REVIEW], BLOQUANT).
  • Anti-replay (R-CP-016 / AUTH-owned attempt counter + lockout): CP cooperates; full anti-replay is AUTH's domain.
  • 72 h TTL (VÉRIFIÉ, AUTH ADR-02): link lifetime bound; CP honours expires_at.

Standards & Conventions

  • docs/standards/critical-rules.md — tenant isolation, audit, no silent failure.
  • docs/standards/connectivity-low-bandwidth.md — rate-limit tuning for low bandwidth, resumability within TTL.
  • docs/standards/api-guidelines.md — 429 semantics, RFC 7807.
  • docs/standards/react-typescript-guidelines.md — 401/403 hard-reload (R-CP-032).
  • docs/design/customer-portal-design.md — §2 (CP-S10 AUTH-owned), §3 NetworkBanner (reused for the 429 notice context).
  • docs/standards/compliance-discipline.md[LEGAL-REVIEW] markers.

Testing Requirements

Unit Tests

  • Expiry check rejects mutations past expires_at.
  • attempt_index increments per attempt.

Integration Tests

  • Expired session XHR → 401/403 → hard reload defers to AUTH.
  • Rate-limit exceed on POST /payments / POST /consent → 429 + audit signal.
  • Tenant isolation: rate-limit counters per IP+link, not tenant-global.

What QA Will Validate

  • Renders at 360 / 768 / 1024; 429 notice + offline banner; French copy exact; Slow-3G smoke; expired link shows AUTH copy; legitimate retries under flaky network not over-throttled.

Out of Scope

  • The AUTH "lien expiré" / lockout copy itself (AUTH-owned).
  • Per-link/per-customer payment retry cap (CP-TV-03: uncapped in V1).
  • "Lien remplacé", "déjà réglé", mentions-légales page (CP-006 / V1).
  • Full anti-replay attempt-counter + lockout cycles (AUTH).

Definition of Done

  • [ ] 72 h expires_at enforced on every session XHR; expired defers to AUTH
  • [ ] attempt_index increments + rendered in audit
  • [ ] Reverse-proxy rate limits on POST /payments + POST /consent → 429 + audit signal
  • [ ] 429 + expiry UI handled at 360 / 768 / 1024 px
  • [ ] All 5 UI states present (loading, empty, error/429, offline, success)
  • [ ] French micro-copy matches spec exactly
  • [ ] AC-005.1 … AC-005.3 pass manually
  • [ ] Tenant isolation verified (per IP+link counters; both SHARED + BYO profiles)
  • [ ] Flow tested under throttled "Slow 3G"
  • [ ] No silent failures on network errors (429 surfaced, never dropped)