Skip to content

Commit abe120d

Browse files
SniderVirgil
andcommitted
feat(api/php): webhooks, API-key hardening, MCP API + OpenAPI docs
core-api PHP package feature work: - webhook endpoints: signing, delivery, templates, secret management - API keys: IP whitelisting + rotation - MCP API controller + resource/server access - OpenAPI documentation builder + examples + SEO report service plus their feature tests. Co-Authored-By: Virgil <virgil@lethean.io>
1 parent c1d0be3 commit abe120d

36 files changed

Lines changed: 528 additions & 407 deletions

php/src/Api/Boot.php

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@
4343
*/
4444
class Boot extends ServiceProvider
4545
{
46+
private const ROUTES_API_PATH = '/Routes/api.php';
47+
private const OAUTH_AUTHORIZE_PATH = '/authorize';
48+
4649
/**
4750
* The module name.
4851
*/
@@ -203,8 +206,8 @@ public function onApiRoutes(ApiRoutesRegistering $event): void
203206
$this->registerMiddlewareAliases();
204207

205208
// Core API routes (SEO, Pixel, Entitlements, MCP)
206-
if (file_exists(__DIR__.'/Routes/api.php') && ! $this->hasCoreApiRoutesRegistered()) {
207-
$event->routes(fn () => Route::middleware('api')->group(__DIR__.'/Routes/api.php'));
209+
if (file_exists(__DIR__.self::ROUTES_API_PATH) && ! $this->hasCoreApiRoutesRegistered()) {
210+
$event->routes(fn () => Route::middleware('api')->group(__DIR__.self::ROUTES_API_PATH));
208211
}
209212

210213
if (class_exists(Passport::class)) {
@@ -248,13 +251,13 @@ protected function registerFallbackApiRoutes(): void
248251
{
249252
$this->registerMiddlewareAliases();
250253

251-
if (! file_exists(__DIR__.'/Routes/api.php') || $this->hasCoreApiRoutesRegistered()) {
254+
if (! file_exists(__DIR__.self::ROUTES_API_PATH) || $this->hasCoreApiRoutesRegistered()) {
252255
return;
253256
}
254257

255258
Route::prefix('api')
256259
->middleware('api')
257-
->group(__DIR__.'/Routes/api.php');
260+
->group(__DIR__.self::ROUTES_API_PATH);
258261

259262
if (class_exists(Passport::class) && ! Route::has('passport.token')) {
260263
$this->registerOAuthRoutes();
@@ -280,11 +283,11 @@ protected function registerOAuthRoutes(): void
280283
->name('passport.token');
281284

282285
Route::middleware(['web', 'auth'])->group(function () {
283-
Route::get('/authorize', [AuthorizationController::class, 'authorize'])
286+
Route::get(self::OAUTH_AUTHORIZE_PATH, [AuthorizationController::class, 'authorize'])
284287
->name('passport.authorizations.authorize');
285-
Route::post('/authorize', [ApproveAuthorizationController::class, 'approve'])
288+
Route::post(self::OAUTH_AUTHORIZE_PATH, [ApproveAuthorizationController::class, 'approve'])
286289
->name('passport.authorizations.approve');
287-
Route::delete('/authorize', [DenyAuthorizationController::class, 'deny'])
290+
Route::delete(self::OAUTH_AUTHORIZE_PATH, [DenyAuthorizationController::class, 'deny'])
288291
->name('passport.authorizations.deny');
289292
});
290293
});

php/src/Api/Controllers/Api/WebhookSecretController.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class WebhookSecretController extends Controller
1919
{
2020
use HasApiResponses;
2121

22+
private const RESOURCE_NAME = 'Webhook endpoint';
23+
2224
public function __construct(
2325
protected WebhookSecretRotationService $rotationService
2426
) {}
@@ -82,7 +84,7 @@ public function rotateContentSecret(Request $request, string $uuid): JsonRespons
8284
->first();
8385

8486
if (! $endpoint) {
85-
return $this->notFoundResponse('Webhook endpoint');
87+
return $this->notFoundResponse(self::RESOURCE_NAME);
8688
}
8789

8890
$validated = $request->validate([
@@ -149,7 +151,7 @@ public function contentSecretStatus(Request $request, string $uuid): JsonRespons
149151
->first();
150152

151153
if (! $endpoint) {
152-
return $this->notFoundResponse('Webhook endpoint');
154+
return $this->notFoundResponse(self::RESOURCE_NAME);
153155
}
154156

155157
return response()->json([
@@ -200,7 +202,7 @@ public function invalidateContentPreviousSecret(Request $request, string $uuid):
200202
->first();
201203

202204
if (! $endpoint) {
203-
return $this->notFoundResponse('Webhook endpoint');
205+
return $this->notFoundResponse(self::RESOURCE_NAME);
204206
}
205207

206208
$this->rotationService->invalidatePreviousSecret($endpoint);
@@ -266,7 +268,7 @@ public function updateContentGracePeriod(Request $request, string $uuid): JsonRe
266268
->first();
267269

268270
if (! $endpoint) {
269-
return $this->notFoundResponse('Webhook endpoint');
271+
return $this->notFoundResponse(self::RESOURCE_NAME);
270272
}
271273

272274
$validated = $request->validate([

php/src/Api/Controllers/McpApiController.php

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class McpApiController extends Controller
2828
{
2929
use HasApiResponses;
3030

31+
private const VALIDATION_SERVER_ID_INVALID = 'The selected server id is invalid.';
32+
private const VALIDATION_TOOL_NAME_INVALID = 'The selected tool name is invalid.';
33+
3134
/**
3235
* Safe MCP server identifier pattern.
3336
*
@@ -106,7 +109,7 @@ public function server(Request $request, string $id): JsonResponse
106109
{
107110
if (! $this->isValidServerId($id)) {
108111
return $this->validationErrorResponse([
109-
'id' => ['The selected server id is invalid.'],
112+
'id' => [self::VALIDATION_SERVER_ID_INVALID],
110113
]);
111114
}
112115

@@ -155,7 +158,7 @@ public function tools(Request $request, string $id): JsonResponse
155158
{
156159
if (! $this->isValidServerId($id)) {
157160
return $this->validationErrorResponse([
158-
'id' => ['The selected server id is invalid.'],
161+
'id' => [self::VALIDATION_SERVER_ID_INVALID],
159162
]);
160163
}
161164

@@ -222,7 +225,7 @@ public function resources(Request $request, string $id): JsonResponse
222225
{
223226
if (! $this->isValidServerId($id)) {
224227
return $this->validationErrorResponse([
225-
'id' => ['The selected server id is invalid.'],
228+
'id' => [self::VALIDATION_SERVER_ID_INVALID],
226229
]);
227230
}
228231

@@ -343,7 +346,7 @@ public function callTool(Request $request): JsonResponse
343346

344347
if (! $this->isValidToolName($validated['tool'])) {
345348
return $this->validationErrorResponse([
346-
'tool' => ['The selected tool name is invalid.'],
349+
'tool' => [self::VALIDATION_TOOL_NAME_INVALID],
347350
]);
348351
}
349352

@@ -374,13 +377,13 @@ public function callToolByRoute(Request $request, string $server, string $tool):
374377
{
375378
if (! $this->isValidServerId($server)) {
376379
return $this->validationErrorResponse([
377-
'server' => ['The selected server id is invalid.'],
380+
'server' => [self::VALIDATION_SERVER_ID_INVALID],
378381
]);
379382
}
380383

381384
if (! $this->isValidToolName($tool)) {
382385
return $this->validationErrorResponse([
383-
'tool' => ['The selected tool name is invalid.'],
386+
'tool' => [self::VALIDATION_TOOL_NAME_INVALID],
384387
]);
385388
}
386389

@@ -418,7 +421,7 @@ protected function executeToolCall(
418421
): JsonResponse {
419422
if (! $this->isValidToolName($tool)) {
420423
return $this->validationErrorResponse([
421-
'tool' => ['The selected tool name is invalid.'],
424+
'tool' => [self::VALIDATION_TOOL_NAME_INVALID],
422425
]);
423426
}
424427

@@ -667,13 +670,13 @@ public function toolVersions(Request $request, string $server, string $tool): Js
667670
{
668671
if (! $this->isValidServerId($server)) {
669672
return $this->validationErrorResponse([
670-
'server' => ['The selected server id is invalid.'],
673+
'server' => [self::VALIDATION_SERVER_ID_INVALID],
671674
]);
672675
}
673676

674677
if (! $this->isValidToolName($tool)) {
675678
return $this->validationErrorResponse([
676-
'tool' => ['The selected tool name is invalid.'],
679+
'tool' => [self::VALIDATION_TOOL_NAME_INVALID],
677680
]);
678681
}
679682

@@ -712,13 +715,13 @@ public function toolVersion(Request $request, string $server, string $tool, stri
712715
{
713716
if (! $this->isValidServerId($server)) {
714717
return $this->validationErrorResponse([
715-
'server' => ['The selected server id is invalid.'],
718+
'server' => [self::VALIDATION_SERVER_ID_INVALID],
716719
]);
717720
}
718721

719722
if (! $this->isValidToolName($tool)) {
720723
return $this->validationErrorResponse([
721-
'tool' => ['The selected tool name is invalid.'],
724+
'tool' => [self::VALIDATION_TOOL_NAME_INVALID],
722725
]);
723726
}
724727

@@ -767,7 +770,7 @@ public function resource(Request $request, string $uri): JsonResponse
767770

768771
if (! $this->isValidServerId($serverId)) {
769772
return $this->validationErrorResponse([
770-
'uri' => ['The selected server id is invalid.'],
773+
'uri' => [self::VALIDATION_SERVER_ID_INVALID],
771774
]);
772775
}
773776

php/src/Api/Database/Factories/ApiKeyFactory.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
*/
2222
class ApiKeyFactory extends Factory
2323
{
24+
private const API_KEY_SUFFIX = ' API Key';
25+
2426
/**
2527
* The name of the factory's corresponding model.
2628
*
@@ -49,7 +51,7 @@ public function definition(): array
4951
return [
5052
'workspace_id' => Workspace::factory(),
5153
'user_id' => User::factory(),
52-
'name' => fake()->words(2, true).' API Key',
54+
'name' => fake()->words(2, true).self::API_KEY_SUFFIX,
5355
'key' => Hash::driver('bcrypt')->make($plainKey),
5456
'hash_algorithm' => ApiKey::HASH_BCRYPT,
5557
'prefix' => $prefix,
@@ -91,7 +93,7 @@ public static function createWithPlainKey(
9193
return ApiKey::generate(
9294
$workspace->id,
9395
$user->id,
94-
fake()->words(2, true).' API Key',
96+
fake()->words(2, true).self::API_KEY_SUFFIX,
9597
$scopes,
9698
$expiresAt
9799
);
@@ -117,7 +119,7 @@ public static function createLegacyKey(
117119
$apiKey = ApiKey::create([
118120
'workspace_id' => $workspace->id,
119121
'user_id' => $user->id,
120-
'name' => fake()->words(2, true).' API Key',
122+
'name' => fake()->words(2, true).self::API_KEY_SUFFIX,
121123
'key' => hash('sha256', $plainKey),
122124
'hash_algorithm' => ApiKey::HASH_SHA256,
123125
'prefix' => $prefix,
@@ -136,7 +138,7 @@ public static function createLegacyKey(
136138
*/
137139
public function legacyHash(): static
138140
{
139-
return $this->state(function (array $attributes) {
141+
return $this->state(function (array $_attributes) {
140142
// Extract the plain key from the stored state
141143
$parts = explode('_', $this->plainKey ?? '', 3);
142144
$plainKey = $parts[2] ?? Str::random(48);

php/src/Api/Documentation/DocumentationController.php

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
namespace Core\Api\Documentation;
66

77
use Illuminate\Http\JsonResponse;
8-
use Illuminate\Http\Request;
98
use Illuminate\Http\Response;
109
use Illuminate\View\View;
1110
use Symfony\Component\Yaml\Yaml;
@@ -27,22 +26,22 @@ public function __construct(
2726
*
2827
* Redirects to the configured default UI.
2928
*/
30-
public function index(Request $request): View
29+
public function index(): View
3130
{
3231
$defaultUi = config('api-docs.ui.default', 'swagger');
3332

3433
return match ($defaultUi) {
35-
'swagger' => $this->swagger($request),
36-
'redoc' => $this->redoc($request),
37-
'stoplight' => $this->stoplight($request),
38-
default => $this->scalar($request),
34+
'swagger' => $this->swagger(),
35+
'redoc' => $this->redoc(),
36+
'stoplight' => $this->stoplight(),
37+
default => $this->scalar(),
3938
};
4039
}
4140

4241
/**
4342
* Show Swagger UI.
4443
*/
45-
public function swagger(Request $request): View
44+
public function swagger(): View
4645
{
4746
$config = config('api-docs.ui.swagger', []);
4847

@@ -55,7 +54,7 @@ public function swagger(Request $request): View
5554
/**
5655
* Show Scalar API Reference.
5756
*/
58-
public function scalar(Request $request): View
57+
public function scalar(): View
5958
{
6059
$config = config('api-docs.ui.scalar', []);
6160

@@ -68,7 +67,7 @@ public function scalar(Request $request): View
6867
/**
6968
* Show ReDoc documentation.
7069
*/
71-
public function redoc(Request $request): View
70+
public function redoc(): View
7271
{
7372
return view('api-docs::redoc', [
7473
'specUrl' => route('api.docs.openapi.json'),
@@ -78,7 +77,7 @@ public function redoc(Request $request): View
7877
/**
7978
* Show Stoplight Elements.
8079
*/
81-
public function stoplight(Request $request): View
80+
public function stoplight(): View
8281
{
8382
$config = config('api-docs.ui.stoplight', []);
8483

@@ -91,7 +90,7 @@ public function stoplight(Request $request): View
9190
/**
9291
* Get OpenAPI specification as JSON.
9392
*/
94-
public function openApiJson(Request $request): JsonResponse
93+
public function openApiJson(): JsonResponse
9594
{
9695
$spec = $this->builder->build();
9796

@@ -102,7 +101,7 @@ public function openApiJson(Request $request): JsonResponse
102101
/**
103102
* Get OpenAPI specification as YAML.
104103
*/
105-
public function openApiYaml(Request $request): Response
104+
public function openApiYaml(): Response
106105
{
107106
$spec = $this->builder->build();
108107

@@ -117,7 +116,7 @@ public function openApiYaml(Request $request): Response
117116
/**
118117
* Clear the documentation cache.
119118
*/
120-
public function clearCache(Request $request): JsonResponse
119+
public function clearCache(): JsonResponse
121120
{
122121
$this->builder->clearCache();
123122

php/src/Api/Documentation/DocumentationServiceProvider.php

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
class DocumentationServiceProvider extends ServiceProvider
1717
{
18+
private const CONFIG_FILE = '/config.php';
1819
/**
1920
* Register any application services.
2021
*/
@@ -23,10 +24,10 @@ public function register(): void
2324
// Merge documentation configuration under both the package-local
2425
// `api-docs` namespace and the RFC-facing `scramble` namespace so
2526
// either config file shape can drive the same documentation surface.
26-
$this->mergeConfigFrom(__DIR__.'/config.php', 'api-docs');
27-
$this->mergeConfigFrom(__DIR__.'/config.php', 'scramble');
27+
$this->mergeConfigFrom(__DIR__.self::CONFIG_FILE, 'api-docs');
28+
$this->mergeConfigFrom(__DIR__.self::CONFIG_FILE, 'scramble');
2829

29-
$baseConfig = require __DIR__.'/config.php';
30+
$baseConfig = require __DIR__.self::CONFIG_FILE;
3031
$scrambleConfig = config('scramble', []);
3132
$apiDocsConfig = config('api-docs', []);
3233
$effectiveConfig = array_replace_recursive($baseConfig, $scrambleConfig, $apiDocsConfig);
@@ -37,7 +38,7 @@ public function register(): void
3738
]);
3839

3940
// Register OpenApiBuilder as singleton
40-
$this->app->singleton(OpenApiBuilder::class, function ($app) {
41+
$this->app->singleton(OpenApiBuilder::class, function ($_app) {
4142
return new OpenApiBuilder;
4243
});
4344
}
@@ -58,11 +59,11 @@ public function boot(): void
5859
// Publish configuration
5960
if ($this->app->runningInConsole()) {
6061
$this->publishes([
61-
__DIR__.'/config.php' => config_path('api-docs.php'),
62+
__DIR__.self::CONFIG_FILE => config_path('api-docs.php'),
6263
], 'api-docs-config');
6364

6465
$this->publishes([
65-
__DIR__.'/config.php' => config_path('scramble.php'),
66+
__DIR__.self::CONFIG_FILE => config_path('scramble.php'),
6667
], 'scramble-config');
6768

6869
$this->publishes([

0 commit comments

Comments
 (0)