$data */ public static function validate(array $data): void { $required = ['key_id', 'key_hash', 'name', 'role']; foreach ($required as $field) { if (!isset($data[$field]) || !is_string($data[$field]) || $data[$field] === '') { throw new \InvalidArgumentException("Missing required field: {$field}"); } } $allowedRoles = ['superuser', 'admin', 'customer', 'subuser']; if (!in_array($data['role'], $allowedRoles, true)) { throw new \InvalidArgumentException("Invalid role: {$data['role']}"); } } /** * @param array $data * @return int inserted id */ public static function create(array $data): int { self::validate($data); $db = self::db(); $scopesJson = isset($data['scopes']) && $data['scopes'] !== null ? (is_string($data['scopes']) ? $data['scopes'] : json_encode($data['scopes'], JSON_UNESCAPED_SLASHES)) : null; $stmt = $db->conn()->prepare( 'INSERT INTO api_keys (key_id, key_hash, name, role, scopes, customer_id, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ); if ($stmt === false) { throw new Exception('Failed to prepare insert: ' . $db->conn()->error); } $customerId = isset($data['customer_id']) ? (int)$data['customer_id'] : null; $createdBy = isset($data['created_by']) ? (int)$data['created_by'] : null; $expiresAt = isset($data['expires_at']) && $data['expires_at'] !== null ? (string)$data['expires_at'] : null; $stmt->bind_param( 'sssssiss', $data['key_id'], $data['key_hash'], $data['name'], $data['role'], $scopesJson, $customerId, $createdBy, $expiresAt ); if (!$stmt->execute()) { $err = $stmt->error; $stmt->close(); throw new Exception('Failed to insert api_key: ' . $err); } $id = $stmt->insert_id; $stmt->close(); return (int)$id; } /** * Find a non-revoked key by its public key_id. * * @return array|null */ public static function findActiveByKeyId(string $keyId): ?array { if ($keyId === '') { return null; } $db = self::db(); $stmt = $db->conn()->prepare( 'SELECT * FROM api_keys WHERE key_id = ? AND revoked_at IS NULL LIMIT 1' ); if ($stmt === false) { throw new Exception('Failed to prepare select: ' . $db->conn()->error); } $stmt->bind_param('s', $keyId); if (!$stmt->execute()) { $err = $stmt->error; $stmt->close(); throw new Exception('Failed to execute select: ' . $err); } $result = $stmt->get_result(); $row = $result ? $result->fetch_assoc() : null; $stmt->close(); return $row ?: null; } /** * Find any key by id (including revoked). * * @return array|null */ public static function findById(int $id): ?array { $db = self::db(); $stmt = $db->conn()->prepare('SELECT * FROM api_keys WHERE id = ? LIMIT 1'); if ($stmt === false) { throw new Exception('Failed to prepare select: ' . $db->conn()->error); } $stmt->bind_param('i', $id); if (!$stmt->execute()) { $err = $stmt->error; $stmt->close(); throw new Exception('Failed to execute select: ' . $err); } $result = $stmt->get_result(); $row = $result ? $result->fetch_assoc() : null; $stmt->close(); return $row ?: null; } /** * Revoke a key (sets revoked_at = NOW()). Returns true on success. */ public static function revoke(int $id): bool { $db = self::db(); $stmt = $db->conn()->prepare( 'UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL' ); if ($stmt === false) { throw new Exception('Failed to prepare revoke: ' . $db->conn()->error); } $stmt->bind_param('i', $id); $ok = $stmt->execute(); $affected = $stmt->affected_rows; $stmt->close(); return $ok && $affected > 0; } /** * Bump last_used_at for a key. Best-effort: failures are swallowed * because this is a hot-path observability hook and must not * break the request. */ public static function touchLastUsed(int $id): void { try { $db = self::db(); $stmt = $db->conn()->prepare( 'UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?' ); if ($stmt === false) { return; } $stmt->bind_param('i', $id); $stmt->execute(); $stmt->close(); } catch (Throwable) { // intentionally ignored } } /** * List keys for a customer, newest first. * * @return array> */ public static function listForCustomer(int $customerId, bool $includeRevoked = false): array { $db = self::db(); $sql = 'SELECT * FROM api_keys WHERE customer_id = ?'; if (!$includeRevoked) { $sql .= ' AND revoked_at IS NULL'; } $sql .= ' ORDER BY id DESC'; $stmt = $db->conn()->prepare($sql); if ($stmt === false) { throw new Exception('Failed to prepare list: ' . $db->conn()->error); } $stmt->bind_param('i', $customerId); if (!$stmt->execute()) { $err = $stmt->error; $stmt->close(); throw new Exception('Failed to execute list: ' . $err); } $result = $stmt->get_result(); $rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; $stmt->close(); return is_array($rows) ? $rows : []; } /** * Delete a key by id. Returns true if a row was removed. * Generally prefer `revoke()` over `delete()` so audit trails * stay intact. */ public static function delete(int $id): bool { $db = self::db(); $stmt = $db->conn()->prepare('DELETE FROM api_keys WHERE id = ?'); if ($stmt === false) { throw new Exception('Failed to prepare delete: ' . $db->conn()->error); } $stmt->bind_param('i', $id); $ok = $stmt->execute(); $affected = $stmt->affected_rows; $stmt->close(); return $ok && $affected > 0; } }