Files
crewli/api/app/Http/Middleware/IdempotencyKey60sRedis.php
bert.hausmans 546f121ee8 feat(timetable): 60s Redis idempotency-key middleware
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) <noreply@anthropic.com>
2026-05-08 20:54:20 +02:00

77 lines
2.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
/**
* RFC v0.2 R1 — 60-second Idempotency-Key replay window backed by
* Redis cache.
*
* Why 60s and not the 12-hour MySQL window from ARCH §10:
* a stale 12-hour replay of the cascade-bump endpoint can corrupt
* timetable state in hard-to-detect ways (the persisted lanes have
* since been edited; replaying a cached response over a fresh edit
* would silently undo it). 60 seconds covers honest network retry
* without giving stale requests a window in which to resurrect.
*
* Storage: Laravel Cache facade with the default store (Redis in
* non-test environments). The key namespace `idempotency:60s:` is
* deliberately distinct from any other idempotency surface in the
* codebase — keys never collide with the FormSubmission DB-column
* idempotency.
*
* Applied today only on `POST /api/v1/events/{event}/timetable/move`.
* Other R-numbered idempotent endpoints (RFC §6 lists POST
* /performances and POST /engagements as candidates) get the regular
* 12-hour pattern when ARCH §10 lands; this middleware is purposely
* narrow.
*/
final class IdempotencyKey60sRedis
{
public function handle(Request $request, Closure $next): Response
{
$key = $request->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;
}
}