Skip to content

feat: Badge identity delivery — kya_web_fetch + kya_getHeaders (KYA-55) - #27

Merged
kyalabs merged 8 commits into
mainfrom
james/kya-55-identity-delivery
Mar 25, 2026
Merged

feat: Badge identity delivery — kya_web_fetch + kya_getHeaders (KYA-55)#27
kyalabs merged 8 commits into
mainfrom
james/kya-55-identity-delivery

Conversation

@kyalabs

@kyalabs kyalabs commented Mar 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • kya_web_fetch — fetch with automatic Kya-Token header injection + auto-declare (browse_declared event). SSRF protection, HTTPS-only, 5MB body cap, 30s timeout, manual redirects. Primary identity delivery path (Path 1).
  • kya_getHeaders — returns { "Kya-Token": token } for Playwright setExtraHTTPHeaders or Chrome extension document.cookie. Identity delivery for browser automation (Path 2/2.5).
  • Deprecate kya_reportBadgeOutcome + kya_reportBadgeNotPresented to no-ops — outcomes now tracked server-side via verify endpoint; not-presented event no longer scored.
  • Extract isPublicOrigin() to shared src/lib/url-safety.ts with 16 SSRF tests (RFC1918, localhost, IPv6, link-local, AWS metadata).
  • Version bump 2.4.0 → 2.5.0

Security

  • SSRF: isPublicOrigin() blocks RFC1918, localhost, loopback, link-local (169.254.x), IPv6 private
  • Token safety: redirect: "manual" prevents Kya-Token leaking to redirect targets
  • Response filtering: strips set-cookie from response headers
  • Method allowlist: GET/HEAD/OPTIONS only (no mutation through proxy)
  • Agent cannot override Kya-Token via headers param (our token wins)
  • No console.log in production paths (stderr only)

Test plan

  • 168 tests passing (32 net new)
  • npm run build — zero TypeScript errors
  • No console.log in production paths
  • No internal URLs/table names in tool descriptions or errors
  • CodeRabbit review
  • Manual test: install in Claude Desktop, agent shops with kya_web_fetch — header present

Linear: KYA-55

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a web-fetch tool that fetches HTTPS pages with automatic identity header injection and a helper to retrieve identity headers for automation.
  • Deprecations

    • Two badge-reporting tools marked deprecated; outcome recording moved server-side.
  • Security

    • Strengthened URL safety: block private/local addresses, enforce HTTPS-only fetches, and avoid following redirects to prevent token leakage.
  • Tests

    • Added comprehensive tests covering URL safety, token enrollment, header retrieval, and web-fetch behavior.
  • Chores

    • Bumped package to 2.5.0, updated changelog, and added .env patterns to .gitignore.

kyalabs and others added 5 commits March 25, 2026 14:48
SSRF protection was defined locally in ucp-manifest.ts. Extract to
src/lib/url-safety.ts so kya_web_fetch and future outbound fetch
tools can reuse it. Zero functional change — import replaces inline.

Adds 16 tests covering RFC1918, localhost, IPv6 private, link-local,
AWS metadata (169.254.x), and malformed URL cases.

Part of: KYA-55

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Returns { headers: { "Kya-Token": token } } for agents using
Playwright (setExtraHTTPHeaders) or Chrome extensions (document.cookie).
Returns NO_IDENTITY error when no identity established.

Part of: KYA-55 (Path 2/2.5 identity delivery)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Primary identity delivery path. Wraps fetch with:
- Kya-Token header auto-injection
- Auto-declare (browse_declared event, fire-and-forget)
- SSRF protection via isPublicOrigin()
- HTTPS-only, 5MB body cap, 30s timeout
- Manual redirects (prevents token leak to redirect targets)
- Method allowlist: GET, HEAD, OPTIONS
- Response header filtering (strips set-cookie)

29 tests covering identity, URL validation, SSRF, methods, fetch
behavior, truncation, timeouts, auto-declare, and header override
prevention.

Part of: KYA-55 (Path 1 identity delivery)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Register kya_web_fetch and kya_getHeaders in index.ts.
Deprecate kya_reportBadgeOutcome and kya_reportBadgeNotPresented
to no-ops — outcomes tracked server-side via verify endpoint,
not-presented event no longer scored. Both log once-per-session
stderr warning. Remove unused imports.

Update kya_reportBadgePresented description to stop referencing
the now-deprecated kya_reportBadgeOutcome.

Part of: KYA-55

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Version 2.4.0 → 2.5.0 (minor: new tools, deprecations, no breaking changes).
CHANGELOG documents kya_web_fetch, kya_getHeaders, deprecations.
.gitignore adds .env patterns.

Part of: KYA-55

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Mar 25, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds web-fetch and header helper tools, deprecates two outcome-reporting tools to no-ops, extracts SSRF URL-safety logic into a shared module, implements per-merchant badge-token enrollment/caching with enroll-on-identity side-effect, bumps package version and changelog, and adds comprehensive Vitest tests for new modules.

Changes

Cohort / File(s) Summary
Config & Release
\.gitignore, package.json, CHANGELOG.md
Ignore .env* files; bump package to 2.5.0; add changelog entry documenting new tools, deprecations, and URL-safety extraction.
URL Safety
src/lib/url-safety.ts, src/lib/url-safety.test.ts
Add exported isPublicOrigin(origin: string) enforcing HTTPS (except VITEST) and blocking localhost/private IPv4/IPv6 ranges; add tests covering allowed/blocked cases.
Manifest Integration
src/lib/ucp-manifest.ts
Remove in-file SSRF validator and import isPublicOrigin from shared url-safety (control flow unchanged).
Entrypoint / MCP Tools
src/index.ts
Bump MCP server version; deprecate kya_reportBadgeOutcome/kya_reportBadgeNotPresented to one-time-warning no-ops; add new MCP tools kya_web_fetch and kya_getHeaders.
Web Fetch
src/tools/webFetch.ts, src/tools/webFetch.test.ts
New webFetch with origin validation, SSRF blocking, token injection, method allowlist (GET/HEAD/OPTIONS), redirect: "manual", 30s timeout, ~5MB body cap, response header filtering, and fire-and-forget browse_declared telemetry; tests exercise identity, SSRF, headers, truncation, timeouts, and telemetry.
Headers Helper
src/tools/getHeaders.ts, src/tools/getHeaders.test.ts
Add getHeaders() returning { "Kya-Token": string } or { error, code: "NO_IDENTITY" }; tests validate cached token behavior and stderr redaction.
Badge Token Cache
src/lib/badge-token.ts, src/lib/badge-token.test.ts
Add per-merchant badge-token enrollment with in-memory cache, retrieval helpers, and tests for enroll, caching across merchants, and error cases.
Agent Identity Update
src/tools/getAgentIdentity.ts
Fire-and-forget enrollAndCacheBadgeToken(merchant) after identity acquisition when applicable; update next-step guidance text.
Misc Tests
src/tools/..., src/lib/...
Add/extend Vitest suites for new modules and behaviors (webFetch, getHeaders, badge-token, url-safety).

