Skip to content

release: v0.8.0-alpha.1 — module migration, SDK-codegen surface, security hardening#3

Merged
Snider merged 4 commits into
mainfrom
release/v0.8.0-alpha.1
May 13, 2026
Merged

release: v0.8.0-alpha.1 — module migration, SDK-codegen surface, security hardening#3
Snider merged 4 commits into
mainfrom
release/v0.8.0-alpha.1

Conversation

@Snider
Copy link
Copy Markdown
Contributor

@Snider Snider commented Apr 25, 2026

Summary

Consolidates the v0.8.0-alpha.1 release work for core/api. Module path migration, new SDK-codegen interfaces, and significant security hardening across the request boundary.

Headline changes

  • Module path migrationforge.lthn.ai/core/apidappco.re/go/api. All sibling dappco.re/go/* deps pinned to v0.8.0-alpha.1. PHP-side dedoc/scramble bumped ^0.12^0.13.
  • SDK codegen surface (Go)
    • Describable interface — providers expose human-readable metadata
    • Renderable interface — renderers expose templates and hints
    • StreamGroup interface — SSE endpoints declare event shapes
    • Cache-Control hints integrate with RouteGroup
    • IsValidMCPServerID validates server identifiers at the boundary
  • Security hardening
    • SSRF guard at the doHTTPClientRequest choke point with allow-list and unit + integration coverage
    • PackageName hardened against flag-injection (G204 false-positive annotated)
    • schemaInt bound-checks uint/uint64int coercion (G115)
    • newChatCompletionID G404 false-positive annotated
    • outbound request cookie path G124 false-positive annotated
    • API key auth lookup failure path hardened
    • .gitleaksignore added for one documented false-positive
  • PHP test infrastructure — Pest testbench bootstrap signature corrected; stable core module release pinned.
  • AX-10 scaffoldtests/cli/api/ Taskfile drivers cover the CLI surface.
  • Repo hygiene — removed previously-tracked .DS_Store under src/php/src/Website; dropped empty .core/TODO.md.

Refs

  • RFC-CORE-008-AGENT-EXPERIENCE.md (AX-1, AX-6, AX-10)

Test plan

  • go test ./... passes
  • tests/cli/api Taskfile drivers exercise the CLI surface
  • Pest tests green
  • CodeRabbit review

Co-authored-by: Athena athena@lthn.ai
Co-authored-by: Cerberus cerberus@lthn.ai
Co-authored-by: Hephaestus hephaestus@lthn.ai
Co-authored-by: Cladius Maximus cladius@lthn.ai

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for Server-Sent Events (SSE) and WebSocket stream endpoints with declarative configuration and routing.
  • Documentation

    • Added API RFC documentation with practical handler implementation guidance.
  • Tests

    • Expanded test coverage for OpenAPI metadata extraction, cache settings, runtime configuration, and stream endpoint functionality.

…rity hardening

Consolidates the v0.8.0-alpha.1 release work for core/api. Module path
migration, new SDK-codegen interfaces, and significant security
hardening across the request boundary.

* Module path migration: forge.lthn.ai/core/api -> dappco.re/go/api.
  All sibling dappco.re/go/* deps pinned to v0.8.0-alpha.1; PHP-side
  dedoc/scramble bumped ^0.12 -> ^0.13.

* SDK codegen surface (Go side):
    - Describable interface — providers expose human-readable metadata
    - Renderable interface — renderers expose templates and hints
    - StreamGroup interface — SSE endpoints declare event shapes
    - Cache-Control hints integrate with RouteGroup
    - IsValidMCPServerID validates server identifiers at the boundary

* Security hardening:
    - SSRF guard at the doHTTPClientRequest choke point with allow-list
      and unit + integration coverage
    - PackageName hardened against flag-injection (G204 false-positive
      annotated)
    - schemaInt bound-checks uint/uint64 -> int coercion (G115)
    - newChatCompletionID G404 false-positive annotated
    - outbound request cookie path G124 false-positive annotated
    - API key auth lookup failure path hardened
    - .gitleaksignore added for one documented false-positive

* PHP test infrastructure:
    - Pest testbench bootstrap signature corrected
    - Stable core module release pinned

* AX-10 scaffold: tests/cli/api/ Taskfile drivers cover the CLI surface.

* Repo hygiene:
    - removed previously-tracked .DS_Store under src/php/src/Website
    - dropped empty .core/TODO.md

Refs: RFC-CORE-008-AGENT-EXPERIENCE.md (AX-1, AX-6, AX-10)

Co-authored-by: Athena <athena@lthn.ai>
Co-authored-by: Cerberus <cerberus@lthn.ai>
Co-authored-by: Hephaestus <hephaestus@lthn.ai>
Co-authored-by: Cladius Maximus <cladius@lthn.ai>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 25, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 218891ce-0f01-4e93-a60b-5df2b9835750

📥 Commits

Reviewing files that changed from the base of the PR and between 58677ee and 9d9499d.

📒 Files selected for processing (1)
  • go/codegen_test.go

📝 Walkthrough

Walkthrough

This pull request introduces a new stream endpoint package for declarative SSE and WebSocket registration, adds comprehensive test suites for OpenAPI specification generation from handler metadata, implements engine configuration inspection APIs with test coverage, and refactors SDK generator tests to use standard library helpers instead of internal utilities.

Changes

Stream Endpoint Group Declaration and Registration

Layer / File(s) Summary
Stream protocol and handler types
pkg/stream/stream_group.go
Defines Protocol (SSE, WebSocket), Handler struct (protocol, method, path, handler function), and StreamGroup interface. Group implementation normalises group names, trims/uppercases handler metadata (method, path), aliases protocols, and filters invalid handlers before registration. Helper constructors SSE and WebSocket create GET handler descriptors.
Stream group routing and validation
pkg/stream/stream_group_test.go
Verifies stream group metadata exposure, correct HTTP routing and response headers for registered SSE and WebSocket endpoints, handler metadata normalisation, defensive cloning of handler lists, and multi-tenant integration with separate stream groups registered into a single API engine.
Stream group usage example
pkg/stream/stream_group_example_test.go
Demonstrates creating an API engine, registering a stream group with an SSE route at /events, and verifying a 200 response with text/event-stream content and data: ready message body.

API OpenAPI Handler Metadata Flow

Layer / File(s) Summary
API RFC and conceptual documentation
RFC.md
Defines the API RFC note concept and provides a Go example of a handler implementing api.Describable and api.Renderable, showing how SpecBuilder extracts handler metadata (operationId, tags, summary, description) and render hints for OpenAPI population.
Handler metadata to OpenAPI spec generation
api_describable_test.go
Tests that SpecBuilder extracts handler-provided metadata (operationId, tags, summary, description) and request/response schemas into the OpenAPI operation. Validates fallback to route-level and generated fields when handler metadata is absent or handler is nil.
Render hints to OpenAPI extension
api_renderable_test.go
Verifies that non-empty api.RenderHints from a renderable handler populate the x-render-hints OpenAPI extension with structured content (kind, fields, actions), and that empty or nil handlers safely omit the extension.

Engine Configuration and Utility Testing

Layer / File(s) Summary
Cache and runtime configuration inspection
cache_config_test.go, runtime_config_test.go
Tests Engine.CacheConfig() and Engine.RuntimeConfig() methods; validates that configured cache limits (TTL, MaxEntries, MaxBytes) and runtime settings (Swagger, GraphQL, I18n, auth paths) are correctly snapshotted, and that nil engines return zero-value configs.
Integer boundary and conversion utility
bridge_internal_test.go
Validates schemaInt conversion function: overflow inputs return (0, false), in-range values return the expected int with ok=true, and boundaries (e.g. uint64(math.MaxInt) success, uint64(math.MaxInt)+1 failure) are correctly enforced.
SDK generator test refactoring
go/codegen_test.go
Updates package-name validation tests to use standard library helpers (filepath.Join, os.WriteFile, strings.Contains) instead of project core wrappers, preserving existing assertion logic for rejected and accepted package names.

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread ssrf_guard.go
"metadata.google.internal": {},
"metadata.googleapis.com": {},
"metadata.azure.com": {},
"169.254.169.254": {}, // AWS / GCP / OpenStack / Azure (legacy)
Comment thread ssrf_guard.go
"metadata.googleapis.com": {},
"metadata.azure.com": {},
"169.254.169.254": {}, // AWS / GCP / OpenStack / Azure (legacy)
"fd00:ec2::254": {}, // AWS IPv6
Comment thread ssrf_guard.go
"metadata.azure.com": {},
"169.254.169.254": {}, // AWS / GCP / OpenStack / Azure (legacy)
"fd00:ec2::254": {}, // AWS IPv6
"100.100.100.200": {}, // Alibaba Cloud
Comment thread webhook.go Fixed
Comment thread webhook.go Fixed
Comment thread webhook.go Fixed
Comment thread webhook.go Fixed
Comment thread webhook.go Fixed
Comment thread webhook.go Fixed
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: 2

Note

Due to the large number of review comments, Critical 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 (4)
bridge.go (1)

659-695: ⚠️ Potential issue | 🟠 Major

Add bounds checks for signed 64-bit integer coercions.

The unsigned types (uint, uint64) are guarded against math.MaxInt, but the signed paths (int64 and json.Number.Int64()) cast directly to int without validation. On 32-bit systems where int is narrower than int64, values outside the valid range will wrap or cause undefined behaviour. Apply the same bounds check to both signed cases before casting.

Proposed fix
 func schemaInt(value any) (int, bool) {
 	switch v := value.(type) {
 	case int:
 		return v, true
 	case int8:
 		return int(v), true
 	case int16:
 		return int(v), true
 	case int32:
 		return int(v), true
 	case int64:
+		if v < int64(math.MinInt) || v > int64(math.MaxInt) {
+			return 0, false
+		}
 		return int(v), true
 	case uint:
 		if v > math.MaxInt {
 			return 0, false
 		}
 		return int(v), true
@@
 	case json.Number:
 		if n, err := v.Int64(); err == nil {
+			if n < int64(math.MinInt) || n > int64(math.MaxInt) {
+				return 0, false
+			}
 			return int(n), true
 		}
 	}
 	return 0, false
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bridge.go` around lines 659 - 695, The schemaInt function currently casts
int64 and json.Number.Int64() results straight to int without validating
platform bounds; update the int64 case and the json.Number branch to check that
the value lies between math.MinInt and math.MaxInt before casting, returning
(0,false) if out of range, so both signed 64-bit coercions are guarded the same
way as the uint/uint64 branches.
pkg/provider/registry_test.go (1)

1-1: ⚠️ Potential issue | 🟠 Major

Fix SPDX header spelling for licence scanners.

Line [1] uses SPDX-Licence-Identifier; the expected token is SPDX-License-Identifier. This can prevent automated licence tooling from detecting the header.

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

As per coding guidelines, **/*.go: Include SPDX-License-Identifier header comment // SPDX-License-Identifier: EUPL-1.2 at the top of all 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, Replace the misspelled SPDX header
token "SPDX-Licence-Identifier" with the correct "SPDX-License-Identifier" at
the top of the file (the existing comment line starting with "// SPDX-...");
ensure the header reads exactly "// SPDX-License-Identifier: EUPL-1.2" so Go
files comply with automated license scanners and project coding guidelines.
src/php/src/Api/Documentation/config.php (1)

