Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ One-click content repurposing to 8 formats with AI-powered tone preservation and
---
## [Unreleased]

### Added

- Webhooks admin UI — manage webhook endpoints, event subscriptions, delivery logs, and secret rotation directly from the admin panel (Settings → Webhooks)

## [0.8.0] — 2026-03-15

### Added
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ OpenAI, Together AI, fal.ai, Replicate — choose the best model for your brand.
### RBAC with AI Governance
Role-based access control (Admin, Editor, Author, Viewer) with space-scoped permissions, AI budget limits, and immutable audit logs.

### Webhooks Admin UI
Manage webhook endpoints and event subscriptions directly from the admin panel (Settings → Webhooks). Create, edit, delete endpoints; select event subscriptions; view delivery logs; rotate signing secrets; and manually redeliver webhooks (rate-limited to 10/minute per user).

### CLI for Automation
8 commands for content, briefs, and pipeline management — perfect for CI/CD hooks and server-side workflows.

Expand Down
244 changes: 244 additions & 0 deletions app/Http/Controllers/Admin/WebhookAdminController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\Webhook;
use App\Models\WebhookDelivery;
use App\Rules\ExternalUrl;
use App\Services\AuthorizationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;

class WebhookAdminController extends Controller
{
/**
* Reserved header names that must not be overridden by custom headers.
*/
private const RESERVED_HEADERS = ['x-numen-signature', 'content-type', 'user-agent'];

public function __construct(private readonly AuthorizationService $authz) {}

/**
* List all webhooks for the first space the user has access to.
*/
public function index(Request $request): Response
{
// @phpstan-ignore-next-line
$spaceId = $request->user()->roles()->first()?->pivot->space_id;

if (! $spaceId) {
abort(403, 'No accessible space found.');
}

$this->authz->authorize($request->user(), 'webhooks.manage', $spaceId);

$webhooks = Webhook::where('space_id', $spaceId)
->latest()
->get()
->map(fn (Webhook $w) => [
'id' => $w->id,
'url' => $w->url,
'events' => $w->events,
'is_active' => $w->is_active,
'created_at' => $w->created_at->toIso8601String(),
]);

return Inertia::render('Settings/Webhooks', [
'webhooks' => $webhooks,
'newSecret' => session('newSecret'),
]);
}

/**
* Create a new webhook.
*/
public function store(Request $request): RedirectResponse
{
// @phpstan-ignore-next-line
$spaceId = $request->user()->roles()->first()?->pivot->space_id;

if (! $spaceId) {
abort(403, 'No accessible space found.');
}

$this->authz->authorize($request->user(), 'webhooks.manage', $spaceId);

$validated = $request->validate([
'url' => ['required', 'url', 'max:2048', new ExternalUrl],
'events' => ['required', 'array', 'min:1'],
'events.*' => ['required', 'string', 'max:64'],
'is_active' => ['sometimes', 'boolean'],
'retry_policy' => ['sometimes', 'nullable', 'array'],
'headers' => ['sometimes', 'nullable', 'array'],
'headers.*' => ['string', 'regex:/^[^\r\n]+$/'],
'batch_mode' => ['sometimes', 'boolean'],
'batch_timeout' => ['sometimes', 'integer', 'min:100', 'max:300000'],
]);

if (isset($validated['headers'])) {
$validated['headers'] = $this->sanitizeHeaders($validated['headers']);
}

$validated['space_id'] = $spaceId;
$secret = Str::random(64);
$validated['secret'] = $secret;

Webhook::create($validated);

return redirect()->route('admin.webhooks')->with('success', 'Webhook created.')->with('newSecret', $secret);
}

/**
* Update a webhook.
*/
public function update(Request $request, string $id): RedirectResponse
{
$spaceId = $this->resolveSpaceId($request);
$webhook = Webhook::where('space_id', $spaceId)->findOrFail($id);
$this->authz->authorize($request->user(), 'webhooks.manage', $spaceId);

$validated = $request->validate([
'url' => ['sometimes', 'url', 'max:2048', new ExternalUrl, Rule::unique('webhooks')->where('space_id', $webhook->space_id)->ignore($webhook->id)],
'events' => ['sometimes', 'array', 'min:1'],
'events.*' => ['required_with:events', 'string', 'max:64'],
'is_active' => ['sometimes', 'boolean'],
'retry_policy' => ['sometimes', 'nullable', 'array'],
'headers' => ['sometimes', 'nullable', 'array'],
'headers.*' => ['string', 'regex:/^[^\r\n]+$/'],
'batch_mode' => ['sometimes', 'boolean'],
'batch_timeout' => ['sometimes', 'integer', 'min:100', 'max:300000'],
]);

if (isset($validated['headers'])) {
$validated['headers'] = $this->sanitizeHeaders($validated['headers']);
}

$webhook->update($validated);

return redirect()->route('admin.webhooks')->with('success', 'Webhook updated.');
}

/**
* Soft-delete a webhook.
*/
public function destroy(Request $request, string $id): RedirectResponse
{
$spaceId = $this->resolveSpaceId($request);
$webhook = Webhook::where('space_id', $spaceId)->findOrFail($id);
$this->authz->authorize($request->user(), 'webhooks.manage', $spaceId);

$webhook->delete();

return redirect()->route('admin.webhooks')->with('success', 'Webhook deleted.');
}

/**
* Rotate the signing secret.
*/
public function rotateSecret(Request $request, string $id): RedirectResponse
{
$spaceId = $this->resolveSpaceId($request);
$webhook = Webhook::where('space_id', $spaceId)->findOrFail($id);
$this->authz->authorize($request->user(), 'webhooks.manage', $spaceId);

$newSecret = Str::random(64);
$webhook->update(['secret' => $newSecret]);

return redirect()->route('admin.webhooks')->with('newSecret', $newSecret);
}

/**
* Return last 50 deliveries for a webhook as JSON.
*/
public function deliveries(Request $request, string $id): JsonResponse
{
$spaceId = $this->resolveSpaceId($request);
$webhook = Webhook::where('space_id', $spaceId)->findOrFail($id);
$this->authz->authorize($request->user(), 'webhooks.manage', $spaceId);

$deliveries = WebhookDelivery::where('webhook_id', $id)
->orderByDesc('created_at')
->limit(50)
->get()
->map(fn (WebhookDelivery $d) => [
'id' => $d->id,
'event_type' => $d->event_type,
'status' => $d->status,
'http_status' => $d->http_status,
'attempt_number' => $d->attempt_number,
'created_at' => $d->created_at->toIso8601String(),
]);

return response()->json(['data' => $deliveries]);
}

/**
* Re-queue a failed delivery.
*/
public function redeliver(Request $request, string $id, string $deliveryId): JsonResponse
{
$spaceId = $this->resolveSpaceId($request);
$webhook = Webhook::where('space_id', $spaceId)->findOrFail($id);
$this->authz->authorize($request->user(), 'webhooks.manage', $spaceId);

$delivery = WebhookDelivery::where('webhook_id', $id)
->where('id', $deliveryId)
->firstOrFail();

// Mark as pending to re-queue
$delivery->update([
'status' => WebhookDelivery::STATUS_PENDING,
'scheduled_at' => now(),
]);

return response()->json(['message' => 'Delivery re-queued for delivery.']);
}

/**
* Resolve the first space ID accessible by the authenticated user.
*/
private function resolveSpaceId(Request $request): string
{
// @phpstan-ignore-next-line
$spaceId = $request->user()->roles()->first()?->pivot->space_id;

if (! $spaceId) {
abort(403, 'No accessible space found.');
}

return $spaceId;
}

/**
* Remove reserved headers and validate header key format.
*
* @param array<string, string> $headers
* @return array<string, string>
*/
private function sanitizeHeaders(array $headers): array
{
$sanitized = [];

foreach ($headers as $key => $value) {
// Reject invalid key format
if (! preg_match('/^[a-zA-Z0-9_-]+$/', (string) $key)) {
continue;
}

// Reject reserved headers (case-insensitive)
if (in_array(strtolower((string) $key), self::RESERVED_HEADERS, true)) {
continue;
}

$sanitized[$key] = $value;
}

return $sanitized;
}
}
Loading
Loading