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>
This commit is contained in:
2026-05-08 20:54:20 +02:00
parent 9e94ab78d8
commit 546f121ee8
2 changed files with 79 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
<?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;
}
}

View File

@@ -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 {