1-3: 🛠️ Refactor suggestion | 🟠 Major

Add strict types declaration to this PHP file.

This touched PHP file is missing declare(strict_types=1); immediately after the opening tag.

🔧 Minimal fix
 <?php
+
+declare(strict_types=1);
 
 /**
  * API Documentation Configuration
As per coding guidelines: `**/*.php`: Include `declare(strict_types=1);` as the first statement (after opening PHP tag) in every PHP file.
🤖 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 1 - 3, Add the strict
types declaration by inserting declare(strict_types=1); immediately after the
opening <?php tag in src/php/src/Api/Documentation/config.php so it is the first
statement in the file (no blank lines or comments before it); ensure any
subsequent namespace or use statements remain after this declaration.
src/php/src/Api/Controllers/McpApiController.php (1)

1187-1212: ⚠️ Potential issue | 🟠 Major

Header redaction is incomplete while request and result bodies stay raw.

McpApiRequest::log() still receives the full validated tool arguments and full response payload. MCP calls commonly carry credentials, prompts, and customer data, so this change still leaves a high-risk persistence path even though the headers are now sanitised.

🤖 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 1187 - 1212,
logApiRequest currently passes raw validated request body and raw response to
McpApiRequest::log even though headers are sanitized; update logApiRequest to
sanitize/redact sensitive fields from $validated and $response before calling
McpApiRequest::log. Implement or call a sanitizer (e.g., add a private
sanitizePayload(array $payload): array) and apply it to requestBody and
responseBody, removing or masking keys like prompt, password, token, api_key,
secret, key, access_token, authorization, credentials, and any nested
occurrences; keep workspace/server/tool identifiers intact and still pass
sanitized headers via sanitizeHeaders($request->headers->all()) when calling
McpApiRequest::log.
🟠 Major comments (23)
src/php/src/Api/Services/WebhookTemplateService.php-496-502 (1)

496-502: ⚠️ Potential issue | 🟠 Major

Validation now allows @ block tags that rendering does not process.

Line [501] accepts tags like {{#if @FIRST}}, but renderMustache() and renderSimple() still use patterns that exclude @, so templates can pass validation and then render incorrectly.

Suggested fix
-        return preg_replace_callback(
-            '/\{\{\s*([a-zA-Z0-9_\.]+)(?:\s*\|\s*([a-zA-Z0-9_]+)(?::([^\}]+))?)?\s*\}\}/',
+        return preg_replace_callback(
+            '/\{\{\s*(@?[a-zA-Z0-9_\.]+)(?:\s*\|\s*([a-zA-Z0-9_]+)(?::([^\}]+))?)?\s*\}\}/',
             function ($matches) use ($context) {
@@
-            '/\{\{`#if`\s+([a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/if\}\}/s',
+            '/\{\{`#if`\s+(@?[a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/if\}\}/s',
@@
-            '/\{\{`#unless`\s+([a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/unless\}\}/s',
+            '/\{\{`#unless`\s+(@?[a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/unless\}\}/s',
@@
-            '/\{\{`#each`\s+([a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/each\}\}/s',
+            '/\{\{`#each`\s+(@?[a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/each\}\}/s',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Services/WebhookTemplateService.php` around lines 496 - 502,
Validation in isSupportedMustacheBlockTag allows tags with an @ (e.g. {{`#if`
`@first`}}) but renderMustache() and renderSimple() still use patterns that
exclude @ causing validated templates to render incorrectly; update the
rendering regexes in renderMustache() and renderSimple() to accept an optional
leading @ in variable/identifier patterns (mirror the allowed @ in
isSupportedMustacheBlockTag) so block conditions and simple tags containing @
are matched and processed the same way as validation.
src/php/src/Api/Documentation/Middleware/ProtectDocumentation.php-32-35 (1)

32-35: ⚠️ Potential issue | 🟠 Major

Do not default production to public docs access

Line 32 currently makes production publicly accessible when api-docs.access.public_environments is unset. That creates an unsafe default and can expose internal documentation unintentionally.

🔐 Suggested change
-        $publicEnvironments = $config['public_environments'] ?? ['local', 'testing', 'staging', 'production'];
+        $publicEnvironments = $config['public_environments'] ?? ['local', 'testing', 'staging'];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Documentation/Middleware/ProtectDocumentation.php` around
lines 32 - 35, The middleware ProtectDocumentation currently defaults
$publicEnvironments to include 'production', making docs public if config is
missing; change the default in the $publicEnvironments assignment (used in the
middleware's handle method) to a safe set that excludes 'production' (e.g.,
['local', 'testing', 'staging'] or an empty array) so production is not public
unless explicitly configured via api-docs.access.public_environments; keep the
rest of the in_array check ($publicEnvironments, app()->environment(), true)
unchanged so behavior only permits public access when the config explicitly
lists production.
brotli.go-51-51 (1)

51-51: ⚠️ Potential issue | 🟠 Major

Honour Accept-Encoding quality values before enabling Brotli.

core.Contains(..., "br") can still match br;q=0, which explicitly means Brotli is not acceptable. That can cause incorrect content negotiation and client-side decode failures.

Proposed fix
-	if !core.Contains(c.Request.Header.Get("Accept-Encoding"), "br") {
+	if !acceptsBrotli(c.Request.Header.Get("Accept-Encoding")) {
 		c.Next()
 		return
 	}
func acceptsBrotli(acceptEncoding string) bool {
	for _, part := range strings.Split(strings.ToLower(acceptEncoding), ",") {
		token := strings.TrimSpace(part)
		if token == "br" {
			return true
		}
		if strings.HasPrefix(token, "br;") {
			// Treat q=0 as not acceptable.
			if strings.Contains(token, "q=0") || strings.Contains(token, "q=0.0") {
				return false
			}
			return true
		}
	}
	return false
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@brotli.go` at line 51, The current check using
core.Contains(c.Request.Header.Get("Accept-Encoding"), "br") can incorrectly
accept encodings like "br;q=0"; replace it with a small parser function (e.g.,
acceptsBrotli) that splits the header by commas, lowercases and trims tokens,
returns true for a plain "br" or "br" with a q>0, and returns false if a token
is "br" with q=0 (or q=0.0); update the Brotli enablement branch to call
acceptsBrotli(c.Request.Header.Get("Accept-Encoding")) instead of core.Contains
so content negotiation respects quality values.
src/php/src/Api/Concerns/ResolvesWorkspace.php-48-49 (1)

48-49: ⚠️ Potential issue | 🟠 Major

Validate workspace route parameters before (int) coercion.

Casting arbitrary scalar route values to int allows non-numeric inputs (e.g. "abc") to become 0, which silently changes workspace resolution behaviour. Using is_numeric() validation before casting will ensure only valid numeric strings are accepted.

Suggested fix
-        if (is_scalar($workspaceParam) && $workspaceParam !== '') {
-            return $this->findWorkspaceForUser($request, (int) $workspaceParam);
+        if (is_scalar($workspaceParam) && is_numeric($workspaceParam)) {
+            $workspaceId = (int) $workspaceParam;
+            if ($workspaceId > 0) {
+                return $this->findWorkspaceForUser($request, $workspaceId);
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Concerns/ResolvesWorkspace.php` around lines 48 - 49, The
current check casts any non-empty scalar $workspaceParam to int which turns
non-numeric strings into 0; update the condition to require a numeric value
before casting by adding is_numeric($workspaceParam) (e.g., if
(is_scalar($workspaceParam) && $workspaceParam !== '' &&
is_numeric($workspaceParam)) { return $this->findWorkspaceForUser($request,
(int) $workspaceParam); }), so only valid numeric route params are passed to
findWorkspaceForUser and non-numeric inputs fall through to the existing
resolution/validation path.
go.mod-131-137 (1)

131-137: ⚠️ Potential issue | 🟠 Major

Update replace directives to match the required module paths.

The require block references dappco.re/go/{i18n,inference,io,log}, but the replace block still targets the legacy dappco.re/go/core/* paths. This mismatch prevents local development overrides from applying to the actual required modules.

Suggested change
 replace (
 	dappco.re/go/core => ../go
-	dappco.re/go/core/i18n => ../go-i18n
-	dappco.re/go/core/inference => ../go-inference
-	dappco.re/go/core/io => ../go-io
-	dappco.re/go/core/log => ../go-log
+	dappco.re/go/i18n => ../go-i18n
+	dappco.re/go/inference => ../go-inference
+	dappco.re/go/io => ../go-io
+	dappco.re/go/log => ../go-log
 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@go.mod` around lines 131 - 137, The replace directives currently point to
legacy module paths (dappco.re/go/core/*) and should be updated to match the
modules referenced in the require block (dappco.re/go/i18n,
dappco.re/go/inference, dappco.re/go/io, dappco.re/go/log); modify the replace
entries so each key is dappco.re/go/<i18n|inference|io|log> mapping to the
corresponding local folder (e.g., ../go-i18n, ../go-inference, ../go-io,
../go-log) so local overrides apply correctly to the modules used by the build.
middleware.go-60-62 (1)

60-62: ⚠️ Potential issue | 🟠 Major

Use constant-time comparison for bearer token checks.

Line 61 compares secrets with !=. At the authentication boundary, use constant-time comparison to reduce timing leakage.

🔐 Suggested patch
 import (
 	"crypto/rand"
+	"crypto/subtle"
 	"encoding/hex"
 	"net/http"
 	"runtime/debug"
 	"strings"
 	"time"
@@
-		parts := core.SplitN(header, " ", 2)
-		if len(parts) != 2 || core.Lower(parts[0]) != "bearer" || parts[1] != token {
+		parts := core.SplitN(header, " ", 2)
+		if len(parts) != 2 || core.Lower(parts[0]) != "bearer" {
+			c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "invalid bearer token"))
+			return
+		}
+		if len(parts[1]) != len(token) || subtle.ConstantTimeCompare([]byte(parts[1]), []byte(token)) != 1 {
 			c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "invalid bearer token"))
 			return
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@middleware.go` around lines 60 - 62, The bearer token check uses plain !=
which leaks timing; change the validation in the block that handles parts :=
core.SplitN(header, " ", 2) so the secret comparison uses a constant-time
comparison (e.g., crypto/subtle.ConstantTimeCompare) against token instead of
parts[1] != token while keeping the existing header format checks
(core.Lower(parts[0]) != "bearer", len(parts) != 2) and the same abort path
(c.AbortWithStatusJSON/Fail) when the constant-time check indicates a mismatch.
src/php/src/Api/RateLimit/RateLimitService.php-303-331 (1)

303-331: ⚠️ Potential issue | 🟠 Major

Fix native lock detection to account for Laravel's store forwarding pattern.

$this->cache is a Repository contract, so checking method_exists($this->cache, 'lock') is unreliable. Lock support is often exposed via the underlying store, so this branch stays false even on lock-capable backends. Additionally, line 315 should use self::LOCK_TTL_SECONDS instead of hardcoded 10.

Suggested direction
+use Illuminate\Contracts\Cache\LockProvider;
     protected function supportsAtomicLocks(): bool
     {
-        return method_exists($this->cache, 'lock');
+        return method_exists($this->cache, 'getStore')
+            && $this->cache->getStore() instanceof LockProvider;
     }

     protected function acquireBucketLock(string $cacheKey): mixed
     {
         if ($this->supportsAtomicLocks()) {
             try {
-                $lock = $this->cache->lock($cacheKey.':lock', 10);
+                $lock = $this->cache->lock($cacheKey.':lock', self::LOCK_TTL_SECONDS);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/RateLimit/RateLimitService.php` around lines 303 - 331,
supportsAtomicLocks() and acquireBucketLock() should detect native lock support
on the underlying cache store (not the Repository), and use the class constant
for TTL; change the detection to check method_exists($this->cache->getStore(),
'lock') (or call getStore() and inspect that object) and in acquireBucketLock()
pass self::LOCK_TTL_SECONDS instead of the hardcoded 10 when creating the lock;
ensure the rest of the logic (checking $lock->get() and falling back to
acquireAdvisoryLock) remains the same.
src/php/src/Api/Controllers/Api/WorkspaceMemberController.php-61-64 (1)

61-64: ⚠️ Potential issue | 🟠 Major

Don’t allow owner to be granted through the invite endpoint.

Line 63 accepts role=owner, but Line 108 explicitly blocks owner removal here. This endpoint can therefore create privileged, effectively sticky memberships. Ownership changes should go through a separate audited flow.

Suggested fix
-            'role' => ['sometimes', 'string', 'in:owner,admin,member'],
+            'role' => ['sometimes', 'string', 'in:admin,member'],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Controllers/Api/WorkspaceMemberController.php` around lines
61 - 64, The invite endpoint currently allows role=owner in the validation array
inside WorkspaceMemberController (the $request->validate call), which lets
invites grant ownership; update the validation to remove 'owner' from the
allowed values (use 'in:admin,member' or similar) and ensure the controller’s
invite/create method (the method handling $data after validation) never assigns
or elevates a user to owner—default to 'member' when role is absent and reject
or normalize any incoming 'owner' value before creating the membership so
ownership changes remain handled only by the audited owner transfer flow.
src/php/src/Api/Middleware/RateLimitApi.php-232-235 (1)

232-235: ⚠️ Potential issue | 🟠 Major

Don’t bypass the free-tier fallback for malformed workspace attributes.

Line 233 returns before getWorkspaceTier() can downgrade a present-but-invalid workspace attribute to 'free'. That means malformed requests now fall through to the broader authenticated/default bucket instead of the stricter tier bucket.

Suggested fix
 protected function getTierRateLimit(Request $request): ?array
 {
-        $workspace = $request->attributes->get('workspace');
-        if (! is_object($workspace)) {
+        if (! $request->attributes->has('workspace')) {
             return null;
         }
+
+        $workspace = $request->attributes->get('workspace');
 
         $tier = $this->getWorkspaceTier($workspace);
         $tierConfig = config("api.rate_limits.tiers.{$tier}");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Middleware/RateLimitApi.php` around lines 232 - 235, The
early return when $workspace is not an object bypasses the later call to
getWorkspaceTier(), so malformed workspace attributes never get downgraded to
the 'free' tier; instead of returning null in the RateLimitApi middleware,
ensure you do not short-circuit: replace the return with normalizing $workspace
to null (or otherwise ensure getWorkspaceTier() is invoked with the non-object
value) so that getWorkspaceTier($workspace) runs and yields the 'free' tier for
malformed attributes before proceeding.
src/php/src/Api/Controllers/Api/PaymentMethodController.php-99-106 (1)

99-106: ⚠️ Potential issue | 🟠 Major

Avoid returning raw service exception messages to clients.

Line 104 forwards the original RuntimeException message across the API boundary. That can leak provider/internal details and makes the 422 payload unstable. Return a fixed client-safe message here and report the original exception server-side.

Suggested fix
         try {
             $this->service->removePaymentMethod($workspace, $paymentMethod);
         } catch (\RuntimeException $e) {
+            report($e);
+
             return $this->errorResponse(
                 errorCode: 'invalid_status',
-                message: $e->getMessage(),
+                message: 'The payment method cannot be removed in its current state.',
                 status: 422,
             );
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Controllers/Api/PaymentMethodController.php` around lines 99
- 106, The catch block in PaymentMethodController that wraps
$this->service->removePaymentMethod($workspace, $paymentMethod) should not
return the raw RuntimeException message; instead log or report $e (e.g., to the
app logger or error monitoring) and call errorResponse with a stable,
client-safe message (e.g., "Unable to remove payment method" or "Invalid payment
method status") and the same 422 status; update the catch for \RuntimeException
to emit the original exception server-side and replace message: $e->getMessage()
with the fixed message while keeping errorCode: 'invalid_status' and status:
422.
src/php/src/Api/Documentation/Routes/docs.php-19-19 (1)

19-19: ⚠️ Potential issue | 🟠 Major

Root docs route now bypasses configured default UI.

Line 19 hard-codes / to swagger, so api-docs.ui.default and DocumentationController::index() no longer control landing behaviour.

💡 Proposed fix
-Route::get('/', [DocumentationController::class, 'swagger'])->name('api.docs');
+Route::get('/', [DocumentationController::class, 'index'])->name('api.docs');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Documentation/Routes/docs.php` at line 19, The root docs
route currently hard-codes '/' to DocumentationController::swagger which
bypasses the configured api-docs.ui.default and the controller's index() logic;
update the route so the '/' endpoint uses DocumentationController::index (or
resolves to the UI specified by config('api-docs.ui.default')) instead of
directly calling swagger, ensuring DocumentationController::index() can decide
whether to forward to swagger() or another UI based on the api-docs.ui.default
setting.
src/php/src/Api/Resources/ApiKeyResource.php-60-60 (1)

60-60: ⚠️ Potential issue | 🟠 Major

Prevent possible null dereference on updated_at serialisation.

Line 60 calls toIso8601String() directly; if updated_at is null this will 500 at runtime.

🔧 Suggested fix
-            'updated_at' => $this->updated_at->toIso8601String(),
+            'updated_at' => $this->updated_at?->toIso8601String(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Resources/ApiKeyResource.php` at line 60, The serialization
of updated_at in ApiKeyResource may call toIso8601String() on null; update the
ApiKeyResource serialization (where 'updated_at' => ...) to guard against null
by using a null-safe check or conditional and return null (or an empty string)
when $this->updated_at is null—for example replace the direct call with a
null-safe expression ($this->updated_at?->toIso8601String()) or a ternary
($this->updated_at ? $this->updated_at->toIso8601String() : null) so no null
dereference occurs.
src/php/src/Api/Observers/WorkspaceWebhookObserver.php-42-52 (1)

42-52: ⚠️ Potential issue | 🟠 Major

Make the observer defer until after transaction commit.

The observer methods execute synchronously during the parent transaction. Whilst WebhookService->dispatch() does queue jobs with .afterCommit(), the observer itself runs before the parent transaction commits. Add implements ShouldHandleEventsAfterCommit to the observer class to defer the entire webhook dispatch until after commit, ensuring neither the observer nor delivery record creation occur during a potentially rollbackable transaction.

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

In `@src/php/src/Api/Observers/WorkspaceWebhookObserver.php` around lines 42 - 52,
Add the Laravel post-commit event interface to the observer class so the webhook
dispatch is run after the DB transaction commits: update the
WorkspaceWebhookObserver class declaration to implement
ShouldHandleEventsAfterCommit (and add the corresponding use/import for that
interface) so the existing $this->webhookService->dispatch(...) call is executed
after commit rather than during the parent transaction.
src/php/src/Api/Models/Link.php-16-27 (1)

16-27: ⚠️ Potential issue | 🟠 Major

Remove analytics fields from $fillable.

click_count and last_clicked_at are server-derived metrics and should not be mass-assignable. Although the controller currently validates request data and excludes these fields, including them in $fillable represents unnecessary risk. These fields should be updated only by click-tracking code, never through request-backed fill() or create() calls.

Suggested change
     protected $fillable = [
         'workspace_id',
         'user_id',
         'name',
         'slug',
         'destination_url',
         'short_code',
         'is_active',
-        'click_count',
-        'last_clicked_at',
         'metadata',
     ];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Models/Link.php` around lines 16 - 27, Remove the analytics
fields from the model's mass-assignable list: in the Link model adjust the
protected $fillable array to no longer include 'click_count' and
'last_clicked_at' so these server-derived metrics cannot be set via
create()/fill() calls; ensure only click-tracking code updates these attributes
(e.g., methods that increment click_count or set last_clicked_at) and leave the
rest of $fillable (workspace_id, user_id, name, slug, destination_url,
short_code, is_active, metadata) intact.
openapi.go-1408-1471 (1)

1408-1471: ⚠️ Potential issue | 🟠 Major

Do not document chat completions as public when the runtime still authenticates it.

This path item hard-codes security: []any{} and drops 401/403, but WithBearerAuth and WithAuthentik only exempt /health, Swagger, and the standalone OpenAPI JSON route. As written, generated clients will be told /v1/chat/completions is anonymous even though the engine still returns auth failures.

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

In `@openapi.go` around lines 1408 - 1471, The OpenAPI entry for the "post" chat
completions path currently hard-codes "security: []any{}" and omits 401/403
responses which incorrectly documents the endpoint as public; remove the
hard-coded security override (or set it to inherit the global security) in the
same map where operationId is set (the block using operationID("post", path,
operationIDs)) and add 401 and 403 response entries mirroring the 400/404/500
blocks (use chatCompletionsErrorSchema() and errorHeaders) so generated clients
see authentication errors; search for the map containing "requestBody":
map[string]any{...} and update the "security" key and "responses" map
accordingly.
src/php/src/Api/Services/SeoReportService.php-591-621 (1)

591-621: ⚠️ Potential issue | 🟠 Major

Restore the explicit special-use IPv4 blocked-range table here.

This filter_var(...FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) approach is weaker than the explicit range table used in WebhookEndpoint::blockedIpv4Ranges() and reopens the SEO fetch path to non-public address space. Specifically:

  • 100.64.0.0/10 (IPv4 Shared Address Space) is not rejected by either flag
  • 198.51.100.0/24 and 203.0.113.0/24 (documentation and test networks) are not documented as rejected by FILTER_FLAG_NO_RES_RANGE

These gaps represent a material SSRF control weakness. Adopt the same table-based approach as WebhookEndpoint to ensure consistent, explicit protection against reserved and special-use ranges.

🤖 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 591 - 621, The
isPrivateIp(string $ip) function currently relies on FILTER_FLAG_NO_PRIV_RANGE |
FILTER_FLAG_NO_RES_RANGE which misses special-use IPv4 blocks (e.g.,
100.64.0.0/10, 198.51.100.0/24, 203.0.113.0/24); replace that logic with the
same explicit IPv4 blocked-range table used by
WebhookEndpoint::blockedIpv4Ranges() and check membership against that table
(including handling IPv4-mapped IPv6 by extracting the embedded IPv4 and
checking it against the table); return true when the address (or embedded IPv4)
falls into any blocked range, otherwise return false.
src/php/src/Api/Controllers/Api/ApiKeyController.php-84-109 (1)

84-109: ⚠️ Potential issue | 🟠 Major

Catch a dedicated exception for quota exhaustion, not every RuntimeException.

The ApiKeyService::create() explicitly throws \RuntimeException only for the quota limit (line 51–53), but the controller catches all RuntimeException and maps them to entitlement_exceeded. This masks genuine failures from database operations, ApiKey::generate(), scope normalisation, or transaction rollback—all of which could throw RuntimeException—leaving the client with a misleading 422 quota error instead of the real fault.

Replace the broad catch with a dedicated exception class (e.g. QuotaExceededException) in the service, then catch only that in the controller.

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

In `@src/php/src/Api/Controllers/Api/ApiKeyController.php` around lines 84 - 109,
The controller currently catches all \RuntimeException in
ApiKeyController::create which masks real errors; change ApiKeyService::create
to throw a specific QuotaExceededException (new class) when the API key quota is
hit, update ApiKeyController to catch QuotaExceededException instead of
\RuntimeException, remove or narrow any other broad RuntimeException catch
branches so real runtime errors bubble up, and add the appropriate use/import
for QuotaExceededException in the controller.
src/php/src/Api/Controllers/Api/WebhookController.php-180-188 (1)

180-188: ⚠️ Potential issue | 🟠 Major

Paginate webhook delivery history before this ships.

This endpoint currently loads every delivery row and every stored response_body for the webhook into one response. A busy endpoint will eventually make this action slow and memory-heavy.

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

In `@src/php/src/Api/Controllers/Api/WebhookController.php` around lines 180 -
188, The current WebhookController code fetches all delivery rows into
$deliveries via $webhook->deliveries()->latest()->get(), which will load every
response_body into memory; change this to use Laravel pagination (e.g.
->paginate($perPage)) on the $webhook->deliveries() query, then map/serialize
only the paginated items with serializeDelivery($delivery) and return pagination
metadata (total, per_page, current_page, etc.) instead of count(); update the
response shape to include the paginated deliveries and metadata so the endpoint
no longer returns the entire delivery history in one payload.
chat_completions.go-44-54 (1)

44-54: ⚠️ Potential issue | 🟠 Major

stop is not OpenAI-compatible yet.

OpenAI clients can send "stop": "\n" as a single string. With Stop []string, those requests fail during JSON decoding as an invalid body, so many off-the-shelf clients will not work against this endpoint.

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

In `@chat_completions.go` around lines 44 - 54, The Stop field on
ChatCompletionRequest is typed as []string which rejects JSON where "stop" is a
single string; change Stop to a custom type (e.g., StopSequences) and implement
custom JSON unmarshal/marshal logic so it accepts either a string or an array of
strings during decode/encode; update the ChatCompletionRequest struct to use
StopSequences (field name Stop) and ensure the UnmarshalJSON for StopSequences
normalizes a single string into a one-element slice and MarshalJSON emits an
array for consistency.
src/php/src/Api/Controllers/Api/QrCodeController.php-51-69 (1)

51-69: ⚠️ Potential issue | 🟠 Major

format=png is a broken contract at the moment.

The create endpoint accepts and stores png, but download() always emits SVG bytes with image/svg+xml and a .svg filename. Any client that requests PNG will get the wrong media type and payload.

Smallest safe fix until PNG rendering exists
-            'format' => ['sometimes', 'string', 'in:png,svg'],
+            'format' => ['sometimes', 'string', 'in:svg'],
...
-            'format' => $data['format'] ?? 'svg',
+            'format' => 'svg',

Also applies to: 93-98

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

In `@src/php/src/Api/Controllers/Api/QrCodeController.php` around lines 51 - 69,
The controller currently accepts and persists 'format' values including "png"
but the QrCode download() always returns SVG bytes/headers, causing a mismatch;
fix by preventing "png" from being accepted or by normalizing any requested
format to "svg" before create. Update the request->validate rules (remove "png"
from the 'in:png,svg' rule) or coerce $data['format'] to 'svg', and ensure
QrCode::query()->create(...) stores only 'svg' for the 'format' field; also
audit any uses of download() to confirm they expect 'svg'.
chat_completions.go-960-982 (1)

960-982: ⚠️ Potential issue | 🟠 Major

RemoteAddr is not a trustworthy loopback-only security boundary.

If this handler sits behind nginx, Caddy, or Apache on the same host, external traffic will often arrive with a loopback RemoteAddr, so this check can be bypassed while the endpoint is still publicly exposed.

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

In `@chat_completions.go` around lines 960 - 982, The current isLoopbackRequest
function incorrectly treats r.RemoteAddr as a secure loopback boundary; update
isLoopbackRequest to stop trusting RemoteAddr alone and instead determine the
true client IP by (1) accepting a configurable trusted proxy IP/networks list
and, if X-Forwarded-For (or Forwarded) headers are present, parse the header and
validate that all upstream proxy hops (except the left-most client IP) are
within the trusted proxies before using the left-most IP as the client, and (2)
if no proxy headers exist or proxies are not trusted, fall back to using the
connection peer (r.RemoteAddr or the connection from the request context) to
verify loopback; ensure you reference and update the isLoopbackRequest function
and any callers to pass the trusted proxy config and implement strict
parsing/validation of X-Forwarded-For/Forwarded to avoid treating proxied
external requests as loopback.
src/php/src/Api/Routes/api.php-118-120 (1)

118-120: ⚠️ Potential issue | 🟠 Major

Re-add scope enforcement on the authenticated auth routes.

GET /me and DELETE /token are the only authenticated v1 auth endpoints here that skip api.scope.enforce. That means any valid token can reach them regardless of its granted scopes, which weakens the boundary hardening added elsewhere in this PR.

Suggested fix
-        Route::middleware(['auth.api', 'api.rate', 'api.cache:ephemeral'])->group(function () {
+        Route::middleware(['auth.api', 'api.scope.enforce', 'api.rate', 'api.cache:ephemeral'])->group(function () {
             Route::delete('/token', [AuthController::class, 'destroy'])->name('token.destroy');
             Route::get('/me', [AuthController::class, 'show'])->name('me');
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Routes/api.php` around lines 118 - 120, The auth routes for
GET /me (AuthController::show) and DELETE /token (AuthController::destroy) are
missing scope enforcement; update the Route::middleware call that currently uses
['auth.api','api.rate','api.cache:ephemeral'] to include the 'api.scope.enforce'
middleware so these endpoints require proper token scopes (i.e., add
'api.scope.enforce' to the middleware array or insert it into the group
definition protecting these routes).
src/php/src/Api/Controllers/Api/TicketController.php-55-77 (1)

55-77: ⚠️ Potential issue | 🟠 Major

Reject ticket creation when neither workspace nor user is resolved.

store() can currently persist a ticket with both workspace_id and user_id set to null. That creates orphaned records that index() already refuses to list and that the rest of the controller cannot safely scope later.

Suggested fix
 public function store(Request $request): JsonResponse
 {
     $workspace = $this->resolveWorkspace($request);
+    if ($workspace === null && $request->user() === null) {
+        return $this->forbiddenResponse('Authentication or workspace context required.');
+    }
 
     $data = $request->validate([
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Controllers/Api/TicketController.php` around lines 55 - 77,
In store(), before creating the SupportTicket, add a guard that rejects the
request when both resolveWorkspace($request) returns null and $request->user()
is null: check $workspace and $request->user() immediately after calling
resolveWorkspace and if both are missing return a 400/422 JSON error (or use
$this->errorResponse / abort with a clear message) so no ticket is created with
workspace_id and user_id both null; keep the rest of the creation logic
unchanged and reference store(), resolveWorkspace(), $request->user(), and
SupportTicket::query()->create when making this change.
🟡 Minor comments (7)
docs/index.md-150-150 (1)

150-150: ⚠️ Potential issue | 🟡 Minor

Module-path migration is only partially applied in this doc

Line 150 uses the new ecosystem path, but this page still references the old module path at Line 10 and Line 35. Please update those to avoid conflicting guidance.

📝 Suggested follow-up edits in the same file
-**Module path:** `forge.lthn.ai/core/go-api`
+**Module path:** `dappco.re/go/api`
...
-    api "forge.lthn.ai/core/go-api"
+    api "dappco.re/go/api"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/index.md` at line 150, Update the remaining references to the old module
path in this document so they match the new ecosystem path
`dappco.re/go/core/cli`; locate the earlier module import mentions near the top
of the page (the two places that still use the old path) and replace them with
`dappco.re/go/core/cli` so the doc consistently uses the new module path across
the file.
src/php/src/Api/Concerns/HasApiResponses.php-25-36 (1)

25-36: ⚠️ Potential issue | 🟡 Minor

Data duplication in error response when $meta is non-empty.

When $meta is provided, the same data appears twice in the response:

  1. Under the details key
  2. Merged at the top level via array_merge($response, $meta)

This could lead to bloated responses and potential confusion for API consumers. Consider whether both inclusions are intentional or if one should be removed.

🔧 Suggested fix if duplication is unintended

If only top-level merge is desired:

-        if ($meta !== []) {
-            $response['details'] = $meta;
-        }
-
-        return response()->json(array_merge($response, $meta), $status);
+        return response()->json(array_merge($response, $meta), $status);

Or if only details nesting is desired:

         if ($meta !== []) {
             $response['details'] = $meta;
         }
 
-        return response()->json(array_merge($response, $meta), $status);
+        return response()->json($response, $status);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Concerns/HasApiResponses.php` around lines 25 - 36, The
response currently duplicates $meta by both adding it as $response['details']
and merging it into the top-level with array_merge($response, $meta) before
returning; to fix, pick one approach — I recommend keeping the nested details
key for clarity: remove the array_merge and return response()->json($response,
$status) so that HasApiResponses returns $response with 'details' containing
$meta (remove the top-level merge that causes duplication).
src/php/src/Api/RateLimit/RateLimitService.php-264-297 (1)

264-297: ⚠️ Potential issue | 🟡 Minor

Recompute resetsAt after appending the accepted hit.

calculateResetTime() currently runs on the pre-write slice. When this request consumes the last remaining slot, the returned reset time is based on the old hit set, so callers can receive a later reset timestamp than the bucket actually has.

Proposed fix
     protected function hitWithoutLock(string $cacheKey, int $limit, int $window, float $burst): RateLimitResult
     {
         $effectiveLimit = (int) floor($limit * $burst);
         $now = Carbon::now();
         $windowStart = $now->timestamp - $window;
@@
-        // Calculate reset time
-        $resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit);
-
         if ($currentCount >= $effectiveLimit) {
             // Find oldest hit to determine retry after
             $oldestHit = $hits !== [] ? min($hits) : $now->timestamp;
             $retryAfter = max(1, ($oldestHit + $window) - $now->timestamp);
+            $resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit);
 
             return RateLimitResult::denied($limit, $retryAfter, $resetsAt);
         }
@@
         if (! $this->storeWindowHits($cacheKey, $hits, $window)) {
             return RateLimitResult::denied($limit, 1, $now->copy()->addSecond());
         }
 
         $remaining = max(0, $effectiveLimit - count($hits));
+        $resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit);
 
         return RateLimitResult::allowed($limit, $remaining, $resetsAt);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/RateLimit/RateLimitService.php` around lines 264 - 297, The
reset time is computed before the accepted hit is appended, which can return a
later reset timestamp than the actual bucket; in hitWithoutLock(), after pushing
$now->timestamp into $hits and successfully calling storeWindowHits($cacheKey,
$hits, $window), recompute $resetsAt by calling calculateResetTime($hits,
$window, $effectiveLimit) (or equivalent) using the updated $hits before
computing $remaining and returning RateLimitResult::allowed($limit, $remaining,
$resetsAt); ensure the recompute happens only after storeWindowHits returns true
so denied paths remain unchanged.
modernization_test.go-195-203 (1)

195-203: ⚠️ Potential issue | 🟡 Minor

Use a fatal length assertion before indexing descs.

Lines 198 and 201 index descs even when the length check at Line 195 fails, because t.Errorf is non-fatal.

🔧 Suggested fix
-	if len(descs) != 2 {
-		t.Errorf("DescribeIter failed: expected listing + 1 tool, got %v", descs)
-	}
+	if len(descs) != 2 {
+		t.Fatalf("DescribeIter failed: expected listing + 1 tool, got %v", descs)
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modernization_test.go` around lines 195 - 203, The test currently uses
t.Errorf to check len(descs) and then indexes descs[0] and descs[1], which can
panic if the length check fails; change the length assertion to a fatal
assertion (e.g., t.Fatalf) so the test stops immediately on failure before
indexing descs, keeping the subsequent checks for descs[0] and descs[1] the
same; reference the variable descs and the DescribeIter-related assertions in
the test when making this change.
src/php/src/Api/Boot.php-247-262 (1)

247-262: ⚠️ Potential issue | 🟡 Minor

Potential duplicate OAuth route registration.

registerOAuthRoutes() may be invoked twice: once via onApiRoutes() (line 211) and again via registerFallbackApiRoutes() (line 260). Although the fallback path checks !Route::has('passport.token'), this guard only prevents duplication when the event-driven path runs first.

If registerFallbackApiRoutes() executes before the ApiRoutesRegistering event fires, the routes will be registered twice, potentially causing route conflicts.

🛡️ Suggested guard in onApiRoutes
         if (class_exists(Passport::class)) {
-            $event->routes(fn () => $this->registerOAuthRoutes());
+            $event->routes(fn () => Route::has('passport.token') ? null : $this->registerOAuthRoutes());
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Boot.php` around lines 247 - 262, The onApiRoutes() path can
call registerOAuthRoutes() duplicate to registerFallbackApiRoutes(); add the
same existence guard used in registerFallbackApiRoutes() so onApiRoutes() checks
Route::has('passport.token') (and Passport::class existence) before calling
registerOAuthRoutes(), or otherwise make registerOAuthRoutes() idempotent;
update the onApiRoutes() method to bail out if the 'passport.token' route
already exists to prevent double registration.
src/php/src/Api/Models/WebhookDelivery.php-199-220 (1)

199-220: ⚠️ Potential issue | 🟡 Minor

Accessor may throw JsonException during model hydration.

getPayloadAttribute() uses JSON_THROW_ON_ERROR (line 214) which can throw JsonException when decoding malformed JSON. Since Eloquent accessors run during model hydration, a corrupted payload in the database could cause unexpected exceptions when loading any WebhookDelivery record.

Consider catching and handling the exception gracefully:

🛡️ Suggested defensive handling
     public function getPayloadAttribute(mixed $value): array
     {
         if (is_array($value)) {
             return $value;
         }

         if ($value === null || $value === '') {
             return [];
         }

         if (is_string($value)) {
-            $decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
-
-            return is_array($decoded) ? $decoded : [];
+            try {
+                $decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
+                return is_array($decoded) ? $decoded : [];
+            } catch (\JsonException $e) {
+                Log::warning('Webhook delivery payload decode failed', [
+                    'delivery_id' => $this->id ?? null,
+                    'error' => $e->getMessage(),
+                ]);
+                return [];
+            }
         }

         return (array) $value;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Models/WebhookDelivery.php` around lines 199 - 220, The
accessor getPayloadAttribute currently calls json_decode with
JSON_THROW_ON_ERROR which can throw JsonException during Eloquent model
hydration; wrap the json_decode call in a try/catch that catches JsonException
(and any relevant \JsonException) and returns an empty array (or a safe
fallback) instead of allowing the exception to bubble, so corrupted/malformed
payloads do not break model loading; keep the JSON_THROW_ON_ERROR usage inside
the try block (or remove it) and ensure the method still returns [] for
null/invalid payloads.
src/php/src/Api/Observers/SupportTicketReplyWebhookObserver.php-17-43 (1)

17-43: ⚠️ Potential issue | 🟡 Minor

Consider whether message field may contain sensitive data.

The webhook payload at line 37 includes 'message' => $reply->message. If support ticket messages can contain PII (emails, addresses, credentials shared by users), this could result in sensitive data being transmitted to external webhook endpoints.

Consider whether:

  1. The message should be truncated or redacted
  2. A message_preview with a character limit would be safer
  3. Webhook consumers should fetch the full message via authenticated API call
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/php/src/Api/Observers/SupportTicketReplyWebhookObserver.php` around lines
17 - 43, The webhook currently sends the full support reply text in
SupportTicketReplyWebhookObserver::created via webhookService->dispatch (the
'message' => $reply->message entry); change this to avoid leaking PII by
removing or redacting the full message and instead include a safe preview (e.g.
'message_preview' => mb_substr(strip_tags($reply->message), 0, 200) and/or a
boolean 'full_message_available' => false) so consumers must use an
authenticated API to fetch the full body; update the payload keys and docs
accordingly and ensure created_at/metadata remain unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4221779c-cdfa-493f-9570-3a98a60a4164

📥 Commits

Reviewing files that changed from the base of the PR and between 8e0d60c and 1520125.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • src/php/src/Website/.DS_Store is excluded by !**/.DS_Store
📒 Files selected for processing (231)
  • .gitignore
  • .gitleaksignore
  • CLAUDE.md
  • RFC.md
  • api.go
  • api_describable_test.go
  • api_renderable_test.go
  • api_test.go
  • authentik.go
  • authentik_integration_test.go
  • authentik_test.go
  • authz_test.go
  • bridge.go
  • bridge_internal_test.go
  • bridge_test.go
  • brotli.go
  • brotli_test.go
  • cache.go
  • cache_config_test.go
  • cache_control.go
  • cache_control_test.go
  • cache_test.go
  • chat_completions.go
  • chat_completions_internal_test.go
  • chat_completions_test.go
  • client.go
  • client_test.go
  • cmd/api/cmd.go
  • cmd/api/cmd_args.go
  • cmd/api/cmd_args_test.go
  • cmd/api/cmd_sdk.go
  • cmd/api/cmd_sdk_test.go
  • cmd/api/cmd_spec.go
  • cmd/api/cmd_spec_test.go
  • cmd/api/cmd_test.go
  • cmd/api/spec_builder.go
  • cmd/api/spec_groups_iter.go
  • codegen.go
  • codegen_test.go
  • composer.json
  • docs/architecture.md
  • docs/index.md
  • export.go
  • export_test.go
  • expvar_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
  • group_test.go
  • gzip_test.go
  • httpsign_test.go
  • i18n.go
  • i18n_test.go
  • location_test.go
  • middleware.go
  • middleware_test.go
  • modernization_test.go
  • openapi.go
  • openapi_test.go
  • options.go
  • pkg/provider/cache_control_example_test.go
  • pkg/provider/cache_control_test.go
  • pkg/provider/provider.go
  • pkg/provider/proxy.go
  • pkg/provider/proxy_test.go
  • pkg/provider/registry.go
  • pkg/provider/registry_test.go
  • pkg/stream/stream_group.go
  • pkg/stream/stream_group_example_test.go
  • pkg/stream/stream_group_test.go
  • pprof_test.go
  • ratelimit.go
  • ratelimit_internal_test.go
  • ratelimit_test.go
  • response_meta.go
  • response_meta_test.go
  • response_test.go
  • runtime_config_test.go
  • scripts/generate-sdks.sh
  • scripts/publish-sdks.sh
  • sdk-config/csharp.yaml
  • sdk-config/dart.yaml
  • sdk-config/go.yaml
  • sdk-config/java.yaml
  • sdk-config/kotlin.yaml
  • sdk-config/php.yaml
  • sdk-config/python.yaml
  • sdk-config/ruby.yaml
  • sdk-config/rust.yaml
  • sdk-config/swift.yaml
  • sdk-config/typescript.yaml
  • secure_test.go
  • servers.go
  • servers_test.go
  • sessions_test.go
  • slog_test.go
  • spec_builder_helper.go
  • spec_builder_helper_test.go
  • spec_registry_test.go
  • src/php/phpunit.xml
  • src/php/src/Api/Boot.php
  • src/php/src/Api/Concerns/HasApiResponses.php
  • src/php/src/Api/Concerns/ResolvesWorkspace.php
  • src/php/src/Api/Console/Commands/CleanupExpiredGracePeriods.php
  • src/php/src/Api/Controllers/Api/ApiKeyController.php
  • src/php/src/Api/Controllers/Api/AuthController.php
  • src/php/src/Api/Controllers/Api/BiolinkController.php
  • src/php/src/Api/Controllers/Api/Concerns/SerialisesWorkspaceResource.php
  • src/php/src/Api/Controllers/Api/EntitlementApiController.php
  • src/php/src/Api/Controllers/Api/LinkController.php
  • src/php/src/Api/Controllers/Api/PaymentMethodController.php
  • src/php/src/Api/Controllers/Api/QrCodeController.php
  • src/php/src/Api/Controllers/Api/SeoReportController.php
  • src/php/src/Api/Controllers/Api/TicketController.php
  • src/php/src/Api/Controllers/Api/UnifiedPixelController.php
  • src/php/src/Api/Controllers/Api/WebhookController.php
  • src/php/src/Api/Controllers/Api/WebhookSecretController.php
  • src/php/src/Api/Controllers/Api/WebhookTemplateController.php
  • src/php/src/Api/Controllers/Api/WorkspaceMemberController.php
  • src/php/src/Api/Controllers/McpApiController.php
  • src/php/src/Api/Database/Factories/ApiKeyFactory.php
  • src/php/src/Api/Documentation/Attributes/ApiResponse.php
  • src/php/src/Api/Documentation/DocumentationController.php
  • src/php/src/Api/Documentation/DocumentationServiceProvider.php
  • src/php/src/Api/Documentation/Extensions/ApiKeyAuthExtension.php
  • src/php/src/Api/Documentation/Extensions/RateLimitExtension.php
  • src/php/src/Api/Documentation/Extensions/SunsetExtension.php
  • src/php/src/Api/Documentation/Middleware/ProtectDocumentation.php
  • src/php/src/Api/Documentation/OpenApiBuilder.php
  • src/php/src/Api/Documentation/Routes/docs.php
  • src/php/src/Api/Documentation/config.php
  • src/php/src/Api/Guards/AccessTokenGuard.php
  • src/php/src/Api/Jobs/DeliverWebhookJob.php
  • src/php/src/Api/Listeners/DispatchSubscriptionWebhookEvents.php
  • src/php/src/Api/Middleware/ApiCacheControl.php
  • src/php/src/Api/Middleware/AuthenticateApiKey.php
  • src/php/src/Api/Middleware/RateLimitApi.php
  • src/php/src/Api/Migrations/2026_04_15_000000_create_api_resource_tables.php
  • src/php/src/Api/Migrations/2026_04_15_000001_create_support_ticket_tables.php
  • src/php/src/Api/Models/ApiKey.php
  • src/php/src/Api/Models/Biolink.php
  • src/php/src/Api/Models/Concerns/BelongsToWorkspace.php
  • src/php/src/Api/Models/Link.php
  • src/php/src/Api/Models/QrCode.php
  • src/php/src/Api/Models/SupportTicket.php
  • src/php/src/Api/Models/SupportTicketReply.php
  • src/php/src/Api/Models/WebhookDelivery.php
  • src/php/src/Api/Models/WebhookEndpoint.php
  • src/php/src/Api/Observers/BiolinkWebhookObserver.php
  • src/php/src/Api/Observers/LinkWebhookObserver.php
  • src/php/src/Api/Observers/SupportTicketReplyWebhookObserver.php
  • src/php/src/Api/Observers/SupportTicketWebhookObserver.php
  • src/php/src/Api/Observers/WorkspaceWebhookObserver.php
  • src/php/src/Api/RateLimit/RateLimitService.php
  • src/php/src/Api/Resources/ApiKeyResource.php
  • src/php/src/Api/Routes/admin.php
  • src/php/src/Api/Routes/api.php
  • src/php/src/Api/Services/ApiKeyService.php
  • src/php/src/Api/Services/SeoReportService.php
  • src/php/src/Api/Services/WebhookService.php
  • src/php/src/Api/Services/WebhookTemplateService.php
  • src/php/src/Api/Tests/Feature/ApiKeyRotationTest.php
  • src/php/src/Api/Tests/Feature/ApiKeySecurityTest.php
  • src/php/src/Api/Tests/Feature/ApiKeyTest.php
  • src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php
  • src/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php
  • src/php/src/Api/Tests/Feature/DocumentationControllerTest.php
  • src/php/src/Api/Tests/Feature/DocumentationStoplightTest.php
  • src/php/src/Api/Tests/Feature/McpApiControllerTest.php
  • src/php/src/Api/Tests/Feature/McpResourceTest.php
  • src/php/src/Api/Tests/Feature/McpServerAccessTest.php
  • src/php/src/Api/Tests/Feature/McpServerDetailTest.php
  • src/php/src/Api/Tests/Feature/RateLimitTest.php
  • src/php/src/Api/Tests/Feature/RateLimitingTest.php
  • src/php/src/Api/Tests/Feature/SeoReportEndpointTest.php
  • src/php/src/Api/Tests/Feature/SeoReportServiceTest.php
  • src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php
  • src/php/src/Api/Tests/Feature/WebhookEndpointTest.php
  • src/php/src/Api/Tests/Feature/WebhookTemplateServiceTest.php
  • src/php/src/Api/config.php
  • src/php/src/Front/Api/Middleware/ApiSunset.php
  • src/php/src/Front/Api/Middleware/ApiVersion.php
  • src/php/src/Front/Api/VersionedRoutes.php
  • src/php/src/Front/Api/config.php
  • src/php/src/Website/Api/Controllers/DocsController.php
  • src/php/src/Website/Api/Routes/web.php
  • src/php/src/Website/Api/View/Blade/changelog.blade.php
  • src/php/src/Website/Api/View/Blade/guides/authentication.blade.php
  • src/php/src/Website/Api/View/Blade/guides/index.blade.php
  • src/php/src/Website/Api/View/Blade/guides/quickstart.blade.php
  • src/php/src/Website/Api/View/Blade/guides/rate-limits.blade.php
  • src/php/src/Website/Api/View/Blade/index.blade.php
  • src/php/src/Website/Api/View/Blade/sdks.blade.php
  • src/php/tests/Feature/ApiSunsetTest.php
  • src/php/tests/Feature/ApiVersionParsingTest.php
  • src/php/tests/Feature/ApiVersionServiceTest.php
  • src/php/tests/Feature/AuthenticationGuideTest.php
  • src/php/tests/Feature/DocsControllerTest.php
  • src/php/tests/Feature/VersionedRoutesTest.php
  • sse.go
  • sse_internal_test.go
  • sse_test.go
  • ssrf_guard.go
  • ssrf_guard_internal_test.go
  • static_test.go
  • sunset.go
  • sunset_internal_test.go
  • sunset_test.go
  • swagger.go
  • swagger_test.go
  • tests/Pest.php
  • tests/cli/api/Taskfile.yaml
  • timeout_test.go
  • tracing.go
  • tracing_test.go
  • transport.go
  • transport_client.go
  • transport_client_test.go
  • transport_test.go
  • webhook.go
  • webhook_internal_test.go
  • webhook_test.go
  • websocket.go
  • websocket_internal_test.go
  • websocket_test.go
💤 Files with no reviewable changes (4)
  • go-io/go.mod
  • go-log/error.go
  • go-io/local.go
  • go-log/go.mod

Comment thread sdk-config/go.yaml Outdated
outputDir: ./sdks/go
additionalProperties:
packageName: core
moduleName: github.com/dappcore/core-go
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== SDK config values =="
rg -n "^(generatorName|outputDir|additionalProperties|\\s+packageName|\\s+moduleName):" sdk-config/go.yaml -n -C1

echo
echo "== Cross-repo references to module names =="
rg -n "github.com/dappcore/core-go|dappco\\.re/go/api|dappco\\.re/go/" -C2

echo
echo "== Docs mentioning Go SDK module/import path =="
fd -i "README*" . | xargs -r rg -n "Go SDK|moduleName|import|core-go|dappco\\.re/go" -C2

Repository: dAppCore/api

Length of output: 18677


Update Go SDK moduleName to match namespace migration to dappco.re/go.

The moduleName: github.com/dappcore/core-go in sdk-config/go.yaml conflicts with the codebase's migration to the dappco.re/go/* namespace. The repository's own go.mod declares module dappco.re/go/api, and all internal imports throughout the codebase use dappco.re/go/api and dappco.re/go/core. Update moduleName to dappco.re/go/api (or the appropriate generated SDK module name) to maintain consistency and ensure generated imports align with the current namespace strategy.

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

In `@sdk-config/go.yaml` at line 5, Update the Go SDK moduleName field in
sdk-config/go.yaml from "github.com/dappcore/core-go" to the current namespace
used by the repo (e.g. "dappco.re/go/api") so generated SDK imports match the
codebase; modify the moduleName entry value to "dappco.re/go/api" (or the
appropriate generated module name) ensuring alignment with the project's go.mod
module declaration and internal imports.

Comment thread src/php/src/Api/Controllers/Api/TicketController.php Outdated
Snider and others added 3 commits April 25, 2026 02:39
…close Mantis #931

Both workspace and user resolution returning null fell through to an unscoped SupportTicket::find($id), allowing any actor whose request context fails to resolve to read or reply to ANY ticket by ID enumeration (CWE-639 IDOR, REACHABLE-CRITICAL per Cerberus DREAD).

Fix per Cerberus's recommendation:
- Throw AuthorizationException (not silent null) when both contexts null
- Log fail-open attempts with actor IP for ops alerting
- Keep existing scoped-lookup logic intact when context resolves

Test triad:
- AnonymousAccess_Bad — anonymous request returns 401, not a ticket
- FailOpenAttempt_Ugly — log.warning fires with actor_ip + ticket_id
- AuthenticatedUser_Good — legitimate access still succeeds

Filer: CodeRabbit (PR #3 dAppCore/api)
DREAD review: Cerberus (Mantis #931 verdict REACHABLE-CRITICAL)

Co-Authored-By: CodeRabbit <noreply@coderabbit.ai>
Co-Authored-By: Cerberus <cerberus@lthn.ai>
Co-Authored-By: Athena <athena@lthn.ai>
Co-Authored-By: Virgil <virgil@lethean.io>
…pace

The Go SDK codegen config still pointed at the pre-migration
github.com/dappcore/core-go module path. CodeRabbit (PR #3, Mantis
#932) flagged it as the only sdk-config/* yaml that wasn't updated
during the workspace-wide module rename to dappco.re/go.

Pattern matches the api repo's own go.mod (`module dappco.re/go/api`)
and the Java/TypeScript SDK configs that already use the
dappco.re/re.dappco namespaces. SDK consumers will get a Go module
declaration that resolves under the canonical dappco.re proxy.

Closes Mantis #932 on PR #3 dAppCore/api.

Co-authored-by: Cerberus <cerberus@lthn.ai>
Co-authored-by: Hephaestus <hephaestus@lthn.ai>
Main moved forward independently while the release branch sat
since the v0.8.0-alpha.1 cut. Main carries the canonical:
  - flat namespace `dappco.re/go` (release used `dappco.re/go/core`)
  - reorganised `go/`, `go/pkg/provider/`, `php/` paths
                (release was still on `./`, `pkg/provider/`, `src/php/`)
  - own SSRF guard + Mantis #931 IDOR fix at higher coverage
  - sonar BLOCKER clears (#1287) + CI infra (#1217)
  - service.go consolidation (#1336)

Conflict resolution policy: accept main's version where main's
work is at-or-ahead of release's parallel implementation
(security hardening, namespace, directory layout). Accept the
deletions main made (proxy.go etc — moved/refactored). Release's
genuinely-new test files moved to their canonical post-reorg
paths.

28 conflicts resolved:
  4 modify/delete (release's mods to files main deleted) → accept delete
  4 add/add        (same path, different content) → main's namespace
  1 add unresolved (path rename) → file added at new path
 19 both modified  (mostly namespace + dir migration) → main's version
@Snider Snider merged commit 3cd181b into main May 13, 2026
0 of 3 checks passed
@Snider Snider deleted the release/v0.8.0-alpha.1 branch May 13, 2026 13:07
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
62.7% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

Snider added a commit that referenced this pull request May 13, 2026
The Engine builds gin's NoRoute hook from a new noRouteHandler
field, set via WithNoRoute(h gin.HandlerFunc). Mounted after every
explicit route + group so it never shadows real handlers; nil
preserves gin's default 404 behaviour (back-compat with pre-option
callers).

Use case: lthn/desktop and lthn.ai-hosted Engines both serve an
SPA whose client-router owns the URL space. Today they wrap the
gin engine via Handler() and call NoRoute() directly, which means
they bypass any auth/SSRF/cache middleware composed into the
Engine. With WithNoRoute the SPA fallback becomes part of the
canonical Engine build and inherits the middleware chain.

go:
- pkg/api: Engine gains `noRouteHandler gin.HandlerFunc`
- new option WithNoRoute(h) sets it
- build() registers it via r.NoRoute(...) when non-nil
- TestWithNoRoute_{Good,Bad,Ugly} AX-7 triplet
- ExampleWithNoRoute() showing the SPA-host pattern

Also dedupes accidentally-duplicated TestSDKGenerator_Generate_
PackageName{Accepted_Good,Rejected_Bad} from the PR #3 merge
(two identical copies of the same two tests at 199-269 and
270-339 caused the test binary to fail compile).
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.

2 participants