diff --git a/.agents/LESSONS.md b/.agents/LESSONS.md deleted file mode 100644 index ee6ef3b02e..0000000000 --- a/.agents/LESSONS.md +++ /dev/null @@ -1,1617 +0,0 @@ -# Agent Lessons - -Lessons accumulated across buffbench runs. Each lesson identifies what went wrong (Issue) and what should have been done instead (Fix). - -## 2025-10-21T02:19:38.224Z — add-sidebar-fades (257cb37) - -### Original Agent Prompt - -Enhance the desktop docs sidebar UX by adding subtle top/bottom gradient fades that appear based on scroll position and a thin, themed custom scrollbar. The fades should show when there’s overflow in that direction (top when not at the top, bottom when not at the bottom), be non-interactive, and update on initial render and during scroll. Apply the custom scrollbar styles via a CSS class and use it on the scrollable sidebar container. Preserve the current hash-based smooth scrolling behavior and leave the mobile Sheet implementation unchanged. - -### Lessons - -- **Issue:** Custom scrollbar only used -webkit selectors; Firefox shows default thick scrollbar. - **Fix:** Add cross-browser styles: scrollbar-width: thin; scrollbar-color: hsl(var(--border)/0.6) transparent alongside -webkit rules. - -- **Issue:** Used @apply bg-sidebar-border for the thumb; token may not exist in Tailwind theme. - **Fix:** Use stable theme tokens: bg-border or inline color via hsl(var(--border)) to ensure consistency across themes. - -- **Issue:** Fade visibility isn’t updated when content height changes (e.g., async News load). - **Fix:** Observe size/DOM changes: use ResizeObserver/MutationObserver or re-run handleScroll on content updates and window resize. - -- **Issue:** Gradients set via inline style strings; harder to theme, lint, and CSP-safe. - **Fix:** Prefer Tailwind utilities: bg-gradient-to-b/t, from-background to-transparent with transition-opacity for maintainability. - -## 2025-10-21T02:24:18.953Z — validate-custom-tools (30dc486) - -### Original Agent Prompt - -Add schema-validated custom tool execution. Ensure the server validates custom tool inputs but forwards a sanitized copy of the original input (removing the end-of-step flag) to the client. In the SDK, parse custom tool inputs with the provided Zod schema before invoking the tool handler and update types so handlers receive fully parsed inputs. Keep built-in tool behavior and error handling unchanged. - -### Lessons - -- **Issue:** Server streamed tool_call with parsed input, not sanitized original; client sees schema-shaped payload instead of original minus cb_easp. - **Fix:** In parseRawCustomToolCall, validate with Zod but return input as a clone of raw input with cb_easp removed; use that for toolCalls and onResponseChunk. - -- **Issue:** Sanitization was applied only when calling requestToolCall; toolCalls array and tool_call events still used parsed input, causing inconsistency. - **Fix:** Unify by returning the sanitized original from parseRawCustomToolCall and reusing toolCall.input everywhere (stream, toolCalls, requestToolCall). - -- **Issue:** SDK run() isn’t generic, so CustomToolDefinition type params don’t propagate; handlers lose typed Output inference. - **Fix:** Make CodebuffClient.run generic (e.g., run) and accept CustomToolDefinition[]; pass toolDef through so handler gets Output type. - -- **Issue:** Used any casts for SDK error handling, reducing type-safety and clarity. - **Fix:** Prefer unknown with type guards or narrowing (e.g., error instanceof Error ? error.message : String(error)) to avoid any casts. - -## 2025-10-21T02:25:18.751Z — filter-system-history (456858c) - -### Original Agent Prompt - -Improve spawned agent context handling so that parent system messages are not forwarded. Update both sync and async spawn flows to pass conversation history to sub-agents without any system-role entries, and add tests covering includeMessageHistory on/off, empty history, and system-only history. Keep the overall spawning, validation, and streaming behavior unchanged. - -### Lessons - -- **Issue:** Tests asserted raw strings in the serialized history (e.g., 'assistant', '[]'), making them brittle to formatting changes. - **Fix:** Parse the JSON portion of conversationHistoryMessage and assert on structured fields (roles, length), not string substrings. - -- **Issue:** Async tests implicitly depended on ASYNC_AGENTS_ENABLED and used a carrier.promise + timeout, making them flaky. - **Fix:** Explicitly mock ASYNC_AGENTS_ENABLED (or path) and await loopAgentSteps via spy; avoid timeouts and internal promise hacks. - -- **Issue:** System-role filtering was duplicated in both spawn-agents.ts and spawn-agents-async.ts. - **Fix:** Extract a shared util (e.g., filterOutSystemRole(messages)) in util/messages and use it in both handlers; add a unit test for it. - -- **Issue:** Role presence was verified by substring checks ('assistant') instead of checking message.role, risking false positives. - **Fix:** Assert on exact role fields ("role":"assistant") or, better, parse JSON and check objects’ role values. - -- **Issue:** Initial sync test expected a non-standard empty array format ('[\n \n]'), requiring a later patch. - **Fix:** Use JSON.stringify semantics from the start or parse JSON and assert length === 0 to avoid format assumptions. - -## 2025-10-21T02:26:14.756Z — add-spawn-perms-tests (257c995) - -### Original Agent Prompt - -Add comprehensive unit tests to verify that the spawn_agents tool enforces parent-to-child spawn permissions and that agent ID matching works across publisher, name, and version combinations. Include edge cases and mixed-success scenarios. Also make the internal matching helper importable so the tests can target it directly. Keep the handler logic unchanged; focus on exporting the helper and covering behavior via tests. - -### Lessons - -- **Issue:** Imported TEST_USER_ID from '@codebuff/common/constants' and AgentTemplate from '../templates/types' causing type/resolve errors. - **Fix:** Use correct paths: TEST_USER_ID from '@codebuff/common/old-constants' and AgentTemplate from '@codebuff/common/types/agent-template'. - -- **Issue:** Omitted the 'agent template not found' scenario in handler tests, missing a key error path. - **Fix:** Add a test where localAgentTemplates lacks the requested agent; assert the error message and no loopAgentSteps call. - -- **Issue:** Assertions tightly coupled to exact report header strings, making tests brittle to formatting changes. - **Fix:** Assert via displayName-derived headers or use regex/contains on content while verifying loopAgentSteps calls for success. - -- **Issue:** Did not verify that loopAgentSteps received the resolved agentType from getMatchingSpawn. - **Fix:** Assert loopAgentSteps was called with agentType equal to the matched spawnable (e.g., 'pub1/alpha@1.0.0'). - -- **Issue:** Used afterAll to restore mocks, risking cross-test leakage of spies/mocks. - **Fix:** Restore spies/mocks in afterEach to isolate tests and prevent state leakage between cases. - -- **Issue:** Duplicated local file context creator instead of shared mock, risking schema drift. - **Fix:** Rely on mockFileContext from test-utils and adjust only fields needed per test to keep in sync with schema. - -- **Issue:** Created success-case assertions initially using 'Agent (X):' which mismatched actual handler format. - **Fix:** Base assertions on agentTemplate.displayName (e.g., '**Agent :**'), or compute expected from makeTemplate. - -## 2025-10-21T02:27:58.739Z — extract-agent-parsing (998b585) - -### Original Agent Prompt - -- Add a common parser that can handle both published and local agent IDs, and a strict parser that only passes when a publisher is present. -- Update the agent registry to rely on the strict parser for DB lookups and to prefix with the default org when needed. -- Update the spawn-agents handler to use the shared general parser, with guards for optional fields, so that unprefixed, prefixed, and versioned forms are all matched correctly against the parent’s spawnable agents. - Keep the existing registry cache behavior and spawn matching semantics the same, and make sure existing tests pass without modification. - -### Lessons - -- **Issue:** Put new parsers in agent-name-normalization.ts, conflating concerns and diverging from the repo’s dedicated parsing util pattern. - **Fix:** Create common/src/util/agent-id-parsing.ts exporting parseAgentId + parsePublishedAgentId; import these in registry and spawn-agents. - -- **Issue:** Exposed parseAgentIdLoose/Strict; callers expect parseAgentId (optional fields, no null) and parsePublishedAgentId (strict). - **Fix:** Implement parseAgentId to always return {publisherId?, agentId?, version?} and parsePublishedAgentId for strict published IDs; update call sites. - -- **Issue:** agent-registry.ts imported parseAgentIdStrict from normalization; should use parsePublishedAgentId from the parsing util for DB lookups. - **Fix:** Import parsePublishedAgentId from common/util/agent-id-parsing and use it (with DEFAULT_ORG_PREFIX fallback) for DB queries and cache logic. - -- **Issue:** Only spawn-agents used the shared parser; async/inline spawners still rely on simplistic checks, risking inconsistent spawn matching. - **Fix:** Adopt parseAgentId (loose) in spawn-agents-async and spawn-agent-inline matching to align behavior across all spawn paths with same guards. - -## 2025-10-21T02:29:20.144Z — enhance-docs-nav (26140c8) - -### Original Agent Prompt - -Improve the developer docs experience: make heading clicks update the URL with the section hash and smoothly scroll to the heading, and ensure back/forward navigation to hashes also smoothly scrolls to the right place. Then refresh the Codebuff vs Claude Code comparison and agent-related docs to match current messaging: add SDK/programmatic bullets, expand Claude-specific enterprise reasons, standardize the feature comparison table, streamline the creating/customizing agent docs with concise control flow and field lists, and move domain-specific customization examples out of the overview into the customization page. Keep styles and existing components intact while making these UX and content updates. - -### Lessons - -- **Issue:** copy-heading.tsx onClick handler misses a closing brace/paren, causing a TS/compile error. - **Fix:** Run typecheck/format before commit and ensure onClick closes with '})'. Build locally to catch syntax errors. - -- **Issue:** Back/forward hash scrolling was added in mdx-components instead of at the app layout level. - **Fix:** Add a single useEffect in web/src/app/docs/layout.tsx to handle hashchange/popstate and smooth-scroll to the target. - -- **Issue:** Hash scroll logic was duplicated across mdx-components, TOC, and copy-heading, risking double listeners/bugs. - **Fix:** Centralize: pushState + scroll in heading clicks; global hash scroll in docs layout; avoid per-component event listeners. - -- **Issue:** Claude comparison table diverged from the standardized rows/wording (missing SDK/programmatic rows, dir context, templates). - **Fix:** Replace the table with the exact standardized rows/order and phrasing from product messaging to ensure consistency. - -- **Issue:** Overview.mdx omitted the Built-in Agents list present in the desired messaging/GT. - **Fix:** Add a 'Built-in Agents' section listing base, reviewer, thinker, researcher, planner, file-picker in Overview. - -- **Issue:** Cross-page anchors initially pointed to /docs/agents#customizing-agents though the page lives under 'advanced'. - **Fix:** Audit and fix links to /docs/advanced#customizing-agents and verify troubleshooting slugs match actual routes. - -## 2025-10-21T02:30:15.502Z — match-spawn-agents (9f0b66d) - -### Original Agent Prompt - -Enable flexible matching for spawning subagents. When a parent agent spawns children, the child agent_type string may include an optional publisher and/or version. Update the spawn-agents handler so a child can be allowed if its identifier matches any of the parent’s spawnable agents by agent name alone, by name+publisher, by name+version, or by exact name+publisher+version. Export the existing agent ID parser and use it to implement this matching, while preserving all current spawning, validation, and streaming behaviors. - -### Lessons - -- **Issue:** Matching was too strict: name-only child failed when parent allowed had publisher/version. - **Fix:** Use asymmetric match: if names equal, allow regardless of extra qualifiers on either side. - -- **Issue:** After allow-check, code still used the child id to load templates, ignoring allowed qualifiers. - **Fix:** Resolve to the matched allowed id and use that for getAgentTemplate and execution to honor version/publisher. - -- **Issue:** No tests were added for name-only, name+publisher, name+version, and full-id matching cases. - **Fix:** Add unit tests covering all 4 modes (incl. mixed specificity) to prevent regressions and verify behavior. - -- **Issue:** Helper was placed under handlers/tool, making it less reusable and harder to test. - **Fix:** Move matching utility to a shared module (common util or templates) and import from handlers. - -- **Issue:** Scope creep: updated async and inline handlers though request targeted spawn-agents only. - **Fix:** Keep changes minimal to the requested handler unless necessary; refactor other paths separately. - -- **Issue:** 'latest' was treated as a literal version, potentially rejecting valid matches. - **Fix:** Define semantics for 'latest' (wildcard) and implement or document the intended matching behavior. - -- **Issue:** Duplicated parsing via a new loose parser rather than extending the exported parser behavior. - **Fix:** Wrap the exported parseAgentId with a minimal extension for name@version; avoid duplicating parse logic. - -## 2025-10-21T02:31:29.648Z — add-deep-thinkers (6c362c3) - -### Original Agent Prompt - -Add a family of deep-thinking agents that orchestrate multi-model analysis. Create one coordinator agent that spawns three distinct sub-thinkers (OpenAI, Anthropic, and Gemini) and synthesizes their perspectives, plus a meta-coordinator that can spawn multiple instances of the coordinator to tackle different aspects of a problem. Each agent should define a clear purpose, model, and prompts, and the coordinators should be able to spawn their sub-agents. Ensure the definitions follow the existing agent typing, validation, and spawn mechanics used across the project. - -### Lessons - -- **Issue:** Sub-thinkers rely on stepPrompt to call end_turn; no handleSteps to guarantee completion. - **Fix:** Add handleSteps that yields STEP_ALL (or STEP then end_turn) to deterministically end each sub-thinker. - -- **Issue:** Deep-thinking sub-agents lack reasoningOptions, weakening the "deep" analysis intent. - **Fix:** Set reasoningOptions (enabled, effort high/medium; exclude as needed) per model to emphasize deeper reasoning. - -- **Issue:** New agents weren’t registered in AGENT_PERSONAS, reducing discoverability in CLI/UI. - **Fix:** Add personas (displayName, purpose) for the sub-thinkers/coordinators in common/src/constants/agents.ts. - -- **Issue:** Meta-coordinator doesn’t guard for empty params.aspects, risking a spawn with zero agents. - **Fix:** Validate aspects; if empty, synthesize directly or spawn one coordinator focused on the overall prompt. - -- **Issue:** Attempted to spawn a non-permitted 'validator' agent, violating spawn permissions. - **Fix:** Use only allowed agents; for validation use run_terminal_command or CI scripts instead of spawning unknowns. - -- **Issue:** Factory prompts aren’t trimmed/template-formatted, diverging from project style (e.g., thinker.ts). - **Fix:** Use template literals with .trim() for system/instructions/step prompts to keep style consistent. - -- **Issue:** Captured toolResult into unused vars (subResults/aspectResults), causing avoidable lint warnings. - **Fix:** Prefix unused bindings with \_ or omit them entirely to keep code lint-clean from the start. - -- **Issue:** Coordinator synthesis depends solely on implicit instructions; no structured output path. - **Fix:** Yield STEP_ALL and optionally switch to structured_output + set_output to enforce a concrete synthesis. - -## 2025-10-21T02:33:02.024Z — add-custom-tools (212590d) - -### Original Agent Prompt - -Add end-to-end support for user-defined custom tools alongside the built-in tool set. Agents should be able to list custom tools by string name, the system should describe and document them in prompts, recognize their calls in streamed responses, validate their inputs, and route execution to the SDK client where the tool handler runs. Include options for tools that end the agent step, and support example inputs for prompt documentation. Update types, schemas, and test fixtures accordingly. - -### Lessons - -- **Issue:** CodebuffToolCall stays tied to ToolName; custom names break typing and casts to any in stream-parser/tool-executor. - **Fix:** Broaden types to string tool names. Update CodebuffToolCall/clientTool schemas to accept custom names and map to runtime schemas. - - **Fix:** Add customTools to AgentTemplate (record by name). Ensure assembleLocalAgentTemplates builds this map from agent defs. - -- **Issue:** convertJsonSchemaToZod used in common/src/templates/agent-validation.ts without import/impl; likely compile error. - **Fix:** Import from a shared util (e.g., common/util/zod-schema) or implement it. Add tests to verify conversion and errors. - -- **Issue:** customTools defined as array in dynamic-agent-template, but prompts expect a record (customTools[name]). - **Fix:** Normalize to Record during validation. Store the record on AgentTemplate; use it everywhere. - -- **Issue:** Example inputs aren’t rendered in tool docs; requirement asked for example inputs in prompts. - **Fix:** Enhance getToolsInstructions/getShortToolInstructions to render exampleInputs blocks under each tool description. - -- **Issue:** No tests added for custom tool parsing, execution routing, or prompt docs; fixtures not updated. - **Fix:** Add tests: parseRawToolCall with custom schema, stream recognition, requestToolCall routing, prompt docs incl examples. - -- **Issue:** Loosened toolNames to string[] without validating built-ins vs custom; invalid names can slip silently. - **Fix:** Validate toolNames: each must be built-in or exist in customTools. Emit clear validation errors with file context. - - **Fix:** Remove duplicate import and run the build/tests locally to catch such issues early. - -- **Issue:** processStreamWithTags autocompletes with cb_easp: true always; may invalidate non-end tools’ schemas. - **Fix:** Only append cb_easp for tools marked endsAgentStep or relax schema to ignore unknown fields on autocomplete. - - **Fix:** Plumb customTools through fileContext->assembleLocalAgentTemplates->AgentTemplate so prompts receive full definitions. - -- **Issue:** Types in common/src/tools/list still restrict CodebuffToolCall to ToolName; executeToolCall changed to string. - **Fix:** Refactor common types: permit string tool names in CodebuffToolCall, update discriminators/schemas accordingly. - -- **Issue:** SDK/server validation split is unclear; client handlers don’t validate inputs against schema. - **Fix:** Validate on server (already) and optionally mirror validation client-side before execution for better DX/errors. - -- **Issue:** Documentation example/guide added, but no wiring to surface example agent in init or tests. - **Fix:** Add the example agent to fixtures and a test that loads it, documents tools, and executes a mocked custom tool. - -## 2025-10-21T02:35:01.856Z — add-reasoning-options (fa43720) - -### Original Agent Prompt - -Add a template-level reasoning configuration that agents can specify and have it applied at runtime. Introduce an optional "reasoningOptions" field on agent definitions and dynamic templates (supporting either a max token budget or an effort level, with optional enable/exclude flags). Validate this field in the dynamic template schema. Update the streaming path so these options are passed to the OpenRouter provider as reasoning settings for each agent. Centralize any provider-specific options in the template-aware streaming code and remove such configuration from the lower-level AI SDK wrapper. Provide a baseline agent example that opts into high reasoning effort. - -### Lessons - -- **Issue:** Enabled reasoning in factory/base.ts, affecting all base-derived agents, instead of providing a single baseline example. - **Fix:** Add reasoningOptions only in .agents/base-lite.ts to demo high-effort; keep factory defaults unchanged. - -- **Issue:** Changed providerOptions key from 'gemini' to 'google' in prompt-agent-stream.ts, diverging from repo convention/GT. - **Fix:** Preserve existing keys; use 'gemini' in prompt-agent-stream.ts per providerModelNames mapping. - -- **Issue:** Used camelCase 'maxTokens' in types/schemas; OpenRouter expects 'max_tokens'. This adds unnecessary mapping debt. - **Fix:** Use provider-compatible snake_case 'max_tokens' in AgentDefinition and dynamic schema for direct pass-through. - -- **Issue:** Used any-casts when setting providerOptions.openrouter.reasoning, reducing type safety and clarity. - **Fix:** Import OpenRouterProviderOptions and type providerOptions.openrouter; assign reasoningOptions without any casts. - -- **Issue:** Removed thinkingBudget from promptAiSdkStream options signature, risking call-site breakage without need. - **Fix:** Keep public function signatures stable; only relocate provider-specific config to prompt-agent-stream. - -- **Issue:** Missed converting import to type-only in .agents/factory/base.ts (ModelName), causing unnecessary runtime import. - **Fix:** Use `import type { ModelName }` to match repo style and avoid bundling types at runtime. - -- **Issue:** Dynamic template schema used 'maxTokens' + superRefine, deviating from provider shape and GT expectations. - **Fix:** Validate reasoningOptions as enabled/exclude + union of {max_tokens} or {effort} using Zod .and + union per GT. - -- **Issue:** Conditional/gated mapping for reasoning (enabled/effort/maxTokens) adds complexity and diverges from GT. - **Fix:** Pass template.reasoningOptions directly to providerOptions.openrouter.reasoning; let provider enforce flags. - -- **Issue:** Re-declared reasoningOptions shape in AgentTemplate instead of referencing provider types, risking drift. - **Fix:** Type AgentTemplate.reasoningOptions as OpenRouterProviderOptions['reasoning'] for consistency and safety. - -## 2025-10-21T02:41:42.557Z — autodetect-knowledge (00e8860) - -### Original Agent Prompt - -Add automatic discovery of knowledge files in the SDK run state builder. When users call the SDK without providing knowledge files but do provide project files, detect knowledge files from the provided project files and include them in the session. Treat files as knowledge files when their path ends with knowledge.md or claude.md (case-insensitive). Leave explicit knowledgeFiles untouched when provided. Update the changelog for the current SDK version to mention this behavior change. - -### Lessons - -- **Issue:** Used an inline IIFE in sdk/src/run-state.ts to compute fallback knowledgeFiles, hurting readability. - **Fix:** Build fallback in a small helper (e.g., detectKnowledgeFilesFromProjectFiles) or a simple block; avoid IIFEs. - -- **Issue:** No tests cover auto-discovery in initialSessionState, risking regressions and edge-case bugs. - **Fix:** Add unit tests: undefined vs empty {}, case-insensitive matches, non-matching paths, and explicit override preservation. - -- **Issue:** CHANGELOG updated for 0.1.9 but sdk/package.json still at 0.1.8, creating version mismatch. - **Fix:** Keep versions in sync: bump sdk/package.json to 0.1.9 or mark the changelog section as Unreleased until the bump. - -- **Issue:** Public docs/JSDoc don’t reflect the new auto-discovery behavior, potentially confusing SDK users. - **Fix:** Update JSDoc for CodebuffClient.run and initialSessionState options to mention auto-detection when knowledgeFiles is undefined. - -## 2025-10-21T02:41:48.918Z — update-tool-gen (f8fe9fe) - -### Original Agent Prompt - -Update the tool type generator to write its output into the initial agents template types file and make the web search depth parameter optional. Ensure the generator creates any missing directories so it doesn’t fail on fresh clones. Keep formatting via Prettier and adjust logs accordingly. Confirm that the agent templates continue to import from the updated tools.ts file and that no code depends on the old tools.d.ts path. Depth should be optional and default to standard behavior where omitted. - -### Lessons - -- **Issue:** Edited .agents/types/tools.ts unnecessarily. This is user-scaffolded output, not the generator target. - **Fix:** Only write to common/src/templates/initial-agents-dir/types/tools.ts via the generator; don’t touch .agents/ files. - -- **Issue:** Didn’t fully verify consumers of old path common/src/util/types/tools.d.ts beyond the generator script. - **Fix:** Search repo-wide (incl. non-TS files) for tools.d.ts and update imports/docs; then run a typecheck/build to confirm. - - **Fix:** Default at usage: const d = depth ?? 'standard'; pass { depth: d } to searchWeb and use d for credit calc/logging. - -- **Issue:** Used ripgrep -t flags for unrecognized types (e.g., mjs/tsx), risking missed matches during verification. - **Fix:** Use broader search: rg -n "tools\.d\.ts" --no-ignore or file globs; avoid invalid -t filters to catch all refs. - -- **Issue:** Manually edited the generated template file while also changing the generator, risking drift. - **Fix:** Rely on the generator output (compile-tool-definitions.ts) to produce tools.ts; avoid hand edits to generated targets. - -## 2025-10-21T02:42:27.076Z — enforce-agent-auth (27d87d7) - -### Original Agent Prompt - -### Lessons - -- **Issue:** Used API_KEY_ENV_VAR in npm-app/src/index.ts without importing it, causing a compile/runtime error. - **Fix:** Import API_KEY_ENV_VAR from @codebuff/common/constants at the top of index.ts before referencing it. - -- **Issue:** validateAgentNameHandler returned 401 with {error} for missing key; response shape inconsistent with others. - **Fix:** Return 403 with { valid:false, message:'API key required' } to match API schema and project conventions. - -- **Issue:** CLI validateAgent exits the process on 401, which is stricter than spec and harms UX. - **Fix:** Show a clear auth warning (login or set API key) and continue, or align with project behavior without process.exit. - -- **Issue:** Agent name printing used plain 'Using agent:' without colors/format; inconsistent with CLI style. - **Fix:** Print with project style: console.log(green(`\nAgent: ${bold(displayName)}`)) for consistency and readability. - - **Fix:** Update tests to expect 403 and {valid:false,message:'API key required'} and keep displayName checks for success. - -- **Issue:** validateAgent returns void; misses chance to return displayName for downstream use/tests. - **Fix:** Return string|undefined (displayName) from validateAgent; still print, but expose the value for callers. - -- **Issue:** Added local agent print 'Using agent:' which doesn’t match the 'Agent:' label used elsewhere. - **Fix:** Use the same 'Agent:' label as elsewhere to avoid mixed phrasing and potential user confusion. - -- **Issue:** Chose 401 for missing API key without checking project-wide precedent; ground truth used 403. - **Fix:** Check existing endpoints/tests and align status codes accordingly (use 403 here) to avoid mismatches. - -## 2025-10-21T02:44:14.254Z — fix-agent-steps (fe667af) - -### Original Agent Prompt - -Unify the default for the agent step limit and fix SDK behavior so that the configured maxAgentSteps reliably applies each run. Add a shared constant for the default in the config schema, make the SDK use that constant as the default run() parameter, and ensure the SDK sets stepsRemaining on the session state based on the provided or defaulted value. Update the changelog to reflect the fix. - -### Lessons - -- **Issue:** Config schema imported MAX_AGENT_STEPS_DEFAULT (25) from constants/agents.ts, changing default from 12 and adding cross-module coupling. - **Fix:** Define DEFAULT_MAX_AGENT_STEPS=12 in common/src/json-config/constants.ts and use it in the zod .default(); treat it as the shared source. - -- **Issue:** SDK run() defaulted via agents MAX_AGENT_STEPS_DEFAULT, not the config’s shared constant, risking divergence from config behavior. - **Fix:** Import DEFAULT_MAX_AGENT_STEPS from json-config/constants and set maxAgentSteps=DEFAULT_MAX_AGENT_STEPS in the run() signature. - -- **Issue:** Did not update sdk/CHANGELOG.md; added a scripts/changelog MDX entry instead of the required SDK package changelog. - **Fix:** Edit sdk/CHANGELOG.md and add a Fixed entry (e.g., “maxAgentSteps resets every run”); avoid unrelated docs changes. - -- **Issue:** Computed default inside run() (effectiveMaxAgentSteps = ... ?? const) instead of defaulting the parameter, reducing clarity. - **Fix:** Default the parameter in the signature: run({ ..., maxAgentSteps = DEFAULT_MAX_AGENT_STEPS }) and use it directly. - -- **Issue:** Tests were modified to import MAX_AGENT_STEPS_DEFAULT from agents, binding tests to the wrong layer and the 25 value. - **Fix:** If tests need updates, import DEFAULT_MAX_AGENT_STEPS from json-config/constants and assert the schema’s default (12). - -- **Issue:** getDefaultConfig() was set to MAX_AGENT_STEPS_DEFAULT (25), diverging from the intended 12 config default. - **Fix:** Keep getDefaultConfig in sync with the schema: use DEFAULT_MAX_AGENT_STEPS (12) from json-config/constants.ts. - -## 2025-10-21T02:46:25.999Z — type-client-tools (af3f741) - -### Original Agent Prompt - -### Lessons - -- **Issue:** Added common/src/types/tools.ts duplicating schemas; lost Zod-backed runtime validation and created a second source of truth. - **Fix:** Co-locate shared types with llmToolCallSchema in common/src/tools/list.ts and re-export; keep Zod-backed validation. - -- **Issue:** Client tool union was hand-listed; not derived from publishedTools/llmToolCallSchema, risking drift and gaps. - **Fix:** Derive ClientInvokableToolName from publishedTools and map params from llmToolCallSchema to a discriminated union. - -- **Issue:** requestClientToolCall generic remained ToolName, allowing non-client tools through weak typing. - **Fix:** Narrow requestClientToolCall to ClientInvokableToolName and update all handlers to pass precise union members. - -- **Issue:** Handlers/stream-parser/tool-executor still rely on local types; partial migration weakens type safety. - -- **Issue:** Changed loop-main-prompt to a single call, altering runtime behavior against the refactor-only requirement. - **Fix:** Preserve loop semantics; only remove toolCalls from types/returns. If unused, delete file without logic changes. - -- **Issue:** common/src/tools/list.ts wasn’t aligned with new shared types, leaving two divergent type sources. - **Fix:** Centralize all tool type exports in common/tools/list.ts (or constants) and re-export elsewhere to avoid drift. - -- **Issue:** Evals scaffolding updated imports only; logic ignores client-invokable subset and special input shapes. - **Fix:** Type toolCalls as ClientToolCall, restrict to client tools, and adapt FileChange and run_terminal_command modes. - - **Fix:** Type requestToolCall and all callers to ClientInvokableToolName with params inferred from schema. - -- **Issue:** tool-executor/parseRawToolCall kept local types; not wired to shared unions or client-call constraints. - **Fix:** Refactor parseRawToolCall/executeToolCall to use common types and emit ClientToolCall for client-executed tools. - -- **Issue:** Unrelated import changes (e.g., @codebuff/common/old-constants) add risk and scope creep. - **Fix:** Limit edits to tool typing/import refactor only; avoid touching unrelated constants or behavior. - -## 2025-10-21T02:48:00.593Z — unify-api-auth (12511ca) - -### Original Agent Prompt - -### Lessons - -- **Issue:** Used header name 'X-Codebuff-API-Key' vs canonical 'x-codebuff-api-key', causing inconsistency across CLI/server and tests. - **Fix:** Standardize on 'x-codebuff-api-key' everywhere. Define a single constant and use it for both creation and extraction. - -- **Issue:** Returned generic 401 text ('Missing or invalid authorization header') instead of explicit 'Missing x-codebuff-api-key header'. - **Fix:** Preserve exact error strings. Respond with 401 { error: 'Missing x-codebuff-api-key header' } to match spec/tests. - -- **Issue:** Server extractor accepted Bearer tokens, undermining the goal to standardize on one header for HTTP endpoints. - **Fix:** Only accept x-codebuff-api-key on HTTP endpoints. Remove Bearer fallback from server extractor used by routes. - -- **Issue:** Placed extractor in common/src, increasing cross-package coupling; task called for a small server utility. - - **Fix:** Limit changes to the specified areas (agent validation, repo coverage, admin middleware) to reduce regression risk. - -- **Issue:** Logging used info-level for auth header presence in validate-agent handler, adding noise to logs. - **Fix:** Use debug-level logging for header presence checks to avoid elevating routine diagnostics to info. - -- **Issue:** Did not align server error text to explicitly reference the new header, reducing developer guidance. - **Fix:** Update 401/403 texts to explicitly mention 'x-codebuff-api-key' where relevant, while preserving status shapes. - -## 2025-10-21T02:48:14.602Z — add-agent-validation (26066c2) - -### Original Agent Prompt - -Add a lightweight agent validation system that prevents running with unknown agent IDs. - -On the server, expose a GET endpoint to validate an agent identifier. It should accept a required agentId query parameter, respond with whether it's valid, and include a short-lived cache for positive results. A valid agent can be either a built-in agent or a published agent, and the response should clarify which source it came from and return a normalized identifier. Handle invalid input with a 400 status and structured error. Log when authentication info is present. - -### Lessons - -**Fix:** Use AGENT_PERSONAS/AGENT_IDS from common/src/constants/agents to detect built-ins by ID. - -- **Issue:** Client only sent Authorization; ignored API key env. Missed 'include any credentials'. - -- **Issue:** Server logs only noted Authorization presence; didn’t log X-API-Key as requested. - **Fix:** In handler, log hasAuthHeader and hasApiKey (no secrets) alongside agentId for auditability. - - **Fix:** Add a test asserting URLSearchParams agentId equals the original (publisher/name@version). - -- **Issue:** Redundant loadLocalAgents call before session; duplicates earlier startup loading. - **Fix:** Reuse the initial load result or expose loadedAgents; pass to validation to short-circuit. - -- **Issue:** Built-in check compared raw id; no basic normalization could yield false negatives. - **Fix:** Trim input and match against AGENT_IDS; optionally normalize case if IDs are case-insensitive. - -- **Issue:** Positive cache in server never prunes; Map can grow unbounded under varied queries. - **Fix:** Implement TTL sweep or size-capped LRU eviction to bound memory usage. - -- **Issue:** Server handler didn’t log success/failure context (e.g., source, cache hits). - **Fix:** Add debug/info logs for cache hit/miss, source chosen, normalizedId (no secrets). - -- **Issue:** Validation behavior lives in utils only; no exported CLI-level function for e2e tests. - **Fix:** Export a validateAgent helper used by index.ts so tests can verify full pre-check behavior. - -## 2025-10-21T02:48:36.995Z — refactor-agent-validation (90f0246) - -### Original Agent Prompt - -### Lessons - -- **Issue:** CLI.validateAgent returns undefined for local agents, so the caller can’t print the resolved name. - **Fix:** On local hit, return the displayName (id->config or name match), e.g., localById?.displayName || localByDisplay?.displayName || agent. - - **Fix:** await loadLocalAgents({verbose:false}) before validateAgent; pass agents into it, then print name, then displayGreeting. - -- **Issue:** validateAgent defaults to getCachedLocalAgentInfo which may be empty/stale, breaking local resolution. - **Fix:** Require a localAgents param or load if missing (call loadLocalAgents) to ensure deterministic local matching. - -- **Issue:** Test didn’t assert returned name for local agents, so missing local displayName return went unnoticed. - **Fix:** Add test: expect(await validateAgent(agent,{[agent]:{displayName:'X'}})).toBe('X'); also cover displayName-only lookup. - -- **Issue:** validateAgent compares against raw loadedAgents structure, risking mismatch when checking displayName. - **Fix:** Normalize local agents to {id:{displayName}} before checks; compare consistently by id and displayName. - -## 2025-10-21T02:51:02.634Z — add-run-state-helpers (6a107de) - -### Original Agent Prompt - -Add new run state helper utilities to the SDK to make it easy to create and modify runs, and refactor the client and exports to use them. Specifically: introduce a module that can initialize a fresh SessionState and wrap it in a RunState, provide helpers to append a new message or replace the entire message history for continuing a run, update the client to use this initializer instead of its local implementation, and expose these helpers from the SDK entrypoint. Update the README to show a simple example where a previous run is augmented with an image message before continuing, and bump the SDK version and changelog accordingly. - -### Lessons - -- **Issue:** Helper names diverged from expected API (used create*/make*/append*/replace* vs initialSessionState/generate*/withAdditional*/withMessageHistory). - **Fix:** Match the intended names: initialSessionState, generateInitialRunState, withAdditionalMessage, withMessageHistory; update client/README accordingly. - -- **Issue:** Kept exporting getInitialSessionState from SDK entrypoint and omitted a removal/deprecation note in the changelog, causing API ambiguity. - **Fix:** Remove (or deprecate) getInitialSessionState from index exports and add a changelog entry noting its removal or deprecation for clarity. - -- **Issue:** README image message uses Anthropic-style base64 'source' shape, not CodebuffMessage/modelMessageSchema; likely types/runtime mismatch. - **Fix:** Use modelMessageSchema format, e.g. { type: 'image', image: new URL('https://...') }, and show withAdditionalMessage on a RunState. - -- **Issue:** appendMessageToRun/replaceMessageHistory only shallow-copy session state; callers can mutate shared nested state inadvertently. - **Fix:** Deep clone before modifying (e.g., JSON.parse(JSON.stringify(runState)) or structuredClone) to ensure immutability of nested state. - -- **Issue:** SDK entrypoint exports renamed helpers (createInitialSessionState/makeInitialRunState) instead of the intended helper names. - **Fix:** Export initialSessionState, generateInitialRunState, withAdditionalMessage, withMessageHistory from sdk/src/index.ts as the public API. - -- **Issue:** README doesn’t show creating a fresh RunState, reducing discoverability of the initializer helper. - **Fix:** Add a minimal example using generateInitialRunState (or equivalent) to create an empty run, then augment via withAdditionalMessage. - -## 2025-10-21T02:52:33.654Z — fix-agent-publish (4018082) - -### Original Agent Prompt - -Update the agent publishing pipeline so the publish API accepts raw agent definitions, validates them centrally, and allows missing prompts. On the validator side, return both compiled agent templates and their validated dynamic forms. In the CLI, adjust agent selection by id/displayName and send raw definitions to the API. Ensure that optional prompts are treated as empty strings during validation and that the API responds with clear validation errors when definitions are invalid. - -### Lessons - -- **Issue:** Publish request schema still enforces DynamicAgentDefinitionSchema[] (common/src/types/api/agents/publish.ts), rejecting truly raw defs. - **Fix:** Accept fully raw input: data: z.record(z.string(), z.any()).array(). Validate centrally via validateAgents in the API route. - -- **Issue:** Validator naming drift: validateAgents returns dynamicDefinitions and validateSingleAgent returns dynamicDefinition (vs dynamicTemplates). - **Fix:** Standardize names to dynamicTemplates/dynamicAgentTemplate to reflect parsed forms and keep API/route usage consistent. - -- **Issue:** CLI publish still matches by map key (file key) using Object.entries in npm-app/src/cli-handlers/publish.ts; can select by filename. - **Fix:** Match only by id or displayName using Object.values; build matchingTemplates keyed by template.id to avoid file-key collisions. - -- **Issue:** validateSingleAgent doesn't re-default prompts when constructing AgentTemplate, relying solely on schema defaults. - **Fix:** Set systemPrompt/instructionsPrompt/stepPrompt to '' when building AgentTemplate for robustness if schema defaults change. - -## 2025-10-21T02:56:18.897Z — centralize-placeholders (29d8f3f) - -### Original Agent Prompt - -### Lessons - -- **Issue:** Imported PLACEHOLDER from a non-existent path (@codebuff/common/.../secret-agent-definition), causing dangling refs. - **Fix:** Only import from existing modules or add the file first. Create the common secret-agent-definition.ts before updating imports. - -- **Issue:** Changed common/agent-definition.ts to re-export from './secret-agent-definition' which doesn’t exist in common. - **Fix:** Either add common/.../secret-agent-definition.ts or re-export from an existing module. Don’t point to files that aren’t there. - - **Fix:** Avoid editing files scheduled for deletion. Remove them and update imports/usage sites to the single source of truth. - -- **Issue:** Centralized across packages without a clear plan, introducing cross-package breakage and unresolved imports. - -- **Issue:** Did not validate the repo after refactor (no typecheck/build), so broken imports slipped in. - **Fix:** Run a full typecheck/build after edits. Fix any unresolved modules before concluding to meet the “no dangling refs” requirement. - - **Fix:** Update strings.ts only after the target module exists. If centralizing, add the module first, then adjust imports. - -- **Issue:** Did not verify that prompt formatting still injects the same values at runtime post-refactor. - **Fix:** Smoke-test formatPrompt before/after (or add a snapshot test) to confirm identical placeholder replacements and values. - -- **Issue:** Inconsistent type exports (PlaceholderValue) across modules, risking type import breaks. - **Fix:** Re-export PlaceholderValue alongside PLACEHOLDER at the central file and ensure all imports consistently use that re-export. - -## 2025-10-21T02:58:10.976Z — add-sdk-terminal (660fa34) - -### Original Agent Prompt - -Add first-class SDK support for running terminal commands via the run_terminal_command tool. Implement a synchronous, cross-platform shell execution helper with timeout and project-root cwd handling, and wire it into the SDK client’s tool-call flow. Ensure the tool-call-response uses the standardized output object instead of the previous result string and that errors are surfaced as text output. Match the behavior and message schema used by the server and the npm app, but keep the SDK implementation minimal without background mode. - -### Lessons - -- **Issue:** Used spawnSync, blocking Node’s event loop during command runs; hurts responsiveness even for short commands. - **Fix:** Use spawn with a Promise and a kill-on-timeout guard. Keep SYNC semantics at tool level without blocking the event loop. - -- **Issue:** Did not set color-forcing env vars, so some CLIs may not emit rich output (then stripped to plain). - **Fix:** Match npm app env: add FORCE_COLOR=1, CLICOLOR=1, CLICOLOR_FORCE=1 (and PAGER/GIT_PAGER) to command env. - -- **Issue:** Status text omitted cwd context shown by npm app (e.g., cwd line). Minor parity gap. - **Fix:** Append a cwd line in status (project-root resolved path) to mirror npm-app output and aid debugging. - -- **Issue:** When returning a terminal_command_error payload, success stayed true and error field was empty. - **Fix:** If output contains a terminal_command_error, also populate error (and optionally set success=false) for clearer signaling. - -- **Issue:** Timeout/termination status omitted the signal, reducing diagnostic clarity on killed processes. - **Fix:** Include res.signal (e.g., 'Terminated by signal: SIGTERM') in status when present to improve parity and debuggability. - -## 2025-10-21T02:59:05.311Z — align-agent-types (ea45eda) - -### Original Agent Prompt - -### Lessons - -- **Issue:** Example 01 used find_files with input.prompt; param name likely mismatched the tool schema, risking runtime/type errors. - **Fix:** Check .agents/types/tools.ts and use the exact params find_files expects (e.g., correct key names) inside input. - -- **Issue:** Example 03 set_output passed toolResult directly but outputSchema requires findings: string[]. Likely schema mismatch. - **Fix:** Transform toolResult to match outputSchema, e.g., findings: Array.isArray(x)? x : [String(x)] before calling set_output. - -- **Issue:** Example 03 spawned 'file-picker' locally; repo examples use fully-qualified ids like codebuff/file-picker@0.0.1. - **Fix:** Use fully-qualified spawnable agent ids (e.g., codebuff/file-picker@0.0.1) to match repository conventions. - -- **Issue:** Docblocks in .agents/types/agent-definition.ts weren’t comprehensively updated to emphasize input-object calls. - **Fix:** Revise all handleSteps examples/comments to consistently show toolName + input object usage and remove args mentions. - -- **Issue:** Not all examples validated against actual tool schemas; subtle param drift (e.g., set_output payload shape) slipped in. - **Fix:** Cross-check every example’s input payload against tool typings before committing; align shapes to types precisely. - -- **Issue:** Spawnable agent list in Example 03 didn’t reflect the agent store naming used elsewhere in repo examples. - **Fix:** Mirror repo examples: declare spawnableAgents with fully-qualified ids and ensure toolNames include spawn_agents and set_output. - -- **Issue:** No explicit note added in examples/readme reinforcing JsonObjectSchema requirement for object schemas. - **Fix:** Add concise comments in examples/docs: object schemas must use JsonObjectSchema (type: 'object') for input/output. - -## 2025-10-21T03:00:16.042Z — surface-history-access (6bec422) - -### Original Agent Prompt - -Make dynamic agents not inherit prior conversation history by default. Update the generated spawnable agents description so that, for any agent that can see the current message history, the listing explicitly states that capability. Keep showing each agent’s input schema (prompt and params) when available, otherwise show that there is none. Ensure the instructions prompt includes tool instructions, the spawnable agents description, and output schema details where applicable. - -### Lessons - -- **Issue:** Added extra visibility lines (negative/unknown) in spawnable agents description beyond spec. - **Fix:** Only append "This agent can see the current message history." when includeMessageHistory is true; omit else/unknown lines. - -- **Issue:** Built the description with unconditional strings, risking noise and blank lines. - **Fix:** Use buildArray to conditionally include the visibility line and schema blocks, then join for clean, minimal output. - -- **Issue:** Added "Visibility: Unknown" for unknown agent templates, increasing verbosity. - **Fix:** Keep unknown agents minimal: show type and input schema details only; don’t mention visibility for unknowns. - -## 2025-10-21T03:04:04.761Z — move-agent-templates (26e84af) - -### Original Agent Prompt - -Centralize the built-in agent templates and type definitions under a new common/src/templates/initial-agents-dir. Update the CLI to scaffold user .agents files by copying from this new location instead of bundling from .agents. Update all imports in the SDK and common to reference the new AgentDefinition/ToolCall types path. Remove the old re-export that pointed to .agents so consumers can’t import from the legacy location. Keep runtime loading of user-defined agents from .agents unchanged and ensure the codebase builds cleanly. - -### Lessons - -- **Issue:** Kept common/src/types/agent-definition.ts as a re-export (now to new path) instead of removing it, weakening path enforcement. - **Fix:** Delete the file or stop re-exporting. Force consumers to import from common/src/templates/.../agent-definition directly. - -- **Issue:** Missed updating test import in common/src/types/**tests**/dynamic-agent-template.test.ts to the new AgentDefinition path. - **Fix:** Change import to '../../templates/initial-agents-dir/types/agent-definition' so type-compat tests build and validate correctly. - -- **Issue:** Introduced types/secret-agent-definition.ts under initial-agents-dir, which wasn’t requested and adds scope creep. - **Fix:** Keep scope tight. Only move README, examples, tools.ts, agent-definition.ts, and my-custom-agent.ts as specified. - -- **Issue:** Did not mirror GT change to import AGENT_TEMPLATES_DIR from '@codebuff/common/old-constants' in the CLI scaffolder. - **Fix:** Update npm-app/src/cli-handlers/agents.ts to import AGENT_TEMPLATES_DIR from '@codebuff/common/old-constants'. - -- **Issue:** No exhaustive repo-wide sweep; some AgentDefinition/ToolCall refs still used legacy paths (e.g., tests). - **Fix:** Search for '.agents' and 'AgentDefinition' and update all imports across common/sdk/tests to the new templates path. - -- **Issue:** Did not verify builds; cross-package "text" imports risk missing assets in release bundles. - **Fix:** Run monorepo typecheck/build and ensure package includes/bundler ship common/src/templates/initial-agents-dir assets. - -## 2025-10-21T03:04:54.094Z — add-agent-resolution (de3ea46) - -### Original Agent Prompt - -Add agent ID resolution and improve the CLI UX for traces, agents listing, and publishing. Specifically: create a small utility that resolves a CLI-provided agent identifier by preserving explicit org prefixes, leaving known local IDs intact, and defaulting unknown unprefixed IDs to a default org prefix. Use this resolver in both the CLI and client when showing the selected agent and when sending requests. Replace usage of the old subagent trace viewer with a new traces handler that improves the status hints and allows pressing 'q' to go back (in both the trace buffer and the trace list). Update the agents menu to group valid custom agents by last modified time, with a "Recently Updated" section for the past week and a "Custom Agents" section for the rest; show a placeholder when none exist. Finally, make publishing errors clearer by printing a concise failure line, optional details, and an optional hint, and ensure the returned error contains non-duplicated fields for callers. Keep the implementation consistent with existing patterns in the codebase. - -### Lessons - -- **Issue:** Kept using cli-handlers/subagent.ts; no new traces handler or import updates in cli.ts/client.ts/subagent-list.ts. - **Fix:** Create cli-handlers/traces.ts, move trace UI there, and update all imports to './traces' with improved status and 'q' support. - -- **Issue:** Trace list 'q' exit checks key.name==='q' without guarding ctrl/meta; Ctrl+Q may exit unintentionally. - **Fix:** Only exit on plain 'q': use (!key?.ctrl && !key?.meta && str==='q') in both trace list and buffer handlers. - -- **Issue:** Agents menu doesn’t filter to valid custom agents and ignores metadata; shows all files with generic desc. - **Fix:** Use loadedAgents to filter entries with def.id && def.model, group by mtime, and show def.description; add placeholder if none. - -- **Issue:** Resolver added in common/agent-name-normalization.ts and no tests; deviates from npm-app pattern and untested. - **Fix:** Add npm-app/src/agents/resolve.ts and npm-app/src/agents/resolve.test.ts covering undefined/prefixed/local/default-prefix cases. - -- **Issue:** Resolver knownIds built via getAllAgents(...), not strictly "known local IDs" as spec requested. - **Fix:** Derive knownIds from Object.keys(localAgentInfo) (local IDs only) to decide when to prefix; still preserve explicit org prefixes. - -- **Issue:** Publish flow doesn’t propagate server 'hint' to callers or print it; returns only error/details. - **Fix:** Include hint in publishAgentTemplates error object and print yellow 'Hint: ...' when present; keep fields non-duplicated. - -## 2025-10-21T03:10:54.539Z — add-prompt-error (9847358) - -### Original Agent Prompt - -Introduce a distinct error channel for user prompts. Add a new server action that specifically reports prompt-related failures, wire server middleware and the main prompt execution path to use it when the originating request is a prompt, and update the CLI client to listen for and display these prompt errors just like general action errors. Keep existing success and streaming behaviors unchanged. - -### Lessons - -- **Issue:** Defined prompt-error with promptId; codebase standardizes on userInputId (e.g., response-chunk). Inconsistent ID naming. - **Fix:** Use userInputId in prompt-error schema/payload and pass action.promptId into it. Keep ID fields consistent across actions. - -- **Issue:** onPrompt sent error response-chunks and a prompt-response in addition to new prompt-error, causing duplicate/noisy output. - **Fix:** On failure, emit only prompt-error and skip response-chunk/prompt-response. Preserve success streaming, not error duplication. - -- **Issue:** Middleware duplicated prompt vs non-prompt branching in 3 places, risking drift and errors. - **Fix:** Create a helper (e.g., getServerErrorAction) that returns prompt-error or action-error based on action.type; reuse it. - -- **Issue:** CLI added a separate prompt-error subscriber duplicating action-error handling logic. - **Fix:** Extract a shared onError handler and subscribe both 'action-error' and 'prompt-error' to it to avoid duplication. - -- **Issue:** Left ServerAction/ClientAction types non-generic, reducing type precision and ergonomics across handlers. - **Fix:** Export generic ServerAction/ClientAction and use Extract-based typing for subscribers/handlers for safer code. - -- **Issue:** Kept augmenting message history and scheduling prompt-response on errors, altering prompt session semantics. - **Fix:** Do not modify history or send prompt-response on error; just emit prompt-error to report failure cleanly. - -## 2025-10-21T03:12:06.098Z — stop-think-deeply (97178a8) - -### Original Agent Prompt - -Update the agent step termination so that purely reflective planning tools do not cause another step. Introduce a shared list of non-progress tools (starting with think_deeply) and adjust the end-of-step logic to end the turn whenever only those tools were used, while still ending on explicit end_turn. Keep the change minimal and localized to the agent step logic and shared tool constants. - -### Lessons - -- **Issue:** Termination checked only toolCalls; toolResults were ignored. If a result from a progress tool appears, the step might not end correctly. - **Fix:** Filter both toolCalls and toolResults by non-progress list; end when no progress items remain in either array (mirrors ground-truth logic). - -- **Issue:** Used calls.length>0 && every(nonProgress). This duplicates the no-tools case and is brittle for edge cases and unexpected results. - **Fix:** Compute hasNoProgress = calls.filter(!list).length===0 && results.filter(!list).length===0; set shouldEndTurn = end_turn || hasNoProgress. - -- **Issue:** End-of-step debug log omitted shouldEndTurn (and flags), reducing observability when diagnosing loop behavior changes. - **Fix:** Include shouldEndTurn (and the computed flag like hasNoProgress) in the final logger.debug payload for the step. - -- **Issue:** Unnecessary type cast (call.toolName as ToolName) and non-type import of ToolName hurt type clarity. - **Fix:** Use import type { ToolName } and avoid casts by relying on existing typing of toolCalls or narrowing via generics. - -- **Issue:** Constant name nonProgressTools lacks intent about step control, making semantics less clear to future readers. - **Fix:** Name the shared list to reflect behavior (e.g., TOOLS_WHICH_WONT_FORCE_NEXT_STEP) and keep it in common constants. - -## 2025-10-21T03:13:08.010Z — update-agent-builder (ab4819b) - -### Original Agent Prompt - -Update the agent builder and example agents to support a new starter custom agent and align example configurations. Specifically: make the agent builder gather both existing diff-reviewer examples and a new your-custom-agent starter template; copy the starter template directly into the top-level agents directory while keeping examples under the examples subfolder; remove advertised spawnable agents from the builder; fix the agent personas to remove an obsolete entry and correct a wording typo; and refresh the diff-reviewer examples to use the current Anthropic model, correct the file-explorer spawn target, and streamline the final step behavior. Also add a new your-custom-agent file that scaffolds a Git Committer agent ready to run and publish. - -### Lessons - -- **Issue:** Removed wrong persona in common/src/constants/agents.ts (deleted claude4_gemini_thinking, left base_agent_builder). - **Fix:** Remove base_agent_builder entry and keep others. Also fix typo to 'multi-agent' in agent_builder purpose. - -- **Issue:** diff-reviewer-3 spawn target set to 'file-explorer' not a published id, breaking validation. - **Fix:** Use fully qualified id: spawnableAgents: ['codebuff/file-explorer@0.0.1'] in both common and .agents examples. - -- **Issue:** Streamlining left an extra add_message step in diff-reviewer-3 before final STEP_ALL. - **Fix:** Remove the intermediate 'yield STEP' and the extra add_message; go directly to 'yield STEP_ALL' after step 4. - -- **Issue:** Starter scaffold in common/src/util/your-custom-agent.ts used id 'your-custom-agent' and lacked spawn_agents/file-explorer. - **Fix:** Create a Git Committer starter: id 'git-committer', include 'spawn_agents', spawnableAgents ['codebuff/file-explorer@0.0.1']. - -- **Issue:** Builder injected publisher/version into starter via brittle string replaces and './constants' import. - **Fix:** Author the starter file ready-to-use; builder should copy as-is to .agents root without string mutation/injection. - -- **Issue:** Updated .agents/examples/\* directly (generated outputs), causing duplication and drift. - **Fix:** Only update source examples under common/src/util/examples; let the builder copy them to .agents/examples. - -- **Issue:** diff-reviewer-3 example text wasn’t aligned with streamlined flow (kept separate review message step). - **Fix:** Merge intent into step 4 message (spawn explorer then review) and end with a single 'yield STEP_ALL'. - - **Fix:** Remove or use unused constants/imports to avoid noUnusedLocals warnings after refactors. - -## 2025-10-21T03:13:39.771Z — overhaul-agent-examples (bf5872d) - -### Original Agent Prompt - -Overhaul the example agents and CLI scaffolding. Replace the older diff-reviewer-\* examples with three new examples (basic diff reviewer, intermediate git committer, advanced file explorer), update the CLI to create these files in .agents/examples, enhance the changes-reviewer agent to be able to spawn the file explorer while reviewing diffs or staged changes, add structured output to the file-explorer agent, and revise the default my-custom-agent to focus on reviewing changes rather than committing. Keep existing types and README generation intact. - -### Lessons - -- **Issue:** changes-reviewer spawnPurposePrompt didn’t mention staged changes. - **Fix:** Update spawnPurposePrompt to “review code in git diff or staged changes” in .agents/changes-reviewer.ts. - -- **Issue:** changes-reviewer didn’t guide spawning the file explorer during review. - **Fix:** Inject an add_message hint before STEP_ALL to prompt spawning file-explorer and add spawn_agents usage. - -- **Issue:** Old .agents/examples/diff-reviewer-\*.ts files were left in repo. - **Fix:** Delete diff-reviewer-1/2/3.ts to fully replace them with the new examples and avoid confusion. - -- **Issue:** Advanced example agent lacks an outputSchema while using structured_output. - **Fix:** Add outputSchema to .agents/examples/advanced-file-explorer.ts matching its set_output payload. - -- **Issue:** Advanced example uses local 'file-picker' id instead of a fully qualified ID. - **Fix:** Set spawnableAgents to 'codebuff/file-picker@0.0.1' and spawn that ID for clarity and portability. - -- **Issue:** changes-reviewer kept 'end_turn' in toolNames while also using STEP/STEP_ALL. - **Fix:** Remove 'end_turn' from toolNames to reduce model confusion; rely on STEP/STEP_ALL to end turns. - -- **Issue:** Unused imports (e.g., AgentStepContext) remained in example files. - **Fix:** Remove unused imports in examples to prevent lint/type warnings and keep code clean. - -- **Issue:** File-explorer example output didn’t clearly align outputSchema with actual data shape. - **Fix:** Ensure set_output fields match outputSchema (e.g., files: string[]) and keep names consistent across both. - -## 2025-10-21T03:14:43.174Z — update-validation-api (0acdecd) - -### Original Agent Prompt - -Simplify the agent validation flow to not require authentication and to use an array-based payload. Update the CLI helper to send an array of local agent configs and call the web validation API without any auth. Update the web validation endpoint to accept an array, convert it to the format expected by the shared validator, and return the same response structure. Make sure initialization validates local agents even when the user is not logged in, and keep logging and error responses clear. - -### Lessons - -- **Issue:** Changed validate API payload to a top-level array, breaking callers expecting { agentConfigs }. See utils/agent-validation.ts and web route. - **Fix:** Keep request envelope { agentConfigs: [...] } in client and server; convert to record internally; remove auth only. - -- **Issue:** Renamed helper to validateLocalAgents, risking broken imports/tests. Prior name was used elsewhere (client, potential future refs). - **Fix:** Preserve export name validateAgentConfigsIfAuthenticated; drop the user param and accept an array; update call sites only. - -- **Issue:** Dropped typed request shape in web route; used unknown + Array.isArray. Lost explicit contract and validation detail. - **Fix:** Define a typed ValidateAgentsRequest (or Zod schema) with agentConfigs: any[]; validate and return clear 400 errors on shape. - -- **Issue:** No per-item validation in route; primitives or missing id entries are accepted and keyed as agent-i silently. - **Fix:** Validate each item is an object with string id; reject or report which entries are invalid before calling validateAgents. - -## 2025-10-21T03:17:32.159Z — migrate-agents (02ef7c0) - -### Original Agent Prompt - -### Lessons - -- **Issue:** Did not add .agents/types modules; used inline .d.ts strings from CLI scaffolding. - **Fix:** Create .agents/types/agent-definition.ts and tools.ts files and bundle them; import as text where needed. - -- **Issue:** Agent builder performed fs/path I/O and copied files; not model-only. - **Fix:** Remove file ops and handleSteps side effects; embed types via text imports and set outputMode to 'last_message'. - -- **Issue:** Agent builder toolNames included add_message/set_output and excess tools. - **Fix:** Use minimal tools: ['write_file','str_replace','run_terminal_command','read_files','code_search','spawn_agents','end_turn']. - -- **Issue:** Examples used outdated model IDs (e.g., openai/gpt-5) contrary to spec. - **Fix:** Update example models to anthropic/claude-4-sonnet-20250522 per modern baseline. - -- **Issue:** diff-reviewer-3 spawnableAgents used a non-canonical ID. - **Fix:** Set spawnableAgents to ['codebuff/file-explorer@0.0.1'] to match the agent store IDs. - -- **Issue:** diff-reviewer-3 step flow was verbose with multiple STEP/add_message calls. - **Fix:** Streamline flow and end with a single 'STEP_ALL' after priming any assistant message. - -- **Issue:** Starter agent not created or named incorrectly (starter.ts). - **Fix:** Add .agents/my-custom-agent.ts with a simple, runnable starter (e.g., Git Committer) using modern IDs. - -- **Issue:** README in .agents was missing/minimal and not helpful. - **Fix:** Provide a concise .agents/README.md with getting started, file structure, tool list, and usage tips. - -- **Issue:** Legacy common/src/util/types and util/examples were left in place or neutered, not removed. - **Fix:** Delete those legacy directories after fixing references; or replace files with pure re-exports and then remove dirs. - -- **Issue:** Mixed re-exports with legacy declarations in common/src/util/types/tools.d.ts causing duplicate types. - **Fix:** Replace file contents entirely with re-exports to canonical types; avoid any duplicated declarations. - -- **Issue:** Introduced common/src/types.ts which conflicts with existing types directory. - **Fix:** Avoid a top-level types.ts; add common/src/types/agent-definition.ts and re-export canonical .agents types. - -- **Issue:** SDK build scripts still copy legacy util/types; risk breakage after deletion. - **Fix:** Remove copy-types step in sdk/package.json; have sdk/src/types/\* re-export from @codebuff/common/types. - -- **Issue:** Imports across common/sdk not fully updated to canonical common/src/types. - **Fix:** Point all imports (including tests) to '@codebuff/common/types' or local common/src/types re-exports. - -- **Issue:** CLI scaffolding wrote raw strings instead of using bundled text imports for templates. - **Fix:** Bundle the type/example/starter/README text and write files via ESM text imports in the CLI. - -## 2025-10-21T03:18:26.438Z — restore-subagents-field (b30e2ef) - -### Original Agent Prompt - -Migrate the AgentState structure to use a 'subagents' array instead of 'spawnableAgents' across the schema, state initialization, spawn handlers, and tests. Ensure all places that construct or validate AgentState use 'subagents' consistently while leaving AgentTemplate.spawnableAgents intact. Update developer-facing JSDoc to clarify how to specify spawnable agent IDs. Keep the existing agent spawning behavior unchanged. - -### Lessons - -- **Issue:** Missed migrating async spawn handler: spawn-agents-async.ts still sets AgentState.spawnableAgents: []. - -- **Issue:** Tests not updated: sandbox-generator.test.ts still builds AgentState with spawnableAgents: []. - -- **Issue:** JSDoc for spawnable agent IDs is vague; doesn’t mandate fully-qualified IDs with publisher and version. - **Fix:** Update docs to require 'publisher/name@version' or local '.agents' id. Mirror this in common/src/util/types/agent-config.d.ts. - -- **Issue:** Refactor audit was incomplete; not all AgentState constructors were checked, leading to inconsistency. - **Fix:** Run repo-wide search for AgentState literals and ‘spawnableAgents:’ and fix all to ‘subagents’, especially all spawn handlers. - -- **Issue:** Didn’t validate behavior parity; leaving async path unmigrated risks runtime/type errors and altered spawn flow. - **Fix:** After schema change, typecheck and verify spawning via sync, async, and inline paths to ensure unchanged behavior. - -## 2025-10-21T03:23:52.779Z — expand-agent-types (68e4f6c) - -### Original Agent Prompt - -We need to let our internal .agents declare a superset of tools (including some client-only/internal tools) without affecting public agent validation. Add a new SecretAgentDefinition type for .agents that accepts these internal tools, switch our built-in agents to use it, and keep dynamic/public agents constrained to the public tool list. Also relocate the publishedTools constant from the tools list module to the tools constants module and update any imports that depend on it. No runtime behavior should change—this is a type/constant refactor that must compile cleanly and keep existing tests green. - -### Lessons - -- **Issue:** Did not add a dedicated SecretAgentDefinition for .agents to allow internal tools. - **Fix:** Create .agents/types/secret-agent-definition.ts extending AgentDefinition with toolNames?: AllToolNames[]. - -- **Issue:** Modified the public AgentDefinition instead of isolating secret typing. - **Fix:** Leave AgentDefinition untouched for public/dynamic agents; add a separate SecretAgentDefinition used only by .agents. - -- **Issue:** Built-in .agents still used AgentDefinition. - **Fix:** Switch all built-in agents to import/use SecretAgentDefinition (e.g., .agents/base.ts, ask.ts, base-lite.ts, base-max.ts, superagent.ts). - -- **Issue:** publishedTools stayed in common/src/tools/list.ts. - **Fix:** Move publishedTools to common/src/tools/constants.ts and export it alongside toolNames. - -- **Issue:** Imports weren’t updated after moving publishedTools. - **Fix:** Update import sites to use tools/constants (e.g., common/src/tools/compile-tool-definitions.ts and tests). - -- **Issue:** Dynamic/public agent validation wasn’t constrained to public tools. - **Fix:** Keep DynamicAgentDefinitionSchema using z.enum(toolNames) and ensure only public ToolName is allowed. - -- **Issue:** Internal tool union was not defined as a clean superset of public tools. - **Fix:** Define AllToolNames = Tools.ToolName | 'add_subgoal'|'browser_logs'|'create_plan'|'spawn_agents_async'|'spawn_agent_inline'|'update_subgoal'. - -- **Issue:** Changes risked runtime behavior (editing core types/handlers). - **Fix:** Make a type/constant-only refactor; do not change llmToolCallSchema, handlers, or runtime code paths. - -- **Issue:** Missed updating all agent files to the new type (some remained on AgentDefinition). - **Fix:** Grep all .agents/\*.ts and replace AgentDefinition with SecretAgentDefinition consistently (incl. oss agents). - -- **Issue:** Didn’t validate the refactor with a compile/test pass. - **Fix:** Run typecheck/tests locally to catch missing imports or schema mismatches and keep tests green. - -## 2025-10-21T03:26:22.005Z — migrate-agent-validation (2b5651f) - -### Original Agent Prompt - -### Lessons - -- **Issue:** API route expects 'agents' but CLI util posts 'agentConfigs' (utils/agent-validation.ts) → 400s get swallowed. - **Fix:** Standardize payload to 'agentConfigs' across route and callers; validate and return clear errors. - -- **Issue:** Validation API auth used checkAuthToken and body authToken, diverging from NextAuth cookie session. - **Fix:** Rely on getServerSession(authOptions) only; require NextAuth cookie from CLI for auth. - -- **Issue:** CLI command /agents-validate sends authToken in JSON body instead of session cookie; inconsistent auth. - **Fix:** Send Cookie: next-auth.session-token (like other CLI calls); drop authToken from body. - -- **Issue:** dynamic-agents.knowledge.md was not removed; stale doc risks being ingested as knowledge. - -- **Issue:** ProjectFileContext still sources agentTemplates from global loadedAgents (implicit state). - **Fix:** Assign agentTemplates from await loadLocalAgents(...) return; avoid globals to prevent staleness. - -- **Issue:** onInit removed fileContext from destructure while clients still send it; risks type/API drift. - **Fix:** Keep fileContext in the init signature (even if unused) to match ClientAction and avoid regressions. - -- **Issue:** Silent try/catch around startup validation hides API errors; no debug trail for failures. - **Fix:** Log validation failures at debug/info and print a concise warning when validation cannot run. - -## 2025-10-21T03:30:33.249Z — relocate-ws-errors (70239cb) - -### Original Agent Prompt - -### Lessons - -- **Issue:** Wrapper sendActionOrExit initially called itself, causing infinite recursion and potential stack overflow. - -- **Issue:** Wrapper returned Promise|void with a thenable check, making behavior/contract unclear and harder to reason about. - **Fix:** Implement wrapper as async and always await sendAction; explicitly return Promise and catch/exit on errors. - -- **Issue:** On send failure, the CLI exit path didn’t stop the Spinner, risking UI artifacts on error exits. - **Fix:** Stop the spinner before exiting: Spinner.get().stop(); then log the error, print update guidance, and process.exit(1). - -- **Issue:** No explicit verification that all CLI sendAction call sites were wrapped (only client.ts was updated). - -- **Issue:** If socket isn’t OPEN, sendAction returns undefined; wrapper gives no feedback, so failed sends silently noop. - -## 2025-10-21T03:34:04.751Z — bundle-agent-types (5484add) - -### Original Agent Prompt - -Internalize the AgentConfig definition and related tool type definitions within the SDK so that consumers import types directly from @codebuff/sdk. Update the SDK build to copy the .d.ts type sources from the monorepo’s common package into the SDK before compiling, adjust the client to import AgentConfig from the SDK’s local types, and update the SDK entrypoint to re-export AgentConfig as a type. Add the corresponding type files under sdk/src/util/types to mirror the common definitions and keep them self-contained. - -### Lessons - -- **Issue:** Types weren’t copied from common to SDK before compile; a post-build copy was added from src→dist instead. - **Fix:** Add a prebuild step to copy ../common/src/util/types/\*.d.ts into sdk/src/util/types before tsc runs. - -- **Issue:** Build order was wrong: ran tsc then copied .d.ts, so they weren’t part of the compilation pipeline. - **Fix:** Invoke copy first, then compile (e.g., "bun run copy-types && tsc") so types are available during build. - -- **Issue:** Copied from SDK src to dist only; no automation to sync from the monorepo common package. - **Fix:** Implement a copy-types script that sources from ../common and targets sdk/src to keep SDK in sync. - -- **Issue:** Created static .d.ts in repo, risking drift from common definitions over time. - **Fix:** Automate sync from common on every build to eliminate drift; don’t hand-maintain large type files. - -- **Issue:** Left types as .d.ts in src, requiring a custom copy to dist; TS won’t emit .d.ts for .d.ts. - **Fix:** Copy to .ts in sdk/src (as in GT) so tsc emits declarations to dist without an extra copy step. - -- **Issue:** No dedicated "copy-types" npm script; build hardcoded a post-compile copier. - **Fix:** Add "copy-types" script (mkdir/cp) and call it in build: "bun run copy-types && tsc". - -- **Issue:** Didn’t validate publish output alignment; potential mismatch of exports/types paths in dist. - **Fix:** Run npm pack --dry-run on dist, verify dist/sdk/src/util/types/\*.d.ts exists and exports/types resolve. - -- **Issue:** Introduced unrelated changes (bun.lock, extra deps) not required for the task. - **Fix:** Limit diffs to required files; avoid lockfile/dependency churn unless necessary for the feature. - -## 2025-10-21T03:34:42.036Z — fork-read-files (349a140) - -### Original Agent Prompt - -### Lessons - -- **Issue:** sdk/src/tools/read-files.ts keyed results by originalPath, risking mismatch if server sends absolute paths. - **Fix:** Key results by path.relative(cwd, absolutePath) so returned keys are cwd-relative and stable regardless of input form. - -- **Issue:** Directories aren’t explicitly handled; readFileSync on dirs falls through to generic ERROR after attempt. - **Fix:** Check stats.isDirectory() and immediately return FILE_READ_STATUS.ERROR for directory targets to be explicit. - -- **Issue:** Gitignore check errors are silently swallowed (empty catch), hiding issues and producing inconsistent behavior. - **Fix:** On ig.ignores errors, set status to FILE_READ_STATUS.ERROR or log a console.warn to aid diagnosis. - -- **Issue:** parseGitignore is recreated on every call, adding avoidable overhead for repeated reads in the same cwd. - **Fix:** Cache the parsed ignore matcher per cwd (module-level Map) and reuse it across getFiles calls. - -- **Issue:** Out-of-bounds check uses string startsWith; edge cases (e.g., path casing on Windows) could slip through. - **Fix:** Use common/src/util/file.isSubdir(cwd, absolutePath) for robust cross-platform containment checks. - -## 2025-10-21T03:35:51.223Z — update-sdk-types (73a0d35) - -### Original Agent Prompt - -In the SDK package, move the agent/tool type definitions into a new src/types directory and update internal imports to use it. Adjust the build step that copies type declarations to target the new directory. Simplify the publishing flow so that verification and publishing occur from the sdk directory (no rewriting package.json in dist). Update the package exports to reference the built index path that aligns with publishing from the sdk directory, include the changelog in package files, bump the version, and update the changelog to document the latest release with the completed client and new run() API. - -### Lessons - -- **Issue:** package.json main/types/exports kept ./dist/index.\*; doesn’t align with publishing from sdk or monorepo dist layout. - **Fix:** Update main/types/exports to the actual built entry (e.g. ./dist/sdk/src/index.js/.d.ts) to match the publish cwd and build output. - -- **Issue:** SDK code still imports ../../common/src/\*; publishing from sdk omits common, breaking runtime resolution. - **Fix:** Replace relative common imports with a proper package dep (e.g. @codebuff/common) or point entry to a build that includes common. - -- **Issue:** Committed src/types/\*.ts while still running copy-types to overwrite them, risking drift and confusing source of truth. - **Fix:** Pick one source: either generate at build (keep copy-types, don’t commit files) or commit types and remove the copy-types step. - -- **Issue:** Version bump and CHANGELOG didn’t follow existing style/timeline (0.2.0 vs expected 0.1.x; removed intro line; dates/notes off). - **Fix:** Match repo’s semver and format. Bump to the intended version, keep the header line, and add notes for completed client and run() API. - -- **Issue:** Exports path wasn’t updated to the built index that matches simplified publish (npm pack from sdk, not dist/). - **Fix:** Ensure exports map points to built files reachable when packing from sdk (e.g. types/import/default -> ./dist/sdk/src/index.\*). - -- **Issue:** Did not validate that removing util/types or adding src/types keeps ts outputs consistent and avoids duplicate emit. - **Fix:** After moving types, remove old dir and verify tsconfig include/exclude produce a single set of .js/.d.ts without duplicates. - -## 2025-10-21T03:37:19.438Z — stream-event-bridge (e3c563e) - -### Original Agent Prompt - -### Lessons - -- **Issue:** Event handlers aren’t cleared on non-success paths (schema fail, action-error, cancel, reconnect), risking leaks in promptIdToEventHandler. - **Fix:** Always delete handlers on all end paths: in onResponseError, on PromptResponseSchema reject, on reconnect/close, and when canceling a run. - -- **Issue:** subagent-response-chunk is a no-op; structured subagent events aren’t forwarded to callers. - **Fix:** Implement onSubagentResponseChunk to forward object chunks (with agentId/agentType) for matching userInputId to the provided handler. - -- **Issue:** Structured chunks are forwarded without validation; malformed objects could reach the user callback. - **Fix:** Validate action.chunk with printModeEventSchema before invoking handleEvent; log or ignore when validation fails. - -## 2025-10-21T03:37:33.756Z — spawn-inline-agent (dac33f3) - -### Original Agent Prompt - -### Lessons - -- **Issue:** Inline handler didn’t expire 'userPrompt' TTL after child finishes, leaving temporary prompts in history. - **Fix:** After child run, call expireMessages(finalMessages, 'userPrompt') and write back to state/messages to purge temp prompts. - -- **Issue:** set_messages schema didn’t passthrough extra fields; timeToLive/keepDuringTruncation were stripped from messages. - **Fix:** Update common/src/tools/params/tool/set-messages.ts to .passthrough() the message object to retain custom fields. - -- **Issue:** Child used a cloned message array, not the shared reference; not truly inline during execution. - **Fix:** Set childAgentState.messageHistory = getLatestState().messages (shared array) so inline edits affect the same history. - -- **Issue:** Child agentContext was reset to {}; inline child didn’t share parent context/state. - **Fix:** Initialize child agent with agentContext = parent.agentState.agentContext to share state and preserve updates. - -- **Issue:** Tests mocked loopAgentSteps/set_messages; didn’t exercise real handler path or assert no tool_result emission. - **Fix:** Add integration tests that stream a spawn_agent_inline call, verify no tool_result message, and assert real history updates. - -- **Issue:** TTL tests didn’t verify userPrompt expiration; only simulated agentStep TTL via mocks. - **Fix:** Add a test where child runs normally and assert userPrompt TTL prompts are removed after inline completion. - -- **Issue:** Didn’t update shared .d.ts types with new tool; consumers may miss spawn_agent_inline typings. - **Fix:** Update common/src/util/types/tools.d.ts (ToolName, ToolParamsMap, SpawnAgentInlineParams) to match new tool. - -- **Issue:** Didn’t validate message deletion via actual set_messages tool flow; only mocked replacement. - **Fix:** Create an inline child that calls set_messages; assert schema accepts timeToLive and history is replaced as expected. - -## 2025-10-21T03:37:39.469Z — support-agentconfigs (2fcbe70) - -### Original Agent Prompt - -Enhance the SDK to accept multiple custom agents in a single run and provide a reusable AgentConfig type. Introduce a shared type module that defines both AgentConfig (for user-supplied agent definitions) and ToolCall, export AgentConfig from the SDK entrypoint, and update the SDK client API to take an agentConfigs array. When preparing session state, convert this array into the agentTemplates map, stringifying any handleSteps functions. Refresh the README to document agentConfigs with a brief example and update the parameter reference accordingly. - -### Lessons - -- **Issue:** Breaking API change: agentConfig -> agentConfigs without backward-compat handling. - **Fix:** Accept legacy agentConfig (map) and convert to agentTemplates, while supporting new agentConfigs[]. Deprecate with warning. - -- **Issue:** No validation of agentConfigs array (e.g., missing/duplicate id). - **Fix:** Validate each AgentConfig: ensure non-empty unique id; throw clear error on invalid/dup ids before building agentTemplates. - -- **Issue:** README lacks a concrete AgentConfig example; users may not know required fields. - **Fix:** Add a minimal AgentConfig object example (id, model, displayName, prompts, toolNames) and show import: `import { AgentConfig } from '@codebuff/sdk'`. - -- **Issue:** ToolCall was added to a shared type module but not exported from SDK entrypoint. - **Fix:** Re-export type ToolCall from sdk/src/index.ts (or document where to import it) to avoid consumers reaching into internal paths. - -- **Issue:** JSDoc for `agent` param doesn’t note relation to provided agentConfigs ids. - **Fix:** Update JSDoc: agent must be a built-in or match an id from agentConfigs; clarify selection behavior for custom agents. - -- **Issue:** Minor formatting/indentation drift in client.ts diff could hurt readability. - **Fix:** Run formatter/linter and keep indentation consistent, especially around the initialSessionState call and param blocks. - -## 2025-10-21T03:38:58.318Z — unify-agent-builder (4852954) - -### Original Agent Prompt - -Unify the agent-builder system into a single builder, update agent type definitions to use structured output, and introduce three diff-reviewer example agents. Remove the deprecated messaging tool and update the agent registry and CLI flows to target the unified builder. Ensure the builder prepares local .agents/types and .agents/examples, copies the correct type definitions and example agents from common, and leaves agents and examples ready to compile and run. - -### Lessons - -- **Issue:** Unified the wrong builder: removed agent_builder and kept base_agent_builder across registry/types/personas. - **Fix:** Keep agent_builder as the single builder, remove base_agent_builder and update all refs to AgentTemplateTypes.agent_builder. - - **Fix:** In agent-list.ts, import and register ./agents/agent-builder as AgentTemplateTypes.agent_builder; drop base_agent_builder. - -- **Issue:** CLI flows still target base_agent_builder (npm-app/src/cli-handlers/agent-creation-chat.ts, agents.ts). - **Fix:** Update CLI to use AgentTemplateTypes.agent_builder in resetAgent() and menus so users target the unified builder. - -- **Issue:** Introduced malformed code via str_replace in .agents/agent-builder.ts (broken yield args). - **Fix:** Prefer write_file with full, validated snippet or structured patch; run typecheck after edits to catch syntax errors. - -- **Issue:** Local types in .agents/types/agent-config.d.ts not updated: json mode left; ToolResult generic unchanged. - **Fix:** Change outputMode union to include 'structured_output' (not 'json') and StepGenerator yield generic to string|undefined. - -- **Issue:** Local tools types kept deprecated send_agent_message and missed spawn_agent_inline (.agents/types/tools.d.ts). - **Fix:** Remove send_agent_message from ToolName/params map; add spawn_agent_inline with proper params; adjust param optionals. - -- **Issue:** .agents/superagent.ts still includes deprecated 'send_agent_message' in toolNames. - **Fix:** Remove 'send_agent_message' from toolNames in .agents/superagent.ts to match current tool surface. - -- **Issue:** .agents/file-explorer.ts uses outputMode 'json' instead of structured_output. - **Fix:** Switch outputMode to 'structured_output' in .agents/file-explorer.ts and ensure set_output is available. - -- **Issue:** Placed diff-reviewer examples under common with wrong names; not prepared under .agents/examples. - **Fix:** Create .agents/examples/diff-reviewer-{1,2,3}.ts; ensure correct imports; builder should copy them into that folder. - -- **Issue:** Builder didn’t reliably prepare .agents/examples and copy correct example set from common. - -- **Issue:** Builder/types sync gap: updated common and sdk types but not the local .agents/types used by user agents. - **Fix:** Have the builder write current common types into .agents/types (agent-config.d.ts, tools.d.ts) so locals compile. - -- **Issue:** Removed agent_builder from common/src/types/session-state.ts and constants/agents.ts instead of base_agent_builder. - **Fix:** Keep 'agent_builder' in AgentTemplateTypeList/personas; remove 'base_agent_builder' to reflect the unified builder. - -## 2025-10-21T03:44:28.949Z — add-agent-store (95883eb) - -### Original Agent Prompt - -Build a public Agent Store experience. Add a new /agents page that lists published agents with search and sorting and links into existing agent detail pages. Implement a simple /api/agents list endpoint that pulls agents from the database, joins publisher info, includes basic summary fields from the agent JSON, and adds placeholder usage metrics. Update the site navigation to include an "Agent Store" link in both the header and the user dropdown. Keep the implementation aligned with the existing agent detail route structure and the current database schema. - -### Lessons - -- **Issue:** Agents page used native / - - {state.fieldErrors?.handle && ( -

- {state.fieldErrors.handle.join(', ')} -

- )} - {!state.success && state.message && !state.fieldErrors?.handle && ( -

{state.message}

- )} - - - - ) -} - -export default function AffiliatesPage() { - const { status: sessionStatus } = useSession() - const [userProfile, setUserProfile] = useState< - { handle: string | null; referralCode: string | null } | undefined - >(undefined) - const [fetchError, setFetchError] = useState(null) - - const fetchUserProfile = useCallback(() => { - setFetchError(null) - fetch('/api/user/profile') - .then(async (res) => { - if (!res.ok) { - const errorData = await res.json().catch(() => ({})) - throw new Error( - errorData.error || `HTTP error! status: ${res.status}`, - ) - } - return res.json() - }) - .then((data) => { - setUserProfile({ - handle: data.handle ?? null, - referralCode: data.referral_code ?? null, - }) - }) - .catch((error) => { - console.error('Failed to fetch user profile:', error) - setFetchError(error.message || 'Failed to load profile data.') - setUserProfile({ handle: null, referralCode: null }) - }) - }, []) - - useEffect(() => { - if (sessionStatus === 'authenticated') { - fetchUserProfile() - } else if (sessionStatus === 'unauthenticated') { - setUserProfile({ handle: null, referralCode: null }) - } - }, [sessionStatus, fetchUserProfile]) - - if (sessionStatus === 'loading' || userProfile === undefined) { - return ( -
-
- - - - - - - - - - - -
-
- ) - } - - if (sessionStatus === 'unauthenticated') { - return ( - -

- Want to partner with Codebuff and earn rewards? Log in first! -

- - - } - /> - ) - } - - if (fetchError) { - return ( -
-
-

Error loading affiliate information: {fetchError}

-

Please try refreshing the page or contact support.

-
-
- ) - } - - const userHandle = userProfile?.handle - const referralCode = userProfile?.referralCode - - return ( -
-
- - - - Codebuff Affiliate Program - - - Share Codebuff and earn credits! - - - - {userHandle === null && ( -
-

- Become an Affiliate -

-

- Generate your unique referral link, that grants you{' '} - {AFFILIATE_USER_REFFERAL_LIMIT.toLocaleString()} referrals for - your friends, colleagues, and followers. When they sign up - using your link, you'll both earn an extra{' '} - {CREDITS_REFERRAL_BONUS} credits! -

- - -
- )} - - {userHandle && ( -
-

- Your Affiliate Handle -

-

- Your affiliate handle is set to:{' '} - - {userHandle} - - . You can now refer up to{' '} - {AFFILIATE_USER_REFFERAL_LIMIT.toLocaleString()} new users! -

-

- Your referral link is:{' '} - {`${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/${userHandle}`} -

-
- )} - -

- Questions? Contact us at{' '} - - {env.NEXT_PUBLIC_SUPPORT_EMAIL} - - . -

-
-
-
-
- ) -} diff --git a/web/src/app/analytics.knowledge.md b/web/src/app/analytics.knowledge.md index c2a83208e3..4be048f766 100644 --- a/web/src/app/analytics.knowledge.md +++ b/web/src/app/analytics.knowledge.md @@ -70,12 +70,7 @@ The application uses the following event categories for consistent tracking: - subscription.payment_completed - subscription.change_confirmed -6. Referral Events (`referral.*`) - - referral.link_copied - - referral.code_redeemed - - referral.invite_sent - -7. Documentation Events (`docs.*`) +6. Documentation Events (`docs.*`) - docs.viewed 8. Banner Events (`banner.*`) @@ -129,14 +124,6 @@ Properties that should be included with events: } ``` -2. Banner Events: - ```typescript - { - type: 'youtube_referral' | 'referral', - source?: string // The referrer if available - } - ``` - Other Events: 1. Auth Events: @@ -156,14 +143,6 @@ Other Events: } ``` -3. Referral Events: - ```typescript - { - referrer?: string, - code?: string - } - ``` - Example event tracking: ```typescript @@ -203,12 +182,6 @@ Examples by category: - subscription.upgrade_started - subscription.payment_completed -### Referral Events - -- referral.link_copied -- referral.code_redeemed -- referral.invite_sent - Example event properties: ```typescript @@ -333,70 +306,3 @@ Important: This pattern ensures accurate attribution even when users don't conve - Handle missing or malformed origin headers - Keep CORS headers consistent in both success and error responses -## UTM Source Handling - -Special UTM sources: - -- youtube: Shows personalized banner with referrer name and bonus amount -- Referrer name passed via `referrer` parameter -- Used for tracking creator-driven referrals -- Important: Referrer display names differ from routing keys -- Maintain mapping of routing keys to display names for consistent tracking - -## Referral Link Handling - -Special UTM sources: - -- youtube: Shows personalized banner with referrer name and bonus amount -- Referrer name passed via `referrer` parameter -- Used for tracking creator-driven referrals -- Important: Referrer display names differ from routing keys -- Maintain mapping of routing keys to display names for consistent tracking - -## Route Parameters vs Display Names - -- Route parameters (e.g., [sponsee-name]) are for URL routing only -- Keep routing keys simple and URL-friendly (e.g., 'berman') -- Display names should be separate from routing keys (e.g., 'Matthew Berman') -- Only use routing key validation in the page component -- Use display names only in user-facing UI components like banners -- Keep routing logic separate from display logic -- Example: /[sponsee-name] validates 'berman' for routing but displays "Matthew Berman" in UI - -## Sponsee Referral Configuration - -Each sponsee has three distinct identifiers: - -- Routing key: URL-friendly identifier for page routing (e.g., 'berman') -- Display name: Full name for UI display (e.g., 'Matthew Berman') -- Referral code: Unique code for tracking referrals -- Important: Keep all three IDs together in sponseeConfig -- Use routing key as object key for consistent lookup - -The sponseeConfig object in constants.ts is the single source of truth for: - -- Route validation (/[sponsee] page) -- Display names (banner, referral pages) -- Referral code mapping (referral system) -- YouTube referral tracking - -Example flow: - -1. User visits /{routing-key} -2. Redirects to /?utm_source=youtube&referrer={routing-key} -3. Banner shows {display-name} -4. "Learn more" links to /referrals/{referral-code} - -## Route Parameters vs Display Names - -- Route parameters (e.g., [sponsee-name]) are used for URL routing. -- The `/[sponsee]` page validates the handle against the database. -- Display names shown in the UI (like on the referral redemption page) now primarily come from the API response (`referrerName`) or the `referrer` URL parameter. - -## Referral Link Handling - -Special UTM sources: - -- `youtube`: Indicates a referral likely came from a partner/creator. -- The `referrer` parameter contains the handle associated with the referral link. -- This information is used for tracking in PostHog. diff --git a/web/src/app/api/admin/bot-sweep/route.ts b/web/src/app/api/admin/bot-sweep/route.ts new file mode 100644 index 0000000000..39d28d0127 --- /dev/null +++ b/web/src/app/api/admin/bot-sweep/route.ts @@ -0,0 +1,82 @@ +import { timingSafeEqual } from 'crypto' + +import { env } from '@codebuff/internal/env' +import { sendBasicEmail } from '@codebuff/internal/loops/client' +import { NextResponse } from 'next/server' + +import { + formatSweepReport, + identifyBotSuspects, +} from '@/server/free-session/abuse-detection' +import { reviewSuspects } from '@/server/free-session/abuse-review' +import { logger } from '@/util/logger' + +import type { NextRequest } from 'next/server' + +const REPORT_RECIPIENT = 'james@codebuff.com' + +/** + * Hourly bot-sweep endpoint called by the GitHub Actions workflow. + * + * Auth: static bearer token from BOT_SWEEP_SECRET. This lets CI call the + * endpoint without a NextAuth session, and keeps prod DATABASE_URL out of + * GitHub secrets. + * + * This is a DRY RUN — it reports suspects via email and never bans anyone. + */ +export async function POST(req: NextRequest) { + const secret = env.BOT_SWEEP_SECRET + if (!secret) { + return NextResponse.json( + { error: 'bot-sweep not configured (BOT_SWEEP_SECRET missing)' }, + { status: 503 }, + ) + } + + const authHeader = req.headers.get('Authorization') ?? '' + const expected = `Bearer ${secret}` + const a = Buffer.from(authHeader) + const b = Buffer.from(expected) + if (a.length !== b.length || !timingSafeEqual(a, b)) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + + try { + const report = await identifyBotSuspects({ logger }) + const { subject, message } = formatSweepReport(report) + + // Second-pass agent review. Advisory only — if it fails or returns + // null we still send the rule-based report. Lead with the agent's + // tiered recommendation since that's the actionable part; raw + // rule-based data follows as supporting detail. + const agentReview = await reviewSuspects({ report, logger }) + const fullMessage = agentReview + ? `=== AGENT REVIEW (Claude Sonnet 4.6) ===\n\n${agentReview}\n\n=== RAW RULE-BASED DATA ===\n\n${message}` + : message + + const emailResult = await sendBasicEmail({ + email: REPORT_RECIPIENT, + data: { subject, message: fullMessage }, + logger, + }) + + if (!emailResult.success) { + logger.error( + { error: emailResult.error }, + 'Failed to email bot-sweep report', + ) + } + + return NextResponse.json({ + ok: true, + totalSessions: report.totalSessions, + suspectCount: report.suspects.length, + highTierCount: report.suspects.filter((s) => s.tier === 'high').length, + emailSent: emailResult.success, + agentReview, + }) + } catch (error) { + logger.error({ error }, 'bot-sweep failed') + return NextResponse.json({ error: 'sweep failed' }, { status: 500 }) + } +} diff --git a/web/src/app/api/admin/relabel-for-user/route.ts b/web/src/app/api/admin/relabel-for-user/route.ts index 18ee10b0f5..be85d012fe 100644 --- a/web/src/app/api/admin/relabel-for-user/route.ts +++ b/web/src/app/api/admin/relabel-for-user/route.ts @@ -5,7 +5,6 @@ import { insertRelabel, setupBigQuery, type GetExpandedFileContextForTrainingBlobTrace, - type GetExpandedFileContextForTrainingTrace, type GetRelevantFilesPayload, type GetRelevantFilesTrace, type Relabel, @@ -16,6 +15,7 @@ import { models, TEST_USER_ID, } from '@codebuff/common/old-constants' +import { unwrapPromptResult } from '@codebuff/common/util/error' import { userMessage } from '@codebuff/common/util/messages' import { generateCompactId } from '@codebuff/common/util/string' import { closeXml } from '@codebuff/common/util/xml' @@ -25,9 +25,9 @@ import { NextResponse } from 'next/server' import { checkAdminAuth } from '../../../../lib/admin-auth' import { logger } from '../../../../util/logger' +import type { System } from '@codebuff/agent-runtime/llm-api/claude' import type { Message } from '@codebuff/common/types/messages/codebuff-message' import type { NextRequest } from 'next/server' -import type { System } from '@codebuff/agent-runtime/llm-api/claude' // Type for messages stored in BigQuery traces interface StoredMessage { @@ -40,7 +40,6 @@ interface BigQueryTimestamp { value?: string | number } - const STATIC_SESSION_ID = 'relabel-trace-api' const DEFAULT_RELABEL_LIMIT = 10 const FULL_FILE_CONTEXT_SUFFIX = '-with-full-file-context' @@ -115,9 +114,10 @@ export async function POST(req: NextRequest) { const apiKey = getApiKeyFromRequest(req) if (!apiKey) { return NextResponse.json( - { + { error: 'API key required', - details: 'Provide your API key via Authorization header (Bearer token).', + details: + 'Provide your API key via Authorization header (Bearer token).', hint: 'Visit /usage in the web app to create an API key.', }, { status: 401 }, @@ -207,11 +207,13 @@ async function relabelTraceWithModel(params: { system: payload.system as System, }) - const output = await promptAiSdk({ - ...promptContext, - model, - messages, - }) + const output = unwrapPromptResult( + await promptAiSdk({ + ...promptContext, + model, + messages, + }), + ) const relabel: Relabel = { id: generateCompactId(), @@ -317,7 +319,7 @@ async function relabelUsingFullFilesForUser(params: { } const results = await Promise.allSettled(relabelPromises) - + // Log any failures from parallel relabeling for (const result of results) { if (result.status === 'rejected') { @@ -351,12 +353,14 @@ async function relabelWithRelace(params: { filesWithPath.map((file) => `- ${file.path}`).join('\n'), ].join('\n\n') - const ranked = await promptAiSdk({ - ...promptContext, - model: models.openrouter_claude_sonnet_4, - messages: [userMessage(prompt)], - includeCacheControl: false, - }) + const ranked = unwrapPromptResult( + await promptAiSdk({ + ...promptContext, + model: models.openrouter_claude_sonnet_4, + messages: [userMessage(prompt)], + includeCacheControl: false, + }), + ) const rankedFiles = ranked @@ -433,15 +437,17 @@ async function relabelWithClaudeWithFullFileContext(params: { system = systemCopy } - const output = await promptAiSdk({ - ...promptContext, - model, - messages: messagesWithSystem({ - messages: (tracePayload.messages || []) as Message[], - system, + const output = unwrapPromptResult( + await promptAiSdk({ + ...promptContext, + model, + messages: messagesWithSystem({ + messages: (tracePayload.messages || []) as Message[], + system, + }), + maxOutputTokens: 1000, }), - maxOutputTokens: 1000, - }) + ) const relabel: Relabel = { id: generateCompactId(), @@ -530,8 +536,7 @@ function buildPromptContext(apiKey: string) { sendAction: async () => {}, trackEvent: async () => {}, logger, - liveUserInputRecord: {}, - sessionConnections: {}, + signal: new AbortController().signal, } } diff --git a/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/_get.ts b/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/_get.ts index 3f488d947e..9a8438f94c 100644 --- a/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/_get.ts +++ b/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/_get.ts @@ -32,17 +32,14 @@ interface PendingLookup { /** * Creates a batching agent lookup function that automatically batches * concurrent requests into a single database query. - * + * * This solves the N+1 query problem: when the tree builder processes siblings * in parallel with Promise.all, all their lookupAgent calls will be queued * and executed in a single batch query. - * + * * Query reduction: ~2N queries -> ~maxDepth queries (typically ≤6 total) */ -function createBatchingAgentLookup( - publisherSet: Set, - logger: Logger, -) { +function createBatchingAgentLookup(publisherSet: Set, logger: Logger) { const cache = new Map() const pending: PendingLookup[] = [] let batchScheduled = false @@ -95,13 +92,16 @@ function createBatchingAgentLookup( // Create lookup map for quick access const agentMap = new Map() for (const agent of agents) { - agentMap.set(`${agent.publisher_id}:${agent.id}:${agent.version}`, agent) + agentMap.set( + `${agent.publisher_id}:${agent.id}:${agent.version}`, + agent, + ) } // Resolve all pending requests for (const req of batch) { const cacheKey = `${req.publisher}/${req.agentId}@${req.version}` - + // Resolve duplicates from cache if (cache.has(cacheKey)) { req.resolve(cache.get(cacheKey) ?? null) diff --git a/web/src/app/api/agents/metrics/route.ts b/web/src/app/api/agents/metrics/route.ts new file mode 100644 index 0000000000..33380ad97d --- /dev/null +++ b/web/src/app/api/agents/metrics/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server' + +import { getCachedAgentsMetrics } from '@/server/agents-data' +import { applyCacheHeaders } from '@/server/apply-cache-headers' +import { logger } from '@/util/logger' + +// ISR Configuration for API route - metrics can be cached +export const revalidate = 600 // Cache for 10 minutes +export const dynamic = 'force-static' + +export async function GET() { + try { + const metrics = await getCachedAgentsMetrics() + + const response = NextResponse.json(metrics) + return applyCacheHeaders(response) + } catch (error) { + logger.error({ error }, 'Error fetching agent metrics') + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 }, + ) + } +} diff --git a/web/src/app/api/agents/route.ts b/web/src/app/api/agents/route.ts index d94d61978e..f65410fdbc 100644 --- a/web/src/app/api/agents/route.ts +++ b/web/src/app/api/agents/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server' -import { logger } from '@/util/logger' +import { fetchAgentsWithMetrics } from '@/server/agents-data' import { applyCacheHeaders } from '@/server/apply-cache-headers' -import { getCachedAgents } from '@/server/agents-data' +import { logger } from '@/util/logger' // ISR Configuration for API route export const revalidate = 600 // Cache for 10 minutes @@ -10,7 +10,10 @@ export const dynamic = 'force-static' export async function GET() { try { - const result = await getCachedAgents() + // Note: We use fetchAgentsWithMetrics directly instead of getCachedAgents + // because the payload is >2MB and unstable_cache has a 2MB limit. + // ISR page-level caching (revalidate: 600) handles caching adequately. + const result = await fetchAgentsWithMetrics() const response = NextResponse.json(result) return applyCacheHeaders(response) diff --git a/web/src/app/api/agents/validate/route.ts b/web/src/app/api/agents/validate/route.ts index 13f57a7e41..230986d126 100644 --- a/web/src/app/api/agents/validate/route.ts +++ b/web/src/app/api/agents/validate/route.ts @@ -13,7 +13,8 @@ interface ValidateAgentsRequest { export async function POST(request: NextRequest): Promise { try { - const body = (await request.json()) as ValidateAgentsRequest + const requestBody = await request.json() + const body = requestBody as ValidateAgentsRequest const { agentConfigs } = body let { agentDefinitions } = body diff --git a/web/src/app/api/api-keys/route.ts b/web/src/app/api/api-keys/route.ts index 1a625bf04d..2fe1106864 100644 --- a/web/src/app/api/api-keys/route.ts +++ b/web/src/app/api/api-keys/route.ts @@ -75,7 +75,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) } - const { name, expiresInDays } = parsedJson.data + const { name: _name, expiresInDays } = parsedJson.data try { // Generate a new session token for the PAT with cb-pat- prefix baked in diff --git a/web/src/app/api/auth/[...nextauth]/auth-options.ts b/web/src/app/api/auth/[...nextauth]/auth-options.ts index a415e631e6..6da111f14d 100644 --- a/web/src/app/api/auth/[...nextauth]/auth-options.ts +++ b/web/src/app/api/auth/[...nextauth]/auth-options.ts @@ -1,10 +1,8 @@ import { DrizzleAdapter } from '@auth/drizzle-adapter' -import { processAndGrantCredit } from '@codebuff/billing' +import { grantSignupCredits } from '@codebuff/billing' import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { DEFAULT_FREE_CREDITS_GRANT } from '@codebuff/common/old-constants' -import { getNextQuotaReset } from '@codebuff/common/util/dates' -import { generateCompactId } from '@codebuff/common/util/string' +import { SESSION_MAX_AGE_SECONDS } from '@codebuff/common/old-constants' import { loops } from '@codebuff/internal' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -14,7 +12,6 @@ import { logSyncFailure } from '@codebuff/internal/util/sync-failure' import { eq } from 'drizzle-orm' import GitHubProvider from 'next-auth/providers/github' -import type { Logger } from '@codebuff/common/types/contracts/logger' import type { NextAuthOptions } from 'next-auth' import type { Adapter } from 'next-auth/adapters' @@ -43,23 +40,16 @@ async function createAndLinkStripeCustomer(params: { }, }) - // Create subscription with the usage price - await stripeServer.subscriptions.create({ - customer: customer.id, - items: [{ price: env.STRIPE_USAGE_PRICE_ID }], - }) - await db .update(schema.user) .set({ stripe_customer_id: customer.id, - stripe_price_id: env.STRIPE_USAGE_PRICE_ID, }) .where(eq(schema.user.id, userId)) logger.info( { userId, customerId: customer.id }, - 'Stripe customer created with usage subscription and linked to user.', + 'Stripe customer created and linked to user.', ) return customer.id } catch (error) { @@ -81,53 +71,6 @@ async function createAndLinkStripeCustomer(params: { } } -async function createInitialCreditGrant(params: { - userId: string - expiresAt: Date | null - logger: Logger -}): Promise { - const { userId, expiresAt, logger } = params - - try { - const operationId = `free-${userId}-${generateCompactId()}` - const nextQuotaReset = getNextQuotaReset(expiresAt) - - await processAndGrantCredit({ - ...params, - amount: DEFAULT_FREE_CREDITS_GRANT, - type: 'free', - description: 'Initial free credits', - expiresAt: nextQuotaReset, - operationId, - }) - - logger.info( - { - userId, - operationId, - creditsGranted: DEFAULT_FREE_CREDITS_GRANT, - expiresAt: nextQuotaReset, - }, - 'Initial free credit grant created.', - ) - } catch (grantError) { - const errorMessage = - grantError instanceof Error - ? grantError.message - : 'Unknown error creating initial credit grant' - logger.error( - { userId, error: grantError }, - 'Failed to create initial credit grant.', - ) - await logSyncFailure({ - id: userId, - errorMessage, - provider: 'stripe', - logger, - }) - } -} - export const authOptions: NextAuthOptions = { adapter: DrizzleAdapter(db, { usersTable: schema.user, @@ -143,7 +86,7 @@ export const authOptions: NextAuthOptions = { ], session: { strategy: 'database', - maxAge: 30 * 24 * 60 * 60, // 30 days + maxAge: SESSION_MAX_AGE_SECONDS, }, callbacks: { async session({ session, user }) { @@ -153,33 +96,19 @@ export const authOptions: NextAuthOptions = { session.user.name = user.name session.user.email = user.email session.user.stripe_customer_id = user.stripe_customer_id - session.user.stripe_price_id = user.stripe_price_id } return session }, async redirect({ url, baseUrl }) { - console.log('🟡 NextAuth redirect callback:', { url, baseUrl }) - const potentialRedirectUrl = new URL(url, baseUrl) const authCode = potentialRedirectUrl.searchParams.get('auth_code') - const referralCode = - potentialRedirectUrl.searchParams.get('referral_code') - - console.log('🟡 NextAuth redirect parsed params:', { - authCode: !!authCode, - referralCode, - allParams: Object.fromEntries( - potentialRedirectUrl.searchParams.entries(), - ), - }) if (authCode) { const onboardUrl = new URL(`${baseUrl}/onboard`) potentialRedirectUrl.searchParams.forEach((value, key) => { onboardUrl.searchParams.set(key, value) }) - console.log('🟡 NextAuth CLI flow redirect to:', onboardUrl.toString()) - logger.info( + logger.debug( { url, authCode, redirectTarget: onboardUrl.toString() }, 'Redirecting CLI flow to /onboard', ) @@ -187,22 +116,14 @@ export const authOptions: NextAuthOptions = { } if (url.startsWith('/') || potentialRedirectUrl.origin === baseUrl) { - console.log( - '🟡 NextAuth web flow redirect to:', - potentialRedirectUrl.toString(), - ) - logger.info( + logger.debug( { url, redirectTarget: potentialRedirectUrl.toString() }, 'Redirecting web flow to callbackUrl', ) return potentialRedirectUrl.toString() } - console.log( - '🟡 NextAuth external/invalid URL, redirect to baseUrl:', - baseUrl, - ) - logger.info( + logger.debug( { url, baseUrl, redirectTarget: baseUrl }, 'Callback URL is external or invalid, redirecting to baseUrl', ) @@ -232,24 +153,28 @@ export const authOptions: NextAuthOptions = { return } - const customerId = await createAndLinkStripeCustomer({ + await createAndLinkStripeCustomer({ ...userData, userId: userData.id, }) - if (customerId) { - await createInitialCreditGrant({ + try { + await grantSignupCredits({ userId: userData.id, - expiresAt: userData.next_quota_reset, logger, }) + } catch (error) { + logger.error( + { userId: userData.id, error }, + 'Failed to grant signup credits.', + ) } - // Call the imported function await loops.sendSignupEventToLoops({ ...userData, userId: userData.id, logger, + signupSource: 'codebuff', }) trackEvent({ diff --git a/web/src/app/api/auth/cli/code/__tests__/origin.test.ts b/web/src/app/api/auth/cli/code/__tests__/origin.test.ts new file mode 100644 index 0000000000..8ec4b5466c --- /dev/null +++ b/web/src/app/api/auth/cli/code/__tests__/origin.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' + +import { getLoginUrlOrigin } from '../_origin' + +describe('api/auth/cli/code/_origin', () => { + test('uses the configured public app URL over the request origin', () => { + const req = new Request('https://localhost:10000/api/auth/cli/code') + + expect( + getLoginUrlOrigin( + req, + 'https://www.codebuff.com', + 'https://codebuff.com', + false, + ), + ).toBe('https://www.codebuff.com') + }) + + test('ignores a localhost configured URL in production', () => { + const req = new Request('https://localhost:10000/api/auth/cli/code') + + expect( + getLoginUrlOrigin( + req, + 'https://localhost:10000', + 'https://codebuff.com', + false, + ), + ).toBe('https://codebuff.com') + }) + + test('ignores IPv6 localhost in production', () => { + const req = new Request('http://[::1]:3000/api/auth/cli/code') + + expect( + getLoginUrlOrigin( + req, + 'http://[::1]:3000', + 'https://codebuff.com', + false, + ), + ).toBe('https://codebuff.com') + }) + + test('allows a localhost configured URL outside production', () => { + const req = new Request('http://localhost:3000/api/auth/cli/code') + + expect( + getLoginUrlOrigin( + req, + 'http://localhost:3000', + 'https://codebuff.com', + true, + ), + ).toBe('http://localhost:3000') + }) + + test('falls back to the request origin when configured URL is invalid', () => { + const req = new Request('http://localhost:3000/api/auth/cli/code') + + expect( + getLoginUrlOrigin(req, 'not a url', 'https://codebuff.com', true), + ).toBe('http://localhost:3000') + }) +}) diff --git a/web/src/app/api/auth/cli/code/_origin.ts b/web/src/app/api/auth/cli/code/_origin.ts new file mode 100644 index 0000000000..f2c3c4dfa1 --- /dev/null +++ b/web/src/app/api/auth/cli/code/_origin.ts @@ -0,0 +1,35 @@ +export function getLoginUrlOrigin( + req: Request, + configuredAppUrl: string, + fallbackOrigin: string, + allowLocalhost: boolean, +): string { + const configuredOrigin = getUsableOrigin(configuredAppUrl, allowLocalhost) + if (configuredOrigin) { + return configuredOrigin + } + + return getUsableOrigin(req.url, allowLocalhost) ?? fallbackOrigin +} + +function getUsableOrigin(url: string, allowLocalhost: boolean) { + try { + const parsedUrl = new URL(url) + if (!allowLocalhost && isLocalhost(parsedUrl.hostname)) { + return null + } + return parsedUrl.origin + } catch { + return null + } +} + +function isLocalhost(hostname: string) { + const normalizedHostname = hostname.replace(/^\[|\]$/g, '') + return ( + normalizedHostname === 'localhost' || + normalizedHostname === '127.0.0.1' || + normalizedHostname === '0.0.0.0' || + normalizedHostname === '::1' + ) +} diff --git a/web/src/app/api/auth/cli/code/route.ts b/web/src/app/api/auth/cli/code/route.ts index 071dd2edde..a677e9f09d 100644 --- a/web/src/app/api/auth/cli/code/route.ts +++ b/web/src/app/api/auth/cli/code/route.ts @@ -1,3 +1,5 @@ +import { randomBytes } from 'node:crypto' + import { genAuthCode } from '@codebuff/common/util/credentials' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -6,19 +8,26 @@ import { and, eq, gt } from 'drizzle-orm' import { NextResponse } from 'next/server' import { z } from 'zod/v4' +import { + buildCliAuthCode, + getCliAuthCodeHashPrefix, + getCliAuthCodeTokenIdentifier, +} from '@/app/onboard/_helpers' import { logger } from '@/util/logger' +import { getLoginUrlOrigin } from './_origin' + export async function POST(req: Request) { const reqSchema = z.object({ fingerprintId: z.string(), - referralCode: z.string().optional(), }) - const result = reqSchema.safeParse(await req.json()) + const requestBody = await req.json() + const result = reqSchema.safeParse(requestBody) if (!result.success) { return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) } - const { fingerprintId, referralCode } = result.data + const { fingerprintId } = result.data try { const expiresAt = Date.now() + 60 * 60 * 1000 // 1 hour @@ -55,15 +64,53 @@ export async function POST(req: Request) { ) } - // Generate login URL without modifying the fingerprint record - const loginUrl = `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/login?auth_code=${fingerprintId}.${expiresAt}.${fingerprintHash}${ - referralCode ? `&referral_code=${referralCode}` : '' - }` + const authCode = buildCliAuthCode( + fingerprintId, + expiresAt.toString(), + fingerprintHash, + ) + const loginToken = randomBytes(32).toString('base64url') + + await db.insert(schema.verificationToken).values({ + identifier: getCliAuthCodeTokenIdentifier(loginToken), + token: authCode, + expires: new Date(expiresAt), + }) + + const loginUrl = new URL( + '/login', + getLoginUrlOrigin( + req, + env.NEXT_PUBLIC_CODEBUFF_APP_URL, + 'https://codebuff.com', + env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod', + ), + ) + loginUrl.searchParams.set('auth_code', loginToken) + + logger.info( + { + authCodeTokenHashPrefix: getCliAuthCodeHashPrefix(loginToken), + authCodeTokenLength: loginToken.length, + fingerprintIdPrefix: fingerprintId.slice(0, 24), + fingerprintIdLength: fingerprintId.length, + expiresAt, + loginUrlOrigin: loginUrl.origin, + requestOrigin: new URL(req.url).origin, + requestHost: req.headers.get('host'), + forwardedHost: req.headers.get('x-forwarded-host'), + forwardedProto: req.headers.get('x-forwarded-proto'), + originHeader: req.headers.get('origin'), + configuredAppUrl: env.NEXT_PUBLIC_CODEBUFF_APP_URL, + environment: env.NEXT_PUBLIC_CB_ENVIRONMENT, + }, + 'Issued Codebuff CLI auth code token', + ) return NextResponse.json({ fingerprintId, fingerprintHash, - loginUrl, + loginUrl: loginUrl.toString(), expiresAt, }) } catch (error) { diff --git a/web/src/app/api/auth/cli/logout/__tests__/helpers.test.ts b/web/src/app/api/auth/cli/logout/__tests__/helpers.test.ts new file mode 100644 index 0000000000..26359b2d07 --- /dev/null +++ b/web/src/app/api/auth/cli/logout/__tests__/helpers.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test' + +import { shouldUnclaim } from '../_helpers' + +describe('logout/_helpers', () => { + describe('shouldUnclaim', () => { + describe('when fingerprintMatchFound is true', () => { + test('returns true regardless of hash values', () => { + expect(shouldUnclaim(true, 'stored-hash', 'provided-hash')).toBe(true) + expect(shouldUnclaim(true, null, 'provided-hash')).toBe(true) + expect(shouldUnclaim(true, undefined, 'provided-hash')).toBe(true) + expect(shouldUnclaim(true, 'any-hash', 'different-hash')).toBe(true) + }) + }) + + describe('when fingerprintMatchFound is false', () => { + test('returns true when stored hash matches provided hash', () => { + expect(shouldUnclaim(false, 'matching-hash', 'matching-hash')).toBe( + true, + ) + }) + + test('returns false when stored hash does not match provided hash', () => { + expect(shouldUnclaim(false, 'stored-hash', 'different-hash')).toBe( + false, + ) + }) + + test('returns false when stored hash is null', () => { + expect(shouldUnclaim(false, null, 'provided-hash')).toBe(false) + }) + + test('returns false when stored hash is undefined', () => { + expect(shouldUnclaim(false, undefined, 'provided-hash')).toBe(false) + }) + + test('returns false when stored hash is empty string but provided is not', () => { + expect(shouldUnclaim(false, '', 'provided-hash')).toBe(false) + }) + + test('returns true when both hashes are empty strings', () => { + expect(shouldUnclaim(false, '', '')).toBe(true) + }) + }) + }) +}) diff --git a/web/src/app/api/auth/cli/logout/__tests__/logout.test.ts b/web/src/app/api/auth/cli/logout/__tests__/logout.test.ts new file mode 100644 index 0000000000..1e7954b48f --- /dev/null +++ b/web/src/app/api/auth/cli/logout/__tests__/logout.test.ts @@ -0,0 +1,545 @@ +/** + * @jest-environment node + */ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { NextRequest } from 'next/server' + +import { postLogout } from '../_post' + +import type { LogoutDb } from '../_post' +import type { Logger } from '@codebuff/common/types/contracts/logger' + +describe('/api/auth/cli/logout POST endpoint', () => { + let mockLogger: Logger + let mockDb: LogoutDb + + const testUserId = 'user-123' + const testFingerprintId = 'fp-abc123' + const testFingerprintHash = 'hash-xyz789' + const testAuthToken = 'auth-token-456' + const testFingerprintCreatedAt = new Date('2024-01-01T12:00:00Z') + + function createRequest( + body: object, + headers: Record = {}, + ): NextRequest { + return new NextRequest('http://localhost:3000/api/auth/cli/logout', { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + } + + function createValidBody(overrides: object = {}) { + return { + userId: testUserId, + fingerprintId: testFingerprintId, + fingerprintHash: testFingerprintHash, + ...overrides, + } + } + + function createBaseMockDb(): LogoutDb { + return { + getSessionByToken: mock(async () => [{ userId: testUserId }]), + deleteSessionsByFingerprint: mock(async () => []), + getFingerprintData: mock(async () => []), + deleteOrphanedWebSessions: mock(async () => []), + deleteWebSessionsInTimeWindow: mock(async () => []), + deleteAllWebSessions: mock(async () => []), + unclaimFingerprint: mock(async () => {}), + } + } + + // Setup mocks for fallback tests (fingerprint match returns nothing, fingerprint exists) + function setupFallbackMocks( + db: LogoutDb, + sigHash: string | null = testFingerprintHash, + ) { + db.deleteSessionsByFingerprint = mock(async () => []) + db.getFingerprintData = mock(async () => [ + { created_at: testFingerprintCreatedAt, sig_hash: sigHash }, + ]) + } + + beforeEach(() => { + mockLogger = { + error: mock(() => {}), + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), + } + mockDb = createBaseMockDb() + }) + + afterEach(() => { + mock.restore() + }) + + describe('Request validation', () => { + test('returns 400 when body is not valid JSON', async () => { + const req = new NextRequest('http://localhost:3000/api/auth/cli/logout', { + method: 'POST', + headers: { Authorization: 'Bearer test-token' }, + body: 'not json', + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'Invalid request body' }) + }) + + test('returns 400 when userId is missing', async () => { + const req = createRequest( + { fingerprintId: 'fp', fingerprintHash: 'hash' }, + { Authorization: 'Bearer test-token' }, + ) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'Invalid request body' }) + }) + + test('returns 400 when fingerprintId is missing', async () => { + const req = createRequest( + { userId: 'user', fingerprintHash: 'hash' }, + { Authorization: 'Bearer test-token' }, + ) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'Invalid request body' }) + }) + + test('returns 400 when fingerprintHash is missing', async () => { + const req = createRequest( + { userId: 'user', fingerprintId: 'fp' }, + { Authorization: 'Bearer test-token' }, + ) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'Invalid request body' }) + }) + }) + + describe('Authentication', () => { + test('returns 401 when no auth token is provided', async () => { + const req = createRequest(createValidBody()) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('accepts auth token from Authorization header', async () => { + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + expect(mockDb.getSessionByToken).toHaveBeenCalledWith( + testAuthToken, + testUserId, + ) + }) + + test('accepts auth token from x-codebuff-api-key header', async () => { + const req = createRequest(createValidBody(), { + 'x-codebuff-api-key': testAuthToken, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + expect(mockDb.getSessionByToken).toHaveBeenCalledWith( + testAuthToken, + testUserId, + ) + }) + + test('accepts auth token from body (backwards compatibility)', async () => { + const req = createRequest(createValidBody({ authToken: testAuthToken })) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + expect(mockDb.getSessionByToken).toHaveBeenCalledWith( + testAuthToken, + testUserId, + ) + }) + + test('prefers Authorization header over body authToken', async () => { + const headerToken = 'header-token' + const bodyToken = 'body-token' + + const req = createRequest(createValidBody({ authToken: bodyToken }), { + Authorization: `Bearer ${headerToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + expect(mockDb.getSessionByToken).toHaveBeenCalledWith( + headerToken, + testUserId, + ) + }) + + test('returns success when token is invalid/expired (no-op)', async () => { + mockDb.getSessionByToken = mock(async () => []) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ success: true }) + // Should not proceed to session deletion + expect(mockDb.deleteSessionsByFingerprint).not.toHaveBeenCalled() + }) + }) + + describe('Fingerprint-based deletion (primary)', () => { + test('deletes sessions by fingerprint match', async () => { + mockDb.deleteSessionsByFingerprint = mock(async () => [ + { id: 'session-1' }, + { id: 'session-2' }, + ]) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + expect(mockDb.deleteSessionsByFingerprint).toHaveBeenCalledWith( + testUserId, + testFingerprintId, + ) + // Should not proceed to fallback, but should fetch fingerprint data for orphan cleanup + expect(mockDb.getFingerprintData).toHaveBeenCalled() + expect(mockDb.deleteAllWebSessions).not.toHaveBeenCalled() + }) + + test('unclaims fingerprint when fingerprint match succeeds', async () => { + mockDb.deleteSessionsByFingerprint = mock(async () => [ + { id: 'session-1' }, + ]) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + expect(mockDb.unclaimFingerprint).toHaveBeenCalledWith(testFingerprintId) + }) + + test('cleans up orphaned web sessions after fingerprint match succeeds', async () => { + mockDb.deleteSessionsByFingerprint = mock(async () => [ + { id: 'cli-session' }, + ]) + mockDb.deleteOrphanedWebSessions = mock(async () => [ + { id: 'orphan-web-session' }, + ]) + mockDb.getFingerprintData = mock(async () => [ + { created_at: testFingerprintCreatedAt, sig_hash: testFingerprintHash }, + ]) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + // Should delete CLI session via fingerprint match + expect(mockDb.deleteSessionsByFingerprint).toHaveBeenCalledWith( + testUserId, + testFingerprintId, + ) + // Should also clean up orphaned web sessions (no timestamp filtering) + expect(mockDb.deleteOrphanedWebSessions).toHaveBeenCalledWith(testUserId) + // Should NOT use fallback deletion + expect(mockDb.deleteAllWebSessions).not.toHaveBeenCalled() + }) + }) + + describe('Time-window deletion (intermediate strategy)', () => { + beforeEach(() => { + setupFallbackMocks(mockDb) + }) + + test('tries time-window deletion when fingerprint match fails but fingerprint data exists', async () => { + mockDb.deleteWebSessionsInTimeWindow = mock(async () => [ + { id: 'web-in-window' }, + ]) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + expect(mockDb.deleteWebSessionsInTimeWindow).toHaveBeenCalledWith( + testUserId, + testFingerprintCreatedAt, + ) + // Should NOT proceed to nuclear fallback when time-window deletion succeeds + expect(mockDb.deleteAllWebSessions).not.toHaveBeenCalled() + }) + + test('falls back to deleteAllWebSessions when time-window deletion finds nothing', async () => { + mockDb.deleteWebSessionsInTimeWindow = mock(async () => []) + mockDb.deleteAllWebSessions = mock(async () => [{ id: 'web-1' }]) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + expect(mockDb.deleteWebSessionsInTimeWindow).toHaveBeenCalled() + expect(mockDb.deleteAllWebSessions).toHaveBeenCalledWith(testUserId) + }) + }) + + describe('Final fallback deletion: All web sessions', () => { + test('proceeds directly to nuclear fallback when no fingerprint data exists', async () => { + mockDb.deleteSessionsByFingerprint = mock(async () => []) + mockDb.getFingerprintData = mock(async () => []) + mockDb.deleteAllWebSessions = mock(async () => [{ id: 'web-1' }]) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + expect(mockDb.deleteWebSessionsInTimeWindow).not.toHaveBeenCalled() + expect(mockDb.deleteAllWebSessions).toHaveBeenCalledWith(testUserId) + }) + + test('proceeds directly to nuclear fallback when fingerprint has no created_at', async () => { + mockDb.deleteSessionsByFingerprint = mock(async () => []) + mockDb.getFingerprintData = mock(async () => [ + { created_at: null as unknown as Date, sig_hash: testFingerprintHash }, + ]) + mockDb.deleteAllWebSessions = mock(async () => [{ id: 'web-1' }]) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + expect(mockDb.deleteWebSessionsInTimeWindow).not.toHaveBeenCalled() + expect(mockDb.deleteAllWebSessions).toHaveBeenCalledWith(testUserId) + }) + }) + + describe('Fingerprint unclaim security', () => { + test('unclaims when fingerprint match succeeds (ownership via session)', async () => { + mockDb.deleteSessionsByFingerprint = mock(async () => [ + { id: 'session-1' }, + ]) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(mockDb.unclaimFingerprint).toHaveBeenCalledWith(testFingerprintId) + }) + + test('unclaims when hash matches (fallback path)', async () => { + setupFallbackMocks(mockDb) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(mockDb.unclaimFingerprint).toHaveBeenCalledWith(testFingerprintId) + }) + + test('does NOT unclaim when hash mismatches', async () => { + setupFallbackMocks(mockDb, 'different-hash') + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(mockDb.unclaimFingerprint).not.toHaveBeenCalled() + }) + + test('does NOT unclaim when fingerprint not found', async () => { + mockDb.deleteSessionsByFingerprint = mock(async () => []) + mockDb.getFingerprintData = mock(async () => []) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(mockDb.unclaimFingerprint).not.toHaveBeenCalled() + }) + + test('does NOT unclaim when sig_hash is null', async () => { + setupFallbackMocks(mockDb, null) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(mockDb.unclaimFingerprint).not.toHaveBeenCalled() + }) + + test('prevents malicious unclaim with wrong hash', async () => { + // Attacker passes victim's fingerprintId with wrong hash + setupFallbackMocks(mockDb, 'victim-secret-hash') + + const req = createRequest( + createValidBody({ fingerprintHash: 'attacker-guessed-hash' }), + { Authorization: `Bearer ${testAuthToken}` }, + ) + + await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(mockDb.unclaimFingerprint).not.toHaveBeenCalled() + }) + }) + + describe('Error handling', () => { + test('returns 500 when database operation fails', async () => { + mockDb.getSessionByToken = mock(async () => { + throw new Error('Database connection failed') + }) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body).toEqual({ error: 'Internal server error' }) + expect(mockLogger.error).toHaveBeenCalled() + }) + + test('returns 500 when fingerprint deletion fails', async () => { + mockDb.deleteSessionsByFingerprint = mock(async () => { + throw new Error('Delete failed') + }) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(500) + expect(mockLogger.error).toHaveBeenCalled() + }) + }) + + describe('Full flow integration', () => { + test('fingerprint match success: deletes sessions, runs orphan cleanup, unclaims, skips fallbacks', async () => { + mockDb.deleteSessionsByFingerprint = mock(async () => [ + { id: 'cli-session' }, + ]) + mockDb.deleteOrphanedWebSessions = mock(async () => [ + { id: 'orphan-web-session' }, + ]) + mockDb.getFingerprintData = mock(async () => [ + { created_at: testFingerprintCreatedAt, sig_hash: testFingerprintHash }, + ]) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + const responseBody = await response.json() + expect(responseBody).toEqual({ success: true }) + expect(mockDb.deleteSessionsByFingerprint).toHaveBeenCalled() + expect(mockDb.unclaimFingerprint).toHaveBeenCalled() + // Should run orphan cleanup after fingerprint match success + expect(mockDb.deleteOrphanedWebSessions).toHaveBeenCalledWith(testUserId) + // Should NOT use intermediate or nuclear fallback + expect(mockDb.deleteWebSessionsInTimeWindow).not.toHaveBeenCalled() + expect(mockDb.deleteAllWebSessions).not.toHaveBeenCalled() + }) + + test('time-window deletion success: deletes sessions, unclaims, skips nuclear fallback', async () => { + setupFallbackMocks(mockDb) + mockDb.deleteWebSessionsInTimeWindow = mock(async () => [ + { id: 'web-in-window' }, + ]) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + const responseBody = await response.json() + expect(responseBody).toEqual({ success: true }) + expect(mockDb.deleteWebSessionsInTimeWindow).toHaveBeenCalled() + expect(mockDb.unclaimFingerprint).toHaveBeenCalled() + expect(mockDb.deleteAllWebSessions).not.toHaveBeenCalled() + }) + + test('nuclear fallback with hash mismatch: deletes all sessions, does NOT unclaim', async () => { + setupFallbackMocks(mockDb, 'different-hash') + mockDb.deleteWebSessionsInTimeWindow = mock(async () => []) + mockDb.deleteAllWebSessions = mock(async () => [{ id: 'web-1' }]) + + const req = createRequest(createValidBody(), { + Authorization: `Bearer ${testAuthToken}`, + }) + + const response = await postLogout({ req, db: mockDb, logger: mockLogger }) + + expect(response.status).toBe(200) + const responseBody = await response.json() + expect(responseBody).toEqual({ success: true }) + expect(mockDb.deleteAllWebSessions).toHaveBeenCalled() + expect(mockDb.unclaimFingerprint).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/src/app/api/auth/cli/logout/_db.ts b/web/src/app/api/auth/cli/logout/_db.ts new file mode 100644 index 0000000000..d5ac3bd813 --- /dev/null +++ b/web/src/app/api/auth/cli/logout/_db.ts @@ -0,0 +1,111 @@ +import { SESSION_TIME_WINDOW_MS } from '@codebuff/common/old-constants' +import * as schema from '@codebuff/internal/db/schema' +import { and, eq, gte, isNull, lte } from 'drizzle-orm' + +import type { CodebuffPgDatabase } from '@codebuff/internal/db/types' + +export type FingerprintData = { created_at: Date; sig_hash: string | null } + +export interface LogoutDb { + getSessionByToken( + token: string, + userId: string, + ): Promise<{ userId: string }[]> + deleteSessionsByFingerprint( + userId: string, + fingerprintId: string, + ): Promise<{ id: string }[]> + getFingerprintData(fingerprintId: string): Promise + deleteOrphanedWebSessions(userId: string): Promise<{ id: string }[]> + deleteWebSessionsInTimeWindow( + userId: string, + aroundTime: Date, + ): Promise<{ id: string }[]> + deleteAllWebSessions(userId: string): Promise<{ id: string }[]> + unclaimFingerprint(fingerprintId: string): Promise +} + +export function createLogoutDb(db: CodebuffPgDatabase): LogoutDb { + return { + getSessionByToken: (token, userId) => + db + .select({ userId: schema.session.userId }) + .from(schema.session) + .where( + and( + eq(schema.session.sessionToken, token), + eq(schema.session.userId, userId), + ), + ) + .limit(1), + + deleteSessionsByFingerprint: (userId, fingerprintId) => + db + .delete(schema.session) + .where( + and( + eq(schema.session.userId, userId), + eq(schema.session.fingerprint_id, fingerprintId), + ), + ) + .returning({ id: schema.session.sessionToken }), + + getFingerprintData: (fingerprintId) => + db + .select({ + created_at: schema.fingerprint.created_at, + sig_hash: schema.fingerprint.sig_hash, + }) + .from(schema.fingerprint) + .where(eq(schema.fingerprint.id, fingerprintId)) + .limit(1), + + deleteOrphanedWebSessions: (userId) => + db + .delete(schema.session) + .where( + and( + eq(schema.session.userId, userId), + eq(schema.session.type, 'web'), + isNull(schema.session.fingerprint_id), + ), + ) + .returning({ id: schema.session.sessionToken }), + + deleteWebSessionsInTimeWindow: (userId, aroundTime) => { + const windowStart = new Date( + aroundTime.getTime() - SESSION_TIME_WINDOW_MS, + ) + const windowEnd = new Date(aroundTime.getTime() + SESSION_TIME_WINDOW_MS) + return db + .delete(schema.session) + .where( + and( + eq(schema.session.userId, userId), + eq(schema.session.type, 'web'), + gte(schema.session.created_at, windowStart), + lte(schema.session.created_at, windowEnd), + ), + ) + .returning({ id: schema.session.sessionToken }) + }, + + deleteAllWebSessions: (userId) => + db + .delete(schema.session) + .where( + and( + eq(schema.session.userId, userId), + eq(schema.session.type, 'web'), + ), + ) + .returning({ id: schema.session.sessionToken }), + + unclaimFingerprint: async (fingerprintId) => { + await db + .update(schema.fingerprint) + .set({ sig_hash: null }) + .where(eq(schema.fingerprint.id, fingerprintId)) + }, + } +} diff --git a/web/src/app/api/auth/cli/logout/_helpers.ts b/web/src/app/api/auth/cli/logout/_helpers.ts new file mode 100644 index 0000000000..0241858d5e --- /dev/null +++ b/web/src/app/api/auth/cli/logout/_helpers.ts @@ -0,0 +1,9 @@ +export function shouldUnclaim( + fingerprintMatchFound: boolean, + storedHash: string | null | undefined, + providedHash: string, +): boolean { + return ( + fingerprintMatchFound || (storedHash != null && storedHash === providedHash) + ) +} diff --git a/web/src/app/api/auth/cli/logout/_post.ts b/web/src/app/api/auth/cli/logout/_post.ts new file mode 100644 index 0000000000..91fd998f9a --- /dev/null +++ b/web/src/app/api/auth/cli/logout/_post.ts @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod/v4' + + +import { shouldUnclaim } from './_helpers' + +import type { LogoutDb } from './_db' +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { NextRequest } from 'next/server' + +import { extractApiKeyFromHeader } from '@/util/auth' + +// Re-export for tests +export type { LogoutDb } from './_db' +export { createLogoutDb } from './_db' + +export interface PostLogoutDeps { + req: NextRequest + db: LogoutDb + logger: Logger +} + +const reqSchema = z.object({ + authToken: z.string().optional(), // Deprecated: use Authorization header + userId: z.string(), + fingerprintId: z.string(), + fingerprintHash: z.string(), +}) + +export async function postLogout({ + req, + db, + logger, +}: PostLogoutDeps): Promise { + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + const parsed = reqSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + const { + authToken: bodyToken, + userId, + fingerprintId, + fingerprintHash, + } = parsed.data + const authToken = extractApiKeyFromHeader(req) ?? bodyToken + + if (!authToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const tokenSessions = await db.getSessionByToken(authToken, userId) + const tokenValid = tokenSessions.length > 0 + if (!tokenValid) { + return NextResponse.json({ success: true }) + } + + const fingerprintSessionsDeleted = await db.deleteSessionsByFingerprint( + userId, + fingerprintId, + ) + const fingerprintMatchFound = fingerprintSessionsDeleted.length > 0 + + // Always fetch fingerprint data for subsequent logic + const fingerprintRows = await db.getFingerprintData(fingerprintId) + const fingerprintData = fingerprintRows[0] + + if (fingerprintMatchFound) { + // Also clean up orphaned web sessions (fingerprint_id = null) for this user + await db.deleteOrphanedWebSessions(userId) + } else if (fingerprintData?.created_at) { + // Intermediate strategy: delete web sessions created around the same time as the fingerprint + const timeWindowDeleted = await db.deleteWebSessionsInTimeWindow( + userId, + fingerprintData.created_at, + ) + if (timeWindowDeleted.length === 0) { + // Final fallback: delete all web sessions when time-window deletion finds nothing + await db.deleteAllWebSessions(userId) + } + } else { + // No fingerprint data available, fall back to deleting all web sessions + await db.deleteAllWebSessions(userId) + } + + const storedHash = fingerprintData?.sig_hash + const canUnclaim = shouldUnclaim( + fingerprintMatchFound, + storedHash, + fingerprintHash, + ) + + if (canUnclaim) { + await db.unclaimFingerprint(fingerprintId) + } + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error({ error, userId, fingerprintId }, 'Error during CLI logout') + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 }, + ) + } +} diff --git a/web/src/app/api/auth/cli/logout/route.ts b/web/src/app/api/auth/cli/logout/route.ts index f90a78462e..d7a48939d9 100644 --- a/web/src/app/api/auth/cli/logout/route.ts +++ b/web/src/app/api/auth/cli/logout/route.ts @@ -1,76 +1,16 @@ import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' -import { and, eq } from 'drizzle-orm' -import { NextResponse } from 'next/server' -import { z } from 'zod/v4' -import { extractApiKeyFromHeader } from '@/util/auth' -import { logger } from '@/util/logger' + +import { createLogoutDb, postLogout } from './_post' import type { NextRequest } from 'next/server' +import { logger } from '@/util/logger' + export async function POST(req: NextRequest) { - const reqSchema = z.object({ - // DEPRECATED: authToken in body is for backwards compatibility with older CLI versions. - // New clients should use the Authorization header instead. - authToken: z.string().optional(), - userId: z.string(), - fingerprintId: z.string(), - fingerprintHash: z.string(), + return postLogout({ + req, + db: createLogoutDb(db), + logger, }) - const result = reqSchema.safeParse(await req.json()) - if (!result.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } - - const { authToken: bodyAuthToken, userId, fingerprintId } = result.data - - // Prefer Authorization header, fall back to body authToken for backwards compatibility - const authToken = extractApiKeyFromHeader(req) ?? bodyAuthToken - - if (!authToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - // First delete the session - const validDeletion = await db - .delete(schema.session) - .where( - and( - eq(schema.session.sessionToken, authToken), - eq(schema.session.userId, userId), - eq(schema.session.fingerprint_id, fingerprintId), - ), - ) - .returning({ - id: schema.session.sessionToken, - }) - - // If no session was deleted, it means the token was already invalid or the user was already logged out. - // This is effectively a no-op, so we treat it as a successful logout rather than an error. - if (validDeletion.length === 0) { - logger.info( - { fingerprintId }, - 'Logout attempted with invalid/expired token - treating as successful no-op', - ) - return NextResponse.json({ success: true }) - } - - // Then reset sig_hash to null - await db - .update(schema.fingerprint) - .set({ sig_hash: null }) - .where(eq(schema.fingerprint.id, fingerprintId)) - - logger.info({ fingerprintId }, 'Fingerprint marked as unclaimed') - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error({ error }, 'Error during logout') - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 }, - ) - } } diff --git a/web/src/app/api/auth/cli/status/__tests__/status.test.ts b/web/src/app/api/auth/cli/status/__tests__/status.test.ts new file mode 100644 index 0000000000..a327d47b80 --- /dev/null +++ b/web/src/app/api/auth/cli/status/__tests__/status.test.ts @@ -0,0 +1,137 @@ +import { genAuthCode } from '@codebuff/common/util/credentials' +import { createMockLogger } from '@codebuff/common/testing/mock-types' +import { describe, expect, mock, test } from 'bun:test' + +import { getLoginStatus } from '../_get' + +import type { LoginStatusDb } from '../_get' + +const secret = 'test-secret' +const fingerprintId = 'enhanced-fingerprint' +const expiresAt = '2000000' + +function createRequest(hash: string): Request { + const params = new URLSearchParams({ + fingerprintId, + fingerprintHash: hash, + expiresAt, + }) + return new Request(`http://localhost/api/auth/cli/status?${params}`) +} + +describe('/api/auth/cli/status', () => { + test('returns the CLI session bound to the current login hash even when an older hash exists', async () => { + const currentHash = genAuthCode(fingerprintId, expiresAt, secret) + const oldHash = genAuthCode(fingerprintId, '1000000', secret) + const getCliSessionForAuth = mock( + async (requestedFingerprintId: string, requestedHash: string) => { + const sessions = [ + { + fingerprintId, + cliAuthHash: oldHash, + type: 'cli', + user: { + id: 'old-user', + email: 'old@example.com', + name: 'Old User', + authToken: 'old-token', + }, + }, + { + fingerprintId, + cliAuthHash: currentHash, + type: 'cli', + user: { + id: 'new-user', + email: 'new@example.com', + name: 'New User', + authToken: 'new-token', + }, + }, + ] + + return ( + sessions.find( + (session) => + session.fingerprintId === requestedFingerprintId && + session.cliAuthHash === requestedHash && + session.type === 'cli', + )?.user ?? null + ) + }, + ) + + const response = await getLoginStatus({ + req: createRequest(currentHash), + db: { getCliSessionForAuth } satisfies LoginStatusDb, + logger: createMockLogger(), + secret, + now: () => 1000000, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.user.authToken).toBe('new-token') + expect(getCliSessionForAuth).toHaveBeenCalledWith( + fingerprintId, + currentHash, + ) + }) + + test('rejects a wrong login hash', async () => { + const getCliSessionForAuth = mock(async () => ({ + id: 'user', + email: 'user@example.com', + name: 'User', + authToken: 'token', + })) + + const response = await getLoginStatus({ + req: createRequest('wrong-hash'), + db: { getCliSessionForAuth } satisfies LoginStatusDb, + logger: createMockLogger(), + secret, + now: () => 1000000, + }) + + expect(response.status).toBe(401) + expect(getCliSessionForAuth).not.toHaveBeenCalled() + }) + + test('does not authenticate a linked web session', async () => { + const currentHash = genAuthCode(fingerprintId, expiresAt, secret) + const getCliSessionForAuth = mock(async () => null) + + const response = await getLoginStatus({ + req: createRequest(currentHash), + db: { getCliSessionForAuth } satisfies LoginStatusDb, + logger: createMockLogger(), + secret, + now: () => 1000000, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Authentication failed' }) + }) + + test('returns 400 for malformed expiresAt', async () => { + const params = new URLSearchParams({ + fingerprintId, + fingerprintHash: 'hash', + expiresAt: 'not-a-number', + }) + const getCliSessionForAuth = mock(async () => null) + + const response = await getLoginStatus({ + req: new Request(`http://localhost/api/auth/cli/status?${params}`), + db: { getCliSessionForAuth } satisfies LoginStatusDb, + logger: createMockLogger(), + secret, + now: () => 1000000, + }) + + expect(response.status).toBe(400) + expect(getCliSessionForAuth).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/app/api/auth/cli/status/_db.ts b/web/src/app/api/auth/cli/status/_db.ts new file mode 100644 index 0000000000..49cbb04b5c --- /dev/null +++ b/web/src/app/api/auth/cli/status/_db.ts @@ -0,0 +1,44 @@ +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { and, eq, gt } from 'drizzle-orm' + +export interface LoginStatusUser { + id: string + email: string | null + name: string | null + authToken: string +} + +export interface LoginStatusDb { + getCliSessionForAuth( + fingerprintId: string, + fingerprintHash: string, + ): Promise +} + +export function createLoginStatusDb(): LoginStatusDb { + return { + getCliSessionForAuth: async (fingerprintId, fingerprintHash) => { + const users = await db + .select({ + id: schema.user.id, + email: schema.user.email, + name: schema.user.name, + authToken: schema.session.sessionToken, + }) + .from(schema.session) + .innerJoin(schema.user, eq(schema.session.userId, schema.user.id)) + .where( + and( + eq(schema.session.fingerprint_id, fingerprintId), + eq(schema.session.cli_auth_hash, fingerprintHash), + eq(schema.session.type, 'cli'), + gt(schema.session.expires, new Date()), + ), + ) + .limit(1) + + return users[0] ?? null + }, + } +} diff --git a/web/src/app/api/auth/cli/status/_get.ts b/web/src/app/api/auth/cli/status/_get.ts new file mode 100644 index 0000000000..9816e2780d --- /dev/null +++ b/web/src/app/api/auth/cli/status/_get.ts @@ -0,0 +1,101 @@ +import { genAuthCode } from '@codebuff/common/util/credentials' +import { NextResponse } from 'next/server' +import { z } from 'zod/v4' + +import type { LoginStatusDb } from './_db' +import type { Logger } from '@codebuff/common/types/contracts/logger' + +export type { LoginStatusDb } from './_db' + +interface GetLoginStatusDeps { + req: Request + db: LoginStatusDb + logger: Logger + secret: string + now?: () => number +} + +const reqSchema = z.object({ + fingerprintId: z.string(), + fingerprintHash: z.string(), + expiresAt: z.coerce.number().finite().int().positive(), +}) + +export async function getLoginStatus({ + req, + db, + logger, + secret, + now = Date.now, +}: GetLoginStatusDeps): Promise { + const { searchParams } = new URL(req.url) + const result = reqSchema.safeParse({ + fingerprintId: searchParams.get('fingerprintId'), + fingerprintHash: searchParams.get('fingerprintHash'), + expiresAt: searchParams.get('expiresAt'), + }) + if (!result.success) { + return NextResponse.json( + { error: 'Invalid query parameters' }, + { status: 400 }, + ) + } + + const { fingerprintId, fingerprintHash, expiresAt } = result.data + + if (now() > expiresAt) { + logger.info( + { fingerprintId, fingerprintHash, expiresAt }, + 'Auth code expired', + ) + return NextResponse.json( + { error: 'Authentication failed' }, + { status: 401 }, + ) + } + + const expectedHash = genAuthCode(fingerprintId, expiresAt.toString(), secret) + if (fingerprintHash !== expectedHash) { + logger.info( + { fingerprintId, fingerprintHash, expectedHash }, + 'Invalid auth code', + ) + return NextResponse.json( + { error: 'Authentication failed' }, + { status: 401 }, + ) + } + + try { + const user = await db.getCliSessionForAuth(fingerprintId, fingerprintHash) + + if (!user) { + logger.info( + { fingerprintId, fingerprintHash }, + 'No active CLI session found for login auth code', + ) + return NextResponse.json( + { error: 'Authentication failed' }, + { status: 401 }, + ) + } + + return NextResponse.json({ + user: { + id: user.id, + name: user.name, + email: user.email, + authToken: user.authToken, + fingerprintId, + fingerprintHash, + }, + message: 'Authentication successful!', + }) + } catch (error) { + logger.error({ error }, 'Error checking login status') + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 }, + ) + } +} diff --git a/web/src/app/api/auth/cli/status/route.ts b/web/src/app/api/auth/cli/status/route.ts index 2053232e4f..bba1274b7c 100644 --- a/web/src/app/api/auth/cli/status/route.ts +++ b/web/src/app/api/auth/cli/status/route.ts @@ -1,123 +1,14 @@ -import { genAuthCode } from '@codebuff/common/util/credentials' -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' import { env } from '@codebuff/internal/env' -import { and, eq, gt, or, isNull } from 'drizzle-orm' -import { NextResponse } from 'next/server' -import { z } from 'zod/v4' +import { createLoginStatusDb } from './_db' +import { getLoginStatus } from './_get' import { logger } from '@/util/logger' export async function GET(req: Request) { - const { searchParams } = new URL(req.url) - const reqSchema = z.object({ - fingerprintId: z.string(), - fingerprintHash: z.string(), - expiresAt: z.string().transform(Number), + return getLoginStatus({ + req, + db: createLoginStatusDb(), + logger, + secret: env.NEXTAUTH_SECRET, }) - const result = reqSchema.safeParse({ - fingerprintId: searchParams.get('fingerprintId'), - fingerprintHash: searchParams.get('fingerprintHash'), - expiresAt: searchParams.get('expiresAt'), - }) - if (!result.success) { - return NextResponse.json( - { error: 'Invalid query parameters' }, - { status: 400 }, - ) - } - - const { fingerprintId, fingerprintHash, expiresAt } = result.data - - // Check if code has expired - if (Date.now() > expiresAt) { - logger.info( - { fingerprintId, fingerprintHash, expiresAt }, - 'Auth code expired', - ) - return NextResponse.json( - { error: 'Authentication failed' }, - { status: 401 }, - ) - } - - // Validate the auth code - const expectedHash = genAuthCode( - fingerprintId, - expiresAt.toString(), - env.NEXTAUTH_SECRET, - ) - if (fingerprintHash !== expectedHash) { - logger.info( - { fingerprintId, fingerprintHash, expectedHash }, - 'Invalid auth code', - ) - return NextResponse.json( - { error: 'Authentication failed' }, - { status: 401 }, - ) - } - - try { - const users = await db - .select({ - id: schema.user.id, - email: schema.user.email, - name: schema.user.name, - authToken: schema.session.sessionToken, - }) - .from(schema.user) - .leftJoin(schema.session, eq(schema.user.id, schema.session.userId)) - .leftJoin( - schema.fingerprint, - eq(schema.session.fingerprint_id, schema.fingerprint.id), - ) - .where( - and( - eq(schema.session.fingerprint_id, fingerprintId), - // Allow access if either: - // 1. The fingerprint's sig_hash matches what the user provided (they own it) - // 2. The fingerprint's sig_hash is null (it's unclaimed/abandoned) - or( - eq(schema.fingerprint.sig_hash, fingerprintHash), - isNull(schema.fingerprint.sig_hash), - ), - gt(schema.session.expires, new Date()), // Only return active sessions - ), - ) - - if (users.length === 0) { - // No active session found - either: - // - This is a new fingerprint - // - The fingerprint exists but has no active session - // - The fingerprint is claimed by someone else (sig_hash mismatch) - logger.info( - { fingerprintId, fingerprintHash }, - 'No active session found or fingerprint claimed by another user', - ) - return NextResponse.json( - { error: 'Authentication failed' }, - { status: 401 }, - ) - } - - const user = users[0] - return NextResponse.json({ - user: { - id: user.id, - name: user.name, - email: user.email, - authToken: user.authToken, - fingerprintId, - fingerprintHash, - }, - message: 'Authentication successful!', - }) - } catch (error) { - logger.error({ error }, 'Error checking login status') - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 }, - ) - } } diff --git a/web/src/app/api/docs/agent-definition/route.ts b/web/src/app/api/docs/agent-definition/route.ts index fadb66adba..b8b309d306 100644 --- a/web/src/app/api/docs/agent-definition/route.ts +++ b/web/src/app/api/docs/agent-definition/route.ts @@ -1,7 +1,8 @@ -import { NextResponse } from 'next/server' import { readFile } from 'fs/promises' import { join } from 'path' +import { NextResponse } from 'next/server' + /** * API route that serves the content of the agent-definition.ts file * This allows the docs to dynamically include the actual TypeScript types diff --git a/web/src/app/api/healthz/__tests__/healthz.test.ts b/web/src/app/api/healthz/__tests__/healthz.test.ts new file mode 100644 index 0000000000..0284bdee55 --- /dev/null +++ b/web/src/app/api/healthz/__tests__/healthz.test.ts @@ -0,0 +1,97 @@ +import { describe, test, expect } from 'bun:test' + +import { getHealthz } from '../_get' + + +describe('/api/healthz route', () => { + describe('Success cases', () => { + test('returns 200 with status ok and agent count', async () => { + const mockGetAgentCount = async () => 42 + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.status).toBe('ok') + expect(body.cached_agents).toBe(42) + expect(body.timestamp).toBeDefined() + expect(typeof body.timestamp).toBe('string') + }) + + test('returns correct count when no agents exist', async () => { + const mockGetAgentCount = async () => 0 + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.status).toBe('ok') + expect(body.cached_agents).toBe(0) + }) + + test('returns correct count for large number of agents', async () => { + const mockGetAgentCount = async () => 10000 + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.status).toBe('ok') + expect(body.cached_agents).toBe(10000) + }) + }) + + describe('Error handling', () => { + test('returns 200 with error flag when getAgentCount throws', async () => { + const mockGetAgentCount = async () => { + throw new Error('Database connection failed') + } + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + + // Should still return 200 so health check passes + expect(response.status).toBe(200) + const body = await response.json() + expect(body.status).toBe('ok') + expect(body.agent_count_error).toBe(true) + expect(body.error).toBe('Database connection failed') + expect(body.cached_agents).toBeUndefined() + }) + + test('handles non-Error exceptions gracefully', async () => { + const mockGetAgentCount = async () => { + throw 'String error' + } + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.status).toBe('ok') + expect(body.agent_count_error).toBe(true) + expect(body.error).toBe('Unknown error') + }) + }) + + describe('Response format', () => { + test('response has correct Content-Type header', async () => { + const mockGetAgentCount = async () => 100 + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + + expect(response.headers.get('content-type')).toContain('application/json') + }) + + test('timestamp is in ISO format', async () => { + const mockGetAgentCount = async () => 50 + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + const body = await response.json() + + // Verify timestamp is valid ISO date + const timestamp = new Date(body.timestamp) + expect(timestamp.toString()).not.toBe('Invalid Date') + expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + }) + }) +}) diff --git a/web/src/app/api/healthz/_get.ts b/web/src/app/api/healthz/_get.ts new file mode 100644 index 0000000000..62fe23a437 --- /dev/null +++ b/web/src/app/api/healthz/_get.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server' + +export interface HealthzDeps { + getAgentCount: () => Promise +} + +export const getHealthz = async ({ getAgentCount }: HealthzDeps) => { + try { + // Get a lightweight count of agents without caching the full data + // This avoids the unstable_cache 2MB limit warning + const agentCount = await getAgentCount() + + return NextResponse.json({ + status: 'ok', + cached_agents: agentCount, + timestamp: new Date().toISOString(), + }) + } catch (error) { + console.error('[Healthz] Failed to get agent count:', error) + + // Still return 200 so health check passes, but indicate the error + return NextResponse.json({ + status: 'ok', + agent_count_error: true, + error: error instanceof Error ? error.message : 'Unknown error', + }) + } +} diff --git a/web/src/app/api/healthz/route.ts b/web/src/app/api/healthz/route.ts index 7d27880c9d..c0862ada9f 100644 --- a/web/src/app/api/healthz/route.ts +++ b/web/src/app/api/healthz/route.ts @@ -1,25 +1,7 @@ -import { NextResponse } from 'next/server' -import { getCachedAgentsLite } from '@/server/agents-data' +import { getHealthz } from './_get' -export const GET = async () => { - try { - // Warm the cache by fetching agents data - // This ensures SEO-critical data is available immediately - const agents = await getCachedAgentsLite() - - return NextResponse.json({ - status: 'ok', - cached_agents: agents.length, - timestamp: new Date().toISOString(), - }) - } catch (error) { - console.error('[Healthz] Failed to warm cache:', error) +import { getAgentCount } from '@/server/agents-data' - // Still return 200 so health check passes, but indicate cache warming failed - return NextResponse.json({ - status: 'ok', - cache_warm: false, - error: error instanceof Error ? error.message : 'Unknown error', - }) - } +export const GET = async () => { + return getHealthz({ getAgentCount }) } diff --git a/web/src/app/api/orgs/[orgId]/alerts/[alertId]/dismiss/route.ts b/web/src/app/api/orgs/[orgId]/alerts/[alertId]/dismiss/route.ts index 0b60202d9c..0e448d6014 100644 --- a/web/src/app/api/orgs/[orgId]/alerts/[alertId]/dismiss/route.ts +++ b/web/src/app/api/orgs/[orgId]/alerts/[alertId]/dismiss/route.ts @@ -22,7 +22,7 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { orgId, alertId } = await params + const { orgId, alertId: _alertId } = await params // Check if user is a member of this organization const membership = await db diff --git a/web/src/app/api/orgs/[orgId]/billing/__tests__/feature-flag.test.ts b/web/src/app/api/orgs/[orgId]/billing/__tests__/feature-flag.test.ts new file mode 100644 index 0000000000..1dbb185d5d --- /dev/null +++ b/web/src/app/api/orgs/[orgId]/billing/__tests__/feature-flag.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' + +import { ORG_BILLING_ENABLED } from '@/lib/billing-config' + +/** + * Tests for the org billing feature flag. + * + * These tests verify the feature flag state and document expected behavior. + * Direct route testing is difficult due to Next.js dependencies, so we verify: + * 1. The feature flag is in the expected state + * 2. The flag is properly exported and importable + * + * The actual route behavior (503 responses) is tested via the integration tests + * and verified by the isOrgBillingEvent tests in the webhook test file. + */ +describe('Org Billing Feature Flag', () => { + describe('ORG_BILLING_ENABLED', () => { + test('is exported and accessible', () => { + expect(typeof ORG_BILLING_ENABLED).toBe('boolean') + }) + + test('is currently set to false (org billing disabled)', () => { + // This test documents the current state of the feature flag. + // When re-enabling org billing, update this test to expect true. + expect(ORG_BILLING_ENABLED).toBe(false) + }) + + test('when false, billing routes have appropriate fallback behavior', () => { + // This is a documentation test that describes expected behavior. + // Actual route testing is done via integration/E2E tests. + if (!ORG_BILLING_ENABLED) { + // Expected behavior when org billing is disabled: + // - GET /api/orgs/[orgId]/billing/setup returns 200 with { is_setup: false, disabled: true } + // - POST /api/orgs/[orgId]/billing/setup returns 503 (can't set up new billing) + // - GET /api/orgs/[orgId]/billing/status returns 503 + // - POST /api/orgs/[orgId]/credits returns 503 + // - DELETE /api/orgs/[orgId]/billing/subscription is ALLOWED (users can cancel) + // - Stripe webhook returns 200 for org events (prevents retry storms) + expect(true).toBe(true) + } + }) + }) + + describe('Feature flag integration', () => { + test('flag can be used in conditional logic', () => { + const message = ORG_BILLING_ENABLED + ? 'Billing is enabled' + : 'Organization billing is temporarily disabled' + + expect(message).toBe('Organization billing is temporarily disabled') + }) + + test('flag value is consistent across imports', async () => { + // Verify the flag value is the same when imported multiple times + const { ORG_BILLING_ENABLED: flag1 } = await import('@/lib/billing-config') + const { ORG_BILLING_ENABLED: flag2 } = await import('@/lib/billing-config') + + expect(flag1).toBe(flag2) + expect(flag1).toBe(ORG_BILLING_ENABLED) + }) + }) +}) diff --git a/web/src/app/api/orgs/[orgId]/billing/portal/__tests__/org-billing-portal.test.ts b/web/src/app/api/orgs/[orgId]/billing/portal/__tests__/org-billing-portal.test.ts new file mode 100644 index 0000000000..5e6c3a3bc8 --- /dev/null +++ b/web/src/app/api/orgs/[orgId]/billing/portal/__tests__/org-billing-portal.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, mock, test } from 'bun:test' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +import { postOrgBillingPortal } from '../_post' + +import type { + CreateBillingPortalSessionFn, + GetMembershipFn, + GetSessionFn, + OrgMembership, + Session, +} from '../_post' + +const createMockLogger = (errorFn = mock(() => {})): Logger => ({ + error: errorFn, + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), +}) + +const createMockGetSession = (session: Session): GetSessionFn => + mock(() => Promise.resolve(session)) + +const createMockGetMembership = ( + result: OrgMembership | null +): GetMembershipFn => mock(() => Promise.resolve(result)) + +const createMockCreateBillingPortalSession = ( + result: { url: string } | Error = { url: 'https://billing.stripe.com/session/test_123' } +): CreateBillingPortalSessionFn => { + if (result instanceof Error) { + return mock(() => Promise.reject(result)) + } + return mock(() => Promise.resolve(result)) +} + +const defaultOrg = { + id: 'org-123', + name: 'Test Org', + slug: 'test-org', + stripe_customer_id: 'cus_org_123', +} + +const buildReturnUrl = (orgSlug: string) => `https://codebuff.com/orgs/${orgSlug}/settings` + +describe('/api/orgs/[orgId]/billing/portal POST endpoint', () => { + const orgId = 'org-123' + + describe('Feature flag', () => { + test('returns 503 when org billing is disabled', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: false, + buildReturnUrl, + }) + + expect(response.status).toBe(503) + const body = await response.json() + expect(body).toEqual({ error: 'Organization billing is temporarily disabled' }) + }) + }) + + describe('Authentication', () => { + test('returns 401 when session is null', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession(null), + getMembership: createMockGetMembership(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('returns 401 when session.user is null', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: null }), + getMembership: createMockGetMembership(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('returns 401 when session.user.id is missing', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: {} as any }), + getMembership: createMockGetMembership(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + }) + + describe('Organization membership', () => { + test('returns 404 when user is not a member of the organization', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(404) + const body = await response.json() + expect(body).toEqual({ error: 'Organization not found' }) + }) + + test('calls getMembership with correct parameters', async () => { + const mockGetMembership = createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }) + + await postOrgBillingPortal({ + orgId: 'org-456', + getSession: createMockGetSession({ user: { id: 'user-789' } }), + getMembership: mockGetMembership, + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(mockGetMembership).toHaveBeenCalledTimes(1) + expect(mockGetMembership).toHaveBeenCalledWith({ + orgId: 'org-456', + userId: 'user-789', + }) + }) + }) + + describe('Permissions', () => { + test('returns 403 when user is a member (not owner or admin)', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'member', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body).toEqual({ error: 'Insufficient permissions' }) + }) + + test('allows owner to access billing portal', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(200) + }) + + test('allows admin to access billing portal', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'admin', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(200) + }) + }) + + describe('Stripe customer validation', () => { + test('returns 400 when organization has no stripe_customer_id', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: { ...defaultOrg, stripe_customer_id: null }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'No Stripe customer ID found for organization' }) + }) + }) + + describe('Successful portal session creation', () => { + test('returns 200 with portal URL on success', async () => { + const expectedUrl = 'https://billing.stripe.com/session/org_abc123' + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession({ url: expectedUrl }), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ url: expectedUrl }) + }) + + test('calls createBillingPortalSession with correct parameters', async () => { + const mockCreateSession = createMockCreateBillingPortalSession() + + await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'admin', + organization: { + ...defaultOrg, + slug: 'my-org', + stripe_customer_id: 'cus_my_org_456', + }, + }), + createBillingPortalSession: mockCreateSession, + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl: (slug) => `https://example.com/orgs/${slug}/billing`, + }) + + expect(mockCreateSession).toHaveBeenCalledTimes(1) + expect(mockCreateSession).toHaveBeenCalledWith({ + customer: 'cus_my_org_456', + return_url: 'https://example.com/orgs/my-org/billing', + }) + }) + }) + + describe('Error handling', () => { + test('returns 500 when Stripe API throws an error', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession( + new Error('Stripe API error') + ), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body).toEqual({ error: 'Failed to create billing portal session' }) + }) + + test('logs error when Stripe API fails', async () => { + const mockLoggerError = mock(() => {}) + const testError = new Error('Stripe connection failed') + + await postOrgBillingPortal({ + orgId: 'org-error-test', + getSession: createMockGetSession({ user: { id: 'user-error' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(testError), + logger: createMockLogger(mockLoggerError), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(mockLoggerError).toHaveBeenCalledTimes(1) + expect(mockLoggerError).toHaveBeenCalledWith( + { userId: 'user-error', orgId: 'org-error-test', error: testError }, + 'Failed to create org billing portal session' + ) + }) + }) +}) diff --git a/web/src/app/api/orgs/[orgId]/billing/portal/_post.ts b/web/src/app/api/orgs/[orgId]/billing/portal/_post.ts new file mode 100644 index 0000000000..8a222b44d4 --- /dev/null +++ b/web/src/app/api/orgs/[orgId]/billing/portal/_post.ts @@ -0,0 +1,116 @@ +import { NextResponse } from 'next/server' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +export type OrgMemberRole = 'owner' | 'admin' | 'member' + +export type Organization = { + id: string + name: string + slug: string + stripe_customer_id: string | null +} + +export type OrgMembership = { + role: OrgMemberRole + organization: Organization +} + +export type SessionUser = { + id: string +} + +export type Session = { + user?: SessionUser | null +} | null + +export type GetSessionFn = () => Promise + +export type GetMembershipFn = (params: { + orgId: string + userId: string +}) => Promise + +export type CreateBillingPortalSessionFn = (params: { + customer: string + return_url: string +}) => Promise<{ url: string }> + +export type PostOrgBillingPortalParams = { + orgId: string + getSession: GetSessionFn + getMembership: GetMembershipFn + createBillingPortalSession: CreateBillingPortalSessionFn + logger: Logger + orgBillingEnabled: boolean + buildReturnUrl: (orgSlug: string) => string +} + +export async function postOrgBillingPortal(params: PostOrgBillingPortalParams) { + const { + orgId, + getSession, + getMembership, + createBillingPortalSession, + logger, + orgBillingEnabled, + buildReturnUrl, + } = params + + if (!orgBillingEnabled) { + return NextResponse.json( + { error: 'Organization billing is temporarily disabled' }, + { status: 503 } + ) + } + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + const membership = await getMembership({ orgId, userId }) + + if (!membership) { + return NextResponse.json( + { error: 'Organization not found' }, + { status: 404 } + ) + } + + const { role, organization } = membership + + if (role !== 'owner' && role !== 'admin') { + return NextResponse.json( + { error: 'Insufficient permissions' }, + { status: 403 } + ) + } + + if (!organization.stripe_customer_id) { + return NextResponse.json( + { error: 'No Stripe customer ID found for organization' }, + { status: 400 } + ) + } + + try { + const portalSession = await createBillingPortalSession({ + customer: organization.stripe_customer_id, + return_url: buildReturnUrl(organization.slug), + }) + + return NextResponse.json({ url: portalSession.url }) + } catch (error) { + logger.error( + { userId, orgId, error }, + 'Failed to create org billing portal session' + ) + return NextResponse.json( + { error: 'Failed to create billing portal session' }, + { status: 500 } + ) + } +} diff --git a/web/src/app/api/orgs/[orgId]/billing/portal/route.ts b/web/src/app/api/orgs/[orgId]/billing/portal/route.ts new file mode 100644 index 0000000000..84fc75aba9 --- /dev/null +++ b/web/src/app/api/orgs/[orgId]/billing/portal/route.ts @@ -0,0 +1,61 @@ +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { env } from '@codebuff/internal/env' +import { stripeServer } from '@codebuff/internal/util/stripe' +import { eq, and } from 'drizzle-orm' +import { getServerSession } from 'next-auth' + +import type { NextRequest } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { ORG_BILLING_ENABLED } from '@/lib/billing-config' +import { logger } from '@/util/logger' + +import { postOrgBillingPortal } from './_post' + +import type { GetMembershipFn } from './_post' + +interface RouteParams { + params: Promise<{ + orgId: string + }> +} + +const getMembership: GetMembershipFn = async ({ orgId, userId }) => { + const membership = await db + .select({ + role: schema.orgMember.role, + organization: schema.org, + }) + .from(schema.orgMember) + .innerJoin(schema.org, eq(schema.orgMember.org_id, schema.org.id)) + .where( + and( + eq(schema.orgMember.org_id, orgId), + eq(schema.orgMember.user_id, userId), + ), + ) + .limit(1) + + if (membership.length === 0) { + return null + } + + return membership[0] +} + +export async function POST(req: NextRequest, { params }: RouteParams) { + const { orgId } = await params + + return postOrgBillingPortal({ + orgId, + getSession: () => getServerSession(authOptions), + getMembership, + createBillingPortalSession: (params) => + stripeServer.billingPortal.sessions.create(params), + logger, + orgBillingEnabled: ORG_BILLING_ENABLED, + buildReturnUrl: (orgSlug) => + `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/orgs/${orgSlug}/settings`, + }) +} diff --git a/web/src/app/api/orgs/[orgId]/billing/setup/route.ts b/web/src/app/api/orgs/[orgId]/billing/setup/route.ts index c8fe158ce0..0fc44cd576 100644 --- a/web/src/app/api/orgs/[orgId]/billing/setup/route.ts +++ b/web/src/app/api/orgs/[orgId]/billing/setup/route.ts @@ -10,6 +10,7 @@ import { getServerSession } from 'next-auth' import type { NextRequest } from 'next/server' import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { ORG_BILLING_ENABLED } from '@/lib/billing-config' import { logger } from '@/util/logger' interface RouteParams { @@ -19,6 +20,15 @@ interface RouteParams { } export async function GET(req: NextRequest, { params }: RouteParams) { + // BILLING_DISABLED: Return stub response for GET to not break org pages + // The useOrganizationData hook calls this endpoint, and 503 causes loading spinners + if (!ORG_BILLING_ENABLED) { + return NextResponse.json({ + is_setup: false, + disabled: true, + }) + } + const session = await getServerSession(authOptions) if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -105,6 +115,10 @@ export async function GET(req: NextRequest, { params }: RouteParams) { } export async function POST(req: NextRequest, { params }: RouteParams) { + if (!ORG_BILLING_ENABLED) { + return NextResponse.json({ error: 'Organization billing is temporarily disabled' }, { status: 503 }) + } + const session = await getServerSession(authOptions) if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) diff --git a/web/src/app/api/orgs/[orgId]/billing/status/route.ts b/web/src/app/api/orgs/[orgId]/billing/status/route.ts index dc25999715..057db56ea4 100644 --- a/web/src/app/api/orgs/[orgId]/billing/status/route.ts +++ b/web/src/app/api/orgs/[orgId]/billing/status/route.ts @@ -1,6 +1,5 @@ import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' -import { env } from '@codebuff/internal/env' import { stripeServer } from '@codebuff/internal/util/stripe' import { eq, and, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' @@ -9,6 +8,7 @@ import { getServerSession } from 'next-auth' import type { NextRequest } from 'next/server' import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { ORG_BILLING_ENABLED } from '@/lib/billing-config' import { logger } from '@/util/logger' interface RouteParams { @@ -18,6 +18,10 @@ interface RouteParams { } export async function GET(req: NextRequest, { params }: RouteParams) { + if (!ORG_BILLING_ENABLED) { + return NextResponse.json({ error: 'Organization billing is temporarily disabled' }, { status: 503 }) + } + const session = await getServerSession(authOptions) if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -69,32 +73,21 @@ export async function GET(req: NextRequest, { params }: RouteParams) { // Get subscription details if it exists let subscriptionDetails = null - let billingPortalUrl = null - if (organization.stripe_customer_id) { + if (organization.stripe_customer_id && organization.stripe_subscription_id) { try { - // Create billing portal session - const portalSession = await stripeServer.billingPortal.sessions.create({ - customer: organization.stripe_customer_id, - return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/orgs/${organization.slug}/settings`, - }) - billingPortalUrl = portalSession.url - - // Get subscription details if subscription exists - if (organization.stripe_subscription_id) { - const subscription = await stripeServer.subscriptions.retrieve( - organization.stripe_subscription_id, - ) - - subscriptionDetails = { - status: subscription.status, - current_period_start: subscription.current_period_start, - current_period_end: subscription.current_period_end, - cancel_at_period_end: subscription.cancel_at_period_end, - } + const subscription = await stripeServer.subscriptions.retrieve( + organization.stripe_subscription_id, + ) + + subscriptionDetails = { + status: subscription.status, + current_period_start: subscription.current_period_start, + current_period_end: subscription.current_period_end, + cancel_at_period_end: subscription.cancel_at_period_end, } } catch (error) { - logger.warn({ orgId, error }, 'Failed to get Stripe billing details') + logger.warn({ orgId, error }, 'Failed to get Stripe subscription details') } } @@ -107,7 +100,6 @@ export async function GET(req: NextRequest, { params }: RouteParams) { totalMonthlyCost: seatCount * pricePerSeat, hasActiveSubscription: !!organization.stripe_subscription_id, subscriptionDetails, - billingPortalUrl, organization: { id: organization.id, name: organization.name, diff --git a/web/src/app/api/orgs/[orgId]/billing/subscription/route.ts b/web/src/app/api/orgs/[orgId]/billing/subscription/route.ts index e8d862d473..397eb6bd99 100644 --- a/web/src/app/api/orgs/[orgId]/billing/subscription/route.ts +++ b/web/src/app/api/orgs/[orgId]/billing/subscription/route.ts @@ -17,6 +17,8 @@ interface RouteParams { } export async function DELETE(req: NextRequest, { params }: RouteParams) { + // NOTE: Subscription cancellation is allowed even when org billing is disabled + // Users must be able to cancel existing subscriptions const session = await getServerSession(authOptions) if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) diff --git a/web/src/app/api/orgs/[orgId]/credits/route.ts b/web/src/app/api/orgs/[orgId]/credits/route.ts index 0ebc55fc80..343e5c9012 100644 --- a/web/src/app/api/orgs/[orgId]/credits/route.ts +++ b/web/src/app/api/orgs/[orgId]/credits/route.ts @@ -12,6 +12,7 @@ import { getServerSession } from 'next-auth' import type { NextRequest } from 'next/server' import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { ORG_BILLING_ENABLED } from '@/lib/billing-config' import { logger } from '@/util/logger' interface RouteParams { @@ -21,6 +22,10 @@ interface RouteParams { const ORG_MIN_PURCHASE_CREDITS = 5000 // $50 minimum for organizations export async function POST(request: NextRequest, { params }: RouteParams) { + if (!ORG_BILLING_ENABLED) { + return NextResponse.json({ error: 'Organization billing is temporarily disabled' }, { status: 503 }) + } + const session = await getServerSession(authOptions) if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -41,6 +46,23 @@ export async function POST(request: NextRequest, { params }: RouteParams) { ) } + // Check if user is banned + const user = await db.query.user.findFirst({ + where: eq(schema.user.id, session.user.id), + columns: { banned: true }, + }) + + if (user?.banned) { + logger.warn( + { userId: session.user.id, orgId }, + 'Banned user attempted to purchase organization credits', + ) + return NextResponse.json( + { error: 'Your account has been suspended. Please contact support.' }, + { status: 403 }, + ) + } + // Verify user has permission to purchase credits for this organization const membership = await db.query.orgMember.findFirst({ where: and( diff --git a/web/src/app/api/orgs/[orgId]/invitations/[email]/route.ts b/web/src/app/api/orgs/[orgId]/invitations/[email]/route.ts index f137f27f51..194ee1cc12 100644 --- a/web/src/app/api/orgs/[orgId]/invitations/[email]/route.ts +++ b/web/src/app/api/orgs/[orgId]/invitations/[email]/route.ts @@ -51,7 +51,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { } // Delete the invitation - const result = await db + const _result = await db .delete(schema.orgInvite) .where( and( diff --git a/web/src/app/api/orgs/[orgId]/invitations/bulk/route.ts b/web/src/app/api/orgs/[orgId]/invitations/bulk/route.ts index 4a535a9584..92497ccee0 100644 --- a/web/src/app/api/orgs/[orgId]/invitations/bulk/route.ts +++ b/web/src/app/api/orgs/[orgId]/invitations/bulk/route.ts @@ -21,14 +21,7 @@ interface BulkInviteRequest { }> } -interface BulkInviteResult { - success: boolean - added: number - skipped: Array<{ - email: string - reason: string - }> -} +// BulkInviteResult interface removed - not used (response type inferred from JSON) export async function POST(request: NextRequest, { params }: RouteParams) { try { diff --git a/web/src/app/api/orgs/[orgId]/members/[userId]/route.ts b/web/src/app/api/orgs/[orgId]/members/[userId]/route.ts index bcf1b5871b..764e3b09fa 100644 --- a/web/src/app/api/orgs/[orgId]/members/[userId]/route.ts +++ b/web/src/app/api/orgs/[orgId]/members/[userId]/route.ts @@ -72,7 +72,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ error: 'Member not found' }, { status: 404 }) } - const { role: targetRole, email: targetEmail } = targetMembership[0] + const { role: targetRole, email: _targetEmail } = targetMembership[0] // Only owners can change owner roles if (targetRole === 'owner') { diff --git a/web/src/app/api/orgs/[orgId]/publishers/route.ts b/web/src/app/api/orgs/[orgId]/publishers/route.ts index 0ffb50c1b7..1496e7184a 100644 --- a/web/src/app/api/orgs/[orgId]/publishers/route.ts +++ b/web/src/app/api/orgs/[orgId]/publishers/route.ts @@ -78,10 +78,7 @@ export async function GET( return NextResponse.json({ publishers: response }) } catch (error) { - logger.error( - { error }, - 'Error fetching organization publishers', - ) + logger.error({ error }, 'Error fetching organization publishers') return NextResponse.json( { error: 'Internal server error' }, { status: 500 }, diff --git a/web/src/app/api/orgs/[orgId]/route.ts b/web/src/app/api/orgs/[orgId]/route.ts index 0befa9dcdf..bb554f5698 100644 --- a/web/src/app/api/orgs/[orgId]/route.ts +++ b/web/src/app/api/orgs/[orgId]/route.ts @@ -73,7 +73,7 @@ export async function GET( ]) // Get organization credit balance - let creditBalance: number | undefined + let _creditBalance: number | undefined try { const now = new Date() const quotaResetDate = new Date(now.getFullYear(), now.getMonth(), 1) // First of current month @@ -83,7 +83,7 @@ export async function GET( now, logger, }) - creditBalance = balance.netBalance + _creditBalance = balance.netBalance } catch (error) { // If no credits exist yet, that's fine console.log('No organization credits found:', error) diff --git a/web/src/app/api/referrals/[code]/route.ts b/web/src/app/api/referrals/[code]/route.ts deleted file mode 100644 index 5f7393f1ad..0000000000 --- a/web/src/app/api/referrals/[code]/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' -import { eq } from 'drizzle-orm' -import { NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' - -import { authOptions } from '../../auth/[...nextauth]/auth-options' - -import type { ReferralStatus } from '@/lib/server/referral' - -import { hasMaxedReferrals } from '@/lib/server/referral' - -export type ReferralCodeResponse = { - referrerName: string | null - isSameUser: boolean - status: ReferralStatus -} - -export async function GET( - _req: Request, - { params }: { params: Promise<{ code: string }> }, -): Promise> { - const { code } = await params - const session = await getServerSession(authOptions) - - try { - const user = await db.query.user.findFirst({ - where: eq(schema.user.referral_code, code), - columns: { - name: true, - id: true, - }, - }) - - if (!user) { - return NextResponse.json( - { error: 'Invalid referral code' }, - { status: 400 }, - ) - } - - const isSameUser = user.id === session?.user?.id - const referralStatus = await hasMaxedReferrals(user.id) - - return NextResponse.json({ - referrerName: user.name, - isSameUser, - status: referralStatus, - }) - } catch (error) { - console.error(error) - return NextResponse.json( - { error: 'Internal Server Error' }, - { status: 500 }, - ) - } -} diff --git a/web/src/app/api/referrals/helpers.ts b/web/src/app/api/referrals/helpers.ts deleted file mode 100644 index 642146af07..0000000000 --- a/web/src/app/api/referrals/helpers.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { grantCreditOperation } from '@codebuff/billing' -import { CREDITS_REFERRAL_BONUS } from '@codebuff/common/old-constants' -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' -import { and, eq, sql } from 'drizzle-orm' -import { NextResponse } from 'next/server' - -import { hasMaxedReferrals } from '@/lib/server/referral' -import { logger } from '@/util/logger' - -export async function redeemReferralCode(referralCode: string, userId: string) { - try { - // Check if the user has already used this referral code - const alreadyUsed = await db - .select() - .from(schema.referral) - .where(eq(schema.referral.referred_id, userId)) - .limit(1) - - if (alreadyUsed.length > 0) { - return NextResponse.json( - { - error: - "You've already been referred by someone. Each user can only be referred once.", - }, - { status: 409 }, - ) - } - - // Check if the user is trying to use their own referral code - const referringUser = await db - .select({ userId: schema.user.id }) - .from(schema.user) - .where(eq(schema.user.referral_code, referralCode)) - .limit(1) - .then((users) => { - if (users.length === 1) { - return users[0] - } - return - }) - - if (!referringUser) { - return NextResponse.json( - { - error: - "This referral code doesn't exist! Try again or reach out to support@codebuff.com if the problem persists.", - }, - { - status: 404, - }, - ) - } - if (referringUser.userId === userId) { - return NextResponse.json( - { - error: "Nice try bud, you can't use your own referral code.", - }, - { - status: 400, - }, - ) - } - - // Check if the user has been referred by someone they were referred by - const doubleDipping = await db - .select() - .from(schema.referral) - .where( - and( - eq(schema.referral.referrer_id, userId), - eq(schema.referral.referred_id, referringUser.userId), - ), - ) - .limit(1) - if (doubleDipping.length > 0) { - return NextResponse.json( - { - error: - 'You were referred by this user already. No double dipping, refer someone new!', - }, - { status: 409 }, - ) - } - - // Find the referrer user object - const referrer = await db.query.user.findFirst({ - where: eq(schema.user.referral_code, referralCode), - columns: { id: true }, - }) - if (!referrer) { - logger.warn({ referralCode }, 'Referrer not found.') - return NextResponse.json( - { error: 'Invalid referral code.' }, - { status: 400 }, - ) - } - - // Find the referred user object - const referred = await db.query.user.findFirst({ - where: eq(schema.user.id, userId), - columns: { id: true }, - }) - if (!referred) { - logger.warn( - { userId }, - 'Referred user not found during referral redemption.', - ) - return NextResponse.json({ error: 'User not found.' }, { status: 404 }) - } - - // Check if the referrer has maxed out their referrals - const referralStatus = await hasMaxedReferrals(referrer.id) - if (referralStatus.reason) { - return NextResponse.json( - { error: referralStatus.details?.msg || referralStatus.reason }, - { status: 400 }, - ) - } - - await db.transaction(async (tx) => { - // 1. Create the referral record locally - const now = new Date() - const referralRecord = await tx - .insert(schema.referral) - .values({ - referrer_id: referrer.id, - referred_id: userId, - status: 'completed', - credits: CREDITS_REFERRAL_BONUS, - created_at: now, - completed_at: now, - }) - .returning({ - operation_id: sql`'ref-' || gen_random_uuid()`, - }) - - const operationId = referralRecord[0].operation_id - - // Get the user's next quota reset date - const user = await tx.query.user.findFirst({ - where: eq(schema.user.id, userId), - columns: { - next_quota_reset: true, - }, - }) - - if (!user?.next_quota_reset) { - throw new Error('User next_quota_reset not found') - } - - // 2. Process and grant credits for both users - const grantPromises = [] - - // Process Referrer - grantPromises.push( - grantCreditOperation({ - userId: referrer.id, - amount: CREDITS_REFERRAL_BONUS, - type: 'referral', - description: 'Referral bonus (referrer)', - expiresAt: user.next_quota_reset, - operationId: `${operationId}-referrer`, - tx, - logger, - }) - .then(() => true) - .catch((error: Error) => { - logger.error( - { - error, - userId: referrer.id, - role: 'referrer', - creditsToGrant: CREDITS_REFERRAL_BONUS, - }, - 'Failed to process referral credit grant', - ) - return false - }), - ) - - // Process Referred User - grantPromises.push( - grantCreditOperation({ - userId: referred.id, - amount: CREDITS_REFERRAL_BONUS, - type: 'referral', - description: 'Referral bonus (referred)', - expiresAt: user.next_quota_reset, - operationId: `${operationId}-referred`, - tx, - logger, - }) - .then(() => true) - .catch((error: Error) => { - logger.error( - { - error, - userId: referred.id, - role: 'referred', - creditsToGrant: CREDITS_REFERRAL_BONUS, - }, - 'Failed to process referral credit grant', - ) - return false - }), - ) - - const results = await Promise.all(grantPromises) - - // Check if any grant creation failed - if (results.some((result: boolean) => !result)) { - logger.error( - { operationId, referrerId: referrer.id, referredId: userId }, - 'One or more credit grants failed. Rolling back transaction.', - ) - throw new Error('Failed to create credit grants for referral.') - } else { - logger.info( - { operationId, referrerId: referrer.id, referredId: userId }, - 'Credit grants created successfully for referral.', - ) - } - }) // End transaction - - // If transaction succeeded - return NextResponse.json( - { - message: 'Referral applied successfully!', - credits_redeemed: CREDITS_REFERRAL_BONUS, - }, - { - status: 200, - }, - ) - } catch (error) { - logger.error( - { userId, referralCode, error }, - 'Error applying referral code', - ) - const errorMessage = - error instanceof Error ? error.message : 'Internal Server Error' - return NextResponse.json( - { error: 'Failed to apply referral code. Please try again later.' }, - { status: 500 }, - ) - } -} diff --git a/web/src/app/api/referrals/route.ts b/web/src/app/api/referrals/route.ts index f44fe6eca4..455ab565a8 100644 --- a/web/src/app/api/referrals/route.ts +++ b/web/src/app/api/referrals/route.ts @@ -5,27 +5,22 @@ import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { z } from 'zod/v4' -import { redeemReferralCode } from './helpers' import { authOptions } from '../auth/[...nextauth]/auth-options' -import { extractApiKeyFromHeader } from '@/util/auth' - -import type { NextRequest } from 'next/server' type Referral = Pick & - Pick + Pick const ReferralSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), credits: z.coerce.number(), + is_legacy: z.boolean().default(false), }) export type ReferralData = { - referralCode: string referrals: Referral[] referredBy?: Referral - referralLimit: number } export async function GET() { @@ -36,22 +31,12 @@ export async function GET() { } try { - const user = await db.query.user.findFirst({ - where: eq(schema.user.id, session.user.id), - }) - - const referralCode = user?.referral_code - if (!referralCode) { - throw new Error( - `No referral code found for user with id ${session.user.id}`, - ) - } - // Who did this user refer? const referralsQuery = db .select({ id: schema.referral.referred_id, credits: schema.referral.credits, + is_legacy: schema.referral.is_legacy, }) .from(schema.referral) .where(eq(schema.referral.referrer_id, session.user.id)) @@ -62,6 +47,7 @@ export async function GET() { name: schema.user.name, email: schema.user.email, credits: referralsQuery.credits, + is_legacy: referralsQuery.is_legacy, }) .from(referralsQuery) .leftJoin(schema.user, eq(schema.user.id, referralsQuery.id)) @@ -71,6 +57,7 @@ export async function GET() { .select({ id: schema.referral.referrer_id, credits: schema.referral.credits, + is_legacy: schema.referral.is_legacy, }) .from(schema.referral) .where(eq(schema.referral.referred_id, session.user.id)) @@ -82,6 +69,7 @@ export async function GET() { name: schema.user.name, email: schema.user.email, credits: referredByIdQuery.credits, + is_legacy: referredByIdQuery.is_legacy, }) .from(referredByIdQuery) .leftJoin(schema.user, eq(schema.user.id, referredByIdQuery.id)) @@ -94,7 +82,6 @@ export async function GET() { }) const referralData: ReferralData = { - referralCode, referrals: referrals.reduce((acc, referral) => { const result = ReferralSchema.safeParse(referral) if (result.success) { @@ -103,7 +90,6 @@ export async function GET() { return acc }, [] as Referral[]), referredBy, - referralLimit: user.referral_limit, } return NextResponse.json(referralData) @@ -115,64 +101,3 @@ export async function GET() { ) } } - -export async function POST(request: NextRequest) { - try { - // First try to get the session (web flow) - const session = await getServerSession(authOptions) - if (session?.user?.id) { - const { referralCode } = await request.json() - if (!referralCode) { - return NextResponse.json( - { error: 'Missing referral code' }, - { status: 400 }, - ) - } - return redeemReferralCode(referralCode, session.user.id) - } - } catch (error) { - console.error('Error processing referral:', error) - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 }, - ) - } - - // Fall back to auth token (CLI flow) - // Prefer Authorization header, fall back to body authToken for backwards compatibility - const reqJson = await request.json() - const parsedJson = z - .object({ - referralCode: z.string(), - // DEPRECATED: authToken in body is for backwards compatibility with older CLI versions. - // New clients should use the Authorization header instead. - authToken: z.string().optional(), - }) - .safeParse(reqJson) - - if (!parsedJson.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } - - const { referralCode, authToken: bodyAuthToken } = parsedJson.data - - // Prefer Authorization header, fall back to body authToken for backwards compatibility - const authToken = extractApiKeyFromHeader(request) ?? bodyAuthToken - - if (!authToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const user = await db.query.session.findFirst({ - where: eq(schema.session.sessionToken, authToken), - columns: { - userId: true, - }, - }) - - if (!user?.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - return redeemReferralCode(referralCode, user.userId) -} diff --git a/web/src/app/api/releases/download/[version]/[filename]/route.ts b/web/src/app/api/releases/download/[version]/[filename]/route.ts index b7ac5eea9a..f0f50d1a12 100644 --- a/web/src/app/api/releases/download/[version]/[filename]/route.ts +++ b/web/src/app/api/releases/download/[version]/[filename]/route.ts @@ -1,4 +1,6 @@ -import { NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' + +import type { NextRequest} from 'next/server'; /** * Proxy endpoint for CLI binary downloads. @@ -15,8 +17,11 @@ export async function GET( return NextResponse.json({ error: 'Missing parameters' }, { status: 400 }) } + // Freebuff releases use a "freebuff-v" tag prefix to avoid colliding with codebuff releases + const tagPrefix = filename.startsWith('freebuff-') ? 'freebuff-v' : 'v' + // Current download location - can be changed in the future without affecting old clients - const downloadUrl = `https://github.com/CodebuffAI/codebuff-community/releases/download/v${version}/${filename}` + const downloadUrl = `https://github.com/CodebuffAI/codebuff-community/releases/download/${tagPrefix}${version}/${filename}` return NextResponse.redirect(downloadUrl, 302) } diff --git a/web/src/app/api/sessions/route.ts b/web/src/app/api/sessions/route.ts index cef5371daf..74e30a788b 100644 --- a/web/src/app/api/sessions/route.ts +++ b/web/src/app/api/sessions/route.ts @@ -73,7 +73,7 @@ async function revokeStandardSessions( eq(schema.session.userId, userId), inArray(schema.session.sessionToken, tokensToDelete), // Explicitly restrict to web/cli to avoid PATs here - inArray(schema.session.type, ['web', 'cli'] as any), + inArray(schema.session.type, ['web', 'cli'] as const), ), ) .returning({ sessionToken: schema.session.sessionToken }) @@ -109,12 +109,13 @@ export async function DELETE(req: NextRequest) { return new NextResponse('Unauthorized', { status: 401 }) } - const { - sessionIds, - tokenIds, - }: { sessionIds?: string[]; tokenIds?: string[] } = await req - .json() - .catch(() => ({}) as any) + let body: { sessionIds?: string[]; tokenIds?: string[] } = {} + try { + body = await req.json() + } catch { + body = {} + } + const { sessionIds, tokenIds } = body const userId = session.user.id @@ -137,11 +138,13 @@ export async function DELETE(req: NextRequest) { } return NextResponse.json({ revokedSessions, revokedTokens }) - } catch (e: any) { + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e) + const stack = e instanceof Error ? e.stack : undefined logger.error( - { error: e?.message ?? String(e), stack: e?.stack }, + { error: errorMessage, stack }, 'Error in DELETE /api/sessions', ) - return new NextResponse(e?.message ?? 'Internal error', { status: 500 }) + return new NextResponse(errorMessage, { status: 500 }) } } diff --git a/web/src/app/api/stripe/buy-credits/route.ts b/web/src/app/api/stripe/buy-credits/route.ts index 836f362388..28374e86d3 100644 --- a/web/src/app/api/stripe/buy-credits/route.ts +++ b/web/src/app/api/stripe/buy-credits/route.ts @@ -28,7 +28,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = session.user.id - const userEmail = session.user.email + const _userEmail = session.user.email let data try { @@ -50,9 +50,17 @@ export async function POST(req: NextRequest) { try { const user = await db.query.user.findFirst({ where: eq(schema.user.id, userId), - columns: { stripe_customer_id: true }, + columns: { stripe_customer_id: true, banned: true }, }) + if (user?.banned) { + logger.warn({ userId }, 'Banned user attempted to purchase credits') + return NextResponse.json( + { error: 'Your account has been suspended. Please contact support.' }, + { status: 403 }, + ) + } + if (!user?.stripe_customer_id) { logger.error( { userId }, @@ -177,6 +185,9 @@ export async function POST(req: NextRequest) { }, ], mode: 'payment', + invoice_creation: { enabled: true }, + tax_id_collection: { enabled: true }, // optional (EU B2B) + customer_update: { name: "auto", address: "auto" }, success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/payment-success?session_id={CHECKOUT_SESSION_ID}&purchase=credits&amt=${credits}`, cancel_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage?purchase_canceled=true`, metadata: { diff --git a/web/src/app/api/stripe/cancel-subscription/route.ts b/web/src/app/api/stripe/cancel-subscription/route.ts new file mode 100644 index 0000000000..af1aa779bc --- /dev/null +++ b/web/src/app/api/stripe/cancel-subscription/route.ts @@ -0,0 +1,72 @@ +import { getActiveSubscription } from '@codebuff/billing' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { stripeServer } from '@codebuff/internal/util/stripe' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +export async function POST() { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + const subscription = await getActiveSubscription({ userId, logger }) + if (!subscription) { + return NextResponse.json( + { error: 'No active subscription found.' }, + { status: 404 }, + ) + } + + try { + await stripeServer.subscriptions.update( + subscription.stripe_subscription_id, + { cancel_at_period_end: true }, + ) + } catch (error: unknown) { + const message = + (error as { raw?: { message?: string } })?.raw?.message || + 'Failed to cancel subscription in Stripe.' + logger.error( + { error: message, userId, subscriptionId: subscription.stripe_subscription_id }, + 'Stripe subscription cancellation failed', + ) + return NextResponse.json({ error: message }, { status: 500 }) + } + + try { + await db + .update(schema.subscription) + .set({ cancel_at_period_end: true, scheduled_tier: null }) + .where( + eq( + schema.subscription.stripe_subscription_id, + subscription.stripe_subscription_id, + ), + ) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + logger.error( + { error: message, userId, subscriptionId: subscription.stripe_subscription_id }, + 'Stripe subscription set to cancel but failed to update local DB — data is inconsistent', + ) + return NextResponse.json( + { error: 'Subscription canceled but failed to update records. Please contact support.' }, + { status: 500 }, + ) + } + + logger.info( + { userId, subscriptionId: subscription.stripe_subscription_id }, + 'Subscription set to cancel at period end', + ) + + return NextResponse.json({ success: true }) +} diff --git a/web/src/app/api/stripe/create-subscription/route.ts b/web/src/app/api/stripe/create-subscription/route.ts new file mode 100644 index 0000000000..01808b25bd --- /dev/null +++ b/web/src/app/api/stripe/create-subscription/route.ts @@ -0,0 +1,116 @@ +import { getActiveSubscription, getPriceIdFromTier } from '@codebuff/billing' +import { SUBSCRIPTION_TIERS } from '@codebuff/common/constants/subscription-plans' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { env } from '@codebuff/internal/env' +import { stripeServer } from '@codebuff/internal/util/stripe' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' + +import type { SubscriptionTierPrice } from '@codebuff/common/constants/subscription-plans' +import type { NextRequest } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + const body = await req.json().catch(() => null) + const rawTier = Number(body?.tier) + if (!rawTier || !(rawTier in SUBSCRIPTION_TIERS)) { + return NextResponse.json( + { error: `Invalid tier. Must be one of: ${Object.keys(SUBSCRIPTION_TIERS).join(', ')}.` }, + { status: 400 }, + ) + } + const tier = rawTier as SubscriptionTierPrice + + const priceId = getPriceIdFromTier(tier) + if (!priceId) { + return NextResponse.json( + { error: 'Subscription tier not available' }, + { status: 503 }, + ) + } + + const user = await db.query.user.findFirst({ + where: eq(schema.user.id, userId), + columns: { stripe_customer_id: true, banned: true }, + }) + + if (user?.banned) { + logger.warn({ userId }, 'Banned user attempted to create subscription') + return NextResponse.json( + { error: 'Your account has been suspended. Please contact support.' }, + { status: 403 }, + ) + } + + if (!user?.stripe_customer_id) { + return NextResponse.json( + { error: 'Stripe customer not found.' }, + { status: 400 }, + ) + } + + const existing = await getActiveSubscription({ userId, logger }) + if (existing) { + return NextResponse.json( + { error: 'You already have an active subscription.' }, + { status: 409 }, + ) + } + + try { + const checkoutSession = await stripeServer.checkout.sessions.create({ + customer: user.stripe_customer_id, + mode: 'subscription', + tax_id_collection: { enabled: true }, // optional (EU B2B) + customer_update: { name: "auto", address: "auto" }, + line_items: [{ price: priceId, quantity: 1 }], + allow_promotion_codes: true, + success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile?tab=usage&subscription_success=true`, + cancel_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/pricing?canceled=true`, + metadata: { + userId, + type: 'strong_subscription', + }, + subscription_data: { + metadata: { + userId, + }, + }, + }) + + if (!checkoutSession.url) { + logger.error({ userId }, 'Stripe checkout session created without a URL') + return NextResponse.json( + { error: 'Could not create checkout session.' }, + { status: 500 }, + ) + } + + logger.info( + { userId, sessionId: checkoutSession.id, tier }, + 'Created Strong subscription checkout session', + ) + + return NextResponse.json({ sessionId: checkoutSession.id }) + } catch (error: unknown) { + const message = + (error as { raw?: { message?: string } })?.raw?.message || + 'Internal server error creating subscription.' + logger.error( + { error: message, userId }, + 'Failed to create subscription checkout', + ) + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/web/src/app/api/stripe/webhook/__tests__/org-billing-events.test.ts b/web/src/app/api/stripe/webhook/__tests__/org-billing-events.test.ts new file mode 100644 index 0000000000..fdf3598cd4 --- /dev/null +++ b/web/src/app/api/stripe/webhook/__tests__/org-billing-events.test.ts @@ -0,0 +1,331 @@ +import { + clearMockedModules, + mockModule, +} from '@codebuff/common/testing/mock-modules' +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test' + +import type Stripe from 'stripe' + +import { ORG_BILLING_ENABLED } from '@/lib/billing-config' + +// Mock database query result +let mockDbSelectResult: { id: string }[] = [] + +let isOrgBillingEvent: (event: Stripe.Event) => Promise +let isOrgCustomer: (stripeCustomerId: string) => Promise + +const setupMocks = async () => { + const limitMock = mock(() => Promise.resolve(mockDbSelectResult)) + const whereMock = mock(() => ({ limit: limitMock })) + const fromMock = mock(() => ({ where: whereMock })) + const selectMock = mock(() => ({ from: fromMock })) + + await mockModule('@codebuff/internal/db', () => ({ + default: { + select: selectMock, + }, + })) + + await mockModule('@codebuff/internal/db/schema', () => ({ + org: { + id: 'id', + stripe_customer_id: 'stripe_customer_id', + }, + })) + + await mockModule('drizzle-orm', () => ({ + eq: mock((a: unknown, b: unknown) => ({ column: a, value: b })), + })) + + // Import after mocking + const helpersModule = await import('../_helpers') + isOrgBillingEvent = helpersModule.isOrgBillingEvent + isOrgCustomer = helpersModule.isOrgCustomer +} + +// Setup mocks at module load time (following ban-conditions.test.ts pattern) +await setupMocks() + +beforeEach(() => { + mockDbSelectResult = [] +}) + +afterAll(() => { + clearMockedModules() +}) + +describe('ORG_BILLING_ENABLED feature flag', () => { + test('is currently false (org billing disabled)', () => { + // This test ensures the feature flag is in the expected state + // for the isOrgBillingEvent tests to be meaningful + expect(ORG_BILLING_ENABLED).toBe(false) + }) +}) + +describe('isOrgCustomer', () => { + test('returns true when customer ID belongs to an organization', async () => { + mockDbSelectResult = [{ id: 'org-123' }] + + const result = await isOrgCustomer('cus_org_123') + + expect(result).toBe(true) + }) + + test('returns false when customer ID does not belong to any organization', async () => { + mockDbSelectResult = [] + + const result = await isOrgCustomer('cus_user_123') + + expect(result).toBe(false) + }) +}) + +describe('isOrgBillingEvent', () => { + const createMockEvent = ( + type: string, + data: Record, + ): Stripe.Event => ({ + id: 'evt_test', + type, + data: { object: data }, + api_version: '2023-10-16', + created: Date.now(), + livemode: false, + object: 'event', + pending_webhooks: 0, + request: null, + }) as unknown as Stripe.Event + + describe('metadata-based detection', () => { + test('returns true when metadata contains organization_id', async () => { + const event = createMockEvent('checkout.session.completed', { + metadata: { organization_id: 'org-123' }, + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(true) + }) + + test('returns true when metadata contains organizationId', async () => { + const event = createMockEvent('invoice.paid', { + metadata: { organizationId: 'org-123' }, + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(true) + }) + + test('returns true when metadata.grantType is organization_purchase', async () => { + const event = createMockEvent('checkout.session.completed', { + metadata: { grantType: 'organization_purchase', organizationId: 'org-123' }, + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(true) + }) + + test('returns false when metadata has no org markers', async () => { + const event = createMockEvent('checkout.session.completed', { + metadata: { userId: 'user-123', grantType: 'purchase' }, + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(false) + }) + }) + + describe('invoice events', () => { + test('returns true for invoice event with organizationId in metadata', async () => { + const event = createMockEvent('invoice.paid', { + metadata: { organizationId: 'org-123', type: 'auto-topup' }, + customer: 'cus_123', + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(true) + }) + + test('returns true for invoice event when customer belongs to an org', async () => { + mockDbSelectResult = [{ id: 'org-123' }] + + const event = createMockEvent('invoice.payment_failed', { + metadata: {}, + customer: 'cus_org_123', + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(true) + }) + + test('returns false for invoice event when customer is not an org', async () => { + mockDbSelectResult = [] + + const event = createMockEvent('invoice.paid', { + metadata: {}, + customer: 'cus_user_123', + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(false) + }) + + test('handles invoice.created event', async () => { + mockDbSelectResult = [{ id: 'org-456' }] + + const event = createMockEvent('invoice.created', { + metadata: {}, + customer: 'cus_org_456', + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(true) + }) + }) + + describe('subscription events', () => { + test('returns true for subscription event when customer belongs to an org', async () => { + mockDbSelectResult = [{ id: 'org-123' }] + + const event = createMockEvent('customer.subscription.created', { + metadata: {}, + customer: 'cus_org_123', + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(true) + }) + + test('returns false for subscription event when customer is not an org', async () => { + mockDbSelectResult = [] + + const event = createMockEvent('customer.subscription.updated', { + metadata: {}, + customer: 'cus_user_123', + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(false) + }) + + test('handles customer.subscription.deleted event', async () => { + mockDbSelectResult = [{ id: 'org-789' }] + + const event = createMockEvent('customer.subscription.deleted', { + metadata: {}, + customer: 'cus_org_789', + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(true) + }) + }) + + describe('personal billing events (should return false)', () => { + test('returns false for user credit purchase', async () => { + const event = createMockEvent('checkout.session.completed', { + metadata: { + grantType: 'purchase', + userId: 'user-123', + credits: '1000', + }, + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(false) + }) + + test('returns false for user subscription event', async () => { + mockDbSelectResult = [] + + const event = createMockEvent('customer.subscription.created', { + metadata: {}, + customer: 'cus_user_only', + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(false) + }) + + test('returns false for charge.dispute.created (no org markers)', async () => { + const event = createMockEvent('charge.dispute.created', { + metadata: {}, + charge: 'ch_123', + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(false) + }) + + test('returns false for charge.refunded (no org markers)', async () => { + const event = createMockEvent('charge.refunded', { + metadata: {}, + payment_intent: 'pi_123', + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(false) + }) + }) + + describe('edge cases', () => { + test('handles missing metadata gracefully', async () => { + const event = createMockEvent('checkout.session.completed', {}) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(false) + }) + + test('handles null customer ID', async () => { + const event = createMockEvent('invoice.paid', { + metadata: {}, + customer: null, + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(false) + }) + + test('handles non-string customer ID', async () => { + const event = createMockEvent('customer.subscription.updated', { + metadata: {}, + customer: { id: 'cus_123' }, // Object instead of string + }) + + const result = await isOrgBillingEvent(event) + + expect(result).toBe(false) + }) + + test('prioritizes metadata check over customer lookup', async () => { + // Even if customer lookup would return true, metadata check happens first + mockDbSelectResult = [{ id: 'org-123' }] + + const event = createMockEvent('checkout.session.completed', { + metadata: { organization_id: 'org-456' }, + customer: 'cus_org_123', + }) + + const result = await isOrgBillingEvent(event) + + // Should return true from metadata check (before customer lookup) + expect(result).toBe(true) + }) + }) +}) diff --git a/web/src/app/api/stripe/webhook/_helpers.ts b/web/src/app/api/stripe/webhook/_helpers.ts new file mode 100644 index 0000000000..41f2bf8d28 --- /dev/null +++ b/web/src/app/api/stripe/webhook/_helpers.ts @@ -0,0 +1,67 @@ +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { eq } from 'drizzle-orm' + +import type Stripe from 'stripe' + +import { logger } from '@/util/logger' + +/** + * Checks whether a Stripe customer ID belongs to an organization. + * + * Uses `org.stripe_customer_id` which is set at org creation time, making it + * reliable regardless of webhook ordering (unlike `stripe_subscription_id` + * which may not be populated yet when early invoice events arrive). + */ +export async function isOrgCustomer(stripeCustomerId: string): Promise { + try { + const orgs = await db + .select({ id: schema.org.id }) + .from(schema.org) + .where(eq(schema.org.stripe_customer_id, stripeCustomerId)) + .limit(1) + return orgs.length > 0 + } catch (error) { + logger.error( + { stripeCustomerId, error }, + 'Failed to check if customer is an org - defaulting to false', + ) + return false + } +} + +/** + * BILLING_DISABLED: Checks if a Stripe event is related to organization billing. + * Used to reject org billing events while keeping personal billing working. + */ +export async function isOrgBillingEvent(event: Stripe.Event): Promise { + const eventData = event.data.object as unknown as Record + const metadata = (eventData.metadata || {}) as Record + + // Check metadata for organization markers + if (metadata.organization_id || metadata.organizationId) { + return true + } + if (metadata.grantType === 'organization_purchase') { + return true + } + + // For invoice events, check if customer belongs to an org + // (metadata.organizationId is already checked above in the generic metadata check) + if (event.type.startsWith('invoice.')) { + const customerId = eventData.customer + if (customerId && typeof customerId === 'string') { + return await isOrgCustomer(customerId) + } + } + + // For subscription events, check if customer is an org + if (event.type.startsWith('customer.subscription.')) { + const customerId = eventData.customer + if (customerId && typeof customerId === 'string') { + return await isOrgCustomer(customerId) + } + } + + return false +} diff --git a/web/src/app/api/stripe/webhook/route.ts b/web/src/app/api/stripe/webhook/route.ts index b0aaf58c38..8c34062144 100644 --- a/web/src/app/api/stripe/webhook/route.ts +++ b/web/src/app/api/stripe/webhook/route.ts @@ -2,12 +2,18 @@ import { grantOrganizationCredits, processAndGrantCredit, revokeGrantByOperationId, + handleSubscriptionInvoicePaid, + handleSubscriptionInvoicePaymentFailed, + handleSubscriptionUpdated, + handleSubscriptionDeleted, + handleSubscriptionScheduleCreatedOrUpdated, + handleSubscriptionScheduleReleasedOrCanceled, } from '@codebuff/billing' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' import { env } from '@codebuff/internal/env' import { sendDisputeNotificationEmail } from '@codebuff/internal/loops' -import { stripeServer } from '@codebuff/internal/util/stripe' +import { getStripeId, stripeServer } from '@codebuff/internal/util/stripe' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' @@ -19,12 +25,9 @@ import { evaluateBanConditions, getUserByStripeCustomerId, } from '@/lib/ban-conditions' -import { getStripeCustomerId } from '@/lib/stripe-utils' +import { ORG_BILLING_ENABLED } from '@/lib/billing-config' import { logger } from '@/util/logger' - -async function handleCustomerCreated(customer: Stripe.Customer) { - logger.info({ customerId: customer.id }, 'New customer created') -} +import { isOrgBillingEvent, isOrgCustomer } from './_helpers' async function handleCheckoutSessionCompleted( session: Stripe.Checkout.Session, @@ -224,8 +227,15 @@ async function handleCheckoutSessionCompleted( } } -async function handleSubscriptionEvent(subscription: Stripe.Subscription) { +async function handleOrganizationSubscriptionEvent(subscription: Stripe.Subscription) { const organizationId = subscription.metadata?.organization_id + if (!organizationId) { + logger.warn( + { subscriptionId: subscription.id }, + 'Organization subscription event missing organization_id metadata', + ) + return + } logger.info( { @@ -234,17 +244,9 @@ async function handleSubscriptionEvent(subscription: Stripe.Subscription) { customerId: subscription.customer, organizationId, }, - 'Subscription event received', + 'Organization subscription event received', ) - if (!organizationId) { - logger.warn( - { subscriptionId: subscription.id }, - 'Subscription event received without organization_id in metadata', - ) - return - } - try { // Handle subscription cancellation if (subscription.status === 'canceled') { @@ -305,7 +307,7 @@ async function handleInvoicePaid(invoice: Stripe.Invoice) { let customerId: string | null = null if (invoice.customer) { - customerId = getStripeCustomerId(invoice.customer) + customerId = getStripeId(invoice.customer) } if (creditNotes.data.length > 0) { @@ -340,43 +342,88 @@ const webhookHandler = async (req: NextRequest): Promise => { env.STRIPE_WEBHOOK_SECRET_KEY, ) } catch (err) { - const error = err as Error + const errorMessage = err instanceof Error ? err.message : String(err) logger.error( - { error: error.message }, + { error: errorMessage }, 'Webhook signature verification failed', ) return NextResponse.json( - { error: { message: `Webhook Error: ${error.message}` } }, + { error: { message: `Webhook Error: ${errorMessage}` } }, { status: 400 }, ) } logger.info({ type: event.type }, 'Received Stripe webhook event') + // BILLING_DISABLED: Acknowledge but ignore org-billing related events + // Return 200 to prevent Stripe from retrying (503 would cause retry storms) + if (!ORG_BILLING_ENABLED) { + const isOrgEvent = await isOrgBillingEvent(event) + if (isOrgEvent) { + logger.warn( + { type: event.type, eventId: event.id }, + 'BILLING_DISABLED: Ignoring org billing webhook event', + ) + return NextResponse.json({ + received: true, + ignored: 'org billing disabled', + }) + } + } + try { switch (event.type) { case 'customer.created': break case 'customer.subscription.created': - case 'customer.subscription.updated': + case 'customer.subscription.updated': { + const sub = event.data.object as Stripe.Subscription + if (sub.metadata?.organization_id) { + await handleOrganizationSubscriptionEvent(sub) + } else { + await handleSubscriptionUpdated({ stripeSubscription: sub, logger }) + } + break + } case 'customer.subscription.deleted': { - await handleSubscriptionEvent(event.data.object as Stripe.Subscription) + const sub = event.data.object as Stripe.Subscription + if (sub.metadata?.organization_id) { + await handleOrganizationSubscriptionEvent(sub) + } else { + await handleSubscriptionDeleted({ stripeSubscription: sub, logger }) + } + break + } + case 'subscription_schedule.created': + case 'subscription_schedule.updated': { + const schedule = event.data.object as Stripe.SubscriptionSchedule + // Skip organization schedules (if they have org metadata) + if (!schedule.metadata?.organization_id) { + await handleSubscriptionScheduleCreatedOrUpdated({ schedule, logger }) + } + break + } + case 'subscription_schedule.completed': + case 'subscription_schedule.released': + case 'subscription_schedule.canceled': { + const schedule = event.data.object as Stripe.SubscriptionSchedule + // Skip organization schedules (if they have org metadata) + if (!schedule.metadata?.organization_id) { + await handleSubscriptionScheduleReleasedOrCanceled({ schedule, logger }) + } break } case 'charge.dispute.created': { const dispute = event.data.object as Stripe.Dispute - const chargeId = - typeof dispute.charge === 'string' - ? dispute.charge - : dispute.charge?.id - if (!chargeId) { + if (!dispute.charge) { logger.warn( { disputeId: dispute.id }, 'Dispute received without charge ID', ) break } + const chargeId = getStripeId(dispute.charge) // Get the charge to find the customer const charge = await stripeServer.charges.retrieve(chargeId) @@ -388,9 +435,7 @@ const webhookHandler = async (req: NextRequest): Promise => { break } - const customerId = getStripeCustomerId( - charge.customer as string | Stripe.Customer | Stripe.DeletedCustomer, - ) + const customerId = getStripeId(charge.customer) if (!customerId) { logger.warn( @@ -515,11 +560,39 @@ const webhookHandler = async (req: NextRequest): Promise => { break } case 'invoice.paid': { - await handleInvoicePaid(event.data.object as Stripe.Invoice) + const invoice = event.data.object as Stripe.Invoice + if (invoice.subscription) { + if (!invoice.customer) { + logger.warn( + { invoiceId: invoice.id }, + 'Subscription invoice has no customer — skipping', + ) + } else { + const customerId = getStripeId(invoice.customer) + if (!(await isOrgCustomer(customerId))) { + await handleSubscriptionInvoicePaid({ invoice, logger }) + } + } + } else { + await handleInvoicePaid(invoice) + } break } case 'invoice.payment_failed': { const invoice = event.data.object as Stripe.Invoice + if (invoice.subscription) { + if (!invoice.customer) { + logger.warn( + { invoiceId: invoice.id }, + 'Subscription invoice has no customer — skipping', + ) + } else { + const customerId = getStripeId(invoice.customer) + if (!(await isOrgCustomer(customerId))) { + await handleSubscriptionInvoicePaymentFailed({ invoice, logger }) + } + } + } if ( invoice.metadata?.type === 'auto-topup' && invoice.billing_reason === 'manual' @@ -550,17 +623,17 @@ const webhookHandler = async (req: NextRequest): Promise => { break } default: - console.log(`Unhandled event type ${event.type}`) + logger.debug({ type: event.type }, 'Unhandled Stripe event type') } return NextResponse.json({ received: true }) } catch (err) { - const error = err as Error + const errorMessage = err instanceof Error ? err.message : String(err) logger.error( - { error: error.message, eventType: event.type }, + { error: errorMessage, eventType: event.type }, 'Error processing webhook', ) return NextResponse.json( - { error: { message: `Webhook handler error: ${error.message}` } }, + { error: { message: `Webhook handler error: ${errorMessage}` } }, { status: 500 }, ) } diff --git a/web/src/app/api/user/billing-portal/__tests__/billing-portal.test.ts b/web/src/app/api/user/billing-portal/__tests__/billing-portal.test.ts new file mode 100644 index 0000000000..0fa8744380 --- /dev/null +++ b/web/src/app/api/user/billing-portal/__tests__/billing-portal.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, mock, test } from 'bun:test' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +import { postBillingPortal } from '../_post' + +import type { CreateBillingPortalSessionFn, GetSessionFn, Session } from '../_post' + +const createMockLogger = (errorFn = mock(() => {})): Logger => ({ + error: errorFn, + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), +}) + +const createMockGetSession = (session: Session): GetSessionFn => mock(() => Promise.resolve(session)) + +const createMockCreateBillingPortalSession = ( + result: { url: string } | Error = { url: 'https://billing.stripe.com/session/test_123' } +): CreateBillingPortalSessionFn => { + if (result instanceof Error) { + return mock(() => Promise.reject(result)) + } + return mock(() => Promise.resolve(result)) +} + +describe('/api/user/billing-portal POST endpoint', () => { + const returnUrl = 'https://codebuff.com/profile' + + describe('Authentication', () => { + test('returns 401 when session is null', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('returns 401 when session.user is null', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ user: null }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('returns 401 when session.user.id is missing', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ user: { stripe_customer_id: 'cus_123' } as any }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + }) + + describe('Stripe customer validation', () => { + test('returns 400 when stripe_customer_id is null', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: null }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'No Stripe customer ID found' }) + }) + + test('returns 400 when stripe_customer_id is undefined', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123' }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'No Stripe customer ID found' }) + }) + }) + + describe('Successful portal session creation', () => { + test('returns 200 with portal URL on success', async () => { + const expectedUrl = 'https://billing.stripe.com/session/abc123' + const response = await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: 'cus_test_123' }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession({ url: expectedUrl }), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ url: expectedUrl }) + }) + + test('calls createBillingPortalSession with correct parameters', async () => { + const mockCreateSession = createMockCreateBillingPortalSession() + await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: 'cus_test_456' }, + }), + createBillingPortalSession: mockCreateSession, + logger: createMockLogger(), + returnUrl: 'https://example.com/return', + }) + + expect(mockCreateSession).toHaveBeenCalledTimes(1) + expect(mockCreateSession).toHaveBeenCalledWith({ + customer: 'cus_test_456', + return_url: 'https://example.com/return', + }) + }) + }) + + describe('Error handling', () => { + test('returns 500 when Stripe API throws an error', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: 'cus_test_123' }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession( + new Error('Stripe API error') + ), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body).toEqual({ error: 'Failed to create billing portal session' }) + }) + + test('logs error when Stripe API fails', async () => { + const mockLoggerError = mock(() => {}) + const testError = new Error('Stripe connection failed') + + await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: 'cus_test_123' }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(testError), + logger: createMockLogger(mockLoggerError), + returnUrl, + }) + + expect(mockLoggerError).toHaveBeenCalledTimes(1) + expect(mockLoggerError).toHaveBeenCalledWith( + { userId: 'user-123', error: testError }, + 'Failed to create billing portal session' + ) + }) + }) +}) diff --git a/web/src/app/api/user/billing-portal/_post.ts b/web/src/app/api/user/billing-portal/_post.ts new file mode 100644 index 0000000000..3dfb7ebad8 --- /dev/null +++ b/web/src/app/api/user/billing-portal/_post.ts @@ -0,0 +1,80 @@ +import { NextResponse } from 'next/server' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +export type SessionUser = { + id: string + stripe_customer_id?: string | null +} + +export type Session = { + user?: SessionUser | null +} | null + +export type GetSessionFn = () => Promise + +export type BillingPortalFlowData = { + type: 'subscription_update' + subscription_update: { + subscription: string + } +} + +export type CreateBillingPortalSessionParams = { + customer: string + return_url: string + flow_data?: BillingPortalFlowData +} + +export type CreateBillingPortalSessionFn = ( + params: CreateBillingPortalSessionParams +) => Promise<{ url: string }> + +export type PostBillingPortalParams = { + getSession: GetSessionFn + createBillingPortalSession: CreateBillingPortalSessionFn + logger: Logger + returnUrl: string + flowData?: BillingPortalFlowData +} + +export async function postBillingPortal(params: PostBillingPortalParams) { + const { getSession, createBillingPortalSession, logger, returnUrl, flowData } = params + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const stripeCustomerId = session.user.stripe_customer_id + if (!stripeCustomerId) { + return NextResponse.json( + { error: 'No Stripe customer ID found' }, + { status: 400 } + ) + } + + try { + const portalParams: CreateBillingPortalSessionParams = { + customer: stripeCustomerId, + return_url: returnUrl, + } + + if (flowData) { + portalParams.flow_data = flowData + } + + const portalSession = await createBillingPortalSession(portalParams) + + return NextResponse.json({ url: portalSession.url }) + } catch (error) { + logger.error( + { userId: session.user.id, error }, + 'Failed to create billing portal session' + ) + return NextResponse.json( + { error: 'Failed to create billing portal session' }, + { status: 500 } + ) + } +} diff --git a/web/src/app/api/user/billing-portal/route.ts b/web/src/app/api/user/billing-portal/route.ts new file mode 100644 index 0000000000..69091e4152 --- /dev/null +++ b/web/src/app/api/user/billing-portal/route.ts @@ -0,0 +1,38 @@ +import { env } from '@codebuff/internal/env' +import { stripeServer } from '@codebuff/internal/util/stripe' +import { getServerSession } from 'next-auth' + +import type { NextRequest } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +import { postBillingPortal } from './_post' + +import type { BillingPortalFlowData } from './_post' + +export async function POST(req: NextRequest) { + // Parse optional subscriptionId from request body for deep-linking to subscription update + let flowData: BillingPortalFlowData | undefined + const body = await req.json().catch(() => null) + if (body?.subscriptionId) { + flowData = { + type: 'subscription_update', + subscription_update: { + subscription: body.subscriptionId, + }, + } + } + + // Determine return URL - use provided returnUrl or default to /pricing + const returnUrl = body?.returnUrl || `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/pricing` + + return postBillingPortal({ + getSession: () => getServerSession(authOptions), + createBillingPortalSession: (params) => + stripeServer.billingPortal.sessions.create(params), + logger, + returnUrl, + flowData, + }) +} diff --git a/web/src/app/api/user/preferences/route.ts b/web/src/app/api/user/preferences/route.ts new file mode 100644 index 0000000000..9cee3b079d --- /dev/null +++ b/web/src/app/api/user/preferences/route.ts @@ -0,0 +1,121 @@ +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { z } from 'zod' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { extractApiKeyFromHeader, getUserIdFromSessionToken } from '@/util/auth' +import { logger } from '@/util/logger' + +import type { NextRequest } from 'next/server' + +const updatePreferencesSchema = z.object({ + fallbackToALaCarte: z.boolean().optional(), +}) + +export async function PATCH(request: NextRequest) { + let userId: string | undefined + + // First, try Bearer token authentication (for CLI clients) + const apiKey = extractApiKeyFromHeader(request) + if (apiKey) { + const userIdFromToken = await getUserIdFromSessionToken(apiKey) + if (userIdFromToken) { + userId = userIdFromToken + } + } + + // Fall back to NextAuth session authentication (for web clients) + if (!userId) { + const session = await getServerSession(authOptions) + userId = session?.user?.id + } + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const parsed = updatePreferencesSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.flatten() }, + { status: 400 }, + ) + } + + const { fallbackToALaCarte } = parsed.data + + // Build the update object with only provided fields + const updates: Partial<{ fallback_to_a_la_carte: boolean }> = {} + + if (fallbackToALaCarte !== undefined) { + updates.fallback_to_a_la_carte = fallbackToALaCarte + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: 'No updates provided' }, { status: 400 }) + } + + try { + await db + .update(schema.user) + .set(updates) + .where(eq(schema.user.id, userId)) + + logger.info({ userId, updates }, 'User preferences updated') + + return NextResponse.json({ success: true, ...parsed.data }) + } catch (error) { + logger.error({ error, userId }, 'Error updating user preferences') + return NextResponse.json( + { error: 'Failed to update preferences' }, + { status: 500 }, + ) + } +} + +export async function GET(request: NextRequest) { + let userId: string | undefined + + // First, try Bearer token authentication (for CLI clients) + const apiKey = extractApiKeyFromHeader(request) + if (apiKey) { + const userIdFromToken = await getUserIdFromSessionToken(apiKey) + if (userIdFromToken) { + userId = userIdFromToken + } + } + + // Fall back to NextAuth session authentication (for web clients) + if (!userId) { + const session = await getServerSession(authOptions) + userId = session?.user?.id + } + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const user = await db.query.user.findFirst({ + where: eq(schema.user.id, userId), + columns: { fallback_to_a_la_carte: true }, + }) + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + return NextResponse.json({ + fallbackToALaCarte: user.fallback_to_a_la_carte, + }) +} diff --git a/web/src/app/api/user/profile/route.ts b/web/src/app/api/user/profile/route.ts index ead229e70a..0738d96257 100644 --- a/web/src/app/api/user/profile/route.ts +++ b/web/src/app/api/user/profile/route.ts @@ -22,7 +22,6 @@ export async function GET() { where: eq(schema.user.id, session.user.id), columns: { handle: true, - referral_code: true, auto_topup_enabled: true, auto_topup_threshold: true, auto_topup_amount: true, @@ -39,7 +38,6 @@ export async function GET() { const response: Partial = { handle: user.handle, - referral_code: user.referral_code, auto_topup_enabled: user.auto_topup_enabled && !auto_topup_blocked_reason, auto_topup_threshold: user.auto_topup_threshold ?? 500, auto_topup_amount: user.auto_topup_amount ?? 2000, diff --git a/web/src/app/api/user/sessions/route.ts b/web/src/app/api/user/sessions/route.ts index 61edc6a499..ef4f6b70c7 100644 --- a/web/src/app/api/user/sessions/route.ts +++ b/web/src/app/api/user/sessions/route.ts @@ -1,7 +1,7 @@ import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' -import { cookies } from 'next/headers' import { eq, and, not } from 'drizzle-orm' +import { cookies } from 'next/headers' import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' diff --git a/web/src/app/api/user/subscription/route.ts b/web/src/app/api/user/subscription/route.ts new file mode 100644 index 0000000000..563714e99e --- /dev/null +++ b/web/src/app/api/user/subscription/route.ts @@ -0,0 +1,94 @@ +import { + checkRateLimit, + getActiveSubscription, + getSubscriptionLimits, +} from '@codebuff/billing' +import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { extractApiKeyFromHeader, getUserIdFromSessionToken } from '@/util/auth' +import { logger } from '@/util/logger' + +import type { + NoSubscriptionResponse, + ActiveSubscriptionResponse, +} from '@codebuff/common/types/subscription' +import type { NextRequest } from 'next/server' + +export async function GET(req: NextRequest) { + let userId: string | undefined + + // First, try Bearer token authentication (for CLI clients) + const apiKey = extractApiKeyFromHeader(req) + if (apiKey) { + const userIdFromToken = await getUserIdFromSessionToken(apiKey) + if (userIdFromToken) { + userId = userIdFromToken + } + } + + // Fall back to NextAuth session authentication (for web clients) + if (!userId) { + const session = await getServerSession(authOptions) + userId = session?.user?.id + } + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Fetch user preference for always use a-la-carte + const [subscription, userPrefs] = await Promise.all([ + getActiveSubscription({ userId, logger }), + db.query.user.findFirst({ + where: eq(schema.user.id, userId), + columns: { fallback_to_a_la_carte: true }, + }), + ]) + + const fallbackToALaCarte = userPrefs?.fallback_to_a_la_carte ?? false + + if (!subscription || !subscription.tier) { + const response: NoSubscriptionResponse = { hasSubscription: false, fallbackToALaCarte } + return NextResponse.json(response) + } + + const [rateLimit, limits] = await Promise.all([ + checkRateLimit({ userId, subscription, logger }), + getSubscriptionLimits({ userId, logger, tier: subscription.tier }), + ]) + + const response: ActiveSubscriptionResponse = { + hasSubscription: true, + displayName: SUBSCRIPTION_DISPLAY_NAME, + subscription: { + id: subscription.stripe_subscription_id, + status: subscription.status, + billingPeriodEnd: subscription.billing_period_end.toISOString(), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + canceledAt: subscription.canceled_at?.toISOString() ?? null, + tier: subscription.tier, + scheduledTier: subscription.scheduled_tier, + }, + rateLimit: { + limited: rateLimit.limited, + reason: rateLimit.reason, + canStartNewBlock: rateLimit.canStartNewBlock, + blockUsed: rateLimit.blockUsed, + blockLimit: rateLimit.blockLimit, + blockResetsAt: rateLimit.blockResetsAt?.toISOString(), + weeklyUsed: rateLimit.weeklyUsed, + weeklyLimit: rateLimit.weeklyLimit, + weeklyResetsAt: rateLimit.weeklyResetsAt.toISOString(), + weeklyPercentUsed: rateLimit.weeklyPercentUsed, + }, + limits, + fallbackToALaCarte, + } + return NextResponse.json(response) +} diff --git a/web/src/app/api/v1/_helpers.ts b/web/src/app/api/v1/_helpers.ts index ac705ac46d..839490c79d 100644 --- a/web/src/app/api/v1/_helpers.ts +++ b/web/src/app/api/v1/_helpers.ts @@ -1,17 +1,32 @@ -import { NextResponse } from 'next/server' -import type { ZodType } from 'zod' -import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { extractApiKeyFromHeader } from '@/util/auth' +import { NextResponse } from 'next/server' +import type { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { ConsumeCreditsWithFallbackFn, GetUserUsageDataFn, } from '@codebuff/common/types/contracts/billing' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' -import type { Logger, LoggerWithContextFn } from '@codebuff/common/types/contracts/logger' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' import type { NextRequest } from 'next/server' +import type { ZodType } from 'zod' + +import { extractApiKeyFromHeader } from '@/util/auth' + +/** + * User information returned from API key validation + */ +export interface UserInfo { + id: string + email: string + discord_id: string | null + stripe_customer_id?: string | null + banned?: boolean +} export type HandlerResult = | { ok: true; data: T } @@ -23,8 +38,10 @@ export const parseJsonBody = async (params: { logger: Logger trackEvent: TrackEventFn validationErrorEvent: AnalyticsEvent + userId?: string }): Promise> => { - const { req, schema, logger, trackEvent, validationErrorEvent } = params + const { req, schema, logger, trackEvent, validationErrorEvent, userId } = params + const trackingUserId = userId ?? 'unknown' let json: unknown try { @@ -32,7 +49,7 @@ export const parseJsonBody = async (params: { } catch { trackEvent({ event: validationErrorEvent, - userId: 'unknown', + userId: trackingUserId, properties: { error: 'Invalid JSON' }, logger, }) @@ -49,7 +66,7 @@ export const parseJsonBody = async (params: { if (!parsed.success) { trackEvent({ event: validationErrorEvent, - userId: 'unknown', + userId: trackingUserId, properties: { issues: parsed.error.format() }, logger, }) @@ -73,7 +90,7 @@ export const requireUserFromApiKey = async (params: { trackEvent: TrackEventFn authErrorEvent: AnalyticsEvent }): Promise< - HandlerResult<{ userId: string; userInfo: any; logger: Logger }> + HandlerResult<{ userId: string; userInfo: UserInfo; logger: Logger }> > => { const { req, @@ -134,6 +151,7 @@ export const checkCreditsAndCharge = async (params: { insufficientCreditsEvent: AnalyticsEvent getUserUsageData: GetUserUsageDataFn consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn + ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise }): Promise> => { const { userId, @@ -146,12 +164,30 @@ export const checkCreditsAndCharge = async (params: { insufficientCreditsEvent, getUserUsageData, consumeCreditsWithFallback, + ensureSubscriberBlockGrant, } = params + // Ensure subscription block grant exists before checking credits. + // This creates the grant (if eligible) so its credits appear in the balance below. + // When the function is provided, always include subscription credits in the balance: + // error/null results mean subscription grants have 0 balance, so including them is harmless. + const includeSubscriptionCredits = !!ensureSubscriberBlockGrant + if (ensureSubscriberBlockGrant) { + try { + await ensureSubscriberBlockGrant({ userId, logger }) + } catch (error) { + logger.error( + { error, userId }, + 'Error ensuring subscription block grant in credit check', + ) + // Fail open: proceed with subscription credits included in balance check + } + } + const { balance: { totalRemaining }, nextQuotaReset, - } = await getUserUsageData({ userId, logger }) + } = await getUserUsageData({ userId, logger, includeSubscriptionCredits }) if (totalRemaining <= 0 || totalRemaining < creditsToCharge) { trackEvent({ diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts new file mode 100644 index 0000000000..7762d151c1 --- /dev/null +++ b/web/src/app/api/v1/ads/_post.ts @@ -0,0 +1,242 @@ +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { getErrorObject } from '@codebuff/common/util/error' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { requireUserFromApiKey } from '../_helpers' + +import { createCarbonProvider } from '@/lib/ad-providers/carbon' +import { createGravityProvider } from '@/lib/ad-providers/gravity' +import { createZeroClickProvider } from '@/lib/ad-providers/zeroclick' + +import type { + AdProvider, + AdProviderId, + NormalizedAd, +} from '@/lib/ad-providers/types' +import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' +import type { NextRequest } from 'next/server' + +const messageSchema = z.object({ + role: z.string(), + content: z.string(), +}) + +const deviceSchema = z.object({ + os: z.enum(['macos', 'windows', 'linux']).optional(), + timezone: z.string().optional(), + locale: z.string().optional(), +}) + +const providerSchema = z + .enum(['gravity', 'carbon', 'zeroclick']) + .default('gravity') +const surfaceSchema = z.enum(['waiting_room']) + +const bodySchema = z.object({ + provider: providerSchema.optional(), + messages: z.array(messageSchema).optional().default([]), + sessionId: z.string().optional(), + device: deviceSchema.optional(), + surface: surfaceSchema.optional(), + /** Browser-like useragent passed through to providers that require it. */ + userAgent: z.string().optional(), +}) + +export type AdsEnv = { + GRAVITY_API_KEY: string + CARBON_ZONE_KEY?: string + ZEROCLICK_API_KEY?: string + CB_ENVIRONMENT: string +} + +function noAdsResponse(provider: AdProviderId) { + return NextResponse.json({ ads: [], provider }, { status: 200 }) +} + +export async function postAds(params: { + req: NextRequest + getUserInfoFromApiKey: GetUserInfoFromApiKeyFn + logger: Logger + loggerWithContext: LoggerWithContextFn + trackEvent: TrackEventFn + fetch: typeof globalThis.fetch + serverEnv: AdsEnv +}) { + const { + req, + getUserInfoFromApiKey, + loggerWithContext, + trackEvent, + fetch, + serverEnv, + } = params + + const authed = await requireUserFromApiKey({ + req, + getUserInfoFromApiKey, + logger: params.logger, + loggerWithContext, + trackEvent, + authErrorEvent: AnalyticsEvent.ADS_API_AUTH_ERROR, + }) + if (!authed.ok) return authed.response + + const { userId, userInfo, logger } = authed.data + + // Client IP comes in via the load balancer's X-Forwarded-For header. Every + // provider that targets or bills by IP (Gravity, Carbon, ...) needs this. + const forwardedFor = req.headers.get('x-forwarded-for') + const clientIp = forwardedFor + ? forwardedFor.split(',')[0].trim() + : (req.headers.get('x-real-ip') ?? undefined) + + let parsedBody: z.infer + try { + const json = await req.json() + const parsed = bodySchema.safeParse(json) + if (!parsed.success) { + logger.error({ parsed, json }, '[ads] Invalid request body') + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.format() }, + { status: 400 }, + ) + } + parsedBody = parsed.data + } catch { + return NextResponse.json( + { error: 'Invalid JSON in request body' }, + { status: 400 }, + ) + } + + const providerId: AdProviderId = parsedBody.provider ?? 'gravity' + const userAgent = + parsedBody.userAgent ?? req.headers.get('user-agent') ?? undefined + const requestUserAgent = req.headers.get('user-agent') ?? undefined + + // Pick a provider. If the requested one isn't configured, return no ad + // rather than failing — the client falls back to its cache / fallback UI. + let provider: AdProvider | null = null + if (providerId === 'carbon') { + if (!serverEnv.CARBON_ZONE_KEY) { + logger.warn('[ads] CARBON_ZONE_KEY not configured') + return noAdsResponse(providerId) + } + provider = createCarbonProvider({ zoneKey: serverEnv.CARBON_ZONE_KEY }) + } else if (providerId === 'zeroclick') { + if (!serverEnv.ZEROCLICK_API_KEY) { + logger.warn('[ads] ZEROCLICK_API_KEY not configured') + return noAdsResponse(providerId) + } + provider = createZeroClickProvider({ apiKey: serverEnv.ZEROCLICK_API_KEY }) + } else { + if (!serverEnv.GRAVITY_API_KEY) { + logger.warn('[ads] GRAVITY_API_KEY not configured') + return noAdsResponse(providerId) + } + provider = createGravityProvider({ apiKey: serverEnv.GRAVITY_API_KEY }) + } + + try { + const result = await provider.fetchAd({ + userId, + userEmail: userInfo.email ?? null, + sessionId: parsedBody.sessionId, + clientIp, + userAgent, + requestUserAgent, + device: parsedBody.device, + surface: parsedBody.surface, + messages: parsedBody.messages, + testMode: serverEnv.CB_ENVIRONMENT !== 'prod', + logger, + fetch, + }) + + if (!result) { + return noAdsResponse(provider.id) + } + + // Persist served ads so the impression endpoint can validate + fire the + // correct pixels. Any DB failure is logged but doesn't block serving. + try { + await Promise.all( + result.ads.map((ad) => + db + .insert(schema.adImpression) + .values({ + user_id: userId, + provider: provider.id, + ad_text: ad.adText, + title: ad.title, + cta: ad.cta, + url: ad.url, + favicon: ad.favicon, + click_url: ad.clickUrl, + imp_url: ad.impUrl, + extra_pixels: ad.extraPixels ?? null, + payout: ad.payout != null ? String(ad.payout) : null, + credits_granted: 0, + }) + .onConflictDoNothing(), + ), + ) + } catch (dbError) { + logger.warn( + { + userId, + provider: provider.id, + adCount: result.ads.length, + error: + dbError instanceof Error + ? { name: dbError.name, message: dbError.message } + : dbError, + }, + '[ads] Failed to persist ad_impression rows, serving anyway', + ) + } + + // Strip server-only fields before sending to the CLI. + const toClient = (ad: NormalizedAd) => { + const { payout: _p, extraPixels: _e, ...rest } = ad + return rest + } + + logger.info( + { provider: provider.id, adCount: result.ads.length }, + '[ads] Fetched ads', + ) + return NextResponse.json({ + ads: result.ads.map(toClient), + provider: provider.id, + }) + } catch (error) { + logger.error( + { + userId, + provider: providerId, + error: + error instanceof Error + ? { name: error.name, message: error.message } + : error, + }, + '[ads] Failed to fetch ad', + ) + return NextResponse.json( + { + ads: [], + provider: providerId, + error: getErrorObject(error), + }, + { status: 500 }, + ) + } +} diff --git a/web/src/app/api/v1/ads/impression/_post.ts b/web/src/app/api/v1/ads/impression/_post.ts new file mode 100644 index 0000000000..673e376082 --- /dev/null +++ b/web/src/app/api/v1/ads/impression/_post.ts @@ -0,0 +1,249 @@ +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { requireUserFromApiKey } from '../../_helpers' + +import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' +import type { NextRequest } from 'next/server' + +// Rate limiting: max impressions per user per hour +const MAX_IMPRESSIONS_PER_HOUR = 60 + +// In-memory rate limiter (resets on server restart, which is acceptable for this use case) +const impressionRateLimiter = new Map< + string, + { count: number; resetAt: number } +>() + +/** + * Clean up expired entries from the rate limiter to prevent memory leaks. + * Called periodically during rate limit checks. + */ +function cleanupExpiredEntries(): void { + const now = Date.now() + for (const [userId, limit] of impressionRateLimiter) { + if (now >= limit.resetAt) { + impressionRateLimiter.delete(userId) + } + } +} + +// Track last cleanup time to avoid cleaning up on every request +let lastCleanupTime = 0 +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000 // Clean up every 5 minutes + +/** + * Check and update rate limit for a user. + * Returns true if the request is allowed, false if rate limited. + */ +function checkRateLimit(userId: string): boolean { + const now = Date.now() + const hourMs = 60 * 60 * 1000 + + // Periodically clean up expired entries to prevent memory leak + if (now - lastCleanupTime > CLEANUP_INTERVAL_MS) { + cleanupExpiredEntries() + lastCleanupTime = now + } + + const userLimit = impressionRateLimiter.get(userId) + + if (!userLimit || now >= userLimit.resetAt) { + // Reset or initialize the counter + impressionRateLimiter.set(userId, { count: 1, resetAt: now + hourMs }) + return true + } + + if (userLimit.count >= MAX_IMPRESSIONS_PER_HOUR) { + return false + } + + userLimit.count++ + return true +} + +const bodySchema = z.object({ + impUrl: z.url(), + mode: z.string().optional(), +}) + +export async function postAdImpression(params: { + req: NextRequest + getUserInfoFromApiKey: GetUserInfoFromApiKeyFn + logger: Logger + loggerWithContext: LoggerWithContextFn + trackEvent: TrackEventFn + fetch: typeof globalThis.fetch +}) { + const { req, getUserInfoFromApiKey, loggerWithContext, trackEvent, fetch } = + params + const baseLogger = params.logger + + // Parse and validate request body + let impUrl: string + try { + const json = await req.json() + const parsed = bodySchema.safeParse(json) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.format() }, + { status: 400 }, + ) + } + impUrl = parsed.data.impUrl + } catch { + return NextResponse.json( + { error: 'Invalid JSON in request body' }, + { status: 400 }, + ) + } + + const authed = await requireUserFromApiKey({ + req, + getUserInfoFromApiKey, + logger: baseLogger, + loggerWithContext, + trackEvent, + authErrorEvent: AnalyticsEvent.USAGE_API_AUTH_ERROR, + }) + if (!authed.ok) return authed.response + + const { userId, logger } = authed.data + + // Look up the ad from our database using the impUrl + // This ensures we use server-side trusted data, not client-provided data + const adRecord = await db.query.adImpression.findFirst({ + where: eq(schema.adImpression.imp_url, impUrl), + }) + + if (!adRecord) { + logger.warn( + { userId, impUrl }, + '[ads] Ad impression not found in database - was it served through our API?', + ) + return NextResponse.json( + { success: false, error: 'Ad not found', creditsGranted: 0 }, + { status: 404 }, + ) + } + + // Verify the ad belongs to this user + if (adRecord.user_id !== userId) { + logger.warn( + { userId, adUserId: adRecord.user_id, impUrl }, + '[ads] User attempting to claim impression for ad served to different user', + ) + return NextResponse.json( + { success: false, error: 'Ad not found', creditsGranted: 0 }, + { status: 404 }, + ) + } + + // Check if impression was already fired (before rate limiting to not penalize duplicates) + if (adRecord.impression_fired_at) { + logger.debug( + { userId, impUrl }, + '[ads] Impression already recorded for this ad', + ) + return NextResponse.json({ + success: true, + creditsGranted: adRecord.credits_granted, + alreadyRecorded: true, + }) + } + + // Check rate limit (after duplicate check so duplicates don't consume quota) + if (!checkRateLimit(userId)) { + logger.warn( + { userId, maxPerHour: MAX_IMPRESSIONS_PER_HOUR }, + '[ads] Rate limited ad impression request', + ) + return NextResponse.json( + { success: false, error: 'Rate limited', creditsGranted: 0 }, + { status: 429 }, + ) + } + + // Fire the primary impression pixel plus any provider-specific extra + // tracking pixels (Carbon returns these via the `pixel` field). ZeroClick + // impressions must be reported from the client device, so the CLI handles + // that directly and this endpoint only records our local state. + if (adRecord.provider !== 'zeroclick') { + const now = Math.floor(Date.now() / 1000).toString() + const extraPixels = (adRecord.extra_pixels ?? []).map((p) => + p.replaceAll('[timestamp]', now), + ) + const pixelUrls = [impUrl, ...extraPixels] + const requestUserAgent = req.headers.get('user-agent') ?? undefined + + await Promise.all( + pixelUrls.map(async (pixelUrl) => { + try { + await fetch(pixelUrl, { + ...(requestUserAgent + ? { headers: { 'User-Agent': requestUserAgent } } + : {}), + }) + } catch (error) { + logger.warn( + { + pixelUrl, + error: + error instanceof Error + ? { name: error.name, message: error.message } + : error, + }, + '[ads] Failed to fire impression pixel', + ) + } + }), + ) + logger.info( + { userId, provider: adRecord.provider, pixelCount: pixelUrls.length }, + '[ads] Fired impression pixels', + ) + } + + // No credits granted for ad impressions + const creditsGranted = 0 + + // Update the ad_impression record with impression details (for ALL modes) + try { + await db + .update(schema.adImpression) + .set({ + impression_fired_at: new Date(), + credits_granted: 0, + grant_operation_id: null, + }) + .where(eq(schema.adImpression.id, adRecord.id)) + + logger.info({ userId, impUrl }, '[ads] Updated ad impression record') + } catch (error) { + logger.error( + { + userId, + impUrl, + error: + error instanceof Error + ? { name: error.name, message: error.message } + : error, + }, + '[ads] Failed to update ad impression record', + ) + } + + return NextResponse.json({ + success: true, + creditsGranted, + }) +} diff --git a/web/src/app/api/v1/ads/impression/route.ts b/web/src/app/api/v1/ads/impression/route.ts new file mode 100644 index 0000000000..1212ace244 --- /dev/null +++ b/web/src/app/api/v1/ads/impression/route.ts @@ -0,0 +1,19 @@ +import { trackEvent } from '@codebuff/common/analytics' + +import { postAdImpression } from './_post' + +import type { NextRequest } from 'next/server' + +import { getUserInfoFromApiKey } from '@/db/user' +import { logger, loggerWithContext } from '@/util/logger' + +export async function POST(req: NextRequest) { + return postAdImpression({ + req, + getUserInfoFromApiKey, + logger, + loggerWithContext, + trackEvent, + fetch, + }) +} diff --git a/web/src/app/api/v1/ads/route.ts b/web/src/app/api/v1/ads/route.ts new file mode 100644 index 0000000000..32c86d873f --- /dev/null +++ b/web/src/app/api/v1/ads/route.ts @@ -0,0 +1,26 @@ +import { trackEvent } from '@codebuff/common/analytics' +import { env } from '@codebuff/internal/env' + +import { postAds } from './_post' + +import type { NextRequest } from 'next/server' + +import { getUserInfoFromApiKey } from '@/db/user' +import { logger, loggerWithContext } from '@/util/logger' + +export async function POST(req: NextRequest) { + return postAds({ + req, + getUserInfoFromApiKey, + logger, + loggerWithContext, + trackEvent, + fetch, + serverEnv: { + GRAVITY_API_KEY: env.GRAVITY_API_KEY, + CARBON_ZONE_KEY: env.CARBON_ZONE_KEY, + ZEROCLICK_API_KEY: env.ZEROCLICK_API_KEY, + CB_ENVIRONMENT: env.NEXT_PUBLIC_CB_ENVIRONMENT, + }, + }) +} diff --git a/web/src/app/api/v1/agent-runs/[runId]/steps/__tests__/steps.test.ts b/web/src/app/api/v1/agent-runs/[runId]/steps/__tests__/steps.test.ts index 0e9c02293b..33b4136a3b 100644 --- a/web/src/app/api/v1/agent-runs/[runId]/steps/__tests__/steps.test.ts +++ b/web/src/app/api/v1/agent-runs/[runId]/steps/__tests__/steps.test.ts @@ -11,22 +11,44 @@ import type { LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' + + +interface MockDbResult { + user_id: string +} + +// Mock database interface for testing +interface MockDb { + select: () => { + from: () => { + where: () => { + limit: () => MockDbResult[] + } + } + } + insert: () => { + values: () => Promise + } +} + describe('agentRunsStepsPost', () => { let mockGetUserInfoFromApiKey: GetUserInfoFromApiKeyFn let mockLogger: Logger let mockLoggerWithContext: LoggerWithContextFn let mockTrackEvent: TrackEventFn + // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockDb: any beforeEach(() => { - mockGetUserInfoFromApiKey = async ({ apiKey, fields }) => { + // Mock getUserInfoFromApiKey with proper typing + mockGetUserInfoFromApiKey = (async ({ apiKey, fields }) => { if (apiKey === 'valid-key') { return Object.fromEntries( fields.map((field) => [ field, field === 'id' ? 'user-123' : undefined, ]), - ) as any + ) } if (apiKey === 'test-key') { return Object.fromEntries( @@ -34,10 +56,10 @@ describe('agentRunsStepsPost', () => { field, field === 'id' ? TEST_USER_ID : undefined, ]), - ) as any + ) } return null - } + }) as GetUserInfoFromApiKeyFn mockLogger = { error: () => {}, @@ -174,7 +196,7 @@ describe('agentRunsStepsPost', () => { }), }), }), - } as any + } const req = new NextRequest( 'http://localhost/api/v1/agent-runs/run-123/steps', @@ -210,7 +232,7 @@ describe('agentRunsStepsPost', () => { }), }), }), - } as any + } const req = new NextRequest( 'http://localhost/api/v1/agent-runs/run-123/steps', @@ -308,7 +330,7 @@ describe('agentRunsStepsPost', () => { throw new Error('DB error') }, }), - } as any + } const req = new NextRequest( 'http://localhost/api/v1/agent-runs/run-123/steps', diff --git a/web/src/app/api/v1/agent-runs/__tests__/agent-runs.test.ts b/web/src/app/api/v1/agent-runs/__tests__/agent-runs.test.ts index 47dae5c0b9..8f459bf198 100644 --- a/web/src/app/api/v1/agent-runs/__tests__/agent-runs.test.ts +++ b/web/src/app/api/v1/agent-runs/__tests__/agent-runs.test.ts @@ -6,20 +6,14 @@ import { NextRequest } from 'next/server' import { postAgentRuns } from '../_post' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' -import type { - GetUserInfoFromApiKeyFn, - GetUserInfoFromApiKeyOutput, -} from '@codebuff/common/types/contracts/database' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' describe('/api/v1/agent-runs POST endpoint', () => { - const mockUserData: Record< - string, - NonNullable>> - > = { + const mockUserData: Record = { 'test-api-key-123': { id: 'user-123', }, @@ -38,7 +32,7 @@ describe('/api/v1/agent-runs POST endpoint', () => { if (!userData) { return null } - return { id: userData.id } as any + return { id: userData.id } as Awaited> } let mockLogger: Logger diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 47fb9303c1..80ca4f02d1 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -1,8 +1,18 @@ -import { env } from '@codebuff/internal/env' import { afterEach, beforeEach, describe, expect, mock, it } from 'bun:test' import { NextRequest } from 'next/server' -import { formatQuotaResetCountdown, postChatCompletions } from '../_post' +import { TEST_USER_ID } from '@codebuff/common/constants/paths' +import { + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_GEMINI_PRO_MODEL_ID, + FREEBUFF_GLM_MODEL_ID, + isFreebuffDeploymentHours, +} from '@codebuff/common/constants/freebuff-models' +import { openCodeZenModels } from '@codebuff/common/constants/model-config' +import { postChatCompletions } from '../_post' +import { resetFreeModeRateLimits } from '../free-mode-rate-limiter' +import { getFreeModeCountryAccess } from '@/server/free-mode-country' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { InsertMessageBigqueryFn } from '@codebuff/common/types/contracts/bigquery' @@ -10,20 +20,18 @@ import type { GetUserUsageDataFn } from '@codebuff/common/types/contracts/billin import type { GetAgentRunFromIdFn, GetUserInfoFromApiKeyFn, - GetUserInfoFromApiKeyOutput, } from '@codebuff/common/types/contracts/database' import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' +import type { BlockGrantResult } from '@codebuff/billing/subscription' +import type { GetUserPreferencesFn } from '../_post' describe('/api/v1/chat/completions POST endpoint', () => { - const mockUserData: Record< - string, - { id: string; banned: boolean } - > = { + const mockUserData: Record = { 'test-api-key-123': { - id: 'user-123', + id: TEST_USER_ID, banned: false, }, 'test-api-key-no-credits': { @@ -34,6 +42,22 @@ describe('/api/v1/chat/completions POST endpoint', () => { id: 'banned-user-id', banned: true, }, + 'test-api-key-new-free': { + id: 'user-new-free', + banned: false, + }, + 'test-api-key-new-free-gemini': { + id: 'user-new-free-gemini', + banned: false, + }, + 'test-api-key-reviewer-rate-limit': { + id: 'user-reviewer-rate-limit', + banned: false, + }, + 'test-api-key-gemini-rate-limit': { + id: 'user-gemini-rate-limit', + banned: false, + }, } const mockGetUserInfoFromApiKey: GetUserInfoFromApiKeyFn = async ({ @@ -43,7 +67,10 @@ describe('/api/v1/chat/completions POST endpoint', () => { if (!userData) { return null } - return { id: userData.id, banned: userData.banned } as any + return { + id: userData.id, + banned: userData.banned, + } as Awaited> } let mockLogger: Logger @@ -55,7 +82,35 @@ describe('/api/v1/chat/completions POST endpoint', () => { let mockInsertMessageBigquery: InsertMessageBigqueryFn let nextQuotaReset: string + // Bypasses the freebuff waiting-room gate in tests that exercise free-mode + // flow without seeding a session. Matches the real return for the disabled + // path so downstream logic proceeds normally. + const mockCheckSessionAdmissibleAllow = async () => + ({ ok: true, reason: 'disabled' }) as const + const mockResolveFreeModeCountryAccess = async ( + _userId: string, + req: Parameters[0], + options: Parameters[1], + ) => getFreeModeCountryAccess(req, options) + const postChatCompletionsForTest = ( + params: Parameters[0], + ) => + postChatCompletions({ + resolveFreeModeCountryAccess: mockResolveFreeModeCountryAccess, + ...params, + }) + + const allowedFreeModeHeaders = (apiKey: string) => ({ + Authorization: `Bearer ${apiKey}`, + 'cf-ipcountry': 'US', + 'cf-connecting-ip': '203.0.113.10', + }) + // Some provider-path tests can cross Bun's 5s default on loaded CI runners + // when the mocked network path waits behind unrelated DB reconnect timers. + const FETCH_PATH_TEST_TIMEOUT_MS = 15000 + beforeEach(() => { + resetFreeModeRateLimits() nextQuotaReset = new Date( Date.now() + 3 * 24 * 60 * 60 * 1000 + 5 * 60 * 1000, ).toISOString() @@ -80,6 +135,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { totalDebt: 0, netBalance: 0, breakdown: {}, + principals: {}, }, nextQuotaReset, } @@ -91,6 +147,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { totalDebt: 0, netBalance: 100, breakdown: {}, + principals: {}, }, nextQuotaReset, } @@ -100,12 +157,64 @@ describe('/api/v1/chat/completions POST endpoint', () => { if (runId === 'run-123') { return { agent_id: 'agent-123', + ancestor_run_ids: [], + status: 'running', + } + } + if (runId === 'run-free') { + return { + // Real free-mode allowlisted agent (see FREE_MODE_AGENT_MODELS). + agent_id: 'base2-free', + ancestor_run_ids: [], + status: 'running', + } + } + if (runId === 'run-free-deepseek') { + return { + agent_id: 'base2-free-deepseek', + ancestor_run_ids: [], + status: 'running', + } + } + if (runId === 'run-free-deepseek-flash') { + return { + agent_id: 'base2-free-deepseek-flash', + ancestor_run_ids: [], + status: 'running', + } + } + if (runId === 'run-reviewer-direct') { + return { + agent_id: 'code-reviewer-minimax', + ancestor_run_ids: [], + status: 'running', + } + } + if (runId === 'run-reviewer-child') { + return { + agent_id: 'code-reviewer-minimax', + ancestor_run_ids: ['run-free'], + status: 'running', + } + } + if (runId === 'run-gemini-thinker-child') { + return { + agent_id: 'thinker-with-files-gemini', + ancestor_run_ids: ['run-free'], + status: 'running', + } + } + if (runId === 'run-browser-use-child') { + return { + agent_id: 'browser-use', + ancestor_run_ids: ['run-free'], status: 'running', } } if (runId === 'run-completed') { return { agent_id: 'agent-123', + ancestor_run_ids: [], status: 'completed', } } @@ -114,6 +223,10 @@ describe('/api/v1/chat/completions POST endpoint', () => { // Mock global fetch to return OpenRouter-like responses mockFetch = (async (url: any, options: any) => { + if (String(url).startsWith('https://api.ipinfo.io/lookup/')) { + return Response.json({}) + } + if (!options?.body) { throw new Error('Missing request body') } @@ -170,7 +283,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) } - }) as any + }) as typeof globalThis.fetch mockInsertMessageBigquery = mock(async () => true) }) @@ -189,7 +302,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -199,6 +312,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: globalThis.fetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, }) expect(response.status).toBe(401) @@ -216,7 +330,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -226,6 +340,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, }) expect(response.status).toBe(401) @@ -245,7 +360,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -255,6 +370,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, }) expect(response.status).toBe(400) @@ -272,7 +388,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -282,6 +398,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, }) expect(response.status).toBe(400) @@ -302,7 +419,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -312,6 +429,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, }) expect(response.status).toBe(400) @@ -334,7 +452,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -344,6 +462,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, }) expect(response.status).toBe(400) @@ -368,7 +487,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -378,13 +497,14 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, }) expect(response.status).toBe(403) const body = await response.json() expect(body.error).toBe('account_suspended') - expect(body.message).toContain('Your account has been suspended due to billing issues') - expect(body.message).toContain('to resolve this') + expect(body.message).toContain('Your account has been suspended') + expect(body.message).toContain('if you did not expect this') }) }) @@ -402,7 +522,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -412,35 +532,208 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, }) expect(response.status).toBe(402) const body = await response.json() - const expectedResetCountdown = formatQuotaResetCountdown(nextQuotaReset) - expect(body.message).toContain(expectedResetCountdown) + expect(body.message).toContain('Out of credits. Please add credits at') + expect(body.message).toContain('/usage.') expect(body.message).not.toContain(nextQuotaReset) }) - }) - describe('Successful responses', () => { - it('returns stream with correct headers', async () => { + it( + 'lets a new account with no paid relationship through for non-free mode', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-new-free' }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(200) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it( + 'classifies country access before the active freebuff session gate', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { + Authorization: 'Bearer test-api-key-new-free', + 'cf-ipcountry': 'T1', + 'x-forwarded-for': '8.8.8.8', + }, + body: JSON.stringify({ + model: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + stream: false, + codebuff_metadata: { + run_id: 'run-free-deepseek-flash', + client_id: 'test-client-id-123', + cost_mode: 'free', + freebuff_instance_id: 'active-instance-123', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: async (params) => { + expect(params.accessTier).toBe('limited') + return { ok: true, reason: 'active', remainingMs: 60_000 } as const + }, + }) + + expect(response.status).toBe(200) + expect(mockGetUserUsageData).not.toHaveBeenCalled() + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it( + 'lets a BYOK free-tier new account through the paid-plan gate', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { + Authorization: 'Bearer test-api-key-new-free', + 'x-openrouter-api-key': 'sk-or-byok-test', + }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(200) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it( + 'lets a freebuff/free-mode request through even for a brand-new unpaid account', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free'), + body: JSON.stringify({ + model: 'minimax/minimax-m2.7', + stream: false, + codebuff_metadata: { + run_id: 'run-free', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(200) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it('limits unknown-location free-mode requests to DeepSeek Flash', async () => { + const checkSessionAdmissible = mock(async () => { + throw new Error( + 'limited model enforcement should run before session gate', + ) + }) + // Use a TEST-NET-1 IP (RFC 5737) that geoip-lite cannot resolve, with + // no cf-ipcountry header. This avoids the dev-only localhost bypass + // (which kicks in when there is no cf-ipcountry AND no/loopback IP). const req = new NextRequest( 'http://localhost:3000/api/v1/chat/completions', { method: 'POST', - headers: { Authorization: 'Bearer test-api-key-123' }, + headers: { + Authorization: 'Bearer test-api-key-new-free', + 'cf-connecting-ip': '192.0.2.1', + }, body: JSON.stringify({ - stream: true, + model: 'minimax/minimax-m2.7', + stream: false, codebuff_metadata: { - run_id: 'run-123', + run_id: 'run-free', client_id: 'test-client-id-123', - client_request_id: 'test-client-session-id-123', + cost_mode: 'free', }, }), }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -450,36 +743,43 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible, }) - if (response.status !== 200) { - const errorBody = await response.json() - console.log('Error response:', errorBody) - } - expect(response.status).toBe(200) - expect(response.headers.get('Content-Type')).toBe('text/event-stream') - expect(response.headers.get('Cache-Control')).toBe('no-cache') - expect(response.headers.get('Connection')).toBe('keep-alive') + expect(response.status).toBe(409) + const body = await response.json() + expect(body.error).toBe('session_model_mismatch') + expect(checkSessionAdmissible).toHaveBeenCalledTimes(0) }) - it('returns JSON response for non-streaming requests', async () => { + it('classifies anonymized Cloudflare country codes as limited access', async () => { + const checkSessionAdmissible = mock(async () => { + throw new Error( + 'limited model enforcement should run before session gate', + ) + }) const req = new NextRequest( 'http://localhost:3000/api/v1/chat/completions', { method: 'POST', - headers: { Authorization: 'Bearer test-api-key-123' }, + headers: { + Authorization: 'Bearer test-api-key-new-free', + 'cf-ipcountry': 'T1', + 'x-forwarded-for': '8.8.8.8', + }, body: JSON.stringify({ + model: 'minimax/minimax-m2.7', stream: false, codebuff_metadata: { - run_id: 'run-123', + run_id: 'run-free', client_id: 'test-client-id-123', - client_request_id: 'test-client-session-id-123', + cost_mode: 'free', }, }), }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -489,13 +789,1332 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible, }) - expect(response.status).toBe(200) - expect(response.headers.get('Content-Type')).toContain('application/json') + expect(response.status).toBe(409) + const body = await response.json() + expect(body.error).toBe('session_model_mismatch') + expect(checkSessionAdmissible).toHaveBeenCalledTimes(0) + }) + + it( + 'lets old freebuff clients keep using GLM 5.1 through Fireworks availability rules', + async () => { + const fetchedBodies: Record[] = [] + const fetchViaFireworks = mock( + async (_url: string | URL | Request, init?: RequestInit) => { + fetchedBodies.push(JSON.parse(init?.body as string)) + return new Response( + JSON.stringify({ + id: 'test-id', + model: 'accounts/fireworks/models/glm-5p1', + choices: [{ message: { content: 'test response' } }], + usage: { + prompt_tokens: 10, + completion_tokens: 20, + total_tokens: 30, + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + }, + ) as unknown as typeof globalThis.fetch + + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free'), + body: JSON.stringify({ + model: FREEBUFF_GLM_MODEL_ID, + stream: false, + codebuff_metadata: { + run_id: 'run-free', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: fetchViaFireworks, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + const body = await response.json() + if (isFreebuffDeploymentHours()) { + expect(response.status).toBe(200) + expect(fetchedBodies).toHaveLength(1) + expect(fetchedBodies[0].model).toBe( + 'accounts/fireworks/models/glm-5p1', + ) + expect(body.model).toBe(FREEBUFF_GLM_MODEL_ID) + expect(body.provider).toBe('Fireworks') + } else { + expect(response.status).toBe(503) + expect(fetchedBodies).toHaveLength(0) + expect(body.error.code).toBe('DEPLOYMENT_OUTSIDE_HOURS') + } + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it.each([ + { + codebuffModel: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + upstreamModel: 'deepseek-v4-pro', + runId: 'run-free-deepseek', + }, + { + codebuffModel: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + upstreamModel: 'deepseek-v4-flash', + runId: 'run-free-deepseek-flash', + }, + ])( + 'lets $codebuffModel use the direct DeepSeek provider', + async ({ codebuffModel, upstreamModel, runId }) => { + const fetchedBodies: Record[] = [] + const fetchedUrls: string[] = [] + const fetchViaDeepSeek = mock( + async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).startsWith('https://api.ipinfo.io/lookup/')) { + return Response.json({}) + } + + fetchedUrls.push(String(url)) + fetchedBodies.push(JSON.parse(init?.body as string)) + return new Response( + JSON.stringify({ + id: 'test-id', + model: upstreamModel, + choices: [{ message: { content: 'test response' } }], + usage: { + prompt_tokens: 10, + prompt_cache_hit_tokens: 4, + completion_tokens: 20, + total_tokens: 30, + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + }, + ) as unknown as typeof globalThis.fetch + + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free'), + body: JSON.stringify({ + model: codebuffModel, + stream: false, + codebuff_metadata: { + run_id: runId, + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: fetchViaDeepSeek, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + const body = await response.json() + expect(response.status).toBe(200) + expect(fetchedUrls[0]).toBe('https://api.deepseek.com/chat/completions') + expect(fetchedBodies[0].model).toBe(upstreamModel) + expect(body.model).toBe(codebuffModel) + expect(body.provider).toBe('DeepSeek') + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it( + 'routes OpenCode Zen models and existing Kimi alias to the direct OpenCode Zen provider', + async () => { + const testCases = [ + { + codebuffModel: 'moonshotai/kimi-k2.6', + upstreamModel: 'kimi-k2.6', + }, + { + codebuffModel: openCodeZenModels.opencode_kimi_k2_6, + upstreamModel: 'kimi-k2.6', + }, + { + codebuffModel: openCodeZenModels.opencode_minimax_m2_7, + upstreamModel: 'minimax-m2.7', + }, + ] + + for (const { codebuffModel, upstreamModel } of testCases) { + const fetchedBodies: Record[] = [] + const fetchedUrls: string[] = [] + const fetchViaOpenCodeZen = mock( + async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).startsWith('https://api.ipinfo.io/lookup/')) { + return Response.json({}) + } + + fetchedUrls.push(String(url)) + fetchedBodies.push(JSON.parse(init?.body as string)) + return new Response( + JSON.stringify({ + id: 'test-id', + model: upstreamModel, + choices: [{ message: { content: 'test response' } }], + usage: { + prompt_tokens: 10, + prompt_tokens_details: { cached_tokens: 4 }, + completion_tokens: 20, + total_tokens: 30, + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + }, + ) as unknown as typeof globalThis.fetch + + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { + Authorization: 'Bearer test-api-key-123', + }, + body: JSON.stringify({ + model: codebuffModel, + messages: [ + { + role: 'system', + content: 'system prompt', + cache_control: { type: 'ephemeral' }, + }, + { + role: 'user', + content: [ + { + type: 'text', + text: 'hello', + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ], + tools: [ + { + id: 'tool_1', + type: 'function', + function: { + name: 'read_files', + parameters: { type: 'object' }, + }, + }, + ], + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: fetchViaOpenCodeZen, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + }) + + const body = await response.json() + expect(response.status).toBe(200) + expect(fetchedUrls[0]).toBe( + 'https://opencode.ai/zen/v1/chat/completions', + ) + expect(fetchedBodies[0].model).toBe(upstreamModel) + expect(body.model).toBe(codebuffModel) + expect(body.provider).toBe('OpenCode Zen') + } + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it( + 'rejects unsupported OpenCode Zen-prefixed models without calling the provider', + async () => { + const fetchViaOpenCodeZen = mock( + async (url: string | URL | Request) => { + if (String(url).startsWith('https://api.ipinfo.io/lookup/')) { + return Response.json({}) + } + + throw new Error('OpenCode Zen provider should not be called') + }, + ) as unknown as typeof globalThis.fetch + + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { + Authorization: 'Bearer test-api-key-123', + }, + body: JSON.stringify({ + model: 'opencode/qwen3-coder', + messages: [{ role: 'user', content: 'hello' }], + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: fetchViaOpenCodeZen, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + }) + + const body = await response.json() + expect(response.status).toBe(400) + expect(body.error.code).toBe('unsupported_model') + expect(body.error.message).toContain('opencode/qwen3-coder') + expect(fetchViaOpenCodeZen).toHaveBeenCalledTimes(0) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it('rejects the DeepSeek V4 free agent when it requests another free model', async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free'), + body: JSON.stringify({ + model: FREEBUFF_GEMINI_PRO_MODEL_ID, + stream: false, + codebuff_metadata: { + run_id: 'run-free-deepseek', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + const body = await response.json() + expect(response.status).toBe(403) + expect(body.error).toBe('free_mode_invalid_agent_model') + }) + + it('rejects Gemini 3.1 Pro as a root freebuff model', async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free-gemini'), + body: JSON.stringify({ + model: FREEBUFF_GEMINI_PRO_MODEL_ID, + stream: false, + codebuff_metadata: { + run_id: 'run-free', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + const body = await response.json() + expect(response.status).toBe(403) + expect(body.error).toBe('free_mode_invalid_agent_model') + }) + + it( + 'allows browser-use as a free-mode subagent under a freebuff root', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free-gemini'), + body: JSON.stringify({ + model: 'google/gemini-3.1-flash-lite-preview', + stream: false, + codebuff_metadata: { + run_id: 'run-browser-use-child', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(200) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it('rejects standalone free-mode reviewer runs even when the model is allowlisted', async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free-gemini'), + body: JSON.stringify({ + model: 'minimax/minimax-m2.7', + stream: false, + codebuff_metadata: { + run_id: 'run-reviewer-direct', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toBe('free_mode_invalid_agent_hierarchy') + }) + + it('rejects the Gemini thinker subagent when the session gate rejects it', async () => { + const response = await postChatCompletionsForTest({ + req: new NextRequest('http://localhost:3000/api/v1/chat/completions', { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free-gemini'), + body: JSON.stringify({ + model: FREEBUFF_GEMINI_PRO_MODEL_ID, + stream: false, + codebuff_metadata: { + run_id: 'run-gemini-thinker-child', + client_id: 'test-client-id-123', + cost_mode: 'free', + freebuff_instance_id: 'inst-123', + }, + }), + }), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: async (params) => { + expect(params.requireActiveSession).toBe(true) + expect(params.requestedModel).toBe(FREEBUFF_GEMINI_PRO_MODEL_ID) + expect(params.claimedInstanceId).toBe('inst-123') + return { + ok: false, + code: 'session_model_mismatch', + message: 'This session is bound to minimax/minimax-m2.7.', + } + }, + }) + + expect(response.status).toBe(409) const body = await response.json() - expect(body.id).toBe('test-id') - expect(body.choices[0].message.content).toBe('test response') + expect(body.error).toBe('session_model_mismatch') + }) + + it( + 'requires an active session check for the Gemini thinker subagent', + async () => { + const checkFreeModeRateLimitForTest = mock((userId: string) => { + expect(userId).toBe('user-new-free-gemini') + return { limited: false as const } + }) + + const response = await postChatCompletionsForTest({ + req: new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free-gemini'), + body: JSON.stringify({ + model: FREEBUFF_GEMINI_PRO_MODEL_ID, + stream: false, + codebuff_metadata: { + run_id: 'run-gemini-thinker-child', + client_id: 'test-client-id-123', + cost_mode: 'free', + freebuff_instance_id: 'inst-123', + }, + }), + }, + ), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: async (params) => { + expect(params.requireActiveSession).toBe(true) + expect(params.requestedModel).toBe(FREEBUFF_GEMINI_PRO_MODEL_ID) + expect(params.claimedInstanceId).toBe('inst-123') + return { ok: true, reason: 'active', remainingMs: 60_000 } + }, + checkFreeModeRateLimit: checkFreeModeRateLimitForTest, + }) + + expect(response.status).toBe(200) + expect(checkFreeModeRateLimitForTest).toHaveBeenCalledTimes(1) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it( + 'counts child Gemini thinker requests toward the free-mode request limit', + async () => { + let rateLimitChecks = 0 + const checkFreeModeRateLimitForTest = mock((userId: string) => { + expect(userId).toBe('user-gemini-rate-limit') + rateLimitChecks += 1 + return rateLimitChecks === 1 + ? { limited: false as const } + : { + limited: true as const, + windowName: '1 second', + retryAfterMs: 1_000, + } + }) + + const createRequest = () => + new NextRequest('http://localhost:3000/api/v1/chat/completions', { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-gemini-rate-limit'), + body: JSON.stringify({ + model: FREEBUFF_GEMINI_PRO_MODEL_ID, + stream: false, + codebuff_metadata: { + run_id: 'run-gemini-thinker-child', + client_id: 'test-client-id-123', + cost_mode: 'free', + freebuff_instance_id: 'inst-123', + }, + }), + }) + + const createPostParams = () => ({ + req: createRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + checkFreeModeRateLimit: checkFreeModeRateLimitForTest, + }) + + const firstResponse = + await postChatCompletionsForTest(createPostParams()) + const limitedResponse = + await postChatCompletionsForTest(createPostParams()) + + expect(firstResponse.status).toBe(200) + expect(limitedResponse.status).toBe(429) + const body = await limitedResponse.json() + expect(body.error).toBe('free_mode_rate_limited') + expect(checkFreeModeRateLimitForTest).toHaveBeenCalledTimes(2) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it( + 'skips credit check when in FREE mode even with 0 credits', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-no-credits'), + body: JSON.stringify({ + model: 'minimax/minimax-m2.7', + stream: false, + codebuff_metadata: { + run_id: 'run-free', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(200) + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it('rejects free-mode requests using a non-allowlisted model (e.g. Opus)', async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free'), + body: JSON.stringify({ + // Expensive model the attacker wants for free. + model: 'anthropic/claude-4.7-opus', + stream: true, + codebuff_metadata: { + run_id: 'run-free', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + }) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toBe('free_mode_invalid_agent_model') + }) + + it('rejects free-mode requests with an allowlisted agent but a model outside its allowed set', async () => { + // agent=base2-free is allowlisted, but Opus is not in its allowed + // model set. This is the spoofing variant of the attack where the + // caller picks a real free-mode agentId to try to sneak past the gate. + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free'), + body: JSON.stringify({ + model: 'anthropic/claude-4.7-opus', + stream: true, + codebuff_metadata: { + run_id: 'run-free', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + }) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toBe('free_mode_invalid_agent_model') + }) + + it('rejects free-mode requests where agentId is not in the allowlist at all', async () => { + // run-123 points to agent-123, which is not a free-mode agent. + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free'), + body: JSON.stringify({ + model: 'minimax/minimax-m2.7', + stream: true, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toBe('free_mode_invalid_agent_model') + }) + }) + + describe('Successful responses', () => { + it( + 'returns stream with correct headers', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + stream: true, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + client_request_id: 'test-client-session-id-123', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + if (response.status !== 200) { + const errorBody = await response.json() + console.log('Error response:', errorBody) + } + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/event-stream') + expect(response.headers.get('Cache-Control')).toBe('no-cache') + expect(response.headers.get('Connection')).toBe('keep-alive') + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + + it( + 'returns JSON response for non-streaming requests', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + client_request_id: 'test-client-session-id-123', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toContain( + 'application/json', + ) + const body = await response.json() + expect(body.id).toBe('test-id') + expect(body.choices[0].message.content).toBe('test response') + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + }) + + describe('Subscription limit enforcement', () => { + // Bumped from Bun's 5s default: the non-streaming fetch-path tests here + // have flaked right at the boundary (observed 5001ms) on loaded machines. + const SUBSCRIPTION_TEST_TIMEOUT_MS = 15000 + + const createValidRequest = () => + new NextRequest('http://localhost:3000/api/v1/chat/completions', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + client_request_id: 'test-client-session-id-123', + }, + }), + }) + + it( + 'returns 429 when weekly limit reached and fallback disabled', + async () => { + const weeklyLimitError: BlockGrantResult = { + error: 'weekly_limit_reached', + used: 3500, + limit: 3500, + resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock( + async () => weeklyLimitError, + ) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletionsForTest({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(429) + const body = await response.json() + expect(body.error).toBe('rate_limit_exceeded') + expect(body.message).toContain('weekly limit reached') + expect(body.message).toContain('Enable "Continue with credits"') + }, + SUBSCRIPTION_TEST_TIMEOUT_MS, + ) + + it( + 'skips subscription limit check when in FREE mode even with fallback disabled', + async () => { + const weeklyLimitError: BlockGrantResult = { + error: 'weekly_limit_reached', + used: 3500, + limit: 3500, + resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock( + async () => weeklyLimitError, + ) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const freeModeRequest = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-123'), + body: JSON.stringify({ + model: 'minimax/minimax-m2.7', + stream: false, + codebuff_metadata: { + run_id: 'run-free', + client_id: 'test-client-id-123', + cost_mode: 'free', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req: freeModeRequest, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(200) + }, + SUBSCRIPTION_TEST_TIMEOUT_MS, + ) + + it( + 'returns 429 when block exhausted and fallback disabled', + async () => { + const blockExhaustedError: BlockGrantResult = { + error: 'block_exhausted', + blockUsed: 350, + blockLimit: 350, + resetsAt: new Date(Date.now() + 4 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock( + async () => blockExhaustedError, + ) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletionsForTest({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(429) + const body = await response.json() + expect(body.error).toBe('rate_limit_exceeded') + expect(body.message).toContain('5-hour session limit reached') + expect(body.message).toContain('Enable "Continue with credits"') + }, + SUBSCRIPTION_TEST_TIMEOUT_MS, + ) + + it( + 'continues when weekly limit reached but fallback is enabled', + async () => { + const weeklyLimitError: BlockGrantResult = { + error: 'weekly_limit_reached', + used: 3500, + limit: 3500, + resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock( + async () => weeklyLimitError, + ) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: true, + })) + + const response = await postChatCompletionsForTest({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(200) + expect(mockLogger.info).toHaveBeenCalled() + }, + SUBSCRIPTION_TEST_TIMEOUT_MS, + ) + + it( + 'continues when block grant is created successfully', + async () => { + const blockGrant: BlockGrantResult = { + grantId: 'block-123', + credits: 350, + expiresAt: new Date(Date.now() + 5 * 60 * 60 * 1000), + isNew: true, + } + const mockEnsureSubscriberBlockGrant = mock(async () => blockGrant) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletionsForTest({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(200) + // getUserPreferences should not be called when block grant succeeds + expect(mockGetUserPreferences).not.toHaveBeenCalled() + }, + SUBSCRIPTION_TEST_TIMEOUT_MS, + ) + + it.skip('continues when ensureSubscriberBlockGrant throws an error (fail open)', async () => { + const mockEnsureSubscriberBlockGrant = mock(async () => { + throw new Error('Database connection failed') + }) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletionsForTest({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + // Should continue processing (fail open) + expect(response.status).toBe(200) + expect(mockLogger.error).toHaveBeenCalled() + }) + + it.skip( + 'continues when user is not a subscriber (null result)', + async () => { + const mockEnsureSubscriberBlockGrant = mock(async () => null) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletionsForTest({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + }) + + expect(response.status).toBe(200) + // getUserPreferences should not be called for non-subscribers + expect(mockGetUserPreferences).not.toHaveBeenCalled() + }, + SUBSCRIPTION_TEST_TIMEOUT_MS, + ) + + it.skip( + 'defaults to allowing fallback when getUserPreferences is not provided', + async () => { + const weeklyLimitError: BlockGrantResult = { + error: 'weekly_limit_reached', + used: 3500, + limit: 3500, + resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock( + async () => weeklyLimitError, + ) + + const response = await postChatCompletionsForTest({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + // Note: getUserPreferences is NOT provided + }) + + // Should continue processing (default to allowing a-la-carte) + expect(response.status).toBe(200) + }, + SUBSCRIPTION_TEST_TIMEOUT_MS, + ) + + it.skip('allows subscriber with 0 a-la-carte credits but active block grant', async () => { + const blockGrant: BlockGrantResult = { + grantId: 'block-123', + credits: 350, + expiresAt: new Date(Date.now() + 5 * 60 * 60 * 1000), + isNew: true, + } + const mockEnsureSubscriberBlockGrant = mock(async () => blockGrant) + + // Override mock: when subscription credits are included, simulate the block grant's credits + mockGetUserUsageData = mock( + async ({ + includeSubscriptionCredits, + }: { + includeSubscriptionCredits?: boolean + }) => ({ + usageThisCycle: 0, + balance: { + totalRemaining: includeSubscriptionCredits ? 350 : 0, + totalDebt: 0, + netBalance: includeSubscriptionCredits ? 350 : 0, + breakdown: {}, + principals: { subscription: 350 }, + }, + nextQuotaReset, + }), + ) + + // Use the no-credits user (totalRemaining = 0 without subscription) + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-no-credits' }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + }) + + // Should succeed - subscriber has block grant credits despite 0 a-la-carte credits + expect(response.status).toBe(200) + }) + + it('returns 402 for non-subscriber with 0 credits when ensureSubscriberBlockGrant returns null', async () => { + const mockEnsureSubscriberBlockGrant = mock(async () => null) + + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-no-credits' }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + }) + + // Non-subscriber with 0 credits should get 402 + expect(response.status).toBe(402) + }) + + it('does not call ensureSubscriberBlockGrant before validation passes', async () => { + const mockEnsureSubscriberBlockGrant = mock(async () => null) + + // Request with invalid run_id + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-nonexistent', + }, + }), + }, + ) + + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + }) + + // Should return 400 for invalid run_id + expect(response.status).toBe(400) + // ensureSubscriberBlockGrant should NOT have been called + expect(mockEnsureSubscriberBlockGrant).not.toHaveBeenCalled() }) }) }) diff --git a/web/src/app/api/v1/chat/completions/__tests__/free-mode-rate-limiter.test.ts b/web/src/app/api/v1/chat/completions/__tests__/free-mode-rate-limiter.test.ts new file mode 100644 index 0000000000..9db4e6bc90 --- /dev/null +++ b/web/src/app/api/v1/chat/completions/__tests__/free-mode-rate-limiter.test.ts @@ -0,0 +1,324 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' + +import { + checkFreeModeRateLimit, + FREE_MODE_RATE_LIMITS, + resetFreeModeRateLimits, +} from '../free-mode-rate-limiter' + +const SECOND_MS = 1000 +const MINUTE_MS = 60 * SECOND_MS +const HOUR_MS = 60 * MINUTE_MS + +describe('free-mode-rate-limiter', () => { + let nowSpy: ReturnType + let fakeNow: number + + beforeEach(() => { + resetFreeModeRateLimits() + fakeNow = 1_000_000_000_000 + nowSpy = spyOn(Date, 'now').mockImplementation(() => fakeNow) + }) + + afterEach(() => { + nowSpy.mockRestore() + }) + + function advanceTime(ms: number) { + fakeNow += ms + } + + function makeRequests(userId: string, count: number) { + for (let i = 0; i < count; i++) { + if (i > 0) { + advanceTime(1 * SECOND_MS + 1) + } + const result = checkFreeModeRateLimit(userId) + if (result.limited) { + throw new Error(`Unexpectedly rate limited on request ${i + 1}`) + } + } + } + + describe('checkFreeModeRateLimit', () => { + it('allows the first request', () => { + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(false) + }) + + it('limits when per-second limit is exceeded', () => { + // Make all requests within the same second (no time advancement) + for (let i = 0; i < FREE_MODE_RATE_LIMITS.PER_SECOND; i++) { + expect(checkFreeModeRateLimit('user-1').limited).toBe(false) + } + + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(true) + if (result.limited) { + expect(result.windowName).toBe('1 second') + } + }) + + it('resets per-second window after expiry', () => { + for (let i = 0; i < FREE_MODE_RATE_LIMITS.PER_SECOND; i++) { + checkFreeModeRateLimit('user-1') + } + expect(checkFreeModeRateLimit('user-1').limited).toBe(true) + + advanceTime(1 * SECOND_MS + 1) + + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(false) + }) + + it('allows requests up to the per-minute limit', () => { + for (let i = 0; i < FREE_MODE_RATE_LIMITS.PER_MINUTE; i++) { + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(false) + if (i < FREE_MODE_RATE_LIMITS.PER_MINUTE - 1) { + advanceTime(1 * SECOND_MS + 1) + } + } + }) + + it('limits when per-minute limit is exceeded', () => { + makeRequests('user-1', FREE_MODE_RATE_LIMITS.PER_MINUTE) + // Advance past the 1-second window so the per-minute window is the one that triggers + advanceTime(1 * SECOND_MS + 1) + + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(true) + if (result.limited) { + expect(result.windowName).toBe('1 minute') + } + }) + + it('limits when per-30-minute limit is exceeded', () => { + const perMinute = FREE_MODE_RATE_LIMITS.PER_MINUTE + const per30Min = FREE_MODE_RATE_LIMITS.PER_30_MINUTES + + // Spread requests across multiple 1-minute windows to avoid hitting the per-minute limit + let sent = 0 + while (sent < per30Min) { + const batch = Math.min(perMinute, per30Min - sent) + makeRequests('user-1', batch) + sent += batch + if (sent < per30Min) { + // Advance past the 1-minute window so it resets + advanceTime(1 * MINUTE_MS + 1) + } + } + + // Advance past the 1-minute window so the per-30-minute window is the one that triggers + advanceTime(1 * MINUTE_MS + 1) + + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(true) + if (result.limited) { + expect(result.windowName).toBe('30 minutes') + } + }) + + it('limits when per-5-hour limit is exceeded', () => { + const perMinute = FREE_MODE_RATE_LIMITS.PER_MINUTE + const per30Min = FREE_MODE_RATE_LIMITS.PER_30_MINUTES + const per5Hours = FREE_MODE_RATE_LIMITS.PER_5_HOURS + + // Spread requests across multiple 30-minute windows + let sent = 0 + while (sent < per5Hours) { + const batchStart = fakeNow + const batchFor30Min = Math.min(per30Min, per5Hours - sent) + // Within each 30-min window, spread across 1-min windows + let sentInWindow = 0 + while (sentInWindow < batchFor30Min) { + const batch = Math.min(perMinute, batchFor30Min - sentInWindow) + makeRequests('user-1', batch) + sentInWindow += batch + if (sentInWindow < batchFor30Min) { + advanceTime(1 * MINUTE_MS + 1) + } + } + sent += sentInWindow + if (sent < per5Hours) { + // Advance just past the 30-min window boundary to reset it, + // accounting for time already elapsed in the inner loop + const elapsed = fakeNow - batchStart + advanceTime(30 * MINUTE_MS - elapsed + 1) + } + } + + // Advance past the 30-minute window so the per-5-hour window is the one that triggers + advanceTime(30 * MINUTE_MS + 1) + + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(true) + if (result.limited) { + expect(result.windowName).toBe('5 hours') + } + }) + + it('limits when per-7-day limit is exceeded', () => { + const perMinute = FREE_MODE_RATE_LIMITS.PER_MINUTE + const per30Min = FREE_MODE_RATE_LIMITS.PER_30_MINUTES + const per5Hours = FREE_MODE_RATE_LIMITS.PER_5_HOURS + const per7Days = FREE_MODE_RATE_LIMITS.PER_7_DAYS + + // Spread requests across multiple 5-hour windows + let sent = 0 + while (sent < per7Days) { + const batchFor5Hours = Math.min(per5Hours, per7Days - sent) + let sentIn5Hr = 0 + while (sentIn5Hr < batchFor5Hours) { + const batchFor30Min = Math.min(per30Min, batchFor5Hours - sentIn5Hr) + let sentIn30Min = 0 + while (sentIn30Min < batchFor30Min) { + const batch = Math.min(perMinute, batchFor30Min - sentIn30Min) + makeRequests('user-1', batch) + sentIn30Min += batch + if (sentIn30Min < batchFor30Min) { + advanceTime(1 * MINUTE_MS + 1) + } + } + sentIn5Hr += sentIn30Min + advanceTime(30 * MINUTE_MS + 1) + } + sent += sentIn5Hr + // Advance past the 5-hour window (stays within 7-day window) + advanceTime(5 * HOUR_MS + 1) + } + + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(true) + if (result.limited) { + expect(result.windowName).toBe('7 days') + } + }) + + it('does not increment counters when rate limited', () => { + makeRequests('user-1', FREE_MODE_RATE_LIMITS.PER_MINUTE) + // Advance past the 1-second window so the per-minute window blocks + advanceTime(1 * SECOND_MS + 1) + + // These should all be rejected without changing state + for (let i = 0; i < 5; i++) { + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(true) + } + + // After the 1-minute window expires, the user should only have used PER_MINUTE requests + // against the 30-minute window, not PER_MINUTE + 5 + advanceTime(1 * MINUTE_MS + 1) + + // Should be allowed again (1-min window reset) + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(false) + }) + + it('returns correct retryAfterMs for the violated window', () => { + makeRequests('user-1', FREE_MODE_RATE_LIMITS.PER_MINUTE) + // makeRequests advanced time by (PER_MINUTE - 1) * (SECOND_MS + 1) + const elapsedInMakeRequests = (FREE_MODE_RATE_LIMITS.PER_MINUTE - 1) * (1 * SECOND_MS + 1) + + // Advance past the 1-second window, then a bit more + const additionalAdvance = 2 * SECOND_MS + advanceTime(additionalAdvance) + + const totalElapsed = elapsedInMakeRequests + additionalAdvance + const expectedRetryAfterMs = 1 * MINUTE_MS - totalElapsed + + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(true) + if (result.limited) { + expect(result.windowName).toBe('1 minute') + expect(result.retryAfterMs).toBe(expectedRetryAfterMs) + } + }) + + it('resets per-minute window after expiry', () => { + makeRequests('user-1', FREE_MODE_RATE_LIMITS.PER_MINUTE) + advanceTime(1 * SECOND_MS + 1) + + const limited = checkFreeModeRateLimit('user-1') + expect(limited.limited).toBe(true) + + // Advance past the 1-minute window + advanceTime(1 * MINUTE_MS + 1) + + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(false) + }) + + it('isolates different users', () => { + makeRequests('user-1', FREE_MODE_RATE_LIMITS.PER_MINUTE) + advanceTime(1 * SECOND_MS + 1) + + // user-1 is rate limited + expect(checkFreeModeRateLimit('user-1').limited).toBe(true) + + // user-2 should not be affected + const result = checkFreeModeRateLimit('user-2') + expect(result.limited).toBe(false) + }) + + it('retryAfterMs is never negative', () => { + for (let i = 0; i < FREE_MODE_RATE_LIMITS.PER_SECOND; i++) { + checkFreeModeRateLimit('user-1') + } + + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(true) + if (result.limited) { + expect(result.retryAfterMs).toBeGreaterThanOrEqual(0) + } + }) + + it('tracks counts across all windows simultaneously', () => { + // Make some requests + makeRequests('user-1', 5) + + // Advance past 1-minute window but within 30-minute window + advanceTime(1 * MINUTE_MS + 1) + + // Make more requests — 1-min counter resets, but 30-min counter keeps accumulating + makeRequests('user-1', 5) + + // Advance past 1-minute again + advanceTime(1 * MINUTE_MS + 1) + + // The 30-min window should now have 10 requests counted + // and the 1-min window should be fresh + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(false) + }) + }) + + describe('resetFreeModeRateLimits', () => { + it('clears all rate limit state', () => { + for (let i = 0; i < FREE_MODE_RATE_LIMITS.PER_SECOND; i++) { + checkFreeModeRateLimit('user-1') + } + expect(checkFreeModeRateLimit('user-1').limited).toBe(true) + + resetFreeModeRateLimits() + + const result = checkFreeModeRateLimit('user-1') + expect(result.limited).toBe(false) + }) + + it('clears state for all users', () => { + for (let i = 0; i < FREE_MODE_RATE_LIMITS.PER_SECOND; i++) { + checkFreeModeRateLimit('user-1') + checkFreeModeRateLimit('user-2') + } + + expect(checkFreeModeRateLimit('user-1').limited).toBe(true) + expect(checkFreeModeRateLimit('user-2').limited).toBe(true) + + resetFreeModeRateLimits() + + expect(checkFreeModeRateLimit('user-1').limited).toBe(false) + expect(checkFreeModeRateLimit('user-2').limited).toBe(false) + }) + }) +}) diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index 4c365e8722..7b5a8a9ebc 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -1,21 +1,21 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { BYOK_OPENROUTER_HEADER } from '@codebuff/common/constants/byok' +import { + FREEBUFF_GEMINI_PRO_MODEL_ID, + isFreebuffModelAllowedForAccessTier, + isSupportedFreebuffModelId, +} from '@codebuff/common/constants/freebuff-models' +import { + isFreebuffGeminiThinkerAgent, + isFreebuffRootAgent, + isFreeMode, + isFreeModeAllowedAgentModel, +} from '@codebuff/common/constants/free-agents' import { getErrorObject } from '@codebuff/common/util/error' import { pluralize } from '@codebuff/common/util/string' import { env } from '@codebuff/internal/env' import { NextResponse } from 'next/server' -import { - handleOpenAINonStream, - OPENAI_SUPPORTED_MODELS, -} from '@/llm-api/openai' -import { - handleOpenRouterNonStream, - handleOpenRouterStream, - OpenRouterError, -} from '@/llm-api/openrouter' -import { extractApiKeyFromHeader } from '@/util/auth' - import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { InsertMessageBigqueryFn } from '@codebuff/common/types/contracts/bigquery' import type { GetUserUsageDataFn } from '@codebuff/common/types/contracts/billing' @@ -27,8 +27,81 @@ import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' + +import type { BlockGrantResult } from '@codebuff/billing/subscription' +import { + isWeeklyLimitError, + isBlockExhaustedError, +} from '@codebuff/billing/subscription' + +export type GetUserPreferencesFn = (params: { + userId: string + logger: Logger +}) => Promise<{ fallbackToALaCarte: boolean }> import type { NextRequest } from 'next/server' +import type { ChatCompletionRequestBody } from '@/llm-api/types' + +import { + CanopyWaveError, + handleCanopyWaveNonStream, + handleCanopyWaveStream, + isCanopyWaveModel, +} from '@/llm-api/canopywave' +import { + FireworksError, + handleFireworksNonStream, + handleFireworksStream, + isFireworksModel, +} from '@/llm-api/fireworks' +import { + DeepSeekError, + handleDeepSeekNonStream, + handleDeepSeekStream, + isDeepSeekModel, +} from '@/llm-api/deepseek' +import { + handleMoonshotNonStream, + handleMoonshotStream, + isMoonshotModel, + MoonshotError, +} from '@/llm-api/moonshot' +import { + OpenCodeZenError, + handleOpenCodeZenNonStream, + handleOpenCodeZenStream, + isOpenCodeZenModel, +} from '@/llm-api/opencode-zen' +import { + SiliconFlowError, + handleSiliconFlowNonStream, + handleSiliconFlowStream, + isSiliconFlowModel, +} from '@/llm-api/siliconflow' +import { + handleOpenAINonStream, + handleOpenAIStream, + isOpenAIDirectModel, + OpenAIError, +} from '@/llm-api/openai' +import { + handleOpenRouterNonStream, + handleOpenRouterStream, + OpenRouterError, +} from '@/llm-api/openrouter' +import { checkSessionAdmissible } from '@/server/free-session/public-api' +import { getCachedFreeModeCountryAccess } from '@/server/free-mode-country-access-cache' +import { getFreeModeAccessTier } from '@/server/free-mode-country' + +import type { SessionGateResult } from '@/server/free-session/public-api' +import type { + FreeModeCountryAccess, + FreeModeCountryAccessOptions, +} from '@/server/free-mode-country' +import { extractApiKeyFromHeader } from '@/util/auth' +import { withDefaultProperties } from '@codebuff/common/analytics' +import { checkFreeModeRateLimit as defaultCheckFreeModeRateLimit } from './free-mode-rate-limiter' + export const formatQuotaResetCountdown = ( nextQuotaReset: string | null | undefined, ): string => { @@ -65,6 +138,36 @@ export const formatQuotaResetCountdown = ( return `in ${pluralize(minutes, 'minute')}` } +export type CheckSessionAdmissibleFn = typeof checkSessionAdmissible +export type CheckFreeModeRateLimitFn = typeof defaultCheckFreeModeRateLimit +export type ResolveFreeModeCountryAccessFn = ( + userId: string, + req: NextRequest, + options: FreeModeCountryAccessOptions, +) => Promise + +const FREEBUFF_SUCCESS_SAMPLE_RATE = 0.01 + +function sampleSuccessLogger(logger: Logger, sampled: boolean): Logger { + if (sampled) return logger + return { + ...logger, + info: (() => {}) as Logger['info'], + debug: (() => {}) as Logger['debug'], + } +} + +type GateRejectCode = Extract['code'] + +const STATUS_BY_GATE_CODE = { + waiting_room_required: 428, + waiting_room_queued: 429, + session_superseded: 409, + session_expired: 410, + session_model_mismatch: 409, + freebuff_update_required: 426, +} satisfies Record + export async function postChatCompletions(params: { req: NextRequest getUserInfoFromApiKey: GetUserInfoFromApiKeyFn @@ -75,18 +178,41 @@ export async function postChatCompletions(params: { getAgentRunFromId: GetAgentRunFromIdFn fetch: typeof globalThis.fetch insertMessageBigquery: InsertMessageBigqueryFn + ensureSubscriberBlockGrant?: (params: { + userId: string + logger: Logger + }) => Promise + getUserPreferences?: GetUserPreferencesFn + /** Optional override for the freebuff waiting-room gate. Defaults to the + * real check backed by Postgres; tests inject a no-op. */ + checkSessionAdmissible?: CheckSessionAdmissibleFn + /** Optional override for the free-mode rate limiter. Tests inject this to + * avoid coupling to process-global limiter state. */ + checkFreeModeRateLimit?: CheckFreeModeRateLimitFn + /** Optional override for country/cache checks. Tests inject this to avoid + * coupling to Postgres-backed cache state. */ + resolveFreeModeCountryAccess?: ResolveFreeModeCountryAccessFn }) { const { req, getUserInfoFromApiKey, loggerWithContext, - trackEvent, getUserUsageData, getAgentRunFromId, fetch, insertMessageBigquery, + ensureSubscriberBlockGrant, + getUserPreferences, + checkSessionAdmissible: checkSession = checkSessionAdmissible, + checkFreeModeRateLimit = defaultCheckFreeModeRateLimit, + resolveFreeModeCountryAccess, } = params let { logger } = params + let { trackEvent } = params + const resolveCountryAccess: ResolveFreeModeCountryAccessFn = + resolveFreeModeCountryAccess ?? + ((userId, req, options) => + getCachedFreeModeCountryAccess({ userId, req, options, logger })) try { // Parse request body @@ -108,8 +234,25 @@ export async function postChatCompletions(params: { ) } - const bodyStream = 'stream' in body && body.stream - const runId = (body as any)?.codebuff_metadata?.run_id + const typedBody = body as unknown as ChatCompletionRequestBody + const bodyStream = typedBody.stream ?? false + const runId = typedBody.codebuff_metadata?.run_id + + // Check if the request is in FREE mode (costs 0 credits for allowed agent+model combos) + const costMode = typedBody.codebuff_metadata?.cost_mode + const isFreeModeRequest = isFreeMode(costMode) + const sampleFreebuffSuccess = + !isFreeModeRequest || Math.random() < FREEBUFF_SUCCESS_SAMPLE_RATE + + const trackSuccessEvent: TrackEventFn = (eventParams) => { + if (sampleFreebuffSuccess) { + trackEvent(eventParams) + } + } + + trackEvent = withDefaultProperties(trackEvent, { + freebuff: isFreeModeRequest, + }) // Extract and validate API key const apiKey = extractApiKeyFromHeader(req) @@ -128,7 +271,7 @@ export async function postChatCompletions(params: { // Get user info const userInfo = await getUserInfoFromApiKey({ apiKey, - fields: ['id', 'email', 'discord_id', 'banned'], + fields: ['id', 'email', 'discord_id', 'stripe_customer_id', 'banned'], logger, }) if (!userInfo) { @@ -148,6 +291,8 @@ export async function postChatCompletions(params: { logger = loggerWithContext({ userInfo }) const userId = userInfo.id + const stripeCustomerId = userInfo.stripe_customer_id ?? null + let freebuffAccessTier: 'full' | 'limited' = 'full' // Check if user is banned. // We use a clear, helpful message rather than a cryptic error because: @@ -159,14 +304,15 @@ export async function postChatCompletions(params: { return NextResponse.json( { error: 'account_suspended', - message: `Your account has been suspended due to billing issues. Please contact ${env.NEXT_PUBLIC_SUPPORT_EMAIL} to resolve this.`, + message: `Your account has been suspended. Please contact ${env.NEXT_PUBLIC_SUPPORT_EMAIL} if you did not expect this.`, }, { status: 403 }, ) } - // Track API request - trackEvent({ + // Track API request. Freebuff success-path analytics are sampled to keep + // high-volume free traffic from dominating PostHog and log forwarding. + trackSuccessEvent({ event: AnalyticsEvent.CHAT_COMPLETIONS_REQUEST, userId, properties: { @@ -177,33 +323,53 @@ export async function postChatCompletions(params: { logger, }) - // Check user credits - const { - balance: { totalRemaining }, - nextQuotaReset, - } = await getUserUsageData({ userId, logger }) - if (totalRemaining <= 0) { - trackEvent({ - event: AnalyticsEvent.CHAT_COMPLETIONS_INSUFFICIENT_CREDITS, - userId, - properties: { - totalRemaining, - nextQuotaReset, - }, - logger, + // For free mode requests, classify the request into full or limited + // access. Disallowed countries and anonymized networks are no longer + // blocked outright; they are limited to the cheap DeepSeek Flash path. + if (isFreeModeRequest) { + const countryAccess = await resolveCountryAccess(userId, req, { + fetch, + ipinfoToken: env.IPINFO_TOKEN, + ipHashSecret: env.NEXTAUTH_SECRET, + allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev', + forceLimited: + env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev' && + env.FREEBUFF_DEV_FORCE_LIMITED, }) - const resetCountdown = formatQuotaResetCountdown(nextQuotaReset) - return NextResponse.json( - { - message: `Out of credits. Please add credits at ${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage. Your free credits reset ${resetCountdown}.`, - }, - { status: 402 }, - ) + freebuffAccessTier = getFreeModeAccessTier(countryAccess) + + if (!countryAccess.allowed || sampleFreebuffSuccess) { + logger.info( + { + cfHeader: countryAccess.cfCountry, + geoipResult: countryAccess.geoipCountry, + resolvedCountry: countryAccess.countryCode, + countryBlockReason: countryAccess.blockReason, + ipPrivacySignals: countryAccess.ipPrivacy?.signals, + clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined, + }, + 'Free mode country detection', + ) + } + + if (!countryAccess.allowed) { + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, + userId, + properties: { + error: 'free_mode_not_available_in_country', + countryCode: countryAccess.countryCode, + countryBlockReason: countryAccess.blockReason, + ipPrivacySignals: countryAccess.ipPrivacy?.signals, + clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined, + }, + logger, + }) + } } // Extract and validate agent run ID - const runIdFromBody: string | undefined = (body as any).codebuff_metadata - ?.run_id + const runIdFromBody = typedBody.codebuff_metadata?.run_id if (!runIdFromBody || typeof runIdFromBody !== 'string') { trackEvent({ event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, @@ -223,7 +389,7 @@ export async function postChatCompletions(params: { const agentRun = await getAgentRunFromId({ runId: runIdFromBody, userId, - fields: ['agent_id', 'status'], + fields: ['agent_id', 'ancestor_run_ids', 'status'], }) if (!agentRun) { trackEvent({ @@ -241,7 +407,11 @@ export async function postChatCompletions(params: { ) } - const { agent_id: agentId, status: agentRunStatus } = agentRun + const { + agent_id: agentId, + ancestor_run_ids: ancestorRunIds, + status: agentRunStatus, + } = agentRun if (agentRunStatus !== 'running') { trackEvent({ @@ -260,23 +430,328 @@ export async function postChatCompletions(params: { ) } + // Free-mode requests must use an allowlisted agent+model combination. + // Without this gate, an attacker on a brand-new unpaid account can set + // cost_mode='free' to bypass both the paid-account check and the balance + // check, then request an expensive model (Opus, etc). Our OpenRouter key + // pays for the call; the downstream credit-consumption step records an + // audit row but can't actually deduct from a user who has no grants — + // net result is free Opus for the attacker, real dollars for us. Check + // must happen here, before any call to OpenRouter. + if ( + isFreeModeRequest && + !isFreeModeAllowedAgentModel(agentId, typedBody.model) + ) { + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, + userId, + properties: { + error: 'free_mode_invalid_agent_model', + agentId, + model: typedBody.model, + }, + logger, + }) + return NextResponse.json( + { + error: 'free_mode_invalid_agent_model', + message: + 'Free mode is only available for specific agent and model combinations.', + }, + { status: 403 }, + ) + } + + if (isFreeModeRequest && !isFreebuffRootAgent(agentId)) { + const rootRunId = ancestorRunIds[0] + const rootRun = rootRunId + ? await getAgentRunFromId({ + runId: rootRunId, + userId, + fields: ['agent_id', 'status'], + }) + : null + if ( + !rootRun || + rootRun.status !== 'running' || + !isFreebuffRootAgent(rootRun.agent_id) + ) { + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, + userId, + properties: { + error: 'free_mode_invalid_agent_hierarchy', + agentId, + runId: runIdFromBody, + rootRunId, + }, + logger, + }) + return NextResponse.json( + { + error: 'free_mode_invalid_agent_hierarchy', + message: + 'Free mode subagents must run under an active freebuff session root.', + }, + { status: 403 }, + ) + } + } + + if ( + isFreeModeRequest && + freebuffAccessTier === 'limited' && + (isSupportedFreebuffModelId(typedBody.model) || + typedBody.model === FREEBUFF_GEMINI_PRO_MODEL_ID) && + !isFreebuffModelAllowedForAccessTier(typedBody.model, freebuffAccessTier) + ) { + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, + userId, + properties: { + error: 'session_model_mismatch', + model: typedBody.model, + accessTier: freebuffAccessTier, + }, + logger, + }) + return NextResponse.json( + { + error: 'session_model_mismatch', + message: + 'Limited free access is only available with DeepSeek V4 Flash.', + }, + { status: STATUS_BY_GATE_CODE.session_model_mismatch }, + ) + } + + let freeModeSessionGate: SessionGateResult | null = null + + // Freebuff waiting-room gate. Usually enforced only when + // FREEBUFF_WAITING_ROOM_ENABLED=true. Runs before the rate limiter so + // rejected requests don't burn a queued user's free-mode counters. + if (isFreeModeRequest) { + const claimedInstanceId = + typedBody.codebuff_metadata?.freebuff_instance_id + freeModeSessionGate = await checkSession({ + userId, + accessTier: freebuffAccessTier, + userEmail: userInfo.email, + claimedInstanceId, + requestedModel: typedBody.model, + requireActiveSession: isFreebuffGeminiThinkerAgent(agentId), + }) + if (!freeModeSessionGate.ok) { + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, + userId, + properties: { error: freeModeSessionGate.code }, + logger, + }) + return NextResponse.json( + { + error: freeModeSessionGate.code, + message: freeModeSessionGate.message, + }, + { status: STATUS_BY_GATE_CODE[freeModeSessionGate.code] }, + ) + } + } + + // Rate limit free mode requests (after validation so invalid requests don't consume quota) + if (isFreeModeRequest) { + const rateLimitResult = checkFreeModeRateLimit(userId) + if (rateLimitResult.limited) { + const retryAfterSeconds = Math.ceil(rateLimitResult.retryAfterMs / 1000) + const resetTime = new Date( + Date.now() + rateLimitResult.retryAfterMs, + ).toISOString() + const resetCountdown = formatQuotaResetCountdown(resetTime) + + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, + userId, + properties: { + error: 'free_mode_rate_limited', + windowName: rateLimitResult.windowName, + retryAfterSeconds, + }, + logger, + }) + + return NextResponse.json( + { + error: 'free_mode_rate_limited', + message: `Free mode rate limit exceeded (${rateLimitResult.windowName} limit). Try again ${resetCountdown}.`, + }, + { + status: 429, + headers: { 'Retry-After': String(retryAfterSeconds) }, + }, + ) + } + } + + // For subscribers, ensure a block grant exists before processing the request. + // This is done AFTER validation so malformed requests don't start a new 5-hour block. + // When the function is provided, always include subscription credits in the balance: + // error/null results mean subscription grants have 0 balance, so including them is harmless. + const includeSubscriptionCredits = + !isFreeModeRequest && !!ensureSubscriberBlockGrant + if (!isFreeModeRequest && ensureSubscriberBlockGrant) { + try { + const blockGrantResult = await ensureSubscriberBlockGrant({ + userId, + logger, + }) + + // Check if user hit subscription limit and should be rate-limited + if ( + blockGrantResult && + (isWeeklyLimitError(blockGrantResult) || + isBlockExhaustedError(blockGrantResult)) + ) { + // Fetch user's preference for falling back to a-la-carte credits + const preferences = getUserPreferences + ? await getUserPreferences({ userId, logger }) + : { fallbackToALaCarte: true } // Default to allowing a-la-carte if no preference function + + if (!preferences.fallbackToALaCarte) { + const resetTime = blockGrantResult.resetsAt + const resetCountdown = formatQuotaResetCountdown( + resetTime.toISOString(), + ) + const limitType = isWeeklyLimitError(blockGrantResult) + ? 'weekly' + : '5-hour session' + + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_INSUFFICIENT_CREDITS, + userId, + properties: { + reason: 'subscription_limit_no_fallback', + limitType, + fallbackToALaCarte: false, + }, + logger, + }) + + return NextResponse.json( + { + error: 'rate_limit_exceeded', + message: `Subscription ${limitType} limit reached. Your limit resets ${resetCountdown}. Enable "Continue with credits" in the CLI to use a-la-carte credits.`, + }, + { status: 429 }, + ) + } + // If fallbackToALaCarte is true, continue to use a-la-carte credits + logger.info( + { + userId, + limitType: isWeeklyLimitError(blockGrantResult) + ? 'weekly' + : 'session', + }, + 'Subscriber hit limit, falling back to a-la-carte credits', + ) + } + } catch (error) { + logger.error( + { error: getErrorObject(error), userId }, + 'Error ensuring subscription block grant', + ) + // Fail open: proceed with subscription credits included in balance check + } + } + + // Free-mode requests have already passed their model/session/rate gates + // and should not touch paid billing/usage paths. + if (!isFreeModeRequest) { + // Fetch user credit data (includes subscription credits when block grant was ensured) + const { + balance: { totalRemaining }, + nextQuotaReset, + } = await getUserUsageData({ userId, logger, includeSubscriptionCredits }) + + // Credit check + if (totalRemaining <= 0) { + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_INSUFFICIENT_CREDITS, + userId, + properties: { + totalRemaining, + nextQuotaReset, + }, + logger, + }) + return NextResponse.json( + { + message: `Out of credits. Please add credits at ${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/usage.`, + }, + { status: 402 }, + ) + } + } + const openrouterApiKey = req.headers.get(BYOK_OPENROUTER_HEADER) + const providerLogger = sampleSuccessLogger(logger, sampleFreebuffSuccess) // Handle streaming vs non-streaming try { if (bodyStream) { - // Streaming request - const stream = await handleOpenRouterStream({ - body, + // Streaming request — route supported models to direct providers. + const useSiliconFlow = false // isSiliconFlowModel(typedBody.model) + const useOpenCodeZen = isOpenCodeZenModel(typedBody.model) + const useMoonshot = !useOpenCodeZen && isMoonshotModel(typedBody.model) + const useCanopyWave = + !useMoonshot && !useOpenCodeZen && isCanopyWaveModel(typedBody.model) + const useDeepSeek = + !useMoonshot && + !useOpenCodeZen && + !useCanopyWave && + isDeepSeekModel(typedBody.model) + const useFireworks = + !useMoonshot && + !useOpenCodeZen && + !useCanopyWave && + !useDeepSeek && + isFireworksModel(typedBody.model) + const useOpenAIDirect = + !useMoonshot && + !useOpenCodeZen && + !useCanopyWave && + !useDeepSeek && + !useFireworks && + isOpenAIDirectModel(typedBody.model) + const baseArgs = { + body: typedBody, userId, + stripeCustomerId, agentId, - openrouterApiKey, fetch, - logger, + logger: providerLogger, insertMessageBigquery, - }) - - trackEvent({ + } + const stream = useSiliconFlow + ? await handleSiliconFlowStream(baseArgs) + : useMoonshot + ? await handleMoonshotStream(baseArgs) + : useOpenCodeZen + ? await handleOpenCodeZenStream(baseArgs) + : useCanopyWave + ? await handleCanopyWaveStream(baseArgs) + : useDeepSeek + ? await handleDeepSeekStream(baseArgs) + : useFireworks + ? await handleFireworksStream(baseArgs) + : useOpenAIDirect + ? await handleOpenAIStream(baseArgs) + : await handleOpenRouterStream({ + ...baseArgs, + openrouterApiKey, + }) + + trackSuccessEvent({ event: AnalyticsEvent.CHAT_COMPLETIONS_STREAM_STARTED, userId, properties: { @@ -295,39 +770,62 @@ export async function postChatCompletions(params: { }, }) } else { - // Non-streaming request - const model = (body as any)?.model - const shortModelName = - typeof model === 'string' ? model.split('/')[1] : undefined - const isOpenAIDirectModel = - typeof model === 'string' && - model.startsWith('openai/') && - OPENAI_SUPPORTED_MODELS.includes(shortModelName as any) - // Only use OpenAI endpoint for OpenAI models with n parameter - // All other models (including non-OpenAI with n parameter) should use OpenRouter + // Non-streaming request — route to direct providers for supported models + const model = typedBody.model + const useSiliconFlow = false // isSiliconFlowModel(model) + const useOpenCodeZen = isOpenCodeZenModel(model) + const useMoonshot = !useOpenCodeZen && isMoonshotModel(model) + const useCanopyWave = + !useMoonshot && !useOpenCodeZen && isCanopyWaveModel(model) + const useDeepSeek = + !useMoonshot && + !useOpenCodeZen && + !useCanopyWave && + isDeepSeekModel(model) + const useFireworks = + !useMoonshot && + !useOpenCodeZen && + !useCanopyWave && + !useDeepSeek && + isFireworksModel(model) const shouldUseOpenAIEndpoint = - isOpenAIDirectModel && (body as any)?.codebuff_metadata?.n - - const result = await (shouldUseOpenAIEndpoint - ? handleOpenAINonStream({ - body, - userId, - agentId, - fetch, - logger, - insertMessageBigquery, - }) - : handleOpenRouterNonStream({ - body, - userId, - agentId, - openrouterApiKey, - fetch, - logger, - insertMessageBigquery, - })) - - trackEvent({ + !useMoonshot && + !useOpenCodeZen && + !useCanopyWave && + !useDeepSeek && + !useFireworks && + isOpenAIDirectModel(model) + + const baseArgs = { + body: typedBody, + userId, + stripeCustomerId, + agentId, + fetch, + logger: providerLogger, + insertMessageBigquery, + } + const nonStreamRequest = useSiliconFlow + ? handleSiliconFlowNonStream(baseArgs) + : useMoonshot + ? handleMoonshotNonStream(baseArgs) + : useOpenCodeZen + ? handleOpenCodeZenNonStream(baseArgs) + : useCanopyWave + ? handleCanopyWaveNonStream(baseArgs) + : useDeepSeek + ? handleDeepSeekNonStream(baseArgs) + : useFireworks + ? handleFireworksNonStream(baseArgs) + : shouldUseOpenAIEndpoint + ? handleOpenAINonStream(baseArgs) + : handleOpenRouterNonStream({ + ...baseArgs, + openrouterApiKey, + }) + const result = await nonStreamRequest + + trackSuccessEvent({ event: AnalyticsEvent.CHAT_COMPLETIONS_GENERATION_STARTED, userId, properties: { @@ -345,30 +843,92 @@ export async function postChatCompletions(params: { if (error instanceof OpenRouterError) { openrouterError = error } + let fireworksError: FireworksError | undefined + if (error instanceof FireworksError) { + fireworksError = error + } + let canopywaveError: CanopyWaveError | undefined + if (error instanceof CanopyWaveError) { + canopywaveError = error + } + let deepseekError: DeepSeekError | undefined + if (error instanceof DeepSeekError) { + deepseekError = error + } + let moonshotError: MoonshotError | undefined + if (error instanceof MoonshotError) { + moonshotError = error + } + let siliconflowError: SiliconFlowError | undefined + if (error instanceof SiliconFlowError) { + siliconflowError = error + } + let openaiError: OpenAIError | undefined + if (error instanceof OpenAIError) { + openaiError = error + } + let opencodeZenError: OpenCodeZenError | undefined + if (error instanceof OpenCodeZenError) { + opencodeZenError = error + } // Log detailed error information for debugging const errorDetails = openrouterError?.toJSON() + const providerLabel = siliconflowError + ? 'SiliconFlow' + : opencodeZenError + ? 'OpenCode Zen' + : moonshotError + ? 'Moonshot' + : canopywaveError + ? 'CanopyWave' + : deepseekError + ? 'DeepSeek' + : fireworksError + ? 'Fireworks' + : openaiError + ? 'OpenAI' + : 'OpenRouter' logger.error( { error: getErrorObject(error), userId, agentId, runId: runIdFromBody, - model: (body as any)?.model, + model: typedBody.model, streaming: !!bodyStream, hasByokKey: !!openrouterApiKey, - messageCount: Array.isArray((body as any)?.messages) - ? (body as any).messages.length + messageCount: Array.isArray(typedBody.messages) + ? typedBody.messages.length : 0, - openrouterStatusCode: openrouterError?.statusCode, - openrouterStatusText: openrouterError?.statusText, + messages: typedBody.messages, + providerStatusCode: ( + openrouterError ?? + fireworksError ?? + moonshotError ?? + canopywaveError ?? + deepseekError ?? + siliconflowError ?? + openaiError ?? + opencodeZenError + )?.statusCode, + providerStatusText: ( + openrouterError ?? + fireworksError ?? + moonshotError ?? + canopywaveError ?? + deepseekError ?? + siliconflowError ?? + openaiError ?? + opencodeZenError + )?.statusText, openrouterErrorCode: errorDetails?.error?.code, openrouterErrorType: errorDetails?.error?.type, openrouterErrorMessage: errorDetails?.error?.message, openrouterProviderName: errorDetails?.error?.metadata?.provider_name, openrouterProviderRaw: errorDetails?.error?.metadata?.raw, }, - 'OpenRouter request failed', + `${providerLabel} request failed`, ) trackEvent({ event: AnalyticsEvent.CHAT_COMPLETIONS_ERROR, @@ -382,10 +942,31 @@ export async function postChatCompletions(params: { logger, }) - // Pass through OpenRouter provider-specific errors + // Pass through provider-specific errors if (error instanceof OpenRouterError) { return NextResponse.json(error.toJSON(), { status: error.statusCode }) } + if (error instanceof FireworksError) { + return NextResponse.json(error.toJSON(), { status: error.statusCode }) + } + if (error instanceof MoonshotError) { + return NextResponse.json(error.toJSON(), { status: error.statusCode }) + } + if (error instanceof CanopyWaveError) { + return NextResponse.json(error.toJSON(), { status: error.statusCode }) + } + if (error instanceof DeepSeekError) { + return NextResponse.json(error.toJSON(), { status: error.statusCode }) + } + if (error instanceof SiliconFlowError) { + return NextResponse.json(error.toJSON(), { status: error.statusCode }) + } + if (error instanceof OpenAIError) { + return NextResponse.json(error.toJSON(), { status: error.statusCode }) + } + if (error instanceof OpenCodeZenError) { + return NextResponse.json(error.toJSON(), { status: error.statusCode }) + } return NextResponse.json( { error: 'Failed to process request' }, diff --git a/web/src/app/api/v1/chat/completions/free-mode-rate-limiter.ts b/web/src/app/api/v1/chat/completions/free-mode-rate-limiter.ts new file mode 100644 index 0000000000..e55df567e5 --- /dev/null +++ b/web/src/app/api/v1/chat/completions/free-mode-rate-limiter.ts @@ -0,0 +1,167 @@ +/** + * In-memory rate limiter for FREE mode requests. + * + * Enforces multiple fixed-window limits per user to prevent abuse. + * Each window is anchored to the user's first request in that window + * and resets once the window duration elapses. + * + * Adjust the constants below to tune the limits. + */ + +// --------------------------------------------------------------------------- +// Configurable rate-limit constants +// --------------------------------------------------------------------------- + +export const FREE_MODE_RATE_LIMITS = { + /** Max requests per 1-second window */ + PER_SECOND: 2, + /** Max requests per 1-minute window */ + PER_MINUTE: 25, + /** Max requests per 30-minute window */ + PER_30_MINUTES: 250, + /** Max requests per 5-hour window */ + PER_5_HOURS: 2_000, + /** Max requests per 7-day window */ + PER_7_DAYS: 20_000, +} as const + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +interface RateWindow { + name: string + windowMs: number + maxRequests: number +} + +interface WindowTracker { + count: number + windowStart: number +} + +export type RateLimitResult = { + limited: false +} | { + limited: true + windowName: string + retryAfterMs: number +} + +// --------------------------------------------------------------------------- +// Window definitions (derived from the constants above) +// --------------------------------------------------------------------------- + +const SECOND_MS = 1000 +const MINUTE_MS = 60 * SECOND_MS +const HOUR_MS = 60 * MINUTE_MS +const DAY_MS = 24 * HOUR_MS + +const RATE_WINDOWS: RateWindow[] = [ + { name: '1 second', windowMs: 1 * SECOND_MS, maxRequests: FREE_MODE_RATE_LIMITS.PER_SECOND }, + { name: '1 minute', windowMs: 1 * MINUTE_MS, maxRequests: FREE_MODE_RATE_LIMITS.PER_MINUTE }, + { name: '30 minutes', windowMs: 30 * MINUTE_MS, maxRequests: FREE_MODE_RATE_LIMITS.PER_30_MINUTES }, + { name: '5 hours', windowMs: 5 * HOUR_MS, maxRequests: FREE_MODE_RATE_LIMITS.PER_5_HOURS }, + { name: '7 days', windowMs: 7 * DAY_MS, maxRequests: FREE_MODE_RATE_LIMITS.PER_7_DAYS }, +] + +// --------------------------------------------------------------------------- +// In-memory state +// --------------------------------------------------------------------------- + +// userId -> (windowName -> tracker) +const userWindows = new Map>() + +let lastCleanupTime = 0 +const CLEANUP_INTERVAL_MS = 5 * MINUTE_MS + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +function cleanupExpiredEntries(): void { + const now = Date.now() + for (const [userId, windows] of userWindows) { + for (const [windowName, tracker] of windows) { + const matchingWindow = RATE_WINDOWS.find((w) => w.name === windowName) + if (!matchingWindow) { + windows.delete(windowName) + continue + } + if (now - tracker.windowStart >= matchingWindow.windowMs) { + windows.delete(windowName) + } + } + if (windows.size === 0) { + userWindows.delete(userId) + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Check whether a free-mode request from `userId` should be rate-limited. + * + * If the request is allowed, each window's counter is incremented. + * If any window is exceeded, the request is rejected and no counters change. + */ +export function checkFreeModeRateLimit(userId: string): RateLimitResult { + const now = Date.now() + + // Periodic cleanup to prevent memory leaks + if (now - lastCleanupTime > CLEANUP_INTERVAL_MS) { + cleanupExpiredEntries() + lastCleanupTime = now + } + + let windows = userWindows.get(userId) + if (!windows) { + windows = new Map() + userWindows.set(userId, windows) + } + + // First pass: check all windows without mutating + for (const rateWindow of RATE_WINDOWS) { + let tracker = windows.get(rateWindow.name) + + // Reset the window if it has expired + if (tracker && now - tracker.windowStart >= rateWindow.windowMs) { + windows.delete(rateWindow.name) + tracker = undefined + } + + const currentCount = tracker?.count ?? 0 + if (currentCount >= rateWindow.maxRequests) { + const windowStart = tracker!.windowStart + const retryAfterMs = rateWindow.windowMs - (now - windowStart) + return { + limited: true, + windowName: rateWindow.name, + retryAfterMs: Math.max(0, retryAfterMs), + } + } + } + + // Second pass: increment all window counters (request is allowed) + for (const rateWindow of RATE_WINDOWS) { + let tracker = windows.get(rateWindow.name) + if (!tracker) { + tracker = { count: 0, windowStart: now } + windows.set(rateWindow.name, tracker) + } + tracker.count++ + } + + return { limited: false } +} + +/** + * Reset all rate-limit state. Exposed for testing. + */ +export function resetFreeModeRateLimits(): void { + userWindows.clear() + lastCleanupTime = 0 +} diff --git a/web/src/app/api/v1/chat/completions/route.ts b/web/src/app/api/v1/chat/completions/route.ts index 7b49e8232d..a6a4ace378 100644 --- a/web/src/app/api/v1/chat/completions/route.ts +++ b/web/src/app/api/v1/chat/completions/route.ts @@ -1,15 +1,30 @@ import { insertMessageBigquery } from '@codebuff/bigquery' +import { ensureSubscriberBlockGrant } from '@codebuff/billing/subscription' import { getUserUsageData } from '@codebuff/billing/usage-service' import { trackEvent } from '@codebuff/common/analytics' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { eq } from 'drizzle-orm' import { postChatCompletions } from './_post' +import type { GetUserPreferencesFn } from './_post' import type { NextRequest } from 'next/server' import { getAgentRunFromId } from '@/db/agent-run' import { getUserInfoFromApiKey } from '@/db/user' import { logger, loggerWithContext } from '@/util/logger' +const getUserPreferences: GetUserPreferencesFn = async ({ userId }) => { + const userPrefs = await db.query.user.findFirst({ + where: eq(schema.user.id, userId), + columns: { fallback_to_a_la_carte: true }, + }) + return { + fallbackToALaCarte: userPrefs?.fallback_to_a_la_carte ?? false, + } +} + export async function POST(req: NextRequest) { return postChatCompletions({ req, @@ -21,5 +36,7 @@ export async function POST(req: NextRequest) { getAgentRunFromId, fetch, insertMessageBigquery, + ensureSubscriberBlockGrant, + getUserPreferences, }) } diff --git a/web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts b/web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts index c963e2c5fe..6f3162365d 100644 --- a/web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts +++ b/web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts @@ -13,6 +13,7 @@ import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' +import type { BlockGrantResult } from '@codebuff/billing/subscription' describe('/api/v1/docs-search POST endpoint', () => { let mockLogger: Logger @@ -40,19 +41,20 @@ describe('/api/v1/docs-search POST endpoint', () => { totalDebt: 0, netBalance: 10, breakdown: {}, + principals: {}, }, nextQuotaReset: 'soon', })) mockGetUserInfoFromApiKey = mock(async ({ apiKey }) => - apiKey === 'valid' ? ({ id: 'user-1' } as any) : null, - ) - mockConsumeCreditsWithFallback = mock( - async () => - ({ success: true, value: { chargedToOrganization: false } }) as any, - ) + apiKey === 'valid' ? { id: 'user-1' } : null, + ) as GetUserInfoFromApiKeyFn + mockConsumeCreditsWithFallback = mock(async () => ({ + success: true, + value: { chargedToOrganization: false }, + })) as ConsumeCreditsWithFallbackFn // Mock fetch for Context7 search and docs endpoints - mockFetch = (async (url: any) => { + const fetchImpl = async (url: RequestInfo | URL) => { const u = typeof url === 'string' ? new URL(url) : url if (String(u).includes('/search')) { return new Response( @@ -78,7 +80,8 @@ describe('/api/v1/docs-search POST endpoint', () => { status: 200, headers: { 'Content-Type': 'text/plain' }, }) - }) as any + } + mockFetch = Object.assign(fetchImpl, { preconnect: () => {} }) as typeof fetch }) afterEach(() => { @@ -111,6 +114,7 @@ describe('/api/v1/docs-search POST endpoint', () => { totalDebt: 0, netBalance: 0, breakdown: {}, + principals: {}, }, nextQuotaReset: 'soon', })) @@ -152,4 +156,75 @@ describe('/api/v1/docs-search POST endpoint', () => { const body = await res.json() expect(body.documentation).toContain('Some documentation text') }) + + test('200 for subscriber with 0 a-la-carte credits but active block grant', async () => { + mockGetUserUsageData = mock(async ({ includeSubscriptionCredits }: { includeSubscriptionCredits?: boolean }) => ({ + usageThisCycle: 0, + balance: { + totalRemaining: includeSubscriptionCredits ? 350 : 0, + totalDebt: 0, + netBalance: includeSubscriptionCredits ? 350 : 0, + breakdown: {}, + principals: {}, + }, + nextQuotaReset: 'soon', + })) + const mockEnsureSubscriberBlockGrant = mock(async () => ({ + grantId: 'grant-1', + credits: 350, + expiresAt: new Date(Date.now() + 5 * 60 * 60 * 1000), + isNew: true, + })) as unknown as (params: { userId: string; logger: Logger }) => Promise + + const req = new NextRequest('http://localhost:3000/api/v1/docs-search', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ libraryTitle: 'React' }), + }) + const res = await postDocsSearch({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + consumeCreditsWithFallback: mockConsumeCreditsWithFallback, + fetch: mockFetch, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + }) + expect(res.status).toBe(200) + }) + + test('402 for non-subscriber with 0 credits and no block grant', async () => { + mockGetUserUsageData = mock(async () => ({ + usageThisCycle: 0, + balance: { + totalRemaining: 0, + totalDebt: 0, + netBalance: 0, + breakdown: {}, + principals: {}, + }, + nextQuotaReset: 'soon', + })) + const mockEnsureSubscriberBlockGrant = mock(async () => null) as unknown as (params: { userId: string; logger: Logger }) => Promise + + const req = new NextRequest('http://localhost:3000/api/v1/docs-search', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ libraryTitle: 'React' }), + }) + const res = await postDocsSearch({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + consumeCreditsWithFallback: mockConsumeCreditsWithFallback, + fetch: mockFetch, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + }) + expect(res.status).toBe(402) + }) }) diff --git a/web/src/app/api/v1/docs-search/_post.ts b/web/src/app/api/v1/docs-search/_post.ts index fa0b413c3d..01b4c7c4b5 100644 --- a/web/src/app/api/v1/docs-search/_post.ts +++ b/web/src/app/api/v1/docs-search/_post.ts @@ -1,5 +1,5 @@ +import { fetchContext7LibraryDocumentation } from '@codebuff/agent-runtime/llm-api/context7-api' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { PROFIT_MARGIN } from '@codebuff/common/old-constants' import { NextResponse } from 'next/server' import { z } from 'zod' @@ -8,6 +8,7 @@ import { parseJsonBody, requireUserFromApiKey, } from '../_helpers' + import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { GetUserUsageDataFn, @@ -18,9 +19,9 @@ import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' +import type { BlockGrantResult } from '@codebuff/billing/subscription' import type { NextRequest } from 'next/server' -import { fetchContext7LibraryDocumentation } from '@codebuff/agent-runtime/llm-api/context7-api' const bodySchema = z.object({ libraryTitle: z.string().min(1, 'libraryTitle is required'), @@ -38,6 +39,7 @@ export async function postDocsSearch(params: { getUserUsageData: GetUserUsageDataFn consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn fetch: typeof globalThis.fetch + ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise }) { const { req, @@ -47,6 +49,7 @@ export async function postDocsSearch(params: { getUserUsageData, consumeCreditsWithFallback, fetch, + ensureSubscriberBlockGrant, } = params const baseLogger = params.logger @@ -81,9 +84,8 @@ export async function postDocsSearch(params: { logger, }) - // Credit cost: flat 1 credit (+profit margin) - const baseCost = 1 - const creditsToCharge = Math.round(baseCost * (1 + PROFIT_MARGIN)) + // Temporarily free - charge 0 credits + const creditsToCharge = 0 const credits = await checkCreditsAndCharge({ userId, @@ -96,6 +98,7 @@ export async function postDocsSearch(params: { insufficientCreditsEvent: AnalyticsEvent.DOCS_SEARCH_INSUFFICIENT_CREDITS, getUserUsageData, consumeCreditsWithFallback, + ensureSubscriberBlockGrant, }) if (!credits.ok) return credits.response diff --git a/web/src/app/api/v1/docs-search/route.ts b/web/src/app/api/v1/docs-search/route.ts index d19d040608..df76f22a90 100644 --- a/web/src/app/api/v1/docs-search/route.ts +++ b/web/src/app/api/v1/docs-search/route.ts @@ -1,5 +1,6 @@ -import { getUserUsageData } from '@codebuff/billing/usage-service' import { consumeCreditsWithFallback } from '@codebuff/billing/credit-delegation' +import { ensureSubscriberBlockGrant } from '@codebuff/billing/subscription' +import { getUserUsageData } from '@codebuff/billing/usage-service' import { trackEvent } from '@codebuff/common/analytics' import { postDocsSearch } from './_post' @@ -19,5 +20,6 @@ export async function POST(req: NextRequest) { getUserUsageData, consumeCreditsWithFallback, fetch, + ensureSubscriberBlockGrant, }) } diff --git a/web/src/app/api/v1/feedback/__tests__/feedback.test.ts b/web/src/app/api/v1/feedback/__tests__/feedback.test.ts new file mode 100644 index 0000000000..8452e1879e --- /dev/null +++ b/web/src/app/api/v1/feedback/__tests__/feedback.test.ts @@ -0,0 +1,1015 @@ +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { MAX_RECENT_MESSAGES } from '@codebuff/common/constants/feedback' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { NextRequest } from 'next/server' + +import { postFeedback } from '../_post' + +import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' + +describe('/api/v1/feedback POST endpoint', () => { + const mockUserData: Record = { + 'test-api-key-123': { + id: 'user-123', + email: 'test@example.com', + discord_id: 'discord-123', + }, + 'test-api-key-456': { + id: 'user-456', + email: 'test2@example.com', + discord_id: null, + }, + } + + const mockGetUserInfoFromApiKey: GetUserInfoFromApiKeyFn = async ({ + apiKey, + }) => { + const userData = mockUserData[apiKey] + if (!userData) { + return null + } + return userData as Awaited> + } + + let mockLogger: Logger + let mockLoggerWithContext: LoggerWithContextFn + let mockTrackEvent: TrackEventFn + + beforeEach(() => { + mockLogger = { + error: mock(() => {}), + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), + } + mockLoggerWithContext = mock(() => mockLogger) + mockTrackEvent = mock(() => {}) + }) + + afterEach(() => { + mock.restore() + }) + + const validFeedbackBody = { + text: 'This is test feedback', + category: 'good_result', + type: 'general', + } + + const callPostFeedback = (req: NextRequest) => + postFeedback({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + }) + + describe('Authentication', () => { + test('returns 401 when Authorization header is missing', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + body: JSON.stringify(validFeedbackBody), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ message: 'Unauthorized' }) + }) + + test('returns 401 when Authorization header is malformed', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'InvalidFormat' }, + body: JSON.stringify(validFeedbackBody), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ message: 'Unauthorized' }) + }) + + test('returns 401 when API key is invalid', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer invalid-key' }, + body: JSON.stringify(validFeedbackBody), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ message: 'Invalid Codebuff API key' }) + }) + + test('tracks auth error event when API key is missing', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + body: JSON.stringify(validFeedbackBody), + }) + + await callPostFeedback(req) + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: AnalyticsEvent.FEEDBACK_AUTH_ERROR, + }), + ) + }) + + test('accepts Bearer token in Authorization header', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify(validFeedbackBody), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(200) + }) + + test('accepts x-codebuff-api-key header', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { 'x-codebuff-api-key': 'test-api-key-123' }, + body: JSON.stringify(validFeedbackBody), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(200) + }) + }) + + describe('Request validation', () => { + test('returns 400 when body is not valid JSON', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: 'not json', + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'Invalid JSON in request body' }) + }) + + test('returns 400 when text is missing', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ category: 'other', type: 'general' }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when category is missing', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ text: 'feedback', type: 'general' }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when type is missing', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ text: 'feedback', category: 'other' }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when category is not a valid enum value', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: 'feedback', + category: 'invalid_category', + type: 'general', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when type is not a valid enum value', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: 'feedback', + category: 'other', + type: 'invalid_type', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when type is message but messageId is missing', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: 'feedback', + category: 'other', + type: 'message', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when type is message and messageId is empty', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: 'feedback', + category: 'other', + type: 'message', + messageId: '', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('accepts very long text payloads', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: 'a'.repeat(20000), + category: 'other', + type: 'general', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(200) + }) + + test('returns 400 when text is empty after trim', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: '', + category: 'other', + type: 'general', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when text is whitespace-only', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: ' \n\t ', + category: 'other', + type: 'general', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when credits is negative', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + credits: -1, + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when sessionCreditsUsed is negative', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + sessionCreditsUsed: -5, + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when messageId exceeds max length', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + messageId: 'a'.repeat(201), + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when recentMessages exceeds max array length', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + recentMessages: Array.from({ length: MAX_RECENT_MESSAGES + 1 }, (_, i) => ({ type: 'user', id: `msg-${i}` })), + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when errors array exceeds max length', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + errors: Array.from({ length: 51 }, (_, i) => ({ id: `err-${i}`, message: 'error' })), + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when error message exceeds max length', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + errors: [{ id: 'err-1', message: 'a'.repeat(2001) }], + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when messageVariant is not a valid variant', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + messageVariant: 'variant-a', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when completionTime exceeds max length', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + completionTime: 'a'.repeat(51), + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when agentMode exceeds max length', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + agentMode: 'a'.repeat(101), + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when error id exceeds max length', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + errors: [{ id: 'a'.repeat(201), message: 'error' }], + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when clientFeedbackId is not a valid UUID', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + clientFeedbackId: 'not-a-uuid', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when source is not a valid enum value', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + source: 'invalid_source', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when recentMessages item type is not a valid variant', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + recentMessages: [{ type: 'invalid_variant', id: 'msg-1' }], + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when recentMessages item is missing required type field', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + recentMessages: [{ id: 'msg-1' }], + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('returns 400 when recentMessages item is missing required id field', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + recentMessages: [{ type: 'user' }], + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.error).toBe('Invalid request body') + }) + + test('accepts text with exactly 1 character after trim', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: ' x ', + category: 'other', + type: 'general', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(200) + }) + + test('tracks validation error event on invalid body', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ text: '', category: 'bad', type: 'bad' }), + }) + + await callPostFeedback(req) + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: AnalyticsEvent.FEEDBACK_VALIDATION_ERROR, + userId: 'user-123', + }), + ) + }) + }) + + describe('Boundary values (exactly at limit)', () => { + test('accepts constrained fields at their max limits', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: 'a'.repeat(5000), + category: 'good_result', + type: 'message', + messageId: 'a'.repeat(200), + messageVariant: 'ai', + completionTime: 'a'.repeat(50), + credits: 0, + agentMode: 'a'.repeat(100), + sessionCreditsUsed: 0, + clientFeedbackId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + recentMessages: Array.from({ length: MAX_RECENT_MESSAGES }, (_, i) => ({ type: 'user', id: `msg-${i}` })), + errors: Array.from({ length: 50 }, (_, i) => ({ + id: 'a'.repeat(200), + message: 'a'.repeat(2000), + })), + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ success: true }) + }) + }) + + describe('Successful responses', () => { + test('returns 200 with minimal valid feedback', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify(validFeedbackBody), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ success: true }) + }) + + test('returns 200 with all optional fields', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: 'Detailed feedback', + category: 'bad_result', + type: 'message', + messageId: 'msg-123', + messageVariant: 'ai', + completionTime: '3.5s', + credits: 42, + agentMode: 'MAX', + sessionCreditsUsed: 100, + source: 'cli', + clientFeedbackId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + recentMessages: [{ type: 'user', id: 'msg-1' }], + errors: [{ id: 'err-1', message: 'Something went wrong' }], + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ success: true }) + }) + + test('accepts all valid category values', async () => { + const categories = ['good_result', 'bad_result', 'app_bug', 'other'] as const + for (const category of categories) { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ text: 'test', category, type: 'general' }), + }) + + const response = await callPostFeedback(req) + expect(response.status).toBe(200) + } + }) + + test('accepts both valid type values', async () => { + const typesWithBody = [ + { type: 'general' }, + { type: 'message', messageId: 'msg-1' }, + ] + for (const extra of typesWithBody) { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ text: 'test', category: 'other', ...extra }), + }) + + const response = await callPostFeedback(req) + expect(response.status).toBe(200) + } + }) + + test('accepts zero credits (nonnegative allows zero)', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + credits: 0, + sessionCreditsUsed: 0, + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ success: true }) + }) + + test('trims whitespace from text before validation', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: ' actual feedback ', + category: 'other', + type: 'general', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(200) + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: AnalyticsEvent.FEEDBACK_SUBMITTED, + properties: expect.objectContaining({ + source: 'cli', + feedback: expect.objectContaining({ + text: 'actual feedback', + }), + }), + }), + ) + }) + + test('tracks FEEDBACK_SUBMITTED event with correct properties', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: 'Great feature', + category: 'good_result', + type: 'message', + messageId: 'msg-456', + messageVariant: 'user', + completionTime: '2.1s', + credits: 10, + agentMode: 'DEFAULT', + sessionCreditsUsed: 50, + }), + }) + + await callPostFeedback(req) + + expect(mockTrackEvent).toHaveBeenCalledWith({ + event: AnalyticsEvent.FEEDBACK_SUBMITTED, + userId: 'user-123', + properties: { + clientFeedbackId: null, + source: 'cli', + messageId: 'msg-456', + variant: 'user', + completionTime: '2.1s', + credits: 10, + agentMode: 'DEFAULT', + sessionCreditsUsed: 50, + recentMessages: null, + feedback: { + text: 'Great feature', + category: 'good_result', + type: 'message', + errors: null, + }, + }, + logger: mockLogger, + }) + }) + + test('emits exactly one FEEDBACK_SUBMITTED event per successful submit', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify(validFeedbackBody), + }) + + await callPostFeedback(req) + + expect(mockTrackEvent).toHaveBeenCalledTimes(1) + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: AnalyticsEvent.FEEDBACK_SUBMITTED, + }), + ) + }) + + test('tracks event with null for omitted optional fields', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify(validFeedbackBody), + }) + + await callPostFeedback(req) + + expect(mockTrackEvent).toHaveBeenCalledWith({ + event: AnalyticsEvent.FEEDBACK_SUBMITTED, + userId: 'user-123', + properties: { + clientFeedbackId: null, + source: 'cli', + messageId: null, + variant: null, + completionTime: null, + credits: null, + agentMode: null, + sessionCreditsUsed: null, + recentMessages: null, + feedback: { + text: 'This is test feedback', + category: 'good_result', + type: 'general', + errors: null, + }, + }, + logger: mockLogger, + }) + }) + + test('strips unknown fields from request body', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + unknownField: 'should be stripped', + anotherUnknown: 12345, + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(200) + const trackCall = (mockTrackEvent as ReturnType).mock.calls[0][0] as Record + const properties = trackCall.properties as Record + expect(properties).not.toHaveProperty('unknownField') + expect(properties).not.toHaveProperty('anotherUnknown') + }) + + test('uses source from payload when provided', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + source: 'sdk', + }), + }) + + await callPostFeedback(req) + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + source: 'sdk', + }), + }), + ) + }) + + test('forwards clientFeedbackId to analytics when provided', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + ...validFeedbackBody, + clientFeedbackId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }), + }) + + await callPostFeedback(req) + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + clientFeedbackId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }), + }), + ) + }) + + test('defaults source to cli when not provided', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify(validFeedbackBody), + }) + + await callPostFeedback(req) + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + source: 'cli', + }), + }), + ) + }) + + test('accepts type message with messageId', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: 'feedback', + category: 'other', + type: 'message', + messageId: 'msg-123', + }), + }) + + const response = await callPostFeedback(req) + + expect(response.status).toBe(200) + }) + + test('returns 500 when an unexpected error occurs', async () => { + const throwingGetUserInfo: typeof mockGetUserInfoFromApiKey = async () => { + throw new Error('Database connection failed') + } + + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify(validFeedbackBody), + }) + + const response = await postFeedback({ + req, + getUserInfoFromApiKey: throwingGetUserInfo, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + }) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body).toEqual({ error: 'Internal server error' }) + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.any(Error) }), + 'Error handling /api/v1/feedback request', + ) + }) + + test('logs feedback submission metadata', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/feedback', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + text: 'Bug report', + category: 'app_bug', + type: 'message', + messageId: 'msg-789', + }), + }) + + await callPostFeedback(req) + + expect(mockLogger.info).toHaveBeenCalledWith( + { userId: 'user-123', category: 'app_bug', type: 'message' }, + 'Feedback submitted', + ) + }) + }) +}) diff --git a/web/src/app/api/v1/feedback/_post.ts b/web/src/app/api/v1/feedback/_post.ts new file mode 100644 index 0000000000..eba1735a4c --- /dev/null +++ b/web/src/app/api/v1/feedback/_post.ts @@ -0,0 +1,105 @@ +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { feedbackRequestSchema } from '@codebuff/common/schemas/feedback' +import { NextResponse } from 'next/server' + +import { parseJsonBody, requireUserFromApiKey } from '../_helpers' + +import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' +import type { NextRequest } from 'next/server' + +export async function postFeedback(params: { + req: NextRequest + getUserInfoFromApiKey: GetUserInfoFromApiKeyFn + logger: Logger + loggerWithContext: LoggerWithContextFn + trackEvent: TrackEventFn +}) { + const { + req, + getUserInfoFromApiKey, + logger: baseLogger, + loggerWithContext, + trackEvent, + } = params + + // TODO: Persist feedback to a database table for durability and queryability + // TODO: Add rate limiting (e.g., 10 requests/minute per userId) + + try { + const userResult = await requireUserFromApiKey({ + req, + getUserInfoFromApiKey, + logger: baseLogger, + loggerWithContext, + trackEvent, + authErrorEvent: AnalyticsEvent.FEEDBACK_AUTH_ERROR, + }) + + if (!userResult.ok) { + return userResult.response + } + + const { userId, logger } = userResult.data + + const bodyResult = await parseJsonBody({ + req, + schema: feedbackRequestSchema, + logger, + trackEvent, + validationErrorEvent: AnalyticsEvent.FEEDBACK_VALIDATION_ERROR, + userId, + }) + + if (!bodyResult.ok) { + return bodyResult.response + } + + const feedback = bodyResult.data + + try { + const { + clientFeedbackId, source, messageId, messageVariant, + completionTime, credits, agentMode, sessionCreditsUsed, + recentMessages, text, category, type, errors, + } = feedback + + trackEvent({ + event: AnalyticsEvent.FEEDBACK_SUBMITTED, + userId, + properties: { + clientFeedbackId: clientFeedbackId ?? null, + source: source ?? 'cli', + messageId: messageId ?? null, + variant: messageVariant ?? null, + completionTime: completionTime ?? null, + credits: credits ?? null, + agentMode: agentMode ?? null, + sessionCreditsUsed: sessionCreditsUsed ?? null, + recentMessages: recentMessages ?? null, + feedback: { text, category, type, errors: errors ?? null }, + }, + logger, + }) + } catch (error) { + logger.warn({ error }, 'Failed to track feedback analytics event') + } + + logger.info( + { userId, category: feedback.category, type: feedback.type }, + 'Feedback submitted', + ) + + return NextResponse.json({ success: true }) + } catch (error) { + baseLogger.error({ error }, 'Error handling /api/v1/feedback request') + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 }, + ) + } +} diff --git a/web/src/app/api/v1/feedback/route.ts b/web/src/app/api/v1/feedback/route.ts new file mode 100644 index 0000000000..2221e6a72d --- /dev/null +++ b/web/src/app/api/v1/feedback/route.ts @@ -0,0 +1,18 @@ +import { trackEvent } from '@codebuff/common/analytics' + +import { postFeedback } from './_post' + +import type { NextRequest } from 'next/server' + +import { getUserInfoFromApiKey } from '@/db/user' +import { logger, loggerWithContext } from '@/util/logger' + +export async function POST(req: NextRequest) { + return postFeedback({ + req, + getUserInfoFromApiKey, + logger, + loggerWithContext, + trackEvent, + }) +} diff --git a/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts b/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts new file mode 100644 index 0000000000..00c1d15889 --- /dev/null +++ b/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts @@ -0,0 +1,439 @@ +import { describe, expect, test } from 'bun:test' + +import { + deleteFreebuffSession, + FREEBUFF_INSTANCE_HEADER, + FREEBUFF_MODEL_HEADER, + getFreebuffSession, + postFreebuffSession, +} from '../_handlers' +import { FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID } from '@codebuff/common/constants/freebuff-models' + +import type { FreebuffSessionDeps } from '../_handlers' +import type { FreeModeCountryAccess } from '@/server/free-mode-country' +import type { SessionDeps } from '@/server/free-session/public-api' +import type { InternalSessionRow } from '@/server/free-session/types' +import type { NextRequest } from 'next/server' + +const DEFAULT_MODEL = 'minimax/minimax-m2.7' + +function testCountryAccess(req: NextRequest): FreeModeCountryAccess { + const cfCountry = req.headers.get('cf-ipcountry')?.toUpperCase() ?? null + const hasClientIp = Boolean( + req.headers.get('x-forwarded-for') ?? + req.headers.get('cf-connecting-ip') ?? + req.headers.get('x-real-ip'), + ) + if (cfCountry === 'T1' || cfCountry === 'XX') { + return { + allowed: false, + countryCode: null, + blockReason: 'anonymized_or_unknown_country', + cfCountry, + geoipCountry: null, + ipPrivacy: null, + hasClientIp, + clientIpHash: hasClientIp ? 'test-ip-hash' : null, + } + } + if (!cfCountry || !hasClientIp) { + return { + allowed: false, + countryCode: null, + blockReason: 'missing_client_ip', + cfCountry, + geoipCountry: null, + ipPrivacy: null, + hasClientIp, + clientIpHash: hasClientIp ? 'test-ip-hash' : null, + } + } + if (cfCountry !== 'US') { + return { + allowed: false, + countryCode: cfCountry, + blockReason: 'country_not_allowed', + cfCountry, + geoipCountry: null, + ipPrivacy: null, + hasClientIp, + clientIpHash: 'test-ip-hash', + } + } + return { + allowed: true, + countryCode: cfCountry, + blockReason: null, + cfCountry, + geoipCountry: null, + ipPrivacy: { signals: [] }, + hasClientIp, + clientIpHash: 'test-ip-hash', + } +} + +function makeReq( + apiKey: string | null, + opts: { + instanceId?: string + cfCountry?: string | null + model?: string + } = {}, +): NextRequest { + const headers = new Headers() + if (apiKey) headers.set('Authorization', `Bearer ${apiKey}`) + if (opts.instanceId) headers.set(FREEBUFF_INSTANCE_HEADER, opts.instanceId) + const cfCountry = opts.cfCountry === null ? null : (opts.cfCountry ?? 'US') + if (cfCountry) { + headers.set('cf-ipcountry', cfCountry) + headers.set('cf-connecting-ip', '203.0.113.10') + } + if (opts.model) headers.set(FREEBUFF_MODEL_HEADER, opts.model) + return { + headers, + } as unknown as NextRequest +} + +function makeSessionDeps(overrides: Partial = {}): SessionDeps & { + rows: Map +} { + const rows = new Map() + const now = new Date('2026-04-17T12:00:00Z') + let instanceCounter = 0 + return { + rows, + isWaitingRoomEnabled: () => true, + graceMs: 30 * 60 * 1000, + sessionLengthMs: 60 * 60 * 1000, + // Keep instant-admit disabled in handler tests — they verify queue/state + // transitions, not admission policy. With capacity 0 the deps below + // aren't reached, so they're trivial stubs. + getInstantAdmitCapacity: () => 0, + activeCountForModel: async () => 0, + promoteQueuedUser: async () => null, + // No admits in handler tests — the rate-limit check reads empty and + // every request falls through to the queue. + listRecentPremiumAdmits: async () => [], + now: () => now, + getSessionRow: async (userId) => rows.get(userId) ?? null, + queueDepthsByModel: async () => { + const out: Record = {} + for (const r of rows.values()) { + if (r.status !== 'queued') continue + out[r.model] = (out[r.model] ?? 0) + 1 + } + return out + }, + queuePositionFor: async () => 1, + endSession: async ({ userId }) => { + rows.delete(userId) + }, + joinOrTakeOver: async ({ + userId, + model, + accessTier, + now, + countryAccess, + }) => { + const r: InternalSessionRow = { + user_id: userId, + status: 'queued', + active_instance_id: `inst-${++instanceCounter}`, + model, + access_tier: accessTier, + country_code: countryAccess?.countryCode ?? null, + cf_country: countryAccess?.cfCountry ?? null, + geoip_country: countryAccess?.geoipCountry ?? null, + country_block_reason: countryAccess?.blockReason ?? null, + ip_privacy_signals: countryAccess?.ipPrivacySignals ?? null, + client_ip_hash: countryAccess?.clientIpHash ?? null, + country_checked_at: countryAccess?.checkedAt ?? null, + queued_at: now, + admitted_at: null, + expires_at: null, + created_at: now, + updated_at: now, + } + rows.set(userId, r) + return r + }, + ...overrides, + } +} + +const LOGGER = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, +} + +function makeDeps( + sessionDeps: SessionDeps, + userId: string | null, + opts: { + banned?: boolean + getCountryAccess?: FreebuffSessionDeps['getCountryAccess'] + } = {}, +): FreebuffSessionDeps { + return { + logger: LOGGER as unknown as FreebuffSessionDeps['logger'], + getCountryAccess: + opts.getCountryAccess ?? (async (req) => testCountryAccess(req)), + getUserInfoFromApiKey: (async () => + userId + ? { id: userId, banned: opts.banned ?? false } + : undefined) as unknown as FreebuffSessionDeps['getUserInfoFromApiKey'], + sessionDeps, + } +} + +describe('POST /api/v1/freebuff/session', () => { + test('401 when Authorization header is missing', async () => { + const sessionDeps = makeSessionDeps() + const resp = await postFreebuffSession( + makeReq(null), + makeDeps(sessionDeps, null), + ) + expect(resp.status).toBe(401) + }) + + test('401 when API key is invalid', async () => { + const sessionDeps = makeSessionDeps() + const resp = await postFreebuffSession( + makeReq('bad'), + makeDeps(sessionDeps, null), + ) + expect(resp.status).toBe(401) + }) + + test('creates a queued session for authed user', async () => { + const sessionDeps = makeSessionDeps() + const resp = await postFreebuffSession( + makeReq('ok'), + makeDeps(sessionDeps, 'u1'), + ) + expect(resp.status).toBe(200) + const body = await resp.json() + expect(body.status).toBe('queued') + expect(body.instanceId).toBe('inst-1') + expect(sessionDeps.rows.get('u1')).toMatchObject({ + country_code: 'US', + cf_country: 'US', + ip_privacy_signals: [], + client_ip_hash: 'test-ip-hash', + }) + }) + + test('returns disabled when waiting room flag is off', async () => { + const sessionDeps = makeSessionDeps({ isWaitingRoomEnabled: () => false }) + const resp = await postFreebuffSession( + makeReq('ok'), + makeDeps(sessionDeps, 'u1'), + ) + const body = await resp.json() + expect(body.status).toBe('disabled') + }) + + test('creates a limited DeepSeek Flash session for disallowed country', async () => { + const sessionDeps = makeSessionDeps() + const resp = await postFreebuffSession( + makeReq('ok', { cfCountry: 'JP', model: DEFAULT_MODEL }), + makeDeps(sessionDeps, 'u1'), + ) + expect(resp.status).toBe(200) + const body = await resp.json() + expect(body.status).toBe('queued') + expect(body.accessTier).toBe('limited') + expect(body.model).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) + expect(sessionDeps.rows.get('u1')).toMatchObject({ + access_tier: 'limited', + country_code: 'JP', + country_block_reason: 'country_not_allowed', + }) + }) + + test('creates a limited DeepSeek Flash session when country is unknown', async () => { + const sessionDeps = makeSessionDeps() + const resp = await postFreebuffSession( + makeReq('ok', { cfCountry: null }), + makeDeps(sessionDeps, 'u1'), + ) + expect(resp.status).toBe(200) + const body = await resp.json() + expect(body.status).toBe('queued') + expect(body.accessTier).toBe('limited') + expect(body.model).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) + }) + + test('creates a limited DeepSeek Flash session for anonymized Cloudflare country', async () => { + const sessionDeps = makeSessionDeps() + const resp = await postFreebuffSession( + makeReq('ok', { cfCountry: 'T1' }), + makeDeps(sessionDeps, 'u1'), + ) + expect(resp.status).toBe(200) + const body = await resp.json() + expect(body.status).toBe('queued') + expect(body.accessTier).toBe('limited') + expect(body.model).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) + }) + + test('allows queue entry for allowed country', async () => { + const sessionDeps = makeSessionDeps() + const resp = await postFreebuffSession( + makeReq('ok', { cfCountry: 'US' }), + makeDeps(sessionDeps, 'u1'), + ) + const body = await resp.json() + expect(body.status).toBe('queued') + }) + + test('returns model_unavailable for legacy GLM 5.1 outside deployment hours', async () => { + const sessionDeps = makeSessionDeps() + const resp = await postFreebuffSession( + makeReq('ok', { model: 'z-ai/glm-5.1' }), + makeDeps(sessionDeps, 'u1'), + ) + expect(resp.status).toBe(409) + const body = await resp.json() + expect(body.status).toBe('model_unavailable') + expect(body.availableHours).toBe('9am ET-5pm PT every day') + expect(sessionDeps.rows.size).toBe(0) + }) + + // Banned bots with valid API keys were POSTing every few seconds and + // inflating queueDepth between the 15s admission-tick sweeps. Rejecting at + // the HTTP layer with 403 (terminal, like country_blocked) keeps them out + // entirely. Also verifies no queue row is created as a side effect. + test('returns banned 403 without joining the queue for banned user', async () => { + const sessionDeps = makeSessionDeps() + const resp = await postFreebuffSession( + makeReq('ok'), + makeDeps(sessionDeps, 'u1', { banned: true }), + ) + expect(resp.status).toBe(403) + const body = await resp.json() + expect(body.status).toBe('banned') + expect(sessionDeps.rows.size).toBe(0) + }) +}) + +describe('GET /api/v1/freebuff/session', () => { + test('returns { status: none } when user has no session', async () => { + const sessionDeps = makeSessionDeps() + const resp = await getFreebuffSession( + makeReq('ok'), + makeDeps(sessionDeps, 'u1'), + ) + expect(resp.status).toBe(200) + const body = await resp.json() + expect(body.status).toBe('none') + }) + + test('returns limited access for disallowed country on GET', async () => { + const sessionDeps = makeSessionDeps() + const resp = await getFreebuffSession( + makeReq('ok', { cfCountry: 'JP' }), + makeDeps(sessionDeps, 'u1'), + ) + expect(resp.status).toBe(200) + const body = await resp.json() + expect(body.status).toBe('none') + expect(body.accessTier).toBe('limited') + }) + + test('rechecks country on GET so access tier changes are visible immediately', async () => { + const sessionDeps = makeSessionDeps() + sessionDeps.rows.set('u1', { + user_id: 'u1', + status: 'queued', + active_instance_id: 'inst-1', + model: DEFAULT_MODEL, + access_tier: 'full', + country_code: 'US', + cf_country: 'US', + geoip_country: null, + country_block_reason: null, + ip_privacy_signals: [], + client_ip_hash: 'test-ip-hash', + country_checked_at: new Date('2026-04-17T11:45:00Z'), + queued_at: new Date('2026-04-17T11:45:00Z'), + admitted_at: null, + expires_at: null, + created_at: new Date('2026-04-17T11:45:00Z'), + updated_at: new Date('2026-04-17T11:45:00Z'), + }) + let countryChecks = 0 + const resp = await getFreebuffSession( + makeReq('ok', { cfCountry: 'JP' }), + makeDeps(sessionDeps, 'u1', { + getCountryAccess: async (req) => { + countryChecks++ + return testCountryAccess(req) + }, + }), + ) + const body = await resp.json() + expect(resp.status).toBe(200) + expect(body.status).toBe('none') + expect(body.accessTier).toBe('limited') + expect(countryChecks).toBe(1) + }) + + test('returns banned 403 on GET for banned user', async () => { + const sessionDeps = makeSessionDeps() + const resp = await getFreebuffSession( + makeReq('ok'), + makeDeps(sessionDeps, 'u1', { banned: true }), + ) + expect(resp.status).toBe(403) + const body = await resp.json() + expect(body.status).toBe('banned') + }) + + test('returns superseded when active row exists with mismatched instance id', async () => { + const sessionDeps = makeSessionDeps() + sessionDeps.rows.set('u1', { + user_id: 'u1', + status: 'active', + active_instance_id: 'real-id', + model: DEFAULT_MODEL, + queued_at: new Date(), + admitted_at: new Date(), + expires_at: new Date(Date.now() + 60_000), + created_at: new Date(), + updated_at: new Date(), + }) + const resp = await getFreebuffSession( + makeReq('ok', { instanceId: 'stale-id' }), + makeDeps(sessionDeps, 'u1'), + ) + const body = await resp.json() + expect(body.status).toBe('superseded') + }) +}) + +describe('DELETE /api/v1/freebuff/session', () => { + test('ends the session', async () => { + const sessionDeps = makeSessionDeps() + // Pre-seed a row + sessionDeps.rows.set('u1', { + user_id: 'u1', + status: 'active', + active_instance_id: 'x', + model: DEFAULT_MODEL, + queued_at: new Date(), + admitted_at: new Date(), + expires_at: new Date(Date.now() + 60_000), + created_at: new Date(), + updated_at: new Date(), + }) + const resp = await deleteFreebuffSession( + makeReq('ok'), + makeDeps(sessionDeps, 'u1'), + ) + expect(resp.status).toBe(200) + expect(sessionDeps.rows.has('u1')).toBe(false) + }) +}) diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts new file mode 100644 index 0000000000..196c0aab03 --- /dev/null +++ b/web/src/app/api/v1/freebuff/session/_handlers.ts @@ -0,0 +1,254 @@ +import { NextResponse } from 'next/server' +import { env } from '@codebuff/internal/env' + +import { + endUserSession, + getSessionState, + requestSession, +} from '@/server/free-session/public-api' +import { getFreeModeAccessTier } from '@/server/free-mode-country' +import { getCachedFreeModeCountryAccess } from '@/server/free-mode-country-access-cache' +import { extractApiKeyFromHeader } from '@/util/auth' + +import type { FreeModeCountryAccess } from '@/server/free-mode-country' +import type { FreeSessionCountryAccessMetadata } from '@/server/free-session/types' +import type { SessionDeps } from '@/server/free-session/public-api' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { NextRequest } from 'next/server' + +/** Resolves the caller's current free-mode country/privacy classification. + * This no longer blocks unsupported countries outright; the HTTP layer uses + * it to choose full vs limited Freebuff access. */ +type GetCountryAccessFn = (req: NextRequest) => Promise + +async function getCountryAccess( + userId: string, + req: NextRequest, + deps: FreebuffSessionDeps, +): Promise { + return ( + deps.getCountryAccess?.(req) ?? + getCachedFreeModeCountryAccess({ + userId, + req, + logger: deps.logger, + options: { + ipinfoToken: env.IPINFO_TOKEN, + ipHashSecret: env.NEXTAUTH_SECRET, + allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev', + forceLimited: + env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev' && + env.FREEBUFF_DEV_FORCE_LIMITED, + }, + }) + ) +} + +function toSessionCountryAccess( + countryAccess: FreeModeCountryAccess, +): FreeSessionCountryAccessMetadata { + return { + countryCode: countryAccess.countryCode, + cfCountry: countryAccess.cfCountry, + geoipCountry: countryAccess.geoipCountry, + blockReason: countryAccess.blockReason, + ipPrivacySignals: countryAccess.ipPrivacy?.signals ?? null, + clientIpHash: countryAccess.clientIpHash, + checkedAt: new Date(), + } +} + +/** Header the CLI uses to identify which instance is polling. Used by GET to + * detect when another CLI on the same account has rotated the id. */ +export const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id' +/** Header the CLI sends on POST to pick which model's queue to join. */ +export const FREEBUFF_MODEL_HEADER = 'x-freebuff-model' + +export interface FreebuffSessionDeps { + getUserInfoFromApiKey: GetUserInfoFromApiKeyFn + logger: Logger + sessionDeps?: SessionDeps + getCountryAccess?: GetCountryAccessFn +} + +type AuthResult = + | { error: NextResponse } + | { userId: string; userEmail: string | null; userBanned: boolean } + +async function resolveUser( + req: NextRequest, + deps: FreebuffSessionDeps, +): Promise { + const apiKey = extractApiKeyFromHeader(req) + if (!apiKey) { + return { + error: NextResponse.json( + { + error: 'unauthorized', + message: 'Missing or invalid Authorization header', + }, + { status: 401 }, + ), + } + } + const userInfo = await deps.getUserInfoFromApiKey({ + apiKey, + fields: ['id', 'email', 'banned'], + logger: deps.logger, + }) + if (!userInfo?.id) { + return { + error: NextResponse.json( + { error: 'unauthorized', message: 'Invalid API key' }, + { status: 401 }, + ), + } + } + return { + userId: String(userInfo.id), + userEmail: userInfo.email ?? null, + userBanned: Boolean(userInfo.banned), + } +} + +function serverError( + deps: FreebuffSessionDeps, + route: string, + userId: string | null, + error: unknown, +): NextResponse { + const err = error instanceof Error ? error : new Error(String(error)) + deps.logger.error( + { + route, + userId, + errorName: err.name, + errorMessage: err.message, + errorCode: (err as any).code, + cause: + (err as any).cause instanceof Error + ? { + name: (err as any).cause.name, + message: (err as any).cause.message, + code: (err as any).cause.code, + } + : (err as any).cause, + stack: err.stack, + }, + '[freebuff/session] handler failed', + ) + return NextResponse.json( + { error: 'internal_error', message: err.message }, + { status: 500 }, + ) +} + +/** POST /api/v1/freebuff/session — join queue / take over as this instance. */ +export async function postFreebuffSession( + req: NextRequest, + deps: FreebuffSessionDeps, +): Promise { + const auth = await resolveUser(req, deps) + if ('error' in auth) return auth.error + + const countryAccess = await getCountryAccess(auth.userId, req, deps) + const accessTier = getFreeModeAccessTier(countryAccess) + + const requestedModel = req.headers.get(FREEBUFF_MODEL_HEADER) ?? '' + + try { + const state = await requestSession({ + userId: auth.userId, + userEmail: auth.userEmail, + userBanned: auth.userBanned, + model: requestedModel, + accessTier, + countryAccess: toSessionCountryAccess(countryAccess), + deps: deps.sessionDeps, + }) + // model_locked / model_unavailable are 409 so they're distinguishable + // from normal queued/active responses on the client. banned is a 403 + // (terminal, mirrors country_blocked) so older CLIs that don't know the + // status fall into their `!resp.ok` error path and back off instead of + // tight-polling on the unrecognized 200 body. rate_limited uses 429 for + // the same reason as banned — older CLIs back off, newer CLIs parse the + // structured body. + const status = + state.status === 'model_locked' || state.status === 'model_unavailable' + ? 409 + : state.status === 'banned' + ? 403 + : state.status === 'rate_limited' + ? 429 + : 200 + return NextResponse.json(state, { status }) + } catch (error) { + return serverError(deps, 'POST', auth.userId, error) + } +} + +/** GET /api/v1/freebuff/session — read current state without mutation. The + * caller's instance id (via X-Freebuff-Instance-Id) is used to detect + * takeover by another CLI on the same account. */ +export async function getFreebuffSession( + req: NextRequest, + deps: FreebuffSessionDeps, +): Promise { + const auth = await resolveUser(req, deps) + if ('error' in auth) return auth.error + + try { + const countryAccess = await getCountryAccess(auth.userId, req, deps) + const accessTier = getFreeModeAccessTier(countryAccess) + + const claimedInstanceId = + req.headers.get(FREEBUFF_INSTANCE_HEADER) ?? undefined + const state = await getSessionState({ + userId: auth.userId, + accessTier, + userEmail: auth.userEmail, + userBanned: auth.userBanned, + claimedInstanceId, + deps: deps.sessionDeps, + }) + if (state.status === 'none') { + return NextResponse.json( + { + status: 'none', + accessTier: state.accessTier, + message: 'Call POST to join the waiting room.', + queueDepthByModel: state.queueDepthByModel, + rateLimitsByModel: state.rateLimitsByModel, + }, + { status: 200 }, + ) + } + // banned is terminal; 403 for the same reason as country_blocked — older + // CLIs that don't know this status treat it as a generic error. + const status = state.status === 'banned' ? 403 : 200 + return NextResponse.json(state, { status }) + } catch (error) { + return serverError(deps, 'GET', auth.userId, error) + } +} + +/** DELETE /api/v1/freebuff/session — end session / leave queue immediately. */ +export async function deleteFreebuffSession( + req: NextRequest, + deps: FreebuffSessionDeps, +): Promise { + const auth = await resolveUser(req, deps) + if ('error' in auth) return auth.error + + try { + await endUserSession({ + userId: auth.userId, + userEmail: auth.userEmail, + deps: deps.sessionDeps, + }) + return NextResponse.json({ status: 'ended' }, { status: 200 }) + } catch (error) { + return serverError(deps, 'DELETE', auth.userId, error) + } +} diff --git a/web/src/app/api/v1/freebuff/session/route.ts b/web/src/app/api/v1/freebuff/session/route.ts new file mode 100644 index 0000000000..3bd014d352 --- /dev/null +++ b/web/src/app/api/v1/freebuff/session/route.ts @@ -0,0 +1,27 @@ +import { + deleteFreebuffSession, + getFreebuffSession, + postFreebuffSession, +} from './_handlers' + +import { getUserInfoFromApiKey } from '@/db/user' +import { logger } from '@/util/logger' + +import type { NextRequest } from 'next/server' + +const freebuffSessionDeps = { + getUserInfoFromApiKey, + logger, +} + +export async function GET(req: NextRequest) { + return getFreebuffSession(req, freebuffSessionDeps) +} + +export async function POST(req: NextRequest) { + return postFreebuffSession(req, freebuffSessionDeps) +} + +export async function DELETE(req: NextRequest) { + return deleteFreebuffSession(req, { getUserInfoFromApiKey, logger }) +} diff --git a/web/src/app/api/v1/gravity-index/__tests__/gravity-index.test.ts b/web/src/app/api/v1/gravity-index/__tests__/gravity-index.test.ts new file mode 100644 index 0000000000..079fb1a843 --- /dev/null +++ b/web/src/app/api/v1/gravity-index/__tests__/gravity-index.test.ts @@ -0,0 +1,398 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { NextRequest } from 'next/server' + +import { postGravityIndex } from '../_post' + +import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' + +const testServerEnv = { GRAVITY_API_KEY: 'gravity-key' } + +describe('/api/v1/gravity-index POST endpoint', () => { + let mockLogger: Logger + let mockLoggerWithContext: LoggerWithContextFn + let mockTrackEvent: TrackEventFn + let mockGetUserInfoFromApiKey: GetUserInfoFromApiKeyFn + let mockFetch: typeof globalThis.fetch + let mockWarn: ReturnType + + beforeEach(() => { + mockWarn = mock(() => {}) + mockLogger = { + error: mock(() => {}), + warn: mockWarn, + info: mock(() => {}), + debug: mock(() => {}), + } + mockLoggerWithContext = mock(() => mockLogger) + mockTrackEvent = mock(() => {}) + mockGetUserInfoFromApiKey = mock(async ({ apiKey }) => + apiKey === 'valid' ? { id: 'user-1' } : null, + ) as GetUserInfoFromApiKeyFn + mockFetch = Object.assign( + mock(async () => + Response.json({ + search_id: 'search-1', + recommendation: { + name: 'SendGrid', + slug: 'sendgrid', + category: 'Email', + website_url: 'https://sendgrid.com', + docs_url: 'https://docs.sendgrid.com', + }, + reasoning: 'Best fit for transactional email.', + install: { + summary: 'Create an API key', + env_vars: ['SENDGRID_API_KEY'], + }, + conversion_url: 'https://index.trygravity.ai/go/test', + }), + ), + { preconnect: () => {} }, + ) as typeof fetch + }) + + afterEach(() => { + mock.restore() + }) + + test('401 when missing API key', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/gravity-index', { + method: 'POST', + body: JSON.stringify({ + action: 'search', + query: 'transactional email', + }), + }) + + const res = await postGravityIndex({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + fetch: mockFetch, + serverEnv: testServerEnv, + }) + + expect(res.status).toBe(401) + expect(mockFetch).not.toHaveBeenCalled() + }) + + test('503 when Gravity API key is not configured', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/gravity-index', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ + action: 'search', + query: 'transactional email', + }), + }) + + const res = await postGravityIndex({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + fetch: mockFetch, + serverEnv: {}, + }) + + expect(res.status).toBe(503) + expect(mockFetch).not.toHaveBeenCalled() + }) + + test('catalog browse does not require Gravity API key', async () => { + mockFetch = Object.assign( + mock(async () => + Response.json({ + services: [{ name: 'SendGrid', slug: 'sendgrid' }], + total: 1, + }), + ), + { preconnect: () => {} }, + ) as typeof fetch + const req = new NextRequest('http://localhost:3000/api/v1/gravity-index', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ action: 'browse', category: 'Email' }), + }) + + const res = await postGravityIndex({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + fetch: mockFetch, + serverEnv: {}, + }) + + expect(res.status).toBe(200) + expect( + (mockFetch as unknown as ReturnType).mock.calls[0][0], + ).toBe('https://index.trygravity.ai/services?category=Email') + }) + + test('sends Gravity API key only from server env', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/gravity-index', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ + action: 'search', + query: 'transactional email', + platform_api_key: 'user-supplied-key', + }), + }) + + const res = await postGravityIndex({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + fetch: mockFetch, + serverEnv: testServerEnv, + }) + + expect(res.status).toBe(200) + expect(mockFetch).toHaveBeenCalledTimes(1) + const [, init] = (mockFetch as unknown as ReturnType).mock + .calls[0] as [string, RequestInit] + expect(JSON.parse(String(init.body))).toEqual({ + query: 'transactional email', + platform_api_key: 'gravity-key', + }) + }) + + test('returns Gravity recommendation on success', async () => { + const req = new NextRequest('http://localhost:3000/api/v1/gravity-index', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ + action: 'search', + query: 'transactional email', + }), + }) + + const res = await postGravityIndex({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + fetch: mockFetch, + serverEnv: testServerEnv, + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.recommendation.name).toBe('SendGrid') + expect(body.conversion_url).toBe('https://index.trygravity.ai/go/test') + expect(body.creditsUsed).toBe(0) + }) + + test('browse maps to GET /services with filters', async () => { + mockFetch = Object.assign( + mock(async () => + Response.json({ + services: [{ name: 'SendGrid', slug: 'sendgrid' }], + total: 1, + categories: ['Email'], + }), + ), + { preconnect: () => {} }, + ) as typeof fetch + const req = new NextRequest('http://localhost:3000/api/v1/gravity-index', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ action: 'browse', category: 'Email', q: 'send' }), + }) + + const res = await postGravityIndex({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + fetch: mockFetch, + serverEnv: testServerEnv, + }) + + expect(res.status).toBe(200) + expect( + (mockFetch as unknown as ReturnType).mock.calls[0][0], + ).toBe('https://index.trygravity.ai/services?category=Email&q=send') + }) + + test('list_categories maps to GET /categories', async () => { + mockFetch = Object.assign( + mock(async () => Response.json({ categories: [], total: 0 })), + { preconnect: () => {} }, + ) as typeof fetch + const req = new NextRequest('http://localhost:3000/api/v1/gravity-index', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ action: 'list_categories' }), + }) + + const res = await postGravityIndex({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + fetch: mockFetch, + serverEnv: testServerEnv, + }) + + expect(res.status).toBe(200) + expect( + (mockFetch as unknown as ReturnType).mock.calls[0][0], + ).toBe('https://index.trygravity.ai/categories') + }) + + test('get_service maps to GET /services/{slug}', async () => { + mockFetch = Object.assign( + mock(async () => Response.json({ name: 'SendGrid', slug: 'sendgrid' })), + { preconnect: () => {} }, + ) as typeof fetch + const req = new NextRequest('http://localhost:3000/api/v1/gravity-index', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ action: 'get_service', slug: 'sendgrid' }), + }) + + const res = await postGravityIndex({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + fetch: mockFetch, + serverEnv: testServerEnv, + }) + + expect(res.status).toBe(200) + expect( + (mockFetch as unknown as ReturnType).mock.calls[0][0], + ).toBe('https://index.trygravity.ai/services/sendgrid') + }) + + test('report_integration maps to POST /integrations/report', async () => { + mockFetch = Object.assign( + mock(async () => + Response.json({ status: 'converted', slug: 'sendgrid' }), + ), + { preconnect: () => {} }, + ) as typeof fetch + const req = new NextRequest('http://localhost:3000/api/v1/gravity-index', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ + action: 'report_integration', + search_id: 'search-1', + integrated_slug: 'sendgrid', + }), + }) + + const res = await postGravityIndex({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + fetch: mockFetch, + serverEnv: testServerEnv, + }) + + expect(res.status).toBe(200) + const [, init] = (mockFetch as unknown as ReturnType).mock + .calls[0] as [string, RequestInit] + expect(JSON.parse(String(init.body))).toEqual({ + search_id: 'search-1', + integrated_slug: 'sendgrid', + platform_api_key: 'gravity-key', + }) + }) + + test('502 when Gravity upstream fails', async () => { + mockFetch = Object.assign( + mock(async () => + Response.json({ error: 'bad request' }, { status: 400 }), + ), + { preconnect: () => {} }, + ) as typeof fetch + const req = new NextRequest('http://localhost:3000/api/v1/gravity-index', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ + action: 'search', + query: 'transactional email', + }), + }) + + const res = await postGravityIndex({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + fetch: mockFetch, + serverEnv: testServerEnv, + }) + + expect(res.status).toBe(502) + expect(await res.json()).toEqual({ error: 'bad request' }) + }) + + test('redacts Gravity API key from upstream error responses and logs', async () => { + mockFetch = Object.assign( + mock( + async () => + new Response( + JSON.stringify({ + detail: [ + { + input: { + query: '', + platform_api_key: 'gravity-key', + }, + }, + ], + }), + { status: 422, headers: { 'Content-Type': 'application/json' } }, + ), + ), + { preconnect: () => {} }, + ) as typeof fetch + const req = new NextRequest('http://localhost:3000/api/v1/gravity-index', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ + action: 'search', + query: 'transactional email', + }), + }) + + const res = await postGravityIndex({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + fetch: mockFetch, + serverEnv: testServerEnv, + }) + + expect(res.status).toBe(502) + expect(JSON.stringify(await res.json())).not.toContain('gravity-key') + expect(JSON.stringify(mockWarn.mock.calls)).not.toContain('gravity-key') + expect(JSON.stringify(mockWarn.mock.calls)).toContain('[redacted]') + }) +}) diff --git a/web/src/app/api/v1/gravity-index/_post.ts b/web/src/app/api/v1/gravity-index/_post.ts new file mode 100644 index 0000000000..0bd4da00f7 --- /dev/null +++ b/web/src/app/api/v1/gravity-index/_post.ts @@ -0,0 +1,263 @@ +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { + gravityIndexActionRequiresApiKey, + gravityIndexInputSchema, +} from '@codebuff/common/types/gravity-index' +import { NextResponse } from 'next/server' + +import { parseJsonBody, requireUserFromApiKey } from '../_helpers' + +import type { GravityIndexInput } from '@codebuff/common/types/gravity-index' +import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' +import type { NextRequest } from 'next/server' + +const GRAVITY_INDEX_BASE_URL = 'https://index.trygravity.ai' +const FETCH_TIMEOUT_MS = 30_000 + +const tryParseJson = (text: string): unknown => { + try { + return JSON.parse(text) + } catch { + return null + } +} + +const getErrorMessage = (value: unknown): string | undefined => { + if (!value || typeof value !== 'object') return undefined + const record = value as Record + const message = record.error ?? record.message + return typeof message === 'string' ? message : undefined +} + +const redactGravityApiKey = ( + text: string, + gravityApiKey: string | undefined, +) => (gravityApiKey ? text.split(gravityApiKey).join('[redacted]') : text) + +const withQuery = ( + path: string, + params: Record, +) => { + const qs = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value) qs.set(key, value) + } + const query = qs.toString() + return query ? `${path}?${query}` : path +} + +const requireGravityApiKey = (gravityApiKey: string | undefined) => { + if (!gravityApiKey) { + throw new Error('GRAVITY_API_KEY is not configured') + } + return gravityApiKey +} + +const buildGravityIndexRequest = ( + input: GravityIndexInput, + gravityApiKey: string | undefined, + signal: AbortSignal, +): Parameters => { + switch (input.action) { + case 'search': { + const apiKey = requireGravityApiKey(gravityApiKey) + return [ + `${GRAVITY_INDEX_BASE_URL}/search`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: input.query, + ...(input.search_id ? { search_id: input.search_id } : {}), + ...(input.context ? { context: input.context } : {}), + platform_api_key: apiKey, + }), + signal, + }, + ] + } + case 'browse': + return [ + `${GRAVITY_INDEX_BASE_URL}${withQuery('/services', { + category: input.category, + q: input.q, + })}`, + { signal }, + ] + case 'list_categories': + return [`${GRAVITY_INDEX_BASE_URL}/categories`, { signal }] + case 'get_service': + return [ + `${GRAVITY_INDEX_BASE_URL}/services/${encodeURIComponent(input.slug)}`, + { signal }, + ] + case 'report_integration': { + const apiKey = requireGravityApiKey(gravityApiKey) + return [ + `${GRAVITY_INDEX_BASE_URL}/integrations/report`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + search_id: input.search_id, + integrated_slug: input.integrated_slug, + platform_api_key: apiKey, + }), + signal, + }, + ] + } + } +} + +export async function postGravityIndex(params: { + req: NextRequest + getUserInfoFromApiKey: GetUserInfoFromApiKeyFn + logger: Logger + loggerWithContext: LoggerWithContextFn + trackEvent: TrackEventFn + fetch: typeof globalThis.fetch + serverEnv: { + GRAVITY_API_KEY?: string + } +}) { + const { + req, + getUserInfoFromApiKey, + loggerWithContext, + trackEvent, + fetch, + serverEnv, + } = params + const baseLogger = params.logger + + const parsedBody = await parseJsonBody({ + req, + schema: gravityIndexInputSchema, + logger: baseLogger, + trackEvent, + validationErrorEvent: AnalyticsEvent.GRAVITY_INDEX_VALIDATION_ERROR, + }) + if (!parsedBody.ok) return parsedBody.response + + const authed = await requireUserFromApiKey({ + req, + getUserInfoFromApiKey, + logger: baseLogger, + loggerWithContext, + trackEvent, + authErrorEvent: AnalyticsEvent.GRAVITY_INDEX_AUTH_ERROR, + }) + if (!authed.ok) return authed.response + + const { userId, logger } = authed.data + const input = parsedBody.data + const gravityApiKey = serverEnv.GRAVITY_API_KEY + + trackEvent({ + event: AnalyticsEvent.GRAVITY_INDEX_REQUEST, + userId, + properties: { action: input.action }, + logger, + }) + + if (gravityIndexActionRequiresApiKey(input.action) && !gravityApiKey) { + logger.error('GRAVITY_API_KEY is not configured') + trackEvent({ + event: AnalyticsEvent.GRAVITY_INDEX_ERROR, + userId, + properties: { reason: 'missing_gravity_api_key' }, + logger, + }) + return NextResponse.json( + { error: 'Gravity Index is not configured' }, + { status: 503 }, + ) + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + + try { + const response = await fetch( + ...buildGravityIndexRequest(input, gravityApiKey, controller.signal), + ) + const text = await response.text() + const redactedText = redactGravityApiKey(text, gravityApiKey) + const json = tryParseJson(text) + + if (!response.ok) { + const upstreamError = getErrorMessage(json) + const error = + (upstreamError + ? redactGravityApiKey(upstreamError, gravityApiKey) + : redactedText) || 'Gravity Index failed' + logger.warn( + { + status: response.status, + statusText: response.statusText, + body: redactedText.slice(0, 500), + }, + 'Gravity Index upstream request failed', + ) + trackEvent({ + event: AnalyticsEvent.GRAVITY_INDEX_ERROR, + userId, + properties: { action: input.action, status: response.status, error }, + logger, + }) + return NextResponse.json({ error }, { status: 502 }) + } + + if (!json || typeof json !== 'object' || Array.isArray(json)) { + logger.warn( + { body: redactedText.slice(0, 500) }, + 'Invalid Gravity Index JSON', + ) + return NextResponse.json( + { error: 'Invalid Gravity Index response' }, + { status: 502 }, + ) + } + + return NextResponse.json({ + ...(json as Record), + creditsUsed: 0, + }) + } catch (error) { + const message = + error instanceof Error && error.name === 'AbortError' + ? 'Gravity Index request timed out' + : 'Error calling Gravity Index' + logger.error( + { + error: + error instanceof Error + ? { name: error.name, message: error.message, stack: error.stack } + : error, + }, + message, + ) + trackEvent({ + event: AnalyticsEvent.GRAVITY_INDEX_ERROR, + userId, + properties: { + action: input.action, + error: error instanceof Error ? error.message : 'Unknown error', + }, + logger, + }) + return NextResponse.json({ error: message }, { status: 502 }) + } finally { + clearTimeout(timeout) + } +} diff --git a/web/src/app/api/v1/gravity-index/route.ts b/web/src/app/api/v1/gravity-index/route.ts new file mode 100644 index 0000000000..dbcfb7d73c --- /dev/null +++ b/web/src/app/api/v1/gravity-index/route.ts @@ -0,0 +1,21 @@ +import { trackEvent } from '@codebuff/common/analytics' +import { env } from '@codebuff/internal/env' + +import { postGravityIndex } from './_post' + +import type { NextRequest } from 'next/server' + +import { getUserInfoFromApiKey } from '@/db/user' +import { logger, loggerWithContext } from '@/util/logger' + +export async function POST(req: NextRequest) { + return postGravityIndex({ + req, + getUserInfoFromApiKey, + logger, + loggerWithContext, + trackEvent, + fetch, + serverEnv: { GRAVITY_API_KEY: env.GRAVITY_API_KEY }, + }) +} diff --git a/web/src/app/api/v1/me/__tests__/me.test.ts b/web/src/app/api/v1/me/__tests__/me.test.ts index ead1e47674..801a2598ed 100644 --- a/web/src/app/api/v1/me/__tests__/me.test.ts +++ b/web/src/app/api/v1/me/__tests__/me.test.ts @@ -4,10 +4,10 @@ import { NextRequest } from 'next/server' import { getMe } from '../_get' +import type { VALID_USER_INFO_FIELDS } from '@/db/user' import type { AgentRuntimeDeps } from '@codebuff/common/types/contracts/agent-runtime' import type { GetUserInfoFromApiKeyOutput } from '@codebuff/common/types/contracts/database' -import { VALID_USER_INFO_FIELDS } from '@/db/user' describe('/api/v1/me route', () => { const mockUserData: Record< @@ -22,15 +22,17 @@ describe('/api/v1/me route', () => { id: 'user-123', email: 'test@example.com', discord_id: 'discord-123', - referral_code: 'ref-user-123', + stripe_customer_id: 'cus_test_123', banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }, 'test-api-key-456': { id: 'user-456', email: 'test2@example.com', discord_id: null, - referral_code: 'ref-user-456', + stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }, } @@ -44,8 +46,8 @@ describe('/api/v1/me route', () => { return null } return Object.fromEntries( - fields.map((field) => [field, userData[field]]), - ) as any + fields.map((field) => [field, userData[field as keyof typeof userData]]), + ) as Awaited> }, } }) @@ -212,7 +214,7 @@ describe('/api/v1/me route', () => { const body = await response.json() expect(body.error).toContain('Invalid fields: invalid_field') expect(body.error).toContain( - 'Valid fields are: id, email, discord_id, referral_code, referral_link', + 'Valid fields are: id, email, discord_id, stripe_customer_id, banned, created_at', ) }) @@ -302,23 +304,6 @@ describe('/api/v1/me route', () => { }) }) - test('returns referral_link when requested', async () => { - const req = new NextRequest( - 'http://localhost:3000/api/v1/me?fields=referral_link', - { - headers: { Authorization: 'Bearer test-api-key-123' }, - }, - ) - - const response = await getMe({ - ...agentRuntimeImpl, - req, - }) - expect(response.status).toBe(200) - const body = await response.json() - expect(typeof body.referral_link).toBe('string') - }) - test('handles null discord_id correctly', async () => { const req = new NextRequest( 'http://localhost:3000/api/v1/me?fields=id,discord_id', diff --git a/web/src/app/api/v1/me/_get.ts b/web/src/app/api/v1/me/_get.ts index e5b52246f4..97d275df3b 100644 --- a/web/src/app/api/v1/me/_get.ts +++ b/web/src/app/api/v1/me/_get.ts @@ -1,5 +1,4 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { getReferralLink } from '@codebuff/common/util/referral' import { NextResponse } from 'next/server' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' @@ -10,16 +9,7 @@ import type { NextRequest } from 'next/server' import { VALID_USER_INFO_FIELDS } from '@/db/user' import { extractApiKeyFromHeader } from '@/util/auth' -const DERIVED_USER_INFO_FIELDS = ['referral_link'] as const - -type DerivedField = (typeof DERIVED_USER_INFO_FIELDS)[number] -type ValidDbField = (typeof VALID_USER_INFO_FIELDS)[number] -type ValidField = ValidDbField | DerivedField - -const ALL_USER_INFO_FIELDS = [ - ...VALID_USER_INFO_FIELDS, - ...DERIVED_USER_INFO_FIELDS, -] as const +type ValidField = (typeof VALID_USER_INFO_FIELDS)[number] export async function getMe(params: { req: NextRequest @@ -51,7 +41,7 @@ export async function getMe(params: { if (requestedFields.length === 0) { return NextResponse.json( { - error: `Invalid fields: empty. Valid fields are: ${ALL_USER_INFO_FIELDS.join(', ')}`, + error: `Invalid fields: empty. Valid fields are: ${VALID_USER_INFO_FIELDS.join(', ')}`, }, { status: 400 }, ) @@ -59,7 +49,7 @@ export async function getMe(params: { // Validate that all requested fields are valid const invalidFields = requestedFields.filter( - (f) => !ALL_USER_INFO_FIELDS.includes(f as ValidField), + (f) => !VALID_USER_INFO_FIELDS.includes(f as ValidField), ) if (invalidFields.length > 0) { trackEvent({ @@ -73,7 +63,7 @@ export async function getMe(params: { }) return NextResponse.json( { - error: `Invalid fields: ${invalidFields.join(', ')}. Valid fields are: ${ALL_USER_INFO_FIELDS.join(', ')}`, + error: `Invalid fields: ${invalidFields.join(', ')}. Valid fields are: ${VALID_USER_INFO_FIELDS.join(', ')}`, }, { status: 400 }, ) @@ -84,23 +74,10 @@ export async function getMe(params: { fields = ['id'] } - // Build database field selection (exclude derived fields, always include id) - const dbFieldsSet = new Set() - - for (const field of fields) { - if (VALID_USER_INFO_FIELDS.includes(field as ValidDbField)) { - dbFieldsSet.add(field as ValidDbField) - } - } - + const dbFieldsSet = new Set(fields) // Always include id for tracking dbFieldsSet.add('id') - // If referral_link is requested, ensure we also fetch referral_code - if (fields.includes('referral_link') && !dbFieldsSet.has('referral_code')) { - dbFieldsSet.add('referral_code') - } - const dbFields = Array.from(dbFieldsSet) // Get user info @@ -127,23 +104,14 @@ export async function getMe(params: { logger, }) - // Build response including derived fields const userInfoRecord = userInfo as Partial< - Record + Record > const responseBody: Record = {} for (const field of fields) { - if (field === 'referral_link') { - const referralCode = userInfoRecord.referral_code ?? null - responseBody.referral_link = - typeof referralCode === 'string' && referralCode.length > 0 - ? getReferralLink(referralCode) - : null - } else { - responseBody[field] = userInfoRecord[field as ValidDbField] ?? null - } + responseBody[field] = userInfoRecord[field] ?? null } return NextResponse.json(responseBody) diff --git a/web/src/app/api/v1/token-count/__tests__/token-count.test.ts b/web/src/app/api/v1/token-count/__tests__/token-count.test.ts new file mode 100644 index 0000000000..22c89bf640 --- /dev/null +++ b/web/src/app/api/v1/token-count/__tests__/token-count.test.ts @@ -0,0 +1,937 @@ +import { describe, expect, it } from 'bun:test' + +import { + convertContentToAnthropic, + convertToAnthropicMessages, + convertToResponsesApiInput, + countTokensViaOpenAI, + formatToolContent, +} from '../_post' + +describe('convertContentToAnthropic', () => { + describe('image handling', () => { + it('converts base64 image with image field correctly', () => { + const content = [ + { + type: 'image', + image: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ', + mediaType: 'image/png', + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toEqual([ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ', + }, + }, + ]) + }) + + it('uses default media type when not provided', () => { + const content = [ + { + type: 'image', + image: 'base64data', + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toEqual([ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'base64data', + }, + }, + ]) + }) + + it('converts URL-based image with http://', () => { + const content = [ + { + type: 'image', + image: 'http://example.com/image.png', + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toEqual([ + { + type: 'image', + source: { + type: 'url', + url: 'http://example.com/image.png', + }, + }, + ]) + }) + + it('converts URL-based image with https://', () => { + const content = [ + { + type: 'image', + image: 'https://example.com/image.jpg', + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toEqual([ + { + type: 'image', + source: { + type: 'url', + url: 'https://example.com/image.jpg', + }, + }, + ]) + }) + + it('skips images with missing image field', () => { + const content = [ + { + type: 'image', + // No image field - this was the bug! + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toBeUndefined() + }) + + it('skips images with empty string image field', () => { + const content = [ + { + type: 'image', + image: '', + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toBeUndefined() + }) + + it('skips images with null image field', () => { + const content = [ + { + type: 'image', + image: null, + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toBeUndefined() + }) + + it('does not use legacy data/mimeType fields (regression test)', () => { + // This was the original bug - code was looking at part.data/mimeType + // instead of part.image/mediaType + const content = [ + { + type: 'image', + data: 'base64data', // old incorrect field + mimeType: 'image/png', // old incorrect field + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + // Should skip since 'image' field is missing + expect(result).toBeUndefined() + }) + + it('handles data: URI as base64 (not URL)', () => { + const content = [ + { + type: 'image', + image: 'data:image/png;base64,iVBORw0KGgo=', + mediaType: 'image/png', + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + // data: URIs don't start with http/https, so treated as base64 + expect(result).toEqual([ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'data:image/png;base64,iVBORw0KGgo=', + }, + }, + ]) + }) + + it('handles mixed content with valid image and text', () => { + const content = [ + { type: 'text', text: 'Check this image:' }, + { + type: 'image', + image: 'base64imagedata', + mediaType: 'image/jpeg', + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toEqual([ + { type: 'text', text: 'Check this image:' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/jpeg', + data: 'base64imagedata', + }, + }, + ]) + }) + + it('handles mixed content with invalid image (skips only the invalid image)', () => { + const content = [ + { type: 'text', text: 'Some text' }, + { + type: 'image', + // Missing image field + }, + { type: 'text', text: 'More text' }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toEqual([ + { type: 'text', text: 'Some text' }, + { type: 'text', text: 'More text' }, + ]) + }) + + it('handles multiple valid images', () => { + const content = [ + { + type: 'image', + image: 'image1data', + mediaType: 'image/png', + }, + { + type: 'image', + image: 'https://example.com/image2.jpg', + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toEqual([ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'image1data', + }, + }, + { + type: 'image', + source: { + type: 'url', + url: 'https://example.com/image2.jpg', + }, + }, + ]) + }) + }) + + describe('text handling', () => { + it('converts simple string content', () => { + const result = convertContentToAnthropic('Hello world', 'user') + expect(result).toBe('Hello world') + }) + + it('converts text parts', () => { + const content = [{ type: 'text', text: 'Hello' }] + const result = convertContentToAnthropic(content, 'user') + expect(result).toEqual([{ type: 'text', text: 'Hello' }]) + }) + + it('skips empty text parts', () => { + const content = [ + { type: 'text', text: ' ' }, + { type: 'text', text: 'Valid text' }, + ] + const result = convertContentToAnthropic(content, 'user') + expect(result).toEqual([{ type: 'text', text: 'Valid text' }]) + }) + }) + + describe('tool-call handling', () => { + it('converts tool-call for assistant role', () => { + const content = [ + { + type: 'tool-call', + toolCallId: 'call-123', + toolName: 'read_file', + input: { path: 'test.ts' }, + }, + ] + + const result = convertContentToAnthropic(content, 'assistant') + + expect(result).toEqual([ + { + type: 'tool_use', + id: 'call-123', + name: 'read_file', + input: { path: 'test.ts' }, + }, + ]) + }) + + it('skips tool-call for user role', () => { + const content = [ + { + type: 'tool-call', + toolCallId: 'call-123', + toolName: 'read_file', + input: { path: 'test.ts' }, + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toBeUndefined() + }) + }) + + describe('json handling', () => { + it('converts json parts with object value', () => { + const content = [ + { + type: 'json', + value: { key: 'value' }, + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toEqual([{ type: 'text', text: '{"key":"value"}' }]) + }) + + it('converts json parts with string value', () => { + const content = [ + { + type: 'json', + value: 'string value', + }, + ] + + const result = convertContentToAnthropic(content, 'user') + + expect(result).toEqual([{ type: 'text', text: 'string value' }]) + }) + }) +}) + +describe('convertToAnthropicMessages', () => { + it('skips system messages', () => { + const messages = [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'Hello' }, + ] + + const result = convertToAnthropicMessages(messages) + + expect(result).toEqual([{ role: 'user', content: 'Hello' }]) + }) + + it('converts tool messages to user messages with tool_result', () => { + const messages = [ + { + role: 'tool', + toolCallId: 'call-123', + content: 'Tool output here', + }, + ] + + const result = convertToAnthropicMessages(messages) + + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-123', + content: 'Tool output here', + }, + ], + }, + ]) + }) + + it('handles user messages with image content', () => { + const messages = [ + { + role: 'user', + content: [ + { type: 'text', text: 'Look at this' }, + { + type: 'image', + image: 'base64data', + mediaType: 'image/png', + }, + ], + }, + ] + + const result = convertToAnthropicMessages(messages) + + expect(result).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'Look at this' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'base64data', + }, + }, + ], + }, + ]) + }) + + it('skips messages with empty content after conversion', () => { + const messages = [ + { + role: 'user', + content: [{ type: 'image' }], // Invalid image, will be skipped + }, + { + role: 'user', + content: 'Valid message', + }, + ] + + const result = convertToAnthropicMessages(messages) + + expect(result).toEqual([{ role: 'user', content: 'Valid message' }]) + }) +}) + +describe('convertToResponsesApiInput', () => { + it('converts a simple user message', () => { + const result = convertToResponsesApiInput([ + { role: 'user', content: 'Hello world' }, + ]) + expect(result).toEqual([ + { type: 'message', role: 'user', content: 'Hello world' }, + ]) + }) + + it('maps system messages to developer role', () => { + const result = convertToResponsesApiInput([ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'Hi' }, + ]) + expect(result).toEqual([ + { type: 'message', role: 'developer', content: 'You are helpful' }, + { type: 'message', role: 'user', content: 'Hi' }, + ]) + }) + + it('converts tool messages to function_call_output', () => { + const result = convertToResponsesApiInput([ + { role: 'tool', toolCallId: 'call-1', content: 'File contents here' }, + ]) + expect(result).toEqual([ + { type: 'function_call_output', call_id: 'call-1', output: 'File contents here' }, + ]) + }) + + it('uses unknown call_id when toolCallId is missing', () => { + const result = convertToResponsesApiInput([ + { role: 'tool', content: 'Some output' }, + ]) + expect(result).toEqual([ + { type: 'function_call_output', call_id: 'unknown', output: 'Some output' }, + ]) + }) + + it('converts assistant messages', () => { + const result = convertToResponsesApiInput([ + { role: 'assistant', content: 'I can help with that.' }, + ]) + expect(result).toEqual([ + { type: 'message', role: 'assistant', content: 'I can help with that.' }, + ]) + }) + + it('handles array content with text parts', () => { + const result = convertToResponsesApiInput([ + { + role: 'user', + content: [{ type: 'text', text: 'What is TypeScript?' }], + }, + ]) + expect(result).toEqual([ + { type: 'message', role: 'user', content: 'What is TypeScript?' }, + ]) + }) + + it('converts tool-call content to function_call items', () => { + const result = convertToResponsesApiInput([ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'read_file', + input: { path: 'src/index.ts' }, + }, + ], + }, + ]) + expect(result).toEqual([ + { + type: 'function_call', + id: 'call-1', + name: 'read_file', + arguments: '{"path":"src/index.ts"}', + }, + ]) + }) + + it('splits assistant messages with text and tool-calls', () => { + const result = convertToResponsesApiInput([ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Let me read that file.' }, + { + type: 'tool-call', + toolCallId: 'call-2', + toolName: 'read_file', + input: { path: 'test.ts' }, + }, + ], + }, + ]) + expect(result).toEqual([ + { type: 'message', role: 'assistant', content: 'Let me read that file.' }, + { + type: 'function_call', + id: 'call-2', + name: 'read_file', + arguments: '{"path":"test.ts"}', + }, + ]) + }) + + it('handles json content parts', () => { + const result = convertToResponsesApiInput([ + { + role: 'user', + content: [{ type: 'json', value: { key: 'value' } }], + }, + ]) + expect(result).toEqual([ + { type: 'message', role: 'user', content: '{"key":"value"}' }, + ]) + }) + + it('converts a multi-turn conversation', () => { + const result = convertToResponsesApiInput([ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + { role: 'user', content: 'How are you?' }, + ]) + expect(result).toEqual([ + { type: 'message', role: 'user', content: 'Hello' }, + { type: 'message', role: 'assistant', content: 'Hi there!' }, + { type: 'message', role: 'user', content: 'How are you?' }, + ]) + }) + + describe('image handling', () => { + it('converts user message with URL image to content array', () => { + const result = convertToResponsesApiInput([ + { + role: 'user', + content: [ + { type: 'text', text: 'What is in this image?' }, + { + type: 'image', + image: 'https://example.com/photo.png', + }, + ], + }, + ]) + expect(result).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'What is in this image?' }, + { type: 'input_image', image_url: 'https://example.com/photo.png' }, + ], + }, + ]) + }) + + it('converts base64 image to data: URI', () => { + const result = convertToResponsesApiInput([ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe this' }, + { + type: 'image', + image: 'iVBORw0KGgoAAAANSUhEUg', + mediaType: 'image/png', + }, + ], + }, + ]) + expect(result).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'Describe this' }, + { type: 'input_image', image_url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg' }, + ], + }, + ]) + }) + + it('uses default media type for base64 when not specified', () => { + const result = convertToResponsesApiInput([ + { + role: 'user', + content: [ + { + type: 'image', + image: 'base64data', + }, + ], + }, + ]) + expect(result).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_image', image_url: 'data:image/png;base64,base64data' }, + ], + }, + ]) + }) + + it('passes through data: URIs as-is', () => { + const result = convertToResponsesApiInput([ + { + role: 'user', + content: [ + { + type: 'image', + image: 'data:image/jpeg;base64,/9j/4AAQ', + mediaType: 'image/jpeg', + }, + ], + }, + ]) + expect(result).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_image', image_url: 'data:image/jpeg;base64,/9j/4AAQ' }, + ], + }, + ]) + }) + + it('handles http:// image URLs', () => { + const result = convertToResponsesApiInput([ + { + role: 'user', + content: [ + { + type: 'image', + image: 'http://example.com/image.jpg', + }, + ], + }, + ]) + expect(result).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_image', image_url: 'http://example.com/image.jpg' }, + ], + }, + ]) + }) + + it('handles multiple images with text', () => { + const result = convertToResponsesApiInput([ + { + role: 'user', + content: [ + { type: 'text', text: 'Compare these images' }, + { type: 'image', image: 'https://example.com/a.png' }, + { type: 'image', image: 'https://example.com/b.png' }, + ], + }, + ]) + expect(result).toEqual([ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'Compare these images' }, + { type: 'input_image', image_url: 'https://example.com/a.png' }, + { type: 'input_image', image_url: 'https://example.com/b.png' }, + ], + }, + ]) + }) + + it('skips images with missing image field', () => { + const result = convertToResponsesApiInput([ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'image' }, + ], + }, + ]) + expect(result).toEqual([ + { type: 'message', role: 'user', content: 'Hello' }, + ]) + }) + + it('skips images with empty string image field', () => { + const result = convertToResponsesApiInput([ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'image', image: '' }, + ], + }, + ]) + expect(result).toEqual([ + { type: 'message', role: 'user', content: 'Hello' }, + ]) + }) + + it('uses plain string content when no valid images are present', () => { + const result = convertToResponsesApiInput([ + { + role: 'user', + content: [ + { type: 'text', text: 'Just text' }, + { type: 'image' }, + ], + }, + ]) + expect(result).toEqual([ + { type: 'message', role: 'user', content: 'Just text' }, + ]) + }) + }) + + it('handles a full tool-use round trip', () => { + const result = convertToResponsesApiInput([ + { role: 'user', content: 'Read the file' }, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'call-abc', + toolName: 'read_file', + input: { path: 'index.ts' }, + }, + ], + }, + { + role: 'tool', + toolCallId: 'call-abc', + content: 'console.log("hello")', + }, + { role: 'assistant', content: 'The file contains a log statement.' }, + ]) + expect(result).toEqual([ + { type: 'message', role: 'user', content: 'Read the file' }, + { + type: 'function_call', + id: 'call-abc', + name: 'read_file', + arguments: '{"path":"index.ts"}', + }, + { + type: 'function_call_output', + call_id: 'call-abc', + output: 'console.log("hello")', + }, + { + type: 'message', + role: 'assistant', + content: 'The file contains a log statement.', + }, + ]) + }) +}) + +describe('countTokensViaOpenAI', () => { + const mockLogger = { + info: () => {}, + error: () => {}, + warn: () => {}, + debug: () => {}, + } as any + + function createMockFetch(inputTokens: number) { + return (async () => + new Response(JSON.stringify({ object: 'response.input_tokens', input_tokens: inputTokens }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof globalThis.fetch + } + + it('returns token count from OpenAI API', async () => { + const result = await countTokensViaOpenAI({ + messages: [{ role: 'user', content: 'Hello world' }], + system: undefined, + model: 'openai/gpt-5.3-codex', + fetch: createMockFetch(42), + logger: mockLogger, + }) + expect(result).toBe(42) + }) + + it('passes system prompt as instructions', async () => { + let capturedBody: any + const mockFetch = async (_url: string, init: RequestInit) => { + capturedBody = JSON.parse(init.body as string) + return new Response( + JSON.stringify({ object: 'response.input_tokens', input_tokens: 10 }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + } + + await countTokensViaOpenAI({ + messages: [{ role: 'user', content: 'Hi' }], + system: 'You are a helpful assistant.', + model: 'openai/gpt-5.3', + fetch: mockFetch as any, + logger: mockLogger, + }) + + expect(capturedBody.instructions).toBe('You are a helpful assistant.') + expect(capturedBody.model).toBe('gpt-5.3') + }) + + it('strips openai/ prefix from model', async () => { + let capturedBody: any + const mockFetch = async (_url: string, init: RequestInit) => { + capturedBody = JSON.parse(init.body as string) + return new Response( + JSON.stringify({ object: 'response.input_tokens', input_tokens: 5 }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + } + + await countTokensViaOpenAI({ + messages: [{ role: 'user', content: 'Test' }], + system: undefined, + model: 'openai/gpt-5.3-codex', + fetch: mockFetch as any, + logger: mockLogger, + }) + + expect(capturedBody.model).toBe('gpt-5.3-codex') + }) + + it('omits instructions when system is undefined', async () => { + let capturedBody: any + const mockFetch = async (_url: string, init: RequestInit) => { + capturedBody = JSON.parse(init.body as string) + return new Response( + JSON.stringify({ object: 'response.input_tokens', input_tokens: 5 }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + } + + await countTokensViaOpenAI({ + messages: [{ role: 'user', content: 'Test' }], + system: undefined, + model: 'openai/gpt-5.3', + fetch: mockFetch as any, + logger: mockLogger, + }) + + expect(capturedBody.instructions).toBeUndefined() + }) + + it('throws on API error', async () => { + const mockFetch = async () => + new Response('Internal Server Error', { status: 500 }) + + await expect( + countTokensViaOpenAI({ + messages: [{ role: 'user', content: 'Test' }], + system: undefined, + model: 'openai/gpt-5.3-codex', + fetch: mockFetch as any, + logger: mockLogger, + }), + ).rejects.toThrow('OpenAI API error: 500') + }) +}) + +describe('formatToolContent', () => { + it('returns string content as-is', () => { + expect(formatToolContent('simple string')).toBe('simple string') + }) + + it('formats array content with text parts', () => { + const content = [ + { type: 'text', text: 'Line 1' }, + { type: 'text', text: 'Line 2' }, + ] + expect(formatToolContent(content)).toBe('Line 1\nLine 2') + }) + + it('formats array content with json parts', () => { + const content = [{ type: 'json', value: { key: 'value' } }] + expect(formatToolContent(content)).toBe('{"key":"value"}') + }) + + it('formats object content as JSON', () => { + const content = { key: 'value' } + expect(formatToolContent(content)).toBe('{"key":"value"}') + }) +}) diff --git a/web/src/app/api/v1/token-count/_post.ts b/web/src/app/api/v1/token-count/_post.ts new file mode 100644 index 0000000000..e37da5455d --- /dev/null +++ b/web/src/app/api/v1/token-count/_post.ts @@ -0,0 +1,491 @@ +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { + isClaudeModel, + toAnthropicModelId, +} from '@codebuff/common/constants/anthropic' +import { isOpenAIProviderModel } from '@codebuff/common/constants/chatgpt-oauth' +import { getErrorObject } from '@codebuff/common/util/error' +import { env } from '@codebuff/internal/env' +import { NextResponse } from 'next/server' +import { z } from 'zod/v4' + +import { parseJsonBody, requireUserFromApiKey } from '../_helpers' + +import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' +import type { NextRequest } from 'next/server' + +const tokenCountRequestSchema = z.object({ + messages: z.array(z.any()), + system: z.string().optional(), + model: z.string().optional(), + tools: z.array(z.object({ + name: z.string(), + description: z.string().optional(), + input_schema: z.any().optional(), + })).optional(), +}) + +type TokenCountRequest = z.infer + +const DEFAULT_ANTHROPIC_MODEL = 'claude-opus-4-6' + +export async function postTokenCount(params: { + req: NextRequest + getUserInfoFromApiKey: GetUserInfoFromApiKeyFn + logger: Logger + loggerWithContext: LoggerWithContextFn + trackEvent: TrackEventFn + fetch: typeof globalThis.fetch +}) { + const { + req, + getUserInfoFromApiKey, + logger: baseLogger, + loggerWithContext, + trackEvent, + fetch, + } = params + + // Authenticate user + const userResult = await requireUserFromApiKey({ + req, + getUserInfoFromApiKey, + logger: baseLogger, + loggerWithContext, + trackEvent, + authErrorEvent: AnalyticsEvent.TOKEN_COUNT_AUTH_ERROR, + }) + + if (!userResult.ok) { + return userResult.response + } + + const { userId, logger } = userResult.data + + // Parse request body + const bodyResult = await parseJsonBody({ + req, + schema: tokenCountRequestSchema, + logger, + trackEvent, + validationErrorEvent: AnalyticsEvent.TOKEN_COUNT_VALIDATION_ERROR, + }) + + if (!bodyResult.ok) { + return bodyResult.response + } + + const { messages, system, model, tools } = bodyResult.data + + try { + const useOpenAI = model != null && false // isOpenAIProviderModel(model) + const inputTokens = useOpenAI + ? await countTokensViaOpenAI({ messages, system, model, fetch, logger }) + : await countTokensViaAnthropic({ + messages, + system, + model, + tools, + fetch, + logger, + }) + + logger.info({ + userId, + messageCount: messages.length, + hasSystem: !!system, + hasTools: !!tools, + toolCount: tools?.length, + model: model ?? DEFAULT_ANTHROPIC_MODEL, + tokenCount: inputTokens, + provider: useOpenAI ? 'openai' : 'anthropic', + }, + `Token count: ${inputTokens}` + ) + + return NextResponse.json({ inputTokens }) + } catch (error) { + logger.error( + { error: getErrorObject(error), userId }, + 'Failed to count tokens', + ) + + return NextResponse.json( + { error: 'Failed to count tokens' }, + { status: 500 }, + ) + } +} + +// Buffer to add to token count for non-Anthropic models since tokenizers differ +const NON_ANTHROPIC_TOKEN_BUFFER = 0.3 + +export async function countTokensViaOpenAI(params: { + messages: TokenCountRequest['messages'] + system: string | undefined + model: string + fetch: typeof globalThis.fetch + logger: Logger +}): Promise { + const { messages, system, model, fetch, logger } = params + + const openaiModelId = model.startsWith('openai/') + ? model.slice('openai/'.length) + : model + + const input = convertToResponsesApiInput(messages) + + const response = await fetch( + 'https://api.openai.com/v1/responses/input_tokens', + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.OPENAI_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: openaiModelId, + input, + ...(system && { instructions: system }), + }), + }, + ) + + if (!response.ok) { + const errorText = await response.text() + logger.error( + { status: response.status, errorText, model }, + 'OpenAI token count API error', + ) + throw new Error(`OpenAI API error: ${response.status} - ${errorText}`) + } + + const data = await response.json() + return data.input_tokens +} + +export type ResponsesApiContentPart = + | { type: 'input_text'; text: string } + | { type: 'input_image'; image_url: string } + +export type ResponsesApiInputItem = + | { type: 'message'; role: 'user' | 'assistant' | 'developer'; content: string | ResponsesApiContentPart[] } + | { type: 'function_call'; id: string; name: string; arguments: string } + | { type: 'function_call_output'; call_id: string; output: string } + +export function convertToResponsesApiInput( + messages: TokenCountRequest['messages'], +): ResponsesApiInputItem[] { + const input: ResponsesApiInputItem[] = [] + + for (const message of messages) { + if (message.role === 'system') { + const content = buildMessageContent(message.content) + if (content) { + input.push({ type: 'message', role: 'developer', content }) + } + continue + } + + if (message.role === 'tool') { + input.push({ + type: 'function_call_output', + call_id: message.toolCallId ?? 'unknown', + output: formatToolContent(message.content), + }) + continue + } + + if (message.role === 'user') { + const content = buildMessageContent(message.content) + if (content) { + input.push({ type: 'message', role: 'user', content }) + } + continue + } + + if (message.role === 'assistant') { + const content = buildMessageContent(message.content) + if (content) { + input.push({ type: 'message', role: 'assistant', content }) + } + if (Array.isArray(message.content)) { + for (const part of message.content) { + if (part.type === 'tool-call') { + input.push({ + type: 'function_call', + id: part.toolCallId ?? 'unknown', + name: part.toolName, + arguments: JSON.stringify(part.input ?? {}), + }) + } + } + } + } + } + + return input +} + +function buildMessageContent( + content: unknown, +): string | ResponsesApiContentPart[] | null { + if (typeof content === 'string') return content || null + if (!Array.isArray(content)) { + const text = JSON.stringify(content) + return text || null + } + + const hasImages = content.some( + (part) => part.type === 'image' && typeof part.image === 'string' && part.image, + ) + + if (!hasImages) { + const text = extractTextParts(content) + return text || null + } + + const parts: ResponsesApiContentPart[] = [] + for (const part of content) { + if (part.type === 'text' && typeof part.text === 'string' && part.text) { + parts.push({ type: 'input_text', text: part.text }) + } else if (part.type === 'json') { + const text = typeof part.value === 'string' ? part.value : JSON.stringify(part.value) + if (text) { + parts.push({ type: 'input_text', text }) + } + } else if (part.type === 'image') { + const imageUrl = toImageUrl(part.image, part.mediaType) + if (imageUrl) { + parts.push({ type: 'input_image', image_url: imageUrl }) + } + } + } + + return parts.length > 0 ? parts : null +} + +function toImageUrl(image: unknown, mediaType?: string): string | null { + if (typeof image !== 'string' || !image) return null + if (image.startsWith('http://') || image.startsWith('https://') || image.startsWith('data:')) { + return image + } + return `data:${mediaType ?? 'image/png'};base64,${image}` +} + +function extractTextParts(content: Array>): string { + const parts: string[] = [] + for (const part of content) { + if (part.type === 'text' && typeof part.text === 'string') { + parts.push(part.text) + } else if (part.type === 'json') { + parts.push(typeof part.value === 'string' ? part.value : JSON.stringify(part.value)) + } + } + return parts.join('\n') +} + +async function countTokensViaAnthropic(params: { + messages: TokenCountRequest['messages'] + system: string | undefined + model: string | undefined + tools: TokenCountRequest['tools'] + fetch: typeof globalThis.fetch + logger: Logger +}): Promise { + const { messages, system, model, tools, fetch, logger } = params + + // Convert messages to Anthropic format + const anthropicMessages = convertToAnthropicMessages(messages) + + // Convert model from OpenRouter format (e.g. "anthropic/claude-opus-4.5") to Anthropic format (e.g. "claude-opus-4-5-20251101") + // For non-Anthropic models, use the default Anthropic model for token counting + const isNonAnthropicModel = !model || !isClaudeModel(model) + const anthropicModelId = isNonAnthropicModel + ? DEFAULT_ANTHROPIC_MODEL + : toAnthropicModelId(model) + + // Use the count_tokens endpoint (beta) or make a minimal request + const response = await fetch( + 'https://api.anthropic.com/v1/messages/count_tokens', + { + method: 'POST', + headers: { + 'x-api-key': env.ANTHROPIC_API_KEY, + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'token-counting-2024-11-01', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: anthropicModelId, + messages: anthropicMessages, + ...(system && { system }), + ...(tools && { tools }), + }), + }, + ) + + if (!response.ok) { + const errorText = await response.text() + logger.error( + { + status: response.status, + errorText, + messages: anthropicMessages, + system, + model, + }, + 'Anthropic token count API error', + ) + throw new Error(`Anthropic API error: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const baseTokens = data.input_tokens + + // Add 30% buffer for OpenAI and Gemini models since their tokenizers differ from Anthropic's + // Other non-Anthropic models (x-ai, qwen, deepseek, etc.) are routed through providers that + // use similar tokenization, so the buffer is not needed and was causing premature context pruning. + const isOpenAIModel = model ? isOpenAIProviderModel(model) : false + const isGeminiModel = model?.startsWith('google/') ?? false + if (isOpenAIModel || isGeminiModel) { + return Math.ceil(baseTokens * (1 + NON_ANTHROPIC_TOKEN_BUFFER)) + } + + return baseTokens +} + +export function convertToAnthropicMessages( + messages: TokenCountRequest['messages'], +): Array<{ role: 'user' | 'assistant'; content: any }> { + const result: Array<{ role: 'user' | 'assistant'; content: any }> = [] + + for (const message of messages) { + // Skip system messages - they're handled separately + if (message.role === 'system') { + continue + } + + // Handle tool messages by converting to user messages with tool_result + if (message.role === 'tool') { + result.push({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: message.toolCallId ?? 'unknown', + content: formatToolContent(message.content), + }, + ], + }) + continue + } + + // Handle user and assistant messages + if (message.role === 'user' || message.role === 'assistant') { + const content = convertContentToAnthropic(message.content, message.role) + if (content) { + result.push({ + role: message.role, + content, + }) + } + } + } + + return result +} + +export function convertContentToAnthropic( + content: any, + role: 'user' | 'assistant', +): any { + if (typeof content === 'string') { + return content + } + + if (!Array.isArray(content)) { + return JSON.stringify(content) + } + + const anthropicContent: any[] = [] + + for (const part of content) { + if (part.type === 'text') { + const text = part.text.trim() + if (text) { + anthropicContent.push({ type: 'text', text }) + } + } else if (part.type === 'tool-call' && role === 'assistant') { + anthropicContent.push({ + type: 'tool_use', + id: part.toolCallId ?? 'unknown', + name: part.toolName, + input: part.input ?? {}, + }) + } else if (part.type === 'image') { + // Handle image content - the image field can be base64 data or a URL string + const imageData = part.image + if (typeof imageData === 'string' && imageData) { + if ( + imageData.startsWith('http://') || + imageData.startsWith('https://') + ) { + // URL-based image + anthropicContent.push({ + type: 'image', + source: { + type: 'url', + url: imageData, + }, + }) + } else { + // Base64 encoded image data + anthropicContent.push({ + type: 'image', + source: { + type: 'base64', + media_type: part.mediaType ?? 'image/png', + data: imageData, + }, + }) + } + } + // Skip images without valid data + } else if (part.type === 'json') { + const text = + typeof part.value === 'string' + ? part.value.trim() + : JSON.stringify(part.value).trim() + if (text) { + anthropicContent.push({ + type: 'text', + text, + }) + } + } + } + + return anthropicContent.length > 0 ? anthropicContent : undefined +} + +export function formatToolContent(content: any): string { + if (typeof content === 'string') { + return content + } + if (Array.isArray(content)) { + return content + .map((part) => { + if (part.type === 'text') return part.text + if (part.type === 'json') return JSON.stringify(part.value) + return JSON.stringify(part) + }) + .join('\n') + } + return JSON.stringify(content) +} diff --git a/web/src/app/api/v1/token-count/route.ts b/web/src/app/api/v1/token-count/route.ts new file mode 100644 index 0000000000..d14cbeb7a2 --- /dev/null +++ b/web/src/app/api/v1/token-count/route.ts @@ -0,0 +1,19 @@ +import { trackEvent } from '@codebuff/common/analytics' + +import { postTokenCount } from './_post' + +import type { NextRequest } from 'next/server' + +import { getUserInfoFromApiKey } from '@/db/user' +import { logger, loggerWithContext } from '@/util/logger' + +export async function POST(req: NextRequest) { + return postTokenCount({ + req, + getUserInfoFromApiKey, + logger, + loggerWithContext, + trackEvent, + fetch, + }) +} diff --git a/web/src/app/api/v1/usage/_post.ts b/web/src/app/api/v1/usage/_post.ts index 6303671e8d..e64c34fe21 100644 --- a/web/src/app/api/v1/usage/_post.ts +++ b/web/src/app/api/v1/usage/_post.ts @@ -3,17 +3,17 @@ import { INVALID_AUTH_TOKEN_MESSAGE } from '@codebuff/common/old-constants' import { NextResponse } from 'next/server' import { z } from 'zod/v4' -import { extractApiKeyFromHeader } from '@/util/auth' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' -import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' -import type { Logger } from '@codebuff/common/types/contracts/logger' -import type { NextRequest } from 'next/server' - import type { GetOrganizationUsageResponseFn, GetUserUsageDataFn, } from '@codebuff/common/types/contracts/billing' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { NextRequest } from 'next/server' + +import { extractApiKeyFromHeader } from '@/util/auth' const usageRequestSchema = z.object({ fingerprintId: z.string(), diff --git a/web/src/app/api/v1/web-search/__tests__/web-search.test.ts b/web/src/app/api/v1/web-search/__tests__/web-search.test.ts index c7ad5b9b0d..6a30fe9d66 100644 --- a/web/src/app/api/v1/web-search/__tests__/web-search.test.ts +++ b/web/src/app/api/v1/web-search/__tests__/web-search.test.ts @@ -13,6 +13,7 @@ import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' +import type { BlockGrantResult } from '@codebuff/billing/subscription' const testServerEnv = { LINKUP_API_KEY: 'test-linkup-key' } @@ -42,23 +43,27 @@ describe('/api/v1/web-search POST endpoint', () => { totalDebt: 0, netBalance: 10, breakdown: {}, + principals: {}, }, nextQuotaReset: 'soon', })) mockGetUserInfoFromApiKey = mock(async ({ apiKey }) => - apiKey === 'valid' ? ({ id: 'user-1' } as any) : null, - ) - mockConsumeCreditsWithFallback = mock( - async () => - ({ success: true, value: { chargedToOrganization: false } }) as any, - ) + apiKey === 'valid' ? { id: 'user-1' } : null, + ) as GetUserInfoFromApiKeyFn + mockConsumeCreditsWithFallback = mock(async () => ({ + success: true, + value: { chargedToOrganization: false }, + })) as ConsumeCreditsWithFallbackFn // Mock fetch to return Linkup-like response - mockFetch = (async () => - new Response(JSON.stringify({ answer: 'result', sources: [] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })) as any + mockFetch = Object.assign( + async () => + new Response(JSON.stringify({ answer: 'result', sources: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + { preconnect: () => {} }, + ) as typeof fetch }) afterEach(() => { @@ -92,6 +97,7 @@ describe('/api/v1/web-search POST endpoint', () => { totalDebt: 0, netBalance: 0, breakdown: {}, + principals: {}, }, nextQuotaReset: 'soon', })) @@ -135,4 +141,77 @@ describe('/api/v1/web-search POST endpoint', () => { const body = await res.json() expect(body.result).toBeDefined() }) + + test('200 for subscriber with 0 a-la-carte credits but active block grant', async () => { + mockGetUserUsageData = mock(async ({ includeSubscriptionCredits }: { includeSubscriptionCredits?: boolean }) => ({ + usageThisCycle: 0, + balance: { + totalRemaining: includeSubscriptionCredits ? 350 : 0, + totalDebt: 0, + netBalance: includeSubscriptionCredits ? 350 : 0, + breakdown: {}, + principals: {}, + }, + nextQuotaReset: 'soon', + })) + const mockEnsureSubscriberBlockGrant = mock(async () => ({ + grantId: 'grant-1', + credits: 350, + expiresAt: new Date(Date.now() + 5 * 60 * 60 * 1000), + isNew: true, + })) as unknown as (params: { userId: string; logger: Logger }) => Promise + + const req = new NextRequest('http://localhost:3000/api/v1/web-search', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ query: 'hello' }), + }) + const res = await postWebSearch({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + consumeCreditsWithFallback: mockConsumeCreditsWithFallback, + fetch: mockFetch, + serverEnv: testServerEnv, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + }) + expect(res.status).toBe(200) + }) + + test('402 for non-subscriber with 0 credits and no block grant', async () => { + mockGetUserUsageData = mock(async () => ({ + usageThisCycle: 0, + balance: { + totalRemaining: 0, + totalDebt: 0, + netBalance: 0, + breakdown: {}, + principals: {}, + }, + nextQuotaReset: 'soon', + })) + const mockEnsureSubscriberBlockGrant = mock(async () => null) as unknown as (params: { userId: string; logger: Logger }) => Promise + + const req = new NextRequest('http://localhost:3000/api/v1/web-search', { + method: 'POST', + headers: { Authorization: 'Bearer valid' }, + body: JSON.stringify({ query: 'hello' }), + }) + const res = await postWebSearch({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + loggerWithContext: mockLoggerWithContext, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + consumeCreditsWithFallback: mockConsumeCreditsWithFallback, + fetch: mockFetch, + serverEnv: testServerEnv, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + }) + expect(res.status).toBe(402) + }) }) diff --git a/web/src/app/api/v1/web-search/_post.ts b/web/src/app/api/v1/web-search/_post.ts index 9b7552183b..b91df8ded1 100644 --- a/web/src/app/api/v1/web-search/_post.ts +++ b/web/src/app/api/v1/web-search/_post.ts @@ -1,5 +1,6 @@ +import { searchWeb } from '@codebuff/agent-runtime/llm-api/linkup-api' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { PROFIT_MARGIN } from '@codebuff/common/old-constants' +import { sleep } from '@codebuff/common/util/promise' import { NextResponse } from 'next/server' import { z } from 'zod' @@ -8,6 +9,8 @@ import { parseJsonBody, requireUserFromApiKey, } from '../_helpers' + +import type { LinkupEnv } from '@codebuff/agent-runtime/llm-api/linkup-api' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { GetUserUsageDataFn, @@ -18,11 +21,11 @@ import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' +import type { BlockGrantResult } from '@codebuff/billing/subscription' import type { NextRequest } from 'next/server' -import { searchWeb } from '@codebuff/agent-runtime/llm-api/linkup-api' -import type { LinkupEnv } from '@codebuff/agent-runtime/llm-api/linkup-api' + const bodySchema = z.object({ query: z.string().min(1, 'query is required'), @@ -40,6 +43,7 @@ export async function postWebSearch(params: { consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn fetch: typeof globalThis.fetch serverEnv: LinkupEnv + ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise }) { const { req, @@ -50,6 +54,7 @@ export async function postWebSearch(params: { consumeCreditsWithFallback, fetch, serverEnv, + ensureSubscriberBlockGrant, } = params const baseLogger = params.logger @@ -84,21 +89,31 @@ export async function postWebSearch(params: { logger, }) - const baseCost = depth === 'deep' ? 5 : 1 - const creditsToCharge = Math.round(baseCost * (1 + PROFIT_MARGIN)) + // Temporarily free - charge 0 credits + const creditsToCharge = 0 - const credits = await checkCreditsAndCharge({ - userId, - creditsToCharge, - repoUrl, - context: 'web search', - logger, - trackEvent, - insufficientCreditsEvent: AnalyticsEvent.WEB_SEARCH_INSUFFICIENT_CREDITS, - getUserUsageData, - consumeCreditsWithFallback, - }) - if (!credits.ok) return credits.response + // Retry credits charge up to 3 times (flaky) + let credits: Awaited> | undefined + for (let attempt = 1; attempt <= 3; attempt++) { + credits = await checkCreditsAndCharge({ + userId, + creditsToCharge, + repoUrl, + context: 'web search', + logger, + trackEvent, + insufficientCreditsEvent: AnalyticsEvent.WEB_SEARCH_INSUFFICIENT_CREDITS, + getUserUsageData, + consumeCreditsWithFallback, + ensureSubscriberBlockGrant, + }) + if (credits.ok) break + if (attempt < 3) { + await sleep(1000 * attempt) + logger.warn({ attempt }, 'Credits charge failed, retrying') + } + } + if (!credits!.ok) return credits!.response // Perform search try { @@ -119,7 +134,7 @@ export async function postWebSearch(params: { return NextResponse.json({ result, - creditsUsed: credits.data.creditsUsed, + creditsUsed: credits!.data.creditsUsed, }) } catch (error) { logger.error( diff --git a/web/src/app/api/v1/web-search/route.ts b/web/src/app/api/v1/web-search/route.ts index e682c83a7f..8e274e6e82 100644 --- a/web/src/app/api/v1/web-search/route.ts +++ b/web/src/app/api/v1/web-search/route.ts @@ -1,5 +1,6 @@ -import { getUserUsageData } from '@codebuff/billing/usage-service' import { consumeCreditsWithFallback } from '@codebuff/billing/credit-delegation' +import { ensureSubscriberBlockGrant } from '@codebuff/billing/subscription' +import { getUserUsageData } from '@codebuff/billing/usage-service' import { trackEvent } from '@codebuff/common/analytics' import { env } from '@codebuff/internal/env' @@ -21,5 +22,6 @@ export async function POST(req: NextRequest) { consumeCreditsWithFallback, fetch, serverEnv: { LINKUP_API_KEY: env.LINKUP_API_KEY }, + ensureSubscriberBlockGrant, }) } diff --git a/web/src/app/docs/[category]/[slug]/page.tsx b/web/src/app/docs/[category]/[slug]/page.tsx index 31682fc1ed..21d093d494 100644 --- a/web/src/app/docs/[category]/[slug]/page.tsx +++ b/web/src/app/docs/[category]/[slug]/page.tsx @@ -1,15 +1,151 @@ -'use client' - +import { env } from '@codebuff/common/env' import dynamic from 'next/dynamic' import NextLink from 'next/link' -import { notFound, useParams } from 'next/navigation' +import { notFound } from 'next/navigation' import React from 'react' import type { Doc } from '@/types/docs' +import { allDocs } from '.contentlayer/generated' import { Mdx } from '@/components/docs/mdx/mdx-components' import { getDocsByCategory } from '@/lib/docs' -import { allDocs } from '.contentlayer/generated' + +// Generate static params for all doc pages at build time +export function generateStaticParams(): Array<{ + category: string + slug: string +}> { + return allDocs + .filter((doc) => !doc.slug.startsWith('_')) + .map((doc) => ({ + category: doc.category, + slug: doc.slug, + })) +} + +// FAQ structured data for SEO - parsed from the FAQ MDX content +const FAQ_ITEMS = [ + { + question: 'What can Codebuff be used for?', + answer: + 'Software development: Writing features, tests, and scripts across common languages and frameworks. It can also run CLI commands, adjust build configs, review code, and answer questions about your repo.', + }, + { + question: 'What model does Codebuff use?', + answer: + 'Multiple. The orchestrator uses Claude Opus 4.7 in Default and Max modes, or Kimi K2.6 in Lite mode. Subagents are matched to their tasks: Claude Opus 4.7 and GPT-5.4 for deep reasoning and code review, and Gemini 3.1 Flash Lite for terminal commands, file discovery, and web/docs research.', + }, + { + question: 'Can I use my Claude Pro or Max subscription with Codebuff?', + answer: + 'Connecting your Claude Pro or Max subscription to Codebuff is deprecated and will be removed on March 1st. At least one user had their Anthropic account disabled after heavy usage via Codebuff. We recommend switching to a Codebuff Strong subscription instead — it includes generous usage limits across all models without needing to connect an external subscription.', + }, + { + question: 'Is Codebuff open source?', + answer: "Yes. It's Apache 2.0 at github.com/CodebuffAI/codebuff.", + }, + { + question: 'Do you store my data?', + answer: + "We don't store your codebase. The server forwards requests to model providers. We keep small slices of chat logs for debugging.", + }, + { + question: + 'Do you use model providers that train on my codebase or chat data?', + answer: + "No, we don't choose providers that will train on your data in our standard modes.", + }, + { + question: 'Can I trust Codebuff with full access to my terminal?', + answer: + 'If you want isolation, use the Dockerfile to run Codebuff against a scoped copy of your codebase.', + }, + { + question: 'Can I specify custom instructions for Codebuff?', + answer: + "Yes. Add knowledge.md files to describe patterns, constraints, and commands. Codebuff also reads AGENTS.md and CLAUDE.md if present. Per directory, it picks one: knowledge.md first, then AGENTS.md, then CLAUDE.md. Codebuff updates existing knowledge files but won't create them unless you ask.", + }, + { + question: 'Can I tell Codebuff to ignore certain files?', + answer: + 'Codebuff by default will not read files that are specified in your .gitignore. You can also create a .codebuffignore file to specify additional files or folders to ignore.', + }, + { + question: 'How does Codebuff work?', + answer: + 'Codebuff runs specialized models in parallel: one finds files, another reasons through the problem, another writes code, another reviews. A selector picks the best output. In Max mode, multiple implementations compete.', + }, + { + question: 'How does Codebuff compare to Claude Code?', + answer: + 'Codebuff is faster, cheaper, and handles large codebases better. See the detailed comparison in our documentation.', + }, +] + +function FAQJsonLd() { + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: FAQ_ITEMS.map((item) => ({ + '@type': 'Question', + name: item.question, + acceptedAnswer: { + '@type': 'Answer', + text: item.answer, + }, + })), + } + + return ( +