Skip to content

Dev#2

Merged
Snider merged 255 commits into
mainfrom
dev
Apr 7, 2026
Merged

Dev#2
Snider merged 255 commits into
mainfrom
dev

Conversation

@Snider
Copy link
Copy Markdown
Contributor

@Snider Snider commented Apr 7, 2026

Summary by CodeRabbit

  • New Features

    • Bounded in-memory response cache; per-key rate limiting; runtime OpenAPI client; configurable Swagger/GraphQL/WS/SSE paths; Stoplight docs UI; new endpoints: entitlements, SEO report, pixel tracking, webhook secret management; SSE broker drains on shutdown.
  • Improvements

    • Richer OpenAPI metadata and examples; request-id/duration exposed and injected into JSON responses; stronger i18n fallbacks; safer snapshotting of runtime configs and route/groups; improved error envelopes and panic recovery returning standardized JSON.

Virgil added 30 commits April 1, 2026 06:41
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Adds a token-bucket WithRateLimit option that rejects excess requests with 429 Too Many Requests and a standard error envelope.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Add a bearerAuth security scheme to the generated OpenAPI document and mark non-public operations as secured, while keeping /health explicitly public.

Co-Authored-By: Virgil <virgil@lethean.io>
Add an optional maxEntries cap to WithCache so the in-memory cache can evict old entries instead of growing without bound.\n\nCo-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Keep OpenAPI requestBody generation aligned with the RouteDescription contract by allowing non-GET operations, including DELETE, to declare JSON bodies.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Add stable registry helpers for enumerating provider OpenAPI spec files, plus iterator coverage. This gives discovery consumers a direct way to aggregate provider docs without changing routing behaviour.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Virgil and others added 21 commits April 2, 2026 13:51
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Virgil <virgil@lethean.io>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 7, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 818028e7-77af-4d53-8004-44cc7f973a8f

📥 Commits

Reviewing files that changed from the base of the PR and between 3723262 and e27006d.

📒 Files selected for processing (2)
  • cache.go
  • ratelimit.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • cache.go

📝 Walkthrough

Walkthrough

Adds bounded, LRU response caching, transport path configurability (WS/SSE/GraphQL), enriched OpenAPI/Swagger spec builder and runtime client, authentik and i18n snapshotting, response-meta injection middleware, token-bucket rate limiting, tool-bridge JSON Schema validation, iterator/snapshot protections, CLI/spec/sdk improvements, provider/proxy hardening, many tests, and numerous PHP API features.

Changes

