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 {