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 checksexpires_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). Thecp_sessioncookieMax-Ageis bounded to 259200 s (72 h ceiling). - Attempt counter:
attempt_indexincrements 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 /paymentsandPOST /consentconfigured 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_athonoured 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_indexalready a field). - Rate-limit hit → an audit entry (e.g.
customer_payment_initiatedis 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 honoursexpires_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_atenforced on every session XHR; expired defers to AUTH - [ ]
attempt_indexincrements + 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)