Add bnfexpress signed admin client and AI Agent Filament UI

- Modules\Shared\Bnfexpress\BnfexpressAdminClient: HMAC-signed HTTP client
  for bnfexpress's admin API (EV FAQs, agent instructions, chat history),
  with a bnfexpress:smoke-test command and full unit coverage.
- New ai-agent module: Filament pages to manage EV FAQs, publish/roll back
  agent instruction versions, and browse EV chat history + transcripts.
- New manage_ai_agent permission (super_admin/admin).
- Recorded .ai/rules for the client's auth scheme and non-Resource
  Filament page/table testing gotchas.
This commit is contained in:
Nyan Lin Paing
2026-08-30 23:52:21 +07:00
parent 95b369174d
commit b8d31e3dc4
30 changed files with 1362 additions and 1 deletions
@@ -0,0 +1,204 @@
<?php
namespace Modules\Shared\Bnfexpress;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
use Modules\Shared\Bnfexpress\Support\BnfexpressSignature;
/**
* Signed HTTP client for bnfexpress's admin APIs EV FAQs, agent
* instruction versions, and read-only EV chat history. Backend-to-backend
* auth only (no user session/JWT): every request is signed per
* BnfexpressSignature (config('services.bnfexpress')).
*/
class BnfexpressAdminClient
{
private const AGENT = 'ev';
private readonly string $baseUrl;
private readonly string $clientId;
private readonly string $secret;
/**
* @param array<string, mixed>|null $config
*/
public function __construct(?array $config = null)
{
$config ??= (array) config('services.bnfexpress');
$this->baseUrl = rtrim((string) ($config['ai_api_url'] ?? ''), '/');
$this->clientId = (string) ($config['client_id'] ?? '');
$this->secret = (string) ($config['client_secret'] ?? '');
}
// --- FAQs ---------------------------------------------------------
/**
* @return array<string, mixed>
*/
public function listFaqs(?string $q = null, string $search = 'normal', ?int $limit = null, ?int $offset = null): array
{
return $this->request('GET', '/admin/faqs', query: array_filter([
'agent' => self::AGENT,
'q' => $q,
// Ignored by bnfexpress when q is empty, but only sent when q is set.
'search' => ($q !== null && $q !== '') ? $search : null,
'limit' => $limit,
'offset' => $offset,
], fn (mixed $value): bool => $value !== null));
}
/**
* @return array<string, mixed>
*/
public function getFaq(int|string $id): array
{
return $this->request('GET', "/admin/faqs/{$id}");
}
/**
* @param array<string, mixed> $metadata
* @return array<string, mixed>
*/
public function createFaq(string $content, array $metadata = []): array
{
return $this->request('POST', '/admin/faqs', body: [
'content' => $content,
'agent' => self::AGENT,
'metadata' => $metadata,
]);
}
/**
* @param array<string, mixed>|null $metadata
* @return array<string, mixed>
*/
public function updateFaq(int|string $id, ?string $content = null, ?array $metadata = null): array
{
return $this->request('PATCH', "/admin/faqs/{$id}", body: array_filter([
'content' => $content,
'metadata' => $metadata,
], fn (mixed $value): bool => $value !== null));
}
/**
* @return array<string, mixed>
*/
public function deleteFaq(int|string $id): array
{
return $this->request('DELETE', "/admin/faqs/{$id}");
}
// --- Agent instructions --------------------------------------------
/**
* @return array<string, mixed>
*/
public function listInstructions(?int $limit = null, ?int $offset = null): array
{
return $this->request('GET', '/admin/agent-instructions', query: array_filter([
'agent' => self::AGENT,
'limit' => $limit,
'offset' => $offset,
], fn (mixed $value): bool => $value !== null));
}
/**
* @return array<string, mixed>
*/
public function getActiveInstruction(): array
{
return $this->request('GET', '/admin/agent-instructions/active', query: [
'agent' => self::AGENT,
]);
}
/**
* Publishes a new instruction version. Setting $activate (default true)
* deactivates the previously active version automatically, server-side.
*
* @return array<string, mixed>
*/
public function publishInstruction(string $content, bool $activate = true): array
{
return $this->request('POST', '/admin/agent-instructions', body: [
'agent' => self::AGENT,
'content' => $content,
'activate' => $activate,
]);
}
/**
* Rolls back to an older instruction version.
*
* @return array<string, mixed>
*/
public function activateInstruction(int|string $id): array
{
return $this->request('POST', "/admin/agent-instructions/{$id}/activate");
}
// --- EV chat history (read-only) ------------------------------------
/**
* @return array{total: int, limit: int, offset: int, sessions: list<array<string, mixed>>}
*/
public function listSessions(?int $limit = null, ?int $offset = null): array
{
return $this->request('GET', '/admin/ev/history', query: array_filter([
'limit' => $limit,
'offset' => $offset,
], fn (mixed $value): bool => $value !== null));
}
/**
* @return array<string, mixed>
*/
public function getSessionTranscript(string $userId, string $sessionId): array
{
return $this->request('GET', "/admin/ev/history/{$userId}/{$sessionId}");
}
// --- Request plumbing ------------------------------------------------
/**
* @param array<string, mixed> $query
* @param array<string, mixed>|null $body
* @return array<string, mixed>
*/
private function request(string $method, string $path, array $query = [], ?array $body = null): array
{
// Signed over exactly these bytes — must match what's actually sent,
// so it's built once and reused for both the signature and the body.
$rawBody = $body !== null ? json_encode($body, JSON_THROW_ON_ERROR) : '';
$headers = BnfexpressSignature::headers($method, $path, $rawBody, $this->clientId, $this->secret);
$pending = Http::baseUrl($this->baseUrl)->withHeaders($headers);
try {
$response = match ($method) {
'GET' => $pending->get($path, $query),
'DELETE' => $pending->delete($path, $query),
'POST' => $pending->withBody($rawBody, 'application/json')->post($path),
'PATCH' => $pending->withBody($rawBody, 'application/json')->patch($path),
default => throw new \InvalidArgumentException("Unsupported HTTP method [{$method}]."),
};
} catch (ConnectionException $exception) {
throw new BnfexpressApiException($exception->getMessage());
}
if (! $response->successful()) {
throw new BnfexpressApiException(
(string) ($response->json('detail') ?? "bnfexpress request failed with status {$response->status()}."),
$response->status(),
);
}
return (array) $response->json();
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Shared\Bnfexpress\Exceptions;
use RuntimeException;
/**
* Thrown when bnfexpress's admin API returns a non-2xx response or the
* request fails to connect. Carries the gateway's own {"detail": "..."}
* message (falling back to a generic one) rather than a bare status code.
*/
class BnfexpressApiException extends RuntimeException
{
public function __construct(string $message, public readonly int $status = 0)
{
parent::__construct($message);
}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Shared\Bnfexpress\Support;
/**
* bnfexpress's backend-to-backend admin auth scheme: every request carries
* X-Client-Id/X-Timestamp/X-Signature, where the signature is a hex HMAC-SHA256
* over "{METHOD}\n{PATH}\n{TIMESTAMP}\n{RAW_BODY}" (uppercase verb, path only
* no scheme/host/query and the exact raw JSON bytes being sent, or "" for a
* bodyless request). Timestamps are generated fresh per call bnfexpress
* rejects anything more than 300s from server time so headers() must never
* be memoized/reused across requests.
*/
class BnfexpressSignature
{
/**
* @param int|null $timestamp Overrides the current time; only ever passed in tests.
* @return array{'X-Client-Id': string, 'X-Timestamp': string, 'X-Signature': string}
*/
public static function headers(string $method, string $path, string $rawBody, string $clientId, string $secret, ?int $timestamp = null): array
{
$timestamp = (string) ($timestamp ?? time());
$payload = strtoupper($method)."\n".$path."\n".$timestamp."\n".$rawBody;
return [
'X-Client-Id' => $clientId,
'X-Timestamp' => $timestamp,
'X-Signature' => hash_hmac('sha256', $payload, $secret),
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace Modules\Shared\Console\Commands;
use Illuminate\Console\Command;
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
/**
* Exercises the signed bnfexpress admin client end to end (list FAQs, get
* the active instruction) against the real BNFEXPRESS_AI_API_URL, to confirm
* request signing checks out before any UI is wired up to it.
*/
class BnfexpressSmokeTestCommand extends Command
{
protected $signature = 'bnfexpress:smoke-test';
protected $description = 'Call bnfexpress\'s admin API (list EV FAQs, get the active EV instruction) to verify request signing';
public function handle(BnfexpressAdminClient $client): int
{
try {
$this->components->task('GET /admin/faqs?agent=ev', function () use ($client) {
$faqs = $client->listFaqs();
$this->line(' '.json_encode($faqs));
});
$this->components->task('GET /admin/agent-instructions/active?agent=ev', function () use ($client) {
$active = $client->getActiveInstruction();
$this->line(' '.json_encode($active));
});
} catch (BnfexpressApiException $exception) {
$this->components->error('bnfexpress request failed: '.$exception->getMessage());
return self::FAILURE;
}
$this->components->info('bnfexpress signing verified.');
return self::SUCCESS;
}
}