From 546f121ee865ad0f2c70b720a222b014b0e89136 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 20:54:20 +0200 Subject: [PATCH] feat(timetable): 60s Redis idempotency-key middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC v0.2 R1 — Idempotency-Key replay window for POST /api/v1/events/{event}/timetable/move. Narrow scope by design: the 12-hour ARCH §10 default would let a cached cascade-bump response overwrite a fresh edit; 60 seconds covers honest network retry but expires before a meaningful conflict can emerge. Backed by the Laravel Cache facade (Redis in non-test env). Cache key namespace `idempotency:60s:*` distinct from FormSubmission's DB-column idempotency. Replays carry an `Idempotency-Replayed: true` header so observability can distinguish them. Registered as the route-middleware alias `idempotency.60s` in bootstrap/app.php; will be applied on the move route in Step 8. Missing or empty Idempotency-Key returns 400 with `{"error":"idempotency_key_required"}`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Middleware/IdempotencyKey60sRedis.php | 76 +++++++++++++++++++ api/bootstrap/app.php | 3 + 2 files changed, 79 insertions(+) create mode 100644 api/app/Http/Middleware/IdempotencyKey60sRedis.php diff --git a/api/app/Http/Middleware/IdempotencyKey60sRedis.php b/api/app/Http/Middleware/IdempotencyKey60sRedis.php new file mode 100644 index 00000000..91964fa9 --- /dev/null +++ b/api/app/Http/Middleware/IdempotencyKey60sRedis.php @@ -0,0 +1,76 @@ +header('Idempotency-Key'); + + if (! is_string($key) || trim($key) === '') { + return response()->json( + ['error' => 'idempotency_key_required'], + 400, + ); + } + + $cacheKey = 'idempotency:60s:'.$key; + $cached = Cache::get($cacheKey); + + if (is_array($cached)) { + $response = response($cached['body'], $cached['status']); + foreach ($cached['headers'] ?? [] as $name => $value) { + $response->headers->set($name, $value); + } + $response->headers->set('Idempotency-Replayed', 'true'); + + return $response; + } + + /** @var Response $response */ + $response = $next($request); + + if ($response->isSuccessful()) { + Cache::put($cacheKey, [ + 'status' => $response->getStatusCode(), + 'body' => $response->getContent(), + 'headers' => [ + 'Content-Type' => $response->headers->get('Content-Type'), + ], + ], 60); + } + + return $response; + } +} diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index d8978393..8e5268f8 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -59,6 +59,9 @@ return Application::configure(basePath: dirname(__DIR__)) 'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'impersonation' => \App\Http\Middleware\HandleImpersonation::class, + // RFC-TIMETABLE v0.2 R1 — 60s Redis idempotency window for + // POST /timetable/move. Narrow scope by design. + 'idempotency.60s' => \App\Http\Middleware\IdempotencyKey60sRedis::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void {