Cohort / File(s) Summary
Engine Core & Routing
api.go, api_test.go
Engine extended with cache limits, WS/SSE/Swagger/OpenAPI metadata, authentikConfig, i18nConfig; Serve() returns listen errors early and drains SSE broker; Groups()/GroupsIter() return clones; Register skips nil/typed-nil groups.
Caching
cache.go, cache_config.go, cache_test.go
Bounded in-memory cache with entry sizing, LRU order, maxEntries/maxBytes, TTL and eviction by size/count; updated hit handling (meta refresh, headers) and CacheConfig snapshot API; tests for eviction, hits, metadata and TTL disabling.
Response Meta & Middleware
response_meta.go, response.go, middleware.go, middleware_test.go
Middleware buffers JSON responses to inject request meta (request_id, duration) into top-level envelopes; AttachRequestMeta and request-meta getters added; custom panic recovery middleware added.
Rate Limiting
ratelimit.go, ratelimit_test.go
Per-key token-bucket rate-limit middleware (credential/IP hashing), bucket eviction/overflow handling, X-RateLimit-* and Retry-After headers, periodic sweep and tests for isolation/refill.
SSE / WebSocket / GraphQL Transport
sse.go, sse_test.go, graphql.go, graphql_test.go, servers.go
Path normalisation/resolution for SSE/WS/GraphQL; SSEBroker adds waitgroup and idempotent client shutdown, Drain() waits for handlers; GraphQL path/playground normalisation and GraphQLConfig() snapshot.
Authentik & Auth
authentik.go, authentik_test.go
Engine.AuthentikConfig() snapshot added; middleware accepts dynamic public-path provider; public-path normalisation/cloning helpers; tests ensure correct prefix matching and Swagger exemption.
OpenAPI / Swagger / Spec Builder
openapi.go, spec_builder_helper.go, spec_builder_helper_test.go, spec_builder_helper_internal_test.go, swagger.go, export.go, export_test.go
SpecBuilder expanded with rich info/x-* fields (summary, contact, license, servers, securitySchemes, GraphQL/WS/SSE, pprof/expvar, cache, i18n, authentik), iterator-backed Build/Export, stable operationId handling, cloning helpers, OpenAPISpecBuilder()/SwaggerConfig() snapshots, and Swagger UI path normalisation/registration.
OpenAPI Client
client.go, client_test.go
New runtime OpenAPI client: lazy/memoised spec loading, operation/server snapshots, param placement (path/query/header/cookie/body), optional request/response schema validation, envelope-aware decoding, and comprehensive tests.
Tool Bridge Validation
bridge.go, bridge_test.go
ToolBridge now validates JSON request bodies and 2xx responses against tool schemas, enforces 10MiB body limit and single-JSON decoding, buffers and replaces invalid 2xx responses, centralises route tagging and includes an internal JSON-schema-like validator; tests added.
Spec Registry & Iteration
spec_registry.go, spec_registry_test.go, group.go, modernization_test.go
Global spec registry with deduplication by identity, iterator-backed registration/snapshots; RouteDescription expanded (deprecation, status, parameters, security) and iterator APIs snapshot to avoid mutation races.
CLI: spec / sdk / codegen
cmd/api/*, codegen.go, codegen_test.go
spec/sdk commands gain many flags (Swagger/GraphQL/transport/cache/i18n/authentik/servers/security), use iterator-backed group sources and improved temp-file lifecycle; SDKGenerator.buildArgs signature updated and Generate() validates inputs; tests updated.
Options & Helpers
options.go, cmd/api/cmd_args.go, spec_builder_helper.go
New Option helpers (WithResponseMeta, WithWSPath, WithSSEPath, WithSunset, WithRateLimit, WithCacheLimits, Swagger setters); CSV/path normalisation and spec-builder config normalisation helpers added.
Runtime Config Snapshots
runtime_config.go, cache_config.go, graphql_config.go
New snapshot types/accessors: RuntimeConfig, CacheConfig, GraphQLConfig and Engine.RuntimeConfig(), Engine.CacheConfig(), Engine.GraphQLConfig() returning nil-safe clones.
Providers & Proxy
pkg/provider/*
ProxyProvider no longer panics on invalid upstream, stores config err accessible via Err(), improved base-path stripping, registry gains iterator APIs; tests updated.
Modules & Utilities
go-io/..., go-log/..., go.mod
New local modules: io.Local filesystem helpers and log.E() error helper; go.mod dependency/replace updates.
Spec Export & Atomic Write
export.go, export_test.go
Iterator-based export APIs, centralized writer helper, atomic temp-file write+rename and improved error wrapping; tests for format normalisation and iterator export.
Sunset / Deprecation Middleware & OpenAPI Extensions
sunset.go, sunset_test.go, PHP OpenAPI extensions
ApiSunset middleware adds Deprecation/Sunset/Link/X-API-Warn headers with formatting and preservation rules; PHP Sunset/Version extensions document headers and per-operation deprecation metadata; tests added.
SSE/Response Meta Interaction & Shutdown
api.go, sse.go, sse_test.go, response_meta.go
Engine drains SSE broker on shutdown; response-meta middleware coexists with SSE and request-ID propagation; tests validate drain and shutdown ordering.
PHP API additions & docs
src/php/src/...
Centralised HasApiResponses trait; new controllers (EntitlementApiController, SeoReportController, UnifiedPixelController), SEO report service, MCP improvements, OpenAPI SunsetExtension/VersionExtension, Stoplight UI integration, route/view additions and many PHP tests.
Tests & Coverage
many *_test.go and PHP tests
Extensive new unit and integration tests across Go and PHP covering caching, schema validation, rate-limiting, SSE draining, OpenAPI generation, CLI flags, snapshot immutability and new behaviors.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant OpenAPIClient
    participant SpecLoader
    participant HTTPClient
    participant SchemaValidator

    Client->>OpenAPIClient: Call(operationID, params)
    OpenAPIClient->>SpecLoader: Load spec (lazy/memoised)
    SpecLoader-->>OpenAPIClient: Operations metadata
    OpenAPIClient->>SchemaValidator: Validate params (if schema present)
    alt Params invalid
        SchemaValidator-->>OpenAPIClient: Error
        OpenAPIClient-->>Client: Return validation error
    else Params valid
        OpenAPIClient->>HTTPClient: Build request (path/query/body/headers)
        HTTPClient-->>OpenAPIClient: Response + body
        OpenAPIClient->>SchemaValidator: Validate response (if schema present)
        alt Response invalid
            SchemaValidator-->>OpenAPIClient: Error
            OpenAPIClient-->>Client: Return response error
        else Response valid
            OpenAPIClient-->>Client: Return decoded result
        end
    end
Loading
sequenceDiagram
    participant Request as Client Request
    participant Middleware as ResponseMetaMiddleware
    participant Recorder as ResponseMetaRecorder
    participant Handler as App Handler
    participant JSONProc as JSON injector

    Request->>Middleware: Incoming request
    Middleware->>Recorder: Wrap ResponseWriter
    Recorder->>Handler: Call next handler
    Handler->>Recorder: Write status, headers, body (buffered)
    Handler-->>Middleware: Handler done
    Middleware->>Recorder: Inspect buffered body
    alt Body is JSON envelope and meta available
        Middleware->>JSONProc: Decode and inject request_id/duration
        JSONProc-->>Middleware: Re-encoded body
        Middleware->>Recorder: Replace body, update Content-Length
    end
    Recorder->>Client: Commit headers, status, body
Loading

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 20

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
codegen.go (1)

71-96: ⚠️ Potential issue | 🟠 Major

Pass the trimmed spec path into buildArgs.

Lines 71-79 normalise g.SpecPath, but Line 112 still passes the raw field to openapi-generator-cli. A value like " ./openapi.yaml " now passes validation and then fails at execution time.

Suggested fix
-	args := g.buildArgs(generator, outputDir)
+	args := g.buildArgs(specPath, generator, outputDir)
-func (g *SDKGenerator) buildArgs(generator, outputDir string) []string {
+func (g *SDKGenerator) buildArgs(specPath, generator, outputDir string) []string {
 	args := []string{
 		"generate",
-		"-i", g.SpecPath,
+		"-i", specPath,
 		"-g", generator,
 		"-o", outputDir,
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codegen.go` around lines 71 - 96, The trimmed specPath variable (from
strings.TrimSpace(g.SpecPath)) must be used when building CLI args: change the
call sites that invoke g.buildArgs(generator, outputDir) to pass specPath
instead of relying on g.SpecPath, and update the buildArgs function
signature/implementation to accept and use this specPath parameter (replace any
uses of g.SpecPath inside buildArgs) so the normalized path is what's passed to
openapi-generator-cli.
cache.go (1)

50-77: ⚠️ Potential issue | 🔴 Critical

Keep expiry removal in one critical section.

get reads entry, unlocks, and later deletes by key. If another goroutine refreshes the same key in between, this path can delete the fresh entry and subtract the stale size from currentBytes. Recheck that s.entries[key] == entry under the second lock, or perform the expiry check and removal while still holding the mutex.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cache.go` around lines 50 - 77, The get method on cacheStore currently
unlocks before checking and removing expired entries which can race with
concurrent updates; fix by performing the expiry check and any removal while
still holding s.mu (or, if you prefer minimal change, re-acquire s.mu and verify
that s.entries[key] == entry before removing and adjusting s.currentBytes).
Specifically, update the get function to keep the mutex locked across the
time.Now().After(entry.expires) check and any calls that modify s.index,
s.order, s.currentBytes, and s.entries (or, if re-locking, compare the stored
pointer to the original entry to avoid deleting a refreshed entry).
pkg/provider/proxy.go (1)

61-72: ⚠️ Potential issue | 🟠 Major

Strip basePath before running the default director.

httputil.NewSingleHostReverseProxy joins target.Path with the incoming request path. If cfg.Upstream contains its own path prefix, stripping after defaultDirector(req) no longer matches basePath, and the upstream receives the provider prefix as well. Rewrite req.URL.Path and req.URL.RawPath first, then let the default director join against the target. A regression test with an upstream path prefix would catch this.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/provider/proxy.go` around lines 61 - 72, The current proxy.Director
strips cfg.BasePath after calling defaultDirector(req), which is wrong because
httputil.NewSingleHostReverseProxy's defaultDirector may join the target.Path
with the incoming path; instead, rewrite req.URL.Path and req.URL.RawPath by
calling stripBasePath(req.URL.Path, basePath) and stripBasePath(req.URL.RawPath,
basePath) first, then call defaultDirector(req) so the director joins against
the stripped path; update the proxy.Director closure accordingly (references:
proxy.Director, defaultDirector, stripBasePath, cfg.BasePath, req.URL.Path,
req.URL.RawPath).
sse.go (1)

130-150: ⚠️ Potential issue | 🟠 Major

Drain can still admit late clients and hang shutdown.

Drain() only signals the clients present under the lock. A Handler() racing just after Line 217 can still wg.Add(1) and stay connected indefinitely, so wg.Wait() may never return. Set a broker-level draining flag before iterating and reject or immediately close late arrivals.

Suggested shape of the fix
 type SSEBroker struct {
 	mu      sync.RWMutex
 	wg      sync.WaitGroup
 	clients map[*sseClient]struct{}
+	draining bool
 }
@@
 func (b *SSEBroker) Handler() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		channel := c.Query("channel")
 
 		client := &sseClient{
 			channel: channel,
 			events:  make(chan sseEvent, 64),
 			done:    make(chan struct{}),
 		}
 
 		b.mu.Lock()
+		if b.draining {
+			b.mu.Unlock()
+			c.Status(http.StatusServiceUnavailable)
+			return
+		}
 		b.clients[client] = struct{}{}
 		b.wg.Add(1)
 		b.mu.Unlock()
@@
 func (b *SSEBroker) Drain() {
 	b.mu.Lock()
+	b.draining = true
 	for client := range b.clients {
 		client.signalDone()
 		client.closeEvents()
 	}
 	b.mu.Unlock()
 
 	b.wg.Wait()
 }

Also applies to: 211-220

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sse.go` around lines 130 - 150, The Drain logic in SSEBroker can miss races
where Handler adds a client after Drain starts, causing wg.Wait() to hang; add a
broker-level draining flag (e.g., b.draining bool or an atomic) set by Drain
under b.mu before iterating clients, and have Handler check that flag (under
b.mu or atomically) and refuse/close late arrivals instead of doing b.wg.Add(1)
and registering them; ensure Handler returns a closed response (or 503) and does
not add the client when b.draining is true, and keep existing cleanup
(client.signalDone/delete) for normal flows so wg counts remain consistent.
pkg/provider/registry_test.go (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Fix the SPDX header token typo.

The file header uses SPDX-Licence-Identifier; the required token is SPDX-License-Identifier.

Suggested patch
-// SPDX-Licence-Identifier: EUPL-1.2
+// SPDX-License-Identifier: EUPL-1.2

As per coding guidelines, **/*.go: Include SPDX license header // SPDX-License-Identifier: EUPL-1.2 at the top of Go files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/provider/registry_test.go` at line 1, The SPDX header token is misspelled
as "SPDX-Licence-Identifier"; update the top-of-file header to use the correct
token "SPDX-License-Identifier" (i.e., replace "SPDX-Licence-Identifier:
EUPL-1.2" with "SPDX-License-Identifier: EUPL-1.2") so the file header conforms
to the required SPDX header format for Go files.
🟡 Minor comments (12)
pkg/provider/proxy_internal_test.go-7-25 (1)

7-25: ⚠️ Potential issue | 🟡 Minor

Move the _Good suffix to the end of each test name.

These names end with the scenario label instead of _Good, so they miss the repository convention. TestStripBasePath_ExactBoundary_Good, TestStripBasePath_RootPath_Good, and TestStripBasePath_DoesNotTrimPartialPrefix_Good would fit it. As per coding guidelines, **/*_test.go: Use _Good, _Bad, and _Ugly suffixes for Go test function names.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/provider/proxy_internal_test.go` around lines 7 - 25, Rename the three
test functions so the scenario label comes before the _Good suffix: change
TestStripBasePath_Good_ExactBoundary to TestStripBasePath_ExactBoundary_Good,
TestStripBasePath_Good_RootPath to TestStripBasePath_RootPath_Good, and
TestStripBasePath_Good_DoesNotTrimPartialPrefix to
TestStripBasePath_DoesNotTrimPartialPrefix_Good; update any references or test
invocations accordingly so the function names
(TestStripBasePath_Good_ExactBoundary, TestStripBasePath_Good_RootPath,
TestStripBasePath_Good_DoesNotTrimPartialPrefix) are replaced with the new
names.
src/php/src/Api/Controllers/Api/UnifiedPixelController.php-46-63 (1)

46-63: ⚠️ Potential issue | 🟡 Minor

Remove unused $pixelKey parameter or implement tracking logic.

The $pixelKey parameter is captured from the route but never used in the method body. The controller currently only returns HTTP responses without recording or processing the pixel key—no event dispatch, no logging, no tracking state.

Either this parameter should be removed if tracking is deferred to other infrastructure, or the implementation should use it to record the tracking event.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Controllers/Api/UnifiedPixelController.php` around lines 46 -
63, The track(Request $request, string $pixelKey) method currently ignores the
route parameter $pixelKey; either remove the unused parameter from the method
signature and corresponding route, or implement tracking: inside track() (using
$pixelKey and $request) validate the key, build a tracking payload (IP,
user-agent, query params, timestamp), persist or dispatch it (e.g., via an
event, repository call, or logger) before returning the same GIF/no-content
responses; update any tests or route definitions accordingly so $pixelKey is no
longer unused.
swagger_internal_test.go-25-25 (1)

25-25: ⚠️ Potential issue | 🟡 Minor

Adjust the test name to end with _Good, _Bad, or _Ugly.

TestSwaggerSpec_ReadDoc_Good_SnapshotsGroups does not end with the required suffix format.

As per coding guidelines, **/*_test.go: Use _Good, _Bad, and _Ugly suffixes for Go test function names.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@swagger_internal_test.go` at line 25, Rename the test function
TestSwaggerSpec_ReadDoc_Good_SnapshotsGroups so its name ends with one of the
approved suffixes; e.g., change the identifier to
TestSwaggerSpec_ReadDoc_SnapshotsGroups_Good (or any variant that places _Good
at the end) and update any references/calls accordingly; ensure the new function
name is used in swagger_internal_test.go and in test runners/imports so the test
still compiles and runs.
pkg/provider/registry_test.go-136-136 (1)

136-136: ⚠️ Potential issue | 🟡 Minor

Rename these tests to end with _Good, _Bad, or _Ugly exactly.

These function names append extra suffixes after _Good, which does not match the required test naming convention for Go test files.

As per coding guidelines, **/*_test.go: Use _Good, _Bad, and _Ugly suffixes for Go test function names.

Also applies to: 176-176, 216-216, 247-247, 284-284

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/provider/registry_test.go` at line 136, The test function names must end
exactly with _Good, _Bad, or _Ugly; rename
TestRegistry_StreamableIter_Good_SnapshotCurrentProviders to remove the extra
trailing text so it ends with _Good (e.g., TestRegistry_StreamableIter_Good) and
similarly rename the other test functions in the same file that currently append
extra suffixes so each ends exactly with one of the permitted suffixes
(_Good/_Bad/_Ugly); update any references (calls or t.Run names) to those
functions accordingly to keep tests runnable.
graphql_config_test.go-13-13 (1)

13-13: ⚠️ Potential issue | 🟡 Minor

Rename test functions to match the required suffix convention.

Both test names include trailing text after _Good; they should end with _Good, _Bad, or _Ugly.

As per coding guidelines, **/*_test.go: Use _Good, _Bad, and _Ugly suffixes for Go test function names.

Also applies to: 38-38

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@graphql_config_test.go` at line 13, Rename test functions so they end exactly
with the required suffixes (_Good, _Bad, or _Ugly): change
TestEngine_GraphQLConfig_Good_SnapshotsCurrentSettings to
TestEngine_GraphQLConfig_Good and similarly trim any other test names noted (the
function referenced at the other location) so they end with only the suffix;
update any references/imports/benchmarks or t.Run strings that refer to the old
names to the new names to keep builds and test runs passing.
cache_test.go-50-57 (1)

50-57: ⚠️ Potential issue | 🟡 Minor

Make the “large” payload materially larger than the “small” one.

Right now both routes serialise the same payload size (strings.Repeat(..., 96)), so the size-eviction test depends on the current response envelope staying above the 250-byte threshold rather than on the intended large-entry boundary.

Proposed fix
 	rg.GET("/small", func(c *gin.Context) {
 		n := g.counter.Add(1)
-		c.JSON(http.StatusOK, api.OK(fmt.Sprintf("small-%d-%s", n, strings.Repeat("a", 96))))
+		c.JSON(http.StatusOK, api.OK(fmt.Sprintf("small-%d-%s", n, strings.Repeat("a", 32))))
 	})
 	rg.GET("/large", func(c *gin.Context) {
 		n := g.counter.Add(1)
-		c.JSON(http.StatusOK, api.OK(fmt.Sprintf("large-%d-%s", n, strings.Repeat("b", 96))))
+		c.JSON(http.StatusOK, api.OK(fmt.Sprintf("large-%d-%s", n, strings.Repeat("b", 256))))
 	})

Also applies to: 518-553

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cache_test.go` around lines 50 - 57, The test routes for "/small" and
"/large" both serialize payloads with strings.Repeat(..., 96), so make the
"/large" response materially bigger by increasing its repeat count (e.g., use a
much larger repeat value than 96) in the rg.GET handler for "/large" (the
handler that calls g.counter.Add and c.JSON with api.OK(fmt.Sprintf(...,
strings.Repeat(...)))) so the cache size-eviction test exercises large-entry
behavior; apply the same change to the other duplicate test block that defines
the same "/small" and "/large" handlers.
src/php/src/Front/Api/Middleware/ApiSunset.php-36-60 (1)

36-60: ⚠️ Potential issue | 🟡 Minor

Trim $sunsetDate before branching on it.

A whitespace-only middleware argument still satisfies !== '', so this path emits an empty Sunset header and an X-API-Warn message with a dangling date. Normalise once in handle() and reuse the trimmed value for the header and warning decisions.

Proposed fix
     public function handle(Request $request, Closure $next, string $sunsetDate = '', ?string $replacement = null): Response
     {
+        $sunsetDate = trim($sunsetDate);
+
         /** `@var` Response $response */
         $response = $next($request);

Also applies to: 68-73

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Front/Api/Middleware/ApiSunset.php` around lines 36 - 60, In
handle(), normalize the middleware args by trimming $sunsetDate (and
$replacement) at the start so whitespace-only values don't pass the !== ''
checks; use the trimmed $sunsetDate when calling formatSunsetDate(), when
setting the 'Sunset' header, and when building the $warning string, and use the
trimmed $replacement when setting the 'Link' header — update references to
$sunsetDate/$replacement throughout handle() (including the later branch that
sets the warning/header) to use the trimmed variables.
cache_test.go-389-420 (1)

389-420: ⚠️ Potential issue | 🟡 Minor

Prove the second request is a cache HIT.

This test only checks that the same Link values come back on the second request, but cacheHeaderGroup emits those headers on every execution. As written, it can still pass if caching regresses completely.

Proposed fix
 	w2 := httptest.NewRecorder()
 	req2, _ := http.NewRequest(http.MethodGet, "/cache/multi", nil)
 	h.ServeHTTP(w2, req2)
 	if w2.Code != http.StatusOK {
 		t.Fatalf("expected 200 on cache hit, got %d", w2.Code)
 	}
+	if got := w2.Header().Get("X-Cache"); got != "HIT" {
+		t.Fatalf("expected X-Cache=HIT, got %q", got)
+	}
 
 	linkHeaders := w2.Header().Values("Link")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cache_test.go` around lines 389 - 420, The test doesn't prove the second
request was a cache hit because cacheHeaderGroup always emits the Link headers;
update the test to assert the handler was executed only once by adding a
side-effect counter or marker and checking it after the two requests: e.g., add
a package-level counter increment in cacheHeaderGroup's handler (or wrap
e.Handler() with a middleware that increments a counter on actual handler
execution), call the endpoint twice in
TestWithCache_Good_PreservesMultiValueHeadersOnHit, then assert the counter
equals 1 (and keep the existing assertions about the Link header values) to
prove the second request came from the cache.
i18n.go-199-219 (1)

