From 9011b50b553b4f7be71fc01d3bb2dad508915a2a Mon Sep 17 00:00:00 2001 From: chocothebot Date: Mon, 18 May 2026 18:11:54 +0000 Subject: [PATCH] fix: expand "me" shorthand on GET /v1/agents/me/tap and /me/reputation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both getTAPAgentRoute and getReputationRoute passed the literal string "me" to the KV lookup when agents used the /agents/me/... shorthand, returning AGENT_NOT_FOUND instead of the agent's own profile. Fix: - getTAPAgentRoute: if rawId === "me", call validateTAPAppAccess and use appAccess.agentId (consistent with the existing /v1/agents/:id handler in index.tsx which already supports the "me" shorthand) - getReputationRoute: same approach — validateTAPAppAccess is already called for auth; agentId is now extracted from it when rawId === "me" Both error cases are handled: - No token → 401 UNAUTHORIZED - Token with no agent_id claim (app-level token) → 401 UNAUTHORIZED 8 regression tests added covering both routes, both error paths, and the non-"me" path (regression guard). Confirmed live: GET /v1/agents/me/reputation returned REPUTATION_LOOKUP_FAILED "Agent not found" before this fix; /v1/agents/me/tap returned AGENT_NOT_FOUND. --- .../src/tap-reputation-routes.ts | 17 +- packages/cloudflare-workers/src/tap-routes.ts | 25 +- .../fix-me-shorthand-2026-05-18.test.ts | 307 ++++++++++++++++++ 3 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 tests/unit/agents/fix-me-shorthand-2026-05-18.test.ts diff --git a/packages/cloudflare-workers/src/tap-reputation-routes.ts b/packages/cloudflare-workers/src/tap-reputation-routes.ts index e249ad8..84963de 100644 --- a/packages/cloudflare-workers/src/tap-reputation-routes.ts +++ b/packages/cloudflare-workers/src/tap-reputation-routes.ts @@ -36,8 +36,8 @@ import { export async function getReputationRoute(c: Context) { try { // Support both :agent_id (primary route) and :id (alias route /v1/agents/:id/reputation) - const agentId = c.req.param('agent_id') || c.req.param('id'); - if (!agentId) { + const rawId = c.req.param('agent_id') || c.req.param('id'); + if (!rawId) { return c.json({ success: false, error: 'MISSING_AGENT_ID', @@ -54,6 +54,19 @@ export async function getReputationRoute(c: Context) { }, (appAccess.status || 401) as 401); } + // Support "me" as a shorthand for the authenticated agent + let agentId = rawId; + if (rawId === 'me') { + if (!appAccess.agentId) { + return c.json({ + success: false, + error: 'UNAUTHORIZED', + message: 'Cannot use "me" shorthand — token does not carry an agent_id. Use a botcha-verified or agent-identity token.', + }, 401); + } + agentId = appAccess.agentId; + } + const result = await getReputationScore( c.env.SESSIONS, c.env.AGENTS, diff --git a/packages/cloudflare-workers/src/tap-routes.ts b/packages/cloudflare-workers/src/tap-routes.ts index 8ecdb9d..825ea2b 100644 --- a/packages/cloudflare-workers/src/tap-routes.ts +++ b/packages/cloudflare-workers/src/tap-routes.ts @@ -294,14 +294,35 @@ export async function registerTAPAgentRoute(c: Context) { */ export async function getTAPAgentRoute(c: Context) { try { - const agentId = c.req.param('id'); - if (!agentId) { + const rawId = c.req.param('id'); + if (!rawId) { return c.json({ success: false, error: 'MISSING_AGENT_ID', message: 'Agent ID is required' }, 400); } + + // Support "me" as a shorthand for the authenticated agent + let agentId = rawId; + if (rawId === 'me') { + const appAccess = await validateTAPAppAccess(c, true); + if (!appAccess.valid) { + return c.json({ + success: false, + error: appAccess.error, + message: 'Authentication required to use "me" shorthand', + }, (appAccess.status || 401) as 401); + } + if (!appAccess.agentId) { + return c.json({ + success: false, + error: 'UNAUTHORIZED', + message: 'Cannot use "me" shorthand — token does not carry an agent_id. Use a botcha-verified or agent-identity token.', + }, 401); + } + agentId = appAccess.agentId; + } const result = await getTAPAgent(c.env.AGENTS, agentId); diff --git a/tests/unit/agents/fix-me-shorthand-2026-05-18.test.ts b/tests/unit/agents/fix-me-shorthand-2026-05-18.test.ts new file mode 100644 index 0000000..0bb02b5 --- /dev/null +++ b/tests/unit/agents/fix-me-shorthand-2026-05-18.test.ts @@ -0,0 +1,307 @@ +/** + * Regression tests for "me" shorthand support on TAP sub-routes (2026-05-18). + * + * Bug: GET /v1/agents/me/tap and GET /v1/agents/me/reputation both returned + * AGENT_NOT_FOUND because the literal string "me" was passed to the KV + * lookup instead of being expanded to the authenticated agent's ID. + * + * Fix: + * - getTAPAgentRoute: if rawId === 'me', call validateTAPAppAccess and use + * appAccess.agentId + * - getReputationRoute: if rawId === 'me', expand to appAccess.agentId from + * the already-called validateTAPAppAccess result + * + * Both routes already require auth; the fix reuses the existing token to + * extract the caller's agent_id rather than adding a second auth call. + */ + +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +// ─── Mock tap-auth-helpers before importing route handlers ────────────────── + +vi.mock('../../../packages/cloudflare-workers/src/tap-auth-helpers.js', () => ({ + validateTAPAppAccess: vi.fn(), +})); + +import { validateTAPAppAccess } from '../../../packages/cloudflare-workers/src/tap-auth-helpers.js'; +const mockValidateTAPAppAccess = validateTAPAppAccess as ReturnType; + +// ─── Mock KV stores used by route handlers ─────────────────────────────────── + +vi.mock('../../../packages/cloudflare-workers/src/tap-agents.js', () => ({ + getTAPAgent: vi.fn(), + generateKeyFingerprint: vi.fn().mockResolvedValue('deadbeef'), +})); + +import { getTAPAgent } from '../../../packages/cloudflare-workers/src/tap-agents.js'; +const mockGetTAPAgent = getTAPAgent as ReturnType; + +vi.mock('../../../packages/cloudflare-workers/src/tap-reputation.js', () => ({ + getReputationScore: vi.fn(), +})); + +import { getReputationScore } from '../../../packages/cloudflare-workers/src/tap-reputation.js'; +const mockGetReputationScore = getReputationScore as ReturnType; + +// ─── Import the route handlers under test ─────────────────────────────────── + +import { getTAPAgentRoute } from '../../../packages/cloudflare-workers/src/tap-routes.js'; +import { getReputationRoute } from '../../../packages/cloudflare-workers/src/tap-reputation-routes.js'; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const TEST_APP_ID = 'app_testme123'; +const TEST_AGENT_ID = 'agent_testme456'; +const SECRET = 'test-secret-key-12345'; + +// ─── Helper: minimal Hono context mock ────────────────────────────────────── + +class MockKV { + private store = new Map(); + async get(key: string) { return this.store.get(key) ?? null; } + async put(key: string, value: string) { this.store.set(key, value); } + async delete(key: string) { this.store.delete(key); } +} + +function createMockContext(paramValue: string, extraEnv: Record = {}) { + return { + req: { + json: vi.fn().mockResolvedValue({}), + param: vi.fn().mockImplementation((key: string) => + key === 'id' ? paramValue : undefined + ), + header: vi.fn().mockReturnValue(undefined), + query: vi.fn().mockReturnValue(undefined), + }, + json: vi.fn().mockImplementation((body: any, status?: number) => + new Response(JSON.stringify(body), { + status: status ?? 200, + headers: { 'content-type': 'application/json' }, + }) + ), + env: { + AGENTS: new MockKV(), + SESSIONS: new MockKV(), + JWT_SECRET: SECRET, + ...extraEnv, + }, + } as any; +} + +async function parseJson(response: Response) { + return response.json(); +} + +// ─── getTAPAgentRoute — "me" shorthand ─────────────────────────────────────── + +describe('getTAPAgentRoute: "me" shorthand', () => { + const fakeAgent = { + agent_id: TEST_AGENT_ID, + app_id: TEST_APP_ID, + name: 'Test Agent', + operator: 'Tester', + created_at: Date.now(), + tap_enabled: true, + trust_level: 'basic', + capabilities: [{ action: 'browse' }], + signature_algorithm: 'ed25519', + last_verified_at: Date.now(), + public_key: 'fakepublickey==', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('expands "me" to the authenticated agent_id from the token', async () => { + mockValidateTAPAppAccess.mockResolvedValue({ + valid: true, + appId: TEST_APP_ID, + agentId: TEST_AGENT_ID, + }); + mockGetTAPAgent.mockResolvedValue({ success: true, agent: fakeAgent }); + + const ctx = createMockContext('me'); + const res = await getTAPAgentRoute(ctx); + const body = await parseJson(res); + + expect(body.success).toBe(true); + expect(body.agent_id).toBe(TEST_AGENT_ID); + // Verify it looked up the real agent_id, not the literal "me" + expect(mockGetTAPAgent).toHaveBeenCalledWith(expect.anything(), TEST_AGENT_ID); + }); + + test('returns 401 when no token is provided with "me"', async () => { + mockValidateTAPAppAccess.mockResolvedValue({ + valid: false, + error: 'UNAUTHORIZED', + status: 401, + }); + + const ctx = createMockContext('me'); + const res = await getTAPAgentRoute(ctx); + const body = await parseJson(res); + + expect(res.status).toBe(401); + expect(body.success).toBe(false); + expect(body.error).toBe('UNAUTHORIZED'); + // Should NOT have called getTAPAgent at all + expect(mockGetTAPAgent).not.toHaveBeenCalled(); + }); + + test('returns 401 when token is valid but carries no agent_id', async () => { + // e.g. an app-level token (no agent_id claim) + mockValidateTAPAppAccess.mockResolvedValue({ + valid: true, + appId: TEST_APP_ID, + agentId: undefined, + }); + + const ctx = createMockContext('me'); + const res = await getTAPAgentRoute(ctx); + const body = await parseJson(res); + + expect(res.status).toBe(401); + expect(body.success).toBe(false); + expect(body.error).toBe('UNAUTHORIZED'); + expect(mockGetTAPAgent).not.toHaveBeenCalled(); + }); + + test('still works with an explicit agent_id (non-"me" path unaffected)', async () => { + mockGetTAPAgent.mockResolvedValue({ success: true, agent: fakeAgent }); + + const ctx = createMockContext(TEST_AGENT_ID); + const res = await getTAPAgentRoute(ctx); + const body = await parseJson(res); + + expect(body.success).toBe(true); + // validateTAPAppAccess should NOT have been called for a regular ID + expect(mockValidateTAPAppAccess).not.toHaveBeenCalled(); + expect(mockGetTAPAgent).toHaveBeenCalledWith(expect.anything(), TEST_AGENT_ID); + }); +}); + +// ─── getReputationRoute — "me" shorthand ──────────────────────────────────── + +describe('getReputationRoute: "me" shorthand', () => { + const fakeScore = { + agent_id: TEST_AGENT_ID, + app_id: TEST_APP_ID, + score: 500, + tier: 'neutral', + event_count: 3, + positive_events: 3, + negative_events: 0, + last_event_at: new Date().toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + category_scores: { verification: 5 }, + }; + + function createRepMockContext(paramValue: string) { + return { + req: { + json: vi.fn().mockResolvedValue({}), + param: vi.fn().mockImplementation((key: string) => { + if (key === 'id') return paramValue; + if (key === 'agent_id') return undefined; + return undefined; + }), + header: vi.fn().mockReturnValue(undefined), + query: vi.fn().mockReturnValue(undefined), + }, + json: vi.fn().mockImplementation((body: any, status?: number) => + new Response(JSON.stringify(body), { + status: status ?? 200, + headers: { 'content-type': 'application/json' }, + }) + ), + env: { + AGENTS: new MockKV(), + SESSIONS: new MockKV(), + JWT_SECRET: SECRET, + }, + } as any; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('expands "me" to the authenticated agent_id from the token', async () => { + mockValidateTAPAppAccess.mockResolvedValue({ + valid: true, + appId: TEST_APP_ID, + agentId: TEST_AGENT_ID, + }); + mockGetReputationScore.mockResolvedValue({ success: true, score: fakeScore }); + + const ctx = createRepMockContext('me'); + const res = await getReputationRoute(ctx); + const body = await parseJson(res); + + expect(body.success).toBe(true); + expect(body.agent_id).toBe(TEST_AGENT_ID); + expect(mockGetReputationScore).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + TEST_AGENT_ID, + TEST_APP_ID + ); + }); + + test('returns 401 when token is valid but carries no agent_id', async () => { + mockValidateTAPAppAccess.mockResolvedValue({ + valid: true, + appId: TEST_APP_ID, + agentId: undefined, // no agent claim in token + }); + + const ctx = createRepMockContext('me'); + const res = await getReputationRoute(ctx); + const body = await parseJson(res); + + expect(res.status).toBe(401); + expect(body.success).toBe(false); + expect(body.error).toBe('UNAUTHORIZED'); + expect(mockGetReputationScore).not.toHaveBeenCalled(); + }); + + test('still works with an explicit :id param (via /v1/agents/:id/reputation alias)', async () => { + mockValidateTAPAppAccess.mockResolvedValue({ + valid: true, + appId: TEST_APP_ID, + agentId: TEST_AGENT_ID, + }); + mockGetReputationScore.mockResolvedValue({ success: true, score: fakeScore }); + + const ctx = createRepMockContext(TEST_AGENT_ID); + const res = await getReputationRoute(ctx); + const body = await parseJson(res); + + expect(body.success).toBe(true); + // Should use the explicit id, not the token's agent_id + expect(mockGetReputationScore).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + TEST_AGENT_ID, + TEST_APP_ID + ); + }); + + test('returns 401 when no token provided with "me"', async () => { + mockValidateTAPAppAccess.mockResolvedValue({ + valid: false, + error: 'UNAUTHORIZED', + status: 401, + }); + + const ctx = createRepMockContext('me'); + const res = await getReputationRoute(ctx); + const body = await parseJson(res); + + expect(res.status).toBe(401); + expect(body.success).toBe(false); + expect(mockGetReputationScore).not.toHaveBeenCalled(); + }); +});