findInvocationIndex($command, $identifier) !== null) { return false; } $item = [ 'command' => $command, 'identifier' => $identifier, ]; if ($fields !== null) { $processed = $this->processFields($fields); $item['names'] = $processed['names']; $item['hmac'] = $processed['hmac']; } $this->allowedInvocations[] = $item; return true; } /** * Returns true if a matching invocation has been granted and not yet consumed. * The provided fields must produce the same sorted key list and HMAC as * the fields that were registered via allowInvocation(). */ public function isInvocationAllowed( FormDefinitionPersistenceCommand $command, string|int $identifier, ?array $fields = null, ): bool { $index = $this->findInvocationIndex($command, $identifier); if ($index === null) { return false; } if ($fields === null) { return true; } $item = $this->allowedInvocations[$index]; $processed = $this->processFields($fields); return $item['names'] === $processed['names'] && $item['hmac'] === $processed['hmac']; } /** * Consumes a matching invocation (removes it from the pending list). * Called both by the hook after successful verification (single-use * enforcement) and by the repository's finally block (cleanup). */ public function consumeInvocation( FormDefinitionPersistenceCommand $command, string|int $identifier, ?array $fields = null, ): void { $index = $this->findInvocationIndex($command, $identifier); if ($index === null) { return; } if ($fields === null) { unset($this->allowedInvocations[$index]); return; } $item = $this->allowedInvocations[$index]; $processed = $this->processFields($fields); if ($item['names'] === $processed['names'] && $item['hmac'] === $processed['hmac']) { unset($this->allowedInvocations[$index]); } } private function findInvocationIndex(FormDefinitionPersistenceCommand $command, string|int $identifier): ?int { foreach ($this->allowedInvocations as $index => $invocation) { if ($invocation['command'] === $command && $invocation['identifier'] === $identifier) { return $index; } } return null; } /** * Sorts fields alphabetically and returns an array with keys 'names' and 'hmac'. * * @return array{names: list, hmac: string} */ private function processFields(array $fields): array { ksort($fields); return [ 'names' => array_keys($fields), 'hmac' => $this->hashService->hmac( json_encode($fields, JSON_THROW_ON_ERROR), FormDefinitionPersistenceGuard::class, HashAlgo::SHA3_384 ), ]; } }