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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||
|
||||
$config = [
|
||||
'ai_api_url' => 'https://bnfexpress.test',
|
||||
'client_id' => 'ev_admin',
|
||||
'client_secret' => 'shared-secret',
|
||||
];
|
||||
|
||||
function assertSignedCorrectly($request, string $method, string $path, string $rawBody, string $secret): bool
|
||||
{
|
||||
$timestamp = $request->header('X-Timestamp')[0] ?? null;
|
||||
$expected = hash_hmac('sha256', strtoupper($method)."\n".$path."\n".$timestamp."\n".$rawBody, $secret);
|
||||
|
||||
return $request->hasHeader('X-Client-Id', 'ev_admin')
|
||||
&& $timestamp !== null
|
||||
&& abs(time() - (int) $timestamp) < 5
|
||||
&& $request->header('X-Signature')[0] === $expected;
|
||||
}
|
||||
|
||||
test('listFaqs signs a GET request and excludes the query string from the signed path', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['faqs' => []])]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->listFaqs(q: 'range', search: 'semantic', limit: 10, offset: 0);
|
||||
|
||||
Http::assertSent(function ($request) use ($config) {
|
||||
return $request->url() === 'https://bnfexpress.test/admin/faqs?agent=ev&q=range&search=semantic&limit=10&offset=0'
|
||||
&& assertSignedCorrectly($request, 'GET', '/admin/faqs', '', $config['client_secret']);
|
||||
});
|
||||
});
|
||||
|
||||
test('listFaqs omits search when q is empty', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['faqs' => []])]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->listFaqs();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/faqs?agent=ev');
|
||||
});
|
||||
|
||||
test('createFaq signs the exact raw JSON body being sent', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['id' => 1], 201)]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->createFaq('How do I charge?', ['source' => 'admin']);
|
||||
|
||||
$rawBody = json_encode([
|
||||
'content' => 'How do I charge?',
|
||||
'agent' => 'ev',
|
||||
'metadata' => ['source' => 'admin'],
|
||||
]);
|
||||
|
||||
Http::assertSent(function ($request) use ($config, $rawBody) {
|
||||
return $request->url() === 'https://bnfexpress.test/admin/faqs'
|
||||
&& $request->method() === 'POST'
|
||||
&& $request->body() === $rawBody
|
||||
&& assertSignedCorrectly($request, 'POST', '/admin/faqs', $rawBody, $config['client_secret']);
|
||||
});
|
||||
});
|
||||
|
||||
test('getActiveInstruction requests the active instruction for the ev agent', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['content' => 'You are the EV assistant.'])]);
|
||||
|
||||
$result = (new BnfexpressAdminClient($config))->getActiveInstruction();
|
||||
|
||||
expect($result)->toBe(['content' => 'You are the EV assistant.']);
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/agent-instructions/active?agent=ev');
|
||||
});
|
||||
|
||||
test('a non-2xx response surfaces the gateway detail message', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['detail' => 'FAQ not found.'], 404)]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->getFaq(999);
|
||||
})->throws(BnfexpressApiException::class, 'FAQ not found.');
|
||||
|
||||
test('deleteFaq signs a bodyless DELETE request', function () use ($config) {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response(['deleted' => true])]);
|
||||
|
||||
(new BnfexpressAdminClient($config))->deleteFaq(5);
|
||||
|
||||
Http::assertSent(function ($request) use ($config) {
|
||||
return $request->url() === 'https://bnfexpress.test/admin/faqs/5'
|
||||
&& $request->method() === 'DELETE'
|
||||
&& assertSignedCorrectly($request, 'DELETE', '/admin/faqs/5', '', $config['client_secret']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Modules\Shared\Bnfexpress\Support\BnfexpressSignature;
|
||||
|
||||
test('headers computes the hex HMAC-SHA256 over METHOD\PATH\TIMESTAMP\RAW_BODY joined by newlines', function () {
|
||||
$headers = BnfexpressSignature::headers(
|
||||
method: 'post',
|
||||
path: '/admin/faqs',
|
||||
rawBody: '{"content":"hi"}',
|
||||
clientId: 'ev_admin',
|
||||
secret: 'shared-secret',
|
||||
timestamp: 1_700_000_000,
|
||||
);
|
||||
|
||||
$expected = hash_hmac('sha256', "POST\n/admin/faqs\n1700000000\n{\"content\":\"hi\"}", 'shared-secret');
|
||||
|
||||
expect($headers)->toBe([
|
||||
'X-Client-Id' => 'ev_admin',
|
||||
'X-Timestamp' => '1700000000',
|
||||
'X-Signature' => $expected,
|
||||
]);
|
||||
});
|
||||
|
||||
test('headers signs an empty raw body for a bodyless request', function () {
|
||||
$headers = BnfexpressSignature::headers(
|
||||
method: 'GET',
|
||||
path: '/admin/faqs/1',
|
||||
rawBody: '',
|
||||
clientId: 'ev_admin',
|
||||
secret: 'shared-secret',
|
||||
timestamp: 1_700_000_000,
|
||||
);
|
||||
|
||||
$expected = hash_hmac('sha256', "GET\n/admin/faqs/1\n1700000000\n", 'shared-secret');
|
||||
|
||||
expect($headers['X-Signature'])->toBe($expected);
|
||||
});
|
||||
|
||||
test('headers generates a fresh timestamp per call when none is given', function () {
|
||||
$before = time();
|
||||
|
||||
$headers = BnfexpressSignature::headers('GET', '/admin/faqs', '', 'ev_admin', 'secret');
|
||||
|
||||
expect((int) $headers['X-Timestamp'])->toBeGreaterThanOrEqual($before)
|
||||
->and((int) $headers['X-Timestamp'])->toBeLessThanOrEqual(time());
|
||||
});
|
||||
Reference in New Issue
Block a user