199-219: ⚠️ Potential issue | 🟡 Minor

Use golang.org/x/text/language to handle BCP 47 fallback chain semantics correctly.

The string-based approach splits on - and rejoin, which produces invalid intermediate tags when the locale includes BCP 47 extensions or private-use subtags. For example, de-DE-u-co would generate intermediates like de-DE-u and de-u, which are malformed. The language.Parse() and tag.Parent() methods handle BCP 47 semantics properly and are already available via the imported package.

Proposed fix
 func localeFallbacks(locale string) []string {
 	locale = strings.TrimSpace(strings.ReplaceAll(locale, "_", "-"))
 	if locale == "" {
 		return nil
 	}
 
-	parts := strings.Split(locale, "-")
-	if len(parts) == 0 {
+	tag, err := language.Parse(locale)
+	if err != nil {
 		return []string{locale}
 	}
 
-	fallbacks := make([]string, 0, len(parts))
-	for i := len(parts); i >= 1; i-- {
-		fallbacks = append(fallbacks, strings.Join(parts[:i], "-"))
+	var fallbacks []string
+	for tag != language.Und {
+		fallbacks = append(fallbacks, tag.String())
+		next := tag.Parent()
+		if next == language.Und {
+			break
+		}
+		tag = next
 	}
 
 	return fallbacks
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@i18n.go` around lines 199 - 219, The current localeFallbacks function builds
fallbacks by naive string splitting which produces invalid BCP47 intermediates
(e.g., "de-DE-u-co" -> "de-DE-u"); replace the body of localeFallbacks to parse
the input with language.Parse, then iterate using tag.Parent() to collect each
parent tag's String() (in most-specific to least-specific order) until Parent
returns language.Und or repeats; if language.Parse returns an error, fall back
to returning the trimmed input as a single-element slice to preserve behavior.
Use golang.org/x/text/language's Parse and Parent methods to ensure correct
BCP47 semantics.
cmd/api/spec_builder.go-92-95 (1)

92-95: ⚠️ Potential issue | 🟡 Minor

Keep the default locale inside the supported set.

If i18nSupportedLocales is something like fr,de and no default is supplied, this emits I18nDefaultLocale = "en", which makes the generated spec internally inconsistent. Default to the first supported locale, or only choose "en" when it is actually present.

Suggested fix
 	builder.I18nSupportedLocales = parseLocales(cfg.i18nSupportedLocales)
 	if builder.I18nDefaultLocale == "" && len(builder.I18nSupportedLocales) > 0 {
-		builder.I18nDefaultLocale = "en"
+		builder.I18nDefaultLocale = builder.I18nSupportedLocales[0]
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/api/spec_builder.go` around lines 92 - 95, The current logic sets
builder.I18nDefaultLocale to "en" even when "en" isn't in
builder.I18nSupportedLocales, causing inconsistency; update the block that
assigns builder.I18nDefaultLocale (after builder.I18nSupportedLocales =
parseLocales(cfg.i18nSupportedLocales)) so that if I18nDefaultLocale is empty
and I18nSupportedLocales has entries you set the default to the first element of
builder.I18nSupportedLocales, and only fall back to "en" if I18nSupportedLocales
is empty or explicitly contains "en"; reference builder.I18nSupportedLocales,
builder.I18nDefaultLocale, parseLocales and cfg.i18nSupportedLocales when making
the change.
src/php/src/Api/Services/SeoReportService.php-195-211 (1)

195-211: ⚠️ Potential issue | 🟡 Minor

Extract the charset token from legacy http-equiv meta tags.

For <meta http-equiv="content-type" content="text/html; charset=UTF-8">, the fallback currently returns the whole content type string in charset. Parse out the charset= value instead, otherwise this field is wrong on older pages.

💡 Proposed fix
-        return $this->extractMetaContent($xpath, 'content-type', 'http-equiv');
+        $contentType = $this->extractMetaContent($xpath, 'content-type', 'http-equiv');
+        if ($contentType !== null && preg_match('/charset\s*=\s*["\']?([^;"\']+)/i', $contentType, $matches) === 1) {
+            return $matches[1];
+        }
+
+        return null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Services/SeoReportService.php` around lines 195 - 211, The
extractCharset method currently falls back to returning the entire content-type
string from extractMetaContent; update extractCharset to parse that returned
string for a charset token (case-insensitive search for "charset=") and return
only the charset value (trimmed, stripped of quotes) or null if none found.
Locate extractCharset and after calling
extractMetaContent('content-type','http-equiv') inspect the result, split on ';'
or search for the "charset=" segment, extract and normalize the token, and
return it instead of the full content-type string.
src/php/src/Api/Documentation/Extensions/VersionExtension.php-60-63 (1)

60-63: ⚠️ Potential issue | 🟡 Minor

Add handling for default and HTTP status range keys.

OpenAPI operations can declare responses as default and 1XX5XX ranges. The current filter only processes numeric status codes 200599, so version and deprecation headers are silently omitted from valid response definitions that use those forms.

💡 Proposed fix
 foreach ($operation['responses'] as $status => &$response) {
-    if (! is_numeric($status) || (int) $status < 200 || (int) $status >= 600) {
+    $status = (string) $status;
+    $isExact = is_numeric($status) && (int) $status >= 200 && (int) $status < 600;
+    $isRange = preg_match('/^[1-5]XX$/', $status) === 1;
+    if (! $isExact && $status !== 'default' && ! $isRange) {
         continue;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Documentation/Extensions/VersionExtension.php` around lines
60 - 63, The loop in VersionExtension:: (iterating $operation['responses'])
currently skips non-numeric keys and thus ignores 'default' and range keys like
'2XX'–'5XX'; change the filter so it treats numeric status codes 200–599, the
literal 'default' key, and range keys matching /^[1-5]XX$/i as valid response
entries and applies the version/deprecation header logic to them (i.e., replace
the is_numeric check with a condition that allows 'default' and X'XX' ranges and
still excludes invalid keys, then operate on the &$response as before).
🧹 Nitpick comments (23)
src/php/src/Api/Documentation/config.php (1)

271-277: Configuration not utilised in the Website Stoplight view.

These configuration options are defined but the Website view at src/php/src/Website/Api/View/Blade/stoplight.blade.php hardcodes theme="dark" and layout="sidebar" instead of reading from this config. Consider whether the Website view should also use these configurable values for consistency with the API Documentation view.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Documentation/config.php` around lines 271 - 277, The
Stoplight configuration array ('stoplight' => ['theme','layout','hide_try_it'])
is defined but the Blade view stoplight.blade.php currently hardcodes
theme="dark" and layout="sidebar"; update the view to read from the config
(e.g., config('Api.Documentation.stoplight.theme', 'dark'),
config('Api.Documentation.stoplight.layout', 'sidebar'), and
config('Api.Documentation.stoplight.hide_try_it', false)) and use those values
for the Stoplight component attributes so the Website view honors the same
configurable options as the API Documentation configuration.
src/php/src/Website/Api/View/Blade/stoplight.blade.php (2)

8-13: Configuration values are not applied to the component.

The theme and layout attributes are hardcoded, and hideTryIt is not included despite being configured in api-docs.ui.stoplight. Consider using the config values for consistency with the API Documentation controller's Stoplight view.

♻️ Proposed fix to use config values
     <div class="min-h-[calc(100vh-4rem)]">
         <elements-api
             apiDescriptionUrl="{{ route('api.openapi.json') }}"
             router="hash"
-            layout="sidebar"
-            theme="dark"
+            layout="{{ config('api-docs.ui.stoplight.layout', 'sidebar') }}"
+            `@if`(config('api-docs.ui.stoplight.hide_try_it', false)) hideTryIt="true" `@endif`
         ></elements-api>
     </div>

Note: Stoplight Elements doesn't support a theme attribute on the web component; theming is typically done via CSS variables or by choosing the appropriate stylesheet variant.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Website/Api/View/Blade/stoplight.blade.php` around lines 8 - 13,
The elements-api component in stoplight.blade.php uses hardcoded attributes
(layout="sidebar", theme="dark") and omits the configured hideTryIt value;
update the component to read the UI config (api-docs.ui.stoplight) instead of
hardcoding: replace the fixed layout and router/theme values with the
corresponding config entries from api-docs.ui.stoplight, remove the unsupported
theme attribute (Stoplight theming should be handled via CSS), and add the
hideTryIt (or hide-try-it) attribute bound to the configured boolean so the
component honors the controller's Stoplight view settings.

17-22: Pin CDN dependency versions to prevent unexpected breaking changes.

Loading Stoplight Elements from unpkg without version pinning means the library could update at any time, potentially causing breaking changes or introducing vulnerabilities.

📦 Proposed fix to pin versions
 `@push`('head')
-    <link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css">
+    <link rel="stylesheet" href="https://unpkg.com/@stoplight/elements@9.0.6/styles.min.css">
 `@endpush`

 `@push`('scripts')
-    <script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
+    <script src="https://unpkg.com/@stoplight/elements@9.0.6/web-components.min.js"></script>
 `@endpush`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Website/Api/View/Blade/stoplight.blade.php` around lines 17 - 22,
The CDN includes in the Blade template (`@push`('head') link rel="stylesheet" and
`@push`('scripts') script src for `@stoplight/elements`) are unpinned and should be
fixed by referencing a specific released package version (e.g.
`@stoplight/elements`@<version>) instead of the floating unpkg URL; update both
the stylesheet and web-components script URLs to include the exact version, and
optionally add SRI (integrity) and crossorigin attributes for additional
security and immutability.
bridge_test.go (1)

309-344: Assert that invalid requests never reach the tool handler.

This still passes if the bridge calls the handler and then overwrites the response with 400. A small flag here makes the short-circuit guarantee explicit.

Suggested test tightening
 	bridge := api.NewToolBridge("/tools")
+	handlerCalled := false
 	bridge.Add(api.ToolDescriptor{
 		Name:        "file_read",
 		Description: "Read a file from disk",
 		Group:       "files",
@@
-	}, func(c *gin.Context) {
+	}, func(c *gin.Context) {
+		handlerCalled = true
 		c.JSON(http.StatusOK, api.OK("should not run"))
 	})
@@
 	if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
 		t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
 	}
+	if handlerCalled {
+		t.Fatal("handler should not be called for an invalid request body")
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bridge_test.go` around lines 309 - 344, The test currently only checks the
final 400 response but not whether the tool handler was invoked; add an explicit
short-circuit assertion by introducing a boolean flag (e.g., invoked := false)
in the test and have the bridge.Add handler set invoked = true (or write a
distinct header like "X-Tool-Invoked") if it runs; after ServeHTTP assert that
invoked is false (and/or the header is absent) in addition to the 400 and
invalid_request_body checks to guarantee the bridge validation short-circuits
and never calls the handler.
src/php/src/Api/Tests/Feature/OpenApiDocumentationTest.php (1)

22-23: Consider migrating this test file to Pest syntax.

As per coding guidelines, files matching src/php/src/Api/Tests/Feature/**/*.php should use Pest syntax rather than PHPUnit. This is a pre-existing pattern in the file, so it could be addressed as a separate refactoring task.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Tests/Feature/OpenApiDocumentationTest.php` around lines 22 -
23, The file contains a PHPUnit-style test class OpenApiDocumentationTest
extending TestCase; migrate it to Pest by replacing the class and method-based
tests with Pest test() functions (or it() as preferred), moving any setUp logic
into beforeEach/afterEach closures, converting assertions to Pest's expectation
helpers where appropriate, and removing the TestCase subclass import; locate
symbols OpenApiDocumentationTest and any setUp/tearDown or test* methods and
transform them into top-level Pest tests and lifecycle hooks.
client.go (1)

166-177: Redundant nil check for httpClient.

The httpClient is set to http.DefaultClient at line 168, so the nil check at lines 173–175 can never trigger. Similarly, the check at lines 287–289 in Call is also redundant since construction guarantees a non-nil client.

🧹 Proposed simplification
 func NewOpenAPIClient(opts ...OpenAPIClientOption) *OpenAPIClient {
 	c := &OpenAPIClient{
 		httpClient: http.DefaultClient,
 	}
 	for _, opt := range opts {
 		opt(c)
 	}
-	if c.httpClient == nil {
-		c.httpClient = http.DefaultClient
-	}
 	return c
 }

And in Call:

 func (c *OpenAPIClient) Call(operationID string, params any) (any, error) {
 	if err := c.load(); err != nil {
 		return nil, err
 	}
-	if c.httpClient == nil {
-		c.httpClient = http.DefaultClient
-	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client.go` around lines 166 - 177, The nil-checks for httpClient are
redundant because NewOpenAPIClient already initializes httpClient to
http.DefaultClient; remove the unreachable guard in NewOpenAPIClient (the if
c.httpClient == nil block) and the similar nil-check in Call so the code relies
on constructor-initialized httpClient; locate and update the NewOpenAPIClient
function and the Call method (references: NewOpenAPIClient, Call) to delete
those redundant checks and ensure no other code paths can set httpClient to nil.
src/php/src/Api/Documentation/Attributes/ApiResponse.php (1)

84-89: Minor: Status codes out of numerical order.

Status code 410 appears before 409 in the match expression, which slightly reduces readability.

🔧 Suggested reordering for consistency
             404 => 'Not found',
             405 => 'Method not allowed',
-            410 => 'Gone',
             409 => 'Conflict',
+            410 => 'Gone',
             422 => 'Validation error',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Documentation/Attributes/ApiResponse.php` around lines 84 -
89, The match expression in ApiResponse (class ApiResponse) lists HTTP status
codes out of numeric order (410 appears before 409); reorder the entries in the
response mapping inside the match in ApiResponse.php so status codes are sorted
numerically (e.g., 401, 403, 404, 405, 409, 410) to improve readability and
consistency.
src/php/src/Api/Middleware/AuthenticateApiKey.php (1)

133-136: Minor: Redundant status parameter.

The forbiddenResponse() method defines int $status = 403 as a default parameter value. Passing status: 403 explicitly is unnecessary.

🔧 Optional simplification
     protected function forbidden(string $message): Response
     {
-        return $this->forbiddenResponse($message, status: 403);
+        return $this->forbiddenResponse($message);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Middleware/AuthenticateApiKey.php` around lines 133 - 136,
The forbidden() method in AuthenticateApiKey currently calls
forbiddenResponse($message, status: 403) redundantly since forbiddenResponse()
already defaults $status to 403; update AuthenticateApiKey::forbidden to call
forbiddenResponse($message) (remove the explicit status: 403 argument) to
simplify the code and avoid redundancy while preserving behavior.
src/php/src/Front/Api/VersionedRoutes.php (1)

256-257: Missing curly braces in string interpolation.

The variable interpolation at line 257 is missing curly braces around $this->replacement. While PHP can parse $this->replacement in this context, the inconsistency with line 252 (which uses {$this->replacement}) could cause issues if the property name changes and makes the code less maintainable.

♻️ Suggested fix
-            $middleware[] = "api.sunset:,$this->replacement";
+            $middleware[] = "api.sunset:,{$this->replacement}";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Front/Api/VersionedRoutes.php` around lines 256 - 257, In
VersionedRoutes.php update the string interpolation for the middleware entry so
it uses explicit braces around the property; replace the current
"api.sunset:,$this->replacement" usage with a braced interpolation form (e.g.,
"api.sunset:,{$this->replacement}") to match the style used elsewhere (see other
occurrences of {$this->replacement}) and avoid future ambiguities when the
property name changes.
src/php/src/Api/Controllers/Api/SeoReportController.php (1)

48-59: Consider logging the exception for observability.

The exception is caught but discarded without logging. For production debugging, capturing the underlying error (e.g. connection timeout, DNS failure, SSL error) would help diagnose issues without exposing details to the client.

♻️ Suggested improvement
+use Illuminate\Support\Facades\Log;
...
         try {
             $report = $this->seoReportService->analyse($validated['url']);
-        } catch (RuntimeException) {
+        } catch (RuntimeException $e) {
+            Log::warning('SEO report fetch failed', [
+                'url' => $validated['url'],
+                'error' => $e->getMessage(),
+            ]);
             return $this->errorResponse(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Controllers/Api/SeoReportController.php` around lines 48 -
59, Catch the RuntimeException as a variable and log the exception before
returning the sanitized error response: change the catch from catch
(RuntimeException) to catch (RuntimeException $e), call your logger (e.g.
$this->logger->error(...) or \Log::error(...)) with a brief message referencing
SeoReportController::analyse or seoReportService->analyse and include
$e->getMessage() and contextual meta (e.g.
'error_code'=>'seo_unavailable','provider'=>'seo') for observability, then
return the existing errorResponse unchanged so sensitive details are not exposed
to the client.
src/php/tests/Feature/ApiVersionParsingTest.php (1)

10-45: Type the Pest closures and the inline $next callbacks.

This file still has untyped test closures, and the middleware callbacks are missing the expected Request/Response signature.

Proposed fix
-beforeEach(function () {
+beforeEach(function (): void {
@@
-it('resolves the api version from an accept-version header with parameters', function () {
+it('resolves the api version from an accept-version header with parameters', function (): void {
@@
-    $response = $middleware->handle($request, fn () => new Response('OK'));
+    $response = $middleware->handle($request, fn (Request $_): Response => new Response('OK'));
@@
-it('resolves the api version from a vendor accept header inside a list', function () {
+it('resolves the api version from a vendor accept header inside a list', function (): void {
@@
-    $response = $middleware->handle($request, fn () => new Response('OK'));
+    $response = $middleware->handle($request, fn (Request $_): Response => new Response('OK'));
As per coding guidelines, "Use full type hints on all PHP function parameters and return types".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/tests/Feature/ApiVersionParsingTest.php` around lines 10 - 45, The
Pest test closures and the inline middleware callbacks lack type hints; update
the beforeEach and each it(...) closures to declare return type void (e.g.,
beforeEach(function (): void { ... }) and it(..., function (): void { ... }) and
change the middleware next callbacks passed to ApiVersion::handle to typed
Request/Response signatures (use fn(Request $request): Response => new
Response('OK') or an equivalent function(Request $request): Response) so the
Request and Response types (used in the tests around ApiVersion->handle and the
$request variable) are explicit and satisfy the coding guidelines.
src/php/tests/Feature/ApiVersionServiceTest.php (1)

8-40: Add explicit return types to the Pest callbacks.

The new test closures are still untyped. Please add : void so this file matches the repo-wide PHP typing rule.

Proposed fix
-beforeEach(function () {
+beforeEach(function (): void {
@@
-it('normalises configured versions before reading them', function () {
+it('normalises configured versions before reading them', function (): void {
As per coding guidelines, "Use full type hints on all PHP function parameters and return types".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/tests/Feature/ApiVersionServiceTest.php` around lines 8 - 40, Update
the untyped Pest callbacks by adding explicit void return types: change the
beforeEach(function () { ... }) closure to beforeEach(function (): void { ... })
and the it('normalises configured versions before reading them', function () {
... }) closure to it('normalises configured versions before reading them',
function (): void { ... }); ensure both callback signatures use ": void" so they
conform to the repo-wide PHP typing rule.
spec_registry.go (1)

48-56: Consider using a map for deduplication to improve performance.

The current implementation calls specRegistryContains for each group, resulting in O(n²) complexity when registering multiple groups. For typical usage with a small number of route groups this is acceptable, but if the registry grows, a supplementary map[string]struct{} for seen keys would improve this.

♻️ Optional optimisation using a map for deduplication
 var specRegistry struct {
 	mu     sync.RWMutex
 	groups []RouteGroup
+	seen   map[string]struct{}
 }

 func RegisterSpecGroupsIter(groups iter.Seq[RouteGroup]) {
 	if groups == nil {
 		return
 	}

 	specRegistry.mu.Lock()
 	defer specRegistry.mu.Unlock()

+	if specRegistry.seen == nil {
+		specRegistry.seen = make(map[string]struct{})
+	}
+
 	for group := range groups {
 		if group == nil {
 			continue
 		}
-		if specRegistryContains(group) {
+		key := specGroupKey(group)
+		if _, ok := specRegistry.seen[key]; ok {
 			continue
 		}
+		specRegistry.seen[key] = struct{}{}
 		specRegistry.groups = append(specRegistry.groups, group)
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec_registry.go` around lines 48 - 56, The loop uses specRegistryContains
for each group causing O(n²); replace it with a local deduplication set: before
iterating `groups` create a `seen` map (e.g. map[interface{}]struct{}) and for
each `group` skip if nil or already in `seen`, otherwise add to `seen` and
append to `specRegistry.groups`; remove the call to `specRegistryContains` and
keep references to the existing symbols `groups`, `group`, and
`specRegistry.groups` so the change is localized and avoids quadratic behavior.
sunset_test.go (1)

50-50: These test names still miss the required _Good suffix.

The current names place _Good in the middle of the identifier rather than at the end.

Based on learnings, "Applies to **/*_test.go : Use _Good, _Bad, and _Ugly suffixes for Go test function names".

Also applies to: 80-80, 109-109

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sunset_test.go` at line 50, The test function names place the "_Good" suffix
in the middle instead of at the end; rename
TestWithSunset_Good_AddsDeprecationHeaders to
TestWithSunset_AddsDeprecationHeaders_Good and similarly rename the other
affected test functions in this file so their names end with the appropriate
suffixes (_Good, _Bad, or _Ugly) to follow the required naming convention;
update any references or test invocations to use the new function names (e.g.,
adjust TestWithSunset_* identifiers and any calls from helper functions).
ratelimit_test.go (1)

27-27: Rename these tests to end with the required suffix.

_Good and _Ugly are currently infixes, not suffixes, so these names miss the repository convention.

As per coding guidelines, "Use _Good, _Bad, and _Ugly suffixes for Go test function names".

Also applies to: 92-92, 119-119, 154-154, 189-189, 224-224

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ratelimit_test.go` at line 27, Rename the test functions so the quality
marker is a suffix (e.g., move `_Good`, `_Bad`, `_Ugly` to the end); for example
change TestWithRateLimit_Good_AllowsBurstThenRejects to
TestWithRateLimit_AllowsBurstThenRejects_Good and apply the same transformation
to the other test functions that currently contain `_Good`/`_Bad`/`_Ugly` as
infixes so they end with those suffixes instead.
pkg/provider/registry.go (1)

192-194: Clone Channels() when building ProviderInfo.

Info() and InfoIter() store the slice returned by Channels() directly. If a provider returns its internal slice, callers can mutate provider state through ProviderInfo, which breaks the snapshot behaviour these helpers are meant to give. slices.Clone(s.Channels()) keeps the summary detached.

Also applies to: 224-226

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/provider/registry.go` around lines 192 - 194, When building ProviderInfo
in Info() and InfoIter(), the code stores the raw slice returned by
Streamable.Channels(), allowing callers to mutate provider internals; change
those assignments to use slices.Clone(s.Channels()) so ProviderInfo contains a
detached copy. Update both places where p.(Streamable) is asserted (the block
using s.Channels() at the current diff and the similar block around lines
224-226) to assign info.Channels = slices.Clone(s.Channels()).
src/php/src/Api/Documentation/OpenApiBuilder.php (3)

802-814: Unused $value parameter in inferStringSchema.

The $value parameter is never used in this method. The static analysis correctly identifies this. The method only uses $key to infer format hints, making $value redundant.

♻️ Proposed fix to remove unused parameter
-    protected function inferStringSchema(string $value, ?string $key): array
+    protected function inferStringSchema(?string $key): array
     {

Then update the call site at line 734:

-            return $this->inferStringSchema($value, $key);
+            return $this->inferStringSchema($key);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Documentation/OpenApiBuilder.php` around lines 802 - 814, The
method inferStringSchema has an unused $value parameter; remove that parameter
from the method signature and any docblock for inferStringSchema, then update
all call sites that pass a value to it (e.g. the place currently calling
inferStringSchema(..., $value)) to pass only the $key (or adjust the call to
match the new single-parameter signature). Keep the function name
inferStringSchema and maintain its return type/behavior (use $key only to infer
nullable/format) so nothing else changes.

741-746: Returning an empty array for unrecognised types may produce invalid OpenAPI schema.

When $value is of an unrecognised type (e.g., a resource handle), this returns [], which is not a valid OpenAPI schema object. Consider returning a generic schema instead.

♻️ Proposed fix
-        return [];
+        return ['type' => 'string', 'description' => 'Unknown type'];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Documentation/OpenApiBuilder.php` around lines 741 - 746, The
method currently returns an empty array for unrecognized types which yields
invalid OpenAPI; update the fallback in the function that contains the shown
snippet (the branch that calls inferObjectSchema($value) when is_object($value)
and otherwise returns []) to return a valid generic schema instead (for example
a simple string schema or a permissive object schema, e.g. ['type' => 'string',
'nullable' => true] or ['type' => 'object', 'additionalProperties' => true]);
modify the return in that function so callers always receive a valid OpenAPI
schema object rather than [].

873-920: Unused $route parameter in generic request body path.

The $route parameter is only used for the MCP controller check. In the default case (lines 912-919), it's unused. Consider extracting the MCP-specific logic or documenting that $route enables future extension points.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Documentation/OpenApiBuilder.php` around lines 873 - 920, The
buildRequestBody(Route $route, ?object $controller, string $action) method takes
a $route parameter that is never used in the default branch; extract the
MCP-specific branch into a dedicated helper (e.g. buildMcpRequestBody or
isMcpCallTool) and call it from buildRequestBody so the generic path no longer
depends on $route, or if you intend to keep the signature for future use, add a
short phpdoc note and mark $route as intentionally unused (e.g. /* unused */
$route) to silence reviewers and static analyzers; update references to the
controller/action check to use the new helper (preserve the controller
instanceof \Core\Api\Controllers\McpApiController && $action === 'callTool'
logic).
src/php/src/Api/Controllers/McpApiController.php (3)

678-695: uniqid() for JSON-RPC ID is acceptable but consider alternatives.

uniqid() provides reasonable uniqueness for request correlation. For high-concurrency environments, you might consider uniqid('', true) or bin2hex(random_bytes(8)) for stronger guarantees.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Controllers/McpApiController.php` around lines 678 - 695, The
JSON-RPC id generation in buildToolCallRequest currently uses uniqid() which may
collide under high concurrency; replace it with a stronger generator (e.g., use
uniqid('', true) for more entropy or use bin2hex(random_bytes(8)) for
cryptographic uniqueness) and ensure the returned 'id' value remains a string in
the returned array from buildToolCallRequest so request correlation works
reliably.

173-207: Duplicated resource mapping logic between resources() and enrichResourcesWithContent().

Both methods contain nearly identical logic for filtering, mapping resource arrays, and conditionally adding content. Consider extracting a shared helper to reduce duplication.

♻️ Proposed refactor
+    /**
+     * Map a single resource definition to an API payload.
+     *
+     * `@param`  array<string, mixed>  $resource
+     * `@param`  bool  $includeContent
+     * `@return` array<string, mixed>
+     */
+    protected function mapResourceToPayload(array $resource, bool $includeContent): array
+    {
+        $payload = array_filter([
+            'uri' => $resource['uri'] ?? null,
+            'path' => $resource['path'] ?? null,
+            'name' => $resource['name'] ?? null,
+            'description' => $resource['description'] ?? null,
+            'mime_type' => $resource['mime_type'] ?? ($resource['mimeType'] ?? null),
+        ], static fn ($value) => $value !== null);
+
+        if ($includeContent && $this->resourceDefinitionHasContent($resource)) {
+            $payload['content'] = $this->normaliseResourceContent($resource);
+        }
+
+        return $payload;
+    }

Then update both resources() and enrichResourcesWithContent() to use this helper.

Also applies to: 243-264

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Controllers/McpApiController.php` around lines 173 - 207,
Extract the duplicated mapping logic in resources() and
enrichResourcesWithContent() into a single private helper (e.g.
transformResourcePayload(array $resource, bool $includeContent): array) that
builds the payload keys ('uri','path','name','description','mime_type') using
the same null-filtering, and when $includeContent is true uses
$this->resourceDefinitionHasContent($resource) and
$this->normaliseResourceContent($resource) to add 'content'; then replace the
inline map closures in resources() and enrichResourcesWithContent() to call this
helper for each resource and return the resulting collections.

756-768: Server command mapping is hardcoded.

The resolveMcpServerCommand() method uses a hardcoded map. Consider moving this to configuration or a dedicated registry for easier maintenance and extensibility.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Controllers/McpApiController.php` around lines 756 - 768, The
resolveMcpServerCommand method currently hardcodes $commandMap inside the
function; replace this by loading the mapping from a configurable source (e.g.,
a config entry or injected registry) so it can be extended without editing the
method. Modify resolveMcpServerCommand(string $server) to obtain the map from a
service/config (e.g., $this->mcpCommandMap or config('mcp.server_commands')),
then return the mapped value or null as before; ensure the new map is provided
via dependency injection or config bootstrap and keep the fallback behavior
($map[$server] ?? null). Include any necessary validation or defaulting when
reading the map to avoid runtime errors.
src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php (1)

1195-1218: Test fixtures lack return type hints.

Per coding guidelines, PHP functions should have full type hints. The test controller methods return void but TestJsonResource::toArray lacks a return type.

♻️ Proposed fix for TestJsonResource
-    public function toArray($request): array
+    public function toArray(mixed $request): array

Also applies to: 1270-1279

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php`
around lines 1195 - 1218, The test fixture class TestJsonResource is missing a
return type on its toArray method; update TestJsonResource::toArray to declare
and return the appropriate type (e.g., ": array") and ensure its implementation
returns an array, and scan other test resource methods referenced by
TestOpenApiController (e.g., any response transformers) to add explicit return
type hints consistent with their docblocks so all controller methods (index,
show, store, update, destroy) tie to typed resource outputs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f572b61f-101b-497e-b531-5259297dd48d

📥 Commits

Reviewing files that changed from the base of the PR and between ca9b495 and aea902e.

📒 Files selected for processing (122)
  • api.go
  • api_test.go
  • authentik.go
  • authentik_test.go
  • bridge.go
  • bridge_test.go
  • cache.go
  • cache_config.go
  • cache_test.go
  • client.go
  • client_test.go
  • cmd/api/cmd.go
  • cmd/api/cmd_args.go
  • cmd/api/cmd_sdk.go
  • cmd/api/cmd_spec.go
  • cmd/api/cmd_test.go
  • cmd/api/spec_builder.go
  • cmd/api/spec_groups_iter.go
  • codegen.go
  • codegen_test.go
  • docs/architecture.md
  • docs/history.md
  • docs/index.md
  • export.go
  • export_test.go
  • go-io/go.mod
  • go-io/local.go
  • go-log/error.go
  • go-log/go.mod
  • go.mod
  • graphql.go
  • graphql_config_test.go
  • graphql_test.go
  • group.go
  • i18n.go
  • i18n_test.go
  • middleware.go
  • middleware_test.go
  • modernization_test.go
  • openapi.go
  • openapi_test.go
  • options.go
  • pkg/provider/provider.go
  • pkg/provider/proxy.go
  • pkg/provider/proxy_internal_test.go
  • pkg/provider/proxy_test.go
  • pkg/provider/registry.go
  • pkg/provider/registry_test.go
  • ratelimit.go
  • ratelimit_test.go
  • response.go
  • response_meta.go
  • runtime_config.go
  • servers.go
  • spec_builder_helper.go
  • spec_builder_helper_internal_test.go
  • spec_builder_helper_test.go
  • spec_registry.go
  • spec_registry_test.go
  • src/php/src/Api/Concerns/HasApiResponses.php
  • src/php/src/Api/Controllers/Api/EntitlementApiController.php
  • src/php/src/Api/Controllers/Api/SeoReportController.php
  • src/php/src/Api/Controllers/Api/UnifiedPixelController.php
  • src/php/src/Api/Controllers/Api/WebhookSecretController.php
  • src/php/src/Api/Controllers/Api/WebhookTemplateController.php
  • src/php/src/Api/Controllers/McpApiController.php
  • src/php/src/Api/Documentation/Attributes/ApiResponse.php
  • src/php/src/Api/Documentation/DocumentationController.php
  • src/php/src/Api/Documentation/Extensions/ApiKeyAuthExtension.php
  • src/php/src/Api/Documentation/Extensions/SunsetExtension.php
  • src/php/src/Api/Documentation/Extensions/VersionExtension.php
  • src/php/src/Api/Documentation/OpenApiBuilder.php
  • src/php/src/Api/Documentation/Routes/docs.php
  • src/php/src/Api/Documentation/Views/stoplight.blade.php
  • src/php/src/Api/Documentation/config.php
  • src/php/src/Api/Exceptions/RateLimitExceededException.php
  • src/php/src/Api/Middleware/AuthenticateApiKey.php
  • src/php/src/Api/Middleware/CheckApiScope.php
  • src/php/src/Api/Middleware/EnforceApiScope.php
  • src/php/src/Api/Resources/ErrorResource.php
  • src/php/src/Api/Routes/api.php
  • src/php/src/Api/Services/ApiUsageService.php
  • src/php/src/Api/Services/SeoReportService.php
  • src/php/src/Api/Tests/Feature/ApiUsageTest.php
  • src/php/src/Api/Tests/Feature/DocumentationStoplightTest.php
  • src/php/src/Api/Tests/Feature/EntitlementsEndpointTest.php
  • src/php/src/Api/Tests/Feature/McpApiControllerTest.php
  • src/php/src/Api/Tests/Feature/McpResourceTest.php
  • src/php/src/Api/Tests/Feature/McpServerDetailTest.php
  • src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php
  • src/php/src/Api/Tests/Feature/OpenApiDocumentationTest.php
  • src/php/src/Api/Tests/Feature/OpenApiVersionHeadersTest.php
  • src/php/src/Api/Tests/Feature/PixelEndpointTest.php
  • src/php/src/Api/Tests/Feature/SeoReportEndpointTest.php
  • src/php/src/Api/Tests/Feature/WebhookSecretRoutesTest.php
  • src/php/src/Api/config.php
  • src/php/src/Front/Api/ApiVersionService.php
  • src/php/src/Front/Api/Middleware/ApiSunset.php
  • src/php/src/Front/Api/Middleware/ApiVersion.php
  • src/php/src/Front/Api/README.md
  • src/php/src/Front/Api/VersionedRoutes.php
  • src/php/src/Website/Api/Controllers/DocsController.php
  • src/php/src/Website/Api/Routes/web.php
  • src/php/src/Website/Api/View/Blade/layouts/docs.blade.php
  • src/php/src/Website/Api/View/Blade/stoplight.blade.php
  • src/php/tests/Feature/ApiSunsetTest.php
  • src/php/tests/Feature/ApiVersionHeadersTest.php
  • src/php/tests/Feature/ApiVersionParsingTest.php
  • src/php/tests/Feature/ApiVersionServiceTest.php
  • src/php/tests/Feature/VersionedRoutesTest.php
  • sse.go
  • sse_test.go
  • sunset.go
  • sunset_test.go
  • swagger.go
  • swagger_internal_test.go
  • swagger_test.go
  • timeout_test.go
  • tracing.go
  • transport.go
  • websocket.go
  • websocket_test.go

Comment thread bridge.go
Comment thread bridge.go
Comment thread bridge.go
Comment thread cache.go
Comment thread cmd/api/cmd_sdk.go Outdated
Comment thread src/php/src/Api/Documentation/Views/stoplight.blade.php
Comment thread src/php/src/Api/Documentation/Views/stoplight.blade.php
Comment thread src/php/src/Api/Services/ApiUsageService.php
Comment thread src/php/src/Api/Services/SeoReportService.php
Comment thread src/php/src/Api/Services/SeoReportService.php
Go:
- codegen: pass trimmed specPath to buildArgs instead of raw g.SpecPath
- cmd/sdk: use local resolvedSpecFile to avoid mutating flag variable per-invocation
- export: write to temp file + atomic rename to prevent destination truncation on failure
- openapi: gate effectiveGraphQLPath/SwaggerPath/WSPath/SSEPath on enable flags; use effectiveSwaggerPath in effectiveAuthentikPublicPaths
- cache: reject oversized replacement before mutating LRU state for existing keys
- ratelimit: move setRateLimitHeaders before c.Next() so headers are sent; hash credential headers with SHA-256 to avoid storing raw secrets; prefer validated principal from context
- response_meta: track size separately from body buffer so Size() is accurate after body rewrites and in passthrough mode
- bridge: limit request body reads with http.MaxBytesReader (10 MiB); allow missing data key in ValidateResponse for nil/zero success responses; update recorder status in writeErrorResponse
- pkg/provider/proxy: validate target scheme and host after url.Parse to catch hostless inputs
- cmd_test: snapshot/restore global spec registry in TestAPISpecCmd_Good_RegisteredSpecGroups

PHP:
- HasApiResponses.php, config.php: add declare(strict_types=1)
- RateLimitExceededException: validate Origin against cors.allowed_origins before reflecting in CORS header
- ApiUsageService: import and use Core\Api\Models\ApiKey instead of fully-qualified Mod\ path
- SeoReportService: add SSRF protection (scheme check, private-IP rejection); add .throw() for HTTP error handling; disable automatic redirects

Co-Authored-By: Virgil <virgil@lethean.io>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cache.go (1)

63-76: ⚠️ Potential issue | 🟠 Major

TOCTOU race when evicting expired entries.

Between releasing the lock (line 58) and re-acquiring it (line 64), another goroutine may replace the entry via set(). The current code then:

  1. Decrements currentBytes by the stale entry's size (line 69), corrupting the byte counter
  2. Deletes the new valid entry from entries (line 73)

Re-verify the entry pointer is unchanged before proceeding with eviction.

🔧 Proposed fix
 	if time.Now().After(entry.expires) {
 		s.mu.Lock()
+		currentEntry, stillExists := s.entries[key]
+		if !stillExists || currentEntry != entry {
+			// Entry was replaced or removed; nothing to clean up.
+			s.mu.Unlock()
+			return nil
+		}
 		if elem, exists := s.index[key]; exists {
 			s.order.Remove(elem)
 			delete(s.index, key)
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cache.go` around lines 63 - 76, The eviction path is vulnerable to a TOCTOU
race: after the time check but before holding the mutex you must re-verify the
cached entry is still the same object to avoid removing a newly-set entry and
mis-updating s.currentBytes. Fix: inside the existing mutex-protected block in
the expiry branch, read current := s.entries[key] and compare pointer/identifier
to the previously-captured entry variable; if they differ, simply unlock and
return (do not decrement s.currentBytes or delete), otherwise proceed with
s.order.Remove(elem), delete(s.index, key), s.currentBytes -= entry.size
(clamped to >=0), delete(s.entries, key) and unlock. Reference symbols: entry,
key, s.entries, s.index, s.order, s.currentBytes, and set().
♻️ Duplicate comments (1)
bridge.go (1)

705-721: ⚠️ Potential issue | 🟠 Major

Keep the recorder body and headers in sync with the replacement error response.

After reset(), writeErrorResponse() still writes straight to the underlying writer. Status() and Written() are now correct, but Header() stays empty and Size() stays 0, so post-handler middleware and metrics still observe the wrong response metadata. Buffer the JSON into the recorder and commit(), or mirror w.headers and w.body before returning.

Proposed fix
 func (w *toolResponseRecorder) writeErrorResponse(status int, resp Response[any]) {
 	data, err := json.Marshal(resp)
 	if err != nil {
 		w.status = http.StatusInternalServerError
 		w.wroteHeader = true
 		http.Error(w.ResponseWriter, "internal server error", http.StatusInternalServerError)
 		return
 	}
 
-	// Update recorder state so middleware observing c.Writer.Status() or
-	// Written() sees the correct values after an error response is emitted.
+	// Keep recorder state aligned with the replacement response.
 	w.status = status
 	w.wroteHeader = true
-
-	w.ResponseWriter.Header().Set("Content-Type", "application/json")
-	w.ResponseWriter.WriteHeader(status)
-	_, _ = w.ResponseWriter.Write(data)
+	if w.headers == nil {
+		w.headers = make(http.Header)
+	}
+	w.headers.Set("Content-Type", "application/json")
+	w.body.Reset()
+	_, _ = w.body.Write(data)
+	w.commit()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bridge.go` around lines 705 - 721, writeErrorResponse in toolResponseRecorder
writes directly to the underlying ResponseWriter after reset(), leaving the
recorder's headers/body/Size() out of sync; change writeErrorResponse to marshal
the JSON into the recorder's buffer (w.body) and update/mirror w.headers from
the replacement headers, set w.status and w.wroteHeader, then call commit() (or
otherwise copy w.body and w.headers into the underlying ResponseWriter) so
Status(), Written(), Header() and Size() reflect the error response; reference
toolResponseRecorder.writeErrorResponse, reset(), commit(), w.headers, w.body,
Status(), Written(), Header(), Size() in your changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@bridge.go`:
- Around line 293-320: ValidateResponse currently uses json.Unmarshal into
envelope which coerces numbers to float64; replace that with a json.Decoder on
body and call dec.UseNumber() before Decode(&envelope) so numeric precision is
preserved (matching Validate). In other words, in the ValidateResponse function
use json.NewDecoder(bytes.NewReader(body)), call UseNumber(), Decode into the
envelope map[string]any, and keep the same error handling and subsequent logic
(including encoding/decoding of data) to ensure large integers are not lost.

In `@cmd/api/cmd_sdk.go`:
- Around line 99-100: sdkSpecGroupsIter() currently inserts an empty tool group
because goapi.NewToolBridge("/tools") only creates a stub; fix by populating the
bridge with the actual tool routes before adding it to the spec groups: obtain
the registered tool route groups (or tool handlers) from the existing
registry/registry function you use elsewhere, call Add(...) on the instance
returned by goapi.NewToolBridge("/tools") to attach those routes, then include
that populated bridge in sdkSpecGroupsIter(); ensure the same change is applied
to the other occurrence that mirrors this logic.

In `@go.mod`:
- Line 8: Update the import path in the four listed files to match the migrated
module path: replace any import of "forge.lthn.ai/core/cli/pkg/cli" with
"dappco.re/go/core/cli/pkg/cli" in cmd/api/cmd.go, cmd/api/cmd_test.go,
cmd/api/cmd_sdk.go, and cmd/api/cmd_spec.go so the code matches go.mod's
dappco.re/go/core/cli module and builds successfully.

In `@openapi.go`:
- Around line 500-505: operationResponses currently merges cacheSuccessHeaders
into GET responses unconditionally; change the signature to accept a
cacheEnabled boolean (or a context flag) and only merge cacheSuccessHeaders()
when that flag is true. Update call sites to pass the actual cache-enabled state
into operationResponses, and modify other response-builder functions that call
cacheSuccessHeaders (the other response builders noted in the review) to follow
the same pattern: thread a cache-enabled parameter through and conditionally
include cacheSuccessHeaders only when caching is enabled. Ensure you continue to
use documentedResponseHeaders, mergeHeaders, standardResponseHeaders, and
rateLimitSuccessHeaders as before, but guard cacheSuccessHeaders with the
cacheEnabled flag.
- Around line 1555-1567: The loop over groups can panic on typed-nil RouteGroup
values because it only checks "g == nil" before calling g.Name() and
g.BasePath(); replace that check with a call to isNilRouteGroup(g) (alongside
the existing isHiddenRouteGroup check) so typed-nil values are detected safely;
update the loop that builds preparedRouteGroup entries (where you call g.Name(),
g.BasePath(), and collectRouteDescriptions(g)) to skip any group for which
isNilRouteGroup(g) returns true.

In `@ratelimit.go`:
- Around line 193-220: The bucket-key selection currently checks client IP
before credential headers, making the credential branches in the function (use
of c.GetHeader("X-API-Key") and bearerTokenFromHeader(...)) effectively
unreachable; reorder the fallback so that after the validated principal/userID
checks you first check and hash credentials (X-API-Key then Authorization via
bearerTokenFromHeader) and only then fall back to c.ClientIP() and
c.Request.RemoteAddr; preserve trimming, sha256.Sum256 and hex.EncodeToString
usage and keep principal/userID checks first to match the public WithRateLimit
contract.

In `@src/php/src/Api/Exceptions/RateLimitExceededException.php`:
- Around line 53-65: The render() method in RateLimitExceededException contains
ad-hoc CORS handling that does exact-match checks against
config('cors.allowed_origins') and manually sets
Vary/Access-Control-Allow-Origin, which ignores allowed_origins_patterns and
other CORS options; remove that entire conditional ($request !== null) block and
stop trying to compute CORS headers inside RateLimitExceededException::render();
instead let the existing PublicApiCors middleware (or the framework's CORS
service) apply proper CORS headers for error responses (or, if absolutely
necessary, inject and call the app's CORS service/middleware logic rather than
using config() and manual header manipulation).

In `@src/php/src/Api/Services/SeoReportService.php`:
- Around line 203-220: The extractCharset function currently falls back to
returning the whole Content-Type string from extractMetaContent (e.g.,
"text/html; charset=utf-8"); update extractCharset to parse and return only the
charset token when extractMetaContent returns a content-type value: call
extractMetaContent($xpath, 'content-type', 'http-equiv') into a variable, then
run a case-insensitive regex (e.g., /charset\s*=\s*("?)([^\s;"]+)\1/i) or
equivalent string-splitting to extract the second capture (the actual charset),
trim and return it (or null if not found); keep extractMetaContent untouched and
reference extractCharset and extractMetaContent in the change to locate the
code.
- Around line 385-404: The current validation misses when the URL host is an IP
literal because dns_get_record returns empty and gethostbyname returns the same
value; update SeoReportService to explicitly detect and validate IP literals:
normalize $host (trim surrounding brackets for IPv6), use filter_var($host,
FILTER_VALIDATE_IP) to detect an IP literal before or alongside the
dns_get_record/gethostbyname logic, and if it's an IP call isPrivateIp($ip) and
throw the RuntimeException when private/reserved; otherwise continue with the
existing DNS lookup and gethostbyname fallback as before.
- Around line 440-458: The IPv6 branch in SeoReportService is missing handling
for IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) so those skip the IPv4
private-range checks, and the IPv4 private list also omits 0.0.0.0/8; update the
IP-check routine (the method using $ip, $packed, $prefix2 and $privateRanges) to
detect IPv4-mapped IPv6 by checking the packed bytes for the ::ffff: prefix
(first 12 bytes zero + next two bytes 0xff 0xff), extract the trailing 4 bytes
as an IPv4 address and run the existing IPv4 private-range logic against it, and
add the 0.0.0.0/8 range to the $privateRanges array so the IPv4 checks block
that reserved network as well.

---

Outside diff comments:
In `@cache.go`:
- Around line 63-76: The eviction path is vulnerable to a TOCTOU race: after the
time check but before holding the mutex you must re-verify the cached entry is
still the same object to avoid removing a newly-set entry and mis-updating
s.currentBytes. Fix: inside the existing mutex-protected block in the expiry
branch, read current := s.entries[key] and compare pointer/identifier to the
previously-captured entry variable; if they differ, simply unlock and return (do
not decrement s.currentBytes or delete), otherwise proceed with
s.order.Remove(elem), delete(s.index, key), s.currentBytes -= entry.size
(clamped to >=0), delete(s.entries, key) and unlock. Reference symbols: entry,
key, s.entries, s.index, s.order, s.currentBytes, and set().

---

Duplicate comments:
In `@bridge.go`:
- Around line 705-721: writeErrorResponse in toolResponseRecorder writes
directly to the underlying ResponseWriter after reset(), leaving the recorder's
headers/body/Size() out of sync; change writeErrorResponse to marshal the JSON
into the recorder's buffer (w.body) and update/mirror w.headers from the
replacement headers, set w.status and w.wroteHeader, then call commit() (or
otherwise copy w.body and w.headers into the underlying ResponseWriter) so
Status(), Written(), Header() and Size() reflect the error response; reference
toolResponseRecorder.writeErrorResponse, reset(), commit(), w.headers, w.body,
Status(), Written(), Header(), Size() in your changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2d891442-a7d2-4302-80e8-6a1d88e324e5

📥 Commits

Reviewing files that changed from the base of the PR and between aea902e and e54dd2e.

📒 Files selected for processing (17)
  • bridge.go
  • cache.go
  • cmd/api/cmd_sdk.go
  • cmd/api/cmd_test.go
  • codegen.go
  • export.go
  • go.mod
  • openapi.go
  • pkg/provider/proxy.go
  • ratelimit.go
  • response_meta.go
  • src/php/src/Api/Concerns/HasApiResponses.php
  • src/php/src/Api/Documentation/Views/stoplight.blade.php
  • src/php/src/Api/Exceptions/RateLimitExceededException.php
  • src/php/src/Api/Services/ApiUsageService.php
  • src/php/src/Api/Services/SeoReportService.php
  • src/php/src/Api/config.php
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/php/src/Api/Documentation/Views/stoplight.blade.php
  • codegen.go
  • src/php/src/Api/Concerns/HasApiResponses.php

Comment thread bridge.go
Comment thread cmd/api/cmd_sdk.go
Comment thread go.mod
Comment thread openapi.go Outdated
Comment thread openapi.go
Comment thread ratelimit.go
Comment thread src/php/src/Api/Exceptions/RateLimitExceededException.php Outdated
Comment thread src/php/src/Api/Services/SeoReportService.php
Comment thread src/php/src/Api/Services/SeoReportService.php
Comment thread src/php/src/Api/Services/SeoReportService.php
Go:
- cache: fix TOCTOU race in get() — re-verify entry pointer under lock before
  evicting to prevent corrupting s.currentBytes and removing a newly-set entry
- bridge: fix writeErrorResponse recorder out of sync — buffer into w.body/
  w.headers and call commit() so Status(), Header(), Size() reflect error response
- bridge: fix ValidateResponse number precision — use json.Decoder+UseNumber for
  initial envelope decode to preserve large integers (matches Validate path)
- ratelimit: fix unreachable credential branches — move X-API-Key and
  Authorization hashing before IP fallback so NAT'd clients are bucketed by key
- openapi: gate cacheSuccessHeaders on sb.CacheEnabled flag, not just method==get
- openapi: use isNilRouteGroup in prepareRouteGroups to catch typed-nil RouteGroup

PHP:
- RateLimitExceededException: remove ad-hoc CORS handling — let framework CORS
  middleware apply correct headers for all responses including errors
- SeoReportService.extractCharset: parse charset token from Content-Type value
  instead of returning the full "text/html; charset=utf-8" string
- SeoReportService: validate IP literals directly with filter_var before DNS
  lookup so ::ffff:127.0.0.1-style hosts don't bypass private-IP checks
- SeoReportService.isPrivateIp: extract isPrivateIpv4 helper; handle
  IPv4-mapped IPv6 (::ffff::/96) by checking embedded IPv4 against private
  ranges; add 0.0.0.0/8 to private range list

Co-Authored-By: Virgil <virgil@lethean.io>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
bridge.go (2)

498-506: Consider caching compiled regex patterns.

The pattern constraint compiles the regex on every validation call. For high-throughput scenarios with repeated validations against the same schema, pre-compiling and caching patterns would improve performance.

This is acceptable for typical use cases but worth noting if validation becomes a hot path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bridge.go` around lines 498 - 506, The ValidateSchema block currently
recompiles the regex on each call by using regexp.Compile(pattern); to fix,
introduce a thread-safe cache (e.g., sync.Map or a map+sync.RWMutex) keyed by
the pattern string and store compiled *regexp.Regexp values, then change
ToolBridge.ValidateSchema to first look up the compiled regexp from the cache
and only compile/store on miss (propagating compile errors as before); ensure
cache access is concurrent-safe and use the cached regexp for
re.MatchString(value) so repeated validations reuse the precompiled pattern.

510-518: Potential precision loss in numeric constraint comparisons for large integers.

The numericValue() function converts all numeric types to float64 for comparison in numericLessThan/numericGreaterThan. For int64 values beyond 2^53, this conversion loses precision, potentially causing incorrect constraint validation (e.g., a value of 9007199254740993 might incorrectly pass a maximum: 9007199254740992 constraint).

Consider comparing integers directly when both the value and limit are integral, or document this limitation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bridge.go` around lines 510 - 518, The current numeric comparisons lose
precision because numericValue/numericLessThan/numericGreaterThan convert
everything to float64; update the numeric comparison logic so when both the
input value and schema limit are integral you perform an integer-safe comparison
(e.g., detect integral values via a new isIntegralNumeric helper, parse/cast
them to big.Int or an exact integer type and compare with math/big), and only
fall back to float64 comparisons when either side is non-integral; update
numericLessThan and numericGreaterThan (and any helpers called by
validateNumericConstraints) to implement this integer-path comparison so large
int64/json.Number values beyond 2^53 are compared exactly.
src/php/src/Api/Services/SeoReportService.php (1)

412-430: Consider failing closed when hostname resolution yields no records.

If both dns_get_record() and gethostbyname() fail to resolve any addresses, $records remains empty and validation passes silently. While the HTTP request would subsequently fail, rejecting unresolvable hostnames at validation time is a more defensive approach.

Proposed fix
         foreach ($records as $record) {
             $ip = $record['ip'] ?? $record['ipv6'] ?? null;
             if ($ip === null) {
                 continue;
             }
             if ($this->isPrivateIp($ip)) {
                 throw new RuntimeException('The supplied URL resolves to a private or reserved address.');
             }
         }
+
+        // Fail closed: reject URLs whose hostnames cannot be resolved.
+        if (empty($records)) {
+            throw new RuntimeException('Unable to resolve the supplied URL hostname.');
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Services/SeoReportService.php` around lines 412 - 430, The
validation currently allows unresolvable hostnames to pass because $records can
remain empty after dns_get_record and gethostbyname; update the SeoReportService
validation (in the block that sets $records and calls gethostbyname) to fail
closed by throwing a RuntimeException when no addresses were resolved (i.e.,
$records is empty after both lookups) before entering the foreach loop; keep the
existing private/reserved address check via isPrivateIp unchanged and use a
clear message like "The supplied URL could not be resolved to any address." to
distinguish resolution failures from private IP rejections.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cache.go`:
- Around line 38-45: The constructor newCacheStore currently treats non-positive
maxEntries and maxBytes as "no limit", which allows WithCacheLimits(ttl, 0, 0)
to create an unbounded cache; change the logic to fail closed by validating
inputs before creating the store: in the call site that invokes newCacheStore
and inside newCacheStore itself, detect when both maxEntries <= 0 and maxBytes
<= 0 and either return an error (or nil) to prevent creation or skip registering
the middleware entirely; update callers to handle the error/skip path
accordingly so at least one of maxEntries or maxBytes must be positive before
instantiating a cacheStore (referencing newCacheStore and the caller that
forwards maxEntries/maxBytes).
- Around line 202-227: When refreshCachedResponseMeta(entry.body, meta) is used
(i.e., meta != nil) the cached body may change, so before replaying
entry.headers in the store.get branch you must remove stale validators like
"ETag", "Digest", and "Content-MD5" (or recompute and set new values) so they
don't misrepresent the rewritten body; modify the code around
GetRequestMeta/refreshCachedResponseMeta to drop those header keys from
entry.headers (or replace them with freshly computed values) before calling
c.Writer.Header().Add and before setting Content-Length/WriteHeader/Write.
- Around line 50-83: In cacheStore.get, currently s.order.MoveToFront(elem) is
called before checking expiry which can promote an expired entry and cause
races; change the logic so you only MoveToFront for an entry after verifying it
exists and is not expired, and perform the expiry check and potential eviction
within the same critical section (hold s.mu) to avoid releasing the lock between
read and remove; specifically, in cacheStore.get refer to s.entries[key],
s.index[key], s.order.MoveToFront, s.order.Remove, s.currentBytes and ensure
MoveToFront is executed only after confirming time.Now().Before(entry.expires)
and that evictions (remove from s.order, delete from s.index/entries and adjust
s.currentBytes) occur while s.mu is held.

In `@ratelimit.go`:
- Around line 57-66: The buckets map can grow unbounded; add a hard cap and an
overflow bucket to prevent memory blowups: introduce a maxBuckets constant and
check len(s.buckets) before inserting a new rateLimitBucket in rateLimitStore
(the code around s.buckets, rateLimitBucket, rateLimitStaleAfter, s.limit), and
if the cap is reached either reuse/route new keys to a single overflow bucket
(e.g., an "__overflow__" bucket stored on the store) or evict the
least-recently-seen stale entry before inserting; ensure this check is performed
in the same critical section protected by s.mu and that lastSeen and tokens are
initialized the same way as normal buckets.
- Around line 188-191: Update the top comment for clientRateLimitKey in
ratelimit.go to reflect the actual behavior: state that the function prefers a
validated principal from context first, then falls back to raw credential
headers (which are hashed with SHA-256) and only uses the client IP as the final
fallback; remove or change the phrase that credentials are used “as a last
resort” so the comment matches the code path in clientRateLimitKey.

---

Nitpick comments:
In `@bridge.go`:
- Around line 498-506: The ValidateSchema block currently recompiles the regex
on each call by using regexp.Compile(pattern); to fix, introduce a thread-safe
cache (e.g., sync.Map or a map+sync.RWMutex) keyed by the pattern string and
store compiled *regexp.Regexp values, then change ToolBridge.ValidateSchema to
first look up the compiled regexp from the cache and only compile/store on miss
(propagating compile errors as before); ensure cache access is concurrent-safe
and use the cached regexp for re.MatchString(value) so repeated validations
reuse the precompiled pattern.
- Around line 510-518: The current numeric comparisons lose precision because
numericValue/numericLessThan/numericGreaterThan convert everything to float64;
update the numeric comparison logic so when both the input value and schema
limit are integral you perform an integer-safe comparison (e.g., detect integral
values via a new isIntegralNumeric helper, parse/cast them to big.Int or an
exact integer type and compare with math/big), and only fall back to float64
comparisons when either side is non-integral; update numericLessThan and
numericGreaterThan (and any helpers called by validateNumericConstraints) to
implement this integer-path comparison so large int64/json.Number values beyond
2^53 are compared exactly.

In `@src/php/src/Api/Services/SeoReportService.php`:
- Around line 412-430: The validation currently allows unresolvable hostnames to
pass because $records can remain empty after dns_get_record and gethostbyname;
update the SeoReportService validation (in the block that sets $records and
calls gethostbyname) to fail closed by throwing a RuntimeException when no
addresses were resolved (i.e., $records is empty after both lookups) before
entering the foreach loop; keep the existing private/reserved address check via
isPrivateIp unchanged and use a clear message like "The supplied URL could not
be resolved to any address." to distinguish resolution failures from private IP
rejections.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 57870df8-0088-45e2-9252-5308147fb298

📥 Commits

Reviewing files that changed from the base of the PR and between e54dd2e and 3723262.

📒 Files selected for processing (6)
  • bridge.go
  • cache.go
  • openapi.go
  • ratelimit.go
  • src/php/src/Api/Exceptions/RateLimitExceededException.php
  • src/php/src/Api/Services/SeoReportService.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • openapi.go

Comment thread cache.go
Comment thread cache.go
Comment thread cache.go
Comment thread ratelimit.go
Comment thread ratelimit.go Outdated
- cache: fix LRU race — check expiry before MoveToFront within same lock
  critical section; previously a stale entry could be promoted before
  expiry was verified
- cache: drop stale ETag/Content-Md5/Digest headers when body is
  rewritten by refreshCachedResponseMeta to avoid misrepresenting the
  new body to clients
- ratelimit: cap buckets map at 100k entries with stale-eviction fallback
  and shared overflow bucket to prevent unbounded memory growth under
  high-cardinality traffic
- ratelimit: fix clientRateLimitKey comment — credentials are tried
  second (before IP), not as a "last resort"

Co-Authored-By: Virgil <virgil@lethean.io>
@Snider Snider merged commit 8e0d60c into main Apr 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant