[feature] Normalize live PRIM departures #4
- @github-anthodev opened this issue · Aug 29, 2026edited
🔢 Impact Version (SemVer)
MINOR
🌿 Suggested Branch
feature/normalize-live-departures/backend/4
📝 Description
Turn PRIM SIRI Lite responses into the shared result/error contracts while transiently relaying each user's personal key only on a 60-second cache miss. Validate/reduce upstream data, preserve time/status semantics, share non-secret fresh results, coalesce safely, expose quota failures, and instrument request/latency/byte counts without logging credentials or user configuration.
✅ Expected Behavior
A valid-key miss—or a fresh public-data cache hit carrying any non-empty key—returns normalized all-mode data. The bearer is upstream credential handoff, not app identity: a hit does not validate it; 401/403 exists only on a PRIM miss. Source, malformed, quota, and failure cases map stably; concurrent misses single-flight without one caller's credential failure poisoning another event.
📋 Acceptance Criteria
- Resolve only service IDs produced by the catalog boundary into PRIM requests; unknown IDs return
INVALID_SERVICE. - Require a non-empty
Authorization: Bearer <personal PRIM key>before cache lookup; missing returnsAPI_KEY_REQUIRED. Treat it as transient upstream credential, not user authentication/identity, and forward only on a cache miss as PRIMapikeyto the exact HTTPS host with gzip accepted. - Hold the raw key only in request/upstream-call memory; redact Authorization and upstream
apikeyheaders before framework/proxy/APM/error logging. Never persist, echo, meter, hash into a cache key, or include the key in response bodies. - Map an upstream PRIM 401/403 to
API_KEY_INVALID—never claim validation on a cache hit—and 429 toRATE_LIMITED; capture actual quota/retry header semantics live, exposing only validated boundedretryAfterSeconds. - Preserve aimed and expected timestamps and calculate
minutesagainst a clearly defined backend clock/time-zone rule. - Define and freeze source-normalization rules for
REALTIME,SCHEDULED, andMIXED, including exactly when a multi-departure result isMIXED; preserveSTALEin the shared enum but leave useful-stale age and failure-state selection to[feature] Harden failures and compatibility(https://github.com/Anthodev/lapin-fute/issues/8). - Normalize source status into
ON_TIME,DELAYED,CANCELLED, orUNKNOWNwithout inventing a delay/cancellation absent from the source. - Define “comparable departures” and calculate
nextIntervalMinutesonly for consecutive departures that meet that rule. - Bound request/header size, upstream connection/read time, decompressed SIRI bytes, and concurrent upstream calls; reject overflow without parsing/forwarding and never send raw SIRI to client/watch.
- Cache one binding-free public snapshot per resolved service/query for exactly 60 seconds—never request/favorite/key/user data—and preserve source time; wrap success per caller. On leader 401/403/429, fan a separately bound error to the leader and every queued event presenting that same in-flight key, clear those entries, then promote exactly one different-key event if present; otherwise terminate the flight. Keep remaining different-key waiters coalesced and never persist/hash a key for matching.
- An expired entry makes one attempt per promoted event. A shared source failure is wrapped into a separate correctly bound
ErrorResultper waiter, never cached or labeledSTALE; make zero immediate retries for the same event/credential. - Emit credential-free counters/histograms for cache hit/miss, upstream request count/status/latency, normalized response bytes, and coalescing; cache-hit p95 backend latency must remain ≤200 ms under the recorded representative load.
- Live queries return normalized results for one bus, one metro, and one RER or Transilien service with current coverage, preserving the source Gate.
- Deterministic response fixtures cover bus, metro, tram, RER, and Transilien; current live smoke covers one example of each mode available in PRIM's published real-time perimeter, and records any source-declared temporary coverage absence without narrowing the implemented mode contract.
- Recorded delayed, cancelled, scheduled-only, mixed, and malformed responses produce the specified result or error.
- Valid concurrent callers trigger one PRIM call and receive their own bindings. Same-key-only 401/403/429 queues all settle from one failure; mixed-key queues drain failed-key waiters then promote one valid different-key leader without retrying/bursting.
- Cache age uses original source timestamps; repeated app-open/favorite-change/Select events inside 60 seconds add zero PRIM calls and never reuse another caller's binding.
- Record live/quota output, all-waiter settlement and mixed-key promotion traces, cache bindings/age, PRIM counts, latency/bytes, and redacted secret scans in a resolution comment.
🔗 Affected Endpoints/Routes
- New read-only departure route accepting one opaque
serviceIdand the personal key only throughAuthorization: Bearer. - Freeze exact path, 60-second cache semantics, HTTP/error mapping (
401 API_KEY_REQUIRED|API_KEY_INVALID,429 RATE_LIMITEDwith optional sanitizedretryAfterSeconds), and JSON contracts in tests. - The route never accepts key in URL/query/body, returns it, forwards outside fixed PRIM HTTPS, or exposes raw SIRI. Credential length, timeout, decompressed-byte, and global upstream-concurrency bounds are repository-owned constants tested at boundaries.
🧪 Tests to Add
Type Coverage Unit tests Timestamp/minute calculation, source freshness/status, mixed/comparable interval rules, stable key/quota/source error mapping Schema/fixture tests Valid, delayed, cancelled, scheduled-only, mixed, partial, malformed, 401/403/429, and source-failure SIRI responses HTTP/security tests Handoff vs identity, hit non-validation, miss-only 401/403, header/request/timeout/decompressed-byte/concurrency bounds, exact-host forwarding/redaction, no key leakage Cache/concurrency tests Binding-free 60-second cache; per-caller success/error; same-key failed queue settlement; mixed-key one-at-a-time promotion; no retry/burst Performance/mode smoke Cache-hit p95 ≤200 ms; request/status/latency/byte metrics; deterministic bus, metro, tram, RER, Transilien fixtures and current live examples
⚙️ Constraints
- Reuse the exact contracts frozen by
[feature] Establish the application foundation and contracts(https://github.com/Anthodev/lapin-fute/issues/2). - PRIM is the only live source; every production upstream miss uses the requesting user's personal key.
- Expected/aimed times remain distinct; omit interval without two comparable departures.
- Fresh cache TTL is exactly 60 seconds, a product policy aligned to PRIM's documented minute-grained next-passage guidance. Useful-stale watch age belongs exclusively to
[feature] Harden failures and compatibility(https://github.com/Anthodev/lapin-fute/issues/8). - Shared cache contains only binding-free public transport snapshots;
requestId/favoriteIdare applied per response. Final attribution/license verification belongs to[feature] Ship Lapin Futé version 1(https://github.com/Anthodev/lapin-fute/issues/9). - The backend has no server-owned PRIM key, credential database, user account, or key-derived persistent identifier.
🔀 Dependencies
- Blocked by
[feature] Establish the application foundation and contracts(https://github.com/Anthodev/lapin-fute/issues/2).
⚠️ Impact on Existing Behavior
Introduces the production departure boundary consumed by PebbleKit JS. SIRI parsing stays backend-side; the personal key stays phone-local at rest and exists on the backend only during the authenticated relay call.
🔍 Technical Context
DepartureResultcarries current request/favorite binding plus original source/cache timestamps. Shared cache stores only the binding-free normalized snapshot and wraps it per caller.ErrorResulthas stable code and optional sanitizedretryAfterSeconds, never raw quota/secret detail. Live fixtures use an operator personal test key supplied only at runtime.[feature] Resolve valid IDFM services(https://github.com/Anthodev/lapin-fute/issues/3) owns the production opaque-service catalog but is not a blocker: preserve the parallel branch by using recorded backend-only service-resolution fixtures here.[feature] Deliver Pebble watch consultation(https://github.com/Anthodev/lapin-fute/issues/7) validates the composed catalog→departure path after both siblings close.Required references
- Resolve only service IDs produced by the catalog boundary into PRIM requests; unknown IDs return
Sign in to comment.