diff --git a/BUGS.md b/BUGS.md index fd844b8..079e689 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,6 +1,6 @@ # BOTCHA โ€” Active Issues Tracker -*Last updated: 2026-02-23 by Choco* +*Last updated: 2026-04-13 by Choco* --- @@ -20,42 +20,38 @@ Closed/merged work is tracked in `CHANGELOG.md`. This file tracks only open issu **Commit:** b6f0c98 **Fix:** Removed `requireDashboardAuth` from `GET /device`; device code is now the sole trust anchor (RFC 8628 ยง6.1) ---- +### PR #28 โ€” OIDC-A Attestation (MERGED) +Full OIDC-A attestation endpoint โ€” EAT tokens, agent grants, OAuth AS metadata. Merged. -## ๐Ÿ”„ IN PROGRESS (PR #28 โ€” open) +### PR #26 โ€” A2A Agent Card (MERGED) +A2A trust oracle with agent cards. Merged. -### PR #28 โ€” OIDC-A Attestation -**Landed on this branch:** -- โœ… `GET /v1/auth/agent-grant/:id/status` now requires bearer auth and enforces same-app ownership -- โœ… `POST /v1/auth/agent-grant/:id/resolve` now requires bearer auth and enforces same-app ownership -- โœ… `POST /v1/attestation/eat` now validates `ttl_seconds` as a positive finite number -- โœ… OIDC docs/metadata now use `/.well-known/jwks` (not `/v1/jwks`) -- โœ… Added focused OIDC-A tests (`tests/unit/agents/tap-oidca.test.ts`) -- โœ… Rebased with main and resolved route conflicts (`index.tsx`) -- โœ… OIDC-A routes documented in OpenAPI/static docs (`packages/cloudflare-workers/src/static.ts`) +--- -**Open issue:** -- ๐ŸŸก Grant resolve policy is app-owner scoped; stricter enterprise admin model may still be needed +## ๐Ÿ”„ IN PROGRESS -**Remaining before merge:** -1. Decide on stricter admin policy for grant resolve (currently app-owner scoped) -2. Final PR review + merge +### PR #41 โ€” TAP UX Improvements (open, needs BOTCHA verify + CI) +**URL:** https://github.com/dupe-com/botcha/pull/41 +**Bugs fixed (all confirmed on live API 2026-04-13):** +1. `GET /v1/agents/me` โ†’ 404 (now resolves from Bearer token) +2. `ttl_seconds: -100` on `POST /v1/sessions/tap` โ†’ silently accepted (now 400 INVALID_TTL) +3. `GET /v1/sessions/:id/tap` returns `time_remaining` in ms (renamed to `time_remaining_seconds`, now integer seconds) +4. `ACTION_CATEGORY_MISMATCH` error gives no hint about valid actions (now includes `valid_actions` array) +5. `GET /v1/agents/:id/reputation` โ†’ 404 (alias route added, must come before generic `:id`) -### TAP Route Test Stability (cross-branch) -- โœ… `tests/unit/agents/tap-routes.test.ts` now passes on current branch (`41/41`) -- โœ… Replaced `vi.mocked(...)` usage with Bun-compatible explicit mocks -- โœ… Added missing auth stubs in rotate-key tests +### Issue #37 โ€” CJS Support +**URL:** https://github.com/dupe-com/botcha/issues/37 +**PRs:** #39 (Copilot, uses tsup), #40 (chocothebot, uses tsc + tsconfig.cjs.json) +**Recommendation:** Merge PR #39 โ€” more comprehensive, covers langchain + verify packages, uses tsup for better bundler compatibility. Supersedes #40. --- -## ๐Ÿ”ฎ TECHNICAL DEBT (post-merge, existing in main) - -These were identified during TAP feature testing but deprioritized in favor of the 5 epics: +## ๐Ÿ”ฎ TECHNICAL DEBT (existing in main, deprioritized) ### 1. KV Read-Modify-Write Race Condition **Location:** `last_verified_at` updates on TAP session creation **Risk:** Two simultaneous requests updating agent metadata can silently lose one update -**Fix:** Implement compare-and-swap or use `put` with `putOptions.ifMatch` (Cloudflare KV doesn't support CAS natively โ€” workaround: use Durable Objects or pessimistic locking) +**Fix:** Implement compare-and-swap or use Durable Objects for pessimistic locking **Priority:** ๐ŸŸ  MAJOR โ€” affects correctness of reputation/trust tracking under load ### 2. RFC 9421 HTTP Message Signatures (Dead Code) @@ -66,7 +62,7 @@ These were identified during TAP feature testing but deprioritized in favor of t ### 3. Payment/Invoice Flow Untested **Endpoints:** `/v1/invoices/*`, Consumer/Payment Container verification -**Issue:** Requires `card_acceptor_id` to test โ€” not available in our test environment +**Issue:** Requires `card_acceptor_id` to test โ€” not available in test environment **Priority:** ๐ŸŸก MINOR โ€” feature exists, just untested ### 4. x402 X-Payment Header Path Hangs @@ -74,3 +70,9 @@ These were identified during TAP feature testing but deprioritized in favor of t **Issue:** Well-formed fake payments pass structural validation and reach the nonce KV step โ€” if KV is slow/unavailable this can hang **Fix:** Add explicit timeout around `noncesKV.get()` calls; return 504 on timeout **Priority:** ๐ŸŸก MINOR โ€” degrades gracefully in practice + +### 5. Delegation field naming inconsistency (docs vs API) +**Location:** `POST /v1/delegations` +**Issue:** Natural field names are `delegator_agent_id`/`delegate_agent_id` but API uses `grantor_id`/`grantee_id`. AI agents consistently use the wrong names (tested 2026-04-13). +**Fix:** Accept both field names (alias) or update docs/OpenAPI to be clearer +**Priority:** ๐ŸŸก MINOR โ€” docs confusion diff --git a/package-lock.json b/package-lock.json index 7842872..0321e05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -156,8 +156,7 @@ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260213.0.tgz", "integrity": "sha512-dr905ft/1R0mnfdT9aun4vanLgIBN27ZyPxTCENKmhctSz6zNmBOvHbzDWAhGE0RBAKFf3X7ifMRcd0MkmBvgA==", "dev": true, - "license": "MIT OR Apache-2.0", - "peer": true + "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -1918,7 +1917,6 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -2421,7 +2419,6 @@ "version": "4.22.1", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -2616,7 +2613,6 @@ "integrity": "sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -3722,7 +3718,6 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -3771,7 +3766,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3895,7 +3889,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -4002,7 +3995,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -4123,7 +4115,6 @@ "node_modules/zod": { "version": "3.25.76", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/packages/cloudflare-workers/src/index.tsx b/packages/cloudflare-workers/src/index.tsx index d65e850..cd34907 100644 --- a/packages/cloudflare-workers/src/index.tsx +++ b/packages/cloudflare-workers/src/index.tsx @@ -2489,6 +2489,10 @@ app.post('/v1/agents/register/tap', registerTAPAgentRoute); app.get('/v1/agents/tap', listTAPAgentsRoute); app.get('/v1/agents/:id/tap', getTAPAgentRoute); +// Convenience alias โ€” GET /v1/agents/:id/reputation โ†’ GET /v1/reputation/:agent_id +// Must be registered before the generic /v1/agents/:id handler (Hono first-match routing). +app.get('/v1/agents/:id/reputation', getReputationRoute); + // Agent identity auth โ€” prove you are a specific registered agent app.post('/v1/agents/auth', handleAgentAuthChallenge); app.post('/v1/agents/auth/verify', handleAgentAuthVerify); @@ -2720,11 +2724,11 @@ app.post('/v1/agents/register', async (c) => { } }); -// Get agent by ID +// Get agent by ID โ€” supports "me" as a shorthand for the authenticated agent app.get('/v1/agents/:id', async (c) => { try { - const agent_id = c.req.param('id'); - + let agent_id = c.req.param('id'); + if (!agent_id) { return c.json({ success: false, @@ -2732,9 +2736,32 @@ app.get('/v1/agents/:id', async (c) => { message: 'Agent ID is required', }, 400); } - + + // Support "me" as a shorthand for the currently authenticated agent + if (agent_id === 'me') { + const authHeader = c.req.header('authorization'); + const bearerToken = extractBearerToken(authHeader); + if (!bearerToken) { + return c.json({ + success: false, + error: 'UNAUTHORIZED', + message: 'Authorization: Bearer is required to use /v1/agents/me', + }, 401); + } + const publicKey = getPublicKey(c.env); + const verification = await verifyToken(bearerToken, c.env.JWT_SECRET, c.env, undefined, publicKey); + if (!verification.valid || !verification.payload?.agent_id) { + return c.json({ + success: false, + error: 'UNAUTHORIZED', + message: 'Invalid or expired token โ€” must be an agent-identity token to use /v1/agents/me', + }, 401); + } + agent_id = verification.payload.agent_id; + } + const agent = await getAgent(c.env.AGENTS, agent_id); - + if (!agent) { return c.json({ success: false, @@ -2742,7 +2769,7 @@ app.get('/v1/agents/:id', async (c) => { message: `No agent found with ID: ${agent_id}`, }, 404); } - + return c.json({ success: true, agent_id: agent.agent_id, diff --git a/packages/cloudflare-workers/src/static.ts b/packages/cloudflare-workers/src/static.ts index 1759a78..de1b9c3 100644 --- a/packages/cloudflare-workers/src/static.ts +++ b/packages/cloudflare-workers/src/static.ts @@ -84,7 +84,7 @@ curl https://botcha.ai/agent-only -H "Authorization: Bearer " | Method | Path | Description | |--------|------|-------------| | \`POST\` | \`/v1/agents/register\` | Register agent identity (name, operator, version) | -| \`GET\` | \`/v1/agents/:id\` | Get agent by ID (public, no auth) | +| \`GET\` | \`/v1/agents/:id\` | Get agent by ID (public, no auth); use \`me\` with Bearer token to resolve current agent | | \`GET\` | \`/v1/agents\` | List all agents for your app (auth required) | ### Webhooks (v0.22.0) @@ -244,7 +244,7 @@ curl https://botcha.ai/agent-only/x402 \ | Method | Path | Description | |--------|------|-------------| -| \`GET\` | \`/v1/reputation/:agent_id\` | Get agent reputation score | +| \`GET\` | \`/v1/reputation/:agent_id\` | Get agent reputation score (alias: \`GET /v1/agents/:id/reputation\`) | | \`POST\` | \`/v1/reputation/events\` | Record a reputation event | | \`GET\` | \`/v1/reputation/:agent_id/events\` | List reputation events | | \`POST\` | \`/v1/reputation/:agent_id/reset\` | Reset reputation (admin) | @@ -532,7 +532,7 @@ Endpoint: POST https://botcha.ai/gate - Submit code form, redirects to /go/:code # Agent Registry Endpoints (app_id required) Endpoint: POST https://botcha.ai/v1/agents/register - Register agent identity โ€” requires app_id -Endpoint: GET https://botcha.ai/v1/agents/:id - Get agent by ID (public, no auth) โ€” requires app_id +Endpoint: GET https://botcha.ai/v1/agents/:id - Get agent by ID (public, no auth) โ€” requires app_id; use "me" as the ID with a Bearer token to fetch the currently authenticated agent Endpoint: GET https://botcha.ai/v1/agents - List all agents for authenticated app โ€” requires app_id Endpoint: DELETE https://botcha.ai/v1/agents/:id - Delete agent โ€” requires dashboard session @@ -599,6 +599,8 @@ Endpoint: POST https://botcha.ai/v1/verify/attestation - Verify attestation toke # Agent Reputation Scoring (v0.18.0) (app_id required) Endpoint: GET https://botcha.ai/v1/reputation/:agent_id - Get agent reputation score (0-1000, 5 tiers) โ€” requires app_id Endpoint: POST https://botcha.ai/v1/reputation/events - Record a reputation event (18 action types, 6 categories) โ€” requires app_id +Reputation-Event-Categories: verification: challenge_solved|challenge_failed|auth_success|auth_failure; attestation: attestation_issued|attestation_verified|attestation_revoked; delegation: delegation_granted|delegation_received|delegation_revoked; session: session_created|session_expired|session_terminated; violation: rate_limit_exceeded|invalid_token|abuse_detected; endorsement: endorsement_received|endorsement_given +Reputation-Alias: GET /v1/agents/:id/reputation is an alias for GET /v1/reputation/:agent_id Endpoint: GET https://botcha.ai/v1/reputation/:agent_id/events - List reputation events (?category=&limit=) โ€” requires app_id Endpoint: POST https://botcha.ai/v1/reputation/:agent_id/reset - Reset reputation to default (admin action) โ€” requires app_id @@ -750,8 +752,8 @@ TAP-Register: POST /v1/agents/register/tap with {name, public_key, signature_alg TAP-Algorithms: ed25519 (Visa recommended), ecdsa-p256-sha256, rsa-pss-sha256 TAP-Trust-Levels: basic, verified, enterprise TAP-Capabilities: Array of {action, resource, constraints} โ€” scoped access control -TAP-Session-Create: POST /v1/sessions/tap with {agent_id, user_context, intent} -TAP-Session-Get: GET /v1/sessions/:id/tap โ€” includes time_remaining +TAP-Session-Create: POST /v1/sessions/tap with {agent_id, user_context, intent, ttl_seconds?} โ€” ttl_seconds must be a positive integer (max 86400); defaults to 3600 if omitted +TAP-Session-Get: GET /v1/sessions/:id/tap โ€” includes time_remaining_seconds (integer, seconds until expiry) TAP-Get-Agent: GET /v1/agents/:id/tap โ€” includes public_key for verification TAP-List-Agents: GET /v1/agents/tap?app_id=...&tap_only=true TAP-Middleware-Modes: tap, signature-only, challenge-only, flexible @@ -1656,7 +1658,7 @@ export function getOpenApiSpec(version: string) { "/v1/agents/{id}": { get: { summary: "Get agent by ID", - description: "Retrieve agent information by agent ID. Public endpoint, no authentication required.", + description: "Retrieve agent information by agent ID. Public endpoint, no authentication required. Use \"me\" as the id with a Bearer token (Authorization: Bearer ) to retrieve the currently authenticated agent without knowing your own agent_id.", operationId: "getAgent", parameters: [ { @@ -1664,7 +1666,7 @@ export function getOpenApiSpec(version: string) { in: "path", required: true, schema: { type: "string" }, - description: "The agent_id to retrieve (e.g., 'agent_abc123')" + description: "The agent_id to retrieve (e.g., 'agent_abc123'), or the special value 'me' to resolve from the Bearer token" } ], responses: { @@ -1676,6 +1678,7 @@ export function getOpenApiSpec(version: string) { } } }, + "401": { description: "Unauthorized โ€” only returned when using 'me' without a valid Bearer token" }, "404": { description: "Agent not found" } } } @@ -2265,13 +2268,15 @@ export function getOpenApiSpec(version: string) { "user_context": { type: "string", description: "User context identifier" }, "intent": { type: "object", + required: ["action"], properties: { - "action": { type: "string", description: "Intended action (e.g., read, write)" }, + "action": { type: "string", enum: ["browse", "compare", "purchase", "audit", "search"], description: "Intended action" }, "resource": { type: "string", description: "Target resource path" }, - "purpose": { type: "string", description: "Human-readable purpose" } + "scope": { type: "array", items: { type: "string" }, description: "Optional scope constraints" } }, description: "Declared intent for the session" - } + }, + "ttl_seconds": { type: "integer", minimum: 1, maximum: 86400, description: "Session TTL in seconds (default: 3600, max: 86400). Must be a positive integer." } } } } @@ -2279,8 +2284,8 @@ export function getOpenApiSpec(version: string) { }, responses: { "201": { description: "TAP session created with capabilities and expiry" }, - "400": { description: "Missing required fields or invalid intent" }, - "403": { description: "Agent lacks required capability for declared intent" }, + "400": { description: "Missing required fields, invalid intent, or invalid ttl_seconds" }, + "403": { description: "Agent lacks required capability for declared intent or TAP not enabled" }, "404": { description: "Agent not found" } } } @@ -2810,7 +2815,7 @@ export function getOpenApiSpec(version: string) { properties: { "agent_id": { type: "string", description: "Agent to record event for" }, "category": { type: "string", enum: ["verification", "attestation", "delegation", "session", "violation", "endorsement"], description: "Event category" }, - "action": { type: "string", description: "Event action (e.g. challenge_solved, abuse_detected)" }, + "action": { type: "string", enum: ["challenge_solved", "challenge_failed", "auth_success", "auth_failure", "attestation_issued", "attestation_verified", "attestation_revoked", "delegation_granted", "delegation_received", "delegation_revoked", "session_created", "session_expired", "session_terminated", "rate_limit_exceeded", "invalid_token", "abuse_detected", "endorsement_received", "endorsement_given"], description: "Event action โ€” must match the selected category. verification: challenge_solved|challenge_failed|auth_success|auth_failure; attestation: attestation_issued|attestation_verified|attestation_revoked; delegation: delegation_granted|delegation_received|delegation_revoked; session: session_created|session_expired|session_terminated; violation: rate_limit_exceeded|invalid_token|abuse_detected; endorsement: endorsement_received|endorsement_given" }, "source_agent_id": { type: "string", description: "Source agent for endorsements" }, "metadata": { type: "object", additionalProperties: { type: "string" }, description: "Optional key/value metadata" } } diff --git a/packages/cloudflare-workers/src/tap-reputation-routes.ts b/packages/cloudflare-workers/src/tap-reputation-routes.ts index c097350..225f1ed 100644 --- a/packages/cloudflare-workers/src/tap-reputation-routes.ts +++ b/packages/cloudflare-workers/src/tap-reputation-routes.ts @@ -21,6 +21,7 @@ import { isValidCategory, isValidAction, isValidCategoryAction, + CATEGORY_ACTIONS, type RecordEventOptions, type ReputationEventCategory, type ReputationEventAction, @@ -161,10 +162,12 @@ export async function recordReputationEventRoute(c: Context) { } if (!isValidCategoryAction(body.category, body.action)) { + const validActionsForCategory = CATEGORY_ACTIONS[body.category as ReputationEventCategory] ?? []; return c.json({ success: false, error: 'ACTION_CATEGORY_MISMATCH', - message: `Action "${body.action}" does not belong to category "${body.category}"` + message: `Action "${body.action}" does not belong to category "${body.category}". Valid actions for "${body.category}": ${validActionsForCategory.join(', ')}`, + valid_actions: validActionsForCategory, }, 400); } diff --git a/packages/cloudflare-workers/src/tap-reputation.ts b/packages/cloudflare-workers/src/tap-reputation.ts index d64357b..8750308 100644 --- a/packages/cloudflare-workers/src/tap-reputation.ts +++ b/packages/cloudflare-workers/src/tap-reputation.ts @@ -459,7 +459,7 @@ export async function resetReputation( // ============ VALIDATION ============ -const CATEGORY_ACTIONS: Record = { +export const CATEGORY_ACTIONS: Record = { verification: ['challenge_solved', 'challenge_failed', 'auth_success', 'auth_failure'], attestation: ['attestation_issued', 'attestation_verified', 'attestation_revoked'], delegation: ['delegation_granted', 'delegation_received', 'delegation_revoked'], diff --git a/packages/cloudflare-workers/src/tap-routes.ts b/packages/cloudflare-workers/src/tap-routes.ts index 4235ffe..316c902 100644 --- a/packages/cloudflare-workers/src/tap-routes.ts +++ b/packages/cloudflare-workers/src/tap-routes.ts @@ -481,7 +481,33 @@ export async function createTAPSessionRoute(c: Context) { message: capabilityCheck.error }, 403); } - + + // Validate and apply ttl_seconds if provided. + // The default (3600s) and max (86400s) are enforced in createTAPSession; + // we reject nonsensical values here so callers get a clear error rather + // than silent truncation or a session that looks like it succeeded but + // has a different TTL than requested. + const MAX_TTL = 86400; // 24 hours + if (body.ttl_seconds !== undefined) { + const ttl = body.ttl_seconds; + if (typeof ttl !== 'number' || !Number.isFinite(ttl) || !Number.isInteger(ttl) || ttl <= 0) { + return c.json({ + success: false, + error: 'INVALID_TTL', + message: 'ttl_seconds must be a positive integer (max 86400)', + }, 400); + } + if (ttl > MAX_TTL) { + return c.json({ + success: false, + error: 'INVALID_TTL', + message: `ttl_seconds must not exceed ${MAX_TTL} (24 hours)`, + }, 400); + } + // Wire the caller's TTL into the intent so createTAPSession picks it up. + intentResult.intent!.duration = ttl; + } + // Create session const sessionResult = await createTAPSession( c.env.SESSIONS, @@ -574,7 +600,8 @@ export async function getTAPSessionRoute(c: Context) { intent: session.intent, created_at: new Date(session.created_at).toISOString(), expires_at: new Date(session.expires_at).toISOString(), - time_remaining: Math.max(0, session.expires_at - Date.now()) + // time_remaining_seconds: seconds until expiry (use expires_at for authoritative value) + time_remaining_seconds: Math.max(0, Math.floor((session.expires_at - Date.now()) / 1000)), }); } catch (error) { diff --git a/tests/unit/agents/tap-reputation.test.ts b/tests/unit/agents/tap-reputation.test.ts index 2c48311..54a6756 100644 --- a/tests/unit/agents/tap-reputation.test.ts +++ b/tests/unit/agents/tap-reputation.test.ts @@ -9,6 +9,7 @@ import { isValidCategoryAction, isValidCategory, isValidAction, + CATEGORY_ACTIONS, type RecordEventOptions, type ReputationScore, type ReputationEventCategory, @@ -247,6 +248,44 @@ describe('TAP Agent Reputation Scoring', () => { }); }); + // ============ CATEGORY_ACTIONS export ============ + + describe('CATEGORY_ACTIONS', () => { + test('is exported and covers all 6 categories', () => { + const categories = Object.keys(CATEGORY_ACTIONS); + expect(categories).toContain('verification'); + expect(categories).toContain('attestation'); + expect(categories).toContain('delegation'); + expect(categories).toContain('session'); + expect(categories).toContain('violation'); + expect(categories).toContain('endorsement'); + expect(categories).toHaveLength(6); + }); + + test('each category has at least one action', () => { + for (const [category, actions] of Object.entries(CATEGORY_ACTIONS)) { + expect(actions.length).toBeGreaterThan(0); + for (const action of actions) { + expect(typeof action).toBe('string'); + expect(action.length).toBeGreaterThan(0); + } + } + }); + + test('all listed actions pass isValidCategoryAction', () => { + for (const [category, actions] of Object.entries(CATEGORY_ACTIONS)) { + for (const action of actions) { + expect(isValidCategoryAction(category as ReputationEventCategory, action as ReputationEventAction)).toBe(true); + } + } + }); + + test('totals 18 actions across all categories', () => { + const total = Object.values(CATEGORY_ACTIONS).reduce((sum, actions) => sum + actions.length, 0); + expect(total).toBe(18); + }); + }); + // ============ Get Reputation Score ============ describe('getReputationScore', () => { diff --git a/tests/unit/agents/tap-routes.test.ts b/tests/unit/agents/tap-routes.test.ts index 3491418..7f450af 100644 --- a/tests/unit/agents/tap-routes.test.ts +++ b/tests/unit/agents/tap-routes.test.ts @@ -936,6 +936,160 @@ describe('TAP Routes - createTAPSessionRoute', () => { expect(updatedAgent.last_verified_at).toBeDefined(); expect(updatedAgent.last_verified_at).toBeGreaterThanOrEqual(beforeMs); }); + + // --- ttl_seconds validation --- + + const tapEnabledAgent: TAPAgent = { + agent_id: TEST_AGENT_ID, + app_id: TEST_APP_ID, + name: 'TTLAgent', + created_at: Date.now(), + tap_enabled: true, + capabilities: [{ action: 'browse' }], + }; + + test('should accept valid ttl_seconds and honour it in the response', async () => { + const agentsKV = new MockKV(); + const sessionsKV = new MockKV(); + agentsKV.seed(`agent:${TEST_AGENT_ID}`, tapEnabledAgent); + + const mockContext = createMockContext({ + agentsKV, + sessionsKV, + json: vi.fn().mockResolvedValue({ + agent_id: TEST_AGENT_ID, + user_context: 'ctx', + intent: { action: 'browse' }, + ttl_seconds: 600, + }), + }); + + const response = await createTAPSessionRoute(mockContext); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.success).toBe(true); + // expires_at should be ~600s from now (allow ยฑ5s slop) + const expiresAt = new Date(data.expires_at).getTime(); + const expectedMs = Date.now() + 600 * 1000; + expect(Math.abs(expiresAt - expectedMs)).toBeLessThan(5000); + }); + + test('should reject negative ttl_seconds with INVALID_TTL', async () => { + const agentsKV = new MockKV(); + agentsKV.seed(`agent:${TEST_AGENT_ID}`, tapEnabledAgent); + + const mockContext = createMockContext({ + agentsKV, + json: vi.fn().mockResolvedValue({ + agent_id: TEST_AGENT_ID, + user_context: 'ctx', + intent: { action: 'browse' }, + ttl_seconds: -300, + }), + }); + + const response = await createTAPSessionRoute(mockContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('INVALID_TTL'); + }); + + test('should reject zero ttl_seconds with INVALID_TTL', async () => { + const agentsKV = new MockKV(); + agentsKV.seed(`agent:${TEST_AGENT_ID}`, tapEnabledAgent); + + const mockContext = createMockContext({ + agentsKV, + json: vi.fn().mockResolvedValue({ + agent_id: TEST_AGENT_ID, + user_context: 'ctx', + intent: { action: 'browse' }, + ttl_seconds: 0, + }), + }); + + const response = await createTAPSessionRoute(mockContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('INVALID_TTL'); + }); + + test('should reject ttl_seconds exceeding 86400 with INVALID_TTL', async () => { + const agentsKV = new MockKV(); + agentsKV.seed(`agent:${TEST_AGENT_ID}`, tapEnabledAgent); + + const mockContext = createMockContext({ + agentsKV, + json: vi.fn().mockResolvedValue({ + agent_id: TEST_AGENT_ID, + user_context: 'ctx', + intent: { action: 'browse' }, + ttl_seconds: 999999, + }), + }); + + const response = await createTAPSessionRoute(mockContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('INVALID_TTL'); + expect(data.message).toContain('86400'); + }); + + test('should reject non-integer ttl_seconds with INVALID_TTL', async () => { + const agentsKV = new MockKV(); + agentsKV.seed(`agent:${TEST_AGENT_ID}`, tapEnabledAgent); + + const mockContext = createMockContext({ + agentsKV, + json: vi.fn().mockResolvedValue({ + agent_id: TEST_AGENT_ID, + user_context: 'ctx', + intent: { action: 'browse' }, + ttl_seconds: 3600.5, + }), + }); + + const response = await createTAPSessionRoute(mockContext); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('INVALID_TTL'); + }); + + test('should use default TTL when ttl_seconds is omitted', async () => { + const agentsKV = new MockKV(); + const sessionsKV = new MockKV(); + agentsKV.seed(`agent:${TEST_AGENT_ID}`, tapEnabledAgent); + + const mockContext = createMockContext({ + agentsKV, + sessionsKV, + json: vi.fn().mockResolvedValue({ + agent_id: TEST_AGENT_ID, + user_context: 'ctx', + intent: { action: 'browse' }, + // no ttl_seconds + }), + }); + + const response = await createTAPSessionRoute(mockContext); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.success).toBe(true); + // Default TTL is 3600s โ€” expires_at should be ~1 hour from now + const expiresAt = new Date(data.expires_at).getTime(); + const expectedMs = Date.now() + 3600 * 1000; + expect(Math.abs(expiresAt - expectedMs)).toBeLessThan(5000); + }); }); describe('TAP Routes - getTAPSessionRoute', () => { @@ -973,7 +1127,8 @@ describe('TAP Routes - getTAPSessionRoute', () => { expect(data.app_id).toBe(TEST_APP_ID); expect(data.capabilities).toHaveLength(1); expect(data.intent.action).toBe('browse'); - expect(data.time_remaining).toBeGreaterThan(0); + expect(data.time_remaining_seconds).toBeGreaterThan(0); + expect(data.time_remaining).toBeUndefined(); // old ms field removed }); test('should return 404 when session not found', async () => {