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:
76
api/app/Http/Middleware/IdempotencyKey60sRedis.php
Normal file
76
api/app/Http/Middleware/IdempotencyKey60sRedis.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user