Sequence Diagram

sequenceDiagram
    actor Agent as Agent/Client
    participant MCP as MCP Server
    participant Storage as Storage
    participant URLValidator as URL Safety
    participant Fetch as HTTP Fetch
    participant API as Remote API

    Agent->>MCP: call kya_web_fetch(url, method?, headers?)
    MCP->>Storage: getCachedBadgeToken / getStoredConsentKey
    Storage-->>MCP: token or null

    alt no token
        MCP-->>Agent: { error: "NO_IDENTITY", code: "NO_IDENTITY" }
    else token available
        MCP->>URLValidator: isPublicOrigin(url)
        URLValidator-->>MCP: true/false

        alt blocked
            MCP-->>Agent: { error: "BLOCKED_URL", code: "BLOCKED_URL" }
        else allowed
            MCP->>Fetch: fetch(url, { headers with Kya-Token, redirect: "manual", timeout: 30s })
            Fetch-->>MCP: Response (status, headers, body, location?)
            MCP->>MCP: parse/truncate body (5MB), filter headers
            MCP-->>Agent: WebFetchSuccess { status, headers, body, truncated, url }

            par fire-and-forget telemetry
                MCP->>Storage: getOrCreateInstallId()
                MCP->>API: POST /api/badge/report (browse_declared) (5s timeout)
                API-->>MCP: logged (errors to stderr only)
            end
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped through code to fetch with care,

Tokens snug in headers I bear,
I barred the hidden private door,
Old reports sleep while new tools soar,
Version 2.5 — a joyful hare!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main changes: introduction of two new identity-delivery tools (kya_web_fetch and kya_getHeaders) referenced with the ticket number.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch james/kya-55-identity-delivery

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (1)
src/tools/webFetch.ts (1)

22-24: Centralize the release version.

BADGE_VERSION now duplicates version: "2.5.0" in src/index.ts:18-21. This will eventually drift and send stale badge_version telemetry unless it comes from one shared source.

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

In `@src/tools/webFetch.ts` around lines 22 - 24, Replace the duplicated
BADGE_VERSION constant with a single shared source: export the app version from
the existing version symbol in src/index.ts (e.g., export const version = "...")
or create a central constant (e.g., APP_VERSION) and import it into
src/tools/webFetch.ts; then use that imported symbol in place of BADGE_VERSION
so badge_version telemetry always comes from the single authoritative version
constant (update the BADGE_VERSION reference in webFetch.ts to use the imported
version and remove the hardcoded "2.5.0").
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/url-safety.ts`:
- Around line 13-22: The check in isPublicOrigin uses origin.startsWith instead
of the parsed URL protocol, so malformed schemes like "ftp:" or "file:" slip
through when VITEST is set; update isPublicOrigin to parse the URL once (using
new URL(origin)), read url.protocol (e.g., "https:" / "http:"), and replace the
startsWith check with a protocol comparison that enforces "https:" normally and
only relaxes to allow "http:" when process.env.VITEST is set; keep the existing
try/catch that returns false for invalid origins and use the parsed hostname
variable as before.
- Around line 24-42: The SSRF guard misses full IPv4 loopback and IPv6 fe80::/10
ranges; update the IPv4 check in src/lib/url-safety.ts to reject all 127.0.0.0/8
by adding a condition that returns false when the first octet (variable a
derived from ipv4Match) equals 127, and broaden the IPv6 bracketed check
(variable inner) to match the entire fe80::/10 range by rejecting addresses
whose hex prefix starts with "fe8", "fe9", "fea" or "feb" (use
inner.toLowerCase()). Keep existing checks for fc*/fd* and ::1, and add
regression tests asserting that "127.0.0.2" (or any 127.*) and "[fe90::1]" are
treated as unsafe.

In `@src/tools/getHeaders.test.ts`:
- Around line 14-18: The stderr spy created as stderrSpy =
vi.spyOn(process.stderr, "write") is not restored; vi.clearAllMocks() only
clears call history, so add an afterAll hook that calls stderrSpy.mockRestore()
(i.e., afterAll(() => stderrSpy.mockRestore())) to restore process.stderr.write
and avoid leaking the mocked implementation into other tests; keep the existing
afterEach clearing but add this afterAll cleanup referencing stderrSpy and
mockRestore.

In `@src/tools/webFetch.test.ts`:
- Around line 230-237: The test must cover case-insensitive header names: add
one or two tests that call webFetch (same as existing test using mockFetch and
mockResponse) passing agent headers with different casing like "kya-token" and
"KYA-TOKEN" and assert the server token still wins; locate the existing test for
webFetch and either extend it or add new it() blocks that find the fetch call
(using mockFetch.mock.calls and the URL check as in the current test) and verify
by normalizing header names (e.g., lowercasing keys from fetchCall[1].headers)
that the value for "kya-token" equals the expected server token
("pk_test_abc123"), ensuring all case permutations cannot override the server
token.
- Around line 35-47: The mockResponse helper incorrectly sets ok for 3xx codes;
update the mockResponse function so its ok property matches Fetch spec (true
only for 200–299) by computing ok as status >= 200 && status < 300 (or status <=
299), keeping the rest of the mock behavior (headers, text, url) unchanged so
tests reflect real Response.ok semantics.

In `@src/tools/webFetch.ts`:
- Around line 98-102: The request header construction in src/tools/webFetch.ts
currently does `...(headers ?? {})` then sets "Kya-Token": token, which allows
callers to inject case-variant headers (e.g. "kya-token" or "KYA-TOKEN") that
will be normalized and concatenated by Fetch; fix by filtering incoming headers
case-insensitively before spreading: remove any header whose lowercased name
equals "kya-token" (use the same approach as the response header filtering used
around lines 133-141) and then set the canonical "Kya-Token": token so our token
always wins; update the logic that builds requestHeaders (the block creating
requestHeaders) accordingly and add/adjust tests to cover
lower/upper/case-variant header names.
- Around line 120-131: The current block uses response.text() and string.length
which buffers the entire body and counts UTF-16 code units instead of bytes;
replace that logic in the function that reads the response (the body/truncated
handling around response.text(), MAX_BODY_BYTES, and truncated) with a streaming
reader: use response.body?.getReader() to read Uint8Array chunks, sum
value.byteLength to enforce MAX_BODY_BYTES, push bytes into a buffer (or decode
incrementally with a TextDecoder) up to the byte limit, set truncated = true
when the limit would be exceeded and stop reading further, and then produce the
final body string from the collected bytes; ensure the fallback catch logic
remains if response.body is absent or reading fails.
- Around line 105-117: The catch block that classifies timeouts only checks for
AbortError so undici's TimeoutError (thrown during headers/body reads) is
misclassified; update the error handling around the fetch (the
variables/constructs: response, err, AbortSignal.timeout, FETCH_TIMEOUT_MS) to
treat both AbortError and TimeoutError as timeouts by checking err.name ===
"AbortError" || err.name === "TimeoutError" (or err.constructor?.name ===
"TimeoutError"), and return the same { error: "Request timed out", code:
"TIMEOUT" } for either case; optionally, if undici's TimeoutError class can be
imported in your environment, use instanceof TimeoutError for a stronger check.

---

Nitpick comments:
In `@src/tools/webFetch.ts`:
- Around line 22-24: Replace the duplicated BADGE_VERSION constant with a single
shared source: export the app version from the existing version symbol in
src/index.ts (e.g., export const version = "...") or create a central constant
(e.g., APP_VERSION) and import it into src/tools/webFetch.ts; then use that
imported symbol in place of BADGE_VERSION so badge_version telemetry always
comes from the single authoritative version constant (update the BADGE_VERSION
reference in webFetch.ts to use the imported version and remove the hardcoded
"2.5.0").

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 96e49c5f-e7e3-4f75-abdc-fd7d8f23d387

📥 Commits

Reviewing files that changed from the base of the PR and between 2624838 and f789483.

📒 Files selected for processing (11)
  • .gitignore
  • CHANGELOG.md
  • package.json
  • src/index.ts
  • src/lib/ucp-manifest.ts
  • src/lib/url-safety.test.ts
  • src/lib/url-safety.ts
  • src/tools/getHeaders.test.ts
  • src/tools/getHeaders.ts
  • src/tools/webFetch.test.ts
  • src/tools/webFetch.ts

Comment thread src/lib/url-safety.ts
Comment on lines +13 to +22
export function isPublicOrigin(origin: string): boolean {
let hostname: string;
try {
hostname = new URL(origin).hostname;
} catch {
return false;
}

// Block non-https (except in tests)
if (!origin.startsWith("https://") && !process.env.VITEST) return false;

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 | 🟡 Minor

Use the parsed protocol here, not a raw string prefix.

With process.env.VITEST set, inputs like ftp://example.com and file:///tmp/x pass this gate because the condition only rejects non-HTTPS when the env var is absent. If the intended exception is “allow http: in tests”, compare url.protocol and only relax that case.

Suggested fix
 export function isPublicOrigin(origin: string): boolean {
-  let hostname: string;
+  let url: URL;
   try {
-    hostname = new URL(origin).hostname;
+    url = new URL(origin);
   } catch {
     return false;
   }
+  const { hostname, protocol } = url;

   // Block non-https (except in tests)
-  if (!origin.startsWith("https://") && !process.env.VITEST) return false;
+  if (protocol !== "https:" && !(process.env.VITEST && protocol === "http:")) return false;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/url-safety.ts` around lines 13 - 22, The check in isPublicOrigin uses
origin.startsWith instead of the parsed URL protocol, so malformed schemes like
"ftp:" or "file:" slip through when VITEST is set; update isPublicOrigin to
parse the URL once (using new URL(origin)), read url.protocol (e.g., "https:" /
"http:"), and replace the startsWith check with a protocol comparison that
enforces "https:" normally and only relaxes to allow "http:" when
process.env.VITEST is set; keep the existing try/catch that returns false for
invalid origins and use the parsed hostname variable as before.

