Skip to content

docs(agent): document structured event schemas and add validation tests - #479

Open
samueldoo wants to merge 3 commits into
Stellar-Ecosystem:mainfrom
samueldoo:365-agent-event-schema
Open

docs(agent): document structured event schemas and add validation tests#479
samueldoo wants to merge 3 commits into
Stellar-Ecosystem:mainfrom
samueldoo:365-agent-event-schema

Conversation

@samueldoo

@samueldoo samueldoo commented Jul 29, 2026

Copy link
Copy Markdown

Closes #365

Summary

This PR establishes clear field schemas and contracts for all canonical events emitted by the agent (agent/agent.js), making it possible to build reliable log consumers and telemetry integrations.

Key Changes

  1. Event Schema Documentation (agent/README.md):

    • Documented field structures, optional vs. required properties, and JSON payload examples for all 10 events in EVENT:
      • agent_start
      • agent_registered
      • task_start
      • service_selected
      • spend_check_passed
      • spend_check_blocked
      • payment_success
      • payment_failed
      • score_updated
      • agent_complete
    • Included a complete, runnable sample log-consumer snippet showing how to stream and handle events.
  2. Automated Schema Validation (agent/agent.test.js):

    • Added a unit test suite that asserts emitted logger payloads match their documented schemas (checking required fields and property types) and verifies all canonical events are accounted for in the schema registry.

Verification

  • Ran npx vitest run agent.test.js — all 29 tests passed cleanly.

Summary by CodeRabbit

  • Security

    • Added protections against unsafe outbound requests, including private-network targets, metadata services, DNS rebinding, and unsafe redirects.
    • Restricted endpoint access to HTTPS and approved domains.
  • Documentation

    • Added comprehensive API documentation available at /openapi.json.
    • Documented structured agent lifecycle and payment events.
  • Testing

    • Added validation coverage for request security, redirects, event logging, and API response schemas.

- Add comprehensive OpenAPI 3.1 spec covering all routes in registry.js, agents.js, services.js, and demo.js
- Serve OpenAPI spec at /openapi.json endpoint
- Add contract tests to validate API responses against OpenAPI spec
- Install @apidevtools/swagger-parser for spec validation
- Document all request/response schemas with proper types and validation
- Add url-validator.js with comprehensive URL validation
- Block non-HTTPS endpoints
- Block private, loopback, and link-local IP addresses (IPv4 and IPv6)
- Resolve DNS and block endpoints that resolve to private IPs
- Add configurable ALLOWED_ENDPOINTS allowlist for local development
- Validate redirects to prevent SSRF via redirect chains
- Integrate validation into agent.js HTTP client
- Add comprehensive test suite covering SSRF attack scenarios
- Tests cover AWS metadata service, localhost, and DNS rebinding attacks
@drips-wave

drips-wave Bot commented Jul 29, 2026

Copy link
Copy Markdown

@samueldoo Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR documents and tests agent event schemas, adds SSRF protections to agent HTTP requests, and introduces a served OpenAPI 3.1 specification with contract tests for backend routes and schemas.

Changes

Agent event contracts

Layer / File(s) Summary
Document and validate agent events
agent/README.md, agent/agent.test.js
Documents canonical event fields and examples, provides a log consumer, and validates emitted event fields and types during full execution.

SSRF-protected agent requests

Layer / File(s) Summary
URL and IP validation
agent/url-validator.js
Adds HTTPS enforcement, private IP detection, DNS validation, endpoint allowlisting, and redirect validation.
Safe fetch integration
agent/url-validator.js, agent/agent.js
Adds manual redirect handling and routes probe and payment requests through validated fetch calls.
SSRF scenario coverage
agent/url-validator.test.js
Tests private addresses, metadata services, redirects, allowlists, internal networks, and DNS rebinding.

OpenAPI contract

Layer / File(s) Summary
Define API paths and schemas
backend/openapi.json
Adds the OpenAPI 3.1 document with backend paths and reusable request and response schemas.
Serve the OpenAPI document
backend/src/index.js, backend/package.json
Adds GET /openapi.json and the Swagger parser development dependency.
Validate the published contract
backend/test/openapi.test.js
Checks specification structure, required paths and schemas, published JSON, and selected endpoint responses.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Agent as agent.js
  participant SafeFetch as safeFetch
  participant Validator as URL validator
  participant Endpoint as Remote endpoint
  Agent->>SafeFetch: Send probe or payment request
  SafeFetch->>Validator: Validate target URL
  Validator-->>SafeFetch: Return validation result
  SafeFetch->>Endpoint: Fetch with manual redirects
  Endpoint-->>SafeFetch: Return response or redirect
  SafeFetch->>Validator: Validate redirect target
  SafeFetch-->>Agent: Return validated response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also adds backend OpenAPI files, a new /openapi.json endpoint, and tests unrelated to the agent schema issue. Split the backend OpenAPI/spec endpoint work into a separate PR, or remove it from this change so the diff stays focused on issue #365.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the agent docs and validation test changes.
Linked Issues check ✅ Passed The PR documents all canonical agent event schemas, adds validation tests, and includes a consumer snippet as requested.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 13

🧹 Nitpick comments (3)
backend/test/openapi.test.js (1)

124-177: 🗄️ Data Integrity & Integration | 🔵 Trivial | 🏗️ Heavy lift

Consider validating bodies against the spec schemas instead of property presence.

These assertions only check key presence, so type/enum/required drift in openapi.json goes undetected. Compiling the resolved component schemas with Ajv (2020-12 for 3.1) against response.body would make the contract tests actually enforce the contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/test/openapi.test.js` around lines 124 - 177, Replace the
property-presence checks in the Response Schema Validation tests with actual
validation of each response.body against its corresponding resolved OpenAPI
component schema. Configure Ajv for OpenAPI 3.1’s JSON Schema 2020-12 dialect,
compile HealthResponse, ServicesListResponse, AgentsListResponse, RegistryStats,
and Error schemas, and assert validation succeeds while preserving the existing
status-code and conditional agents behavior.
backend/src/index.js (1)

93-106: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Load the OpenAPI spec once at startup once the runtime version can be ensured.

backend/package.json declares node >=22.0.0, so the with: { type: "json" } import syntax is supported; if the deployed runtime can be locked to >=22.12.0, loading ../openapi.json once from backend/openapi.json and serving the cached spec avoids runtime exception handling and async per-request work.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/index.js` around lines 93 - 106, Update the startup
initialization and the /openapi.json handler to load ../openapi.json once after
confirming the runtime meets the required Node version, cache its default
export, and serve the cached spec synchronously. Remove the per-request dynamic
import and its associated runtime exception handling while preserving the
existing JSON response behavior.
agent/agent.js (1)

195-209: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Redundant SSRF validation — safeFetch already re-validates the same URL.

createSafeFetch (Line 199 in url-validator.js) calls validateEndpointUrl internally before every request. The manual validateEndpointUrl call here duplicates that check (and its DNS lookup) before safeFetch is even invoked at Line 209, adding latency and duplicated error-handling logic without extra protection (the inner check runs again regardless).

♻️ Suggested simplification
   const safeFetch = createSafeFetch(fetch);

   httpClient.fetch = async (url, init = {}) => {
-    // Validate endpoint URL before making any request
-    const validation = await validateEndpointUrl(url);
-    if (!validation.valid) {
-      logger.warn({ url, reason: validation.reason }, 'Endpoint URL blocked by SSRF protection');
-      const err = new Error(validation.reason);
-      err.code = 'SSRF_BLOCKED';
-      throw err;
-    }
-
-    const probe = await safeFetch(url, init);
+    const probe = await safeFetch(url, init).catch((err) => {
+      logger.warn({ url, reason: err.message }, 'Endpoint URL blocked by SSRF protection');
+      throw err;
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/agent.js` around lines 195 - 209, Remove the manual validateEndpointUrl
call and its duplicate SSRF_BLOCKED warning/error handling from the
httpClient.fetch wrapper. Rely on safeFetch, created via createSafeFetch, to
perform endpoint validation before each request, while preserving the existing
request flow and error propagation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@agent/agent.test.js`:
- Around line 601-626: Update validateEvent to enforce the schema’s allowed
fields by combining schema.required and schema.optional, rejecting payload
fields outside that set. Preserve required-field and type validation, and
explicitly include the documented logger metadata fields in the permitted set
where applicable.
- Around line 590-593: Update the AGENT_COMPLETE schema in agent/agent.test.js
so finalScore and scoreDelta accept number or null, and add a disabled-scoring
test exercising the emitted null values. Update the corresponding AGENT_COMPLETE
example and field documentation in agent/README.md to describe these fields as
number | null.

In `@agent/README.md`:
- Around line 323-340: Update the README JSON log example to consume raw log
lines from an actual readable stream or Pino destination instead of accessing
logger.stream from the pino-pretty transport. Ensure the input remains
newline-delimited JSON before passing each line to JSON.parse, while keeping the
existing log-processing flow intact.

In `@agent/url-validator.js`:
- Line 137: Replace substring-based allowlist checks in the hostname validation
logic and validateRedirect with exact-domain or subdomain-boundary matching:
accept the allowlisted hostname itself or a hostname ending with "." plus the
allowlisted domain, while rejecting attacker-controlled suffix and prefix
variants.
- Around line 150-162: Update validateEndpointUrl and createSafeFetch so DNS
resolution is performed once and the validated address is pinned to the actual
HTTP connection. Reject any private or unsafe resolved IP, then configure the
fetch agent/lookup or request target to use that validated address without
resolving the hostname again, preserving the existing validation failure
responses.
- Around line 10-16: Add the 0.0.0.0/8 network to PRIVATE_IP_RANGES, preserving
the existing range representation and ordering conventions so unspecified or
local-host addresses are treated as private.
- Line 134: Update the hostname handling in the URL validation flow to remove
the surrounding brackets from IPv6 literals before passing the value to
net.isIP() or DNS checks. Preserve unbracketed hostnames and non-IPv6 behavior,
so valid public IPv6 URLs bypass DNS resolution and are validated normally.
- Around line 196-235: Update createSafeFetch to track the number of redirects
followed and stop with an error once the configured maximum is reached, using a
standard cap such as 20. Propagate the incremented redirect count through the
recursive redirect call while preserving URL validation and normal response
handling.
- Around line 18-23: Fix ipv6ToBytes so a compressed “::” expands to the correct
number of zero hextets and does not advance the output index for each empty
split segment; verify leading, trailing, and embedded compression parses into
the exact 16-byte address. Extend IPv6 private-address validation to recognize
IPv4-mapped forms such as ::ffff:169.254.169.254 and ::ffff:127.0.0.1, either by
adding the mapped private range or routing the embedded IPv4 value through the
existing IPv4 private-range logic.

In `@agent/url-validator.test.js`:
- Around line 1-2: Update the dns/promises mock used by the URL validator tests
to create a hoisted resolver shared with the test lifecycle, expose it through
the mock’s default export with resolve, and reset it in beforeEach so
DNS-specific return values do not leak into other tests. Anchor the changes to
the dns/promises vi.mock setup and existing beforeEach/afterEach hooks while
preserving the current URL assertions.

In `@backend/openapi.json`:
- Around line 1975-1976: The OpenAPI document duplicates the policy path,
causing the later entry to overwrite GET, while the test list also masks the
issue. In backend/openapi.json lines 1975-1976, remove the duplicate path item
and merge its put operation into the existing /api/agents/{address}/policy item
containing get; in backend/test/openapi.test.js lines 44-59, remove the
duplicate requiredPaths entry and assert that this path exposes both get and put
operations.
- Around line 1262-1273: Move every operation-level X-Idempotency-Key entry from
the invalid headers arrays into each operation’s existing parameters array,
adding "in": "header" while preserving required, description, and schema
constraints. Apply this consistently to all five referenced operations and
remove the unsupported headers fields.

In `@backend/test/openapi.test.js`:
- Line 12: Update the SwaggerParser.validate call in the test setup to resolve
openapi.json relative to the test module URL rather than process.cwd(). Use the
module URL with the appropriate path-to-filesystem conversion so beforeAll loads
the package’s OpenAPI specification reliably.

---

Nitpick comments:
In `@agent/agent.js`:
- Around line 195-209: Remove the manual validateEndpointUrl call and its
duplicate SSRF_BLOCKED warning/error handling from the httpClient.fetch wrapper.
Rely on safeFetch, created via createSafeFetch, to perform endpoint validation
before each request, while preserving the existing request flow and error
propagation.

In `@backend/src/index.js`:
- Around line 93-106: Update the startup initialization and the /openapi.json
handler to load ../openapi.json once after confirming the runtime meets the
required Node version, cache its default export, and serve the cached spec
synchronously. Remove the per-request dynamic import and its associated runtime
exception handling while preserving the existing JSON response behavior.

In `@backend/test/openapi.test.js`:
- Around line 124-177: Replace the property-presence checks in the Response
Schema Validation tests with actual validation of each response.body against its
corresponding resolved OpenAPI component schema. Configure Ajv for OpenAPI 3.1’s
JSON Schema 2020-12 dialect, compile HealthResponse, ServicesListResponse,
AgentsListResponse, RegistryStats, and Error schemas, and assert validation
succeeds while preserving the existing status-code and conditional agents
behavior.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4045c786-c9e7-4f22-a63a-ffa2c7bd6932

📥 Commits

Reviewing files that changed from the base of the PR and between a3fd824 and db28656.

⛔ Files ignored due to path filters (1)
  • backend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • agent/README.md
  • agent/agent.js
  • agent/agent.test.js
  • agent/url-validator.js
  • agent/url-validator.test.js
  • backend/openapi.json
  • backend/package.json
  • backend/src/index.js
  • backend/test/openapi.test.js

Comment thread agent/agent.test.js
Comment on lines +590 to +593
[EVENT.AGENT_COMPLETE]: {
required: ['event', 'agentAddress', 'totalTasks', 'successCount', 'failCount', 'totalUsdcSpent', 'runDurationMs'],
optional: ['finalScore', 'scoreDelta'],
types: { event: 'string', agentAddress: 'string', totalTasks: 'number', successCount: 'number', failCount: 'number', totalUsdcSpent: 'string', runDurationMs: 'number', finalScore: 'number', scoreDelta: 'number' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)agent/(agent\.(js|test\.js)|README\.md)$' || true

echo "== relevant snippets =="
for f in agent/agent.js agent/agent.test.js agent/README.md; do
  if [ -f "$f" ]; then
    echo "--- $f ($(wc -l < "$f") lines) ---"
  fi
done

echo "--- agent/agent.js outline/score mentions ---"
rg -n "scoreDelta|finalScore|score|AGENT_COMPLETE|eventAddress|agentAddress" agent/agent.js || true

echo "--- agent/agent.test.js lines 560-610 ---"
sed -n '560,610p' agent/agent.test.js || true

echo "--- agent/README.md lines 340-370 ---"
sed -n '340,370p' agent/README.md || true

echo "== behavioral parse contract evidence =="
python3 - <<'PY'
from pathlib import Path
import re
p = Path('agent/agent.test.js')
s = p.read_text()
needle = r'EVENT\.AGENT_COMPLETE:[\s\S]*?types:\s*{[^}]*}'
m = re.search(needle, s)
print(m.group(0) if m else "AGENT_COMPLETE schema not found")
# Extract declared optional keys/types if possible from the printed schema.
for field in ['finalScore','scoreDelta']:
    print(field, "required:", "required:", bool(re.search(r"required:\s*\[[^\]]*\b"+re.escape(field)+r"\b", s)), "optional:", bool(re.search(r"optional:\s*\[[^\]]*\b"+re.escape(field)+r"\b", s)), "types:", "string" in re.search(r"types:\s*{[^}]*}", s).group(0) if re.search(r"types:\s*{[^}]*}", s) else False)
PY

Repository: Stellar-Ecosystem/lodestar

Length of output: 6244


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- agent.js lines 380-475 ---"
sed -n '380,475p' agent/agent.js

echo "--- agent.test.js validation helper lines 610-650 ---"
sed -n '610,650p' agent/agent.test.js

echo "--- precise occurrences of finalScore/scoreDelta/null checks (docs/tests/code) ---"
rg -n "finalScore|scoreDelta|scoreBefore|scoreAfter|scoreAfterRegistration|scoringEnabled" agent/agent.js agent/agent.test.js agent/README.md

echo "== emit null absent semantics probe =="
python3 - <<'PY'
from pathlib import Path
p = Path('agent/agent.test.js')
text = p.read_text()
m_start = text.index('const schemas = {')
m_end = text.index('};', m_start)
schema_text = text[m_start:m_end+1]
assert 'finalScore' in schema_text and 'scoreDelta' in schema_text
types = 'types: {'
start = schema_text.index(types)
types = schema_text[start:schema_text.index('}', start)]
print('AGENT_COMPLETE optional includes both:', "optional: ['finalScore', 'scoreDelta']" in schema_text)
print('types contains finalScore number:', 'finalScore: \'number\'' in types)
print('types contains scoreDelta number:', 'scoreDelta: \'number\'' in types)
schema_block = schema_text[start:]
print('no null type union in schema text:', 'null' not in schema_block)

# Inspect implementation around AGENT_COMPLETE construction for whether fields exist when score is null.
src = Path('agent/agent.js').read_text()
idx = src.index('const finalScore = currentScore;')
block = src[idx:idx+350]
print('implementation always includes finalScore key:', 'finalScore,' in block)
print('implementation always includes scoreDelta key:', 'scoreDelta,' in block)
PY

Repository: Stellar-Ecosystem/lodestar

Length of output: 9815


🌐 Web query:

Stellar Ecosystem Agent credit contract score null disable scoring

💡 Result:

There is no single "Stellar Ecosystem Agent" credit contract with a standardized "null disable" scoring function, as credit scoring on the Stellar network is implemented through various independent, custom Soroban smart contracts [1][2][3][4]. Because these are decentralized applications, the mechanisms for handling scores, resetting them, or disabling them depend entirely on the specific implementation of the contract you are using [1][5][6]. In the context of Soroban smart contracts on Stellar: 1. Absence of a Standardized API: Scoring logic is not a native network protocol feature but a contract-level implementation [1][2][4]. You must refer to the specific documentation or source code of the contract involved [1][6]. 2. Handling "Null" or "Disabled" Scores: - Many implementations treat a "null" or missing state as a baseline or default value (e.g., a neutral score of 40 or 500) rather than an error state [7][2]. - If a contract provides a way to "disable" scoring, it is typically handled via administrative functions (e.g., deregister_feeder or reset_project) or role-based access control where an admin can revoke a status [1][5][4]. - If you are attempting to programmatically handle an account with no recorded score, common practices include checking for the presence of the record in the contract's data storage before attempting a calculation [7][5]. 3. Security Considerations: - Always ensure that score modifications (like recording payments or resetting status) are protected by proper authorization checks (require_auth()) [5][3][6]. Improperly implemented contracts can allow unauthorized entities to manipulate scores [5]. To resolve your specific issue, you should: - Identify the specific repository or contract address you are interacting with [6]. - Review the lib.rs or contract.rs file within that project to see how the ScoreRecord or equivalent data structure is defined and if there is an explicit disable or reset function provided for administrators [1][5][4]. If you are developing your own contract and want to implement a "disable" feature, you should define a status flag (e.g., enum Status { Active, Disabled }) within your storage struct and add an administrative function that flips this flag, causing subsequent get_score calls to return a null or default value [1][4].

Citations:


Align optional score fields with null emission.

AGENT_COMPLETE always emits finalScore and scoreDelta, and the README example shows them as null when scoring is unavailable. The test schema rejects those nullable values as non-numbers, so the disabled-scoring path validates incorrectly. Add a disabled-scoring exercise to agent/agent.test.js and update finalScore/scoreDelta in the AGENT_COMPLETE schema and API docs to allow number | null.

📍 Affects 2 files
  • agent/agent.test.js#L590-L593 (this comment)
  • agent/README.md#L355-L360
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/agent.test.js` around lines 590 - 593, Update the AGENT_COMPLETE schema
in agent/agent.test.js so finalScore and scoreDelta accept number or null, and
add a disabled-scoring test exercising the emitted null values. Update the
corresponding AGENT_COMPLETE example and field documentation in agent/README.md
to describe these fields as number | null.

Comment thread agent/agent.test.js
Comment on lines +601 to +626
const validateEvent = (fields, eventName) => {
const schema = schemas[eventName];
if (!schema) {
throw new Error(`No schema defined for event: ${eventName}`);
}

// Check required fields
for (const field of schema.required) {
if (!(field in fields)) {
throw new Error(`Event ${eventName} missing required field: ${field}`);
}
}

// Check field types
for (const [field, expectedType] of Object.entries(schema.types)) {
if (field in fields) {
const actualType = typeof fields[field];
if (actualType !== expectedType) {
throw new Error(`Event ${eventName} field ${field} has type ${actualType}, expected ${expectedType}`);
}
}
}

// Ensure no unexpected fields (optional: can be strict or lenient)
// For now, we allow extra fields for flexibility
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Enforce the documented allowed-field set.

schema.optional is never used, and extra fields are explicitly allowed. Consequently, an undocumented field—or a field omitted from both required and types—will not fail this contract test. Validate each payload against required ∪ optional and explicitly handle any permitted logger metadata.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/agent.test.js` around lines 601 - 626, Update validateEvent to enforce
the schema’s allowed fields by combining schema.required and schema.optional,
rejecting payload fields outside that set. Preserve required-field and type
validation, and explicitly include the documented logger metadata fields in the
permitted set where applicable.

Comment thread agent/README.md
Comment on lines +323 to +340
```javascript
import pino from 'pino';
import { EVENT } from './agent.js';

// Create a pino instance that reads from a log file or stream
const logger = pino({
level: 'info',
transport: { target: 'pino-pretty', options: { colorize: true } },
});

// Or read from an existing log file:
// const logStream = fs.createReadStream('./agent.log');
// const logger = pino(logStream);

// Process logs
logger.stream.on('data', (line) => {
try {
const parsed = JSON.parse(line);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -a 'package.json' -x rg -n '"pino"|"pino-pretty"' {}
rg -n 'logger\.stream|pino-pretty|JSON\.parse|createReadStream' agent/README.md

Repository: Stellar-Ecosystem/lodestar

Length of output: 278


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "README context:"
sed -n '300,360p' agent/README.md || true

echo
echo "Search agent docs for pino/pretty/log examples:"
rg -n "pino|pino-pretty|logger\.stream|createReadStream|readline|JSON\.parse" agent/README.md || true

echo
echo "package files mentioning pino:"
fd -a 'package.json' -x sh -c 'echo "--- $1"; rg -n "\"pino\"|\"pino-pretty\"" "$1" || true' sh {}

Repository: Stellar-Ecosystem/lodestar

Length of output: 2652


🌐 Web query:

Pino API logger stream pino-pretty format readable stream

💡 Result:

To format Pino logs into a readable stream using pino-pretty, you have two primary approaches: using the modern Transport API or using the legacy stream-based approach. The Transport API is the recommended method for most applications. 1. Transport API (Recommended) This method runs the prettifier in a separate worker thread, which is more performant and follows modern Pino best practices. const pino = require('pino'); const logger = pino({ transport: { target: 'pino-pretty', options: { colorize: true // Add other pino-pretty options here } } }); logger.info('This will be pretty-printed'); 2. Stream-based Approach This approach processes logs within the main thread. While still functional, it is generally intended for development environments. const pino = require('pino'); const pretty = require('pino-pretty'); // Create a readable stream const stream = pretty({ colorize: true }); const logger = pino(stream); logger.info('This will also be pretty-printed'); Advanced Optimization: Metadata Wrapper For improved performance when using the stream-based approach, you can use the asMetaWrapper method. This allows pino-pretty to work directly with Pino's log objects, skipping expensive JSON parsing. const pino = require('pino'); const pretty = require('pino-pretty'); const prettyStream = pretty({ colorize: true }); // Use asMetaWrapper to accept the writable stream const dest = prettyStream.asMetaWrapper(process.stdout); const logger = pino({}, dest); Key Considerations - Performance: Using pino-pretty in production is generally discouraged due to the overhead of prettifying logs [1][2]. - Flush Limitations: The logger.flush method is not supported when using pino-pretty [2][3]. - Development Setup: A common pattern is to only enable the transport when running in a TTY environment: const transport = process.stdout.isTTY? { transport: { target: 'pino-pretty' } }: {}; const logger = pino({...transport });

Citations:


Use a raw line reader for the JSON log example.

pino { transport: { target: 'pino-pretty' } } does not attach Pino’s readable destination to logger.stream; pino-pretty formats output instead. This sample can throw when accessing logger.stream, and JSON.parse(line) cannot parse formatted output.

Proposed consumer shape
-import pino from 'pino';
+import fs from 'node:fs';
+import readline from 'node:readline';

-const logger = pino({
-  level: 'info',
-  transport: { target: 'pino-pretty', options: { colorize: true } },
-});
+const input = fs.createReadStream('./agent.log', { encoding: 'utf8' });
+const lines = readline.createInterface({ input, crlfDelay: Infinity });

-logger.stream.on('data', (line) => {
+for await (const line of lines) {
   const parsed = JSON.parse(line);
   // process parsed event
-});
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```javascript
import pino from 'pino';
import { EVENT } from './agent.js';
// Create a pino instance that reads from a log file or stream
const logger = pino({
level: 'info',
transport: { target: 'pino-pretty', options: { colorize: true } },
});
// Or read from an existing log file:
// const logStream = fs.createReadStream('./agent.log');
// const logger = pino(logStream);
// Process logs
logger.stream.on('data', (line) => {
try {
const parsed = JSON.parse(line);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/README.md` around lines 323 - 340, Update the README JSON log example
to consume raw log lines from an actual readable stream or Pino destination
instead of accessing logger.stream from the pino-pretty transport. Ensure the
input remains newline-delimited JSON before passing each line to JSON.parse,
while keeping the existing log-processing flow intact.

Comment thread agent/url-validator.js
Comment on lines +10 to +16
const PRIVATE_IP_RANGES = [
{ start: '10.0.0.0', prefix: 8 },
{ start: '172.16.0.0', prefix: 12 },
{ start: '192.168.0.0', prefix: 16 },
{ start: '169.254.0.0', prefix: 16 }, // Link-local
{ start: '127.0.0.0', prefix: 8 }, // Loopback
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Missing 0.0.0.0/8 in PRIVATE_IP_RANGES.

0.0.0.0 (and the wider 0.0.0.0/8 range) commonly routes to the local host on Linux/macOS and is a well-known SSRF bypass target, but it's absent from this list.

🛡️ Proposed fix
 const PRIVATE_IP_RANGES = [
   { start: '10.0.0.0', prefix: 8 },
   { start: '172.16.0.0', prefix: 12 },
   { start: '192.168.0.0', prefix: 16 },
   { start: '169.254.0.0', prefix: 16 }, // Link-local
   { start: '127.0.0.0', prefix: 8 }, // Loopback
+  { start: '0.0.0.0', prefix: 8 }, // Unspecified / "this network"
 ];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const PRIVATE_IP_RANGES = [
{ start: '10.0.0.0', prefix: 8 },
{ start: '172.16.0.0', prefix: 12 },
{ start: '192.168.0.0', prefix: 16 },
{ start: '169.254.0.0', prefix: 16 }, // Link-local
{ start: '127.0.0.0', prefix: 8 }, // Loopback
];
const PRIVATE_IP_RANGES = [
{ start: '10.0.0.0', prefix: 8 },
{ start: '172.16.0.0', prefix: 12 },
{ start: '192.168.0.0', prefix: 16 },
{ start: '169.254.0.0', prefix: 16 }, // Link-local
{ start: '127.0.0.0', prefix: 8 }, // Loopback
{ start: '0.0.0.0', prefix: 8 }, // Unspecified / "this network"
];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/url-validator.js` around lines 10 - 16, Add the 0.0.0.0/8 network to
PRIVATE_IP_RANGES, preserving the existing range representation and ordering
conventions so unspecified or local-host addresses are treated as private.

Comment thread agent/url-validator.js
Comment on lines +18 to +23
// IPv6 private ranges
const PRIVATE_IPV6_RANGES = [
{ start: '::1', prefix: 128 }, // Loopback
{ start: 'fe80::', prefix: 10 }, // Link-local
{ start: 'fc00::', prefix: 7 }, // Unique local
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg '(^|/)url-validator\.js$|(^|/)url-validator\.(js|ts|mjs|cjs)$' || true

echo
echo "url-validator outline/size if present:"
if [ -f agent/url-validator.js ]; then
  wc -l agent/url-validator.js
  ast-grep outline agent/url-validator.js || true
  echo
  echo "Relevant sections:"
  cat -n agent/url-validator.js | sed -n '1,140p'
fi

echo
echo "Search isPrivateIP/PRIVATE/ipv6ToBytes usages:"
rg -n "isPrivateIP|PRIVATE_IPV6_RANGES|ipv6ToBytes|\.isIP|isIP" . --glob '!node_modules' --glob '!dist' --glob '!build' || true

Repository: Stellar-Ecosystem/lodestar

Length of output: 6134


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Behavioral probe for the parsing bug / edge case impact without executing repository code.
node - <<'JS'
function ipv6ToBytesOriginal(ip) {
  const bytes = new Uint8Array(16);
  const parts = ip.split(':');
  let byteIndex = 0;
  for (let i = 0; i < parts.length; i++) {
    const part = parts[i];
    if (part === '') {
      const skip = 8 - parts.length + 1;
      byteIndex += skip * 2;
    } else {
      const num = parseInt(part, 16);
      bytes[byteIndex++] = (num >> 8) & 0xFF;
      bytes[byteIndex++] = num & 0xFF;
    }
  }
  return bytes;
}

function ipv6ToBytesFixed(ip) {
  const bytes = new Uint8Array(16);
  const [head, tail] = ip.split('::');
  const headParts = head ? head.split(':') : [];
  const tailParts = tail ? head.split(':') : [];
  const missing = 8 - headParts.length - tailParts.length;
  const groups = [...headParts, ...Array(Math.max(missing, 0)).fill('0'), ...tailParts];
  let byteIndex = 0;
  for (const part of groups) {
    const num = parseInt(part, 16);
    bytes[byteIndex++] = (num >> 8) & 0xFF;
    bytes[byteIndex++] = num & 0xFF;
  }
  return bytes;
}

const addrs = ['::1', '::ffff:127.0.0.1', '::ffff:169.254.169.254', 'fe80::1', 'fc00::1', '::'];
const net = require('net');
for (const ip of addrs) {
  const bOrig = Array.from(ipv6ToBytesOriginal(ip)).join('');
  const bNet = Buffer.from(ip, 'hex').toString('utf-8');
  console.log(ip, 'original=' + bOrig, 'net.isIP=' + net.isIP(ip), 'net.compare=' + Buffer.compare(bOrig, Buffer.from(ip, 'hex')));
}
JS

node - <<'JS'
function isPrivateIP(ip) {
  const PRIVATE_IPV4_RANGES = [
    { start: '10.0.0.0', end: '10.255.255.255' },
    { start: '172.16.0.0', end: '172.31.255.255' },
    { start: '192.168.0.0', end: '192.168.255.255' },
    { start: '127.0.0.0', end: '127.255.255.255' },
    { start: '169.254.0.0', end: '169.254.255.255' },
  ];

  const PRIVATE_IPV6_RANGES = [
    { start: '::1', prefix: 128 }, // Loopback
    { start: 'fe80::', prefix: 10 }, // Link-local
    { start: 'fc00::', prefix: 7 }, // Unique local
  ];

  const net = require('net');
  if (!net.isIP(ip)) return false;

  if (net.isIPv4(ip)) {
    const parts = ip.split('.').map(Number);
    for (const range of PRIVATE_IPV4_RANGES) {
      const rangeStart = range.start.split('.').map(Number);
      const rangeEnd = range.end.split('.').map(Number);
      if (
        parts[0] >= rangeStart[0] && parts[0] <= rangeEnd[0] &&
        parts[1] >= rangeStart[1] && parts[1] <= rangeEnd[1] &&
        parts[2] >= rangeStart[2] && parts[2] <= rangeEnd[2] &&
        parts[3] >= rangeStart[3] && parts[3] <= rangeEnd[3]
      ) {
        return true;
      }
    }
    return false;
  }

  // IPv4-mapped IPv6 -> delegate to IPv4 checks
  if (ip.startsWith('::ffff:') || ip.startsWith('::ffff:0:0/') || ip.includes(':')) {
    const ipv4Match = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)\/?(\d*)/i);
    if (ipv4Match) {
      const ipv4 = ipv4Match[1];
      return isPrivateIP(ipv4);
    }
  }

  if (!net.isIPv6(ip)) return false;

  const bytes = ipv6ToBytes(ip);
  const prefix = ipv6ToBytes(ip.replace('::', '0:'));

  for (const range of PRIVATE_IPV6_RANGES) {
    const rangeStart = ipv6ToBytes(range.start.replace('::', '0:'));
    let bit = 0;
    for (let i = 0; bit < range.prefix; i++) {
      const bits = rangeStart[Math.floor(i / 8)];
      const shifts = i % 8;
      const bitValue = (bits >> (8 - shifts)) & 128;
      const ourBits = bytes[Math.floor(i / 8)];
      const byteValue = (ourBits >> (8 - shifts)) & 128;
      if (bitValue !== byteValue) return false;
      bit++;
    }
  }
  return true;
}

function ipv6ToBytes(ip) {
  const bytes = new Uint8Array(16);
  const parts = ip.split(':');
  let byteIndex = 0;
  for (let i = 0; i < parts.length; i++) {
    const part = parts[i];
    if (part === '') {
      const skip = 8 - parts.length + 1;
      byteIndex += skip * 2;
    } else {
      const num = parseInt(part, 16);
      bytes[byteIndex++] = (num >> 8) & 0xFF;
      bytes[byteIndex++] = num & 0xFF;
    }
  }
  return bytes;
}

for (const ip of ['::1', '::ffff:127.0.0.1', '::ffff:169.254.169.254', 'fe80::1', 'fc00::1', '::']) {
  console.log(ip, isPrivateIP(ip));
}
JS

Repository: Stellar-Ecosystem/lodestar

Length of output: 949


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Parse remainder:"
cat -n agent/url-validator.js | sed -n '135,235p'

echo
echo "Behavioral probe: ipv6ToBytes original and parsing behavior"
node - <<'JS'
function ipv6ToBytesOriginal(ip) {
  const bytes = new Uint8Array(16);
  const parts = ip.split(':');
  let byteIndex = 0;
  for (let i = 0; i < parts.length; i++) {
    const part = parts[i];
    if (part === '') {
      const skip = 8 - parts.length + 1;
      byteIndex += skip * 2;
    } else {
      const num = parseInt(part, 16);
      bytes[byteIndex++] = (num >> 8) & 0xFF;
      bytes[byteIndex++] = num & 0xFF;
    }
  }
  return bytes;
}

for (const ip of ['::1', '::ffff:127.0.0.1', '::ffff:169.254.169.254', 'fe80::1', 'fc00::1', '::', '::1:2:3:4:5:6:7:8']) {
  const parts = ip.split(':');
  let ok = true;
  try {
    const bytes = Array.from(ipv6ToBytesOriginal(ip));
    const netHex = Buffer.from(ip, 'hex');
    console.log(JSON.stringify({... {
      ip,
      splitLength: parts.length,
      byteIndexEnd: parts.length > 0 && parts[0] === '' ? undefined : undefined,
      parseHex: bytes.join(''),
      netHex: netHex.toString(),
      equal: Buffer.compare(Uint8Array.from(bytes), Uint8Array.from(netHex)) === 0,
    }}));
  } catch (e) {
    console.log(JSON.stringify({... {ip, splitLength: parts.length, error: e && e.message, truncated: ok && false}}));
  }
}
JS

echo
echo "Behavioral probe: current isPrivateIP with original ipv6ToBytes"
node - <<'JS'
function isPrivateIP(ip) {
  const PRIVATE_IP_RANGES = [
    { start: '10.0.0.0', prefix: 8 },
    { start: '172.16.0.0', prefix: 12 },
    { start: '192.168.0.0', prefix: 16 },
    { start: '169.254.0.0', prefix: 16 },
    { start: '127.0.0.0', prefix: 8 },
  ];

  const PRIVATE_IPV6_RANGES = [
    { start: '::1', prefix: 128 },
    { start: 'fe80::', prefix: 10 },
    { start: 'fc00::', prefix: 7 },
  ];

  const net = require('net');
  if (!net.isIP(ip)) return false;

  if (net.isIPv4(ip)) {
    const parts = ip.split('.').map(Number);
    for (const range of PRIVATE_IP_RANGES) {
      const rangeStart = range.start.split('.').map(Number);
      const rangeEnd = range.end.split('.').map(Number);
      if (
        parts[0] >= rangeStart[0] && parts[0] <= rangeEnd[0] &&
        parts[1] >= rangeStart[1] && parts[1] <= rangeEnd[1] &&
        parts[2] >= rangeStart[2] && parts[2] <= rangeEnd[2] &&
        parts[3] >= rangeStart[3] && parts[3] <= rangeEnd[3]
      ) {
        return true;
      }
    }
    return false;
  }

  if (net.isIPv6(ip)) {
    for (const range of PRIVATE_IPV6_RANGES) {
      if (isIPv6InRange(ip, range.start, range.prefix)) return true;
    }
  }

  return false;
}

function isIPv6InRange(ip, start, prefix) {
  const ipBytes = ipv6ToBytes(ip);
  const startBytes = ipv6ToBytes(start);
  const maskBytes = prefixToMask(prefix);
  for (let i = 0; i < 16; i++) {
    if ((ipBytes[i] & maskBytes[i]) !== (startBytes[i] & maskBytes[i])) return false;
  }
  return true;
}

function ipv6ToBytes(ip) {
  const bytes = new Uint8Array(16);
  const parts = ip.split(':');
  let byteIndex = 0;
  for (let i = 0; i < parts.length; i++) {
    const part = parts[i];
    if (part === '') {
      const skip = 8 - parts.length + 1;
      byteIndex += skip * 2;
    } else {
      const num = parseInt(part, 16);
      bytes[byteIndex++] = (num >> 8) & 0xFF;
      bytes[byteIndex++] = num & 0xFF;
    }
  }
  return bytes;
}

function prefixToMask(prefix) {
  const bytes = new Uint8Array(16);
  for (let i = 0; i < 16; i++) {
    const bitsRemaining = Math.max(0, prefix - i * 8);
    bytes[i] = bitsRemaining >= 8 ? 0xFF : (0xFF << (8 - bitsRemaining)) & 0xFF;
  }
  return bytes;
}

for (const ip of [
  '::1',
  '::1:2:3:4:5:6:7:8',
  '::ffff:127.0.0.1',
  '::ffff:169.254.169.254',
  'fe80::1',
  'fc00::1',
  '::'
]) {
  console.log(ip, isPrivateIP(ip));
}
JS

Repository: Stellar-Ecosystem/lodestar

Length of output: 5028


Fix IPv6 :: parsing so parsed bytes match the address.

ipv6ToBytes only works for the current loopback-vs-loopback check by accident. Leading/trailing :: addresses like ::ffff:169.254.169.254, ::ffff:127.0.0.1, fe80::1, and fc00::1 can be mis-parsed because each empty segment from split(':') advances the write index multiple times and truncates at the 16-byte array. Once :: parsing is fixed, also add the IPv4-mapped private range check or resolve ::ffff:<IPv4> through the IPv4 private-range logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/url-validator.js` around lines 18 - 23, Fix ipv6ToBytes so a compressed
“::” expands to the correct number of zero hextets and does not advance the
output index for each empty split segment; verify leading, trailing, and
embedded compression parses into the exact 16-byte address. Extend IPv6
private-address validation to recognize IPv4-mapped forms such as
::ffff:169.254.169.254 and ::ffff:127.0.0.1, either by adding the mapped private
range or routing the embedded IPv4 value through the existing IPv4 private-range
logic.

Comment thread agent/url-validator.js
Comment on lines +196 to +235
export function createSafeFetch(baseFetch) {
return async (url, options = {}) => {
// Validate initial URL
const validation = await validateEndpointUrl(url);
if (!validation.valid) {
const err = new Error(validation.reason);
err.code = 'SSRF_BLOCKED';
throw err;
}

// Create fetch with redirect handling
const response = await baseFetch(url, {
...options,
redirect: 'manual', // We'll handle redirects manually
});

// Handle redirects
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
return response;
}

// Resolve relative URLs
const redirectUrl = new URL(location, url).toString();

const redirectValidation = await validateRedirect(url, redirectUrl);
if (!redirectValidation.valid) {
const err = new Error(redirectValidation.reason);
err.code = 'SSRF_REDIRECT_BLOCKED';
throw err;
}

// Follow the redirect
return createSafeFetch(baseFetch)(redirectUrl, options);
}

return response;
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No cap on redirect-following in createSafeFetch.

Each valid redirect recurses into createSafeFetch(baseFetch)(redirectUrl, options) with no maximum-redirect counter. A malicious or misconfigured allowlisted/same-host server that keeps issuing 3xx responses (even a simple redirect loop) causes indefinite request chains instead of failing fast, unlike standard fetch implementations which cap redirects (commonly 20).

🛡️ Proposed fix
-export function createSafeFetch(baseFetch) {
-  return async (url, options = {}) => {
+export function createSafeFetch(baseFetch, maxRedirects = 5) {
+  return async (url, options = {}, redirectCount = 0) => {
+    if (redirectCount > maxRedirects) {
+      const err = new Error(`Too many redirects for ${url}`);
+      err.code = 'SSRF_REDIRECT_BLOCKED';
+      throw err;
+    }
     // Validate initial URL
     const validation = await validateEndpointUrl(url);
     ...
-      return createSafeFetch(baseFetch)(redirectUrl, options);
+      return createSafeFetch(baseFetch, maxRedirects)(redirectUrl, options, redirectCount + 1);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function createSafeFetch(baseFetch) {
return async (url, options = {}) => {
// Validate initial URL
const validation = await validateEndpointUrl(url);
if (!validation.valid) {
const err = new Error(validation.reason);
err.code = 'SSRF_BLOCKED';
throw err;
}
// Create fetch with redirect handling
const response = await baseFetch(url, {
...options,
redirect: 'manual', // We'll handle redirects manually
});
// Handle redirects
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
return response;
}
// Resolve relative URLs
const redirectUrl = new URL(location, url).toString();
const redirectValidation = await validateRedirect(url, redirectUrl);
if (!redirectValidation.valid) {
const err = new Error(redirectValidation.reason);
err.code = 'SSRF_REDIRECT_BLOCKED';
throw err;
}
// Follow the redirect
return createSafeFetch(baseFetch)(redirectUrl, options);
}
return response;
};
}
export function createSafeFetch(baseFetch, maxRedirects = 5) {
return async (url, options = {}, redirectCount = 0) => {
if (redirectCount > maxRedirects) {
const err = new Error(`Too many redirects for ${url}`);
err.code = 'SSRF_REDIRECT_BLOCKED';
throw err;
}
// Validate initial URL
const validation = await validateEndpointUrl(url);
if (!validation.valid) {
const err = new Error(validation.reason);
err.code = 'SSRF_BLOCKED';
throw err;
}
// Create fetch with redirect handling
const response = await baseFetch(url, {
...options,
redirect: 'manual', // We'll handle redirects manually
});
// Handle redirects
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
return response;
}
// Resolve relative URLs
const redirectUrl = new URL(location, url).toString();
const redirectValidation = await validateRedirect(url, redirectUrl);
if (!redirectValidation.valid) {
const err = new Error(redirectValidation.reason);
err.code = 'SSRF_REDIRECT_BLOCKED';
throw err;
}
// Follow the redirect
return createSafeFetch(baseFetch, maxRedirects)(redirectUrl, options, redirectCount + 1);
}
return response;
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/url-validator.js` around lines 196 - 235, Update createSafeFetch to
track the number of redirects followed and stop with an error once the
configured maximum is reached, using a standard cap such as 20. Propagate the
incremented redirect count through the recursive redirect call while preserving
URL validation and normal response handling.

Comment on lines +1 to +2
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { validateEndpointUrl, validateRedirect, createSafeFetch } from './url-validator.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)agent/(url-validator\.(js|test\.js)|.*\.js)$' || true

echo "== test outline =="
wc -l agent/url-validator.test.js agent/url-validator.js 2>/dev/null || true

echo "== relevant test lines =="
sed -n '1,90p' agent/url-validator.test.js
echo "== dns mock area =="
sed -n '90,175p' agent/url-validator.test.js
echo "== later URL tests =="
sed -n '175,360p' agent/url-validator.test.js

echo "== validator import =="
sed -n '1,80p' agent/url-validator.js

echo "== vitest config/package =="
for f in package.json vitest.config.js vitest.config.ts vite.config.js vite.config.ts; do
  [ -f "$f" ] && { echo "--- $f"; sed -n '1,220p' "$f"; }
done

Repository: Stellar-Ecosystem/lodestar

Length of output: 14467


🌐 Web query:

Vitest vi.mock factory default export required for default import namespace hoisting

💡 Result:

In Vitest, when you use a factory function to mock a module containing a default export, you must explicitly include a default key in the returned object [1][2][3]. This requirement arises because Vitest operates in an ES module (ESM) environment, where default exports are treated as a distinct property of the module object [2]. Unlike some legacy CommonJS-based testing tools that might handle default exports implicitly, Vitest requires this explicit definition to correctly map the export for ESM compliance [1][3]. When using vi.mock, the call is hoisted to the top of the file and executed before any static imports [1][4][5]. This hoisting ensures that the mock is registered before the module is actually imported [6][7]. Because of this hoisting, the code inside your factory function cannot reference variables defined in the outer scope of the file [1][8]. If you need to reference external variables within your mock factory (e.g., to create a spy or share state), you should define them using vi.hoisted [1][8]. Example of mocking a default export: vi.mock('./path/to/module.js', => { return { default: { myDefaultKey: vi.fn }, namedExport: vi.fn, } }) If you attempt to mock a module with a default export without providing the default key, the default import will likely result in undefined [2].

Citations:


Hoist and reset the dns/promises mock with a default export.

agent/url-validator.js uses import dns from 'dns/promises', while the inline vi.mock('dns/promises', ...) factory only returns a named resolve export. Vitest hoists the factory too, so dns can become undefined instead of an object with resolve, and the hardcoded private/public DNS return values persist across tests because there is no hoisted mock shared with beforeEach/afterEach reset.

Use a hoisted resolver and include the default export, then reset it before each test so normal HTTPS URL assertions are not affected by previous DNS tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/url-validator.test.js` around lines 1 - 2, Update the dns/promises mock
used by the URL validator tests to create a hoisted resolver shared with the
test lifecycle, expose it through the mock’s default export with resolve, and
reset it in beforeEach so DNS-specific return values do not leak into other
tests. Anchor the changes to the dns/promises vi.mock setup and existing
beforeEach/afterEach hooks while preserving the current URL assertions.

Comment thread backend/openapi.json
Comment on lines +1262 to +1273
"headers": [
{
"name": "X-Idempotency-Key",
"required": true,
"description": "Unique idempotency key for the payment (1-255 printable ASCII characters)",
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 255
}
}
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

headers is not a valid Operation Object field — required auth headers won't be documented.

Operation-level headers arrays (also at Lines 1502-1512, 1848-1858, 1910-1920, 1992-2002) are ignored by OpenAPI tooling. Header inputs belong in parameters with "in": "header".

♻️ Proposed fix
-        "headers": [
-          {
-            "name": "X-Idempotency-Key",
-            "required": true,
-            "description": "Unique idempotency key for the payment (1-255 printable ASCII characters)",
-            "schema": {
-              "type": "string",
-              "minLength": 1,
-              "maxLength": 255
-            }
-          }
-        ],

and add the entry to the existing parameters array:

{
  "name": "X-Idempotency-Key",
  "in": "header",
  "required": true,
  "description": "Unique idempotency key for the payment (1-255 printable ASCII characters)",
  "schema": { "type": "string", "minLength": 1, "maxLength": 255 }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/openapi.json` around lines 1262 - 1273, Move every operation-level
X-Idempotency-Key entry from the invalid headers arrays into each operation’s
existing parameters array, adding "in": "header" while preserving required,
description, and schema constraints. Apply this consistently to all five
referenced operations and remove the unsupported headers fields.

Comment thread backend/openapi.json
Comment on lines +1975 to +1976
"/api/agents/{address}/policy": {
"put": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Duplicate /api/agents/{address}/policy path item, mirrored in the test list. The spec declares the same path key twice — Line 935 (get) and Line 1975 (put) — so the later JSON key wins and the GET operation with its AgentPolicy response disappears from the served document. The test's requiredPaths array lists that path twice as well, so the assertion passes regardless and cannot detect the loss.

  • backend/openapi.json#L1975-L1976: delete this second "/api/agents/{address}/policy" entry and move its put operation into the existing path item at Line 935 alongside get.
  • backend/test/openapi.test.js#L44-L59: remove the duplicate '/api/agents/{address}/policy' entry at Line 59 and add an assertion that the path item exposes both get and put.
📍 Affects 2 files
  • backend/openapi.json#L1975-L1976 (this comment)
  • backend/test/openapi.test.js#L44-L59
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/openapi.json` around lines 1975 - 1976, The OpenAPI document
duplicates the policy path, causing the later entry to overwrite GET, while the
test list also masks the issue. In backend/openapi.json lines 1975-1976, remove
the duplicate path item and merge its put operation into the existing
/api/agents/{address}/policy item containing get; in
backend/test/openapi.test.js lines 44-59, remove the duplicate requiredPaths
entry and assert that this path exposes both get and put operations.

Source: Linters/SAST tools


beforeAll(async () => {
api = request(app);
spec = await SwaggerParser.validate('../openapi.json');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Relative spec path is resolved against process.cwd(), not this file.

swagger-parser resolves a relative path from the working directory. With Vitest rooted at backend/, '../openapi.json' points outside the package and beforeAll will throw, failing every test in the suite. Resolve it from the module URL instead.

🐛 Proposed fix
+import { fileURLToPath } from 'node:url';
...
-    spec = await SwaggerParser.validate('../openapi.json');
+    spec = await SwaggerParser.validate(
+      fileURLToPath(new URL('../openapi.json', import.meta.url))
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
spec = await SwaggerParser.validate('../openapi.json');
import { fileURLToPath } from 'node:url';
spec = await SwaggerParser.validate(
fileURLToPath(new URL('../openapi.json', import.meta.url))
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/test/openapi.test.js` at line 12, Update the SwaggerParser.validate
call in the test setup to resolve openapi.json relative to the test module URL
rather than process.cwd(). Use the module URL with the appropriate
path-to-filesystem conversion so beforeAll loads the package’s OpenAPI
specification reliably.

@ritik4ever

Copy link
Copy Markdown
Collaborator

Hi @samueldoo,

This PR could not be merged because it has merge conflicts with the target branch.

Please resolve the merge conflicts, push the updated changes, and the PR can be reviewed and merged.

Thank you!

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.

Agent: structured event names are defined but there is no schema or consumer

2 participants