Comment thread src/lib/url-safety.ts
Comment on lines +24 to +42
// Block localhost and loopback
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return false;
if (hostname.endsWith(".localhost")) return false;

// Block private/reserved IPv4 ranges
const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number);
if (a === 10) return false; // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return false; // 172.16.0.0/12
if (a === 192 && b === 168) return false; // 192.168.0.0/16
if (a === 169 && b === 254) return false; // 169.254.0.0/16 (link-local + metadata)
if (a === 0) return false; // 0.0.0.0/8
}

// Block IPv6 loopback/link-local
if (hostname.startsWith("[")) {
const inner = hostname.slice(1, -1).toLowerCase();
if (inner === "::1" || inner.startsWith("fe80:") || inner.startsWith("fc") || inner.startsWith("fd")) return false;

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

The shared SSRF guard still allows part of loopback/link-local space.

127.0.0.2/127.255.255.255 and IPv6 link-local addresses like [fe90::1] still return true here. Since both src/lib/ucp-manifest.ts and kya_web_fetch now rely on this helper, that leaves a shared SSRF bypass. Expand the IPv4 check to all of 127.0.0.0/8 and the IPv6 check to the full fe80::/10 range, then add a regression case for each.

Suggested fix
   if (ipv4Match) {
     const [, a, b] = ipv4Match.map(Number);
+    if (a === 127) return false;                         // 127.0.0.0/8
     if (a === 10) return false;                          // 10.0.0.0/8
     if (a === 172 && b >= 16 && b <= 31) return false;   // 172.16.0.0/12
     if (a === 192 && b === 168) return false;             // 192.168.0.0/16
     if (a === 169 && b === 254) return false;             // 169.254.0.0/16 (link-local + metadata)
     if (a === 0) return false;                            // 0.0.0.0/8
   }

   // Block IPv6 loopback/link-local
   if (hostname.startsWith("[")) {
     const inner = hostname.slice(1, -1).toLowerCase();
-    if (inner === "::1" || inner.startsWith("fe80:") || inner.startsWith("fc") || inner.startsWith("fd")) return false;
+    const firstHextet = Number.parseInt(inner.split(":")[0] ?? "", 16);
+    if (inner === "::1") return false;
+    if (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) return false; // fe80::/10
+    if (inner.startsWith("fc") || inner.startsWith("fd")) return false;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/url-safety.ts` around lines 24 - 42, The SSRF guard misses full IPv4
loopback and IPv6 fe80::/10 ranges; update the IPv4 check in
src/lib/url-safety.ts to reject all 127.0.0.0/8 by adding a condition that
returns false when the first octet (variable a derived from ipv4Match) equals
127, and broaden the IPv6 bracketed check (variable inner) to match the entire
fe80::/10 range by rejecting addresses whose hex prefix starts with "fe8",
"fe9", "fea" or "feb" (use inner.toLowerCase()). Keep existing checks for
fc*/fd* and ::1, and add regression tests asserting that "127.0.0.2" (or any
127.*) and "[fe90::1]" are treated as unsafe.

Comment on lines +14 to +18
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);

afterEach(() => {
vi.clearAllMocks();
});

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine the test file
find . -name "getHeaders.test.ts" -type f

Repository: kyalabs-Io/badge-server

Length of output: 97


🏁 Script executed:

#!/bin/bash
# List the tools directory to understand structure
ls -la src/tools/ 2>/dev/null || echo "Tools dir not found"

Repository: kyalabs-Io/badge-server

Length of output: 587


🏁 Script executed (no clone):

#!/bin/bash
# Research Vitest's vi.clearAllMocks() behavior
cat > /tmp/vitest_research.txt << 'EOF'
Vitest documentation on vi.clearAllMocks():
- clearAllMocks(): Clears all mock call history and partially restores spy functionality
- Does NOT restore the original implementation when used with mockImplementation()
- mockRestore(): Restores the spy to its original pre-spy state

Key difference:
- clearAllMocks() removes call history and resets return values but KEEPS the spy active
- mockRestore() removes the spy completely and restores the original function

For spies created with vi.spyOn().mockImplementation(), the original function is replaced.
After clearAllMocks(), the spy remains in place with the mock implementation.
EOF
cat /tmp/vitest_research.txt

Length of output: 664


🏁 Script executed:

#!/bin/bash
# Read the test file
cat -n src/tools/getHeaders.test.ts

Repository: kyalabs-Io/badge-server

Length of output: 1721


🏁 Script executed:

#!/bin/bash
# Check if there are other test files that might be affected by the unrestored stderr spy
find . -name "*.test.ts" -type f | head -20

Repository: kyalabs-Io/badge-server

Length of output: 392


🏁 Script executed:

#!/bin/bash
# Check if any other test files use process.stderr
grep -r "process.stderr" . --include="*.test.ts" --include="*.ts" | head -20

Repository: kyalabs-Io/badge-server

Length of output: 1571


🏁 Script executed (no clone):

#!/bin/bash
# Verify Vitest's actual behavior with afterEach and spies
cat > /tmp/vitest_cleanup.txt << 'EOF'
Vitest spy cleanup behavior:

IMPORTANT: When a spy is created at describe-block scope (not in beforeEach):
- The spy is created ONCE when the describe block is parsed
- afterEach runs after EACH test, but the spy remains active
- When the describe block ENDS, the variable goes out of scope, but the spy on the actual method does NOT automatically restore

CRITICAL ISSUE: process.stderr.write is a GLOBAL object
- The spy modifies the actual process.stderr.write method
- This modification persists across tests and potentially across test files
- Without mockRestore(), subsequent tests may receive the mocked behavior instead of the real one

BEST PRACTICE FIX:
Option 1: afterAll(() => stderrSpy.mockRestore()) - Restore after all tests in describe
Option 2: Move spy creation to beforeEach and restore in afterEach
Option 3: Use vi.stubGlobal() which auto-restores on test cleanup

This is a genuine test isolation concern.
EOF
cat /tmp/vitest_cleanup.txt

Length of output: 960


Restore the stderr spy after all tests complete.

vi.clearAllMocks() clears call history but does not restore this spy. Since process.stderr is a global object, the mocked implementation persists and can affect stderr calls in other tests. Add afterAll(() => stderrSpy.mockRestore()) to properly clean up the spy.

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

In `@src/tools/getHeaders.test.ts` around lines 14 - 18, The stderr spy created as
stderrSpy = vi.spyOn(process.stderr, "write") is not restored;
vi.clearAllMocks() only clears call history, so add an afterAll hook that calls
stderrSpy.mockRestore() (i.e., afterAll(() => stderrSpy.mockRestore())) to
restore process.stderr.write and avoid leaking the mocked implementation into
other tests; keep the existing afterEach clearing but add this afterAll cleanup
referencing stderrSpy and mockRestore.

Comment on lines +35 to +47
function mockResponse(body: string, init?: { status?: number; headers?: Record<string, string> }) {
const status = init?.status ?? 200;
const headers = new Map(Object.entries(init?.headers ?? { "content-type": "text/html" }));
return {
ok: status >= 200 && status < 400,
status,
headers: {
entries: () => headers.entries(),
get: (k: string) => headers.get(k.toLowerCase()) ?? null,
},
text: () => Promise.resolve(body),
url: "https://example.com",
};

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
git ls-files | head -20

Repository: kyalabs-Io/badge-server

Length of output: 406


🏁 Script executed:

#!/bin/bash
fd "webFetch" --type f

Repository: kyalabs-Io/badge-server

Length of output: 115


🏁 Script executed:

#!/bin/bash
cat -n src/tools/webFetch.test.ts | head -100

Repository: kyalabs-Io/badge-server

Length of output: 3908


🏁 Script executed:

#!/bin/bash
cat -n src/tools/webFetch.test.ts | sed -n '100,200p'

Repository: kyalabs-Io/badge-server

Length of output: 4590


🏁 Script executed:

#!/bin/bash
cat -n src/tools/webFetch.ts

Repository: kyalabs-Io/badge-server

Length of output: 6857


🌐 Web query:

Fetch API Response.ok specification MDN

💡 Result:

The Response.ok read-only property of the Fetch API's Response interface is a boolean indicating whether the HTTP response is successful, i.e., if the status code is in the range 200-299. It returns true for status codes 200 through 299, and false otherwise (including status 0 for opaque responses). Example from MDN: fetch(myRequest).then((response) => { console.log(response.ok); // true if successful }); This is defined in the Fetch Standard, referenced in MDN documentation.

Citations:


The mock incorrectly marks 3xx responses as ok: true.

Per the Fetch API spec, Response.ok is true only for status codes 200–299. The current mock marks all 2xx and 3xx as ok, which diverges from real Fetch behavior. This can mask future regressions if code depends on response.ok to validate redirect responses.

Fix
   return {
-    ok: status >= 200 && status < 400,
+    ok: status >= 200 && status < 300,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/webFetch.test.ts` around lines 35 - 47, The mockResponse helper
incorrectly sets ok for 3xx codes; update the mockResponse function so its ok
property matches Fetch spec (true only for 200–299) by computing ok as status >=
200 && status < 300 (or status <= 299), keeping the rest of the mock behavior
(headers, text, url) unchanged so tests reflect real Response.ok semantics.

Comment thread src/tools/webFetch.test.ts Outdated
Comment on lines +230 to +237
it("does not allow agent to override Kya-Token via headers param", async () => {
mockFetch.mockResolvedValue(mockResponse("ok"));
await webFetch("https://example.com", "GET", { "Kya-Token": "evil" });
const fetchCall = mockFetch.mock.calls.find(
(c: any[]) => c[0] === "https://example.com"
);
// Our token wins, not the agent's
expect(fetchCall![1].headers["Kya-Token"]).toBe("pk_test_abc123");

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, locate the webFetch.test.ts file and check its existence
find . -name "webFetch.test.ts" -type f

Repository: kyalabs-Io/badge-server

Length of output: 95


🏁 Script executed:

#!/bin/bash
# Get the relevant lines from the test file
if [ -f "src/tools/webFetch.test.ts" ]; then
  echo "=== Lines 230-237 ===" 
  sed -n '230,237p' src/tools/webFetch.test.ts
  echo ""
  echo "=== Context around line 230 (225-250) ==="
  sed -n '225,250p' src/tools/webFetch.test.ts
fi

Repository: kyalabs-Io/badge-server

Length of output: 1602


🏁 Script executed:

#!/bin/bash
# Check the webFetch implementation
if [ -f "src/tools/webFetch.ts" ]; then
  echo "=== webFetch.ts implementation ===" 
  cat -n src/tools/webFetch.ts
fi

Repository: kyalabs-Io/badge-server

Length of output: 6892


🏁 Script executed:

#!/bin/bash
# Check if there are any other tests for case-insensitive header handling
grep -n -i "kya-token\|kya.token" src/tools/webFetch.test.ts | head -20

Repository: kyalabs-Io/badge-server

Length of output: 577


🏁 Script executed:

#!/bin/bash
# Search for any case normalization in the webFetch implementation
grep -n -i "lowercase\|toLocaleCase\|normalize" src/tools/webFetch.ts

Repository: kyalabs-Io/badge-server

Length of output: 171


Add case-insensitive override test for Kya-Token.

HTTP header names are case-insensitive (RFC 7230), but the current test only checks the exact "Kya-Token" spelling. The implementation merges agent headers and server headers using plain object spread, which is case-sensitive. An agent passing "kya-token", "KYA-TOKEN", or other case variants would create separate object keys, potentially allowing the agent's value to coexist with or override the server's token depending on merge order and fetch implementation behavior. Add tests for lowercase and mixed-case variants to ensure all case permutations are protected.

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

In `@src/tools/webFetch.test.ts` around lines 230 - 237, The test must cover
case-insensitive header names: add one or two tests that call webFetch (same as
existing test using mockFetch and mockResponse) passing agent headers with
different casing like "kya-token" and "KYA-TOKEN" and assert the server token
still wins; locate the existing test for webFetch and either extend it or add
new it() blocks that find the fetch call (using mockFetch.mock.calls and the URL
check as in the current test) and verify by normalizing header names (e.g.,
lowercasing keys from fetchCall[1].headers) that the value for "kya-token"
equals the expected server token ("pk_test_abc123"), ensuring all case
permutations cannot override the server token.

Comment thread src/tools/webFetch.ts Outdated
Comment thread src/tools/webFetch.ts
Comment on lines +105 to +117
let response: Response;
try {
response = await fetch(url, {
method: resolvedMethod,
headers: requestHeaders,
redirect: "manual",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return { error: "Request timed out", code: "TIMEOUT" };
}
return { error: "Failed to fetch URL", code: "FETCH_ERROR" };

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if webFetch.ts exists and examine its content
fd -t f "webFetch.ts" || echo "File not found with fd"

Repository: kyalabs-Io/badge-server

Length of output: 88


🏁 Script executed:

#!/bin/bash
# List TypeScript files in src/tools to understand structure
ls -la src/tools/ 2>/dev/null || echo "src/tools not found"

Repository: kyalabs-Io/badge-server

Length of output: 587


🏁 Script executed:

#!/bin/bash
# Search for fetch-related files more broadly
find . -name "*fetch*" -o -name "*web*" 2>/dev/null | head -20

Repository: kyalabs-Io/badge-server

Length of output: 119


🏁 Script executed:

#!/bin/bash
# Read webFetch.ts with line numbers to examine lines 105-117
cat -n src/tools/webFetch.ts | sed -n '95,125p'

Repository: kyalabs-Io/badge-server

Length of output: 1088


🏁 Script executed:

#!/bin/bash
# Get total line count to understand file size
wc -l src/tools/webFetch.ts

Repository: kyalabs-Io/badge-server

Length of output: 92


🏁 Script executed:

#!/bin/bash
# Check package.json for Node version and dependencies
cat package.json | head -50

Repository: kyalabs-Io/badge-server

Length of output: 1201


🏁 Script executed:

#!/bin/bash
# Search for any timeout-related handling or tests
rg -i "timeout|aborterror|timeouterror" --type ts

Repository: kyalabs-Io/badge-server

Length of output: 4606


🏁 Script executed:

#!/bin/bash
# Read the webFetch.test.ts file to see timeout tests
cat -n src/tools/webFetch.test.ts

Repository: kyalabs-Io/badge-server

Length of output: 12338


🏁 Script executed (no clone):

#!/bin/bash
# Research AbortSignal.timeout() behavior in Node.js
node --version 2>/dev/null || echo "Node not available locally"

Length of output: 38


🌐 Web query:

AbortSignal.timeout() Node.js 20 TimeoutError AbortError behavior

💡 Result:

In Node.js 20, AbortSignal.timeout(delay) creates an AbortSignal that automatically aborts after the specified milliseconds by internally using setTimeout to call abort on the signal with a TimeoutError DOMException as the reason (signal.reason). This feature was added in Node.js v16.14.0 via commit 62c4b4c and is stable in v20 (LTS). When passed to abortable APIs: - Timers.promises (setTimeout, setImmediate, etc.): Rejects with AbortError if aborted, ignoring the reason. Check err.name === 'AbortError'. TimeoutError is not directly thrown by the timer; it's only the abort reason. - Fetch (powered by undici): Rejects with AbortError in most cases (spec-compliant behavior for fetch), even when triggered by AbortSignal.timeout. Some undici issues (#2171, #3296) note inconsistent TimeoutError vs AbortError depending on timing (headers vs body), but generally expects AbortError. Recent blog post (2025) shows TimeoutError example, possibly fixed or specific conditions. To distinguish timeout from manual abort, check if (err.name === 'AbortError' && err.cause?.name === 'TimeoutError'). Official Node.js docs do not explicitly detail error types for AbortSignal.timeout consumers, aligning with Web API spec where abort reasons may not propagate to AbortError in fetch/timers. Example: try { await fetch(url, { signal: AbortSignal.timeout(5000) }); } catch (err) { if (err.name === 'AbortError' && err.cause?.name === 'TimeoutError') { console.log('Request timed out'); } } For timers: const { setTimeout } = require('timers/promises'); try { await setTimeout(5000, null, { signal: AbortSignal.timeout(3000) }); } catch (err) { if (err.name === 'AbortError') console.log('Aborted (check signal.reason for TimeoutError)'); } Node.js 20 behavior matches spec: signal aborts with TimeoutError reason, but consuming APIs throw AbortError.

Citations:


🌐 Web query:

Node.js undici fetch AbortSignal.timeout() error types 2024 2025

💡 Result:

In Node.js undici fetch with AbortSignal.timeout, the primary error type thrown on timeout is DOMException [AbortError]: "The operation was aborted." This occurs because AbortSignal.timeout aborts the signal with a TimeoutError DOMException as its reason, but fetch rejects with AbortError per the Fetch spec when the signal aborts during the request. A DOMException [TimeoutError] can occur in specific timing scenarios (e.g., connect timeout or internal undici timeouts during headers/body reading), but this is inconsistent and considered spec-compliant behavior where AbortError is expected for signal aborts. No changes specific to 2024-2025 alter this fundamental behavior; recent issues discuss related bugs but confirm AbortError as the standard outcome for AbortSignal.timeout in fetch. Undici's own errors (e.g., ConnectTimeoutError) are separate from fetch signal aborts and use custom UndiciError subclasses.

Citations:


🏁 Script executed:

#!/bin/bash
# Search for other AbortSignal.timeout usage
rg "AbortSignal\.timeout" --type ts -B 3 -A 5

Repository: kyalabs-Io/badge-server

Length of output: 1023


🏁 Script executed:

#!/bin/bash
# Check if TimeoutError is handled anywhere in codebase
rg "TimeoutError" --type ts

Repository: kyalabs-Io/badge-server

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Check error handling in ucp-manifest.ts for AbortSignal.timeout
cat -n src/lib/ucp-manifest.ts | sed -n '1,50p'

Repository: kyalabs-Io/badge-server

Length of output: 1866


🏁 Script executed:

#!/bin/bash
# Search for the try-catch around ucp-manifest fetch
rg -B 5 -A 10 "ucp-manifest" src/lib/ucp-manifest.ts | head -50

Repository: kyalabs-Io/badge-server

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Get full context of ucp-manifest fetch call
grep -n "AbortSignal.timeout" src/lib/ucp-manifest.ts -B 10 -A 10

Repository: kyalabs-Io/badge-server

Length of output: 789


TimeoutError from AbortSignal.timeout() will be misclassified as FETCH_ERROR.

AbortSignal.timeout() is designed to abort with AbortError per the Fetch spec; however, undici (Node.js's fetch implementation) can throw TimeoutError in timing-dependent scenarios during headers or body reading (nodejs/undici#2171, #3296). The current code only checks for AbortError, so actual timeouts surfacing as TimeoutError will fall through and return FETCH_ERROR instead of TIMEOUT.

Suggested fix
-  let response: Response;
+  let response: Response;
+  const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
   try {
     response = await fetch(url, {
       method: resolvedMethod,
       headers: requestHeaders,
       redirect: "manual",
-      signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
+      signal,
     });
   } catch (err) {
-    if (err instanceof Error && err.name === "AbortError") {
+    if (
+      signal.aborted ||
+      (err instanceof Error &&
+        (err.name === "AbortError" || err.name === "TimeoutError"))
+    ) {
       return { error: "Request timed out", code: "TIMEOUT" };
     }
     return { error: "Failed to fetch URL", code: "FETCH_ERROR" };
   }
📝 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
let response: Response;
try {
response = await fetch(url, {
method: resolvedMethod,
headers: requestHeaders,
redirect: "manual",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return { error: "Request timed out", code: "TIMEOUT" };
}
return { error: "Failed to fetch URL", code: "FETCH_ERROR" };
let response: Response;
const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
try {
response = await fetch(url, {
method: resolvedMethod,
headers: requestHeaders,
redirect: "manual",
signal,
});
} catch (err) {
if (
signal.aborted ||
(err instanceof Error &&
(err.name === "AbortError" || err.name === "TimeoutError"))
) {
return { error: "Request timed out", code: "TIMEOUT" };
}
return { error: "Failed to fetch URL", code: "FETCH_ERROR" };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/webFetch.ts` around lines 105 - 117, The catch block that
classifies timeouts only checks for AbortError so undici's TimeoutError (thrown
during headers/body reads) is misclassified; update the error handling around
the fetch (the variables/constructs: response, err, AbortSignal.timeout,
FETCH_TIMEOUT_MS) to treat both AbortError and TimeoutError as timeouts by
checking err.name === "AbortError" || err.name === "TimeoutError" (or
err.constructor?.name === "TimeoutError"), and return the same { error: "Request
timed out", code: "TIMEOUT" } for either case; optionally, if undici's
TimeoutError class can be imported in your environment, use instanceof
TimeoutError for a stronger check.

Comment thread src/tools/webFetch.ts
Comment on lines +120 to +131
// 8. Read body with size cap
let body: string;
let truncated = false;
try {
body = await response.text();
if (body.length > MAX_BODY_BYTES) {
body = body.slice(0, MAX_BODY_BYTES);
truncated = true;
}
} catch {
body = "";
}

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate webFetch.ts and understand the code structure
find . -name "webFetch.ts" -o -name "webFetch.js" | head -20

Repository: kyalabs-Io/badge-server

Length of output: 90


🏁 Script executed:

#!/bin/bash
# Read webFetch.ts to understand the code structure
wc -l ./src/tools/webFetch.ts

Repository: kyalabs-Io/badge-server

Length of output: 94


🏁 Script executed:

#!/bin/bash
# Read the entire webFetch.ts file
cat -n ./src/tools/webFetch.ts

Repository: kyalabs-Io/badge-server

Length of output: 6857


🏁 Script executed:

#!/bin/bash
# Check if there are any tests or related code that might show how this is used
# or what Node.js version is targeted
grep -r "MAX_BODY_BYTES\|response\.text\|getReader" --include="*.ts" --include="*.js" --include="*.json" | head -20

Repository: kyalabs-Io/badge-server

Length of output: 313


🏁 Script executed:

#!/bin/bash
# Check package.json for Node.js version requirements
cat package.json | grep -A 5 -B 5 "node\|engines" | head -30

Repository: kyalabs-Io/badge-server

Length of output: 784


🏁 Script executed:

#!/bin/bash
# Check if there are tests for webFetch
find . -name "*webFetch*.test.*" -o -name "*webFetch*.spec.*" | head -10

Repository: kyalabs-Io/badge-server

Length of output: 95


🏁 Script executed:

#!/bin/bash
# Read the test file to understand usage patterns
cat -n ./src/tools/webFetch.test.ts

Repository: kyalabs-Io/badge-server

Length of output: 12338


The 5MB limit is enforced after the full response is already buffered, and body.length counts UTF-16 code units rather than bytes.

response.text() reads the entire response before truncation occurs (lines 124–127). More critically, JavaScript string .length measures UTF-16 code units, not bytes. A response containing multi-byte UTF-8 characters (emoji, CJK) can exceed the advertised 5MB byte limit while still passing the code-unit-based check. For example, a 5.3MB response of 4-byte UTF-8 characters would be decoded into fewer than 5,242,880 UTF-16 code units and would not be truncated.

The proposed streaming fix using response.body?.getReader() correctly counts actual bytes via value.byteLength and truncates at the proper boundary.

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

In `@src/tools/webFetch.ts` around lines 120 - 131, The current block uses
response.text() and string.length which buffers the entire body and counts
UTF-16 code units instead of bytes; replace that logic in the function that
reads the response (the body/truncated handling around response.text(),
MAX_BODY_BYTES, and truncated) with a streaming reader: use
response.body?.getReader() to read Uint8Array chunks, sum value.byteLength to
enforce MAX_BODY_BYTES, push bytes into a buffer (or decode incrementally with a
TextDecoder) up to the byte limit, set truncated = true when the limit would be
exceeded and stop reading further, and then produce the final body string from
the collected bytes; ensure the fallback catch logic remains if response.body is
absent or reading fails.

kyalabs and others added 2 commits March 25, 2026 15:31
…YA-98)

The #1 E2E blocker. getHeaders() and webFetch() were injecting the
consent key (pk_* / OAuth token) as Kya-Token header. The verify
endpoint expects kya_* opaque tokens from /api/badge/enroll.

Fix:
- New module badge-token.ts: per-merchant badge token cache + enroll API
- getAgentIdentity: calls enroll after identity established (fire-and-forget)
- webFetch: looks up cached badge token by merchant, enrolls on-the-fly if needed
- getHeaders: reads from badge token cache (last enrolled merchant)

Also updates NEXT_STEP_TEXT to reference 2.0 tools (KYA-105).

178 tests passing (10 new in badge-token.test.ts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Enroll endpoint now requires auth. badge-token.ts must send the
consent key as Authorization: Bearer header when calling enroll.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (1)
src/tools/webFetch.test.ts (1)

41-53: ⚠️ Potential issue | 🟡 Minor

Make mockResponse.ok match real Fetch behavior.

Response.ok is true only for 2xx responses. Treating 3xx as ok makes the redirect-path assertions less trustworthy.

🧩 Proposed fix
   return {
-    ok: status >= 200 && status < 400,
+    ok: status >= 200 && status < 300,
     status,
     headers: {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/webFetch.test.ts` around lines 41 - 53, The mockResponse helper
currently sets ok for 2xx–3xx; update mockResponse so Response.ok mirrors real
Fetch by making ok true only for 2xx (i.e., status >= 200 && status < 300).
Locate the mockResponse function and change the ok calculation accordingly (keep
the rest of the return shape the same so tests using headers, text, and url
behave unchanged).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/badge-token.test.ts`:
- Around line 48-55: The enroll API assertion is missing verification that the
request includes the consent key; after parsing opts.body (where
mockFetch.mock.calls[0] is unpacked into url and opts and assigned to body), add
an assertion that body.consent exists and equals the expected consent value used
by the test (or at minimum is truthy), so update the assertions around mockFetch
/ body to include expect(body.consent)... using the same expected consent
variable from the test setup.

In `@src/lib/badge-token.ts`:
- Around line 22-23: getCachedBadgeToken() returns a cached token but never
updates the process-global lastEnrolledMerchant, causing subsequent calls to
kya_getHeaders / getHeaders to pick the wrong merchant; update
lastEnrolledMerchant whenever getCachedBadgeToken() returns a cached token (set
lastEnrolledMerchant = merchant argument or derived merchant id inside
getCachedBadgeToken), and likewise ensure any other cache-hit paths in
getBadgeToken/getCachedBadgeToken and the no-arg getHeaders/kya_getHeaders
fallback refresh lastEnrolledMerchant before returning so the global pointer
matches the token being returned; longer-term, consider changing
getHeaders/kya_getHeaders to require explicit merchant context instead of
relying on lastEnrolledMerchant.
- Around line 35-47: The enroll request is currently sent without the consent
key even though consentKey is required; update the POST to
`${apiUrl}/api/badge/enroll` to include the consent key (consentKey returned
from getStoredConsentKey) in the payload so the server can authenticate the
enroll call—i.e., modify the JSON body passed to fetch (the JSON.stringify call
in the fetch within this block) to include consent_key: consentKey (or add an
appropriate header if your API expects it) alongside merchant and install_id.

In `@src/tools/getAgentIdentity.ts`:
- Line 163: The message advertises using kya_getHeaders before the badge-token
enrollment completes, causing callers of kya_getAgentIdentity to get NO_IDENTITY
if the cache isn’t populated yet; fix by ensuring the enrollment is awaited:
either have kya_getAgentIdentity await the background enrollment Promise before
returning (so the cache is populated) or change kya_getHeaders to await a shared
in-flight enrollment Promise (e.g., badgeEnrollmentPromise /
startBadgeEnrollment()) before returning headers; update the code paths that
kick off enrollment (the background call around lines ~296-300) to store and
reuse a single Promise so both kya_getAgentIdentity and kya_getHeaders can await
it.

---

Duplicate comments:
In `@src/tools/webFetch.test.ts`:
- Around line 41-53: The mockResponse helper currently sets ok for 2xx–3xx;
update mockResponse so Response.ok mirrors real Fetch by making ok true only for
2xx (i.e., status >= 200 && status < 300). Locate the mockResponse function and
change the ok calculation accordingly (keep the rest of the return shape the
same so tests using headers, text, and url behave unchanged).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8672f480-a98b-4023-a9b0-842a60a65e0f

📥 Commits

Reviewing files that changed from the base of the PR and between f789483 and c154b4d.

📒 Files selected for processing (7)
  • src/lib/badge-token.test.ts
  • src/lib/badge-token.ts
  • src/tools/getAgentIdentity.ts
  • src/tools/getHeaders.test.ts
  • src/tools/getHeaders.ts
  • src/tools/webFetch.test.ts
  • src/tools/webFetch.ts
✅ Files skipped from review due to trivial changes (1)
  • src/tools/getHeaders.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/tools/getHeaders.test.ts
  • src/tools/webFetch.ts

Comment on lines +48 to +55
// Verify the enroll API was called correctly
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toContain("/api/badge/enroll");
expect(opts.method).toBe("POST");
const body = JSON.parse(opts.body);
expect(body.merchant).toBe("etsy.com");
expect(body.install_id).toBe("inst-aaaa-bbbb-cccc-dddddddddddd");

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 | 🟡 Minor

Assert that /api/badge/enroll actually carries the consent key.

This only checks the body today, so the test still passes if the enroll call is anonymous.

🧪 Proposed assertion
     const [url, opts] = mockFetch.mock.calls[0];
     expect(url).toContain("/api/badge/enroll");
     expect(opts.method).toBe("POST");
+    expect(opts.headers.Authorization).toBe("Bearer pk_test_abc123");
     const body = JSON.parse(opts.body);
     expect(body.merchant).toBe("etsy.com");
     expect(body.install_id).toBe("inst-aaaa-bbbb-cccc-dddddddddddd");
📝 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
// Verify the enroll API was called correctly
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toContain("/api/badge/enroll");
expect(opts.method).toBe("POST");
const body = JSON.parse(opts.body);
expect(body.merchant).toBe("etsy.com");
expect(body.install_id).toBe("inst-aaaa-bbbb-cccc-dddddddddddd");
// Verify the enroll API was called correctly
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toContain("/api/badge/enroll");
expect(opts.method).toBe("POST");
expect(opts.headers.Authorization).toBe("Bearer pk_test_abc123");
const body = JSON.parse(opts.body);
expect(body.merchant).toBe("etsy.com");
expect(body.install_id).toBe("inst-aaaa-bbbb-cccc-dddddddddddd");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/badge-token.test.ts` around lines 48 - 55, The enroll API assertion
is missing verification that the request includes the consent key; after parsing
opts.body (where mockFetch.mock.calls[0] is unpacked into url and opts and
assigned to body), add an assertion that body.consent exists and equals the
expected consent value used by the test (or at minimum is truthy), so update the
assertions around mockFetch / body to include expect(body.consent)... using the
same expected consent variable from the test setup.

Comment thread src/lib/badge-token.ts
Comment on lines +22 to +23
/** Track the last enrolled merchant for getHeaders() (no merchant context). */
let lastEnrolledMerchant: string | null = null;

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 | 🟠 Major

lastEnrolledMerchant goes stale on cache hits.

When Line 33 returns a cached token, the “active” merchant is not refreshed, but Lines 80-81 still use lastEnrolledMerchant as the no-arg source of truth. After a multi-merchant session, getCachedBadgeToken() can hand kya_getHeaders the wrong merchant token even without any async race. At minimum refresh it on cache hits; longer-term this lookup should take explicit merchant context instead of a process-global pointer.

🩹 Minimum fix for the stale cache-hit case
   const cached = badgeTokenCache.get(merchant);
-  if (cached) return cached;
+  if (cached) {
+    lastEnrolledMerchant = merchant;
+    return cached;
+  }

Also applies to: 30-33, 61-62, 75-83

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

In `@src/lib/badge-token.ts` around lines 22 - 23, getCachedBadgeToken() returns a
cached token but never updates the process-global lastEnrolledMerchant, causing
subsequent calls to kya_getHeaders / getHeaders to pick the wrong merchant;
update lastEnrolledMerchant whenever getCachedBadgeToken() returns a cached
token (set lastEnrolledMerchant = merchant argument or derived merchant id
inside getCachedBadgeToken), and likewise ensure any other cache-hit paths in
getBadgeToken/getCachedBadgeToken and the no-arg getHeaders/kya_getHeaders
fallback refresh lastEnrolledMerchant before returning so the global pointer
matches the token being returned; longer-term, consider changing
getHeaders/kya_getHeaders to require explicit merchant context instead of
relying on lastEnrolledMerchant.

Comment thread src/lib/badge-token.ts
const AGENT_TYPE = "badge-mcp";
const NEXT_STEP_TEXT =
"When visiting a merchant, report your badge presentation using kya_reportBadgePresented with the merchant domain and your verification token.";
"Your identity is attached automatically via kya_web_fetch. Use kya_web_fetch instead of web_fetch when shopping — it injects your Kya-Token header and records your visit. For browser automation, use kya_getHeaders to get headers for setExtraHTTPHeaders.";

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 | 🟠 Major

Don’t advertise kya_getHeaders before the badge-token enroll finishes.

Line 163 says the next step is kya_getHeaders, but Line 300 fires enrollment in the background. A caller can successfully complete kya_getAgentIdentity(...) and immediately get NO_IDENTITY because the cache is still empty. Either await this here or have kya_getHeaders await a shared in-flight enrollment.

Also applies to: 296-300

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

In `@src/tools/getAgentIdentity.ts` at line 163, The message advertises using
kya_getHeaders before the badge-token enrollment completes, causing callers of
kya_getAgentIdentity to get NO_IDENTITY if the cache isn’t populated yet; fix by
ensuring the enrollment is awaited: either have kya_getAgentIdentity await the
background enrollment Promise before returning (so the cache is populated) or
change kya_getHeaders to await a shared in-flight enrollment Promise (e.g.,
badgeEnrollmentPromise / startBadgeEnrollment()) before returning headers;
update the code paths that kick off enrollment (the background call around lines
~296-300) to store and reuse a single Promise so both kya_getAgentIdentity and
kya_getHeaders can await it.

- Filter case-variant kya-token headers before spreading in webFetch
  so agents can't override the server-injected Kya-Token (Badge #6)
- Update lastEnrolledMerchant on cache hit in getCachedBadgeToken
  so no-arg getHeaders() returns the correct merchant context (Badge #11)

178/178 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/tools/webFetch.ts (1)

61-82: Minor: Step numbering in comments is inconsistent.

Comments jump from "1." (line 61) to "3." (line 69), "4." (line 76), "5." (line 81), then two "7."s (lines 104, 119). Consider renumbering for maintainability.

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

In `@src/tools/webFetch.ts` around lines 61 - 82, The inline step numbering in the
fetch validation comments is inconsistent (e.g., the block around URL
parsing/validation using parsed, the scheme check using isHttps/isTestHttp, the
SSRF check using isPublicOrigin, and the merchant extraction using merchant) and
later duplicated "7." steps; renumber the comment steps sequentially to reflect
the actual flow (1, 2, 3, 4, 5, ...) around those symbols so future readers can
follow the validation stages in order and remove the duplicate "7." labels.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/tools/webFetch.ts`:
- Around line 61-82: The inline step numbering in the fetch validation comments
is inconsistent (e.g., the block around URL parsing/validation using parsed, the
scheme check using isHttps/isTestHttp, the SSRF check using isPublicOrigin, and
the merchant extraction using merchant) and later duplicated "7." steps;
renumber the comment steps sequentially to reflect the actual flow (1, 2, 3, 4,
5, ...) around those symbols so future readers can follow the validation stages
in order and remove the duplicate "7." labels.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9afa85ba-1e7d-459f-af2d-80cae096bbb6

📥 Commits

Reviewing files that changed from the base of the PR and between 512ca51 and ddc1104.

📒 Files selected for processing (2)
  • src/lib/badge-token.ts
  • src/tools/webFetch.ts
✅ Files skipped from review due to trivial changes (1)
  • src/lib/badge-token.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant