diff --git a/docker/.env.example b/docker/.env.example index 484e1d2e..5066a536 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -89,14 +89,6 @@ BETTER_AUTH_SECRET= # Per-tenant concurrent in-flight request limit for each backend process. # MANIFEST_CONCURRENCY_MAX=10 -# Managed free providers (optional). A master key enables automatic -# virtual-key provisioning; users can otherwise paste a key obtained through -# the provider's "Get API key" link. -# CREDITS_BASE_URL=https://credits.manifest.build -# CREDITS_MASTER_KEY= -# CREDITS_AUTO_PROVISION_ALLOWLIST= # Optional comma-separated user emails -# CREDITS_GEMINI_FREE_MAX_BUDGET=10 # Budget in USD for each generated key - # ─── Email provider (optional) ───────────────────────────────────────────── # # Unified config — used for BOTH login emails (signup verification, password diff --git a/docs/dev/manifest-guide.md b/docs/dev/manifest-guide.md index 42192e79..71e3d3ba 100644 --- a/docs/dev/manifest-guide.md +++ b/docs/dev/manifest-guide.md @@ -16,7 +16,7 @@ Manifest is a smart model router for **AI agents**. It sits between an agent and **"Harness" is the dashboard word for an agent.** The UI now labels agents **Harnesses** (nav item "Harnesses", routes under `/harnesses`, categories `AI agent` / `Automation` / `App AI SDK` / `Coding Assistant` from `CATEGORY_LABELS` in `packages/shared/src/agent-type.ts`). This is a **copy-level rename only**: backend code, database tables (`agents`, `agent_messages`, …), API routes (`/api/v1/agents/*`), and entity/service names all still say *agent*. Legacy `/agents/*` dashboard URLs redirect to `/harnesses/*`. When writing UI copy say "harness"; when writing code or API docs keep "agent". -**Product note:** this repo is **Manifest LLM Gateway**, the open-source LLM gateway of Manifest, available and maintained. The dashboard and the READMEs carry no promotion of the other Manifest product (the self-healing layer at `dashboard.manifest.build`), which is on hold. Thinker removed the dashboard's SaaS layer (#12): no API Bot banner or sidebar card, no plans/upgrade/Stripe UI, no GitHub stars, no Free Models page, no managed Gemini Free, and no cloud-mode branches (the dashboard always behaves as self-hosted). The matching backend endpoints (`/api/v1/billing*`, `/api/v1/waitlist*`, `/api/v1/github*`, `/api/v1/free-models*`) still exist until #14/#15 remove them; the dashboard no longer calls them. +**Product note:** this repo is **Manifest LLM Gateway**, the open-source LLM gateway of Manifest, available and maintained. The dashboard and the READMEs carry no promotion of the other Manifest product (the self-healing layer at `dashboard.manifest.build`), which is on hold. Thinker removed the dashboard's SaaS layer (#12): no API Bot banner or sidebar card, no plans/upgrade/Stripe UI, no GitHub stars, no Free Models page, no managed Gemini Free, and no cloud-mode branches (the dashboard always behaves as self-hosted). The matching backend endpoints are gone too: billing in #14, and the waitlist, CRM metrics feed, GitHub stars, free models, public and internal error pages and managed Gemini Free in #15. **Supported agents**: see `AGENT_PLATFORMS` in `packages/shared/src/agent-type.ts` for the current list (OpenClaw, Hermes, Claude Code, OpenCode, generic OpenAI/Anthropic SDK slots, and others — don't duplicate the list here, it grows independently of this doc). OpenClaw remains the deepest integration, but no new code or copy should frame Manifest as OpenClaw-only. When adding examples, prefer "AI agent" as the noun and pick OpenClaw as the worked example rather than the sole target. Manifest is consumed as a generic OpenAI-compatible HTTP endpoint — there are no first-party OpenClaw plugins in this repo anymore. @@ -137,15 +137,10 @@ packages/ │ │ ├── model-prices/ # Model pricing management + sync │ │ ├── notifications/ # Alert rules, email providers, cron │ │ ├── playground/ # Prompt playground (runs, columns, starred/best) -│ │ ├── github/ # GitHub stars endpoint │ │ ├── sse/ # Server-Sent Events for real-time updates │ │ ├── setup/ # First-run admin setup wizard -│ │ ├── free-models/ # Free LLM model catalog │ │ ├── model-discovery/ # Per-provider model fetching + fallback -│ │ ├── error-pages/ # Custom error-page config (internal + public) -│ │ ├── waitlist/ # Pivot waiting-list claims + legacy Autofix claim compatibility route │ │ ├── discovery/ # Self-hosted discovery onboarding (forwarded to Peacock) -│ │ ├── crm-metrics/ # Cloud-only internal feed: Autofix-healed cohort + waitlist claims + corporate signups (secret-gated) │ │ ├── cors-csp-config.ts # Wingman CORS/CSP origin allowlists │ │ ├── sentry/ # Sentry init-options builder (SENTRY_DSN-gated) │ │ ├── telemetry/ # Anonymous self-hosted telemetry @@ -429,20 +424,15 @@ Every resource belongs to a tenant; users only authenticate and (optionally) app | GET/PATCH/DELETE | `/api/v1/messages/:id/*` | Session/API Key | Request details, feedback, miscategorized flag (legacy route name) | | GET | `/api/v1/security` | Session/API Key | Security score + events | | GET | `/api/v1/model-prices` | Session/API Key | Model pricing list | -| GET | `/api/v1/free-models` | Session/API Key | Free LLM model catalog | | GET | `/api/v1/agent/usage` | Bearer / x-api-key (mnfst\_\*) | Token usage for the calling agent | | GET | `/api/v1/agent/costs` | Bearer / x-api-key (mnfst\_\*) | Cost data for the calling agent | | GET | `/api/v1/overview/*` | Session/API Key | Overview timeseries/breakdown sub-endpoints | | GET | `/api/v1/providers` / `/api/v1/providers/usage` | Session/API Key | Connected provider list + usage | | GET | `/api/v1/provider-analytics/*` | Session/API Key | Per-provider analytics | | GET | `/api/v1/errors/breakdown` | Session/API Key | Error breakdown analytics | -| POST | `/api/v1/waitlist/autofix/claim` | Public | Deprecated no-op compatibility route for older self-hosted versions | -| POST | `/api/v1/waitlist/pivot/claim` | Public | Closed pivot waiting-list claim, kept for older self-hosted versions; records origin (cloud/self-hosted) | | GET/POST | `/api/v1/autofix/status` / `.../enable-all` | Session/API Key | Workspace Autofix coverage + API-only fleet enable (no dashboard caller) | | GET | `/api/v1/overview/autofix-*` | Session/API Key | Autofix analytics (stats, timeseries, per-agent/provider/model) | | POST | `/api/v1/discovery/complete` | Session/API Key | Best-effort self-hosted discovery submission forwarded to Peacock | -| GET/POST/DELETE | `/api/v1/internal/error-pages*` | Public (`x-internal-secret` header) | Custom error-page config (Peacock CMS push API) | -| GET | `/api/v1/internal/crm-metrics*` | Public (`x-internal-secret` header) | **Cloud only.** Autofix-healed user cohort, pivot waitlist claims, and corporate signups, for the outreach CRM (`CRM_METRICS_SECRET`) | | GET/PUT/DELETE | `/api/v1/agents/:agentName/enabled-providers*` | Session/API Key | Per-agent provider enable/disable + impact preview | | GET/POST/PATCH/DELETE | `/api/v1/notifications/*` | Session/API Key | Notification rules CRUD + email provider config | | GET/POST/PUT/PATCH/DELETE | `/api/v1/routing/:agentName/*` | Session/API Key | Routing config (tiers, providers, model-params, header-tiers, custom-providers, specificity, autofix, recording, etc.) | @@ -455,7 +445,6 @@ Every resource belongs to a tenant; users only authenticate and (optionally) app | POST | `/api/v1/routing/subscription-providers` | Bearer / x-api-key (mnfst\_\*) | Subscription provider config | | GET | `/api/v1/setup/status` | Public | First-run setup status | | POST | `/api/v1/setup/admin` | Public | Create initial admin user | -| GET | `/api/v1/public/error-pages*` | Public (opt-in) | Published error pages for the marketing site (controlled by `MANIFEST_PUBLIC_STATS`) | | GET | `/v1/models` | Bearer / x-api-key (mnfst\_\*) | Available model list (proxy) | | POST | `/v1/chat/completions` | Bearer / x-api-key (mnfst\_\*) | LLM proxy (OpenAI-compatible) | | POST | `/v1/responses` | Bearer / x-api-key (mnfst\_\*) | LLM proxy (OpenAI Responses API) | @@ -464,7 +453,6 @@ Every resource belongs to a tenant; users only authenticate and (optionally) app | ALL | `/otlp/v1/*`, `/v1/{traces,metrics,logs}` | Public | Structured **410 Gone** — OTLP ingest is removed (`otlp-deprecated.controller.ts`) | | GET/POST/PATCH | `/api/v1/playground/*` | Session/API Key | Playground runs (run, list, star, mark best) | | GET | `/api/v1/events` | Session | SSE real-time events | -| GET | `/api/v1/github/stars` | Public | GitHub star count | | GET | `/api/v1/version` | Session/API Key | Running version + latest GitHub release for the self-hosted update badge (24h cache; off in cloud) | | GET | `/api/v1/me` | Session/API Key (tenant required) | Identity probe for CLI/API clients: `{ tenantId, userId, authMethod, expiresAt }`; env `API_KEY` gets 401 | | POST | `/api/v1/cli/authorize` | Session only | Browser side of `mnfst login`: mints a PKCE-bound short-lived code | @@ -520,8 +508,6 @@ See `packages/backend/.env.example` for all variables. Key ones: - `MANIFEST_MODE` — `selfhosted` or `cloud` (default: `cloud`; auto-detected as `selfhosted` inside Docker via `/.dockerenv` or Podman via `/run/.containerenv`). Self-hosted mode allows custom-provider URLs with `http://` / private IPs. `local` is accepted as a legacy alias for `selfhosted`. - `MANIFEST_TELEMETRY_DISABLED` — Set `1` to opt out of anonymous telemetry (self-hosted only). - `MANIFEST_UPDATE_CHECK_DISABLED` — Set `1` to stop the self-hosted dashboard's daily "new version available" check against GitHub Releases (`GET /api/v1/version`, `version/` module). Separate from the telemetry opt-out: air-gapped installs want no outbound calls at all. Never runs in cloud mode. -- `MANIFEST_PUBLIC_STATS` — Set `true` to serve the published error pages at `/api/v1/public/error-pages*` without auth (cloud-only marketing use). The name predates the removal of the aggregate usage endpoints; it is kept so existing deployments stay on. -- `CRM_METRICS_SECRET` — Cloud only. Shared secret for `GET /api/v1/internal/crm-metrics*` (healed-user cohort; the response carries counts only, no provider breakdown — that join cost 1.1s against the route's 1.5s `statement_timeout` at a wide window), sent in the `x-internal-secret` header. Empty by default and anything under 32 chars counts as unset, because this is the only route that exports user email addresses across tenants. Separate from `ERROR_PAGE_PUSH_SECRET` on purpose: different consumer, different credential. Self-hosted installs never register the module and migration `1802200000000` skips its index there, so the feature leaves no trace on their schema or routes. - `TELEMETRY_ENDPOINT` — Where self-hosted installs POST the anonymous usage report. Default: `https://telemetry.manifest.build/v1/report`. See [Telemetry](#anonymous-usage-telemetry-self-hosted). - `DISCOVERY_ENDPOINT` — Optional override for the discovery submission endpoint. Default: `https://blue.manifest.build/v1/self-hosted/discovery`. - `SENTRY_DSN` / `SENTRY_ENVIRONMENT` / `SENTRY_RELEASE` — Opt-in Sentry error monitoring. Unset `SENTRY_DSN` disables Sentry entirely; `SENTRY_ENVIRONMENT` defaults to `NODE_ENV`. See [Error Monitoring](#error-monitoring-sentry-opt-in). @@ -534,8 +520,8 @@ See `packages/backend/.env.example` for all variables. Key ones: - `AUTOFIX_GLOBAL_ENABLED` — Set `false` to disable Autofix for all agents (default on). Companions: `AUTOFIX_TIMEOUT_MS` (per heal call, default `10000`), `AUTOFIX_REPAIRABLE_STATUSES` (default `400,404,422`). - `AUTOFIX_REPORT_ALL_4XX` — Set `true` to stream an agent's request-side 4xx (4xx except 401/402/403/429) to Phoenix's `POST /api/heal/observe` as evidence, carrying the full request body. Serves no fix and creates no heal attempt; it only lets Phoenix see the body that failed. Wider than the heal path in scope (not limited to `AUTOFIX_REPAIRABLE_STATUSES`) but **gated to agents with Autofix on** — `AutofixService.isActiveFor()`, the same per-agent flag that healing checks. Turning Autofix on is what consents to sending failing requests to the healing service; the check fails closed. Off by default: a second, deployment-level switch on top. Manifest persists nothing; the body is secret-scrubbed, capped at 256 KB, batched, and dropped under backpressure. Skipped when Autofix already reported the same failure via `/api/heal`. See `routing/autofix/observation-reporter.ts`. - `REQUEST_RECORDING_STORAGE` / `REQUEST_RECORDING_FILESYSTEM_PATH` / `REQUEST_RECORDING_S3_*` / `REQUEST_RECORDING_RETENTION_DAYS` — Provider Attempt body recording storage + retention. See [Request Recording](#request-recording-provider-attempt-bodies). -- `CREDITS_BASE_URL` / `CREDITS_MASTER_KEY` / `CREDITS_AUTO_PROVISION_ALLOWLIST` / `CREDITS_GEMINI_FREE_MAX_BUDGET` — Managed free providers (e.g. Gemini Free) via the credits gateway (default base URL `https://credits.manifest.build`; free-provider budget default `10`). - Thinker has no billing, plans or request quotas (#14): the `STRIPE_*`, `PLAN_LIMIT_*` and `PLAN_REQUEST_QUOTA_RESET_AT` variables are gone and ignored if set. The proxy no longer checks a plan limit per request and never answers 402 on its own; `DropBillingSchema1803200000000` drops the leftover billing tables and columns. +- Thinker has no waitlist, CRM metrics feed, GitHub stars, free-models catalog, public error pages or managed Gemini Free (#15): `MANIFEST_PUBLIC_STATS`, `ERROR_PAGE_PUSH_SECRET`, `CRM_METRICS_SECRET` and `CREDITS_*` are gone and ignored if set, and so are their outbound calls (`credits.manifest.build`, `api.github.com`, the `mnfst/awesome-free-llm-apis` list on `raw.githubusercontent.com`). `DropSaasFeatureSchema1803300000000` drops `waitlist_claims`, `public_error_pages` and the cloud-only `IDX_requests_autofix_healed` index (with `lock_timeout` set), and deletes every `gemini-free` connection in `tenant_providers` together with its agent grants: the key only worked against `credits.manifest.build`. Request history keeps its `gemini-free` label, and routes that still name the provider resolve as unavailable and fall through to their fallbacks, as after a disconnect. ## Domain Terminology diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 33679bf5..8071b2c8 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -74,15 +74,6 @@ BETTER_AUTH_URL=http://localhost:3001 # MANIFEST_RATE_MAX_REQUESTS=200 # MANIFEST_IP_RATE_MAX_REQUESTS=500 -# ── Managed free providers (optional) ─────────────── -# Managed gateway used by providers such as Gemini Free. A master key enables -# automatic virtual-key provisioning; without it, users can still paste a -# virtual key obtained through the provider's "Get API key" link. -# CREDITS_BASE_URL=https://credits.manifest.build -# CREDITS_MASTER_KEY= -# CREDITS_AUTO_PROVISION_ALLOWLIST= # Optional comma-separated user emails -# CREDITS_GEMINI_FREE_MAX_BUDGET=10 # Budget in USD for each generated key - # ── Email provider (optional, unified) ────────────── # Used for BOTH Better Auth transactional emails (login verification, # password reset) and threshold alert notifications when no per-user @@ -144,8 +135,6 @@ BETTER_AUTH_URL=http://localhost:3001 # ── Internal ──────────────────────────────────────── # MANIFEST_FRONTEND_DIR= # Custom path to frontend dist directory # MANIFEST_EMBEDDED= # Set to skip auto-start (used by embedded server) -# ERROR_PAGE_PUSH_SECRET= # Shared secret for /api/v1/internal/error-pages (CMS push API) -# CRM_METRICS_SECRET= # Cloud only. Secret for /api/v1/internal/crm-metrics; min 32 chars, exports user emails # ── Release announcement email (Auto-fix / Doctor version) ── # The sending tool lives OUTSIDE this repo (sebconejo/autofix-email-to-subscribers); diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index d4149533..3efbffc3 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -25,18 +25,12 @@ import { RoutingModule } from './routing/routing.module'; import { PlaygroundModule } from './playground/playground.module'; import { CommonModule } from './common/common.module'; import { SseModule } from './sse/sse.module'; -import { GithubModule } from './github/github.module'; import { VersionModule } from './version/version.module'; -import { ErrorPagesModule } from './error-pages/error-pages.module'; import { SetupModule } from './setup/setup.module'; -import { FreeModelsModule } from './free-models/free-models.module'; import { TelemetryModule } from './telemetry/telemetry.module'; -import { WaitlistModule } from './waitlist/waitlist.module'; import { DiscoveryModule } from './discovery/discovery.module'; -import { CrmMetricsModule } from './crm-metrics/crm-metrics.module'; import { McpModule } from './mcp/mcp.module'; import { mcpAvailability } from './auth/mcp-availability'; -import { isSelfHosted } from './common/utils/detect-self-hosted'; import { DebugSentryController } from './sentry/debug-sentry.controller'; const frontendPath = resolveFrontendDir(); @@ -69,12 +63,6 @@ const sentryProviders = sentryEnabled const sentryDebugControllers = sentryEnabled && process.env['NODE_ENV'] !== 'production' ? [DebugSentryController] : []; -// The CRM metrics feed drives Cloud outreach. Leaving it unregistered on -// self-hosted means the routes do not exist there at all, rather than existing -// and answering 401 forever, and pairs with migration 1802200000000 skipping -// its index so a self-hosted install sees no trace of this feature. -const crmMetricsImports = isSelfHosted() ? [] : [CrmMetricsModule]; - // The remote MCP server is off on installs whose origin cannot carry an MCP // resource, and on installs that set MCP_ENABLED=false. Leaving the module // unregistered means `/api/v1/mcp` does not exist rather than answering an @@ -110,17 +98,12 @@ const mcpImports = mcpAvailability().enabled ? [McpModule] : []; RoutingModule, PlaygroundModule, SseModule, - GithubModule, VersionModule, - ErrorPagesModule, SetupModule, - FreeModelsModule, TelemetryModule, BackfillModule, - WaitlistModule, DiscoveryModule, ...mcpImports, - ...crmMetricsImports, ], providers: [ ...sentryProviders, diff --git a/packages/backend/src/common/constants/managed-free-providers.spec.ts b/packages/backend/src/common/constants/managed-free-providers.spec.ts deleted file mode 100644 index a0c08ec2..00000000 --- a/packages/backend/src/common/constants/managed-free-providers.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - getManagedFreeLiteLlmAutoAllowlist, - getManagedFreeLiteLlmBaseUrl, - getManagedFreeLiteLlmMasterKey, - getManagedFreeLiteLlmMaxBudgetUsd, - getManagedFreeLiteLlmModelsUrl, - getManagedFreeProviderConfig, - isManagedFreeLiteLlmAutoEligible, -} from './managed-free-providers'; - -describe('managed free provider config', () => { - const env = process.env; - - beforeEach(() => { - process.env = { ...env }; - delete process.env['CREDITS_BASE_URL']; - delete process.env['CREDITS_MASTER_KEY']; - delete process.env['CREDITS_GEMINI_FREE_MAX_BUDGET']; - delete process.env['CREDITS_AUTO_PROVISION_ALLOWLIST']; - }); - - afterAll(() => { - process.env = env; - }); - - it('defaults to the managed gateway', () => { - expect(getManagedFreeLiteLlmBaseUrl()).toBe('https://credits.manifest.build'); - }); - - it('strips trailing slashes from the base URL', () => { - process.env['CREDITS_BASE_URL'] = 'https://example.com/credits/'; - expect(getManagedFreeLiteLlmBaseUrl()).toBe('https://example.com/credits'); - }); - - it('defaults the maximum budget to 10', () => { - const config = getManagedFreeProviderConfig('gemini-free')!; - expect(getManagedFreeLiteLlmMaxBudgetUsd(config)).toBe(10); - }); - - it('parses valid budgets and rejects invalid values', () => { - const config = getManagedFreeProviderConfig('gemini-free')!; - process.env['CREDITS_GEMINI_FREE_MAX_BUDGET'] = '12.5'; - expect(getManagedFreeLiteLlmMaxBudgetUsd(config)).toBe(12.5); - process.env['CREDITS_GEMINI_FREE_MAX_BUDGET'] = '-1'; - expect(getManagedFreeLiteLlmMaxBudgetUsd(config)).toBe(10); - process.env['CREDITS_GEMINI_FREE_MAX_BUDGET'] = 'invalid'; - expect(getManagedFreeLiteLlmMaxBudgetUsd(config)).toBe(10); - }); - - it('builds the model catalog URL from the configured gateway', () => { - process.env['CREDITS_BASE_URL'] = 'https://credits.test/'; - expect(getManagedFreeLiteLlmModelsUrl()).toBe('https://credits.test/v1/models'); - }); - - it('parses allowlisted emails', () => { - process.env['CREDITS_AUTO_PROVISION_ALLOWLIST'] = 'A@x.com, b@y.com '; - expect(getManagedFreeLiteLlmAutoAllowlist()).toEqual(['a@x.com', 'b@y.com']); - }); - - it('requires the master key for automatic provisioning', () => { - expect(isManagedFreeLiteLlmAutoEligible('a@x.com')).toBe(false); - process.env['CREDITS_MASTER_KEY'] = 'sk-test'; - expect(isManagedFreeLiteLlmAutoEligible('a@x.com')).toBe(true); - expect(getManagedFreeLiteLlmMasterKey()).toBe('sk-test'); - }); - - it('honors the allowlist when configured', () => { - process.env['CREDITS_MASTER_KEY'] = 'sk-test'; - process.env['CREDITS_AUTO_PROVISION_ALLOWLIST'] = 'allowed@example.com'; - expect(isManagedFreeLiteLlmAutoEligible('allowed@example.com')).toBe(true); - expect(isManagedFreeLiteLlmAutoEligible('other@example.com')).toBe(false); - }); - - it('keeps Gemini-specific values in one copyable config entry', () => { - expect(getManagedFreeProviderConfig('gemini-free')).toMatchObject({ - id: 'gemini-free', - displayName: 'Gemini Free', - litellmModels: ['gemini/*'], - catalogModelIdPrefix: 'gemini-', - preferredModelIdPrefix: 'gemini/', - maxBudgetEnvVar: 'CREDITS_GEMINI_FREE_MAX_BUDGET', - }); - }); -}); diff --git a/packages/backend/src/common/constants/managed-free-providers.ts b/packages/backend/src/common/constants/managed-free-providers.ts deleted file mode 100644 index 78653450..00000000 --- a/packages/backend/src/common/constants/managed-free-providers.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Configuration for a Manifest-managed free provider backed by LiteLLM. - * - * To add another free provider, copy the Gemini Free entry below and change - * its identity, LiteLLM model group, catalog prefix, filter, and budget env. - * Provisioning, discovery, routing, and connection limits consume this config. - */ -export interface ManagedFreeProviderConfig { - id: string; - displayName: string; - litellmModels: readonly string[]; - catalogModelIdPrefix: string; - preferredModelIdPrefix: string; - nonChatModelPattern: RegExp; - maxBudgetEnvVar: string; -} - -export const MANAGED_FREE_PROVIDER_CONFIGS: readonly ManagedFreeProviderConfig[] = [ - { - id: 'gemini-free', - displayName: 'Gemini Free', - litellmModels: ['gemini/*'], - catalogModelIdPrefix: 'gemini-', - preferredModelIdPrefix: 'gemini/', - nonChatModelPattern: - /(?:\*|(?:^|\/)aqs-|nano-banana|deep-research|computer-use|lyria|veo-|native-audio|audio-preview|image-generation|(?:^|\/)imagen|-image(?:$|-|\/)|live-preview|(?:^|\/|-)live-|embedding|robotics|tts|gemini-1\.5|gemini-2\.0-flash-001|gemini-2\.0-flash-lite|flash-lite-preview-\d|\/gemini-exp-|-exp-\d|learnlm|gemma-|omni-flash|customtools)/i, - maxBudgetEnvVar: 'CREDITS_GEMINI_FREE_MAX_BUDGET', - }, -]; - -export const MANAGED_FREE_PROVIDER_BY_ID: ReadonlyMap = new Map( - MANAGED_FREE_PROVIDER_CONFIGS.map((config) => [config.id, config]), -); - -export function getManagedFreeProviderConfig( - providerId: string, -): ManagedFreeProviderConfig | undefined { - return MANAGED_FREE_PROVIDER_BY_ID.get(providerId.toLowerCase()); -} - -const DEFAULT_CREDITS_BASE_URL = 'https://credits.manifest.build'; -const DEFAULT_MAX_BUDGET_USD = 10; - -export function getManagedFreeLiteLlmBaseUrl(): string { - const raw = process.env['CREDITS_BASE_URL']?.trim() || DEFAULT_CREDITS_BASE_URL; - return raw.replace(/\/+$/, ''); -} - -export function getManagedFreeLiteLlmMasterKey(): string | null { - const key = process.env['CREDITS_MASTER_KEY']?.trim(); - return key && key.length > 0 ? key : null; -} - -export function getManagedFreeLiteLlmMaxBudgetUsd(config: ManagedFreeProviderConfig): number { - const raw = process.env[config.maxBudgetEnvVar]?.trim(); - if (!raw) return DEFAULT_MAX_BUDGET_USD; - const value = Number(raw); - return Number.isFinite(value) && value >= 0 ? value : DEFAULT_MAX_BUDGET_USD; -} - -/** Comma-separated emails. Empty means any user is eligible when a master key is configured. */ -export function getManagedFreeLiteLlmAutoAllowlist(): string[] { - const raw = process.env['CREDITS_AUTO_PROVISION_ALLOWLIST']?.trim() ?? ''; - if (!raw) return []; - return raw - .split(',') - .map((email) => email.trim().toLowerCase()) - .filter(Boolean); -} - -export function isManagedFreeLiteLlmAutoEligible(email: string | null | undefined): boolean { - if (!getManagedFreeLiteLlmMasterKey()) return false; - const allowlist = getManagedFreeLiteLlmAutoAllowlist(); - if (allowlist.length === 0) return true; - if (!email) return false; - return allowlist.includes(email.trim().toLowerCase()); -} - -export function getManagedFreeLiteLlmModelsUrl(): string { - return `${getManagedFreeLiteLlmBaseUrl()}/v1/models`; -} - -export function getManagedFreeLiteLlmKeyGenerateUrl(): string { - return `${getManagedFreeLiteLlmBaseUrl()}/key/generate`; -} diff --git a/packages/backend/src/common/utils/dashboard-url.ts b/packages/backend/src/common/utils/dashboard-url.ts index 3125e50e..4ea7e6f9 100644 --- a/packages/backend/src/common/utils/dashboard-url.ts +++ b/packages/backend/src/common/utils/dashboard-url.ts @@ -1,4 +1,4 @@ -/** Dashboard origin only; API and waitlist destinations are configured separately. */ +/** Dashboard origin only; API destinations are configured separately. */ export const DEFAULT_DASHBOARD_URL = 'https://app.manifest.build'; /** Emails need absolute URLs because they are read outside the dashboard. */ diff --git a/packages/backend/src/config/app.config.ts b/packages/backend/src/config/app.config.ts index b48f0da3..42a05f76 100644 --- a/packages/backend/src/config/app.config.ts +++ b/packages/backend/src/config/app.config.ts @@ -52,10 +52,6 @@ export const appConfig = registerAs('app', () => ({ // routing during the post-SIGTERM deregistration lag (the rolling-deploy 5xx // spike). Must be shorter than Railway's `drainingSeconds`. 0 disables it. shutdownDrainMs: Number(process.env['SHUTDOWN_DRAIN_MS'] ?? 10000), - // When true, /api/v1/public/error-pages serves the published error pages - // without auth. Off by default; only Manifest Cloud's marketing site uses it. - // The variable keeps its historical name so existing deployments stay on. - publicErrorPagesEnabled: process.env['MANIFEST_PUBLIC_STATS'] === 'true', // Optional instance-wide override. When unset, request recordings are kept // for the 365-day default. requestRecordingRetentionDays: optionalPositiveInteger( @@ -72,18 +68,6 @@ export const appConfig = registerAs('app', () => ({ requestRecordingS3AccessKeyId: process.env['REQUEST_RECORDING_S3_ACCESS_KEY_ID'] ?? '', requestRecordingS3SecretAccessKey: process.env['REQUEST_RECORDING_S3_SECRET_ACCESS_KEY'] ?? '', requestRecordingS3ForcePathStyle: process.env['REQUEST_RECORDING_S3_FORCE_PATH_STYLE'] === 'true', - // Shared secret guarding the internal error-page push endpoint - // (/api/v1/internal/error-pages). The Peacock CMS sends it in the - // `x-internal-secret` header to publish/unpublish curated error pages. - // Empty by default — the endpoint rejects all writes until it is set. - errorPagePushSecret: process.env['ERROR_PAGE_PUSH_SECRET'] ?? '', - // Shared secret guarding the internal CRM metrics feed - // (/api/v1/internal/crm-metrics), which the outreach CRM polls daily. Sent in - // the `x-internal-secret` header. Deliberately separate from - // errorPagePushSecret: different consumer, different credential. Empty by - // default, and anything shorter than 32 chars counts as unset — this route - // exports user email addresses across tenants. - crmMetricsSecret: process.env['CRM_METRICS_SECRET'] ?? '', // Sliding lifetime (days) of a CLI-minted management PAT. Every successful // authentication pushes the key's `expires_at` this far into the future, so // an active CLI never has to re-login and an abandoned one lapses. diff --git a/packages/backend/src/cors-csp-config.spec.ts b/packages/backend/src/cors-csp-config.spec.ts index 1054c400..b3e28ac9 100644 --- a/packages/backend/src/cors-csp-config.spec.ts +++ b/packages/backend/src/cors-csp-config.spec.ts @@ -270,33 +270,3 @@ describe('applyPrivateNetworkAllow', () => { expect(setHeader).not.toHaveBeenCalled(); }); }); - -describe('applyPivotClaimCors', () => { - const { applyPivotClaimCors, PIVOT_CLAIM_PATH } = jest.requireActual('./cors-csp-config'); - - function collect() { - const headers: Record = {}; - return { headers, set: (name: string, value: string) => (headers[name] = value) }; - } - - it('ignores other routes entirely', () => { - const { headers, set } = collect(); - expect(applyPivotClaimCors({ method: 'POST', path: '/api/v1/overview' }, set)).toBe(false); - expect(headers).toEqual({}); - }); - - it('opens CORS on the claim POST without ending the response', () => { - const { headers, set } = collect(); - expect(applyPivotClaimCors({ method: 'POST', path: PIVOT_CLAIM_PATH }, set)).toBe(false); - expect(headers['Access-Control-Allow-Origin']).toBe('*'); - expect(headers['Access-Control-Allow-Methods']).toBe('POST, OPTIONS'); - expect(headers['Access-Control-Allow-Headers']).toBe('Content-Type'); - }); - - it('fully answers the claim preflight', () => { - const { headers, set } = collect(); - expect(applyPivotClaimCors({ method: 'OPTIONS', path: PIVOT_CLAIM_PATH }, set)).toBe(true); - expect(headers['Access-Control-Allow-Origin']).toBe('*'); - expect(headers['Access-Control-Max-Age']).toBe('600'); - }); -}); diff --git a/packages/backend/src/cors-csp-config.ts b/packages/backend/src/cors-csp-config.ts index 6c82ec46..0673ff87 100644 --- a/packages/backend/src/cors-csp-config.ts +++ b/packages/backend/src/cors-csp-config.ts @@ -182,39 +182,3 @@ export function applyPrivateNetworkAllow( if (typeof origin !== 'string' || !allowedOrigins.includes(origin)) return; setHeader('Access-Control-Allow-Private-Network', 'true'); } - -/** Route path of the public pivot waiting-list claim. */ -export const PIVOT_CLAIM_PATH = '/api/v1/waitlist/pivot/claim'; - -export interface PivotCorsRequest { - method: string; - path: string; -} - -/** - * Open CORS for the pivot waiting-list claim only. Self-hosted dashboards on - * versions that still ship the waiting-list card post the claim straight - * from the browser, so any origin must be allowed on - * this one route. Safe because no credentials ride along (`fetch` sends none - * cross-origin by default and the allow-list CORS runs with - * `credentials: false`) and the route only accepts an email. Returns true - * when it fully answered a preflight, so the caller ends the response. - */ -export function applyPivotClaimCors( - req: PivotCorsRequest, - setHeader: (name: string, value: string) => void, -): boolean { - if (req.path !== PIVOT_CLAIM_PATH) return false; - setHeader('Access-Control-Allow-Origin', '*'); - setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); - setHeader('Access-Control-Allow-Headers', 'Content-Type'); - setHeader('Access-Control-Max-Age', '600'); - return req.method === 'OPTIONS'; -} - -/** - * Origin of the cloud pivot claim endpoint. Production self-hosted dashboards - * post the claim cross-origin from the browser, so the CSP `connect-src` - * must allow it — CORS alone is not enough, the CSP blocks the fetch first. - */ -export const PIVOT_CLAIM_CLOUD_ORIGIN = 'https://app.manifest.build'; diff --git a/packages/backend/src/crm-metrics/crm-metrics.filters.spec.ts b/packages/backend/src/crm-metrics/crm-metrics.filters.spec.ts deleted file mode 100644 index 1540736c..00000000 --- a/packages/backend/src/crm-metrics/crm-metrics.filters.spec.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { - domainOf, - isConsumerEmail, - isCorporateSignupEmail, - isExcludedEmail, - isMachineGeneratedDomain, - isSignupCluster, -} from './crm-metrics.filters'; - -describe('isExcludedEmail', () => { - it('keeps an ordinary user address', () => { - expect(isExcludedEmail('matheus@example.com')).toBe(false); - expect(isExcludedEmail('someone@gmail.com')).toBe(false); - }); - - it('excludes our own domains', () => { - expect(isExcludedEmail('bruno@buddyweb.fr')).toBe(true); - expect(isExcludedEmail('hello@manifest.build')).toBe(true); - expect(isExcludedEmail('x@mnfstinc.com')).toBe(true); - }); - - it('excludes team members signed up on a personal address', () => { - expect(isExcludedEmail('sebastien.conejo@gmail.com')).toBe(true); - }); - - it('excludes disposable and alias providers', () => { - expect(isExcludedEmail('improving_poison241@dralias.com')).toBe(true); - expect(isExcludedEmail('app.manifest.build@cryptidcloud.org')).toBe(true); - expect(isExcludedEmail('a@slmail.me')).toBe(true); - }); - - it('excludes shared role inboxes', () => { - expect(isExcludedEmail('info@realcompany.com')).toBe(true); - expect(isExcludedEmail('no-reply@realcompany.com')).toBe(true); - expect(isExcludedEmail('test@realcompany.com')).toBe(true); - }); - - it('matches a role inbox on the whole local part, not as a prefix', () => { - expect(isExcludedEmail('information@realcompany.com')).toBe(false); - expect(isExcludedEmail('admin.jones@realcompany.com')).toBe(false); - expect(isExcludedEmail('contact-us@realcompany.com')).toBe(false); - }); - - it('treats subdomains of a junk domain as junk', () => { - expect(isExcludedEmail('a@mail.dralias.com')).toBe(true); - }); - - it('does not match a domain that merely ends with the same letters', () => { - expect(isExcludedEmail('a@notdralias.com')).toBe(false); - expect(isExcludedEmail('a@notmanifest.build')).toBe(false); - }); - - it('normalises case and surrounding whitespace before matching', () => { - expect(isExcludedEmail(' Bruno@BuddyWeb.FR ')).toBe(true); - expect(isExcludedEmail('INFO@Example.com')).toBe(true); - }); - - it('excludes anything that is not a routable address', () => { - expect(isExcludedEmail('no-at-sign')).toBe(true); - expect(isExcludedEmail('@nolocalpart.com')).toBe(true); - expect(isExcludedEmail('nodomain@')).toBe(true); - expect(isExcludedEmail('')).toBe(true); - }); - - it('uses the last @ so a quoted local part cannot smuggle a domain in', () => { - expect(isExcludedEmail('user@notjunk@dralias.com')).toBe(true); - }); -}); - -describe('domainOf', () => { - it('returns the lowercased domain part', () => { - expect(domainOf(' Ada@Example.COM ')).toBe('example.com'); - }); - - it('returns empty for an unroutable address', () => { - expect(domainOf('no-at-sign')).toBe(''); - expect(domainOf('@leading.com')).toBe(''); - expect(domainOf('trailing@')).toBe(''); - }); -}); - -describe('isConsumerEmail', () => { - it('flags mainstream consumer mailboxes', () => { - expect(isConsumerEmail('ada@gmail.com')).toBe(true); - expect(isConsumerEmail('ada@YAHOO.co.uk')).toBe(true); - expect(isConsumerEmail('ada@qq.com')).toBe(true); - }); - - it('flags privacy relays that read as custom domains', () => { - expect(isConsumerEmail('ada@duck.com')).toBe(true); - expect(isConsumerEmail('ada@pm.me')).toBe(true); - expect(isConsumerEmail('ada@simplelogin.io')).toBe(true); - }); - - it('treats subdomains of a consumer domain as consumer', () => { - expect(isConsumerEmail('ada@mail.duck.com')).toBe(true); - }); - - it('does not flag a domain that merely ends with the same letters', () => { - expect(isConsumerEmail('ada@notgmail.com')).toBe(false); - }); - - it('flags an unroutable address', () => { - expect(isConsumerEmail('nonsense')).toBe(true); - }); - - it('leaves real company domains alone', () => { - expect(isConsumerEmail('ada@stripe.com')).toBe(false); - }); - - describe('brand families', () => { - // Every one of these reached the live feed on 2026-09-14 because the - // exact-match list did not enumerate them. - it.each([ - 'yahoo.de', - 'yahoo.com.br', - 'yahoo.co.jp', - 'outlook.de', - 'hotmail.de', - 'live.de', - 'bk.ru', - 'list.ru', - 'inbox.ru', - 'tuta.io', - 'tutamail.com', - 'tutanota.com', - 'disroot.org', - 'gmx.at', - ])('flags %s as a consumer country variant', (domain) => { - expect(isConsumerEmail(`ada@${domain}`)).toBe(true); - }); - - it('does not mistake a corporate mail subdomain for the mail.com family', () => { - // `mail.acme.com`: second label is a brand, not a 2-3 letter TLD. - expect(isConsumerEmail('ada@mail.acme.com')).toBe(false); - }); - - it('catches a consumer brand behind a subdomain', () => { - expect(isConsumerEmail('ada@mail.yahoo.de')).toBe(true); - expect(isConsumerEmail('ada@imap.gmx.net')).toBe(true); - }); - - it('keeps companies whose name is also a consumer mail brand', () => { - // Consumer on their mail TLD, corporate everywhere else. - expect(isConsumerEmail('ada@comcast.net')).toBe(true); - expect(isConsumerEmail('ada@comcast.com')).toBe(false); - expect(isConsumerEmail('ada@orange.fr')).toBe(true); - expect(isConsumerEmail('ada@orange.com')).toBe(false); - expect(isConsumerEmail('ada@web.de')).toBe(true); - expect(isConsumerEmail('ada@web.com')).toBe(false); - expect(isConsumerEmail('ada@free.fr')).toBe(true); - expect(isConsumerEmail('ada@free.com')).toBe(false); - }); - - it('still catches the mail.com family through the exact list', () => { - expect(isConsumerEmail('ada@mail.com')).toBe(true); - expect(isConsumerEmail('ada@engineer.com')).toBe(true); - expect(isConsumerEmail('ada@bk.ru')).toBe(true); - }); - - it('does not flag a company whose name merely starts with a brand word', () => { - expect(isConsumerEmail('ada@livestorm.co')).toBe(false); - expect(isConsumerEmail('ada@freshmail.io')).toBe(false); - expect(isConsumerEmail('ada@webflow.com')).toBe(false); - }); - }); - - describe('relays and disposables', () => { - it.each([ - 'privaterelay.appleid.com', - 'passinbox.com', - 'passmail.net', - 'mozmail.com', - 'sharklasers.com', - 'yopmail.com', - 'harakirimail.com', - 'agentmail.to', - ])('flags %s', (domain) => { - expect(isConsumerEmail(`ada@${domain}`)).toBe(true); - }); - }); - - describe('universities', () => { - it.each([ - 'berkeley.edu', - 'csu.fullerton.edu', - 'iit.du.ac.bd', - 'diu.edu.bd', - 'alfalah.ac.id', - 'blps.tyc.edu.tw', - 'pace.edu.in', - 'campus.technion.ac.il', - ])('flags %s as academic', (domain) => { - expect(isConsumerEmail(`ada@${domain}`)).toBe(true); - }); - - it('does not flag a company that happens to contain "edu"', () => { - expect(isConsumerEmail('ada@educative.io')).toBe(false); - expect(isConsumerEmail('ada@procedure.com')).toBe(false); - }); - - it('only treats a two-letter country code as academic', () => { - // `edu` / `ac` followed by a generic 3-letter TLD is a company, not a - // university. Two letters is a country code by definition, so `.ac.uk` - // and `.edu.bd` stay academic. - expect(isConsumerEmail('ada@company.edu.com')).toBe(false); - expect(isConsumerEmail('ada@startup.ac.dev')).toBe(false); - expect(isConsumerEmail('ada@startup.ac.app')).toBe(false); - }); - }); -}); - -describe('isCorporateSignupEmail', () => { - it('admits a company address', () => { - expect(isCorporateSignupEmail('ada@stripe.com')).toBe(true); - }); - - it('rejects consumer mail', () => { - expect(isCorporateSignupEmail('ada@gmail.com')).toBe(false); - }); - - it('rejects addresses the shared exclusion list already covers', () => { - expect(isCorporateSignupEmail('support@stripe.com')).toBe(false); - expect(isCorporateSignupEmail('ada@manifest.build')).toBe(false); - expect(isCorporateSignupEmail('ada@atomicmail.io')).toBe(false); - }); -}); - -describe('isSignupCluster', () => { - const at = (iso: string, has_traffic = false) => ({ signed_up_at: iso, has_traffic }); - - it('flags three trafficless signups inside a month', () => { - expect( - isSignupCluster([ - at('2026-03-01T00:00:00.000Z'), - at('2026-03-10T00:00:00.000Z'), - at('2026-03-18T00:00:00.000Z'), - ]), - ).toBe(true); - }); - - it('spares a domain below the signup threshold', () => { - expect(isSignupCluster([at('2026-03-01T00:00:00.000Z'), at('2026-03-02T00:00:00.000Z')])).toBe( - false, - ); - }); - - it('spares a domain where anyone ever sent a request', () => { - expect( - isSignupCluster([ - at('2026-03-01T00:00:00.000Z'), - at('2026-03-10T00:00:00.000Z'), - at('2026-03-18T00:00:00.000Z', true), - ]), - ).toBe(false); - }); - - it('spares a team that trickled in over more than a month', () => { - expect( - isSignupCluster([ - at('2026-01-01T00:00:00.000Z'), - at('2026-03-10T00:00:00.000Z'), - at('2026-06-18T00:00:00.000Z'), - ]), - ).toBe(false); - }); - - it('judges the span regardless of row order', () => { - expect( - isSignupCluster([ - at('2026-06-18T00:00:00.000Z'), - at('2026-01-01T00:00:00.000Z'), - at('2026-03-10T00:00:00.000Z'), - ]), - ).toBe(false); - }); -}); - -describe('isMachineGeneratedDomain', () => { - it('flags a domain where every local part is letters then digits', () => { - // aratrin.com and lidugw.com on the first live dry run. - expect(isMachineGeneratedDomain(['vatiy14692', 'xiyese3594'])).toBe(true); - expect(isMachineGeneratedDomain(['lidosej357', 'safoh51835'])).toBe(true); - expect(isMachineGeneratedDomain(['gimade8897', 'taribe8832', 'wipejet728'])).toBe(true); - }); - - it('never judges a single address', () => { - expect(isMachineGeneratedDomain(['john1985'])).toBe(false); - }); - - it('spares a domain where anyone has a human-looking address', () => { - expect(isMachineGeneratedDomain(['john1985', 'ada.lovelace'])).toBe(false); - expect(isMachineGeneratedDomain(['vatiy14692', 'grace'])).toBe(false); - }); - - it('does not match shapes outside the generator pattern', () => { - expect(isMachineGeneratedDomain(['ab12', 'cd34'])).toBe(false); // too short - expect(isMachineGeneratedDomain(['a.b1234', 'c-d5678'])).toBe(false); // punctuation - expect(isMachineGeneratedDomain(['1234abcd', '5678efgh'])).toBe(false); // digits first - }); -}); diff --git a/packages/backend/src/crm-metrics/crm-metrics.filters.ts b/packages/backend/src/crm-metrics/crm-metrics.filters.ts deleted file mode 100644 index e2bc2bc8..00000000 --- a/packages/backend/src/crm-metrics/crm-metrics.filters.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Deliverability filtering for the CRM feed. - * - * These run here rather than in the CRM so every consumer inherits them: an - * address excluded once is excluded everywhere, and the rules stay under test. - * - * The known bot-signup cluster (gibberish names on scraped corporate domains) - * deliberately has no rule. It never produced a successful heal, so the cohort - * predicate already excludes it — a domain list would be dead code that rots. - */ - -/** Our own addresses. Emailing ourselves about our own launch is noise. */ -const INTERNAL_DOMAINS = ['manifest.build', 'buddyweb.fr', 'mnfstinc.com', 'mnfstinc.dev']; - -/** Team members signed up on a personal address, so the domain rule misses them. */ -const INTERNAL_EMAILS = ['sebastien.conejo@gmail.com']; - -/** - * Disposable/alias providers. Mirrors the list the CRM outreach function - * already carries, so both sides agree on what counts as junk. - */ -const JUNK_DOMAINS = [ - 'slmail.me', - 'atomicmail.io', - 'paytrust.cc', - 'rapplo.com', - 'joystill.com', - 'abrdns.com', - 'cryptidcloud.org', - 'dralias.com', - 'mail.cfw.262019.xyz', -]; - -/** Shared inboxes: nobody in particular reads these, and they skew reply rates. */ -const ROLE_LOCAL_PARTS = [ - 'info', - 'admin', - 'contact', - 'support', - 'sales', - 'marketing', - 'office', - 'team', - 'noreply', - 'no-reply', - 'test', -]; - -/** - * True when we should not email this address. - * - * Subdomains of a junk domain count as junk (`x.dralias.com`), but a domain - * that merely ends with the same letters does not (`notdralias.com`). - */ -export function isExcludedEmail(email: string): boolean { - const normalized = email.trim().toLowerCase(); - const at = normalized.lastIndexOf('@'); - // No '@' at all, or an empty local part / domain: not a routable address. - if (at <= 0 || at === normalized.length - 1) return true; - - const local = normalized.slice(0, at); - const domain = normalized.slice(at + 1); - - if (INTERNAL_EMAILS.includes(normalized)) return true; - if (matchesDomain(domain, INTERNAL_DOMAINS)) return true; - if (matchesDomain(domain, JUNK_DOMAINS)) return true; - return ROLE_LOCAL_PARTS.includes(local); -} - -function matchesDomain(domain: string, list: string[]): boolean { - return list.some((entry) => domain === entry || domain.endsWith(`.${entry}`)); -} - -/** - * Consumer mail and privacy-relay providers. - * - * Separate from JUNK_DOMAINS because these addresses are perfectly real — the - * healed-user feed emails plenty of them. They are excluded from the *signup* - * feed only, where the whole premise is "a corporate domain implies a team - * behind it". A gmail.com signup carries no such signal. - * - * The relay entries matter more than the obvious freemail ones: duck.com, - * pm.me and simplelogin addresses read as custom domains to a naive - * `not freemail` check, and were the largest "corporate" domains in the - * database by signup count until they were listed here. - */ -const CONSUMER_DOMAINS = [ - 'gmail.com', - 'googlemail.com', - 'outlook.com', - 'outlook.fr', - 'hotmail.com', - 'hotmail.fr', - 'hotmail.co.uk', - 'live.com', - 'live.fr', - 'msn.com', - 'yahoo.com', - 'yahoo.co.uk', - 'yahoo.fr', - 'ymail.com', - 'aol.com', - 'icloud.com', - 'me.com', - 'mac.com', - 'proton.me', - 'protonmail.com', - 'pm.me', - 'duck.com', - 'simplelogin.com', - 'simplelogin.io', - 'anonaddy.com', - 'posteo.de', - 'gmx.com', - 'gmx.de', - 'gmx.net', - 'web.de', - 't-online.de', - 'free.fr', - 'orange.fr', - 'wanadoo.fr', - 'laposte.net', - 'sfr.fr', - 'comcast.net', - 'qq.com', - '163.com', - '126.com', - 'foxmail.com', - 'sina.com', - 'naver.com', - 'daum.net', - 'yandex.ru', - 'yandex.com', - 'mail.com', - 'mail.ru', - 'rambler.ru', - 'seznam.cz', - 'zoho.com', - 'fastmail.com', - 'hey.com', - 'engineer.com', - 'consultant.com', - 'usa.com', - 'europe.com', - 'bk.ru', - 'list.ru', - 'inbox.ru', - 'inbox.lv', - 'email.com', - 'email.cz', - 'example.com', - 'test.com', -]; - -/** - * Consumer providers with country variants, matched as a family. - * - * An exact list cannot keep up: the first live run of the signup feed let - * through yahoo.de, yahoo.com.br, outlook.de, bk.ru, tuta.io and thirty more - * that were simply not enumerated. The brand followed by a 2-3 letter TLD and - * an optional two-letter country suffix covers the whole family — `yahoo.de`, - * `yahoo.com.br`, `yahoo.co.jp` — without touching `mail.acme.com`, whose - * second label is not a TLD. - * - * Matched at a label boundary, not only at the start, so `mail.yahoo.de` and - * `imap.gmx.net` count too. - * - * Only brands that are consumer mail on *every* TLD belong here. Names that - * are also a real company's domain — comcast.com, orange.com, web.com, - * free.fr vs free.com, mail.com vs mail.acme.com — stay in the exact list - * for their consumer TLDs, so a corporate signup at orange.com is kept. - */ -const CONSUMER_FAMILIES = new RegExp( - '(?:^|\\.)(?:' + - [ - 'gmail', - 'googlemail', - 'yahoo', - 'ymail', - 'rocketmail', - 'outlook', - 'hotmail', - 'live', - 'msn', - 'aol', - 'icloud', - 'proton', - 'protonmail', - 'tuta', - 'tutanota', - 'tutamail', - 'gmx', - 'yandex', - 'rambler', - 'disroot', - 'posteo', - 'mailbox', - 'riseup', - 'zoho', - 'fastmail', - 'qq', - 'foxmail', - 'naver', - 'daum', - 'seznam', - 'laposte', - 'wanadoo', - ].join('|') + - ')\\.[a-z]{2,3}(?:\\.[a-z]{2})?$', -); - -/** - * Privacy relays and disposable inboxes seen in production signups. Real - * people, but the address is not an organisation's and often not even a - * lasting one. Kept separate from CONSUMER_DOMAINS so the two lists say what - * they are. - */ -const RELAY_AND_DISPOSABLE_DOMAINS = [ - 'privaterelay.appleid.com', - 'passinbox.com', - 'passmail.net', - 'passmail.com', - 'passfwd.com', - 'mozmail.com', - 'relay.firefox.com', - 'addy.io', - 'anonaddy.me', - 'simplelogin.co', - 'simplelogin.fr', - 'startmail.com', - 'sharklasers.com', - 'guerrillamail.com', - 'guerrillamail.info', - 'grr.la', - 'yopmail.com', - 'harakirimail.com', - 'tempmail10.com', - 'mailinator.com', - 'agentmail.to', - 'maildrop.cc', - 'getnada.com', - 'dispostable.com', - 'trashmail.com', - '10minutemail.com', -]; - -/** - * Universities. `.edu`, `.edu.xx` and `.ac.xx` cover every national scheme - * seen in production (Bangladesh, India, Korea, Taiwan, the UK, Indonesia, - * Vietnam, Brazil…). A student is a real person with a real address, but not - * the "team behind a corporate domain" this feed exists to find. - * - * The country part is exactly two letters. `{2,3}` would also take - * `company.edu.com` and `startup.ac.dev`, which are companies. - */ -const ACADEMIC_DOMAIN = /(?:^|\.)(?:edu|ac)\.[a-z]{2}$|\.edu$/; - -/** Signups on one domain inside this span, with no traffic, look scripted. */ -const CLUSTER_WINDOW_MS = 30 * 86_400_000; - -/** Fewer than this on a domain is a small team, not a cluster. */ -const CLUSTER_MIN_SIGNUPS = 3; - -/** The domain part of an address, lowercased; empty when unroutable. */ -export function domainOf(email: string): string { - const normalized = email.trim().toLowerCase(); - const at = normalized.lastIndexOf('@'); - if (at <= 0 || at === normalized.length - 1) return ''; - return normalized.slice(at + 1); -} - -/** - * True when the address does not belong to an organisation: a consumer - * mailbox (exact list or brand family), a privacy relay or disposable inbox, - * or a university. - * - * Subdomains count (`mail.duck.com`), matching `isExcludedEmail`. - */ -export function isConsumerEmail(email: string): boolean { - const domain = domainOf(email); - if (!domain) return true; - return ( - matchesDomain(domain, CONSUMER_DOMAINS) || - matchesDomain(domain, RELAY_AND_DISPOSABLE_DOMAINS) || - CONSUMER_FAMILIES.test(domain) || - ACADEMIC_DOMAIN.test(domain) - ); -} - -/** - * The signup feed's admission rule: a routable address, on a domain that looks - * like an organisation, that we are not already excluding for other reasons. - */ -export function isCorporateSignupEmail(email: string): boolean { - return !isExcludedEmail(email) && !isConsumerEmail(email); -} - -/** One signup, reduced to what the cluster rule needs to judge a domain. */ -export interface ClusterCandidate { - signed_up_at: string; - has_traffic: boolean; -} - -/** - * True when a domain's signups look automated rather than like a real team. - * - * Three or more accounts, created inside a month, none of which ever sent a - * request. A genuine team trickles in over quarters and at least one of them - * points something at the gateway; the scraped-address clusters we have seen - * arrive in a burst and never call the API. - * - * Any traffic at all clears the whole domain: a real user among them means the - * burst was a launch, not a script. - */ -export function isSignupCluster(signups: ClusterCandidate[]): boolean { - if (signups.length < CLUSTER_MIN_SIGNUPS) return false; - if (signups.some((signup) => signup.has_traffic)) return false; - - const times = signups - .map((signup) => new Date(signup.signed_up_at).getTime()) - .sort((a, b) => a - b); - return times[times.length - 1] - times[0] <= CLUSTER_WINDOW_MS; -} - -/** - * The local-part shape temp-mail generators produce: a run of letters and a - * run of digits, nothing else. `lidosej357`, `xiyese3594`, `gimade8897`. - * - * Real people use it too (`john1985`), so it is never judged one address at - * a time — only when every address on a domain has the shape, below. - */ -const MACHINE_LOCAL_PART = /^[a-z]{4,8}[0-9]{3,6}$/; - -/** - * True when a domain's signups all look machine-generated. - * - * Two or more accounts, every one of them a letters-then-digits local part. - * A company does not sign up its whole team as `vatiy14692@` and - * `xiyese3594@`; a disposable-mail domain does nothing else. On the first - * dry run this was aratrin.com and lidugw.com — with Faker names attached, - * and with traffic, so the quiet-cluster rule above did not fire. - * - * Across every verified signup this matched four domains: those two, - * mypethealh.com, and one university whose student ids happen to fit and - * which the academic rule already removes. - */ -export function isMachineGeneratedDomain(localParts: string[]): boolean { - return localParts.length >= 2 && localParts.every((local) => MACHINE_LOCAL_PART.test(local)); -} diff --git a/packages/backend/src/crm-metrics/crm-metrics.module.ts b/packages/backend/src/crm-metrics/crm-metrics.module.ts deleted file mode 100644 index e8ea3417..00000000 --- a/packages/backend/src/crm-metrics/crm-metrics.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CrmMetricsService } from './crm-metrics.service'; -import { InternalCrmMetricsController } from './internal-crm-metrics.controller'; - -/** - * Internal feed the CRM polls to drive Autofix product outreach. Queries run - * through the injected DataSource, so no `TypeOrmModule.forFeature` is needed. - */ -@Module({ - controllers: [InternalCrmMetricsController], - providers: [CrmMetricsService], -}) -export class CrmMetricsModule {} diff --git a/packages/backend/src/crm-metrics/crm-metrics.service.spec.ts b/packages/backend/src/crm-metrics/crm-metrics.service.spec.ts deleted file mode 100644 index dc1e85cc..00000000 --- a/packages/backend/src/crm-metrics/crm-metrics.service.spec.ts +++ /dev/null @@ -1,725 +0,0 @@ -import { ServiceUnavailableException } from '@nestjs/common'; -import type { DataSource } from 'typeorm'; -import { CrmMetricsService } from './crm-metrics.service'; -import { toLocalSqlTimestamp } from '../common/utils/postgres-sql'; - -const NOW = new Date('2026-09-04T12:00:00.000Z'); - -interface Scripted { - index?: unknown[]; - cohort?: unknown[]; - claims?: unknown[]; - signups?: unknown[]; -} - -function cohortRow(over: Record = {}) { - return { - email: 'matheus@example.com', - user_name: 'Matheus Vitorio', - tenant_id: 'tenant-a', - healed_recent: '260', - healed_all: '1241', - first_heal_at: '2026-07-30T15:17:31.000Z', - last_heal_at: '2026-09-04T09:47:30.000Z', - ...over, - }; -} - -function signupRow(over: Record = {}) { - return { - email: 'ada@stripe.com', - user_name: 'Ada Lovelace', - signed_up_at: '2026-08-01T10:00:00.000Z', - last_request_at: null, - ...over, - }; -} - -describe('CrmMetricsService', () => { - let query: jest.Mock; - let commitTransaction: jest.Mock; - let rollbackTransaction: jest.Mock; - let release: jest.Mock; - let connect: jest.Mock; - let startTransaction: jest.Mock; - let service: CrmMetricsService; - let scripted: Scripted; - - function setup(next: Scripted = {}): void { - scripted = { index: [{ '?column?': 1 }], ...next }; - query = jest.fn().mockImplementation((sql: string) => { - if (sql.includes('SET LOCAL')) return Promise.resolve([]); - if (sql.includes('pg_index')) return Promise.resolve(scripted.index ?? []); - // Checked before the cohort branch: both queries join `tenants t`. - if (sql.includes('WITH signups AS MATERIALIZED')) - return Promise.resolve(scripted.signups ?? []); - if (sql.includes('JOIN tenants t')) return Promise.resolve(scripted.cohort ?? []); - if (sql.includes('FROM waitlist_claims')) return Promise.resolve(scripted.claims ?? []); - throw new Error(`unexpected SQL: ${sql}`); - }); - commitTransaction = jest.fn(); - rollbackTransaction = jest.fn(); - release = jest.fn(); - connect = jest.fn(); - startTransaction = jest.fn(); - const runner = { - connect, - startTransaction, - commitTransaction, - rollbackTransaction, - release, - query, - }; - service = new CrmMetricsService({ createQueryRunner: () => runner } as unknown as DataSource); - } - - const sqlFor = (needle: string): string => - query.mock.calls.map(([sql]) => String(sql)).find((sql) => sql.includes(needle)) ?? ''; - const paramsFor = (needle: string): unknown[] => - query.mock.calls.find(([sql]) => String(sql).includes(needle))?.[1] ?? []; - - beforeEach(() => setup()); - - afterEach(() => { - jest.useRealTimers(); - }); - - describe('transaction handling', () => { - it('bounds the work with transaction-scoped timeouts', async () => { - await service.getHealedCohort(7, NOW); - - expect(sqlFor('lock_timeout')).toBe(`SET LOCAL lock_timeout = '5000ms'`); - expect(sqlFor('statement_timeout')).toBe(`SET LOCAL statement_timeout = '1500ms'`); - expect(commitTransaction).toHaveBeenCalled(); - }); - - it('rolls back and releases the runner when a query fails', async () => { - setup(); - query.mockRejectedValueOnce(new Error('boom')); - - await expect(service.getHealedCohort(7, NOW)).rejects.toThrow('boom'); - expect(rollbackTransaction).toHaveBeenCalled(); - expect(commitTransaction).not.toHaveBeenCalled(); - expect(release).toHaveBeenCalled(); - }); - - it('releases the runner on the happy path too', async () => { - await service.getHealedCohort(7, NOW); - - expect(release).toHaveBeenCalled(); - }); - - it('opens the transaction before setting the timeouts, and commits after the work', async () => { - setup({ cohort: [cohortRow()] }); - - await service.getHealedCohort(7, NOW); - - // `SET LOCAL` outside a transaction is silently a no-op, so hoisting - // these above startTransaction would disable both bounds without - // changing a single line of SQL text. - expect(connect.mock.invocationCallOrder[0]).toBeLessThan( - startTransaction.mock.invocationCallOrder[0], - ); - expect(startTransaction.mock.invocationCallOrder[0]).toBeLessThan( - query.mock.invocationCallOrder[0], - ); - expect(query.mock.calls.slice(0, 2).map(([sql]) => String(sql))).toEqual([ - `SET LOCAL lock_timeout = '5000ms'`, - `SET LOCAL statement_timeout = '1500ms'`, - ]); - expect(commitTransaction.mock.invocationCallOrder[0]).toBeGreaterThan( - query.mock.invocationCallOrder[query.mock.invocationCallOrder.length - 1], - ); - }); - }); - - describe('index guard', () => { - it('refuses to run an unindexed scan when the index is missing or invalid', async () => { - setup({ index: [], cohort: [cohortRow()] }); - - const error = await service.getHealedCohort(7, NOW).then( - () => null, - (thrown: unknown) => thrown, - ); - - expect(error).toBeInstanceOf(ServiceUnavailableException); - expect((error as Error).message).toContain('IDX_requests_autofix_healed'); - // Cohort rows were scripted, so an empty result here would mean the - // guard let the unindexed scan run and merely found nothing. - expect(sqlFor('JOIN tenants t')).toBe(''); - expect(rollbackTransaction).toHaveBeenCalled(); - expect(release).toHaveBeenCalled(); - }); - - it('reports the outage as a rejection, never as an empty cohort', async () => { - setup({ cohort: [] }); - await expect(service.getHealedCohort(7, NOW)).resolves.toEqual([]); - - setup({ index: [] }); - const outcome = await service.getHealedCohort(7, NOW).then( - (users) => ({ resolved: users }), - (error: unknown) => ({ error }), - ); - - // "Nobody was healed" and "the index is gone" must not look alike to the - // CRM: a `catch { return [] }` here would silently empty the feed. - expect(outcome).toEqual({ error: expect.any(ServiceUnavailableException) }); - }); - - it('probes for the index by name', async () => { - await service.getHealedCohort(7, NOW); - - expect(paramsFor('pg_index')).toEqual(['IDX_requests_autofix_healed']); - }); - }); - - describe('cohort window', () => { - it('bounds the cohort query with a local wall-clock cutoff, not UTC', async () => { - setup({ cohort: [cohortRow()] }); - - await service.getHealedCohort(7, NOW); - - const expected = toLocalSqlTimestamp(new Date(NOW.getTime() - 7 * 86_400_000)); - expect(paramsFor('JOIN tenants t')).toEqual([expected]); - }); - - it('never joins agent_messages: that breakdown cost 1.1s against a 1.5s timeout', async () => { - setup({ cohort: [cohortRow()] }); - - await service.getHealedCohort(365, NOW); - - // The provider join scaled with the window and 500d the endpoint at 365 - // days. It fed a field the outreach email no longer uses. - expect(sqlFor('agent_messages')).toBe(''); - }); - - it('defaults to the current time when no clock is injected', async () => { - setup({ cohort: [cohortRow()] }); - - await expect(service.getHealedCohort(7)).resolves.toHaveLength(1); - await expect(service.getConversions(90)).resolves.toEqual([]); - await expect(service.getCorporateSignups(365)).resolves.toEqual([]); - - const cutoff = paramsFor('JOIN tenants t')[0] as string; - expect(cutoff).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); - }); - - it('applies the canonical healed predicate, not autofix_status alone', async () => { - await service.getHealedCohort(7, NOW); - - const sql = sqlFor('JOIN tenants t'); - expect(sql).toContain(`r.autofix_status = 'retry_succeeded'`); - expect(sql).toContain('r.status IS NULL OR r.status IN'); - expect(sql).toContain('u."emailVerified" = true'); - }); - - it('counts every heal ever, but only surfaces people healed inside the window', async () => { - await service.getHealedCohort(7, NOW); - - const sql = sqlFor('JOIN tenants t'); - expect(sql).toContain('count(*) FILTER (WHERE r.timestamp > $1)'); - expect(sql).toContain('HAVING count(*) FILTER (WHERE r.timestamp > $1) > 0'); - // `healed_all` is deliberately unbounded. Narrowing the WHERE by the - // cutoff would collapse it onto `healed_recent` and quietly turn the - // "heals since Autofix shipped" number into a duplicate of the window. - expect(sql).not.toContain('AND r.timestamp >'); - }); - }); - - describe('cohort shaping', () => { - it('maps a single person', async () => { - setup({ cohort: [cohortRow()] }); - - const [user] = await service.getHealedCohort(7, NOW); - - expect(user).toEqual({ - email: 'matheus@example.com', - name: 'Matheus Vitorio', - healed_recent: 260, - healed_all: 1241, - first_heal_at: '2026-07-30T15:17:31.000Z', - last_heal_at: '2026-09-04T09:47:30.000Z', - }); - }); - - it('sums a person who owns more than one tenant', async () => { - setup({ - cohort: [ - cohortRow({ tenant_id: 'tenant-a', healed_recent: '10', healed_all: '20' }), - cohortRow({ - tenant_id: 'tenant-b', - user_name: null, - healed_recent: '5', - healed_all: '7', - first_heal_at: '2026-07-01T00:00:00.000Z', - last_heal_at: '2026-09-05T00:00:00.000Z', - }), - ], - }); - - const [user] = await service.getHealedCohort(7, NOW); - - expect(user.healed_recent).toBe(15); - expect(user.healed_all).toBe(27); - expect(user.first_heal_at).toBe('2026-07-01T00:00:00.000Z'); - expect(user.last_heal_at).toBe('2026-09-05T00:00:00.000Z'); - expect(user.name).toBe('Matheus Vitorio'); - }); - - it('keeps the first name seen when a later tenant carries a different one', async () => { - setup({ - cohort: [ - cohortRow({ tenant_id: 'tenant-a', user_name: 'First Name' }), - cohortRow({ tenant_id: 'tenant-b', user_name: 'Stale Name' }), - ], - }); - - const [user] = await service.getHealedCohort(7, NOW); - - // First non-null wins; flipping the coalesce to `row.user_name ?? existing` - // would make the last tenant's copy of the name win instead. - expect(user.name).toBe('First Name'); - }); - - it('leaves the name null when no row for the person ever carried one', async () => { - setup({ - cohort: [ - cohortRow({ tenant_id: 'tenant-a', user_name: null }), - cohortRow({ tenant_id: 'tenant-b', user_name: null }), - ], - }); - - const [user] = await service.getHealedCohort(7, NOW); - - expect(user.name).toBeNull(); - expect(user.email).toBe('matheus@example.com'); - }); - - it('keeps a later name when the first row for a person had none', async () => { - setup({ - cohort: [ - cohortRow({ tenant_id: 'tenant-a', user_name: null }), - cohortRow({ tenant_id: 'tenant-b', user_name: 'Late Name' }), - ], - }); - - const [user] = await service.getHealedCohort(7, NOW); - - expect(user.name).toBe('Late Name'); - }); - - it('drops excluded addresses before they reach the payload', async () => { - setup({ - cohort: [ - cohortRow({ email: 'bruno@buddyweb.fr' }), - cohortRow({ email: 'improving_poison241@dralias.com' }), - cohortRow({ email: 'real@example.com' }), - ], - }); - - const users = await service.getHealedCohort(7, NOW); - - expect(users.map((u) => u.email)).toEqual(['real@example.com']); - }); - - it('normalises case and whitespace on the grouping key', async () => { - setup({ - cohort: [ - cohortRow({ email: ' Matheus@Example.com ', tenant_id: 'tenant-a', healed_recent: '3' }), - cohortRow({ email: 'matheus@example.com', tenant_id: 'tenant-b', healed_recent: '4' }), - ], - }); - - const users = await service.getHealedCohort(7, NOW); - - expect(users).toHaveLength(1); - expect(users[0].healed_recent).toBe(7); - }); - - it('orders people by heals in the window, most first', async () => { - setup({ - cohort: [ - cohortRow({ email: 'quiet@example.com', tenant_id: 't1', healed_recent: '2' }), - cohortRow({ email: 'loud@example.com', tenant_id: 't2', healed_recent: '99' }), - ], - }); - - const users = await service.getHealedCohort(7, NOW); - - expect(users.map((u) => u.email)).toEqual(['loud@example.com', 'quiet@example.com']); - }); - }); - - describe('conversions', () => { - it('normalises claims and bounds the window in UTC', async () => { - setup({ - claims: [ - { email: 'Someone@Example.com', source: 'cloud', claimed_at: '2026-09-01T08:00:00.000Z' }, - ], - }); - - const claims = await service.getConversions(90, NOW); - - expect(claims).toEqual([ - { email: 'someone@example.com', source: 'cloud', claimed_at: '2026-09-01T08:00:00.000Z' }, - ]); - // claimed_at is `timestamp WITH time zone`, unlike requests.timestamp. - expect(paramsFor('FROM waitlist_claims')).toEqual([ - new Date(NOW.getTime() - 90 * 86_400_000).toISOString(), - ]); - }); - - it('runs the claims query alone: no index probe, no cross-tenant scan', async () => { - setup({ claims: [] }); - - await service.getConversions(90, NOW); - - expect(query.mock.calls.map(([sql]) => String(sql).trim().split('\n')[0])).toEqual([ - `SET LOCAL lock_timeout = '5000ms'`, - `SET LOCAL statement_timeout = '1500ms'`, - 'SELECT email, source, claimed_at', - ]); - expect(commitTransaction).toHaveBeenCalled(); - }); - - it('reports every claim, including addresses the cohort filters out', async () => { - setup({ - claims: [ - { - email: 'bruno@buddyweb.fr', - source: 'self-hosted', - claimed_at: '2026-09-02T00:00:00.000Z', - }, - { - email: 'info@realcompany.com', - source: 'cloud', - claimed_at: '2026-09-01T00:00:00.000Z', - }, - ], - }); - - const claims = await service.getConversions(90, NOW); - - // Deliverability filtering is about who we may email; a conversion count - // that quietly drops our own signups would understate the campaign. - expect(claims.map((claim) => claim.email)).toEqual([ - 'bruno@buddyweb.fr', - 'info@realcompany.com', - ]); - }); - }); - - describe('caching', () => { - it('serves a repeated cohort request without touching the database', async () => { - setup({ cohort: [cohortRow()] }); - - const first = await service.getHealedCohort(7, NOW); - const callsAfterFirst = query.mock.calls.length; - const second = await service.getHealedCohort(7, NOW); - - expect(second).toBe(first); - expect(query.mock.calls).toHaveLength(callsAfterFirst); - }); - - it('caches per window, so a different day count recomputes', async () => { - setup({ cohort: [cohortRow()] }); - - await service.getHealedCohort(7, NOW); - const callsAfterFirst = query.mock.calls.length; - await service.getHealedCohort(30, NOW); - - expect(query.mock.calls.length).toBeGreaterThan(callsAfterFirst); - }); - - it('does not cache a failed scan, so the next call retries the database', async () => { - setup({ cohort: [cohortRow()] }); - // Target the cohort scan itself, not whichever statement happens to run - // first: `mockRejectedValueOnce` would otherwise fail `SET LOCAL - // lock_timeout` and never reach the query this test is named after. - let failNextScan = true; - const scan = query.getMockImplementation()!; - query.mockImplementation((sql: string, ...params: unknown[]) => { - if (sql.includes('JOIN tenants t') && failNextScan) { - failNextScan = false; - return Promise.reject(new Error('canceling statement due to statement timeout')); - } - return scan(sql, ...params); - }); - - await expect(service.getHealedCohort(7, NOW)).rejects.toThrow('statement timeout'); - const users = await service.getHealedCohort(7, NOW); - - expect(users.map((u) => u.email)).toEqual(['matheus@example.com']); - // Both calls reached the scan: the failure was not memoised. - expect( - query.mock.calls.filter(([sql]) => String(sql).includes('JOIN tenants t')), - ).toHaveLength(2); - }); - - it('does not cache a missing index either, so the feed recovers once it is rebuilt', async () => { - setup({ index: [], cohort: [cohortRow()] }); - - await expect(service.getHealedCohort(7, NOW)).rejects.toBeInstanceOf( - ServiceUnavailableException, - ); - scripted.index = [{ '?column?': 1 }]; - - await expect(service.getHealedCohort(7, NOW)).resolves.toHaveLength(1); - }); - - it('caches an empty window too, so an idle feed stops re-scanning', async () => { - setup({ cohort: [] }); - - await expect(service.getHealedCohort(7, NOW)).resolves.toEqual([]); - const callsAfterFirst = query.mock.calls.length; - await service.getHealedCohort(7, NOW); - - expect(query.mock.calls).toHaveLength(callsAfterFirst); - }); - - it('keeps the cohort and the conversion caches apart at the same window', async () => { - setup({ - cohort: [cohortRow()], - claims: [ - { email: 'someone@example.com', source: 'cloud', claimed_at: '2026-09-01T08:00:00.000Z' }, - ], - }); - - const users = await service.getHealedCohort(7, NOW); - const claims = await service.getConversions(7, NOW); - - // One cache keyed on `days` alone would hand the cohort array back here. - expect(users.map((u) => u.email)).toEqual(['matheus@example.com']); - expect(claims).toEqual([ - { email: 'someone@example.com', source: 'cloud', claimed_at: '2026-09-01T08:00:00.000Z' }, - ]); - }); - - it('recomputes once the 60s ttl lapses', async () => { - jest.useFakeTimers(); - setup({ cohort: [cohortRow()] }); - - await service.getHealedCohort(7, NOW); - const callsAfterFirst = query.mock.calls.length; - - jest.advanceTimersByTime(59_000); - await service.getHealedCohort(7, NOW); - expect(query.mock.calls).toHaveLength(callsAfterFirst); - - jest.advanceTimersByTime(2_000); - await service.getHealedCohort(7, NOW); - expect(query.mock.calls.length).toBeGreaterThan(callsAfterFirst); - }); - - it('caches conversions too', async () => { - setup({ claims: [] }); - - const first = await service.getConversions(90, NOW); - const callsAfterFirst = query.mock.calls.length; - const second = await service.getConversions(90, NOW); - - expect(second).toBe(first); - expect(query.mock.calls).toHaveLength(callsAfterFirst); - }); - }); - - describe('getCorporateSignups', () => { - it('refuses to run when the tenant/timestamp index is missing', async () => { - setup({ index: [] }); - - await expect(service.getCorporateSignups(365, NOW)).rejects.toBeInstanceOf( - ServiceUnavailableException, - ); - expect(sqlFor('WITH signups AS MATERIALIZED')).toBe(''); - }); - - it('excludes anyone on the pivot waiting list, in the query itself', async () => { - // Those people are worked by hand; the exclusion lives in SQL so no - // consumer can forget to apply it. - await service.getCorporateSignups(365, NOW); - - const sql = sqlFor('WITH signups AS MATERIALIZED'); - expect(sql).toContain('NOT EXISTS'); - expect(sql).toContain('FROM waitlist_claims w WHERE lower(w.email) = lower(u.email)'); - }); - - it('checks for the index the lateral probe depends on', async () => { - await service.getCorporateSignups(365, NOW); - - expect(paramsFor('pg_index')).toEqual(['IDX_requests_tenant_timestamp']); - }); - - it('cuts off on a UTC boundary, matching a timestamptz column', async () => { - await service.getCorporateSignups(30, NOW); - - expect(paramsFor('WITH signups AS MATERIALIZED')).toEqual([ - new Date(NOW.getTime() - 30 * 86_400_000).toISOString(), - ]); - }); - - it('maps a signup onto the payload shape', async () => { - setup({ signups: [signupRow()] }); - - expect(await service.getCorporateSignups(365, NOW)).toEqual([ - { - email: 'ada@stripe.com', - name: 'Ada Lovelace', - domain: 'stripe.com', - signed_up_at: '2026-08-01T10:00:00.000Z', - last_request_at: null, - has_traffic: false, - domain_signups: 1, - }, - ]); - }); - - it('keeps a verified user who never created a tenant', async () => { - // Tenants are created lazily on first agent creation, so someone who - // signed up and never built anything has no tenant row. They are exactly - // who this feed is for, and an inner join would drop them: 1,274 verified - // users in production, 144 of them corporate. - setup({ signups: [signupRow({ last_request_at: null })] }); - - const [signup] = await service.getCorporateSignups(365, NOW); - - expect(signup.has_traffic).toBe(false); - expect(signup.last_request_at).toBeNull(); - expect(sqlFor('WITH signups AS MATERIALIZED')).toContain('LEFT JOIN tenants t'); - }); - - it('marks a tenant that has sent a request', async () => { - setup({ signups: [signupRow({ last_request_at: '2026-09-01T08:00:00.000Z' })] }); - - const [signup] = await service.getCorporateSignups(365, NOW); - - expect(signup.has_traffic).toBe(true); - expect(signup.last_request_at).toBe('2026-09-01T08:00:00.000Z'); - }); - - it('normalises the address and derives the domain from it', async () => { - setup({ signups: [signupRow({ email: ' Ada@Stripe.COM ' })] }); - - const [signup] = await service.getCorporateSignups(365, NOW); - - expect(signup.email).toBe('ada@stripe.com'); - expect(signup.domain).toBe('stripe.com'); - }); - - it('keeps a missing display name as null', async () => { - setup({ signups: [signupRow({ user_name: null })] }); - - expect((await service.getCorporateSignups(365, NOW))[0].name).toBeNull(); - }); - - it('drops consumer and already-excluded addresses', async () => { - setup({ - signups: [ - signupRow({ email: 'ada@gmail.com' }), - signupRow({ email: 'support@stripe.com' }), - signupRow({ email: 'dev@manifest.build' }), - signupRow({ email: 'ada@stripe.com' }), - ], - }); - - expect((await service.getCorporateSignups(365, NOW)).map((s) => s.email)).toEqual([ - 'ada@stripe.com', - ]); - }); - - it('counts how many verified signups share a domain', async () => { - setup({ - signups: [signupRow({ email: 'ada@stripe.com' }), signupRow({ email: 'grace@stripe.com' })], - }); - - const signups = await service.getCorporateSignups(365, NOW); - - expect(signups).toHaveLength(2); - expect(signups.every((s) => s.domain_signups === 2)).toBe(true); - }); - - it('drops a scripted signup cluster wholesale', async () => { - setup({ - signups: [ - signupRow({ email: 'a@scraped.com', signed_up_at: '2026-03-01T00:00:00.000Z' }), - signupRow({ email: 'b@scraped.com', signed_up_at: '2026-03-08T00:00:00.000Z' }), - signupRow({ email: 'c@scraped.com', signed_up_at: '2026-03-15T00:00:00.000Z' }), - signupRow({ email: 'ada@stripe.com' }), - ], - }); - - expect((await service.getCorporateSignups(365, NOW)).map((s) => s.email)).toEqual([ - 'ada@stripe.com', - ]); - }); - - it('drops a domain whose signups all look machine-generated', async () => { - // Temp-mail domains: Faker names, traffic, and every local part is - // letters-then-digits. The quiet-cluster rule misses them because they - // have traffic; this one catches them on the address shape. - setup({ - signups: [ - signupRow({ email: 'vatiy14692@aratrin.com', user_name: 'Mrs. Beaulah Shanahan' }), - signupRow({ - email: 'xiyese3594@aratrin.com', - user_name: 'Test01', - last_request_at: '2026-09-01T00:00:00.000Z', - }), - signupRow({ email: 'ada@stripe.com' }), - ], - }); - - expect((await service.getCorporateSignups(365, NOW)).map((s) => s.email)).toEqual([ - 'ada@stripe.com', - ]); - }); - - it('keeps a burst where somebody actually used the gateway', async () => { - setup({ - signups: [ - signupRow({ email: 'a@real.com', signed_up_at: '2026-03-01T00:00:00.000Z' }), - signupRow({ email: 'b@real.com', signed_up_at: '2026-03-08T00:00:00.000Z' }), - signupRow({ - email: 'c@real.com', - signed_up_at: '2026-03-15T00:00:00.000Z', - last_request_at: '2026-04-01T00:00:00.000Z', - }), - ], - }); - - expect(await service.getCorporateSignups(365, NOW)).toHaveLength(3); - }); - - it('orders by domain size, then by newest signup', async () => { - setup({ - signups: [ - signupRow({ email: 'solo@alone.com', signed_up_at: '2026-08-20T00:00:00.000Z' }), - signupRow({ email: 'older@team.com', signed_up_at: '2026-01-01T00:00:00.000Z' }), - signupRow({ email: 'newer@team.com', signed_up_at: '2026-08-01T00:00:00.000Z' }), - ], - }); - - expect((await service.getCorporateSignups(365, NOW)).map((s) => s.email)).toEqual([ - 'newer@team.com', - 'older@team.com', - 'solo@alone.com', - ]); - }); - - it('returns nothing when no signup survives the filters', async () => { - setup({ signups: [signupRow({ email: 'ada@gmail.com' })] }); - - expect(await service.getCorporateSignups(365, NOW)).toEqual([]); - }); - - it('caches signups per window', async () => { - setup({ signups: [signupRow()] }); - - const first = await service.getCorporateSignups(365, NOW); - const callsAfterFirst = query.mock.calls.length; - const second = await service.getCorporateSignups(365, NOW); - - expect(second).toBe(first); - expect(query.mock.calls).toHaveLength(callsAfterFirst); - }); - }); -}); diff --git a/packages/backend/src/crm-metrics/crm-metrics.service.ts b/packages/backend/src/crm-metrics/crm-metrics.service.ts deleted file mode 100644 index 1437a7ca..00000000 --- a/packages/backend/src/crm-metrics/crm-metrics.service.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { Injectable, ServiceUnavailableException } from '@nestjs/common'; -import { DataSource, QueryRunner } from 'typeorm'; -import { TtlCache } from '../common/utils/ttl-cache'; -import { toLocalSqlTimestamp } from '../common/utils/postgres-sql'; -import { sqlIsSuccessStatus } from '../analytics/services/query-helpers'; -import { - domainOf, - isCorporateSignupEmail, - isMachineGeneratedDomain, - isSignupCluster, - isExcludedEmail, -} from './crm-metrics.filters'; -import type { - CohortRow, - CrmCorporateSignup, - CrmHealedUser, - CrmWaitlistClaim, - SignupRow, -} from './crm-metrics.types'; - -/** - * Answers on the cheap: the requests Autofix repaired lately, grouped by the - * person who owns them, plus the waiting-list claims that measure whether the - * campaign worked. - * - * Read once a day by the CRM, so there is no snapshot table and no cron. A - * snapshot would need a cron running exactly the cross-tenant scan this is - * trying to avoid, plus leader election; once indexed, the live query is - * cheaper than that cron would be. Results sit behind a TTL cache. - */ - -const HEALED_INDEX = 'IDX_requests_autofix_healed'; - -/** - * The signup feed's whole cost model is one index probe per tenant. Without - * this index the lateral degrades to a sequential scan of `requests` per row, - * which measured 22s and 6.9 GB of reads against production. - */ -const TENANT_TIMESTAMP_INDEX = 'IDX_requests_tenant_timestamp'; - -/** - * Short on purpose. The cache exists to stop a retrying client from replaying - * the provider join (~80 MB of buffer reads) up to the throttler's 100/min, not - * to serve the expected traffic — a once-a-day poll misses either way. A long - * TTL would buy nothing and make the feed surprisingly stale after an operator - * fixes something and re-runs. - */ -const CACHE_TTL_MS = 60_000; - -/** - * Deliberately *below* the 2.4s the unindexed cohort scan takes. If the - * partial index is missing the query aborts instead of completing, because the - * damage from that plan is not latency but reading 6.6 GB through a ~128 MB - * shared_buffers and evicting everyone else's working set. - */ -const STATEMENT_TIMEOUT_MS = 1_500; -const LOCK_TIMEOUT_MS = 5_000; - -/** The index is the entire point; without it, refuse rather than thrash prod. */ -const INDEX_READY_SQL = ` - SELECT 1 FROM pg_index i - JOIN pg_class c ON c.oid = i.indexrelid - WHERE c.relname = $1 AND i.indisvalid -`; - -/** - * The canonical "healed" predicate, matching request-volume.service.ts. The - * status check is not redundant: a retry can return 2xx while the request still - * concludes failed, and quoting a repair count the dashboard disagrees with is - * worse than quoting none. - * - * No playground exclusion is needed here (every other cross-tenant aggregate - * carries one): playground.service.ts writes `autofix_status: null` on all of - * its request paths, so a playground row can never match this predicate. - */ -const IS_HEALED = `r.autofix_status = 'retry_succeeded' AND ${sqlIsSuccessStatus('r.status')}`; - -const COHORT_SQL = ` - SELECT lower(u.email) AS email, - u.name AS user_name, - r.tenant_id AS tenant_id, - count(*) FILTER (WHERE r.timestamp > $1) AS healed_recent, - count(*) AS healed_all, - min(r.timestamp) AS first_heal_at, - max(r.timestamp) AS last_heal_at - FROM requests r - JOIN tenants t ON t.id = r.tenant_id - JOIN "user" u ON u.id = t.owner_user_id - WHERE ${IS_HEALED} - AND u."emailVerified" = true - GROUP BY lower(u.email), u.name, r.tenant_id - HAVING count(*) FILTER (WHERE r.timestamp > $1) > 0 -`; - -/** - * Every verified signup in the window, with the timestamp of that tenant's most - * recent request. - * - * `LIMIT 1` on a backward index scan rather than an aggregate: it answers - * "have they ever used this, and when last" from the index alone, whereas - * `count(*)` or a filtered aggregate must visit the heap for every row. - * - * Domain rules are deliberately *not* in this SQL. They live in - * crm-metrics.filters.ts so one list governs every consumer and stays under - * test; the cost of returning all ~8k users and filtering in TypeScript is - * one probe each, which measured 59-93ms warm and 999ms cold against - * production. That worst case still fits the 1.5s budget, but the margin is - * the reason this stays one probe per row and never an aggregate. - * - * Anyone on the pivot waiting list is excluded. They raised their hand for - * exactly this product and are worked by hand, one at a time — a templated - * signup email landing on top of that conversation would be a worse - * experience than no email. `waitlist_claims` is ~60 rows with a unique - * index on email, so the anti-join is free. - * - * The join to `tenants` must stay LEFT. Tenants are created lazily on first - * agent creation, so a user who signed up and never built anything has no - * tenant row at all — 1,274 verified users in production, 144 of them on - * corporate domains. An inner join drops exactly the people this feed exists - * to find. With no tenant the lateral matches nothing, which is the correct - * answer: `last_request_at` null, `has_traffic` false. - */ -const SIGNUPS_SQL = ` - WITH signups AS MATERIALIZED ( - SELECT lower(u.email) AS email, - u.name AS user_name, - u."createdAt" AS signed_up_at, - t.id AS tenant_id - FROM "user" u - LEFT JOIN tenants t ON t.owner_user_id = u.id - WHERE u."emailVerified" = true - AND u."createdAt" > $1 - AND NOT EXISTS ( - SELECT 1 FROM waitlist_claims w WHERE lower(w.email) = lower(u.email) - ) - ) - SELECT s.email, s.user_name, s.signed_up_at, a.last_request_at - FROM signups s - LEFT JOIN LATERAL ( - SELECT r.timestamp AS last_request_at - FROM requests r - WHERE r.tenant_id = s.tenant_id - ORDER BY r.timestamp DESC - LIMIT 1 - ) a ON true -`; - -const CLAIMS_SQL = ` - SELECT email, source, claimed_at - FROM waitlist_claims - WHERE claimed_at > $1 - ORDER BY claimed_at DESC -`; - -@Injectable() -export class CrmMetricsService { - private readonly cohortCache = new TtlCache({ - maxSize: 8, - ttlMs: CACHE_TTL_MS, - }); - private readonly claimsCache = new TtlCache({ - maxSize: 8, - ttlMs: CACHE_TTL_MS, - }); - private readonly signupsCache = new TtlCache({ - maxSize: 8, - ttlMs: CACHE_TTL_MS, - }); - - constructor(private readonly dataSource: DataSource) {} - - /** Users whose requests Autofix repaired within `days`, one row per person. */ - async getHealedCohort(days: number, now: Date = new Date()): Promise { - const cached = this.cohortCache.get(days); - if (cached) return cached; - - const cutoff = cutoffFor(days, now); - const users = await this.withRunner(async (runner) => { - const ready = (await runner.query(INDEX_READY_SQL, [HEALED_INDEX])) as unknown[]; - if (ready.length === 0) { - throw new ServiceUnavailableException( - `${HEALED_INDEX} is missing or invalid; refusing to run an unindexed scan`, - ); - } - const merged = mergeByEmail((await runner.query(COHORT_SQL, [cutoff])) as CohortRow[]); - return buildUsers(merged); - }); - - this.cohortCache.set(days, users); - return users; - } - - /** - * Verified signups on organisation domains within `days`, newest first. - * - * Consumer mailboxes, relays, role addresses and scripted signup clusters are - * removed here rather than in the CRM, so every consumer inherits the same - * rules. - */ - async getCorporateSignups(days: number, now: Date = new Date()): Promise { - const cached = this.signupsCache.get(days); - if (cached) return cached; - - // `user."createdAt"` is `timestamp WITH time zone`, unlike the naive - // `requests.timestamp` the cohort query compares against — so a UTC - // boundary is correct here and a local one would be off by the offset. - const cutoff = new Date(now.getTime() - days * 86_400_000).toISOString(); - const signups = await this.withRunner(async (runner) => { - const ready = (await runner.query(INDEX_READY_SQL, [TENANT_TIMESTAMP_INDEX])) as unknown[]; - if (ready.length === 0) { - throw new ServiceUnavailableException( - `${TENANT_TIMESTAMP_INDEX} is missing or invalid; refusing to run an unindexed scan`, - ); - } - return buildSignups((await runner.query(SIGNUPS_SQL, [cutoff])) as SignupRow[]); - }); - - this.signupsCache.set(days, signups); - return signups; - } - - /** Pivot waiting-list claims in the window: who converted, and from where. */ - async getConversions(days: number, now: Date = new Date()): Promise { - const cached = this.claimsCache.get(days); - if (cached) return cached; - - // `waitlist_claims.claimed_at` is `timestamp WITH time zone`, unlike the - // naive `requests.timestamp` below — hence the UTC boundary here. - const cutoff = new Date(now.getTime() - days * 86_400_000).toISOString(); - const claims = await this.withRunner(async (runner) => { - const rows = (await runner.query(CLAIMS_SQL, [cutoff])) as CrmWaitlistClaim[]; - return rows.map((row) => ({ - email: row.email.toLowerCase(), - source: row.source, - claimed_at: new Date(row.claimed_at).toISOString(), - })); - }); - - this.claimsCache.set(days, claims); - return claims; - } - - /** - * `SET LOCAL` is transaction-scoped, which is the only form Railway's - * PgBouncer accepts (see the note in database.module.ts). - */ - private async withRunner(fn: (runner: QueryRunner) => Promise): Promise { - const runner = this.dataSource.createQueryRunner(); - try { - await runner.connect(); - await runner.startTransaction(); - try { - await runner.query(`SET LOCAL lock_timeout = '${LOCK_TIMEOUT_MS}ms'`); - await runner.query(`SET LOCAL statement_timeout = '${STATEMENT_TIMEOUT_MS}ms'`); - const result = await fn(runner); - await runner.commitTransaction(); - return result; - } catch (error) { - await runner.rollbackTransaction(); - throw error; - } - } finally { - await runner.release(); - } - } -} - -/** Accumulator for the rows belonging to one person. */ -interface Merged { - email: string; - name: string | null; - healed_recent: number; - healed_all: number; - first_heal_at: number; - last_heal_at: number; -} - -/** - * Local wall clock, not UTC: `requests.timestamp` is `timestamp without time - * zone` and the pg driver writes JS Dates in the process timezone, so a UTC - * boundary would be offset by that amount and silently drop rows. - */ -function cutoffFor(days: number, now: Date): string { - return toLocalSqlTimestamp(new Date(now.getTime() - days * 86_400_000)); -} - -/** - * The SQL groups by (email, tenant) and the totals are summed here. Today a - * user owns at most one tenant (`uq_tenants_owner_user` is unique), so this is - * a no-op — but `owner_user_id` is nullable by design for future team - * workspaces, so the 1:1 is a current fact rather than an invariant. - * - * Excluded addresses are dropped before their tenants reach the payload. - */ -function mergeByEmail(rows: CohortRow[]): Map { - const merged = new Map(); - for (const row of rows) { - const email = row.email.trim().toLowerCase(); - if (isExcludedEmail(email)) continue; - - const first = new Date(row.first_heal_at).getTime(); - const last = new Date(row.last_heal_at).getTime(); - const existing = merged.get(email); - if (!existing) { - merged.set(email, { - email, - name: row.user_name, - healed_recent: Number(row.healed_recent), - healed_all: Number(row.healed_all), - first_heal_at: first, - last_heal_at: last, - }); - continue; - } - existing.healed_recent += Number(row.healed_recent); - existing.healed_all += Number(row.healed_all); - existing.first_heal_at = Math.min(existing.first_heal_at, first); - existing.last_heal_at = Math.max(existing.last_heal_at, last); - existing.name = existing.name ?? row.user_name; - } - return merged; -} - -function buildUsers(merged: Map): CrmHealedUser[] { - const users = [...merged.values()].map((entry) => ({ - email: entry.email, - name: entry.name, - healed_recent: entry.healed_recent, - healed_all: entry.healed_all, - first_heal_at: new Date(entry.first_heal_at).toISOString(), - last_heal_at: new Date(entry.last_heal_at).toISOString(), - })); - - return users.sort((a, b) => b.healed_recent - a.healed_recent); -} - -/** - * Turns raw signup rows into the payload: drop addresses that are not - * organisations, drop domains whose signups look scripted, then annotate each - * survivor with how many people share its domain. - * - * Ordered by domain size then recency, so if the CRM ever caps a batch it takes - * the strongest team signals first. - */ -function buildSignups(rows: SignupRow[]): CrmCorporateSignup[] { - const byDomain = new Map(); - - for (const row of rows) { - const email = row.email.trim().toLowerCase(); - if (!isCorporateSignupEmail(email)) continue; - - const lastRequest = row.last_request_at ? new Date(row.last_request_at) : null; - const signup: CrmCorporateSignup = { - email, - name: row.user_name, - domain: domainOf(email), - signed_up_at: new Date(row.signed_up_at).toISOString(), - last_request_at: lastRequest ? lastRequest.toISOString() : null, - has_traffic: lastRequest !== null, - // Filled in below, once the domain's full membership is known. - domain_signups: 0, - }; - - const bucket = byDomain.get(signup.domain); - if (bucket) bucket.push(signup); - else byDomain.set(signup.domain, [signup]); - } - - const kept: CrmCorporateSignup[] = []; - for (const signups of byDomain.values()) { - if (isSignupCluster(signups)) continue; - if (isMachineGeneratedDomain(signups.map((s) => s.email.split('@')[0]))) continue; - for (const signup of signups) { - signup.domain_signups = signups.length; - kept.push(signup); - } - } - - return kept.sort( - (a, b) => - b.domain_signups - a.domain_signups || - Date.parse(b.signed_up_at) - Date.parse(a.signed_up_at), - ); -} diff --git a/packages/backend/src/crm-metrics/crm-metrics.types.ts b/packages/backend/src/crm-metrics/crm-metrics.types.ts deleted file mode 100644 index 175ed194..00000000 --- a/packages/backend/src/crm-metrics/crm-metrics.types.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** Shapes returned by the internal CRM metrics feed. */ - -/** One Manifest user whose failing requests Autofix repaired. */ -export interface CrmHealedUser { - /** Lowercased primary address. The CRM dedupes people on this. */ - email: string; - /** Display name from the auth record, or null when never set. */ - name: string | null; - /** Heals inside the requested window — the number worth quoting to them. */ - healed_recent: number; - /** Heals since Autofix shipped, across every tenant this user owns. */ - healed_all: number; - first_heal_at: string; - last_heal_at: string; -} - -/** One pivot waiting-list claim: the conversion signal for the campaign. */ -export interface CrmWaitlistClaim { - email: string; - source: string; - claimed_at: string; -} - -/** Raw cohort row, one per (user, tenant) pair, before merging. */ -export interface CohortRow { - email: string; - user_name: string | null; - tenant_id: string; - healed_recent: string | number; - healed_all: string | number; - first_heal_at: Date | string; - last_heal_at: Date | string; -} - -/** - * One corporate signup: a verified user on an organisation domain. - * - * Never someone on the pivot waiting list: those people are worked by hand - * and must not also receive the templated signup email. - * - * Deliberately thinner than `CrmHealedUser`. Per-tenant request aggregates - * (error counts, 30-day volume) cost a heap fetch per row and took 22s across - * this cohort in production; a single index probe for the latest request is - * 93ms and answers the only question the copy branches on — has this person - * ever actually used the gateway. - */ -export interface CrmCorporateSignup { - /** Lowercased primary address. The CRM dedupes people on this. */ - email: string; - /** Display name from the auth record, or null when never set. */ - name: string | null; - /** Lowercased domain part, so the CRM can group people onto a company. */ - domain: string; - signed_up_at: string; - /** Null when this tenant has never sent a request. */ - last_request_at: string | null; - /** `last_request_at !== null`, precomputed so template branching is trivial. */ - has_traffic: boolean; - /** Verified signups sharing this domain — the "team behind it" signal. */ - domain_signups: number; -} - -/** Raw signup row, one per (user, tenant) pair, before filtering. */ -export interface SignupRow { - email: string; - user_name: string | null; - signed_up_at: Date | string; - last_request_at: Date | string | null; -} diff --git a/packages/backend/src/crm-metrics/dto/crm-metrics-query.dto.spec.ts b/packages/backend/src/crm-metrics/dto/crm-metrics-query.dto.spec.ts deleted file mode 100644 index d9ff3a01..00000000 --- a/packages/backend/src/crm-metrics/dto/crm-metrics-query.dto.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import 'reflect-metadata'; -import { plainToInstance } from 'class-transformer'; -import { validateSync } from 'class-validator'; -import { CrmMetricsQueryDto } from './crm-metrics-query.dto'; - -function parse(query: Record): CrmMetricsQueryDto { - return plainToInstance(CrmMetricsQueryDto, query, { enableImplicitConversion: false }); -} - -describe('CrmMetricsQueryDto', () => { - it('coerces the query string into a number', () => { - const dto = parse({ days: '30' }); - - expect(dto.days).toBe(30); - expect(validateSync(dto)).toHaveLength(0); - }); - - it('allows the parameter to be omitted', () => { - const dto = parse({}); - - expect(dto.days).toBeUndefined(); - expect(validateSync(dto)).toHaveLength(0); - }); - - it('accepts the window bounds', () => { - expect(validateSync(parse({ days: '1' }))).toHaveLength(0); - expect(validateSync(parse({ days: '365' }))).toHaveLength(0); - }); - - it('rejects a window outside the bounds', () => { - expect(validateSync(parse({ days: '0' }))).not.toHaveLength(0); - expect(validateSync(parse({ days: '366' }))).not.toHaveLength(0); - }); - - it('rejects a non-integer window', () => { - expect(validateSync(parse({ days: '7.5' }))).not.toHaveLength(0); - expect(validateSync(parse({ days: 'lots' }))).not.toHaveLength(0); - }); -}); diff --git a/packages/backend/src/crm-metrics/dto/crm-metrics-query.dto.ts b/packages/backend/src/crm-metrics/dto/crm-metrics-query.dto.ts deleted file mode 100644 index b0414d3d..00000000 --- a/packages/backend/src/crm-metrics/dto/crm-metrics-query.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Type } from 'class-transformer'; -import { IsInt, IsOptional, Max, Min } from 'class-validator'; - -/** - * Window selector for both CRM metrics routes. - * - * The global ValidationPipe runs with `whitelist` + `forbidNonWhitelisted`, so - * anything not declared here makes the request 400. `@Type` is what coerces the - * query string into a number. - * - * A day count rather than an ISO `since`: this backend has no `@IsDateString` - * precedent, and the payload already carries `first_heal_at` / `last_heal_at` - * per user, so a consumer that wants a delta can compute one itself. - */ -export class CrmMetricsQueryDto { - @IsOptional() - @IsInt() - @Min(1) - @Max(365) - @Type(() => Number) - days?: number; -} diff --git a/packages/backend/src/crm-metrics/internal-crm-metrics.controller.spec.ts b/packages/backend/src/crm-metrics/internal-crm-metrics.controller.spec.ts deleted file mode 100644 index 8d6996d3..00000000 --- a/packages/backend/src/crm-metrics/internal-crm-metrics.controller.spec.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { - Logger, - NotFoundException, - ServiceUnavailableException, - UnauthorizedException, -} from '@nestjs/common'; -import type { ConfigService } from '@nestjs/config'; -import { InternalCrmMetricsController } from './internal-crm-metrics.controller'; -import type { CrmMetricsService } from './crm-metrics.service'; -import { isSelfHosted } from '../common/utils/detect-self-hosted'; - -jest.mock('../common/utils/detect-self-hosted', () => ({ isSelfHosted: jest.fn() })); -const mockedIsSelfHosted = jest.mocked(isSelfHosted); - -const SECRET = 'a'.repeat(32); -const IP = '203.0.113.7'; - -function makeConfig(secret: string | undefined): ConfigService { - return { - get: jest.fn((key: string) => (key === 'app.crmMetricsSecret' ? secret : undefined)), - } as unknown as ConfigService; -} - -describe('InternalCrmMetricsController', () => { - let mockService: { - getHealedCohort: jest.Mock; - getConversions: jest.Mock; - getCorporateSignups: jest.Mock; - }; - let warn: jest.SpyInstance; - - beforeEach(() => { - mockService = { - getHealedCohort: jest.fn(), - getConversions: jest.fn(), - getCorporateSignups: jest.fn(), - }; - warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); - mockedIsSelfHosted.mockReturnValue(false); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - function makeController(secret: string | undefined): InternalCrmMetricsController { - return new InternalCrmMetricsController( - mockService as unknown as CrmMetricsService, - makeConfig(secret), - ); - } - - describe('secret rejection', () => { - it('rejects when the header is missing', async () => { - const controller = makeController(SECRET); - - await expect( - controller.cohort(undefined as unknown as string, IP, {}), - ).rejects.toBeInstanceOf(UnauthorizedException); - expect(mockService.getHealedCohort).not.toHaveBeenCalled(); - }); - - it('rejects when the header is wrong', async () => { - const controller = makeController(SECRET); - - await expect(controller.cohort('b'.repeat(32), IP, {})).rejects.toBeInstanceOf( - UnauthorizedException, - ); - expect(mockService.getHealedCohort).not.toHaveBeenCalled(); - }); - - it('rejects when the header is the right value but the wrong length', async () => { - const controller = makeController(SECRET); - - await expect(controller.cohort('a'.repeat(31), IP, {})).rejects.toBeInstanceOf( - UnauthorizedException, - ); - expect(mockService.getHealedCohort).not.toHaveBeenCalled(); - }); - - it('leaks neither the configured nor the provided secret when it rejects', async () => { - // Same length on both sides, so the constant-time compare runs rather - // than the length shortcut. - const configured = 'configured-secret'.padEnd(40, 'x'); - const provided = 'provided-secret'.padEnd(40, 'y'); - - const error = await makeController(configured) - .cohort(provided, IP, {}) - .then( - () => null, - (thrown: unknown) => thrown as UnauthorizedException, - ); - - expect(error).toBeInstanceOf(UnauthorizedException); - expect(error?.message).toBe('Invalid or missing internal secret'); - const surfaced = `${error?.message} ${JSON.stringify(error?.getResponse())}`; - expect(surfaced).not.toContain(configured); - expect(surfaced).not.toContain(provided); - // The rejection log is the other place a secret could end up. - const logged = JSON.stringify(warn.mock.calls); - expect(logged).not.toContain(configured); - expect(logged).not.toContain(provided); - }); - - it('rejects when no secret is configured', async () => { - await expect(makeController(undefined).cohort('anything', IP, {})).rejects.toBeInstanceOf( - UnauthorizedException, - ); - await expect(makeController('').cohort('', IP, {})).rejects.toBeInstanceOf( - UnauthorizedException, - ); - expect(mockService.getHealedCohort).not.toHaveBeenCalled(); - }); - - it('treats a secret shorter than 32 chars as unconfigured, even on an exact match', async () => { - const weak = 'short-secret'; - - await expect(makeController(weak).cohort(weak, IP, {})).rejects.toBeInstanceOf( - UnauthorizedException, - ); - }); - - it('guards the conversions route too', async () => { - const controller = makeController(SECRET); - - await expect(controller.conversions('nope', IP, {})).rejects.toBeInstanceOf( - UnauthorizedException, - ); - expect(mockService.getConversions).not.toHaveBeenCalled(); - }); - - it('guards the signups route too', async () => { - const controller = makeController(SECRET); - - await expect(controller.signups('nope', IP, {})).rejects.toBeInstanceOf( - UnauthorizedException, - ); - expect(mockService.getCorporateSignups).not.toHaveBeenCalled(); - }); - - it('logs the source ip of a rejected attempt', async () => { - await expect(makeController(SECRET).cohort('nope', IP, {})).rejects.toThrow(); - - expect(warn).toHaveBeenCalledWith(`Rejected CRM metrics request from ${IP}`); - }); - }); - - describe('cohort', () => { - it('defaults to a 7 day window', async () => { - const users = [{ email: 'a@b.com' }]; - mockService.getHealedCohort.mockResolvedValue(users); - - const result = await makeController(SECRET).cohort(SECRET, IP, {}); - - expect(result).toBe(users); - expect(mockService.getHealedCohort).toHaveBeenCalledWith(7); - }); - - it('passes an explicit window through', async () => { - mockService.getHealedCohort.mockResolvedValue([]); - - await makeController(SECRET).cohort(SECRET, IP, { days: 30 }); - - expect(mockService.getHealedCohort).toHaveBeenCalledWith(30); - }); - - it('propagates an unavailable index instead of answering with an empty cohort', async () => { - mockService.getHealedCohort.mockRejectedValue( - new ServiceUnavailableException('IDX_requests_autofix_healed is missing or invalid'), - ); - - // A 503 is the signal the CRM needs to keep yesterday's list; a 200 with - // `[]` reads as "nobody was healed today". - await expect(makeController(SECRET).cohort(SECRET, IP, {})).rejects.toBeInstanceOf( - ServiceUnavailableException, - ); - }); - }); - - describe('signups', () => { - it('defaults to the full 365 day history', async () => { - const signups = [{ email: 'ada@stripe.com' }]; - mockService.getCorporateSignups.mockResolvedValue(signups); - - const result = await makeController(SECRET).signups(SECRET, IP, {}); - - expect(result).toBe(signups); - expect(mockService.getCorporateSignups).toHaveBeenCalledWith(365); - }); - - it('passes an explicit window through', async () => { - mockService.getCorporateSignups.mockResolvedValue([]); - - await makeController(SECRET).signups(SECRET, IP, { days: 30 }); - - expect(mockService.getCorporateSignups).toHaveBeenCalledWith(30); - }); - - it('propagates an unavailable index instead of answering with an empty list', async () => { - mockService.getCorporateSignups.mockRejectedValue( - new ServiceUnavailableException('IDX_requests_tenant_timestamp is missing or invalid'), - ); - - await expect(makeController(SECRET).signups(SECRET, IP, {})).rejects.toBeInstanceOf( - ServiceUnavailableException, - ); - }); - }); - - describe('conversions', () => { - it('defaults to a 90 day window', async () => { - const claims = [{ email: 'a@b.com', source: 'cloud', claimed_at: 'x' }]; - mockService.getConversions.mockResolvedValue(claims); - - const result = await makeController(SECRET).conversions(SECRET, IP, {}); - - expect(result).toBe(claims); - expect(mockService.getConversions).toHaveBeenCalledWith(90); - }); - - it('passes an explicit window through', async () => { - mockService.getConversions.mockResolvedValue([]); - - await makeController(SECRET).conversions(SECRET, IP, { days: 14 }); - - expect(mockService.getConversions).toHaveBeenCalledWith(14); - }); - }); - - describe('self-hosted', () => { - // app.module.ts normally leaves the module unregistered, but that check - // runs before ConfigModule loads `.env`. This one resolves at construction, - // so a bare-metal install carrying MANIFEST_MODE only in `.env` is covered. - it('answers like an unmounted route on both endpoints', async () => { - mockedIsSelfHosted.mockReturnValue(true); - const controller = makeController(SECRET); - - await expect(controller.cohort(SECRET, IP, {})).rejects.toBeInstanceOf(NotFoundException); - await expect(controller.conversions(SECRET, IP, {})).rejects.toBeInstanceOf( - NotFoundException, - ); - await expect(controller.signups(SECRET, IP, {})).rejects.toBeInstanceOf(NotFoundException); - expect(mockService.getHealedCohort).not.toHaveBeenCalled(); - expect(mockService.getConversions).not.toHaveBeenCalled(); - expect(mockService.getCorporateSignups).not.toHaveBeenCalled(); - }); - - it('checks the mode before the secret, so it never reveals whether one is set', async () => { - mockedIsSelfHosted.mockReturnValue(true); - - // No secret configured at all: a Cloud install would answer 401 here. - await expect(makeController(undefined).cohort('anything', IP, {})).rejects.toBeInstanceOf( - NotFoundException, - ); - expect(warn).not.toHaveBeenCalled(); - }); - - it('resolves the mode once, at construction', async () => { - mockedIsSelfHosted.mockReturnValue(false); - const controller = makeController(SECRET); - mockService.getHealedCohort.mockResolvedValue([]); - const callsAfterConstruction = mockedIsSelfHosted.mock.calls.length; - mockedIsSelfHosted.mockReturnValue(true); - - // A later flip must not change an already-built controller's behaviour, - // and serving a request must not re-read the mode. - await expect(controller.cohort(SECRET, IP, {})).resolves.toEqual([]); - expect(mockedIsSelfHosted.mock.calls).toHaveLength(callsAfterConstruction); - }); - }); -}); diff --git a/packages/backend/src/crm-metrics/internal-crm-metrics.controller.ts b/packages/backend/src/crm-metrics/internal-crm-metrics.controller.ts deleted file mode 100644 index 8b29f890..00000000 --- a/packages/backend/src/crm-metrics/internal-crm-metrics.controller.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - Controller, - Get, - Headers, - Ip, - Logger, - NotFoundException, - Query, - UnauthorizedException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Public } from '../common/decorators/public.decorator'; -import { timingSafeCompare } from '../common/utils/crypto.util'; -import { isSelfHosted } from '../common/utils/detect-self-hosted'; -import { CrmMetricsService } from './crm-metrics.service'; -import { CrmMetricsQueryDto } from './dto/crm-metrics-query.dto'; -import type { CrmCorporateSignup, CrmHealedUser, CrmWaitlistClaim } from './crm-metrics.types'; - -/** - * Internal read API for the CRM outreach pipeline. - * - * `@Public()` to skip the session/api-key guards, then gated by a shared secret - * in the `x-internal-secret` header. Lives under /api/* so the SPA static - * fallback never shadows it. - * - * This is the first Manifest endpoint that exports user email addresses across - * tenants, so it is deliberately stricter than the error-pages sibling it is - * modelled on: the comparison is constant-time, and a secret too short to be - * worth anything counts as unconfigured rather than as a weak guard on a full - * user list. Nothing here logs the payload. - * - * Cloud only. app.module.ts already leaves the module unregistered on - * self-hosted, but that check runs at import time, before ConfigModule reads - * `.env` — so a bare-metal install carrying `MANIFEST_MODE` only in its `.env` - * would look like Cloud there. Containers are unaffected (`/.dockerenv` is - * detected without any env), but this second check closes the gap: it resolves - * at construction, after configuration is loaded. - */ -@Controller('api/v1/internal/crm-metrics') -export class InternalCrmMetricsController { - /** Below this, a configured value is treated as absent. */ - private static readonly MIN_SECRET_LENGTH = 32; - private static readonly DEFAULT_COHORT_DAYS = 7; - private static readonly DEFAULT_CLAIM_DAYS = 90; - /** - * The widest window `CrmMetricsQueryDto` allows, which today is every signup - * ever (the first one is from February 2026). - * - * Wide on purpose: the signup campaign emails each person once ever and - * remembers who in the CRM rather than via a window, so a narrow default - * would strand anyone a late or failed run skipped. Once Manifest is more - * than a year old this stops being the full history, and someone who was - * never successfully contacted before ageing out would be missed. Raise the - * DTO cap then rather than assuming this number still means "everyone". - */ - private static readonly DEFAULT_SIGNUP_DAYS = 365; - - private readonly logger = new Logger(InternalCrmMetricsController.name); - - /** Resolved once at construction, which is after ConfigModule loads `.env`. */ - private readonly selfHosted = isSelfHosted(); - - constructor( - private readonly service: CrmMetricsService, - private readonly config: ConfigService, - ) {} - - /** - * Answer exactly as an unmounted route would. Checked before the secret so a - * self-hosted install never reveals whether one is configured. - */ - private assertCloud(): void { - if (this.selfHosted) throw new NotFoundException(); - } - - private assertSecret(provided: string | undefined, ip: string): void { - const expected = this.config.get('app.crmMetricsSecret') ?? ''; - const configured = expected.length >= InternalCrmMetricsController.MIN_SECRET_LENGTH; - if (!configured || !timingSafeCompare(provided ?? '', expected)) { - this.logger.warn(`Rejected CRM metrics request from ${ip}`); - throw new UnauthorizedException('Invalid or missing internal secret'); - } - } - - /** Users whose failing requests Autofix repaired inside the window. */ - @Public() - @Get() - async cohort( - @Headers('x-internal-secret') secret: string, - @Ip() ip: string, - @Query() query: CrmMetricsQueryDto, - ): Promise { - this.assertCloud(); - this.assertSecret(secret, ip); - return this.service.getHealedCohort( - query.days ?? InternalCrmMetricsController.DEFAULT_COHORT_DAYS, - ); - } - - /** - * Verified signups on organisation domains — the Autofix outreach cohort. - * - * A corporate address implies a team, and a team calling third-party APIs is - * the product's audience whether or not they ever routed an LLM request - * through the gateway. `has_traffic` is what the copy branches on. - * - * Excludes anyone who claimed the pivot waiting list: that cohort is worked - * by hand and lives on the `conversions` route. - */ - @Public() - @Get('signups') - async signups( - @Headers('x-internal-secret') secret: string, - @Ip() ip: string, - @Query() query: CrmMetricsQueryDto, - ): Promise { - this.assertCloud(); - this.assertSecret(secret, ip); - return this.service.getCorporateSignups( - query.days ?? InternalCrmMetricsController.DEFAULT_SIGNUP_DAYS, - ); - } - - /** Pivot waiting-list claims — the campaign's conversion signal. */ - @Public() - @Get('conversions') - async conversions( - @Headers('x-internal-secret') secret: string, - @Ip() ip: string, - @Query() query: CrmMetricsQueryDto, - ): Promise { - this.assertCloud(); - this.assertSecret(secret, ip); - return this.service.getConversions( - query.days ?? InternalCrmMetricsController.DEFAULT_CLAIM_DAYS, - ); - } -} diff --git a/packages/backend/src/database/data-source-definitions.ts b/packages/backend/src/database/data-source-definitions.ts index c8c2c0db..ec921e84 100644 --- a/packages/backend/src/database/data-source-definitions.ts +++ b/packages/backend/src/database/data-source-definitions.ts @@ -24,8 +24,6 @@ import { PlaygroundRun } from '../entities/playground-run.entity'; import { PlaygroundColumn } from '../entities/playground-column.entity'; import { ReasoningContentCacheEntry } from '../entities/reasoning-content-cache-entry.entity'; import { AgentEnabledProvider } from '../entities/agent-enabled-provider.entity'; -import { PublicErrorPage } from '../entities/public-error-page.entity'; -import { WaitlistClaim } from '../entities/waitlist-claim.entity'; import { CliAuthCode } from '../entities/cli-auth-code.entity'; import { AgentUsageDaily } from '../entities/agent-usage-daily.entity'; import { RenameWaitlistClaimsTable1800000000000 } from './migrations/1800000000000-RenameWaitlistClaimsTable'; @@ -57,6 +55,7 @@ import { AddAgentMessagesDirectUsageIndex1802900000000 } from './migrations/1802 import { CoverRequestsLogFilters1803000000000 } from './migrations/1803000000000-CoverRequestsLogFilters'; import { CoverHarnessRequestsIndex1803100000000 } from './migrations/1803100000000-CoverHarnessRequestsIndex'; import { DropBillingSchema1803200000000 } from './migrations/1803200000000-DropBillingSchema'; +import { DropSaasFeatureSchema1803300000000 } from './migrations/1803300000000-DropSaasFeatureSchema'; import { InitialSchema1771464895790 } from './migrations/1771464895790-InitialSchema'; import { HashApiKeys1771500000000 } from './migrations/1771500000000-HashApiKeys'; import { ModelPricingImprovements1771600000000 } from './migrations/1771600000000-ModelPricingImprovements'; @@ -198,8 +197,6 @@ export const entities = [ ReasoningContentCacheEntry, AgentEnabledProvider, BackfillState, - PublicErrorPage, - WaitlistClaim, CliAuthCode, AgentUsageDaily, ]; @@ -352,4 +349,5 @@ export const migrations = [ CoverRequestsLogFilters1803000000000, CoverHarnessRequestsIndex1803100000000, DropBillingSchema1803200000000, + DropSaasFeatureSchema1803300000000, ]; diff --git a/packages/backend/src/database/index-rebuild.ts b/packages/backend/src/database/index-rebuild.ts index eb8d5284..75bc79bb 100644 --- a/packages/backend/src/database/index-rebuild.ts +++ b/packages/backend/src/database/index-rebuild.ts @@ -3,7 +3,7 @@ import type { QueryRunner } from 'typeorm'; /** An index rebuilt in place: same name, same key, with or without INCLUDE columns. */ export interface InPlaceIndex { table: string; - /** Live name; callers such as the CRM metrics check look it up by name. */ + /** Live name; kept across the rebuild so anything that looks it up by name still finds it. */ index: string; /** Temporary name the replacement is built under before the swap. */ build: string; diff --git a/packages/backend/src/database/migrations/1803300000000-DropSaasFeatureSchema.spec.ts b/packages/backend/src/database/migrations/1803300000000-DropSaasFeatureSchema.spec.ts new file mode 100644 index 00000000..11026996 --- /dev/null +++ b/packages/backend/src/database/migrations/1803300000000-DropSaasFeatureSchema.spec.ts @@ -0,0 +1,45 @@ +import { DropSaasFeatureSchema1803300000000 } from './1803300000000-DropSaasFeatureSchema'; + +async function run(direction: 'up' | 'down'): Promise { + const query = jest.fn(async () => []); + await new DropSaasFeatureSchema1803300000000()[direction]({ query } as never); + return query.mock.calls.map((call) => String((call as unknown[])[0])); +} + +describe('DropSaasFeatureSchema1803300000000', () => { + it('sets a transaction-local lock timeout before taking any lock', async () => { + const sql = await run('up'); + + expect(sql[0]).toBe(`SET LOCAL lock_timeout = '10s'`); + }); + + it('removes Gemini Free grants before the connections they point at', async () => { + const sql = await run('up'); + const at = (fragment: string) => sql.findIndex((statement) => statement.includes(fragment)); + + const grants = at('DELETE FROM "agent_enabled_providers"'); + const connections = at('DELETE FROM "tenant_providers"'); + expect(grants).toBeGreaterThan(0); + expect(connections).toBeGreaterThan(grants); + expect(sql[connections]).toContain(`LOWER("provider") = 'gemini-free'`); + }); + + it('drops the SaaS tables and the CRM index idempotently', async () => { + const sql = (await run('up')).join('\n'); + + expect(sql).toContain('DROP TABLE IF EXISTS "waitlist_claims"'); + expect(sql).toContain('DROP TABLE IF EXISTS "public_error_pages"'); + expect(sql).toContain('DROP INDEX IF EXISTS "IDX_requests_autofix_healed"'); + expect(sql).not.toMatch(/DROP (TABLE|INDEX) (?!IF EXISTS)/); + }); + + it('restores the self-hosted tables on revert, without the CRM index', async () => { + const sql = (await run('down')).join('\n'); + + expect(sql).toContain('CREATE TABLE IF NOT EXISTS "waitlist_claims"'); + expect(sql).toContain('CONSTRAINT "UQ_waitlist_claims_email" UNIQUE ("email")'); + expect(sql).toContain('CREATE TABLE IF NOT EXISTS "public_error_pages"'); + expect(sql).not.toContain('IDX_requests_autofix_healed'); + expect(sql).not.toContain('SET LOCAL'); + }); +}); diff --git a/packages/backend/src/database/migrations/1803300000000-DropSaasFeatureSchema.ts b/packages/backend/src/database/migrations/1803300000000-DropSaasFeatureSchema.ts new file mode 100644 index 00000000..7efb4f41 --- /dev/null +++ b/packages/backend/src/database/migrations/1803300000000-DropSaasFeatureSchema.ts @@ -0,0 +1,86 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Drops the schema left behind by the removed Manifest Cloud and marketing + * features (waitlist, CRM metrics feed, public error pages, managed Gemini + * Free): + * + * - `waitlist_claims` (the pivot/Autofix waitlist) and `public_error_pages` + * (the marketing site's mirror pushed by the Peacock CMS). Their indexes and + * constraints go with them. + * - `IDX_requests_autofix_healed`, the index that only served the CRM feed. + * 1802200000000 built it in cloud mode only, so on a self-hosted database + * the drop is a no-op that never touches `requests`. + * - Every `tenant_providers` connection to the managed `gemini-free` provider. + * Its endpoint (credits.manifest.build) and provider id no longer exist, so + * the stored LiteLLM virtual key can never be used again; keeping the row + * would leave a connection the dashboard cannot render and the proxy cannot + * route. Agent grants in `agent_enabled_providers` are deleted with it, and + * `agent_messages.tenant_provider_id` is nulled by its ON DELETE SET NULL + * foreign key, so history keeps its `provider = 'gemini-free'` label. Routes + * that still name the provider resolve as unavailable and fall through to + * their fallbacks, exactly as after a manual disconnect. + * + * `lock_timeout` makes the migration fail fast instead of queueing behind a + * long-running query while holding up writes to the tables it locks; a failed + * run is retried on the next boot. Every statement is idempotent. + * + * `down()` restores the two tables as the earlier migrations created them on + * the self-hosted path, empty. The CRM index was never part of that path, and + * the deleted connections cannot be restored. + */ +export class DropSaasFeatureSchema1803300000000 implements MigrationInterface { + name = 'DropSaasFeatureSchema1803300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`SET LOCAL lock_timeout = '10s'`); + await queryRunner.query( + `DELETE FROM "agent_enabled_providers" WHERE "tenant_provider_id" IN ( + SELECT "id" FROM "tenant_providers" WHERE LOWER("provider") = 'gemini-free' + )`, + ); + await queryRunner.query( + `DELETE FROM "tenant_providers" WHERE LOWER("provider") = 'gemini-free'`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_requests_autofix_healed"`); + await queryRunner.query(`DROP TABLE IF EXISTS "waitlist_claims"`); + await queryRunner.query(`DROP TABLE IF EXISTS "public_error_pages"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "waitlist_claims" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "email" varchar NOT NULL, + "source" varchar NOT NULL DEFAULT 'self-hosted', + "claimed_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_waitlist_claims" PRIMARY KEY ("id"), + CONSTRAINT "UQ_waitlist_claims_email" UNIQUE ("email") + ) + `); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "public_error_pages" ( + "slug" varchar PRIMARY KEY, + "cluster_key" varchar NOT NULL, + "provider" varchar NOT NULL, + "provider_label" varchar NOT NULL DEFAULT '', + "http_status" integer, + "category" varchar NOT NULL DEFAULT 'unknown', + "category_label" varchar NOT NULL DEFAULT '', + "title" varchar NOT NULL, + "meta_description" varchar NOT NULL, + "h1" varchar NOT NULL, + "body_what" text NOT NULL DEFAULT '', + "body_fix" text NOT NULL DEFAULT '', + "body_manifest" text NOT NULL DEFAULT '', + "sample_message" text NOT NULL DEFAULT '', + "faq" jsonb NOT NULL DEFAULT '[]'::jsonb, + "stats" jsonb NOT NULL DEFAULT '{}'::jsonb, + "related" jsonb NOT NULL DEFAULT '[]'::jsonb, + "noindex" boolean NOT NULL DEFAULT false, + "published_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now() + ) + `); + } +} diff --git a/packages/backend/src/entities/public-error-page.entity.ts b/packages/backend/src/entities/public-error-page.entity.ts deleted file mode 100644 index 5683237c..00000000 --- a/packages/backend/src/entities/public-error-page.entity.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Entity, Column, PrimaryColumn } from 'typeorm'; -import { timestampType } from '../common/utils/postgres-sql'; - -/** A single question/answer pair rendered into FAQPage structured data. */ -export interface ErrorPageFaqItem { - q: string; - a: string; -} - -/** One day of the occurrence sparkline shown on the public page. */ -export interface ErrorPageTrendPoint { - date: string; // YYYY-MM-DD - count: number; -} - -/** - * Aggregate, non-identifying stats snapshot for the cluster. Pushed from the - * Peacock CMS and refreshed on its hourly rollup. `tenants` is the k-anonymity - * signal — a page is never published below MIN_TENANTS_FOR_PUBLIC distinct teams. - */ -export interface ErrorPageStats { - tenants: number; - volume_7d: number; - volume_30d: number; - recovery_rate: number | null; // 0..1 — share auto-recovered by a Manifest fallback - last_seen: string | null; // ISO timestamp - trend: ErrorPageTrendPoint[]; - variants?: string[]; // distinct message wordings for the same cluster ("Also seen as") -} - -export interface ErrorPageRelatedLink { - slug: string; - title: string; -} - -/** - * A published, operator-approved error-cluster page served to the public - * marketing site. This table is a READ-OPTIMISED MIRROR: it only ever holds - * rows that an operator explicitly published from the Peacock CMS (pushed via - * the secret-guarded internal endpoint). There is no code path from raw - * `agent_messages` to this table — that is the trust boundary that keeps - * un-curated error data private. - */ -@Entity('public_error_pages') -export class PublicErrorPage { - @PrimaryColumn('varchar') - slug!: string; - - @Column('varchar') - cluster_key!: string; - - @Column('varchar') - provider!: string; - - @Column('varchar', { default: '' }) - provider_label!: string; - - @Column('integer', { nullable: true }) - http_status!: number | null; - - @Column('varchar', { default: 'unknown' }) - category!: string; - - @Column('varchar', { default: '' }) - category_label!: string; - - @Column('varchar') - title!: string; - - @Column('varchar') - meta_description!: string; - - @Column('varchar') - h1!: string; - - @Column('text', { default: '' }) - body_what!: string; - - @Column('text', { default: '' }) - body_fix!: string; - - @Column('text', { default: '' }) - body_manifest!: string; - - @Column('text', { default: '' }) - sample_message!: string; - - @Column('jsonb') - faq!: ErrorPageFaqItem[]; - - @Column('jsonb') - stats!: ErrorPageStats; - - @Column('jsonb') - related!: ErrorPageRelatedLink[]; - - @Column('boolean', { default: false }) - noindex!: boolean; - - @Column(timestampType()) - published_at!: string; - - @Column(timestampType()) - updated_at!: string; -} diff --git a/packages/backend/src/entities/waitlist-claim.entity.ts b/packages/backend/src/entities/waitlist-claim.entity.ts deleted file mode 100644 index 21a8d6c8..00000000 --- a/packages/backend/src/entities/waitlist-claim.entity.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; - -@Entity('waitlist_claims') -export class WaitlistClaim { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column('varchar', { unique: true }) - email!: string; - - @Column('varchar', { default: 'self-hosted' }) - source!: string; - - @Column('timestamp with time zone', { default: () => 'now()' }) - claimed_at!: string; -} diff --git a/packages/backend/src/error-pages/dto/upsert-error-page.dto.ts b/packages/backend/src/error-pages/dto/upsert-error-page.dto.ts deleted file mode 100644 index c07536d5..00000000 --- a/packages/backend/src/error-pages/dto/upsert-error-page.dto.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { IsArray, IsBoolean, IsInt, IsObject, IsOptional, IsString } from 'class-validator'; -import type { - ErrorPageFaqItem, - ErrorPageRelatedLink, - ErrorPageStats, -} from '../../entities/public-error-page.entity'; - -/** - * Payload the Peacock CMS pushes to publish (or re-publish/refresh) a page. - * The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so every - * accepted field must be declared here; nested objects are validated loosely - * (the push is a trusted, secret-guarded internal call, and the service - * re-scrubs + re-validates the k-anonymity floor defensively). - */ -export class UpsertErrorPageDto { - @IsString() slug!: string; - @IsString() cluster_key!: string; - @IsString() provider!: string; - @IsOptional() @IsString() provider_label?: string; - @IsOptional() @IsInt() http_status?: number | null; - @IsOptional() @IsString() category?: string; - @IsOptional() @IsString() category_label?: string; - @IsString() title!: string; - @IsString() meta_description!: string; - @IsString() h1!: string; - @IsOptional() @IsString() body_what?: string; - @IsOptional() @IsString() body_fix?: string; - @IsOptional() @IsString() body_manifest?: string; - @IsOptional() @IsString() sample_message?: string; - @IsOptional() @IsArray() faq?: ErrorPageFaqItem[]; - @IsObject() stats!: ErrorPageStats; - @IsOptional() @IsArray() related?: ErrorPageRelatedLink[]; - @IsOptional() @IsBoolean() noindex?: boolean; -} diff --git a/packages/backend/src/error-pages/error-cluster-seeder.service.spec.ts b/packages/backend/src/error-pages/error-cluster-seeder.service.spec.ts deleted file mode 100644 index 7c8dc9fe..00000000 --- a/packages/backend/src/error-pages/error-cluster-seeder.service.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { ErrorClusterSeederService } from './error-cluster-seeder.service'; - -/** - * Build a fake repo whose query-builder chain resolves `getCount()` to the - * supplied value, and whose `insert` records every batch it receives. - */ -function makeRepo(existingCount: number) { - const qb = { - where: jest.fn().mockReturnThis(), - getCount: jest.fn().mockResolvedValue(existingCount), - }; - const insert = jest.fn().mockResolvedValue(undefined); - const repo = { - createQueryBuilder: jest.fn().mockReturnValue(qb), - insert, - }; - return { repo, qb, insert }; -} - -describe('ErrorClusterSeederService', () => { - const original = process.env['SEED_DATA']; - - afterEach(() => { - if (original === undefined) delete process.env['SEED_DATA']; - else process.env['SEED_DATA'] = original; - jest.restoreAllMocks(); - }); - - it('returns early without querying when SEED_DATA is unset', async () => { - delete process.env['SEED_DATA']; - const { repo, insert } = makeRepo(0); - const service = new ErrorClusterSeederService(repo as never); - - await service.onModuleInit(); - - expect(repo.createQueryBuilder).not.toHaveBeenCalled(); - expect(insert).not.toHaveBeenCalled(); - }); - - it('returns early when SEED_DATA is set to a non-"true" value', async () => { - process.env['SEED_DATA'] = 'false'; - const { repo, insert } = makeRepo(0); - const service = new ErrorClusterSeederService(repo as never); - - await service.onModuleInit(); - - expect(repo.createQueryBuilder).not.toHaveBeenCalled(); - expect(insert).not.toHaveBeenCalled(); - }); - - it('skips inserting when seed rows already exist', async () => { - process.env['SEED_DATA'] = 'true'; - const { repo, qb, insert } = makeRepo(5); - const service = new ErrorClusterSeederService(repo as never); - - await service.onModuleInit(); - - expect(qb.where).toHaveBeenCalledWith('m.id LIKE :p', { p: 'seed-err-%' }); - expect(qb.getCount).toHaveBeenCalledTimes(1); - expect(insert).not.toHaveBeenCalled(); - }); - - describe('full seed run', () => { - it('inserts error rows (plus recovered siblings) in batches of 500', async () => { - process.env['SEED_DATA'] = 'true'; - // Make randomness deterministic so both the recentOnly branch and the - // recovery-sibling branch are exercised on every spec. - jest.spyOn(Math, 'random').mockReturnValue(0.1); - const { repo, insert } = makeRepo(0); - const service = new ErrorClusterSeederService(repo as never); - - await service.onModuleInit(); - - expect(insert).toHaveBeenCalled(); - - // Every batch must be <= 500 rows. - const batches = insert.mock.calls.map((c) => c[0] as unknown[]); - for (const batch of batches) { - expect(batch.length).toBeGreaterThan(0); - expect(batch.length).toBeLessThanOrEqual(500); - } - - const allRows = batches.flat() as Array>; - - // Error rows carry the seed id prefix and an http status field. - const errorRows = allRows.filter((r) => !String(r['id']).endsWith('-ok')); - expect(errorRows.length).toBeGreaterThan(0); - errorRows.forEach((r) => { - expect(String(r['id']).startsWith('seed-err-')).toBe(true); - expect(r['agent_id']).toBe('seed-err-agent'); - }); - - // With random=0.1 (< every spec's recovery fraction at made/total≈0), at - // least some recovered `ok` siblings are produced sharing the trace. - const okRows = allRows.filter((r) => String(r['id']).endsWith('-ok')); - expect(okRows.length).toBeGreaterThan(0); - okRows.forEach((r) => { - expect(r['status']).toBe('success'); - expect(r['input_tokens']).toBe(50); - expect(r['output_tokens']).toBe(80); - }); - }); - - it('omits recovered siblings when randomness exceeds every recovery fraction', async () => { - process.env['SEED_DATA'] = 'true'; - // random=0.99 keeps made/total < recovery false for the very first row of - // each spec (recovery max is 0.88), so the recovery branch is skipped. - jest.spyOn(Math, 'random').mockReturnValue(0.99); - const { repo, insert } = makeRepo(0); - const service = new ErrorClusterSeederService(repo as never); - - await service.onModuleInit(); - - const allRows = insert.mock.calls.flatMap((c) => c[0] as Array>); - // Some `ok` rows may still appear once made/total climbs, but the first - // row of the first spec must be an error with no immediate sibling — the - // branch coverage we care about (the `if` evaluating false) is hit. - expect(allRows.length).toBeGreaterThan(0); - expect(allRows.some((r) => !String(r['id']).endsWith('-ok'))).toBe(true); - }); - }); -}); diff --git a/packages/backend/src/error-pages/error-cluster-seeder.service.ts b/packages/backend/src/error-pages/error-cluster-seeder.service.ts deleted file mode 100644 index 51245d35..00000000 --- a/packages/backend/src/error-pages/error-cluster-seeder.service.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { normalizeStatus } from 'manifest-shared'; -import { AgentMessage } from '../entities/agent-message.entity'; - -/** - * Seeds realistic error `agent_messages` (dev/test only) so the live discovery - * endpoint produces real GROUP BY clusters instead of a hardcoded list. Each - * spec fans out across `tenants` distinct synthetic tenants with a recovered - * fraction (an `ok` sibling sharing the trace) and rotates through several - * message `samples` so discovery can surface real "Also seen as" variants. - * Idempotent: skips if seed rows already exist (id LIKE 'seed-err-%'). - */ -interface Spec { - key: string; - provider: string; - http: number | null; - status: string; - model: string; - samples: string[]; - tenants: number; - rowsPerTenant: number; - recovery: number; - recentOnly?: boolean; -} - -const SPECS: Spec[] = [ - { - key: 'g429', - provider: 'gemini', - http: 429, - status: 'rate_limited', - model: 'gemini-2.5-pro', - tenants: 120, - rowsPerTenant: 6, - recovery: 0.74, - samples: [ - '{ "error": { "code": 429, "message": "You exceeded your current quota, please check your plan and billing details.", "status": "RESOURCE_EXHAUSTED" } }', - '{ "error": { "code": 429, "message": "Resource has been exhausted (e.g. check quota).", "status": "RESOURCE_EXHAUSTED" } }', - 'Quota exceeded for quota metric "Generate Content API requests per minute".', - ], - }, - { - key: 'or404', - provider: 'openrouter', - http: 404, - status: 'error', - model: 'anthropic/claude-3.5-sonnet', - tenants: 80, - rowsPerTenant: 5, - recovery: 0.81, - samples: [ - '{"error":{"message":"No endpoints found for the requested model.","code":404}}', - '{"error":{"message":"No allowed providers are available for the selected model.","code":404}}', - ], - }, - { - key: 'or402', - provider: 'openrouter', - http: 402, - status: 'error', - model: 'qwen/qwen3-coder:free', - tenants: 70, - rowsPerTenant: 6, - recovery: 0.88, - samples: [ - '{"error":{"message":"Rate limit exceeded: free-models-per-day. Add 10 credits to unlock 1000 free model requests per day.","code":402}}', - '{"error":{"message":"Insufficient credits. Add more credits to continue.","code":402}}', - ], - }, - { - key: 'oa401', - provider: 'openai', - http: 401, - status: 'error', - model: 'gpt-4o', - tenants: 55, - rowsPerTenant: 7, - recovery: 0.42, - samples: [ - '{ "error": { "message": "Your authentication token has been invalidated. Please try signing in again.", "type": "invalid_request_error", "code": "invalid_authentication" } }', - '{ "error": { "message": "Incorrect API key provided.", "type": "invalid_request_error", "code": "invalid_api_key" } }', - ], - }, - { - key: 'ds400', - provider: 'deepseek', - http: 400, - status: 'error', - model: 'deepseek-reasoner', - tenants: 48, - rowsPerTenant: 5, - recovery: 0.6, - samples: [ - '{"error":{"message":"The reasoning_content in the thinking mode must be passed.","code":400}}', - '{"error":{"message":"deepseek-reasoner does not support successive user or assistant messages.","code":400}}', - ], - }, - { - key: 'zai429', - provider: 'zai', - http: 429, - status: 'rate_limited', - model: 'glm-4.6', - tenants: 46, - rowsPerTenant: 4, - recovery: 0.5, - samples: [ - '{"error":{"code":"1113","message":"Insufficient balance or no resource package. Please recharge."}}', - ], - }, - { - key: 'g400', - provider: 'gemini', - http: 400, - status: 'error', - model: 'gemini-2.5-flash', - tenants: 42, - rowsPerTenant: 4, - recovery: 0.66, - samples: [ - '{ "error": { "code": 400, "message": "Function call is missing a thought_signature in functionCall parts.", "status": "INVALID_ARGUMENT" } }', - '{ "error": { "code": 400, "message": "Please ensure that function call turns come immediately after a user turn or after a function response turn.", "status": "INVALID_ARGUMENT" } }', - ], - }, - { - key: 'g503', - provider: 'gemini', - http: 503, - status: 'error', - model: 'gemini-2.5-pro', - tenants: 40, - rowsPerTenant: 5, - recovery: 0.79, - recentOnly: true, - samples: [ - '{ "error": { "code": 503, "message": "This model is currently experiencing high demand. Spikes in demand are usually temporary.", "status": "UNAVAILABLE" } }', - '{ "error": { "code": 503, "message": "The service is currently unavailable.", "status": "UNAVAILABLE" } }', - ], - }, - { - key: 'an400', - provider: 'anthropic', - http: 400, - status: 'error', - model: 'claude-sonnet-4', - tenants: 30, - rowsPerTenant: 5, - recovery: 0.7, - samples: [ - '{"type":"error","error":{"type":"invalid_request_error","message":"You are out of extra usage. Add more at claude.ai to keep going."}}', - ], - }, -]; - -const DAY_MS = 86400000; - -@Injectable() -export class ErrorClusterSeederService implements OnModuleInit { - private readonly logger = new Logger(ErrorClusterSeederService.name); - - constructor( - @InjectRepository(AgentMessage) - private readonly repo: Repository, - ) {} - - async onModuleInit(): Promise { - if (process.env['SEED_DATA'] !== 'true') return; - const existing = await this.repo - .createQueryBuilder('m') - .where('m.id LIKE :p', { p: 'seed-err-%' }) - .getCount(); - if (existing > 0) return; - - const rows: Partial[] = []; - const now = Date.now(); - for (const s of SPECS) { - let made = 0; - const total = s.tenants * s.rowsPerTenant; - for (let t = 0; t < s.tenants; t++) { - for (let r = 0; r < s.rowsPerTenant; r++) { - const dayOffset = s.recentOnly ? Math.random() * 2 : Math.pow(Math.random(), 1.6) * 30; - const tsMs = now - dayOffset * DAY_MS - Math.random() * DAY_MS; - const ts = new Date(tsMs).toISOString(); - const trace = `seed-err-${s.key}-${t}-${r}`; - rows.push({ - id: trace, - tenant_id: `seed-err-${s.key}-t${t}`, - agent_id: 'seed-err-agent', - trace_id: trace, - timestamp: ts, - status: normalizeStatus(s.status), - error_message: s.samples[(t + r) % s.samples.length], - error_http_status: s.http, - provider: s.provider, - model: s.model, - input_tokens: 0, - output_tokens: 0, - }); - if (made / total < s.recovery) { - rows.push({ - id: `${trace}-ok`, - tenant_id: `seed-err-${s.key}-t${t}`, - agent_id: 'seed-err-agent', - trace_id: trace, - timestamp: new Date(tsMs + 1500).toISOString(), - status: 'success', - provider: s.provider, - model: s.model, - input_tokens: 50, - output_tokens: 80, - }); - } - made++; - } - } - } - - for (let i = 0; i < rows.length; i += 500) { - await this.repo.insert(rows.slice(i, i + 500)); - } - this.logger.log( - `Seeded ${rows.length} error/recovery agent_messages across ${SPECS.length} clusters (with message variants)`, - ); - } -} diff --git a/packages/backend/src/error-pages/error-discovery.service.spec.ts b/packages/backend/src/error-pages/error-discovery.service.spec.ts deleted file mode 100644 index dc252894..00000000 --- a/packages/backend/src/error-pages/error-discovery.service.spec.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { ErrorDiscoveryService } from './error-discovery.service'; - -type AggRow = { - provider: string; - http: number | null; - volume_30d: number; - volume_7d: number; - tenants: number; - first_seen: string | null; - last_seen: string | null; - recovery: number | null; - sample: string | null; -}; - -type TrendRow = { provider: string; http: number | null; day: string; cnt: number }; - -type VariantRow = { provider: string; http: number | null; msg: string | null; cnt: number }; - -function makeAgg(overrides: Partial = {}): AggRow { - return { - provider: 'gemini', - http: 429, - volume_30d: 400, - volume_7d: 100, - tenants: 42, - first_seen: '2026-05-01T00:00:00.000Z', - last_seen: '2026-06-01T00:00:00.000Z', - recovery: 0.74, - sample: 'quota exceeded', - ...overrides, - }; -} - -describe('ErrorDiscoveryService', () => { - let service: ErrorDiscoveryService; - let query: jest.Mock; - - beforeEach(() => { - query = jest.fn(); - service = new ErrorDiscoveryService({ query } as never); - }); - - /** Wire the three sequential `this.repo.query` calls: agg, trend, variants. */ - function setup(aggRows: AggRow[], trendRows: TrendRow[] = [], variantRows: VariantRow[] = []) { - query - .mockResolvedValueOnce(aggRows) - .mockResolvedValueOnce(trendRows) - .mockResolvedValueOnce(variantRows); - } - - it('issues exactly three queries (aggregate, trend, variants)', async () => { - setup([], []); - - await service.discover(); - - expect(query).toHaveBeenCalledTimes(3); - }); - - it('excludes rows without a tenant from every public aggregate', async () => { - setup([], []); - - await service.discover(); - - for (const [sql] of query.mock.calls) { - expect(sql).toContain('AND e.tenant_id IS NOT NULL'); - } - }); - - it('returns an empty list when there are no clusters', async () => { - setup([], []); - - expect(await service.discover()).toEqual([]); - }); - - describe('categoryFor mapping', () => { - const cases: Array<[number | null, string, string]> = [ - [429, 'rate_limit', 'Rate limit'], - [401, 'auth', 'Authentication'], - [403, 'auth', 'Authentication'], - [402, 'billing', 'Billing / quota'], - [404, 'model_unavailable', 'Model unavailable'], - [500, 'server', 'Server error'], - [503, 'server', 'Server error'], - [400, 'bad_request', 'Bad request'], - [422, 'bad_request', 'Bad request'], - [null, 'unknown', 'Unknown'], - ]; - - it.each(cases)('maps http %p to category %s', async (http, id, label) => { - setup([makeAgg({ http })], []); - - const [cluster] = await service.discover(); - - expect(cluster.category).toBe(id); - expect(cluster.category_label).toBe(label); - }); - }); - - describe('cluster_key and suggested_slug', () => { - it('builds key as provider|http and a hyphenated slug', async () => { - setup([makeAgg({ provider: 'gemini', http: 429 })], []); - - const [cluster] = await service.discover(); - - expect(cluster.cluster_key).toBe('gemini|429'); - expect(cluster.suggested_slug).toBe('gemini-429-rate-limit'); - }); - - it('uses |none and -error- in the slug when http is null', async () => { - setup([makeAgg({ provider: 'openrouter', http: null })], []); - - const [cluster] = await service.discover(); - - expect(cluster.cluster_key).toBe('openrouter|none'); - expect(cluster.suggested_slug).toBe('openrouter-error-unknown'); - }); - - it('replaces all underscores in the category id with hyphens', async () => { - setup([makeAgg({ provider: 'openrouter', http: 404 })], []); - - const [cluster] = await service.discover(); - - // model_unavailable → model-unavailable - expect(cluster.suggested_slug).toBe('openrouter-404-model-unavailable'); - }); - }); - - describe('numeric coercion and passthrough', () => { - it('coerces string-y numbers and passes recovery through', async () => { - setup( - [ - makeAgg({ - http: '429' as unknown as number, - tenants: '42' as unknown as number, - volume_7d: '100' as unknown as number, - volume_30d: '400' as unknown as number, - recovery: 0.74, - }), - ], - [], - ); - - const [cluster] = await service.discover(); - - expect(cluster.http_status).toBe(429); - expect(cluster.tenants).toBe(42); - expect(cluster.volume_7d).toBe(100); - expect(cluster.volume_30d).toBe(400); - expect(cluster.recovery_rate).toBe(0.74); - expect(cluster.first_seen).toBe('2026-05-01T00:00:00.000Z'); - expect(cluster.last_seen).toBe('2026-06-01T00:00:00.000Z'); - }); - - it('passes a null recovery rate through as null', async () => { - setup([makeAgg({ recovery: null })], []); - - const [cluster] = await service.discover(); - - expect(cluster.recovery_rate).toBeNull(); - }); - - it('preserves null first_seen / last_seen', async () => { - setup([makeAgg({ first_seen: null, last_seen: null })], []); - - const [cluster] = await service.discover(); - - expect(cluster.first_seen).toBeNull(); - expect(cluster.last_seen).toBeNull(); - }); - }); - - describe('sample scrubbing', () => { - it('scrubs secrets in the sample message', async () => { - setup([makeAgg({ sample: 'key=sk-ant-ABCDEF1234567890 boom' })], []); - - const [cluster] = await service.discover(); - - expect(cluster.sample_message).toContain('[REDACTED]'); - expect(cluster.sample_message).not.toContain('sk-ant-ABCDEF1234567890'); - }); - - it('treats a null sample as an empty string', async () => { - setup([makeAgg({ sample: null })], []); - - const [cluster] = await service.discover(); - - expect(cluster.sample_message).toBe(''); - }); - }); - - describe('trend assembly', () => { - it('attaches the trend rows matching a cluster key', async () => { - setup( - [makeAgg({ provider: 'gemini', http: 429 })], - [ - { provider: 'gemini', http: 429, day: '2026-05-30', cnt: 5 }, - { provider: 'gemini', http: 429, day: '2026-05-31', cnt: 8 }, - // Different key — must NOT leak into the gemini|429 trend. - { provider: 'openai', http: 401, day: '2026-05-31', cnt: 3 }, - ], - ); - - const [cluster] = await service.discover(); - - expect(cluster.trend).toEqual([ - { date: '2026-05-30', count: 5 }, - { date: '2026-05-31', count: 8 }, - ]); - }); - - it('assembles a trend keyed by provider|none for null http', async () => { - setup( - [makeAgg({ provider: 'openrouter', http: null })], - [{ provider: 'openrouter', http: null, day: '2026-05-31', cnt: 9 }], - ); - - const [cluster] = await service.discover(); - - expect(cluster.trend).toEqual([{ date: '2026-05-31', count: 9 }]); - }); - - it('coerces string trend counts to numbers', async () => { - setup( - [makeAgg({ provider: 'gemini', http: 429 })], - [{ provider: 'gemini', http: 429, day: '2026-05-31', cnt: '7' as unknown as number }], - ); - - const [cluster] = await service.discover(); - - expect(cluster.trend).toEqual([{ date: '2026-05-31', count: 7 }]); - }); - - it('defaults to an empty trend when no trend rows match the key', async () => { - setup([makeAgg({ provider: 'gemini', http: 429 })], []); - - const [cluster] = await service.discover(); - - expect(cluster.trend).toEqual([]); - }); - }); - - describe('variants assembly', () => { - it('attaches up to 5 distinct scrubbed variants by key, isolating other keys', async () => { - const vrows: VariantRow[] = [ - { provider: 'gemini', http: 429, msg: 'You exceeded your current quota', cnt: 10 }, - { provider: 'gemini', http: 429, msg: 'Resource has been exhausted', cnt: 8 }, - { provider: 'gemini', http: 429, msg: 'v3', cnt: 7 }, - { provider: 'gemini', http: 429, msg: 'v4', cnt: 6 }, - { provider: 'gemini', http: 429, msg: 'v5', cnt: 5 }, - { provider: 'gemini', http: 429, msg: 'v6-overflow', cnt: 4 }, - { provider: 'openai', http: 401, msg: 'other-key', cnt: 9 }, - ]; - setup([makeAgg({ provider: 'gemini', http: 429 })], [], vrows); - - const [cluster] = await service.discover(); - - expect(cluster.variants).toHaveLength(5); - expect(cluster.variants).toContain('You exceeded your current quota'); - expect(cluster.variants).not.toContain('v6-overflow'); - expect(cluster.variants).not.toContain('other-key'); - }); - - it('scrubs secrets and truncates each variant to 160 chars', async () => { - const long = 'x'.repeat(200); - setup( - [makeAgg({ provider: 'gemini', http: 429 })], - [], - [{ provider: 'gemini', http: 429, msg: `key=sk-ant-ABCDEF1234567890 ${long}`, cnt: 1 }], - ); - - const [cluster] = await service.discover(); - - expect(cluster.variants[0]).toContain('[REDACTED]'); - expect(cluster.variants[0].length).toBeLessThanOrEqual(160); - }); - - it('treats a null variant message as empty and keys by provider|none', async () => { - setup( - [makeAgg({ provider: 'openrouter', http: null })], - [], - [{ provider: 'openrouter', http: null, msg: null, cnt: 2 }], - ); - - const [cluster] = await service.discover(); - - expect(cluster.variants).toEqual(['']); - }); - - it('defaults to empty variants when none match', async () => { - setup([makeAgg({ provider: 'gemini', http: 429 })], [], []); - - const [cluster] = await service.discover(); - - expect(cluster.variants).toEqual([]); - }); - }); - - it('maps multiple clusters preserving aggregate row order', async () => { - setup( - [ - makeAgg({ provider: 'gemini', http: 429, tenants: 120 }), - makeAgg({ provider: 'openai', http: 401, tenants: 55 }), - ], - [], - ); - - const result = await service.discover(); - - expect(result).toHaveLength(2); - expect(result[0].provider).toBe('gemini'); - expect(result[0].category).toBe('rate_limit'); - expect(result[1].provider).toBe('openai'); - expect(result[1].category).toBe('auth'); - }); -}); diff --git a/packages/backend/src/error-pages/error-discovery.service.ts b/packages/backend/src/error-pages/error-discovery.service.ts deleted file mode 100644 index 528245e4..00000000 --- a/packages/backend/src/error-pages/error-discovery.service.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { AgentMessage } from '../entities/agent-message.entity'; -import { scrubSecrets } from '../common/utils/secret-scrub'; -import { MIN_TENANTS_FOR_PUBLIC } from './error-pages.service'; - -// A message variant must appear across at least this many distinct tenants -// before it's surfaced publicly — k-anonymity at the variant level so one -// customer's exact (possibly identifying) error string is never exposed. -const MIN_TENANTS_PER_VARIANT = 3; - -// Public-facing scrub: provider-credential redaction (shared util) PLUS email -// redaction. Error bodies sometimes echo a user's email; strip it before any -// sample/variant leaves the building. -const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g; -function scrubForPublic(text: string | null | undefined): string { - return scrubSecrets(text ?? '').replace(EMAIL_RE, '[email]'); -} - -export interface DiscoveredCluster { - cluster_key: string; - provider: string; - http_status: number | null; - category: string; - category_label: string; - suggested_slug: string; - tenants: number; - volume_7d: number; - volume_30d: number; - recovery_rate: number | null; - first_seen: string | null; - last_seen: string | null; - sample_message: string; - variants: string[]; - trend: { date: string; count: number }[]; -} - -function categoryFor(http: number | null): { id: string; label: string } { - if (http === 429) return { id: 'rate_limit', label: 'Rate limit' }; - if (http === 401 || http === 403) return { id: 'auth', label: 'Authentication' }; - if (http === 402) return { id: 'billing', label: 'Billing / quota' }; - if (http === 404) return { id: 'model_unavailable', label: 'Model unavailable' }; - if (http != null && http >= 500) return { id: 'server', label: 'Server error' }; - if (http != null && http >= 400) return { id: 'bad_request', label: 'Bad request' }; - return { id: 'unknown', label: 'Unknown' }; -} - -/** - * Computes error-cluster aggregates live from `agent_messages` (cross-tenant). - * This is the rollup the Peacock CMS pulls from to discover and refresh clusters. - * Aggregates only — never returns tenant identities — and applies the same - * k-anonymity floor as publishing, so a cluster below the floor is never - * surfaced for curation in the first place. - */ -@Injectable() -export class ErrorDiscoveryService { - constructor( - @InjectRepository(AgentMessage) - private readonly repo: Repository, - ) {} - - async discover(): Promise { - const aggRows: Array<{ - provider: string; - http: number | null; - volume_30d: number; - volume_7d: number; - tenants: number; - first_seen: string | null; - last_seen: string | null; - recovery: number | null; - sample: string | null; - }> = await this.repo.query(` - SELECT e.provider AS provider, - e.error_http_status AS http, - COUNT(*)::int AS volume_30d, - COUNT(*) FILTER (WHERE e.timestamp >= NOW() - INTERVAL '7 days')::int AS volume_7d, - COUNT(DISTINCT e.tenant_id)::int AS tenants, - MIN(e.timestamp) AS first_seen, - MAX(e.timestamp) AS last_seen, - AVG(CASE WHEN ok.trace_id IS NOT NULL THEN 1.0 ELSE 0.0 END)::float AS recovery, - (array_agg(e.error_message ORDER BY e.timestamp DESC))[1] AS sample - FROM agent_messages e - LEFT JOIN ( - SELECT DISTINCT trace_id FROM agent_messages WHERE status IN ('ok', 'success') AND trace_id IS NOT NULL - ) ok ON ok.trace_id = e.trace_id - WHERE e.status IN ('error','fallback_error','rate_limited','failed') - AND e.tenant_id IS NOT NULL - AND e.provider IS NOT NULL - AND e.provider NOT LIKE 'custom:%' - GROUP BY e.provider, e.error_http_status - HAVING COUNT(DISTINCT e.tenant_id) >= ${MIN_TENANTS_FOR_PUBLIC} - ORDER BY COUNT(DISTINCT e.tenant_id) DESC - `); - - const trendRows: Array<{ provider: string; http: number | null; day: string; cnt: number }> = - await this.repo.query(` - SELECT e.provider AS provider, - e.error_http_status AS http, - to_char(date_trunc('day', e.timestamp), 'YYYY-MM-DD') AS day, - COUNT(*)::int AS cnt - FROM agent_messages e - WHERE e.status IN ('error','fallback_error','rate_limited','failed') - AND e.timestamp >= NOW() - INTERVAL '14 days' - AND e.tenant_id IS NOT NULL - AND e.provider IS NOT NULL - AND e.provider NOT LIKE 'custom:%' - GROUP BY e.provider, e.error_http_status, day - ORDER BY day ASC - `); - - const trendByKey = new Map(); - for (const r of trendRows) { - const key = `${r.provider}|${r.http ?? 'none'}`; - if (!trendByKey.has(key)) trendByKey.set(key, []); - trendByKey.get(key)!.push({ date: r.day, count: Number(r.cnt) }); - } - - // Top distinct message strings per cluster — powers the "Also seen as" - // block, widening lexical coverage to the variant wordings users search. - const variantRows: Array<{ provider: string; http: number | null; msg: string; cnt: number }> = - await this.repo.query(` - SELECT e.provider AS provider, - e.error_http_status AS http, - e.error_message AS msg, - COUNT(*)::int AS cnt - FROM agent_messages e - WHERE e.status IN ('error','fallback_error','rate_limited','failed') - AND e.error_message IS NOT NULL - AND e.tenant_id IS NOT NULL - AND e.provider IS NOT NULL - AND e.provider NOT LIKE 'custom:%' - GROUP BY e.provider, e.error_http_status, e.error_message - HAVING COUNT(DISTINCT e.tenant_id) >= ${MIN_TENANTS_PER_VARIANT} - ORDER BY cnt DESC - `); - - const variantsByKey = new Map(); - for (const r of variantRows) { - const key = `${r.provider}|${r.http ?? 'none'}`; - if (!variantsByKey.has(key)) variantsByKey.set(key, []); - const list = variantsByKey.get(key)!; - if (list.length < 5) list.push(scrubForPublic(r.msg).slice(0, 160)); - } - - return aggRows.map((r) => { - const http = r.http != null ? Number(r.http) : null; - const cat = categoryFor(http); - const key = `${r.provider}|${http ?? 'none'}`; - return { - cluster_key: key, - provider: r.provider, - http_status: http, - category: cat.id, - category_label: cat.label, - suggested_slug: `${r.provider}-${http ?? 'error'}-${cat.id.replace(/_/g, '-')}`, - tenants: Number(r.tenants), - volume_7d: Number(r.volume_7d), - volume_30d: Number(r.volume_30d), - recovery_rate: r.recovery != null ? Number(r.recovery) : null, - first_seen: r.first_seen, - last_seen: r.last_seen, - sample_message: scrubForPublic(r.sample), - variants: variantsByKey.get(key) ?? [], - trend: trendByKey.get(key) ?? [], - }; - }); - } -} diff --git a/packages/backend/src/error-pages/error-page-seed-data.spec.ts b/packages/backend/src/error-pages/error-page-seed-data.spec.ts deleted file mode 100644 index 088288a7..00000000 --- a/packages/backend/src/error-pages/error-page-seed-data.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ERROR_PAGE_SEEDS, seedTrend } from './error-page-seed-data'; -import { MIN_TENANTS_FOR_PUBLIC } from './error-pages.service'; - -describe('seedTrend', () => { - const END = Date.UTC(2026, 6, 1); // 2026-07-01 - - it('returns 14 ascending daily points ending on the end date, never zero', () => { - const trend = seedTrend(720, 0, END); - expect(trend).toHaveLength(14); - expect(trend[0].date).toBe('2026-06-18'); - expect(trend[13].date).toBe('2026-07-01'); - for (const p of trend) expect(p.count).toBeGreaterThanOrEqual(1); - const dates = trend.map((p) => p.date); - expect([...dates].sort()).toEqual(dates); // already ascending - }); - - it('is deterministic and scales with volume + salt', () => { - expect(seedTrend(720, 0, END)).toEqual(seedTrend(720, 0, END)); - const big = seedTrend(3000, 1, END).reduce((a, p) => a + p.count, 0); - const small = seedTrend(300, 1, END).reduce((a, p) => a + p.count, 0); - expect(big).toBeGreaterThan(small); - }); - - it('still emits a non-zero line for a tiny volume', () => { - const trend = seedTrend(1, 3, END); - expect(trend).toHaveLength(14); - for (const p of trend) expect(p.count).toBeGreaterThanOrEqual(1); - }); -}); - -describe('ERROR_PAGE_SEEDS', () => { - it('has unique slugs, each prefixed with its provider, above the k-anon floor', () => { - const slugs = ERROR_PAGE_SEEDS.map((s) => s.slug); - expect(new Set(slugs).size).toBe(slugs.length); - for (const s of ERROR_PAGE_SEEDS) { - expect(s.slug.startsWith(`${s.provider}-`)).toBe(true); - expect(s.tenants).toBeGreaterThanOrEqual(MIN_TENANTS_FOR_PUBLIC); - expect(s.http_status).toBeGreaterThan(0); - expect(s.category).toBeTruthy(); - expect(s.title).toBeTruthy(); - } - }); - - it('covers several providers and categories for rich hubs', () => { - expect(new Set(ERROR_PAGE_SEEDS.map((s) => s.provider)).size).toBeGreaterThanOrEqual(4); - expect(new Set(ERROR_PAGE_SEEDS.map((s) => s.category)).size).toBeGreaterThanOrEqual(4); - }); -}); diff --git a/packages/backend/src/error-pages/error-page-seed-data.ts b/packages/backend/src/error-pages/error-page-seed-data.ts deleted file mode 100644 index 779f7a46..00000000 --- a/packages/backend/src/error-pages/error-page-seed-data.ts +++ /dev/null @@ -1,410 +0,0 @@ -import type { ErrorPageTrendPoint } from '../entities/public-error-page.entity'; - -/** - * Curated demo error pages published on boot (dev/test only) so the public - * `/errors/` catalog on the marketing site has a full, chart-rich set to render - * without a live Peacock CMS. Each entry is published through the same - * `ErrorPagesService.upsert` valve as a real CMS push, so the k-anonymity floor - * and secret/email scrub still apply. Content is illustrative, not real tenant - * data — `tenants`/`volume_*` are plausible aggregates and every `sample_message` - * is a public, provider-documented error shape. - */ -export interface ErrorPageSeed { - slug: string; - cluster_key: string; - provider: string; - provider_label: string; - http_status: number; - category: string; - category_label: string; - title: string; - meta_description: string; - h1: string; - body_what: string; - body_fix: string; - sample_message: string; - faq: { q: string; a: string }[]; - tenants: number; - volume_7d: number; - volume_30d: number; - recovery_rate: number; - variants?: string[]; -} - -const DAY_MS = 86400000; -const TREND_DAYS = 14; - -/** - * Build a 14-day occurrence sparkline that sums to roughly the 30-day volume, - * with a gentle upward ramp and a deterministic wave (so the demo chart looks - * alive without random noise, and tests stay stable). Never emits a zero day, - * so the line always renders. - */ -export function seedTrend(volume30d: number, salt: number, endMs: number): ErrorPageTrendPoint[] { - const perDay = Math.max(1, Math.round(volume30d / 30)); - const start = endMs - (TREND_DAYS - 1) * DAY_MS; - const points: ErrorPageTrendPoint[] = []; - for (let d = 0; d < TREND_DAYS; d++) { - const wave = 0.7 + 0.6 * Math.abs(Math.sin((d + salt) * 0.6)); - const ramp = 0.6 + (0.8 * d) / (TREND_DAYS - 1); - const count = Math.max(1, Math.round(perDay * wave * ramp)); - points.push({ date: new Date(start + d * DAY_MS).toISOString().slice(0, 10), count }); - } - return points; -} - -export const ERROR_PAGE_SEEDS: ErrorPageSeed[] = [ - { - slug: 'gemini-429-quota-exceeded', - cluster_key: 'gemini|rate_limited|429|RESOURCE_EXHAUSTED', - provider: 'gemini', - provider_label: 'Google Gemini', - http_status: 429, - category: 'rate_limit', - category_label: 'Rate limit', - title: 'Fix Gemini 429 "You exceeded your current quota"', - meta_description: - 'Why Google Gemini returns 429 RESOURCE_EXHAUSTED across AI agents and how to stop it breaking runs.', - h1: 'Gemini 429 — You exceeded your current quota', - body_what: - 'Gemini returns HTTP 429 with RESOURCE_EXHAUSTED when a project crosses its per-minute or per-day quota. Bursty agent loops hit it first.', - body_fix: - '- Back off and retry with exponential jitter\n- Request a quota increase in Google AI Studio\n- Spread load across keys or fall back to another model', - sample_message: - '{ "error": { "code": 429, "message": "You exceeded your current quota, please check your plan and billing details.", "status": "RESOURCE_EXHAUSTED" } }', - faq: [ - { - q: 'Is a Gemini 429 the same as being rate limited?', - a: 'Yes. RESOURCE_EXHAUSTED means you hit a per-minute or per-day quota; it clears once the window resets.', - }, - ], - tenants: 120, - volume_7d: 297, - volume_30d: 720, - recovery_rate: 0.74, - variants: [ - 'Resource has been exhausted (e.g. check quota).', - 'Quota exceeded for quota metric "Generate Content API requests per minute".', - ], - }, - { - slug: 'gemini-400-missing-thought-signature', - cluster_key: 'gemini|error|400|INVALID_ARGUMENT', - provider: 'gemini', - provider_label: 'Google Gemini', - http_status: 400, - category: 'bad_request', - category_label: 'Bad request', - title: 'Fix Gemini 400 "missing thought_signature" in function calls', - meta_description: - 'Gemini 2.5 rejects tool calls that drop the thought_signature. Here is why and how to keep function calling working.', - h1: 'Gemini 400 — Function call missing a thought_signature', - body_what: - 'Gemini 2.5 returns 400 INVALID_ARGUMENT when a functionCall part is replayed without the thought_signature it originally issued.', - body_fix: - '- Preserve thought_signature on every functionCall part you send back\n- Keep function turns immediately after the matching user or tool turn\n- Do not hand-edit or truncate prior tool messages', - sample_message: - '{ "error": { "code": 400, "message": "Function call is missing a thought_signature in functionCall parts.", "status": "INVALID_ARGUMENT" } }', - faq: [ - { - q: 'Why did this start after upgrading to Gemini 2.5?', - a: 'Thought signatures are new to 2.5 thinking models; older clients that strip unknown fields drop them and trigger the 400.', - }, - ], - tenants: 42, - volume_7d: 61, - volume_30d: 168, - recovery_rate: 0.66, - }, - { - slug: 'gemini-503-overloaded', - cluster_key: 'gemini|error|503|UNAVAILABLE', - provider: 'gemini', - provider_label: 'Google Gemini', - http_status: 503, - category: 'server', - category_label: 'Server error', - title: 'Fix Gemini 503 "model is overloaded / UNAVAILABLE"', - meta_description: - 'Gemini 503 UNAVAILABLE spikes when demand surges. How to ride it out without failing agent runs.', - h1: 'Gemini 503 — The model is currently overloaded', - body_what: - 'A 503 UNAVAILABLE is server-side: Gemini is briefly saturated. It is transient but clusters during peak hours.', - body_fix: - '- Retry after a short delay with backoff\n- Fail over to a second model on repeated 503s\n- Avoid hammering the same region during a spike', - sample_message: - '{ "error": { "code": 503, "message": "The model is currently experiencing high demand. Spikes in demand are usually temporary.", "status": "UNAVAILABLE" } }', - faq: [ - { - q: 'Is a 503 my fault?', - a: 'No. It is a temporary capacity issue on the provider; a retry or fallback usually succeeds within seconds.', - }, - ], - tenants: 40, - volume_7d: 96, - volume_30d: 150, - recovery_rate: 0.79, - }, - { - slug: 'openai-401-invalid-authentication', - cluster_key: 'openai|error|401|invalid_authentication', - provider: 'openai', - provider_label: 'OpenAI', - http_status: 401, - category: 'auth', - category_label: 'Authentication', - title: 'Fix OpenAI 401 "invalid authentication" / bad API key', - meta_description: - 'OpenAI 401 invalid_authentication means the key is missing, wrong, or revoked. How to find and fix it fast.', - h1: 'OpenAI 401 — Invalid authentication', - body_what: - 'OpenAI returns 401 when the API key is missing, malformed, revoked, or scoped to the wrong org or project.', - body_fix: - '- Confirm the Authorization: Bearer header carries a current key\n- Rotate the key if it may be leaked or revoked\n- Check the org and project the key is scoped to', - sample_message: - '{ "error": { "message": "Your authentication token has been invalidated. Please try signing in again.", "type": "invalid_request_error", "code": "invalid_authentication" } }', - faq: [ - { - q: 'Why did a working key suddenly 401?', - a: 'Keys 401 the moment they are rotated or revoked, or if the project they belong to is deleted.', - }, - ], - tenants: 55, - volume_7d: 120, - volume_30d: 385, - recovery_rate: 0.42, - variants: ['Incorrect API key provided.'], - }, - { - slug: 'openai-429-rate-limit', - cluster_key: 'openai|rate_limited|429|rate_limit_exceeded', - provider: 'openai', - provider_label: 'OpenAI', - http_status: 429, - category: 'rate_limit', - category_label: 'Rate limit', - title: 'Fix OpenAI 429 "rate limit exceeded"', - meta_description: - 'OpenAI 429s cap requests-per-minute and tokens-per-minute. How to smooth bursts so agents keep running.', - h1: 'OpenAI 429 — Rate limit exceeded', - body_what: - 'OpenAI throttles by requests-per-minute and tokens-per-minute per model. Parallel agent calls trip it in bursts.', - body_fix: - '- Honor the Retry-After header before retrying\n- Add client-side concurrency limits\n- Request a higher tier or split traffic across models', - sample_message: - '{ "error": { "message": "Rate limit reached for requests. Limit 3500 per min.", "type": "requests", "code": "rate_limit_exceeded" } }', - faq: [ - { - q: 'RPM or TPM — which limit am I hitting?', - a: 'The error names it: "requests" is per-minute request cap, "tokens" is per-minute token cap.', - }, - ], - tenants: 68, - volume_7d: 210, - volume_30d: 540, - recovery_rate: 0.7, - }, - { - slug: 'openai-400-context-length-exceeded', - cluster_key: 'openai|error|400|context_length_exceeded', - provider: 'openai', - provider_label: 'OpenAI', - http_status: 400, - category: 'bad_request', - category_label: 'Bad request', - title: 'Fix OpenAI 400 "context length exceeded"', - meta_description: - "OpenAI 400 context_length_exceeded means the prompt plus completion is over the model's window. How to trim it.", - h1: 'OpenAI 400 — Context length exceeded', - body_what: - "A 400 context_length_exceeded fires when input tokens plus max_tokens exceed the model's context window.", - body_fix: - '- Trim or summarize history before sending\n- Lower max_tokens to leave room for the prompt\n- Move to a longer-context model for big inputs', - sample_message: - '{ "error": { "message": "This model\'s maximum context length is 128000 tokens.", "type": "invalid_request_error", "code": "context_length_exceeded" } }', - faq: [ - { - q: 'Does max_tokens count toward the limit?', - a: 'Yes. The window must fit prompt tokens plus the completion you reserve with max_tokens.', - }, - ], - tenants: 34, - volume_7d: 70, - volume_30d: 210, - recovery_rate: 0.88, - }, - { - slug: 'anthropic-400-out-of-extra-usage', - cluster_key: 'anthropic|error|400|invalid_request_error', - provider: 'anthropic', - provider_label: 'Anthropic', - http_status: 400, - category: 'bad_request', - category_label: 'Bad request', - title: 'Fix Anthropic "you are out of extra usage"', - meta_description: - 'Claude returns a 400 invalid_request_error when a plan runs out of extra usage. What it means and how to keep going.', - h1: 'Anthropic 400 — You are out of extra usage', - body_what: - 'Claude rejects the request when the account has spent its included plus extra usage allowance for the period.', - body_fix: - '- Add or raise the extra usage limit at claude.ai\n- Switch that agent to an API-key provider with credit\n- Fall back to a cheaper model for low-stakes calls', - sample_message: - '{ "type": "error", "error": { "type": "invalid_request_error", "message": "You are out of extra usage. Add more at claude.ai to keep going." } }', - faq: [ - { - q: 'Is this a billing or a rate error?', - a: 'Billing. It clears when you raise the usage cap or the billing period resets, not by retrying.', - }, - ], - tenants: 30, - volume_7d: 66, - volume_30d: 150, - recovery_rate: 0.7, - }, - { - slug: 'anthropic-529-overloaded', - cluster_key: 'anthropic|error|529|overloaded_error', - provider: 'anthropic', - provider_label: 'Anthropic', - http_status: 529, - category: 'server', - category_label: 'Server error', - title: 'Fix Anthropic 529 "overloaded_error"', - meta_description: - 'Claude 529 overloaded_error means the API is briefly saturated. How to retry and fail over cleanly.', - h1: 'Anthropic 529 — Overloaded', - body_what: - 'A 529 overloaded_error is server-side back-pressure from Anthropic during demand spikes. It is transient.', - body_fix: - '- Retry with exponential backoff and jitter\n- Fail over to another provider on repeated 529s\n- Cap concurrency during known peak windows', - sample_message: - '{ "type": "error", "error": { "type": "overloaded_error", "message": "Overloaded" } }', - faq: [ - { - q: 'Why 529 and not 503?', - a: 'Anthropic uses 529 specifically for capacity overload; treat it like a retryable 503.', - }, - ], - tenants: 26, - volume_7d: 72, - volume_30d: 96, - recovery_rate: 0.83, - }, - { - slug: 'openrouter-402-insufficient-credits', - cluster_key: 'openrouter|error|402|insufficient_credits', - provider: 'openrouter', - provider_label: 'OpenRouter', - http_status: 402, - category: 'billing', - category_label: 'Billing / quota', - title: 'Fix OpenRouter 402 "insufficient credits"', - meta_description: - 'OpenRouter 402 means the account or free tier is out of credits. How to top up or route around it.', - h1: 'OpenRouter 402 — Insufficient credits', - body_what: - 'OpenRouter returns 402 when paid credits run out, or when a free model exhausts its daily allowance.', - body_fix: - '- Top up credits on the OpenRouter dashboard\n- Route free-tier traffic to a key-backed provider\n- Watch the free-models-per-day cap in agent loops', - sample_message: - '{ "error": { "message": "Rate limit exceeded: free-models-per-day. Add 10 credits to unlock 1000 free model requests per day.", "code": 402 } }', - faq: [ - { - q: 'Why a 402 on a free model?', - a: 'Free models share a daily request cap; once spent, OpenRouter returns 402 until you add credits or the day resets.', - }, - ], - tenants: 70, - volume_7d: 150, - volume_30d: 420, - recovery_rate: 0.88, - }, - { - slug: 'openrouter-404-model-not-found', - cluster_key: 'openrouter|error|404|no_endpoints', - provider: 'openrouter', - provider_label: 'OpenRouter', - http_status: 404, - category: 'model_unavailable', - category_label: 'Model unavailable', - title: 'Fix OpenRouter 404 "no endpoints found for model"', - meta_description: - 'OpenRouter 404 means the requested model id has no available provider right now. How to pick a live one.', - h1: 'OpenRouter 404 — No endpoints found for model', - body_what: - 'A 404 means the model slug is wrong, deprecated, or every provider behind it is temporarily unavailable.', - body_fix: - '- Verify the exact model id against the OpenRouter models list\n- Drop provider filters that exclude every endpoint\n- Fall back to an equivalent model that is live', - sample_message: - '{ "error": { "message": "No endpoints found for the requested model.", "code": 404 } }', - faq: [ - { - q: 'The model existed yesterday — why 404 now?', - a: 'Models get deprecated or briefly lose all providers; pin to a current id or add a fallback.', - }, - ], - tenants: 80, - volume_7d: 130, - volume_30d: 400, - recovery_rate: 0.81, - }, - { - slug: 'deepseek-400-reasoning-content-required', - cluster_key: 'deepseek|error|400|reasoning_content', - provider: 'deepseek', - provider_label: 'DeepSeek', - http_status: 400, - category: 'bad_request', - category_label: 'Bad request', - title: 'Fix DeepSeek 400 "reasoning_content must be passed"', - meta_description: - 'DeepSeek reasoner rejects turns that drop reasoning_content or stack same-role messages. How to shape the request.', - h1: 'DeepSeek 400 — reasoning_content must be passed', - body_what: - 'deepseek-reasoner requires the prior reasoning_content on follow-up turns and rejects successive same-role messages.', - body_fix: - '- Echo the assistant reasoning_content back on the next turn\n- Never send two user or two assistant messages in a row\n- Strip reasoning_content only for non-reasoner models', - sample_message: - '{ "error": { "message": "The reasoning_content in the thinking mode must be passed.", "code": 400 } }', - faq: [ - { - q: 'Do normal chat models need reasoning_content?', - a: 'No. Only the reasoner enforces it; sending it to a plain chat model can itself error.', - }, - ], - tenants: 48, - volume_7d: 96, - volume_30d: 240, - recovery_rate: 0.6, - }, - { - slug: 'groq-429-rate-limit', - cluster_key: 'groq|rate_limited|429|rate_limit_exceeded', - provider: 'groq', - provider_label: 'Groq', - http_status: 429, - category: 'rate_limit', - category_label: 'Rate limit', - title: 'Fix Groq 429 "rate limit exceeded"', - meta_description: - 'Groq 429s cap requests and tokens per minute per model. How to pace bursts so agents keep flowing.', - h1: 'Groq 429 — Rate limit exceeded', - body_what: - 'Groq enforces tight per-minute request and token limits per model; fast agent loops hit them in bursts.', - body_fix: - '- Wait for the window in the Retry-After header\n- Throttle concurrency on the client\n- Spread load across models or request a limit bump', - sample_message: - '{ "error": { "message": "Rate limit reached for model llama-3.3-70b in organization on tokens per minute (TPM).", "code": "rate_limit_exceeded", "type": "tokens" } }', - faq: [ - { - q: 'Why does Groq rate limit so aggressively?', - a: 'Its very high throughput comes with tight per-minute caps; pacing and fallbacks keep runs alive.', - }, - ], - tenants: 38, - volume_7d: 140, - volume_30d: 300, - recovery_rate: 0.76, - }, -]; diff --git a/packages/backend/src/error-pages/error-page-seeder.service.spec.ts b/packages/backend/src/error-pages/error-page-seeder.service.spec.ts deleted file mode 100644 index a313ccf1..00000000 --- a/packages/backend/src/error-pages/error-page-seeder.service.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ErrorPageSeederService } from './error-page-seeder.service'; -import { ErrorPagesService } from './error-pages.service'; -import { ERROR_PAGE_SEEDS } from './error-page-seed-data'; - -describe('ErrorPageSeederService', () => { - let pages: { getBySlug: jest.Mock; upsert: jest.Mock }; - let service: ErrorPageSeederService; - const prev = process.env['SEED_DATA']; - - beforeEach(() => { - pages = { - getBySlug: jest.fn().mockResolvedValue(null), - upsert: jest.fn().mockResolvedValue({ ok: true }), - }; - service = new ErrorPageSeederService(pages as unknown as ErrorPagesService); - }); - - afterEach(() => { - if (prev === undefined) delete process.env['SEED_DATA']; - else process.env['SEED_DATA'] = prev; - }); - - it('does nothing unless SEED_DATA=true', async () => { - delete process.env['SEED_DATA']; - await service.onModuleInit(); - expect(pages.getBySlug).not.toHaveBeenCalled(); - expect(pages.upsert).not.toHaveBeenCalled(); - }); - - it('skips when the demo pages are already published', async () => { - process.env['SEED_DATA'] = 'true'; - pages.getBySlug.mockResolvedValue({ slug: ERROR_PAGE_SEEDS[0].slug }); - await service.onModuleInit(); - expect(pages.upsert).not.toHaveBeenCalled(); - }); - - it('publishes every demo page with a populated 14-day trend when not yet seeded', async () => { - process.env['SEED_DATA'] = 'true'; - await service.onModuleInit(); - expect(pages.upsert).toHaveBeenCalledTimes(ERROR_PAGE_SEEDS.length); - const first = pages.upsert.mock.calls[0][0]; - expect(first.slug).toBe(ERROR_PAGE_SEEDS[0].slug); - expect(first.stats.tenants).toBe(ERROR_PAGE_SEEDS[0].tenants); - expect(first.stats.trend).toHaveLength(14); - expect(first.stats.variants).toEqual(ERROR_PAGE_SEEDS[0].variants ?? []); - expect(first.stats.last_seen).toBeTruthy(); - // an entry without explicit variants still publishes a [] variants array - const noVariant = pages.upsert.mock.calls - .map((c) => c[0]) - .find((d) => d.slug === 'gemini-400-missing-thought-signature'); - expect(noVariant.stats.variants).toEqual([]); - }); -}); diff --git a/packages/backend/src/error-pages/error-page-seeder.service.ts b/packages/backend/src/error-pages/error-page-seeder.service.ts deleted file mode 100644 index 653b9b5e..00000000 --- a/packages/backend/src/error-pages/error-page-seeder.service.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { ErrorPagesService } from './error-pages.service'; -import { ERROR_PAGE_SEEDS, seedTrend } from './error-page-seed-data'; -import type { ErrorPageStats } from '../entities/public-error-page.entity'; - -/** - * Publishes the curated demo error pages (dev/test only) through the same - * `ErrorPagesService.upsert` valve a real Peacock CMS push uses — so the - * marketing `/errors/` catalog renders a full, chart-rich set (sparkline trends - * included) without a live CMS. Gated by `SEED_DATA=true`; idempotent (skips if - * the first demo page is already published, so an operator can unpublish freely). - */ -@Injectable() -export class ErrorPageSeederService implements OnModuleInit { - private readonly logger = new Logger(ErrorPageSeederService.name); - - constructor(private readonly pages: ErrorPagesService) {} - - async onModuleInit(): Promise { - if (process.env['SEED_DATA'] !== 'true') return; - if (await this.pages.getBySlug(ERROR_PAGE_SEEDS[0].slug)) return; - - const now = Date.now(); - const nowIso = new Date(now).toISOString(); - for (let i = 0; i < ERROR_PAGE_SEEDS.length; i++) { - const s = ERROR_PAGE_SEEDS[i]; - const stats: ErrorPageStats = { - tenants: s.tenants, - volume_7d: s.volume_7d, - volume_30d: s.volume_30d, - recovery_rate: s.recovery_rate, - last_seen: nowIso, - trend: seedTrend(s.volume_30d, i, now), - variants: s.variants ?? [], - }; - await this.pages.upsert({ - slug: s.slug, - cluster_key: s.cluster_key, - provider: s.provider, - provider_label: s.provider_label, - http_status: s.http_status, - category: s.category, - category_label: s.category_label, - title: s.title, - meta_description: s.meta_description, - h1: s.h1, - body_what: s.body_what, - body_fix: s.body_fix, - sample_message: s.sample_message, - faq: s.faq, - stats, - }); - } - this.logger.log(`Seeded ${ERROR_PAGE_SEEDS.length} public error pages (dev demo, with trends)`); - } -} diff --git a/packages/backend/src/error-pages/error-pages.module.ts b/packages/backend/src/error-pages/error-pages.module.ts deleted file mode 100644 index 75a0f1ad..00000000 --- a/packages/backend/src/error-pages/error-pages.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { PublicErrorPage } from '../entities/public-error-page.entity'; -import { AgentMessage } from '../entities/agent-message.entity'; -import { ErrorPagesService } from './error-pages.service'; -import { ErrorDiscoveryService } from './error-discovery.service'; -import { ErrorClusterSeederService } from './error-cluster-seeder.service'; -import { ErrorPageSeederService } from './error-page-seeder.service'; -import { PublicErrorPagesController } from './public-error-pages.controller'; -import { InternalErrorPagesController } from './internal-error-pages.controller'; - -@Module({ - imports: [TypeOrmModule.forFeature([PublicErrorPage, AgentMessage])], - controllers: [PublicErrorPagesController, InternalErrorPagesController], - providers: [ - ErrorPagesService, - ErrorDiscoveryService, - ErrorClusterSeederService, - ErrorPageSeederService, - ], -}) -export class ErrorPagesModule {} diff --git a/packages/backend/src/error-pages/error-pages.service.spec.ts b/packages/backend/src/error-pages/error-pages.service.spec.ts deleted file mode 100644 index d85dbd2d..00000000 --- a/packages/backend/src/error-pages/error-pages.service.spec.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; -import { ErrorPagesService, MIN_TENANTS_FOR_PUBLIC } from './error-pages.service'; -import type { PublicErrorPage, ErrorPageStats } from '../entities/public-error-page.entity'; -import type { UpsertErrorPageDto } from './dto/upsert-error-page.dto'; - -function makeStats(overrides: Partial = {}): ErrorPageStats { - return { - tenants: 12, - volume_7d: 100, - volume_30d: 400, - recovery_rate: 0.5, - last_seen: '2026-06-01T00:00:00.000Z', - trend: [], - ...overrides, - }; -} - -function makeDto(overrides: Partial = {}): UpsertErrorPageDto { - return { - slug: 'gemini-429-rate-limit', - cluster_key: 'gemini|429', - provider: 'gemini', - title: 'Gemini 429 rate limit', - meta_description: 'A description', - h1: 'Gemini rate limit', - stats: makeStats(), - ...overrides, - } as UpsertErrorPageDto; -} - -describe('ErrorPagesService', () => { - let service: ErrorPagesService; - let mockRepo: { - find: jest.Mock; - findOne: jest.Mock; - save: jest.Mock; - delete: jest.Mock; - count: jest.Mock; - }; - - beforeEach(() => { - mockRepo = { - find: jest.fn(), - findOne: jest.fn(), - save: jest.fn(), - delete: jest.fn(), - count: jest.fn(), - }; - service = new ErrorPagesService(mockRepo as never); - }); - - it('exposes the k-anonymity floor constant', () => { - expect(MIN_TENANTS_FOR_PUBLIC).toBe(10); - }); - - describe('listPublished', () => { - it('returns pages ordered by published_at DESC', async () => { - const pages = [{ slug: 'a' }, { slug: 'b' }] as PublicErrorPage[]; - mockRepo.find.mockResolvedValue(pages); - - const result = await service.listPublished(); - - expect(result).toBe(pages); - expect(mockRepo.find).toHaveBeenCalledWith({ order: { published_at: 'DESC' } }); - }); - }); - - describe('getBySlug', () => { - it('looks up a single page by slug', async () => { - const page = { slug: 'gemini-429' } as PublicErrorPage; - mockRepo.findOne.mockResolvedValue(page); - - const result = await service.getBySlug('gemini-429'); - - expect(result).toBe(page); - expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { slug: 'gemini-429' } }); - }); - - it('returns null when no page matches', async () => { - mockRepo.findOne.mockResolvedValue(null); - - expect(await service.getBySlug('missing')).toBeNull(); - }); - }); - - describe('upsert', () => { - it('rejects when tenants is below the k-anonymity floor', async () => { - const dto = makeDto({ stats: makeStats({ tenants: 9 }) }); - - await expect(service.upsert(dto)).rejects.toBeInstanceOf(BadRequestException); - await expect(service.upsert(dto)).rejects.toThrow( - 'cluster affects 9 tenants, below the k-anonymity floor of 10', - ); - expect(mockRepo.save).not.toHaveBeenCalled(); - }); - - it('rejects when stats is missing entirely (defaults tenants to 0)', async () => { - const dto = { ...makeDto(), stats: undefined } as unknown as UpsertErrorPageDto; - - await expect(service.upsert(dto)).rejects.toBeInstanceOf(BadRequestException); - await expect(service.upsert(dto)).rejects.toThrow('cluster affects 0 tenants'); - expect(mockRepo.save).not.toHaveBeenCalled(); - }); - - it('rejects when tenants is non-finite (NaN)', async () => { - const dto = makeDto({ - stats: makeStats({ tenants: 'oops' as unknown as number }), - }); - - await expect(service.upsert(dto)).rejects.toBeInstanceOf(BadRequestException); - expect(mockRepo.save).not.toHaveBeenCalled(); - }); - - it('rejects a custom: provider even above the floor (never a public page)', async () => { - const dto = makeDto({ provider: 'custom:1aee1812', stats: makeStats({ tenants: 500 }) }); - await expect(service.upsert(dto)).rejects.toBeInstanceOf(BadRequestException); - await expect(service.upsert(dto)).rejects.toThrow('custom providers are tenant-specific'); - expect(mockRepo.save).not.toHaveBeenCalled(); - }); - - it('accepts exactly the floor (10 tenants)', async () => { - const dto = makeDto({ stats: makeStats({ tenants: MIN_TENANTS_FOR_PUBLIC }) }); - mockRepo.findOne.mockResolvedValue(null); - mockRepo.save.mockResolvedValue(undefined); - - const result = await service.upsert(dto); - - expect(result).toEqual({ ok: true, slug: dto.slug }); - expect(mockRepo.save).toHaveBeenCalledTimes(1); - }); - - it('scrubs secrets from sample_message before persisting', async () => { - const dto = makeDto({ - sample_message: 'token leaked: sk-proj-ABCDEF1234567890 in the body', - }); - mockRepo.findOne.mockResolvedValue(null); - mockRepo.save.mockResolvedValue(undefined); - - await service.upsert(dto); - - const saved = mockRepo.save.mock.calls[0][0] as PublicErrorPage; - expect(saved.sample_message).toBe('token leaked: [REDACTED] in the body'); - expect(saved.sample_message).not.toContain('sk-proj-ABCDEF1234567890'); - }); - - it('preserves an existing published_at on re-upsert', async () => { - const original = '2026-01-01T00:00:00.000Z'; - mockRepo.findOne.mockResolvedValue({ published_at: original } as PublicErrorPage); - mockRepo.save.mockResolvedValue(undefined); - - await service.upsert(makeDto()); - - const saved = mockRepo.save.mock.calls[0][0] as PublicErrorPage; - expect(saved.published_at).toBe(original); - // updated_at is always refreshed to "now", distinct from the preserved value. - expect(saved.updated_at).not.toBe(original); - }); - - it('sets published_at to now for a brand-new page', async () => { - mockRepo.findOne.mockResolvedValue(null); - mockRepo.save.mockResolvedValue(undefined); - - await service.upsert(makeDto()); - - const saved = mockRepo.save.mock.calls[0][0] as PublicErrorPage; - expect(saved.published_at).toBe(saved.updated_at); - expect(() => new Date(saved.published_at).toISOString()).not.toThrow(); - }); - - it('fills defaults for optional fields', async () => { - mockRepo.findOne.mockResolvedValue(null); - mockRepo.save.mockResolvedValue(undefined); - - await service.upsert(makeDto({ provider: 'gemini' })); - - const saved = mockRepo.save.mock.calls[0][0] as PublicErrorPage; - expect(saved.provider_label).toBe('gemini'); // ← provider - expect(saved.http_status).toBeNull(); - expect(saved.category).toBe('unknown'); - expect(saved.category_label).toBe(''); - expect(saved.body_what).toBe(''); - expect(saved.body_fix).toBe(''); - expect(saved.body_manifest).toBe(''); - expect(saved.sample_message).toBe(''); - expect(saved.faq).toEqual([]); - expect(saved.related).toEqual([]); - expect(saved.noindex).toBe(false); - }); - - it('keeps explicitly provided optional fields', async () => { - mockRepo.findOne.mockResolvedValue(null); - mockRepo.save.mockResolvedValue(undefined); - const dto = makeDto({ - provider_label: 'Google Gemini', - http_status: 429, - category: 'rate_limit', - category_label: 'Rate limit', - body_what: 'what', - body_fix: 'fix', - body_manifest: 'manifest', - faq: [{ q: 'why?', a: 'because' }], - related: [{ slug: 'other', title: 'Other' }], - noindex: true, - }); - - await service.upsert(dto); - - const saved = mockRepo.save.mock.calls[0][0] as PublicErrorPage; - expect(saved.provider_label).toBe('Google Gemini'); - expect(saved.http_status).toBe(429); - expect(saved.category).toBe('rate_limit'); - expect(saved.category_label).toBe('Rate limit'); - expect(saved.body_what).toBe('what'); - expect(saved.body_fix).toBe('fix'); - expect(saved.body_manifest).toBe('manifest'); - expect(saved.faq).toEqual([{ q: 'why?', a: 'because' }]); - expect(saved.related).toEqual([{ slug: 'other', title: 'Other' }]); - expect(saved.noindex).toBe(true); - expect(saved.cluster_key).toBe(dto.cluster_key); - expect(saved.stats).toBe(dto.stats); - }); - - it('treats an explicit null http_status as null', async () => { - mockRepo.findOne.mockResolvedValue(null); - mockRepo.save.mockResolvedValue(undefined); - - await service.upsert(makeDto({ http_status: null })); - - const saved = mockRepo.save.mock.calls[0][0] as PublicErrorPage; - expect(saved.http_status).toBeNull(); - }); - }); - - describe('remove', () => { - it('deletes by slug and echoes the slug', async () => { - mockRepo.delete.mockResolvedValue(undefined); - - const result = await service.remove('gemini-429'); - - expect(result).toEqual({ ok: true, slug: 'gemini-429' }); - expect(mockRepo.delete).toHaveBeenCalledWith({ slug: 'gemini-429' }); - }); - }); -}); diff --git a/packages/backend/src/error-pages/error-pages.service.ts b/packages/backend/src/error-pages/error-pages.service.ts deleted file mode 100644 index 3569186c..00000000 --- a/packages/backend/src/error-pages/error-pages.service.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { PublicErrorPage } from '../entities/public-error-page.entity'; -import { scrubSecrets } from '../common/utils/secret-scrub'; -import { UpsertErrorPageDto } from './dto/upsert-error-page.dto'; - -/** - * k-anonymity floor: a cluster is never published as a public page unless it - * affected at least this many distinct tenants. Enforced here (defensively) in - * addition to the Peacock CMS, so even a CMS bug can't leak a sub-threshold - * cluster through the push endpoint. - */ -export const MIN_TENANTS_FOR_PUBLIC = 10; - -// Public valve scrub: provider-credential redaction (shared util) PLUS email -// redaction. This is the single choke point — every sample reaching -// `public_error_pages` passes through here regardless of which source pushed it. -const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g; -function scrubForPublic(text: string | null | undefined): string { - return scrubSecrets(text ?? '').replace(EMAIL_RE, '[email]'); -} - -// A `custom:` provider is a single tenant's private endpoint — its errors -// are tenant-specific and can never become a public page. Rejected here at the -// valve (the single choke point) no matter what the caller pushes, mirroring the -// `custom:%` exclusion already applied in discovery. -function isCustomProvider(provider: string): boolean { - return /^custom($|[:/])/i.test(provider); -} - -@Injectable() -export class ErrorPagesService { - constructor( - @InjectRepository(PublicErrorPage) - private readonly repo: Repository, - ) {} - - listPublished(): Promise { - return this.repo.find({ order: { published_at: 'DESC' } }); - } - - getBySlug(slug: string): Promise { - return this.repo.findOne({ where: { slug } }); - } - - /** - * Publish or refresh a page. Re-validates the k-anonymity floor and re-scrubs - * the public sample message before persisting, regardless of what the caller - * sent — the public table must never hold an un-vetted row. - */ - async upsert(dto: UpsertErrorPageDto): Promise<{ ok: true; slug: string }> { - if (isCustomProvider(dto.provider)) { - throw new BadRequestException( - `Refusing to publish "${dto.slug}": custom providers are tenant-specific and cannot be public pages.`, - ); - } - - const tenants = Number(dto.stats?.tenants ?? 0); - if (!Number.isFinite(tenants) || tenants < MIN_TENANTS_FOR_PUBLIC) { - throw new BadRequestException( - `Refusing to publish "${dto.slug}": cluster affects ${tenants} tenants, below the k-anonymity floor of ${MIN_TENANTS_FOR_PUBLIC}.`, - ); - } - - const now = new Date().toISOString(); - const existing = await this.repo.findOne({ where: { slug: dto.slug } }); - - const row: PublicErrorPage = { - slug: dto.slug, - cluster_key: dto.cluster_key, - provider: dto.provider, - provider_label: dto.provider_label ?? dto.provider, - http_status: dto.http_status ?? null, - category: dto.category ?? 'unknown', - category_label: dto.category_label ?? '', - title: dto.title, - meta_description: dto.meta_description, - h1: dto.h1, - body_what: dto.body_what ?? '', - body_fix: dto.body_fix ?? '', - body_manifest: dto.body_manifest ?? '', - sample_message: scrubForPublic(dto.sample_message), - faq: dto.faq ?? [], - stats: dto.stats, - related: dto.related ?? [], - noindex: dto.noindex ?? false, - published_at: existing?.published_at ?? now, - updated_at: now, - }; - - await this.repo.save(row); - return { ok: true, slug: dto.slug }; - } - - async remove(slug: string): Promise<{ ok: true; slug: string }> { - await this.repo.delete({ slug }); - return { ok: true, slug }; - } -} diff --git a/packages/backend/src/error-pages/internal-error-pages.controller.spec.ts b/packages/backend/src/error-pages/internal-error-pages.controller.spec.ts deleted file mode 100644 index 8dc1a90a..00000000 --- a/packages/backend/src/error-pages/internal-error-pages.controller.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { UnauthorizedException } from '@nestjs/common'; -import type { ConfigService } from '@nestjs/config'; -import { InternalErrorPagesController } from './internal-error-pages.controller'; -import type { ErrorPagesService } from './error-pages.service'; -import type { ErrorDiscoveryService } from './error-discovery.service'; -import type { UpsertErrorPageDto } from './dto/upsert-error-page.dto'; - -const SECRET = 'super-secret-push-token'; - -function makeConfig(secret: string | undefined): ConfigService { - return { - get: jest.fn((key: string) => (key === 'app.errorPagePushSecret' ? secret : undefined)), - } as unknown as ConfigService; -} - -describe('InternalErrorPagesController', () => { - let mockService: { upsert: jest.Mock; remove: jest.Mock }; - let mockDiscovery: { discover: jest.Mock }; - - beforeEach(() => { - mockService = { upsert: jest.fn(), remove: jest.fn() }; - mockDiscovery = { discover: jest.fn() }; - }); - - function makeController(secret: string | undefined): InternalErrorPagesController { - return new InternalErrorPagesController( - mockService as unknown as ErrorPagesService, - mockDiscovery as unknown as ErrorDiscoveryService, - makeConfig(secret), - ); - } - - const dto = { slug: 'gemini-429' } as UpsertErrorPageDto; - - describe('secret rejection', () => { - it('rejects clusters when the header is missing', async () => { - const controller = makeController(SECRET); - - await expect(controller.clusters(undefined as unknown as string)).rejects.toBeInstanceOf( - UnauthorizedException, - ); - expect(mockDiscovery.discover).not.toHaveBeenCalled(); - }); - - it('rejects clusters when the header is wrong', async () => { - const controller = makeController(SECRET); - - await expect(controller.clusters('nope')).rejects.toBeInstanceOf(UnauthorizedException); - expect(mockDiscovery.discover).not.toHaveBeenCalled(); - }); - - it('rejects when the configured secret is empty even if header matches it', async () => { - const controller = makeController(''); - - // Both provided and expected would be '' — but an empty configured secret - // must never authorize, so this still rejects. - await expect(controller.clusters('')).rejects.toBeInstanceOf(UnauthorizedException); - expect(mockDiscovery.discover).not.toHaveBeenCalled(); - }); - - it('rejects when the configured secret is unset (undefined → "")', async () => { - const controller = makeController(undefined); - - await expect(controller.clusters('anything')).rejects.toBeInstanceOf(UnauthorizedException); - }); - - it('rejects upsert with a bad secret without touching the service', async () => { - const controller = makeController(SECRET); - - await expect(controller.upsert('wrong', dto)).rejects.toBeInstanceOf(UnauthorizedException); - expect(mockService.upsert).not.toHaveBeenCalled(); - }); - - it('rejects remove with a bad secret without touching the service', async () => { - const controller = makeController(SECRET); - - await expect(controller.remove('wrong', 'gemini-429')).rejects.toBeInstanceOf( - UnauthorizedException, - ); - expect(mockService.remove).not.toHaveBeenCalled(); - }); - }); - - describe('with the correct secret', () => { - it('clusters delegates to the discovery service', async () => { - const clusters = [{ cluster_key: 'gemini|429' }]; - mockDiscovery.discover.mockResolvedValue(clusters); - const controller = makeController(SECRET); - - const result = await controller.clusters(SECRET); - - expect(result).toBe(clusters); - expect(mockDiscovery.discover).toHaveBeenCalledTimes(1); - }); - - it('upsert delegates to the pages service', async () => { - const response = { ok: true as const, slug: 'gemini-429' }; - mockService.upsert.mockResolvedValue(response); - const controller = makeController(SECRET); - - const result = await controller.upsert(SECRET, dto); - - expect(result).toBe(response); - expect(mockService.upsert).toHaveBeenCalledWith(dto); - }); - - it('remove delegates to the pages service', async () => { - const response = { ok: true as const, slug: 'gemini-429' }; - mockService.remove.mockResolvedValue(response); - const controller = makeController(SECRET); - - const result = await controller.remove(SECRET, 'gemini-429'); - - expect(result).toBe(response); - expect(mockService.remove).toHaveBeenCalledWith('gemini-429'); - }); - }); -}); diff --git a/packages/backend/src/error-pages/internal-error-pages.controller.ts b/packages/backend/src/error-pages/internal-error-pages.controller.ts deleted file mode 100644 index 51816c34..00000000 --- a/packages/backend/src/error-pages/internal-error-pages.controller.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - Headers, - Param, - Post, - UnauthorizedException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Public } from '../common/decorators/public.decorator'; -import { ErrorPagesService } from './error-pages.service'; -import { ErrorDiscoveryService } from './error-discovery.service'; -import { UpsertErrorPageDto } from './dto/upsert-error-page.dto'; - -/** - * Internal write API. The Peacock CMS pushes published pages here (and deletes - * on unpublish). Marked @Public() to skip the session/api-key guards, then - * gated by a shared secret in the `x-internal-secret` header — this is the only - * way a row reaches `public_error_pages`. Lives under /api/* so the SPA static - * fallback never shadows it. - */ -@Controller('api/v1/internal/error-pages') -export class InternalErrorPagesController { - constructor( - private readonly service: ErrorPagesService, - private readonly discovery: ErrorDiscoveryService, - private readonly config: ConfigService, - ) {} - - private assertSecret(provided: string | undefined): void { - const expected = this.config.get('app.errorPagePushSecret') ?? ''; - if (!expected || provided !== expected) { - throw new UnauthorizedException('Invalid or missing push secret'); - } - } - - // Live cross-tenant cluster rollup the Peacock CMS pulls from to discover and - // refresh clusters. Same secret as the push endpoints. - @Public() - @Get('clusters') - async clusters(@Headers('x-internal-secret') secret: string) { - this.assertSecret(secret); - return this.discovery.discover(); - } - - @Public() - @Post() - async upsert(@Headers('x-internal-secret') secret: string, @Body() dto: UpsertErrorPageDto) { - this.assertSecret(secret); - return this.service.upsert(dto); - } - - @Public() - @Delete(':slug') - async remove(@Headers('x-internal-secret') secret: string, @Param('slug') slug: string) { - this.assertSecret(secret); - return this.service.remove(slug); - } -} diff --git a/packages/backend/src/error-pages/public-error-pages.controller.spec.ts b/packages/backend/src/error-pages/public-error-pages.controller.spec.ts deleted file mode 100644 index 31e15824..00000000 --- a/packages/backend/src/error-pages/public-error-pages.controller.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import type { ConfigService } from '@nestjs/config'; -import { PublicErrorPagesController } from './public-error-pages.controller'; -import type { ErrorPagesService } from './error-pages.service'; -import type { PublicErrorPage } from '../entities/public-error-page.entity'; - -function makeConfig(enabled: boolean): ConfigService { - return { - get: jest.fn((key: string) => (key === 'app.publicErrorPagesEnabled' ? enabled : undefined)), - } as unknown as ConfigService; -} - -function makePage(overrides: Partial = {}): PublicErrorPage { - return { - slug: 'gemini-429-rate-limit', - cluster_key: 'gemini|429', - provider: 'gemini', - provider_label: 'Google Gemini', - http_status: 429, - category: 'rate_limit', - category_label: 'Rate limit', - title: 'Gemini 429', - meta_description: 'desc', - h1: 'Gemini rate limit', - body_what: 'what', - body_fix: 'fix', - body_manifest: 'manifest', - sample_message: 'sample', - faq: [], - stats: { - tenants: 42, - volume_7d: 100, - volume_30d: 400, - recovery_rate: 0.74, - last_seen: '2026-06-01T00:00:00.000Z', - trend: [], - }, - related: [], - noindex: false, - published_at: '2026-05-01T00:00:00.000Z', - updated_at: '2026-06-01T00:00:00.000Z', - ...overrides, - }; -} - -describe('PublicErrorPagesController', () => { - let mockService: { listPublished: jest.Mock; getBySlug: jest.Mock }; - - beforeEach(() => { - mockService = { - listPublished: jest.fn(), - getBySlug: jest.fn(), - }; - }); - - function makeController(enabled: boolean): PublicErrorPagesController { - return new PublicErrorPagesController( - mockService as unknown as ErrorPagesService, - makeConfig(enabled), - ); - } - - describe('when public stats are disabled', () => { - it('throws NotFoundException from list without calling the service', async () => { - const controller = makeController(false); - - await expect(controller.list()).rejects.toBeInstanceOf(NotFoundException); - expect(mockService.listPublished).not.toHaveBeenCalled(); - }); - - it('throws NotFoundException from detail without calling the service', async () => { - const controller = makeController(false); - - await expect(controller.detail('gemini-429')).rejects.toBeInstanceOf(NotFoundException); - expect(mockService.getBySlug).not.toHaveBeenCalled(); - }); - }); - - describe('list (enabled)', () => { - it('projects only the public summary fields plus cached_at', async () => { - mockService.listPublished.mockResolvedValue([makePage()]); - const controller = makeController(true); - - const result = await controller.list(); - - expect(mockService.listPublished).toHaveBeenCalledTimes(1); - expect(result.cached_at).toBeDefined(); - expect(typeof result.cached_at).toBe('string'); - expect(result.pages).toHaveLength(1); - - const page = result.pages[0]; - expect(page).toEqual({ - slug: 'gemini-429-rate-limit', - title: 'Gemini 429', - h1: 'Gemini rate limit', - meta_description: 'desc', - provider: 'gemini', - provider_label: 'Google Gemini', - http_status: 429, - category: 'rate_limit', - category_label: 'Rate limit', - stats: makePage().stats, - noindex: false, - published_at: '2026-05-01T00:00:00.000Z', - updated_at: '2026-06-01T00:00:00.000Z', - }); - }); - - it('does not leak internal/body fields in the projection', async () => { - mockService.listPublished.mockResolvedValue([makePage()]); - const controller = makeController(true); - - const page = (await controller.list()).pages[0] as Record; - - expect(page).not.toHaveProperty('cluster_key'); - expect(page).not.toHaveProperty('body_what'); - expect(page).not.toHaveProperty('body_fix'); - expect(page).not.toHaveProperty('body_manifest'); - expect(page).not.toHaveProperty('sample_message'); - expect(page).not.toHaveProperty('faq'); - expect(page).not.toHaveProperty('related'); - }); - - it('returns an empty pages array when nothing is published', async () => { - mockService.listPublished.mockResolvedValue([]); - const controller = makeController(true); - - const result = await controller.list(); - - expect(result.pages).toEqual([]); - }); - }); - - describe('detail (enabled)', () => { - it('returns the full page when found', async () => { - const page = makePage(); - mockService.getBySlug.mockResolvedValue(page); - const controller = makeController(true); - - const result = await controller.detail('gemini-429-rate-limit'); - - expect(result).toBe(page); - expect(mockService.getBySlug).toHaveBeenCalledWith('gemini-429-rate-limit'); - }); - - it('throws NotFoundException when the page is missing', async () => { - mockService.getBySlug.mockResolvedValue(null); - const controller = makeController(true); - - await expect(controller.detail('missing')).rejects.toBeInstanceOf(NotFoundException); - }); - }); -}); diff --git a/packages/backend/src/error-pages/public-error-pages.controller.ts b/packages/backend/src/error-pages/public-error-pages.controller.ts deleted file mode 100644 index 258f072b..00000000 --- a/packages/backend/src/error-pages/public-error-pages.controller.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Controller, Get, NotFoundException, Param } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Public } from '../common/decorators/public.decorator'; -import { ErrorPagesService } from './error-pages.service'; - -/** - * Public, unauthenticated read API consumed by the marketing site - * (manifest.build/errors/...). Gated by MANIFEST_PUBLIC_STATS, 404 (not 403) - * when disabled so probes can't distinguish "off" from "absent". - */ -@Controller('api/v1/public/error-pages') -export class PublicErrorPagesController { - constructor( - private readonly service: ErrorPagesService, - private readonly config: ConfigService, - ) {} - - private assertEnabled(): void { - if (!this.config.get('app.publicErrorPagesEnabled')) { - throw new NotFoundException(); - } - } - - @Public() - @Get() - async list() { - this.assertEnabled(); - const pages = await this.service.listPublished(); - return { - pages: pages.map((p) => ({ - slug: p.slug, - title: p.title, - h1: p.h1, - meta_description: p.meta_description, - provider: p.provider, - provider_label: p.provider_label, - http_status: p.http_status, - category: p.category, - category_label: p.category_label, - stats: p.stats, - noindex: p.noindex, - published_at: p.published_at, - updated_at: p.updated_at, - })), - cached_at: new Date().toISOString(), - }; - } - - @Public() - @Get(':slug') - async detail(@Param('slug') slug: string) { - this.assertEnabled(); - const page = await this.service.getBySlug(slug); - if (!page) throw new NotFoundException(); - return page; - } -} diff --git a/packages/backend/src/free-models/free-models-provider-metadata.ts b/packages/backend/src/free-models/free-models-provider-metadata.ts deleted file mode 100644 index de43f17f..00000000 --- a/packages/backend/src/free-models/free-models-provider-metadata.ts +++ /dev/null @@ -1,96 +0,0 @@ -export interface ProviderMetadata { - /** Path to self-hosted icon, e.g. '/icons/cohere.svg' */ - logo: string; - /** Override the display name from GitHub data.json */ - displayName?: string; - /** Bullet-point tags shown under the provider name */ - tags: string[]; - /** Yellow warning banner text */ - warning: string; -} - -/** - * UI-specific metadata keyed by the exact `name` from GitHub data.json. - * baseUrl and model IDs come from GitHub — this map only holds - * display/UX fields that don't belong in the shared data source. - */ -export const PROVIDER_METADATA: Readonly> = { - Cerebras: { - logo: '/icons/cerebras.svg', - tags: ['Fast inference', 'No credit card required'], - warning: '', - }, - 'Cloudflare Workers AI': { - logo: '/icons/workersai.svg', - tags: ['10,000 neurons/day free', 'No credit card required'], - warning: '', - }, - Cohere: { - logo: '/icons/cohere.svg', - tags: ['Up to 1,000 calls/month', 'No credit card required'], - warning: - 'Trial keys cannot be used for production or commercial workloads. Data may be used for training.', - }, - 'GitHub Models': { - logo: '/icons/github.svg', - tags: ['Rate-limited free tier', 'No credit card required'], - warning: '', - }, - 'Google Gemini': { - logo: '/icons/gemini.svg', - tags: ['250K TPM (Tokens / Minute) shared across models', 'No credit card required'], - warning: - 'Rate limits apply per Google Cloud project, not per API key. On the free tier, prompts and responses may be used to improve Google products.', - }, - Groq: { - logo: '/icons/groq.svg', - tags: ['Fast inference', 'No credit card required'], - warning: '', - }, - 'Hugging Face': { - logo: '/icons/huggingface.svg', - tags: ['Serverless Inference API', 'No credit card required'], - warning: '', - }, - 'Kilo Code': { - logo: '/icons/kilocode.svg', - tags: ['No credit card required'], - warning: 'Prompts and outputs are logged on free models to improve provider products.', - }, - 'LLM7.io': { - logo: '', - tags: ['No credit card required'], - warning: '', - }, - 'Mistral AI': { - logo: '/icons/providers/mistral.svg', - tags: ['No credit card required'], - warning: '', - }, - 'NVIDIA NIM': { - logo: '/icons/nvidia.svg', - tags: ['1,000 free credits', 'No credit card required'], - warning: '', - }, - 'Ollama Cloud': { - logo: '/icons/ollama.svg', - tags: ['No credit card required'], - warning: '', - }, - OpenRouter: { - logo: '/icons/openrouter.svg', - tags: ['Free tier models available', 'No credit card required'], - warning: '', - }, - SiliconFlow: { - logo: '/icons/siliconflow.svg', - tags: ['No credit card required'], - warning: '', - }, - 'Z AI (Zhipu AI)': { - logo: '/icons/zai.svg', - displayName: 'Z AI', - tags: ['No credit card required'], - warning: '', - }, -}; diff --git a/packages/backend/src/free-models/free-models-sync.service.spec.ts b/packages/backend/src/free-models/free-models-sync.service.spec.ts deleted file mode 100644 index f751ab76..00000000 --- a/packages/backend/src/free-models/free-models-sync.service.spec.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { FreeModelsSyncService } from './free-models-sync.service'; - -const GITHUB_RAW_URL = - 'https://raw.githubusercontent.com/mnfst/awesome-free-llm-apis/8b0feb0e3adda96455bcc380b815454944ff3832/data.json'; - -const sampleData = { - lastUpdated: '2026-04-17', - providers: [ - { - name: 'Cohere', - category: 'provider_api', - country: 'CA', - flag: '\u{1F1E8}\u{1F1E6}', - url: 'https://dashboard.cohere.com/api-keys', - baseUrl: 'https://api.cohere.ai/compatibility/v1', - description: 'Free trial API key.', - footnoteRef: null, - models: [ - { - id: 'command-a-03-2025', - name: 'Command A (111B)', - context: '256K', - maxOutput: '4K', - modality: 'Text', - rateLimit: '20 RPM', - }, - ], - }, - { - name: 'Google Gemini', - category: 'provider_api', - country: 'US', - flag: '\u{1F1FA}\u{1F1F8}', - url: 'https://aistudio.google.com/app/apikey', - baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/', - description: 'Free tier.', - footnoteRef: 1, - models: [ - { - id: 'gemini-2.5-flash', - name: 'Gemini 2.5 Flash', - context: '1M', - maxOutput: '65K', - modality: 'Text + Image', - rateLimit: '10 RPM', - }, - ], - }, - ], -}; - -let fetchSpy: jest.SpyInstance; - -describe('FreeModelsSyncService', () => { - let service: FreeModelsSyncService; - - beforeEach(() => { - service = new FreeModelsSyncService(); - fetchSpy = jest.spyOn(global, 'fetch'); - }); - - afterEach(() => { - fetchSpy.mockRestore(); - }); - - describe('onModuleInit', () => { - it('kicks off refreshCache without blocking and populates the cache', async () => { - const refresh = jest.spyOn(service, 'refreshCache'); - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => sampleData, - }); - - // Fire-and-forget (must not block boot — see #1894); await the kicked-off - // refresh deterministically via the spy's returned promise. - service.onModuleInit(); - await refresh.mock.results[0].value; - expect(fetchSpy).toHaveBeenCalledWith( - GITHUB_RAW_URL, - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - expect(service.getAll()).toHaveLength(2); - }); - - it('does not leave an unhandled rejection when refreshCache rejects', async () => { - const refresh = jest - .spyOn(service, 'refreshCache') - .mockRejectedValue(new Error('Unexpected')); - service.onModuleInit(); - // onModuleInit attaches a .catch that swallows the failure. - await expect(refresh.mock.results[0].value).rejects.toThrow('Unexpected'); - }); - }); - - describe('refreshCache', () => { - it('populates cache with successful response', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => sampleData, - }); - - const count = await service.refreshCache(); - expect(count).toBe(2); - expect(service.getAll()).toHaveLength(2); - expect(service.getAll()[0].name).toBe('Cohere'); - expect(service.getAll()[1].name).toBe('Google Gemini'); - expect(service.getLastFetchedAt()).toBeInstanceOf(Date); - }); - - it('returns 0 when API returns non-OK status', async () => { - fetchSpy.mockResolvedValue({ ok: false, status: 503 }); - - const count = await service.refreshCache(); - expect(count).toBe(0); - expect(service.getAll()).toHaveLength(0); - }); - - it('returns 0 when fetch throws', async () => { - fetchSpy.mockRejectedValue(new Error('Network error')); - - const count = await service.refreshCache(); - expect(count).toBe(0); - expect(service.getAll()).toHaveLength(0); - }); - - it('returns 0 when providers array is missing', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ lastUpdated: '2026-04-17' }), - }); - - const count = await service.refreshCache(); - expect(count).toBe(0); - }); - - it('keeps stale cache when fetch fails after previous success', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => sampleData, - }); - await service.refreshCache(); - expect(service.getAll()).toHaveLength(2); - - fetchSpy.mockRejectedValue(new Error('Network error')); - const count = await service.refreshCache(); - expect(count).toBe(0); - // Stale cache preserved - expect(service.getAll()).toHaveLength(2); - }); - - it('drops entries whose url is not https', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - lastUpdated: '2026-04-17', - providers: [ - { ...sampleData.providers[0], url: 'http://insecure.example/keys' }, - sampleData.providers[1], - ], - }), - }); - const count = await service.refreshCache(); - expect(count).toBe(1); - expect(service.getAll()[0].name).toBe('Google Gemini'); - }); - - it('drops entries whose url string cannot be parsed as a URL', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - lastUpdated: '2026-04-17', - providers: [{ ...sampleData.providers[0], url: ':::not a url' }, sampleData.providers[1]], - }), - } as never); - const count = await service.refreshCache(); - expect(count).toBe(1); - expect(service.getAll()[0].name).toBe('Google Gemini'); - }); - - it('drops entries whose baseUrl is not https or null', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - lastUpdated: '2026-04-17', - providers: [ - { ...sampleData.providers[0], baseUrl: 'http://insecure.example/v1' }, - sampleData.providers[1], - ], - }), - }); - const count = await service.refreshCache(); - expect(count).toBe(1); - }); - - it('keeps entries whose baseUrl is null', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - lastUpdated: '2026-04-17', - providers: [{ ...sampleData.providers[0], baseUrl: null }], - }), - }); - const count = await service.refreshCache(); - expect(count).toBe(1); - }); - - it('drops entries with non-string name', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - lastUpdated: '2026-04-17', - providers: [{ ...sampleData.providers[0], name: 123 }, sampleData.providers[1]], - }), - }); - const count = await service.refreshCache(); - expect(count).toBe(1); - expect(service.getAll()[0].name).toBe('Google Gemini'); - }); - - it('drops entries whose models array contains non-objects', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - lastUpdated: '2026-04-17', - providers: [ - { ...sampleData.providers[0], models: ['not-an-object'] }, - sampleData.providers[1], - ], - }), - }); - const count = await service.refreshCache(); - expect(count).toBe(1); - }); - - it('drops null providers without throwing', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - lastUpdated: '2026-04-17', - providers: [null, sampleData.providers[1]], - }), - }); - const count = await service.refreshCache(); - expect(count).toBe(1); - }); - - it('replaces entire cache on successful refresh', async () => { - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => sampleData, - }); - await service.refreshCache(); - expect(service.getAll()).toHaveLength(2); - - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - lastUpdated: '2026-04-18', - providers: [sampleData.providers[0]], - }), - }); - await service.refreshCache(); - expect(service.getAll()).toHaveLength(1); - }); - }); - - describe('getAll', () => { - it('returns empty array initially', () => { - expect(service.getAll()).toEqual([]); - }); - }); - - describe('getLastFetchedAt', () => { - it('returns null before any refresh', () => { - expect(service.getLastFetchedAt()).toBeNull(); - }); - - it('does not update when fetch fails', async () => { - fetchSpy.mockRejectedValue(new Error('fail')); - await service.refreshCache(); - expect(service.getLastFetchedAt()).toBeNull(); - }); - - it('does not update when API returns non-OK', async () => { - fetchSpy.mockResolvedValue({ ok: false, status: 500 }); - await service.refreshCache(); - expect(service.getLastFetchedAt()).toBeNull(); - }); - }); -}); diff --git a/packages/backend/src/free-models/free-models-sync.service.ts b/packages/backend/src/free-models/free-models-sync.service.ts deleted file mode 100644 index 4b5a1486..00000000 --- a/packages/backend/src/free-models/free-models-sync.service.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; - -export interface GitHubModel { - id: string | null; - name: string; - context: string; - maxOutput: string; - modality: string; - rateLimit: string; -} - -export interface GitHubProvider { - name: string; - category: string; - country: string; - flag: string; - url: string; - baseUrl: string | null; - description: string; - footnoteRef: number | null; - models: GitHubModel[]; -} - -interface GitHubDataJson { - lastUpdated: string; - providers: GitHubProvider[]; -} - -// Pinned to an immutable commit SHA so a compromise of the awesome-free-llm-apis -// repo (or its `main` branch) cannot deliver a poisoned data.json that the -// frontend would render. Bump this SHA when refreshing the upstream data. -const GITHUB_RAW_REF = '8b0feb0e3adda96455bcc380b815454944ff3832'; -const GITHUB_RAW_URL = `https://raw.githubusercontent.com/mnfst/awesome-free-llm-apis/${GITHUB_RAW_REF}/data.json`; -const FETCH_TIMEOUT_MS = 10000; - -function isHttpsUrl(value: unknown): value is string { - if (typeof value !== 'string') return false; - try { - return new URL(value).protocol === 'https:'; - } catch { - return false; - } -} - -function isOptionalHttpsUrl(value: unknown): value is string | null { - return value === null || value === undefined || isHttpsUrl(value); -} - -function isValidProvider(p: unknown): p is GitHubProvider { - if (!p || typeof p !== 'object') return false; - const v = p as Record; - if (typeof v.name !== 'string' || v.name.length === 0) return false; - if (typeof v.category !== 'string') return false; - if (typeof v.country !== 'string') return false; - if (typeof v.flag !== 'string') return false; - if (!isHttpsUrl(v.url)) return false; - if (!isOptionalHttpsUrl(v.baseUrl)) return false; - if (typeof v.description !== 'string') return false; - if (!Array.isArray(v.models)) return false; - return v.models.every( - (m) => m && typeof m === 'object' && typeof (m as { name?: unknown }).name === 'string', - ); -} - -@Injectable() -export class FreeModelsSyncService implements OnModuleInit { - private readonly logger = new Logger(FreeModelsSyncService.name); - private cache: GitHubProvider[] = []; - private lastFetchedAt: Date | null = null; - - onModuleInit(): void { - // Fire-and-forget so a slow GitHub fetch can't delay app.listen() and trip - // Railway's healthcheck (see #1894). Nothing reads this cache during boot; - // the free-models endpoints tolerate an empty cache until the fetch lands. - void this.refreshCache().catch((err) => { - this.logger.error(`Startup free models sync failed: ${err}`); - }); - } - - @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) - async refreshCache(): Promise { - this.logger.log('Refreshing free models cache from GitHub...'); - const data = await this.fetchData(); - if (!data) return 0; - - const validated = data.providers.filter((p) => { - if (!isValidProvider(p)) { - this.logger.warn( - `Dropping free-models provider with invalid shape: ${ - (p as { name?: unknown })?.name ?? '' - }`, - ); - return false; - } - return true; - }); - - this.cache = validated; - this.lastFetchedAt = new Date(); - this.logger.log(`Free models cache loaded: ${validated.length} providers`); - return validated.length; - } - - getAll(): readonly GitHubProvider[] { - return this.cache; - } - - getLastFetchedAt(): Date | null { - return this.lastFetchedAt; - } - - private async fetchData(): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - try { - const res = await fetch(GITHUB_RAW_URL, { signal: controller.signal }); - if (!res.ok) { - this.logger.error(`GitHub raw returned ${res.status}`); - return null; - } - const body = (await res.json()) as GitHubDataJson; - if (!Array.isArray(body.providers)) { - this.logger.error('GitHub data.json missing providers array'); - return null; - } - return body; - } catch (err) { - this.logger.error(`Failed to fetch free models data: ${err}`); - return null; - } finally { - clearTimeout(timeout); - } - } -} diff --git a/packages/backend/src/free-models/free-models.controller.spec.ts b/packages/backend/src/free-models/free-models.controller.spec.ts deleted file mode 100644 index c47f1e67..00000000 --- a/packages/backend/src/free-models/free-models.controller.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { FreeModelsController } from './free-models.controller'; -import { FreeModelsService, FreeModelsResponse } from './free-models.service'; - -describe('FreeModelsController', () => { - let controller: FreeModelsController; - let service: FreeModelsService; - - const mockResponse: FreeModelsResponse = { - providers: [ - { - name: 'Cohere', - logo: '/icons/cohere.svg', - description: 'Free trial.', - tags: ['No credit card required'], - api_key_url: 'https://dashboard.cohere.com/api-keys', - base_url: 'https://api.cohere.ai/compatibility/v1', - warning: 'Trial keys only.', - country: 'CA', - flag: '\u{1F1E8}\u{1F1E6}', - models: [ - { - id: 'command-a-03-2025', - name: 'Command A (111B)', - context: '256K', - max_output: '4K', - modality: 'Text', - rate_limit: '20 RPM', - }, - ], - }, - ], - last_synced_at: '2026-04-17T00:00:00.000Z', - }; - - beforeEach(() => { - service = { getAll: jest.fn().mockReturnValue(mockResponse) } as unknown as FreeModelsService; - controller = new FreeModelsController(service); - }); - - describe('getFreeModels', () => { - it('delegates to service.getAll()', () => { - const result = controller.getFreeModels(); - expect(service.getAll).toHaveBeenCalled(); - expect(result).toEqual(mockResponse); - }); - }); -}); diff --git a/packages/backend/src/free-models/free-models.controller.ts b/packages/backend/src/free-models/free-models.controller.ts deleted file mode 100644 index 440dbe32..00000000 --- a/packages/backend/src/free-models/free-models.controller.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Controller, Get, UseInterceptors } from '@nestjs/common'; -import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager'; -import { FreeModelsService } from './free-models.service'; -import { FREE_MODELS_CACHE_TTL_MS } from '../common/constants/cache.constants'; - -@Controller('api/v1') -export class FreeModelsController { - constructor(private readonly freeModelsService: FreeModelsService) {} - - @Get('free-models') - @UseInterceptors(CacheInterceptor) - @CacheTTL(FREE_MODELS_CACHE_TTL_MS) - getFreeModels() { - return this.freeModelsService.getAll(); - } -} diff --git a/packages/backend/src/free-models/free-models.module.ts b/packages/backend/src/free-models/free-models.module.ts deleted file mode 100644 index 701c1860..00000000 --- a/packages/backend/src/free-models/free-models.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { FreeModelsController } from './free-models.controller'; -import { FreeModelsService } from './free-models.service'; -import { FreeModelsSyncService } from './free-models-sync.service'; - -@Module({ - controllers: [FreeModelsController], - providers: [FreeModelsService, FreeModelsSyncService], - exports: [FreeModelsSyncService, FreeModelsService], -}) -export class FreeModelsModule {} diff --git a/packages/backend/src/free-models/free-models.service.spec.ts b/packages/backend/src/free-models/free-models.service.spec.ts deleted file mode 100644 index 3174b13b..00000000 --- a/packages/backend/src/free-models/free-models.service.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { FreeModelsService } from './free-models.service'; -import { FreeModelsSyncService, GitHubProvider } from './free-models-sync.service'; - -function makeSyncService( - providers: GitHubProvider[] = [], - lastFetchedAt: Date | null = null, -): FreeModelsSyncService { - return { - getAll: () => providers, - getLastFetchedAt: () => lastFetchedAt, - } as unknown as FreeModelsSyncService; -} - -const cohereProvider: GitHubProvider = { - name: 'Cohere', - category: 'provider_api', - country: 'CA', - flag: '\u{1F1E8}\u{1F1E6}', - url: 'https://dashboard.cohere.com/api-keys', - baseUrl: 'https://api.cohere.ai/compatibility/v1', - description: 'Free trial API key.', - footnoteRef: null, - models: [ - { - id: 'command-a-03-2025', - name: 'Command A (111B)', - context: '256K', - maxOutput: '4K', - modality: 'Text', - rateLimit: '20 RPM', - }, - ], -}; - -const unknownProvider: GitHubProvider = { - name: 'NewProvider', - category: 'inference_provider', - country: 'DE', - flag: '\u{1F1E9}\u{1F1EA}', - url: 'https://newprovider.com/keys', - baseUrl: 'https://api.newprovider.com/v1', - description: 'A new provider.', - footnoteRef: null, - models: [ - { - id: 'model-x', - name: 'Model X', - context: '128K', - maxOutput: '32K', - modality: 'Text', - rateLimit: '30 RPM', - }, - ], -}; - -const providerWithNullBaseUrl: GitHubProvider = { - name: 'Ollama Cloud', - category: 'inference_provider', - country: 'US', - flag: '\u{1F1FA}\u{1F1F8}', - url: 'https://ollama.com/settings/keys', - baseUrl: null, - description: 'Free tier.', - footnoteRef: null, - models: [ - { - id: 'llama3.1:cloud', - name: 'llama3.1', - context: '128K', - maxOutput: '8K', - modality: 'Text', - rateLimit: '30 RPM', - }, - ], -}; - -const providerWithNullModelId: GitHubProvider = { - name: 'Cohere', - category: 'provider_api', - country: 'CA', - flag: '\u{1F1E8}\u{1F1E6}', - url: 'https://dashboard.cohere.com/api-keys', - baseUrl: 'https://api.cohere.ai/compatibility/v1', - description: 'Free trial.', - footnoteRef: null, - models: [ - { - id: null, - name: '+ 5 more models', - context: 'Varies', - maxOutput: 'Varies', - modality: 'Text', - rateLimit: 'Varies', - }, - ], -}; - -describe('FreeModelsService', () => { - describe('getAll', () => { - it('returns empty providers when sync has no data', () => { - const service = new FreeModelsService(makeSyncService()); - const result = service.getAll(); - expect(result.providers).toEqual([]); - expect(result.last_synced_at).toBeNull(); - }); - - it('transforms known provider with metadata', () => { - const now = new Date('2026-04-17T00:00:00Z'); - const service = new FreeModelsService(makeSyncService([cohereProvider], now)); - const result = service.getAll(); - - expect(result.providers).toHaveLength(1); - expect(result.last_synced_at).toBe(now.toISOString()); - - const p = result.providers[0]; - expect(p.name).toBe('Cohere'); - expect(p.logo).toBe('/icons/cohere.svg'); - expect(p.description).toBe('Free trial API key.'); - expect(p.tags).toEqual(['Up to 1,000 calls/month', 'No credit card required']); - expect(p.api_key_url).toBe('https://dashboard.cohere.com/api-keys'); - expect(p.base_url).toBe('https://api.cohere.ai/compatibility/v1'); - expect(p.warning).toContain('Trial keys cannot be used'); - expect(p.country).toBe('CA'); - expect(p.flag).toBe('\u{1F1E8}\u{1F1E6}'); - - expect(p.models).toHaveLength(1); - expect(p.models[0]).toEqual({ - id: 'command-a-03-2025', - name: 'Command A (111B)', - context: '256K', - max_output: '4K', - modality: 'Text', - rate_limit: '20 RPM', - }); - }); - - it('handles unknown provider without metadata', () => { - const service = new FreeModelsService(makeSyncService([unknownProvider])); - const result = service.getAll(); - - const p = result.providers[0]; - expect(p.name).toBe('NewProvider'); - expect(p.logo).toBeNull(); - expect(p.tags).toEqual([]); - expect(p.warning).toBeNull(); - expect(p.base_url).toBe('https://api.newprovider.com/v1'); - }); - - it('handles provider with null baseUrl', () => { - const service = new FreeModelsService(makeSyncService([providerWithNullBaseUrl])); - const result = service.getAll(); - - expect(result.providers[0].base_url).toBeNull(); - }); - - it('handles models with null id', () => { - const service = new FreeModelsService(makeSyncService([providerWithNullModelId])); - const result = service.getAll(); - - expect(result.providers[0].models[0].id).toBeNull(); - expect(result.providers[0].models[0].name).toBe('+ 5 more models'); - }); - - it('transforms multiple providers', () => { - const service = new FreeModelsService( - makeSyncService([cohereProvider, unknownProvider, providerWithNullBaseUrl]), - ); - const result = service.getAll(); - expect(result.providers).toHaveLength(3); - }); - }); -}); diff --git a/packages/backend/src/free-models/free-models.service.ts b/packages/backend/src/free-models/free-models.service.ts deleted file mode 100644 index 89322520..00000000 --- a/packages/backend/src/free-models/free-models.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { FreeModelsSyncService } from './free-models-sync.service'; -import { PROVIDER_METADATA } from './free-models-provider-metadata'; - -export interface FreeModelDto { - id: string | null; - name: string; - context: string; - max_output: string; - modality: string; - rate_limit: string; -} - -export interface FreeProviderDto { - name: string; - logo: string | null; - description: string; - tags: string[]; - api_key_url: string; - base_url: string | null; - warning: string | null; - country: string; - flag: string; - models: FreeModelDto[]; -} - -export interface FreeModelsResponse { - providers: FreeProviderDto[]; - last_synced_at: string | null; -} - -@Injectable() -export class FreeModelsService { - constructor(private readonly syncService: FreeModelsSyncService) {} - - getAll(): FreeModelsResponse { - const githubProviders = this.syncService.getAll(); - const lastSyncedAt = this.syncService.getLastFetchedAt()?.toISOString() ?? null; - - const providers = githubProviders.map((gp) => { - const meta = PROVIDER_METADATA[gp.name]; - return { - name: meta?.displayName ?? gp.name, - logo: meta?.logo ?? null, - description: gp.description, - tags: meta?.tags ?? [], - api_key_url: gp.url, - base_url: gp.baseUrl, - warning: meta?.warning ?? null, - country: gp.country, - flag: gp.flag, - models: gp.models.map((m) => ({ - id: m.id ?? null, - name: m.name, - context: m.context, - max_output: m.maxOutput, - modality: m.modality, - rate_limit: m.rateLimit, - })), - }; - }); - - return { providers, last_synced_at: lastSyncedAt }; - } -} diff --git a/packages/backend/src/github/github.controller.spec.ts b/packages/backend/src/github/github.controller.spec.ts deleted file mode 100644 index 9b45d9b1..00000000 --- a/packages/backend/src/github/github.controller.spec.ts +++ /dev/null @@ -1,229 +0,0 @@ -const fetchMock = jest.fn(); -(global as unknown as Record).fetch = fetchMock; - -describe('GithubController', () => { - let controller: InstanceType; - // Capture the real Date.now once at module load so individual tests can - // overwrite Date.now safely. afterEach restores it unconditionally so a - // failing test cannot leak a fake Date.now into sibling tests. - const realDateNow = Date.now; - - /** Re-imports the module with fresh module-level cache variables. */ - async function freshImport() { - const mod = await import('./github.controller'); - return new mod.GithubController(); - } - - beforeEach(async () => { - jest.resetModules(); - fetchMock.mockReset(); - controller = await freshImport(); - }); - - afterEach(() => { - // Restore Date.now even when a test throws before its inline restore. - Date.now = realDateNow; - }); - - // ── Happy path ──────────────────────────────────────────────── - - it('should fetch stars from GitHub API on first call', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ stargazers_count: 42 }), - }); - - const result = await controller.getStars(); - - expect(result).toEqual({ stars: 42 }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith('https://api.github.com/repos/mnfst/llm-gateway', { - headers: { Accept: 'application/vnd.github.v3+json' }, - }); - }); - - it('should return cached stars within TTL without fetching', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ stargazers_count: 100 }), - }); - - // First call populates the cache - await controller.getStars(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // Second call should use the cache - const result = await controller.getStars(); - - expect(result).toEqual({ stars: 100 }); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it('should re-fetch after cache TTL expires', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ stargazers_count: 50 }), - }); - - await controller.getStars(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // Advance time past the 10-minute TTL. afterEach restores Date.now. - Date.now = () => realDateNow() + 11 * 60 * 1000; - - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ stargazers_count: 75 }), - }); - - const result = await controller.getStars(); - - expect(result).toEqual({ stars: 75 }); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - // ── Error handling ──────────────────────────────────────────── - - it('should return null when API fails on first call', async () => { - fetchMock.mockResolvedValue({ ok: false, status: 500 }); - - const result = await controller.getStars(); - - expect(result).toEqual({ stars: null }); - }); - - it('should return stale cache when API fails after previous success', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ stargazers_count: 200 }), - }); - await controller.getStars(); - - // Expire the cache. afterEach restores Date.now. - Date.now = () => realDateNow() + 11 * 60 * 1000; - - fetchMock.mockResolvedValue({ ok: false, status: 503 }); - - const result = await controller.getStars(); - - expect(result).toEqual({ stars: 200 }); - }); - - it('should return null when fetch throws on first call', async () => { - fetchMock.mockRejectedValue(new Error('network error')); - - const result = await controller.getStars(); - - expect(result).toEqual({ stars: null }); - }); - - it('should return stale cache when fetch throws after previous success', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ stargazers_count: 300 }), - }); - await controller.getStars(); - - // Expire the cache. afterEach restores Date.now. - Date.now = () => realDateNow() + 11 * 60 * 1000; - - fetchMock.mockRejectedValue(new Error('timeout')); - - const result = await controller.getStars(); - - expect(result).toEqual({ stars: 300 }); - }); - - // ── Edge cases ──────────────────────────────────────────────── - - it('should not update cache when stargazers_count is missing', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ some_other_field: 'abc' }), - }); - - const result = await controller.getStars(); - - expect(result).toEqual({ stars: null }); - }); - - it('should not update cache when stargazers_count is not a number', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ stargazers_count: 'not-a-number' }), - }); - - const result = await controller.getStars(); - - expect(result).toEqual({ stars: null }); - }); - - it('should update cache when stargazers_count is zero', async () => { - fetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ stargazers_count: 0 }), - }); - - const result = await controller.getStars(); - - expect(result).toEqual({ stars: 0 }); - }); - - // ── Concurrency ─────────────────────────────────────────────── - - it('should deduplicate concurrent requests during cache expiry', async () => { - // Step 1: prime the cache with a known value so we can exercise the - // "expired cache + many concurrent callers" code path. - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ stargazers_count: 100 }), - }); - await controller.getStars(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // Step 2: expire the cache by advancing Date.now past the 10-minute TTL. - Date.now = () => realDateNow() + 11 * 60 * 1000; - - // Step 3: fire 10 concurrent calls while a single fetch is in flight. - // The fetch resolution is gated by a manual trigger so all 10 callers - // observe the in-flight request before it completes. - let triggerFetchResolve: (value: { - ok: boolean; - json: () => Promise<{ stargazers_count: number }>; - }) => void = () => undefined; - const pendingFetch = new Promise<{ - ok: boolean; - json: () => Promise<{ stargazers_count: number }>; - }>((resolve) => { - triggerFetchResolve = resolve; - }); - fetchMock.mockReturnValue(pendingFetch); - - const concurrentCalls = Array.from({ length: 10 }, () => controller.getStars()); - - // Yield to the microtask queue so each call has a chance to enter the - // controller and either share or trigger its own fetch. - await Promise.resolve(); - - // Documents the current behavior: the module-level cache has NO in-flight - // request coalescing, so every concurrent caller past TTL triggers its - // own fetch (thundering herd). This test pins the existing semantics so a - // future deduplication patch shows up as an intentional change here. - // Total includes the 1 priming call + 10 concurrent calls = 11. - expect(fetchMock).toHaveBeenCalledTimes(11); - - // Step 4: release the gated fetch and verify all 10 concurrent callers - // resolve to the same, consistent star count. - triggerFetchResolve({ - ok: true, - json: async () => ({ stargazers_count: 250 }), - }); - - const results = await Promise.all(concurrentCalls); - - expect(results).toHaveLength(10); - for (const result of results) { - expect(result).toEqual({ stars: 250 }); - } - }); -}); diff --git a/packages/backend/src/github/github.controller.ts b/packages/backend/src/github/github.controller.ts deleted file mode 100644 index fd6729ce..00000000 --- a/packages/backend/src/github/github.controller.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { Public } from '../common/decorators/public.decorator'; - -const GITHUB_REPO = 'mnfst/llm-gateway'; -const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes - -let cachedStars: number | null = null; -let cachedAt = 0; - -@Controller('api/v1') -export class GithubController { - @Public() - @Get('github/stars') - async getStars(): Promise<{ stars: number | null }> { - if (cachedStars !== null && Date.now() - cachedAt < CACHE_TTL_MS) { - return { stars: cachedStars }; - } - - try { - const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}`, { - headers: { Accept: 'application/vnd.github.v3+json' }, - }); - if (!res.ok) { - return { stars: cachedStars }; - } - const data = (await res.json()) as { stargazers_count?: number }; - if (typeof data.stargazers_count === 'number') { - cachedStars = data.stargazers_count; - cachedAt = Date.now(); - } - } catch { - // Return stale cache or null on failure - } - - return { stars: cachedStars }; - } -} diff --git a/packages/backend/src/github/github.module.ts b/packages/backend/src/github/github.module.ts deleted file mode 100644 index 97cb23d5..00000000 --- a/packages/backend/src/github/github.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Module } from '@nestjs/common'; -import { GithubController } from './github.controller'; - -@Module({ - controllers: [GithubController], -}) -export class GithubModule {} diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index f90df172..0180e7fa 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -18,7 +18,6 @@ import { createProxyBodyBudgetMiddleware, } from './common/middleware/body-parser-limits'; import { - applyPivotClaimCors, applyPrivateNetworkAllow, buildCorsOptions, buildDevAllowedOrigins, @@ -111,18 +110,6 @@ export async function bootstrap() { applyPrivateNetworkAllow(req, corsAllowedOrigins, (name, value) => res.setHeader(name, value)); next(); }); - // The pivot waiting-list claim is posted from self-hosted dashboards in the - // browser, so this one route answers CORS for any origin. Registered before - // the allow-list cors middleware so its preflight wins; see - // `applyPivotClaimCors` for why this is safe. - app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { - const handled = applyPivotClaimCors(req, (name, value) => res.setHeader(name, value)); - if (handled) { - res.sendStatus(204); - return; - } - next(); - }); app.enableCors(buildCorsOptions(corsAllowedOrigins)); app.useGlobalPipes( diff --git a/packages/backend/src/model-discovery/filter-non-chat-models.spec.ts b/packages/backend/src/model-discovery/filter-non-chat-models.spec.ts index a8dce4ba..928dfd66 100644 --- a/packages/backend/src/model-discovery/filter-non-chat-models.spec.ts +++ b/packages/backend/src/model-discovery/filter-non-chat-models.spec.ts @@ -226,31 +226,6 @@ describe('filterNonChatModels', () => { }); }); - describe('Gemini Free LiteLLM catalog', () => { - it('keeps usable Gemini chat models', () => { - const models = [ - makeModel('gemini/gemini-2.5-flash'), - makeModel('gemini/gemini-2.5-pro'), - makeModel('gemini/gemini-3-flash-preview'), - ]; - expect(filterNonChatModels(models, 'gemini-free')).toEqual(models); - }); - - it('filters media-only, retired, and experimental Gemini models', () => { - const models = [ - makeModel('gemini/gemini-2.5-flash'), - makeModel('gemini/gemini-2.5-flash-image'), - makeModel('gemini/gemini-2.5-flash-native-audio-latest'), - makeModel('gemini/gemini-1.5-flash'), - makeModel('gemini/gemini-exp-1206'), - makeModel('gemini/veo-3.1-generate-001'), - ]; - expect(filterNonChatModels(models, 'gemini-free').map((model) => model.id)).toEqual([ - 'gemini/gemini-2.5-flash', - ]); - }); - }); - describe('Mistral-specific patterns', () => { it('filters mistral-ocr model', () => { const models = [makeModel('mistral-ocr'), makeModel('mistral-large-latest')]; diff --git a/packages/backend/src/model-discovery/provider-model-fetcher.service.spec.ts b/packages/backend/src/model-discovery/provider-model-fetcher.service.spec.ts index be9809f6..ae4cb8c2 100644 --- a/packages/backend/src/model-discovery/provider-model-fetcher.service.spec.ts +++ b/packages/backend/src/model-discovery/provider-model-fetcher.service.spec.ts @@ -47,7 +47,6 @@ describe('ProviderModelFetcherService', () => { 'anthropic', 'gemini', 'openrouter', - 'gemini-free', 'ollama', 'ollama-cloud', 'copilot', @@ -58,35 +57,13 @@ describe('ProviderModelFetcherService', () => { } }); - it('discovers only Gemini models from the Gemini Free LiteLLM catalog', async () => { - process.env['CREDITS_BASE_URL'] = 'https://credits.test'; - fetchSpy.mockResolvedValue({ - ok: true, - json: async () => ({ - data: [ - { id: 'gpt-5' }, - { id: 'gemini-2.5-flash' }, - { id: 'gemini/gemini-2.5-flash' }, - { id: 'gemini/gemini-2.5-pro' }, - { id: 'gemini/*' }, - ], - }), - }); + it('has no fetcher for the removed managed Gemini Free provider', async () => { + expect(PROVIDER_CONFIGS['gemini-free']).toBeUndefined(); const result = await service.fetch('gemini-free', 'sk-virtual'); - expect(fetchSpy).toHaveBeenCalledWith( - 'https://credits.test/v1/models', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer sk-virtual', - }), - }), - ); - expect(result.map((model) => model.id)).toEqual([ - 'gemini/gemini-2.5-flash', - 'gemini/gemini-2.5-pro', - ]); + expect(result).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); }); describe('huggingface provider', () => { diff --git a/packages/backend/src/model-discovery/provider-model-fetcher.service.ts b/packages/backend/src/model-discovery/provider-model-fetcher.service.ts index 9c2deca3..5b1a33a6 100644 --- a/packages/backend/src/model-discovery/provider-model-fetcher.service.ts +++ b/packages/backend/src/model-discovery/provider-model-fetcher.service.ts @@ -1,11 +1,6 @@ import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; import { DiscoveredModel, FetcherConfig, DEFAULT_CONTEXT_WINDOW } from './model-fetcher'; import { parseModalities } from './model-capabilities'; -import { - getManagedFreeLiteLlmModelsUrl, - MANAGED_FREE_PROVIDER_CONFIGS, - ManagedFreeProviderConfig, -} from '../common/constants/managed-free-providers'; import { OLLAMA_CLOUD_HOST, OLLAMA_HOST } from '../common/constants/ollama'; import { CODEX_CLI_ORIGINATOR, @@ -223,31 +218,6 @@ const parseBedrockCrisProfiles = createModelParser supportedEndpoints: (entry) => getBedrockRuntimeSupportedEndpoints(entry.inferenceProfileId), }); -/** Keep only the configured model family and prefer LiteLLM's vendor-prefixed ID. */ -function parseManagedFreeLiteLlm( - body: unknown, - provider: string, - config: ManagedFreeProviderConfig, -): DiscoveredModel[] { - const models = parseOpenAI(body, provider).filter((model) => { - const bare = model.id.slice(model.id.lastIndexOf('/') + 1); - return !model.id.includes('*') && bare.startsWith(config.catalogModelIdPrefix); - }); - const byBareId = new Map(); - for (const model of models) { - const bare = model.id.slice(model.id.lastIndexOf('/') + 1); - const existing = byBareId.get(bare); - if ( - !existing || - (model.id.startsWith(config.preferredModelIdPrefix) && - !existing.id.startsWith(config.preferredModelIdPrefix)) - ) { - byBareId.set(bare, model); - } - } - return Array.from(byBareId.values()); -} - const parsePioneer = createModelParser({ arrayKey: 'data', filter: (entry) => typeof entry.id === 'string' && entry.id.length > 0, @@ -460,9 +430,6 @@ export const PROVIDER_NON_CHAT: Record = { // and Veo under their own names. vertex: /(?:^aqs-|nano-banana|^deep-research|computer-use|^lyria|^imagen|^veo|robotics|flash-lite-preview-\d{2}-\d{4}$)/i, - ...Object.fromEntries( - MANAGED_FREE_PROVIDER_CONFIGS.map((config) => [config.id, config.nonChatModelPattern]), - ), mistral: /(?:^mistral-ocr|moderation|voxtral-.*-(?:transcribe|realtime)|^labs-|^mistral-vibe-cli)/i, 'mistral-subscription': /(?:^mistral-ocr|moderation|voxtral-.*-(?:transcribe|realtime)|^labs-)/i, @@ -896,17 +863,6 @@ const parseOpencodeZen = createModelParser({ /* ── Provider configs ── */ -const MANAGED_FREE_FETCHER_CONFIGS: Record = Object.fromEntries( - MANAGED_FREE_PROVIDER_CONFIGS.map((config) => [ - config.id, - { - endpoint: (_key: string) => getManagedFreeLiteLlmModelsUrl(), - buildHeaders: bearerHeaders, - parse: (body: unknown, provider: string) => parseManagedFreeLiteLlm(body, provider, config), - }, - ]), -); - export const PROVIDER_CONFIGS: Record = { openai: { endpoint: 'https://api.openai.com/v1/models', @@ -1077,7 +1033,6 @@ export const PROVIDER_CONFIGS: Record = { buildHeaders: () => ({}), parse: parseOpenRouter, }, - ...MANAGED_FREE_FETCHER_CONFIGS, ollama: { endpoint: `${OLLAMA_HOST}/api/tags`, buildHeaders: () => ({}), diff --git a/packages/backend/src/routing/managed-free-provider/managed-free-provider.controller.spec.ts b/packages/backend/src/routing/managed-free-provider/managed-free-provider.controller.spec.ts deleted file mode 100644 index d81ea729..00000000 --- a/packages/backend/src/routing/managed-free-provider/managed-free-provider.controller.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { ManagedFreeProviderController } from './managed-free-provider.controller'; - -describe('ManagedFreeProviderController', () => { - const managedFreeProvider = { - ensureConnection: jest.fn(), - }; - const tenantCache = { - ensureForUser: jest.fn(), - }; - let controller: ManagedFreeProviderController; - - beforeEach(() => { - jest.resetAllMocks(); - controller = new ManagedFreeProviderController( - managedFreeProvider as never, - tenantCache as never, - ); - }); - - it('uses the authenticated tenant context', async () => { - const expected = { - connected: true, - connection_id: 'conn-1', - source: 'existing', - auto_available: true, - }; - managedFreeProvider.ensureConnection.mockResolvedValue(expected); - - await expect( - controller.ensure( - 'gemini-free', - { tenantId: 'tenant-1', userId: 'user-1' }, - { id: 'user-1', email: 'user@example.com' }, - { apiKey: 'sk-virtual' }, - ), - ).resolves.toBe(expected); - expect(tenantCache.ensureForUser).not.toHaveBeenCalled(); - expect(managedFreeProvider.ensureConnection).toHaveBeenCalledWith('gemini-free', { - tenantId: 'tenant-1', - userId: 'user-1', - userEmail: 'user@example.com', - apiKey: 'sk-virtual', - }); - }); - - it('creates a tenant lazily for an authenticated user', async () => { - tenantCache.ensureForUser.mockResolvedValue('tenant-created'); - managedFreeProvider.ensureConnection.mockResolvedValue({ connected: false }); - - await controller.ensure( - 'gemini-free', - { tenantId: null, userId: null }, - { id: 'user-1', email: 'user@example.com' }, - {}, - ); - - expect(tenantCache.ensureForUser).toHaveBeenCalledWith('user-1'); - expect(managedFreeProvider.ensureConnection).toHaveBeenCalledWith('gemini-free', { - tenantId: 'tenant-created', - userId: 'user-1', - userEmail: 'user@example.com', - apiKey: undefined, - }); - }); - - it('returns unavailable when no tenant can be resolved', async () => { - await expect( - controller.ensure('gemini-free', { tenantId: null, userId: null }, {}, undefined as never), - ).resolves.toEqual({ - connected: false, - connection_id: null, - source: 'none', - auto_available: false, - }); - expect(managedFreeProvider.ensureConnection).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/backend/src/routing/managed-free-provider/managed-free-provider.controller.ts b/packages/backend/src/routing/managed-free-provider/managed-free-provider.controller.ts deleted file mode 100644 index e1e49237..00000000 --- a/packages/backend/src/routing/managed-free-provider/managed-free-provider.controller.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Body, Controller, Param, Post } from '@nestjs/common'; -import { IsOptional, IsString, MaxLength } from 'class-validator'; -import { CurrentUser } from '../../auth/current-user.decorator'; -import { TenantCtx, TenantContext } from '../../common/decorators/tenant-context.decorator'; -import { TenantCacheService } from '../../common/services/tenant-cache.service'; -import { ManagedFreeProviderService } from './managed-free-provider.service'; - -class EnsureManagedFreeProviderBody { - /** Optional LiteLLM virtual key for a manually provisioned connection. */ - @IsOptional() - @IsString() - @MaxLength(500) - apiKey?: string; -} - -@Controller('api/v1/providers') -export class ManagedFreeProviderController { - constructor( - private readonly managedFreeProvider: ManagedFreeProviderService, - private readonly tenantCache: TenantCacheService, - ) {} - - /** Ensure a provider-scoped LiteLLM connection without returning the raw key. */ - @Post(':providerId/ensure') - async ensure( - @Param('providerId') providerId: string, - @TenantCtx() ctx: TenantContext, - @CurrentUser() user: { id?: string; email?: string }, - @Body() body: EnsureManagedFreeProviderBody, - ) { - const tenantId = - ctx.tenantId ?? (user.id ? await this.tenantCache.ensureForUser(user.id) : null); - if (!tenantId) { - return { - connected: false, - connection_id: null, - source: 'none' as const, - auto_available: false, - }; - } - - return this.managedFreeProvider.ensureConnection(providerId, { - tenantId, - userId: ctx.userId ?? user.id ?? null, - userEmail: user.email ?? null, - apiKey: body?.apiKey, - }); - } -} diff --git a/packages/backend/src/routing/managed-free-provider/managed-free-provider.service.spec.ts b/packages/backend/src/routing/managed-free-provider/managed-free-provider.service.spec.ts deleted file mode 100644 index 7bfa008a..00000000 --- a/packages/backend/src/routing/managed-free-provider/managed-free-provider.service.spec.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { - getManagedFreeProviderConfig, - ManagedFreeProviderConfig, -} from '../../common/constants/managed-free-providers'; -import { ManagedFreeProviderService } from './managed-free-provider.service'; - -describe('ManagedFreeProviderService', () => { - const providerRepo = { - find: jest.fn(), - }; - const providerService = { - upsertProvider: jest.fn(), - }; - const discoveryService = { - discoverModels: jest.fn().mockResolvedValue(undefined), - }; - - let service: ManagedFreeProviderService; - const originalFetch = global.fetch; - const env = process.env; - - beforeEach(() => { - jest.resetAllMocks(); - process.env = { ...env }; - process.env['CREDITS_MASTER_KEY'] = 'sk-master'; - process.env['CREDITS_BASE_URL'] = 'https://credits.test'; - delete process.env['CREDITS_AUTO_PROVISION_ALLOWLIST']; - service = new ManagedFreeProviderService( - providerRepo as never, - providerService as never, - discoveryService as never, - ); - global.fetch = jest.fn(); - }); - - afterAll(() => { - global.fetch = originalFetch; - process.env = env; - }); - - it('returns an existing connection without minting', async () => { - providerRepo.find.mockResolvedValue([{ id: 'conn-1', is_active: true }]); - - await expect( - service.ensureConnection('gemini-free', { - tenantId: 't1', - userId: 'u1', - userEmail: 'a@x.com', - }), - ).resolves.toEqual({ - connected: true, - connection_id: 'conn-1', - source: 'existing', - auto_available: true, - }); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('preserves an explicit disconnect without minting a replacement key', async () => { - providerRepo.find.mockResolvedValue([{ id: 'conn-1', is_active: false }]); - - await expect( - service.ensureConnection('gemini-free', { - tenantId: 't1', - userId: 'u1', - userEmail: 'a@x.com', - }), - ).resolves.toEqual({ - connected: false, - connection_id: null, - source: 'none', - auto_available: true, - }); - expect(global.fetch).not.toHaveBeenCalled(); - expect(providerService.upsertProvider).not.toHaveBeenCalled(); - }); - - it('stores a pasted LiteLLM virtual key under Gemini Free', async () => { - providerRepo.find.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - providerService.upsertProvider.mockResolvedValue({ - provider: { id: 'conn-2' }, - isNew: true, - }); - - const result = await service.ensureConnection('gemini-free', { - tenantId: 't1', - userId: 'u1', - userEmail: 'a@x.com', - apiKey: 'sk-virtual', - }); - - expect(result).toMatchObject({ connection_id: 'conn-2', source: 'manual' }); - expect(providerService.upsertProvider).toHaveBeenCalledWith( - null, - 't1', - 'gemini-free', - 'sk-virtual', - 'api_key', - undefined, - undefined, - 'u1', - ); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('keeps a connection when immediate model discovery fails', async () => { - providerRepo.find.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - providerService.upsertProvider.mockResolvedValue({ - provider: { id: 'conn-2' }, - isNew: true, - }); - discoveryService.discoverModels.mockRejectedValueOnce('catalog unavailable'); - - await expect( - service.ensureConnection('gemini-free', { - tenantId: 't1', - userId: 'u1', - userEmail: 'a@x.com', - apiKey: 'sk-virtual', - }), - ).resolves.toMatchObject({ connection_id: 'conn-2', source: 'manual' }); - }); - - it('mints a virtual key scoped to Gemini models', async () => { - providerRepo.find.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => ({ key: 'sk-minted' }), - }); - providerService.upsertProvider.mockResolvedValue({ - provider: { id: 'conn-3' }, - isNew: true, - }); - - await expect( - service.ensureConnection('gemini-free', { - tenantId: 't1', - userId: 'u1', - userEmail: 'a@x.com', - }), - ).resolves.toEqual({ - connected: true, - connection_id: 'conn-3', - source: 'auto', - auto_available: true, - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://credits.test/key/generate', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ Authorization: 'Bearer sk-master' }), - signal: expect.any(AbortSignal), - }), - ); - const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body as string); - expect(body).toMatchObject({ - models: ['gemini/*'], - max_budget: 10, - key_alias: 'gemini-free:t1', - metadata: { - source: 'gemini-free', - tenant_id: 't1', - user_email: 'a@x.com', - }, - }); - }); - - it('returns none when automatic provisioning is unavailable', async () => { - delete process.env['CREDITS_MASTER_KEY']; - providerRepo.find.mockResolvedValue([]); - - await expect( - service.ensureConnection('gemini-free', { - tenantId: 't1', - userId: 'u1', - userEmail: 'a@x.com', - }), - ).resolves.toEqual({ - connected: false, - connection_id: null, - source: 'none', - auto_available: false, - }); - }); - - it('rejects direct key generation when the master key is missing', async () => { - delete process.env['CREDITS_MASTER_KEY']; - const privateService = service as unknown as { - mintVirtualKey( - config: ManagedFreeProviderConfig, - tenantId: string, - userEmail: string | null, - ): Promise; - }; - - await expect( - privateService.mintVirtualKey(getManagedFreeProviderConfig('gemini-free')!, 't1', 'a@x.com'), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('rejects a concurrent second connection', async () => { - providerRepo.find - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ id: 'conn-existing', is_active: true }]); - - await expect( - service.ensureConnection('gemini-free', { - tenantId: 't1', - userId: 'u1', - userEmail: 'a@x.com', - apiKey: 'sk-virtual', - }), - ).rejects.toBeInstanceOf(BadRequestException); - expect(providerService.upsertProvider).not.toHaveBeenCalled(); - }); - - it('rejects failed key generation', async () => { - providerRepo.find.mockResolvedValue([]); - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 500, - text: async () => 'boom', - }); - - await expect( - service.ensureConnection('gemini-free', { - tenantId: 't1', - userId: 'u1', - userEmail: 'a@x.com', - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('rejects a failed or timed-out key-generation request', async () => { - providerRepo.find.mockResolvedValue([]); - (global.fetch as jest.Mock).mockRejectedValue(new Error('request timed out')); - - await expect( - service.ensureConnection('gemini-free', { - tenantId: 't1', - userId: 'u1', - userEmail: 'a@x.com', - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('rejects a key-generation response without a virtual key', async () => { - providerRepo.find.mockResolvedValue([]); - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => ({}), - }); - - await expect( - service.ensureConnection('gemini-free', { - tenantId: 't1', - userId: 'u1', - userEmail: 'a@x.com', - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('rejects provider IDs that are not registered as managed free providers', async () => { - await expect( - service.ensureConnection('future-free', { - tenantId: 't1', - userId: 'u1', - userEmail: 'a@x.com', - }), - ).rejects.toBeInstanceOf(NotFoundException); - expect(providerRepo.find).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/backend/src/routing/managed-free-provider/managed-free-provider.service.ts b/packages/backend/src/routing/managed-free-provider/managed-free-provider.service.ts deleted file mode 100644 index 29f0d4fa..00000000 --- a/packages/backend/src/routing/managed-free-provider/managed-free-provider.service.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { - getManagedFreeLiteLlmKeyGenerateUrl, - getManagedFreeLiteLlmMasterKey, - getManagedFreeLiteLlmMaxBudgetUsd, - getManagedFreeProviderConfig, - isManagedFreeLiteLlmAutoEligible, - ManagedFreeProviderConfig, -} from '../../common/constants/managed-free-providers'; -import { TenantProvider } from '../../entities/tenant-provider.entity'; -import { ModelDiscoveryService } from '../../model-discovery/model-discovery.service'; -import { ProviderService } from '../routing-core/provider.service'; - -export interface ManagedFreeEnsureResult { - connected: boolean; - connection_id: string | null; - source: 'existing' | 'auto' | 'manual' | 'none'; - auto_available: boolean; -} - -const MANAGED_FREE_KEY_GENERATE_TIMEOUT_MS = 5_000; - -@Injectable() -export class ManagedFreeProviderService { - private readonly logger = new Logger(ManagedFreeProviderService.name); - - constructor( - @InjectRepository(TenantProvider) - private readonly providerRepo: Repository, - private readonly providerService: ProviderService, - private readonly discoveryService: ModelDiscoveryService, - ) {} - - private getConfig(providerId: string): ManagedFreeProviderConfig { - const config = getManagedFreeProviderConfig(providerId); - if (!config) { - throw new NotFoundException(`Unknown managed free provider: ${providerId}`); - } - return config; - } - - async findConnection( - config: ManagedFreeProviderConfig, - tenantId: string, - ): Promise { - const rows = await this.providerRepo.find({ - where: { - tenant_id: tenantId, - provider: config.id, - auth_type: 'api_key', - }, - }); - return rows.find((row) => row.is_active) ?? rows[0] ?? null; - } - - async ensureConnection( - providerId: string, - opts: { - tenantId: string; - userId: string | null; - userEmail: string | null | undefined; - apiKey?: string; - }, - ): Promise { - const config = this.getConfig(providerId); - const { tenantId, userId, userEmail, apiKey } = opts; - const autoAvailable = isManagedFreeLiteLlmAutoEligible(userEmail); - - const existing = await this.findConnection(config, tenantId); - if (existing?.is_active) { - return { - connected: true, - connection_id: existing.id, - source: 'existing', - auto_available: autoAvailable, - }; - } - - const trimmedKey = apiKey?.trim(); - if (trimmedKey) { - const row = await this.persistKey(config, tenantId, userId, trimmedKey); - return { - connected: true, - connection_id: row.id, - source: 'manual', - auto_available: autoAvailable, - }; - } - - // An inactive row records an explicit disconnect. Do not silently mint a - // replacement key when the provider list is fetched later. - if (existing) { - return { - connected: false, - connection_id: null, - source: 'none', - auto_available: autoAvailable, - }; - } - - if (!autoAvailable) { - return { - connected: false, - connection_id: null, - source: 'none', - auto_available: false, - }; - } - - const minted = await this.mintVirtualKey(config, tenantId, userEmail); - const row = await this.persistKey(config, tenantId, userId, minted); - return { - connected: true, - connection_id: row.id, - source: 'auto', - auto_available: true, - }; - } - - private async persistKey( - config: ManagedFreeProviderConfig, - tenantId: string, - userId: string | null, - apiKey: string, - ): Promise { - const existing = await this.findConnection(config, tenantId); - if (existing?.is_active) { - throw new BadRequestException( - `${config.displayName} already has a connection for this workspace`, - ); - } - - const { provider } = await this.providerService.upsertProvider( - null, - tenantId, - config.id, - apiKey, - 'api_key', - undefined, - undefined, - userId, - ); - - try { - await this.discoveryService.discoverModels(provider); - } catch (error) { - this.logger.warn( - `${config.displayName} model discovery failed after connect: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - - return provider; - } - - private async mintVirtualKey( - config: ManagedFreeProviderConfig, - tenantId: string, - userEmail: string | null | undefined, - ): Promise { - const masterKey = getManagedFreeLiteLlmMasterKey(); - if (!masterKey) { - throw new BadRequestException('LiteLLM master key is not configured'); - } - - const body = { - models: [...config.litellmModels], - max_budget: getManagedFreeLiteLlmMaxBudgetUsd(config), - key_alias: `${config.id}:${tenantId}`, - metadata: { - source: config.id, - tenant_id: tenantId, - user_email: userEmail ?? null, - }, - }; - - let response: Response; - try { - response = await fetch(getManagedFreeLiteLlmKeyGenerateUrl(), { - method: 'POST', - headers: { - Authorization: `Bearer ${masterKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(MANAGED_FREE_KEY_GENERATE_TIMEOUT_MS), - }); - } catch (error) { - this.logger.error( - `LiteLLM /key/generate request failed: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - throw new BadRequestException(`Failed to create ${config.displayName} key`); - } - - if (!response.ok) { - const text = await response.text().catch(() => ''); - this.logger.error(`LiteLLM /key/generate failed (${response.status}): ${text.slice(0, 300)}`); - throw new BadRequestException(`Failed to create ${config.displayName} key`); - } - - const json = (await response.json()) as { key?: string; token?: string }; - const key = json.key ?? json.token; - if (!key || typeof key !== 'string') { - throw new BadRequestException('LiteLLM did not return a virtual key'); - } - return key; - } -} diff --git a/packages/backend/src/routing/proxy/__tests__/provider-client.spec.ts b/packages/backend/src/routing/proxy/__tests__/provider-client.spec.ts index 1b5cde08..027fd313 100644 --- a/packages/backend/src/routing/proxy/__tests__/provider-client.spec.ts +++ b/packages/backend/src/routing/proxy/__tests__/provider-client.spec.ts @@ -1300,26 +1300,17 @@ describe('ProviderClient', () => { expect(sentBody.model).toBe('openrouter/auto'); }); - it('preserves the Gemini vendor prefix for Gemini Free LiteLLM requests', async () => { - process.env['CREDITS_BASE_URL'] = 'https://credits.test'; - mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); - - await client.forward({ - provider: 'gemini-free', - apiKey: 'sk-virtual', - model: 'gemini/gemini-2.5-flash', - body, - stream: false, - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://credits.test/v1/chat/completions', - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'Bearer sk-virtual' }), + it('refuses the removed managed Gemini Free provider without calling out', async () => { + await expect( + client.forward({ + provider: 'gemini-free', + apiKey: 'sk-virtual', + model: 'gemini/gemini-2.5-flash', + body, + stream: false, }), - ); - const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(sentBody.model).toBe('gemini/gemini-2.5-flash'); + ).rejects.toThrow('No endpoint configured for provider: gemini-free'); + expect(mockFetch).not.toHaveBeenCalled(); }); it('builds Nous Portal requests without stripping vendor-prefixed model IDs', async () => { diff --git a/packages/backend/src/routing/proxy/__tests__/provider-endpoints.spec.ts b/packages/backend/src/routing/proxy/__tests__/provider-endpoints.spec.ts index b6d78871..f687f6e9 100644 --- a/packages/backend/src/routing/proxy/__tests__/provider-endpoints.spec.ts +++ b/packages/backend/src/routing/proxy/__tests__/provider-endpoints.spec.ts @@ -76,7 +76,6 @@ describe('resolveEndpointKey', () => { expect(resolveEndpointKey('commandcode')).toBe('commandcode'); expect(resolveEndpointKey('fireworks')).toBe('fireworks'); expect(resolveEndpointKey('huggingface')).toBe('huggingface'); - expect(resolveEndpointKey('gemini-free')).toBe('gemini-free'); expect(resolveEndpointKey('nous')).toBe('nous'); expect(resolveEndpointKey('nvidia')).toBe('nvidia'); expect(resolveEndpointKey('ollama')).toBe('ollama'); @@ -170,7 +169,7 @@ describe('resolveEndpointKey', () => { expect(known).toContain('commandcode-anthropic'); expect(known).toContain('fireworks'); expect(known).toContain('huggingface'); - expect(known).toContain('gemini-free'); + expect(known).not.toContain('gemini-free'); expect(known).toContain('nous'); expect(known).toContain('openrouter'); expect(known).toContain('nvidia'); @@ -361,16 +360,10 @@ describe('PROVIDER_ENDPOINTS', () => { }, ); - it('routes Gemini Free through the configured LiteLLM gateway', () => { - process.env['CREDITS_BASE_URL'] = 'https://credits.test/'; - const endpoint = PROVIDER_ENDPOINTS['gemini-free']; - - expect(endpoint.baseUrl).toBe('https://credits.test'); - expect(endpoint.buildPath('gemini/gemini-2.5-flash')).toBe('/v1/chat/completions'); - expect(endpoint.buildHeaders('sk-virtual')).toEqual({ - Authorization: 'Bearer sk-virtual', - 'Content-Type': 'application/json', - }); + it('has no endpoint for the removed managed Gemini Free provider', () => { + expect(PROVIDER_ENDPOINTS['gemini-free']).toBeUndefined(); + expect(resolveEndpointKey('gemini-free')).toBeNull(); + expect(resolveEndpointKey('gemini free')).toBeNull(); }); it('huggingface uses the OpenAI-compatible Inference Providers endpoint', () => { diff --git a/packages/backend/src/routing/proxy/__tests__/responses-stream-tools.spec.ts b/packages/backend/src/routing/proxy/__tests__/responses-stream-tools.spec.ts index e2e01461..ffb38e2d 100644 --- a/packages/backend/src/routing/proxy/__tests__/responses-stream-tools.spec.ts +++ b/packages/backend/src/routing/proxy/__tests__/responses-stream-tools.spec.ts @@ -64,7 +64,7 @@ describe('Responses streaming tool calls', () => { it('restores namespaces on every item and keeps tool-before-text ordering', () => { const names = responsesToolNames([ - { type: 'namespace', name: 'crm', tools: [{ type: 'function', name: 'lookup' }] }, + { type: 'namespace', name: 'contacts', tools: [{ type: 'function', name: 'lookup' }] }, ]); const name = [...names.keys()][0]; const t = createResponsesStreamTransformer('auto', { toolNames: names }); @@ -75,9 +75,9 @@ describe('Responses streaming tool calls', () => { ].join(''); const output = collectResponsesSseResponse(sse).output as any[]; expect(output.map((i) => i.type)).toEqual(['function_call', 'message']); - expect(output[0]).toMatchObject({ name: 'lookup', namespace: 'crm', arguments: '{}' }); + expect(output[0]).toMatchObject({ name: 'lookup', namespace: 'contacts', arguments: '{}' }); for (const e of events(sse).filter((e) => e.item?.type === 'function_call')) { - expect(e.item).toMatchObject({ name: 'lookup', namespace: 'crm' }); + expect(e.item).toMatchObject({ name: 'lookup', namespace: 'contacts' }); } }); diff --git a/packages/backend/src/routing/proxy/__tests__/responses-tools.spec.ts b/packages/backend/src/routing/proxy/__tests__/responses-tools.spec.ts index d32de079..e9290a13 100644 --- a/packages/backend/src/routing/proxy/__tests__/responses-tools.spec.ts +++ b/packages/backend/src/routing/proxy/__tests__/responses-tools.spec.ts @@ -14,7 +14,7 @@ const fn = { }; const tools = [ fn, - { type: 'namespace', name: 'crm', tools: [fn] }, + { type: 'namespace', name: 'contacts', tools: [fn] }, { type: 'namespace', name: 'billing', tools: [fn] }, { type: 'web_search' }, ]; @@ -31,7 +31,7 @@ describe('Responses namespace tools', () => { expect(tool.function.name).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); } expect([...names.values()]).toEqual([ - { name: 'lookup', namespace: 'crm' }, + { name: 'lookup', namespace: 'contacts' }, { name: 'lookup', namespace: 'billing' }, { name: 'lookup' }, ]); @@ -42,7 +42,7 @@ describe('Responses namespace tools', () => { const wireName = [...names.keys()][0]; const call = { type: 'function_call', - namespace: 'crm', + namespace: 'contacts', name: 'lookup', call_id: 'call1', arguments: '{}', @@ -55,7 +55,7 @@ describe('Responses namespace tools', () => { { type: 'function_call_output', call_id: 'call1', output: 'one' }, { type: 'function_call_output', call_id: 'call2', output: 'two' }, ], - tool_choice: { type: 'function', namespace: 'crm', name: 'lookup' }, + tool_choice: { type: 'function', namespace: 'contacts', name: 'lookup' }, }; const chat = toChatCompletionsRequest(body) as any; expect(chat.tool_choice).toEqual({ type: 'function', function: { name: wireName } }); @@ -76,11 +76,14 @@ describe('Responses namespace tools', () => { }); it('keeps aliases stable for reordered tools and historical calls', () => { - const name = chatToolName({ namespace: 'crm', name: 'lookup' }, responsesToolNames(tools)); + const name = chatToolName({ namespace: 'contacts', name: 'lookup' }, responsesToolNames(tools)); expect( - chatToolName({ namespace: 'crm', name: 'lookup' }, responsesToolNames([...tools].reverse())), + chatToolName( + { namespace: 'contacts', name: 'lookup' }, + responsesToolNames([...tools].reverse()), + ), ).toBe(name); - expect(chatToolName({ namespace: 'crm', name: 'lookup' }, new Map())).toBe(name); + expect(chatToolName({ namespace: 'contacts', name: 'lookup' }, new Map())).toBe(name); }); it('avoids collisions with top-level names and handles long namespace names', () => { @@ -96,10 +99,10 @@ describe('Responses namespace tools', () => { const invalid = [ null, { type: 'namespace' }, - { type: 'namespace', name: 'crm' }, + { type: 'namespace', name: 'contacts' }, { type: 'namespace', - name: 'crm', + name: 'contacts', tools: [null, { type: 'web_search' }, { type: 'function' }], }, ]; @@ -109,7 +112,7 @@ describe('Responses namespace tools', () => { [ null, { type: 'namespace' }, - { type: 'namespace', name: 'crm', tools: [null, { type: 'web_search' }] }, + { type: 'namespace', name: 'contacts', tools: [null, { type: 'web_search' }] }, ], new Map(), ), diff --git a/packages/backend/src/routing/proxy/provider-client.ts b/packages/backend/src/routing/proxy/provider-client.ts index 711451b5..36fa7a10 100644 --- a/packages/backend/src/routing/proxy/provider-client.ts +++ b/packages/backend/src/routing/proxy/provider-client.ts @@ -49,7 +49,6 @@ import { ProviderModelRegistryService } from '../../model-discovery/provider-mod import { qualifyChatGptResponse } from './chatgpt-response-qualifier'; import { isProviderAvailableForDeployment } from '../../common/utils/provider-availability'; import { ManifestError } from '../../common/errors/manifest-error'; -import { MANAGED_FREE_PROVIDER_BY_ID } from '../../common/constants/managed-free-providers'; import { isBedrockProvider } from '../bedrock-region'; import { getBedrockRuntimeCapabilities } from '../bedrock-runtime-capabilities'; @@ -326,8 +325,8 @@ function openRouterCacheMode(model: string): 'anthropic' | 'message' | null { * Models synced from OpenRouter use vendor prefixes, but native APIs expect bare names. */ function stripModelPrefix(model: string, endpointKey: string): string { - // OpenRouter and managed free providers expect vendor prefixes. - if (endpointKey === 'openrouter' || MANAGED_FREE_PROVIDER_BY_ID.has(endpointKey)) return model; + // OpenRouter expects vendor prefixes. + if (endpointKey === 'openrouter') return model; if (endpointKey === 'commandcode' || endpointKey === 'commandcode-anthropic') { return model.startsWith('commandcode/') ? model.slice('commandcode/'.length) : model; } diff --git a/packages/backend/src/routing/proxy/provider-endpoints.ts b/packages/backend/src/routing/proxy/provider-endpoints.ts index 35e057ec..c577eb3a 100644 --- a/packages/backend/src/routing/proxy/provider-endpoints.ts +++ b/packages/backend/src/routing/proxy/provider-endpoints.ts @@ -1,7 +1,3 @@ -import { - getManagedFreeLiteLlmBaseUrl, - MANAGED_FREE_PROVIDER_CONFIGS, -} from '../../common/constants/managed-free-providers'; import { OLLAMA_CLOUD_HOST, OLLAMA_HOST } from '../../common/constants/ollama'; import { stripVendorPrefix } from '../../common/constants/openai-models'; import { PROVIDER_BY_ID_OR_ALIAS } from '../../common/constants/providers'; @@ -203,21 +199,6 @@ const chatgptSubscriptionHeaders = (apiKey: string) => ({ 'user-agent': CODEX_CLI_USER_AGENT, }); -const MANAGED_FREE_ENDPOINTS: Record = Object.fromEntries( - MANAGED_FREE_PROVIDER_CONFIGS.map((config) => [ - config.id, - { - get baseUrl() { - return getManagedFreeLiteLlmBaseUrl(); - }, - buildHeaders: openaiHeaders, - buildPath: openaiPath, - format: 'openai', - ...openaiStreamUsage, - }, - ]), -); - export const PROVIDER_ENDPOINTS: Record = { openai: { baseUrl: 'https://api.openai.com', @@ -592,7 +573,6 @@ export const PROVIDER_ENDPOINTS: Record = { format: 'openai', ...openaiStreamUsage, }, - ...MANAGED_FREE_ENDPOINTS, ollama: { baseUrl: OLLAMA_HOST, buildHeaders: () => ({ 'Content-Type': 'application/json' }), diff --git a/packages/backend/src/routing/routing-core/provider.service.ts b/packages/backend/src/routing/routing-core/provider.service.ts index 71aea89e..ba92b4d1 100644 --- a/packages/backend/src/routing/routing-core/provider.service.ts +++ b/packages/backend/src/routing/routing-core/provider.service.ts @@ -50,9 +50,7 @@ import { isMinimaxRegion, } from '../oauth/minimax/minimax-oauth-helpers'; import { filterProvidersForDeployment } from '../../common/utils/provider-availability'; -import { getManagedFreeProviderConfig } from '../../common/constants/managed-free-providers'; -const MAX_KEYS_MANAGED_FREE_PROVIDER = 1; const MAX_LABEL_LENGTH = 50; const DEFAULT_LABEL = 'Default'; // Bounds for withSubscriptionCredentialLock's critical section (the provider @@ -495,13 +493,9 @@ export class ProviderService { } const activeCount = existingRows.filter((r) => r.is_active).length; - const managedFreeConfig = getManagedFreeProviderConfig(provider); - const maxKeys = managedFreeConfig ? MAX_KEYS_MANAGED_FREE_PROVIDER : MAX_KEYS_PER_PROVIDER; - if (activeCount >= maxKeys) { + if (activeCount >= MAX_KEYS_PER_PROVIDER) { throw new BadRequestException( - maxKeys === 1 - ? `You can connect at most 1 key for ${managedFreeConfig!.displayName}` - : `You can connect at most ${maxKeys} keys per provider`, + `You can connect at most ${MAX_KEYS_PER_PROVIDER} keys per provider`, ); } diff --git a/packages/backend/src/routing/routing.module.ts b/packages/backend/src/routing/routing.module.ts index 07fd6dd9..ce34a90e 100644 --- a/packages/backend/src/routing/routing.module.ts +++ b/packages/backend/src/routing/routing.module.ts @@ -19,8 +19,6 @@ import { SpecificityController } from './specificity.controller'; import { ModelParamsController } from './model-params.controller'; import { TenantProvidersController } from './tenant-providers.controller'; import { AgentEnabledProvidersController } from './agent-enabled-providers.controller'; -import { ManagedFreeProviderController } from './managed-free-provider/managed-free-provider.controller'; -import { ManagedFreeProviderService } from './managed-free-provider/managed-free-provider.service'; import { OllamaSyncService } from '../database/ollama-sync.service'; import { NotificationsModule } from '../notifications/notifications.module'; import { TenantProvider } from '../entities/tenant-provider.entity'; @@ -68,9 +66,8 @@ import { InstallMetadata } from '../entities/install-metadata.entity'; ModelParamsController, TenantProvidersController, AgentEnabledProvidersController, - ManagedFreeProviderController, ], - providers: [OllamaSyncService, ManagedFreeProviderService], + providers: [OllamaSyncService], exports: [RoutingCoreModule, CustomProviderModule, OAuthModule], }) export class RoutingModule {} diff --git a/packages/backend/src/waitlist/dto/pivot-claim.dto.spec.ts b/packages/backend/src/waitlist/dto/pivot-claim.dto.spec.ts deleted file mode 100644 index eb5ef8be..00000000 --- a/packages/backend/src/waitlist/dto/pivot-claim.dto.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { plainToInstance } from 'class-transformer'; -import { validateSync } from 'class-validator'; -import { PivotClaimDto } from './pivot-claim.dto'; - -function validate(payload: Record) { - const dto = plainToInstance(PivotClaimDto, payload); - return { dto, errors: validateSync(dto, { whitelist: true, forbidNonWhitelisted: true }) }; -} - -describe('PivotClaimDto', () => { - it('accepts a valid email and trims surrounding whitespace', () => { - const { dto, errors } = validate({ email: ' jane@example.com ' }); - expect(errors).toHaveLength(0); - expect(dto.email).toBe('jane@example.com'); - }); - - it('rejects an invalid email', () => { - const { errors } = validate({ email: 'not-an-email' }); - expect(errors.map((error) => error.property)).toContain('email'); - }); - - it('leaves non-string values to the email validator', () => { - const { errors } = validate({ email: 42 }); - expect(errors.map((error) => error.property)).toContain('email'); - }); - - it('rejects unknown fields', () => { - const { errors } = validate({ email: 'jane@example.com', newsletter: true }); - expect(errors.map((error) => error.property)).toContain('newsletter'); - }); - - it('rejects an explicit null source; only omission earns the default', () => { - const { errors } = validate({ email: 'a@b.co', source: null }); - expect(errors.map((error) => error.property)).toContain('source'); - }); - - it('accepts the two claim sources and rejects anything else', () => { - expect(validate({ email: 'a@b.co', source: 'cloud' }).errors).toHaveLength(0); - expect(validate({ email: 'a@b.co', source: 'self-hosted' }).errors).toHaveLength(0); - const { errors } = validate({ email: 'a@b.co', source: 'website' }); - expect(errors.map((error) => error.property)).toContain('source'); - }); -}); diff --git a/packages/backend/src/waitlist/dto/pivot-claim.dto.ts b/packages/backend/src/waitlist/dto/pivot-claim.dto.ts deleted file mode 100644 index 5710bb4c..00000000 --- a/packages/backend/src/waitlist/dto/pivot-claim.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Transform } from 'class-transformer'; -import { IsEmail, IsIn, MaxLength, ValidateIf } from 'class-validator'; - -/** Claim origins this route may record; `website` belongs to Peacock's own form. */ -export const PIVOT_CLAIM_SOURCES = ['cloud', 'self-hosted'] as const; -export type PivotClaimSource = (typeof PIVOT_CLAIM_SOURCES)[number]; - -/** Fallback when a claim carries no source and no same-origin signal. */ -export const DEFAULT_PIVOT_CLAIM_SOURCE: PivotClaimSource = 'self-hosted'; - -/** Body of the public pivot waiting-list claim. */ -export class PivotClaimDto { - @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) - @IsEmail() - @MaxLength(254) - email!: string; - - /** Where the claim was made; absent defaults to self-hosted. Explicit - * null is rejected: only omission earns the default. */ - @ValidateIf((claim) => claim.source !== undefined) - @IsIn([...PIVOT_CLAIM_SOURCES]) - source?: PivotClaimSource; -} diff --git a/packages/backend/src/waitlist/waitlist.controller.spec.ts b/packages/backend/src/waitlist/waitlist.controller.spec.ts deleted file mode 100644 index 7620e4ef..00000000 --- a/packages/backend/src/waitlist/waitlist.controller.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Request } from 'express'; -import { Repository } from 'typeorm'; -import { WaitlistClaim } from '../entities/waitlist-claim.entity'; -import { WaitlistController, claimRequestIsSameOrigin } from './waitlist.controller'; - -function reqWith(headers: Record = {}): Request { - return { headers } as unknown as Request; -} - -describe('claimRequestIsSameOrigin', () => { - it('matches when the Origin host equals the Host header', () => { - expect( - claimRequestIsSameOrigin({ - origin: 'https://app.manifest.build', - host: 'app.manifest.build', - }), - ).toBe(true); - }); - - it('rejects a foreign origin', () => { - expect( - claimRequestIsSameOrigin({ origin: 'https://someone.example', host: 'app.manifest.build' }), - ).toBe(false); - }); - - it('rejects missing, array, or malformed headers', () => { - expect(claimRequestIsSameOrigin({})).toBe(false); - expect(claimRequestIsSameOrigin({ origin: ['a', 'b'], host: 'x' })).toBe(false); - expect(claimRequestIsSameOrigin({ origin: 'not a url', host: 'x' })).toBe(false); - }); -}); - -describe('WaitlistController', () => { - let chain: { - insert: jest.Mock; - into: jest.Mock; - values: jest.Mock; - orUpdate: jest.Mock; - execute: jest.Mock; - }; - let controller: WaitlistController; - - beforeEach(() => { - chain = { - insert: jest.fn(), - into: jest.fn(), - values: jest.fn(), - orUpdate: jest.fn(), - execute: jest.fn().mockResolvedValue(undefined), - }; - chain.insert.mockReturnValue(chain); - chain.into.mockReturnValue(chain); - chain.values.mockReturnValue(chain); - chain.orUpdate.mockReturnValue(chain); - controller = new WaitlistController({ - createQueryBuilder: () => chain, - } as unknown as Repository); - }); - - it('keeps the legacy self-hosted claim endpoint as a no-op success', () => { - expect(controller.receiveClaim()).toEqual({ ok: true }); - expect(chain.execute).not.toHaveBeenCalled(); - }); - - it('stores a claim with a normalized email and the declared source', async () => { - await expect( - controller.receivePivotClaim({ email: ' Jane@Example.COM ', source: 'cloud' }, reqWith()), - ).resolves.toEqual({ ok: true }); - - expect(chain.values).toHaveBeenCalledWith( - expect.objectContaining({ email: 'jane@example.com', source: 'cloud' }), - ); - const row = chain.values.mock.calls[0][0] as { claimed_at: string }; - expect(Number.isNaN(Date.parse(row.claimed_at))).toBe(false); - }); - - it('infers cloud for a sourceless same-origin claim (stale cloud bundle)', async () => { - await controller.receivePivotClaim( - { email: 'jane@example.com' }, - reqWith({ origin: 'https://app.manifest.build', host: 'app.manifest.build' }), - ); - expect(chain.values).toHaveBeenCalledWith(expect.objectContaining({ source: 'cloud' })); - }); - - it('defaults a sourceless cross-origin or headerless claim to self-hosted', async () => { - await controller.receivePivotClaim({ email: 'jane@example.com' }, reqWith()); - expect(chain.values).toHaveBeenCalledWith(expect.objectContaining({ source: 'self-hosted' })); - }); - - it('lets an explicit source win over the origin inference', async () => { - await controller.receivePivotClaim( - { email: 'jane@example.com', source: 'self-hosted' }, - reqWith({ origin: 'https://app.manifest.build', host: 'app.manifest.build' }), - ); - expect(chain.values).toHaveBeenCalledWith(expect.objectContaining({ source: 'self-hosted' })); - }); - - it('lets the latest claim win on conflict, but never over a website row', async () => { - await controller.receivePivotClaim( - { email: 'jane@example.com', source: 'self-hosted' }, - reqWith(), - ); - expect(chain.orUpdate).toHaveBeenCalledWith(['source', 'claimed_at'], ['email'], { - overwriteCondition: { - where: '"waitlist_claims"."source" != :websiteSource', - parameters: { websiteSource: 'website' }, - }, - }); - }); -}); diff --git a/packages/backend/src/waitlist/waitlist.controller.ts b/packages/backend/src/waitlist/waitlist.controller.ts deleted file mode 100644 index 47e23531..00000000 --- a/packages/backend/src/waitlist/waitlist.controller.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Body, Controller, HttpCode, HttpStatus, Post, Req } from '@nestjs/common'; -import { Request } from 'express'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Public } from '../common/decorators/public.decorator'; -import { WaitlistClaim } from '../entities/waitlist-claim.entity'; -import { DEFAULT_PIVOT_CLAIM_SOURCE, PivotClaimDto } from './dto/pivot-claim.dto'; - -/** - * Same-origin check for the sourceless fallback: a stale cloud bundle from - * before the source field posts same-origin to its own backend, while - * self-hosted dashboards post cross-origin. Explicit sources bypass this. - */ -export function claimRequestIsSameOrigin(headers: { - origin?: string | string[]; - host?: string | string[]; -}): boolean { - const origin = headers.origin; - const host = headers.host; - if (typeof origin !== 'string' || typeof host !== 'string') return false; - try { - return new URL(origin).host === host; - } catch { - return false; - } -} - -@Controller('api/v1/waitlist') -export class WaitlistController { - constructor( - @InjectRepository(WaitlistClaim) - private readonly claimRepo: Repository, - ) {} - - /** - * Compatibility endpoint for self-hosted versions that predate Autofix GA. - * Keep returning 200 during the deprecation window, but do not retain new - * waitlist claims now that every tenant has access. - */ - @Public() - @Post('autofix/claim') - @HttpCode(HttpStatus.OK) - receiveClaim(): { ok: boolean } { - return { ok: true }; - } - - /** - * Pivot waiting-list claim. Public and CORS-open on purpose: cloud posts - * same-origin and self-hosted dashboards post here straight from the - * browser, so any locally-registered user can join with a corrected email. - * The upsert dedupes by email and the latest claim wins, source included: - * attribution reflects where the person last joined from, matching the - * historical phone-home behavior. The global throttler still applies. - */ - @Public() - @Post('pivot/claim') - @HttpCode(HttpStatus.OK) - async receivePivotClaim( - @Body() dto: PivotClaimDto, - @Req() req: Request, - ): Promise<{ ok: boolean }> { - // An explicit source always wins; a sourceless same-origin post is the - // cloud dashboard (a cached pre-source bundle) talking to its own - // backend, anything else defaults to self-hosted. - const source = - dto.source ?? (claimRequestIsSameOrigin(req.headers) ? 'cloud' : DEFAULT_PIVOT_CLAIM_SOURCE); - await this.claimRepo - .createQueryBuilder() - .insert() - .into(WaitlistClaim) - .values({ - email: dto.email.trim().toLowerCase(), - source, - claimed_at: new Date().toISOString(), - }) - // Latest claim wins among the sources this route owns; a website row - // (Peacock's own form) is never overwritten by this public endpoint. - .orUpdate(['source', 'claimed_at'], ['email'], { - overwriteCondition: { - where: '"waitlist_claims"."source" != :websiteSource', - parameters: { websiteSource: 'website' }, - }, - }) - .execute(); - return { ok: true }; - } -} diff --git a/packages/backend/src/waitlist/waitlist.module.ts b/packages/backend/src/waitlist/waitlist.module.ts deleted file mode 100644 index 195b8d9f..00000000 --- a/packages/backend/src/waitlist/waitlist.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { WaitlistClaim } from '../entities/waitlist-claim.entity'; -import { WaitlistController } from './waitlist.controller'; - -@Module({ - imports: [TypeOrmModule.forFeature([WaitlistClaim])], - controllers: [WaitlistController], -}) -export class WaitlistModule {} diff --git a/packages/backend/test/crm-metrics.e2e-spec.ts b/packages/backend/test/crm-metrics.e2e-spec.ts deleted file mode 100644 index 9c7c56bc..00000000 --- a/packages/backend/test/crm-metrics.e2e-spec.ts +++ /dev/null @@ -1,366 +0,0 @@ -import request from 'supertest'; -import { INestApplication } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { v4 as uuid } from 'uuid'; -import { createTestApp, TEST_TENANT_ID, TEST_AGENT_ID } from './helpers'; -import { AddRequestsAutofixHealedIndex1802200000000 } from '../src/database/migrations/1802200000000-AddRequestsAutofixHealedIndex'; - -/** - * Exercises the real SQL against real Postgres. The unit specs mock the - * DataSource entirely, so this is the only place a syntax error, a bad column - * name, or an invalid index definition would surface. - */ - -const SECRET = 'e2e-crm-metrics-secret-at-least-32-chars'; -const HEALED_INDEX = 'IDX_requests_autofix_healed'; -const TENANT_TIMESTAMP_INDEX = 'IDX_requests_tenant_timestamp'; - -const OWNER_ID = 'crm-owner-001'; -const OWNER_EMAIL = 'healed.user@example.com'; - -/** Local wall clock, matching how the pg driver writes these naive columns. */ -function localSqlTimestamp(d: Date): string { - const pad = (n: number) => String(n).padStart(2, '0'); - return ( - `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + - `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` - ); -} - -describe('Internal CRM metrics (e2e)', () => { - let app: INestApplication; - let ds: DataSource; - let previousMode: string | undefined; - - beforeAll(async () => { - process.env['CRM_METRICS_SECRET'] = SECRET; - // The migration and the module registration are both Cloud-only. Pin the - // mode so this suite does not depend on whether the runner happens to look - // containerised to `isSelfHosted()`. - previousMode = process.env['MANIFEST_MODE']; - process.env['MANIFEST_MODE'] = 'cloud'; - app = await createTestApp(); - ds = app.get(DataSource); - - // Better Auth owns the `user` table and its migrations do not run in e2e. - await ds.query( - `CREATE TABLE IF NOT EXISTS "user" ( - id VARCHAR PRIMARY KEY, - name VARCHAR, - email VARCHAR, - "emailVerified" BOOLEAN, - "createdAt" TIMESTAMPTZ - )`, - ); - - // The partial index lives in a migration, not on the entity, so - // synchronize() never creates it. Run the real migration rather than a - // copy of its DDL: a hand-copied index would keep passing even if the - // migration drifted, which is exactly the defect this suite exists to - // catch. CONCURRENTLY needs to be outside a transaction, and a bare - // queryRunner autocommits. - const runner = ds.createQueryRunner(); - try { - await runner.connect(); - await new AddRequestsAutofixHealedIndex1802200000000().up(runner); - } finally { - await runner.release(); - } - - // The signups feed refuses to run without this one, because the lateral - // degrades to a sequential scan of `requests` per row (22s and 6.9 GB of - // reads, measured). Production gets it from migration 1801000000000, which - // also creates the tables synchronize() has already built here — so run - // just the index. It is a plain two-column btree with no partial predicate - // to drift, unlike the healed index above. - await ds.query( - `CREATE INDEX IF NOT EXISTS "${TENANT_TIMESTAMP_INDEX}" ON "requests" ("tenant_id", "timestamp")`, - ); - - const [built] = (await ds.query(`SELECT indexdef FROM pg_indexes WHERE indexname = $1`, [ - HEALED_INDEX, - ])) as Array<{ indexdef: string }>; - expect(built?.indexdef).toContain('INCLUDE (status)'); - expect(built?.indexdef).toContain(`WHERE ((autofix_status)::text = 'retry_succeeded'::text)`); - }); - - afterAll(async () => { - await app.close(); - delete process.env['CRM_METRICS_SECRET']; - // Restore rather than delete: e2e runs --runInBand, so every spec shares one - // process, and a shell that exports MANIFEST_MODE would otherwise lose it - // for every sibling suite that reads isSelfHosted(). - if (previousMode === undefined) delete process.env['MANIFEST_MODE']; - else process.env['MANIFEST_MODE'] = previousMode; - }); - - beforeEach(async () => { - await ds.query('DELETE FROM agent_messages'); - await ds.query('DELETE FROM requests'); - await ds.query('DELETE FROM waitlist_claims'); - await ds.query('DELETE FROM "user"'); - await ds.query( - `INSERT INTO "user" (id, name, email, "emailVerified", "createdAt") - VALUES ($1, $2, $3, true, now() - interval '10 days')`, - [OWNER_ID, 'Healed User', OWNER_EMAIL], - ); - await ds.query(`UPDATE tenants SET owner_user_id = $1 WHERE id = $2`, [ - OWNER_ID, - TEST_TENANT_ID, - ]); - }); - - async function seedHealedRequest(ageDays: number, status = 'success'): Promise { - const requestId = uuid(); - const at = localSqlTimestamp(new Date(Date.now() - ageDays * 86_400_000)); - await ds.query( - `INSERT INTO requests (id, tenant_id, agent_id, agent_name, timestamp, status, autofix_status) - VALUES ($1, $2, $3, 'demo-agent', $4, $5, 'retry_succeeded')`, - [requestId, TEST_TENANT_ID, TEST_AGENT_ID, at, status], - ); - await ds.query( - `INSERT INTO agent_messages (id, tenant_id, agent_id, agent_name, request_id, timestamp, provider, model, autofix_applied) - VALUES ($1, $2, $3, 'demo-agent', $4, $5, $6, 'some-model', true)`, - [uuid(), TEST_TENANT_ID, TEST_AGENT_ID, requestId, at, 'openai'], - ); - } - - const get = (path: string) => - request(app.getHttpServer()).get(`/api/v1/internal/crm-metrics${path}`); - - /** - * Results are memoised per window for 60s, which outlives this suite, so each - * cohort assertion asks for its own `days` value. They are all far shorter - * than the 40-day-old row seeded below, so the window itself never matters — - * only that the cache keys differ. - */ - let windowDays = 6; - const nextWindow = () => `?days=${++windowDays}`; - - describe('auth', () => { - it('rejects a request with no secret', async () => { - await get('').expect(401); - }); - - it('rejects a request with the wrong secret', async () => { - await get('').set('x-internal-secret', 'wrong').expect(401); - }); - - it('rejects the conversions route without the secret', async () => { - await get('/conversions').expect(401); - }); - }); - - describe('validation', () => { - it('rejects an out-of-range window', async () => { - await get('?days=0').set('x-internal-secret', SECRET).expect(400); - await get('?days=400').set('x-internal-secret', SECRET).expect(400); - }); - - it('rejects an undeclared query parameter', async () => { - await get('?nope=1').set('x-internal-secret', SECRET).expect(400); - }); - }); - - describe('cohort', () => { - it('returns a healed user with their counts', async () => { - await seedHealedRequest(1); - await seedHealedRequest(2); - await seedHealedRequest(3); - - const res = await get(nextWindow()).set('x-internal-secret', SECRET).expect(200); - - expect(res.body).toHaveLength(1); - expect(res.body[0]).toMatchObject({ - email: OWNER_EMAIL, - name: 'Healed User', - healed_recent: 3, - healed_all: 3, - }); - }); - - it('counts older heals in the all-time total but not the window', async () => { - await seedHealedRequest(1); - await seedHealedRequest(40); - - const res = await get(nextWindow()).set('x-internal-secret', SECRET).expect(200); - - expect(res.body[0]).toMatchObject({ healed_recent: 1, healed_all: 2 }); - }); - - it('ignores a retry that succeeded on a request which still failed', async () => { - await seedHealedRequest(1, 'failed'); - - const res = await get(nextWindow()).set('x-internal-secret', SECRET).expect(200); - - expect(res.body).toEqual([]); - }); - - it('omits users whose email is unverified', async () => { - await seedHealedRequest(1); - await ds.query(`UPDATE "user" SET "emailVerified" = false WHERE id = $1`, [OWNER_ID]); - - const res = await get(nextWindow()).set('x-internal-secret', SECRET).expect(200); - - expect(res.body).toEqual([]); - }); - - it('omits internal addresses', async () => { - await seedHealedRequest(1); - await ds.query(`UPDATE "user" SET email = 'bruno@buddyweb.fr' WHERE id = $1`, [OWNER_ID]); - - const res = await get(nextWindow()).set('x-internal-secret', SECRET).expect(200); - - expect(res.body).toEqual([]); - }); - - it('returns an empty list when nothing was healed', async () => { - const res = await get(nextWindow()).set('x-internal-secret', SECRET).expect(200); - - expect(res.body).toEqual([]); - }); - }); - - describe('conversions', () => { - it('returns waitlist claims in the window, lowercased', async () => { - await ds.query( - `INSERT INTO waitlist_claims (id, email, source, claimed_at) VALUES ($1, $2, $3, now())`, - [uuid(), 'Converted@Example.com', 'cloud'], - ); - await ds.query( - `INSERT INTO waitlist_claims (id, email, source, claimed_at) - VALUES ($1, $2, $3, now() - interval '200 days')`, - [uuid(), 'ancient@example.com', 'cloud'], - ); - - const res = await get('/conversions?days=90').set('x-internal-secret', SECRET).expect(200); - - expect(res.body).toHaveLength(1); - expect(res.body[0]).toMatchObject({ email: 'converted@example.com', source: 'cloud' }); - }); - }); - - describe('signups', () => { - // Same cache caveat as the cohort block: distinct windows per assertion. - let signupWindow = 100; - const nextSignupWindow = () => `/signups?days=${++signupWindow}`; - - /** A corporate address, so the filters in crm-metrics.filters.ts keep it. */ - const addUser = async ( - id: string, - email: string, - opts: { tenantId?: string; name?: string } = {}, - ): Promise => { - await ds.query( - `INSERT INTO "user" (id, name, email, "emailVerified", "createdAt") - VALUES ($1, $2, $3, true, now() - interval '5 days')`, - [id, opts.name ?? 'Ada Lovelace', email], - ); - if (opts.tenantId) { - await ds.query(`UPDATE tenants SET owner_user_id = $1 WHERE id = $2`, [id, opts.tenantId]); - } - }; - - interface SignupRow { - email: string; - last_request_at: string | null; - } - - const signupFor = (body: unknown[], email: string): SignupRow | undefined => - (body as SignupRow[]).find((row) => row.email === email); - - it('returns a corporate signup that never sent a request', async () => { - await addUser('signup-1', 'ada@acmecorp.io', { tenantId: TEST_TENANT_ID }); - - const res = await get(nextSignupWindow()).set('x-internal-secret', SECRET).expect(200); - - expect(signupFor(res.body, 'ada@acmecorp.io')).toMatchObject({ - email: 'ada@acmecorp.io', - name: 'Ada Lovelace', - domain: 'acmecorp.io', - last_request_at: null, - has_traffic: false, - domain_signups: 1, - }); - }); - - it('keeps a verified user who never created a tenant', async () => { - // The regression this exists for. Tenants are created lazily on first - // agent creation, so this user has no tenant row at all — and is exactly - // who the campaign targets. An inner join would drop them silently. - await addUser('signup-orphan', 'grace@orphancorp.io'); - - const res = await get(nextSignupWindow()).set('x-internal-secret', SECRET).expect(200); - - expect(signupFor(res.body, 'grace@orphancorp.io')).toMatchObject({ - has_traffic: false, - last_request_at: null, - }); - }); - - it('marks a signup whose tenant has sent a request', async () => { - await addUser('signup-2', 'ada@trafficcorp.io', { tenantId: TEST_TENANT_ID }); - await ds.query( - `INSERT INTO requests (id, tenant_id, agent_id, agent_name, timestamp, status) - VALUES ($1, $2, $3, 'demo-agent', $4, 'success')`, - [uuid(), TEST_TENANT_ID, TEST_AGENT_ID, localSqlTimestamp(new Date())], - ); - - const res = await get(nextSignupWindow()).set('x-internal-secret', SECRET).expect(200); - const signup = signupFor(res.body, 'ada@trafficcorp.io'); - - expect(signup).toMatchObject({ has_traffic: true }); - expect(signup?.last_request_at).not.toBeNull(); - }); - - it('counts people sharing a domain', async () => { - await addUser('signup-3a', 'ada@teamcorp.io'); - await addUser('signup-3b', 'grace@teamcorp.io'); - - const res = await get(nextSignupWindow()).set('x-internal-secret', SECRET).expect(200); - - expect(signupFor(res.body, 'ada@teamcorp.io')).toMatchObject({ domain_signups: 2 }); - expect(signupFor(res.body, 'grace@teamcorp.io')).toMatchObject({ domain_signups: 2 }); - }); - - it('omits consumer mailboxes and role addresses', async () => { - await addUser('signup-4', 'ada@gmail.com'); - await addUser('signup-5', 'support@acmecorp.io'); - - const res = await get(nextSignupWindow()).set('x-internal-secret', SECRET).expect(200); - - expect(signupFor(res.body, 'ada@gmail.com')).toBeUndefined(); - expect(signupFor(res.body, 'support@acmecorp.io')).toBeUndefined(); - }); - - it('omits anyone who claimed the pivot waiting list', async () => { - // Waitlist people are contacted by hand. Match is case-insensitive, - // because the claim form and the auth record need not agree on case. - await addUser('signup-wl', 'ada@waitlisted.io'); - await ds.query( - `INSERT INTO waitlist_claims (id, email, source, claimed_at) VALUES ($1, $2, 'self-hosted', now())`, - [uuid(), 'Ada@Waitlisted.io'], - ); - - const res = await get(nextSignupWindow()).set('x-internal-secret', SECRET).expect(200); - - expect(signupFor(res.body, 'ada@waitlisted.io')).toBeUndefined(); - }); - - it('omits unverified signups', async () => { - await ds.query( - `INSERT INTO "user" (id, name, email, "emailVerified", "createdAt") - VALUES ($1, 'Un Verified', $2, false, now())`, - ['signup-6', 'ada@unverifiedcorp.io'], - ); - - const res = await get(nextSignupWindow()).set('x-internal-secret', SECRET).expect(200); - - expect(signupFor(res.body, 'ada@unverifiedcorp.io')).toBeUndefined(); - }); - - it('requires the secret', async () => { - await get('/signups').expect(401); - }); - }); -}); diff --git a/packages/backend/test/drop-saas-feature-schema-migration.e2e-spec.ts b/packages/backend/test/drop-saas-feature-schema-migration.e2e-spec.ts new file mode 100644 index 00000000..41458b76 --- /dev/null +++ b/packages/backend/test/drop-saas-feature-schema-migration.e2e-spec.ts @@ -0,0 +1,183 @@ +import { DataSource } from 'typeorm'; +import { entities, migrations } from '../src/database/data-source-definitions'; +import { DropSaasFeatureSchema1803300000000 } from '../src/database/migrations/1803300000000-DropSaasFeatureSchema'; +import { toLocalSqlTimestamp } from '../src/common/utils/postgres-sql'; +import { resolveE2eDatabaseUrl } from './e2e-db-guard'; + +const TENANT = 'saas-drop-tenant'; +const OTHER_TENANT = 'saas-drop-other-tenant'; +const AGENT = 'saas-drop-agent'; +const TIMESTAMP = toLocalSqlTimestamp(new Date(Date.now() - 60 * 1000)); + +interface SaasArtifacts { + waitlistTable: string | null; + errorPagesTable: string | null; + crmIndex: string | null; +} + +async function saasArtifacts(ds: DataSource): Promise { + const rows: SaasArtifacts[] = await ds.query(` + SELECT + to_regclass('waitlist_claims')::text AS "waitlistTable", + to_regclass('public_error_pages')::text AS "errorPagesTable", + to_regclass('"IDX_requests_autofix_healed"')::text AS "crmIndex" + `); + return rows[0]!; +} + +const NONE: SaasArtifacts = { waitlistTable: null, errorPagesTable: null, crmIndex: null }; + +async function runDropMigration(ds: DataSource, direction: 'up' | 'down'): Promise { + const queryRunner = ds.createQueryRunner(); + try { + // Mirror the deploy runner (transaction mode 'each'): SET LOCAL only + // applies inside a transaction. + await queryRunner.startTransaction(); + await new DropSaasFeatureSchema1803300000000()[direction](queryRunner); + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } +} + +async function insertConnection( + ds: DataSource, + id: string, + tenantId: string, + provider: string, +): Promise { + await ds.query( + `INSERT INTO "tenant_providers" + ("id", "tenant_id", "provider", "auth_type", "api_key_encrypted", "label", "is_active") + VALUES ($1, $2, $3, 'api_key', 'encrypted-placeholder', 'Default', true)`, + [id, tenantId, provider], + ); +} + +async function ids(ds: DataSource, sql: string): Promise { + const rows: Array<{ id: string }> = await ds.query(sql); + return rows.map((row) => row.id).sort(); +} + +describe('DropSaasFeatureSchema migration (e2e)', () => { + let ds: DataSource; + + beforeAll(async () => { + ds = new DataSource({ + type: 'postgres', + // Goes through the e2e guard: dropSchema below wipes the database. + url: resolveE2eDatabaseUrl(), + // The exact registries the deploy runner uses, not a glob. + entities, + migrations, + synchronize: false, + dropSchema: true, + logging: false, + }); + await ds.initialize(); + await ds.runMigrations({ transaction: 'each' }); + + for (const tenant of [TENANT, OTHER_TENANT]) { + await ds.query(`INSERT INTO "tenants" ("id", "name", "is_active") VALUES ($1, $1, true)`, [ + tenant, + ]); + } + await ds.query( + `INSERT INTO "agents" ("id", "tenant_id", "name", "display_name", "is_playground") + VALUES ($1, $2, $1, $1, false)`, + [AGENT, TENANT], + ); + }, 120_000); + + afterAll(async () => { + if (ds?.isInitialized) await ds.destroy(); + }); + + it('leaves no SaaS schema on a database migrated from empty', async () => { + expect(await saasArtifacts(ds)).toEqual(NONE); + const applied: Array<{ name: string }> = await ds.query( + `SELECT "name" FROM "migrations" WHERE "name" = 'DropSaasFeatureSchema1803300000000'`, + ); + expect(applied).toHaveLength(1); + }); + + it('reverts to the self-hosted tables and applies again', async () => { + await runDropMigration(ds, 'down'); + expect(await saasArtifacts(ds)).toEqual({ + waitlistTable: 'waitlist_claims', + errorPagesTable: 'public_error_pages', + crmIndex: null, + }); + // The restored tables accept the rows their old writers inserted. + await ds.query(`INSERT INTO "waitlist_claims" ("email") VALUES ('a@example.com')`); + await ds.query( + `INSERT INTO "public_error_pages" ("slug", "cluster_key", "provider", "title", "meta_description", "h1") + VALUES ('s', 'k', 'openai', 't', 'm', 'h')`, + ); + + await runDropMigration(ds, 'up'); + expect(await saasArtifacts(ds)).toEqual(NONE); + + // Idempotent: a second run on the cleaned schema is a no-op. + await runDropMigration(ds, 'up'); + expect(await saasArtifacts(ds)).toEqual(NONE); + }); + + it('drops a leftover cloud-mode CRM index', async () => { + await ds.query( + `CREATE INDEX "IDX_requests_autofix_healed" ON "requests" ("tenant_id", "timestamp") + INCLUDE ("status") WHERE "autofix_status" = 'retry_succeeded'`, + ); + expect((await saasArtifacts(ds)).crmIndex).toBe('"IDX_requests_autofix_healed"'); + + await runDropMigration(ds, 'up'); + + expect(await saasArtifacts(ds)).toEqual(NONE); + }); + + it('deletes Gemini Free connections and their grants, keeping everything else', async () => { + await insertConnection(ds, 'tp-gemini-free', TENANT, 'gemini-free'); + await insertConnection(ds, 'tp-gemini-free-other', OTHER_TENANT, 'Gemini-Free'); + await insertConnection(ds, 'tp-gemini', TENANT, 'gemini'); + await insertConnection(ds, 'tp-openai', TENANT, 'openai'); + for (const providerId of ['tp-gemini-free', 'tp-gemini', 'tp-openai']) { + await ds.query( + `INSERT INTO "agent_enabled_providers" ("agent_id", "tenant_provider_id") VALUES ($1, $2)`, + [AGENT, providerId], + ); + } + await ds.query( + `INSERT INTO "requests" ("id", "tenant_id", "agent_id", "timestamp", "status") + VALUES ('gemini-free-request', $1, $2, $3, 'success')`, + [TENANT, AGENT, TIMESTAMP], + ); + await ds.query( + `INSERT INTO "agent_messages" ( + "id", "request_id", "tenant_id", "agent_id", "agent_name", "timestamp", + "status", "input_tokens", "output_tokens", "cache_read_tokens", + "cache_creation_tokens", "provider", "tenant_provider_id" + ) + VALUES ('gemini-free-attempt', 'gemini-free-request', $1, $2, $2, $3, + 'success', 1, 1, 0, 0, 'gemini-free', 'tp-gemini-free')`, + [TENANT, AGENT, TIMESTAMP], + ); + + await runDropMigration(ds, 'up'); + + expect(await ids(ds, `SELECT "id" FROM "tenant_providers"`)).toEqual([ + 'tp-gemini', + 'tp-openai', + ]); + expect( + await ids(ds, `SELECT "tenant_provider_id" AS "id" FROM "agent_enabled_providers"`), + ).toEqual(['tp-gemini', 'tp-openai']); + // History survives with its provider label; only the dangling link goes. + const history: Array<{ provider: string; tenant_provider_id: string | null }> = await ds.query( + `SELECT "provider", "tenant_provider_id" FROM "agent_messages" WHERE "id" = 'gemini-free-attempt'`, + ); + expect(history).toEqual([{ provider: 'gemini-free', tenant_provider_id: null }]); + }); +}); diff --git a/packages/backend/test/helpers.ts b/packages/backend/test/helpers.ts index 25c06ad3..faec0b52 100644 --- a/packages/backend/test/helpers.ts +++ b/packages/backend/test/helpers.ts @@ -38,8 +38,6 @@ import { PlaygroundColumn } from '../src/entities/playground-column.entity'; import { ReasoningContentCacheEntry } from '../src/entities/reasoning-content-cache-entry.entity'; import { AgentEnabledProvider } from '../src/entities/agent-enabled-provider.entity'; import { BackfillState } from '../src/entities/backfill-state.entity'; -import { PublicErrorPage } from '../src/entities/public-error-page.entity'; -import { WaitlistClaim } from '../src/entities/waitlist-claim.entity'; import { CliAuthCode } from '../src/entities/cli-auth-code.entity'; import { AuthModule } from '../src/auth/auth.module'; import { HealthModule } from '../src/health/health.module'; @@ -52,8 +50,6 @@ import { RoutingModule } from '../src/routing/routing.module'; import { PlaygroundModule } from '../src/playground/playground.module'; import { CommonModule } from '../src/common/common.module'; import { SetupModule } from '../src/setup/setup.module'; -import { WaitlistModule } from '../src/waitlist/waitlist.module'; -import { CrmMetricsModule } from '../src/crm-metrics/crm-metrics.module'; import { ProviderModelFetcherService } from '../src/model-discovery/provider-model-fetcher.service'; import { resolveE2eDatabaseUrl } from './e2e-db-guard'; import { listenOnLoopback } from './loopback'; @@ -86,8 +82,6 @@ const entities = [ ReasoningContentCacheEntry, AgentEnabledProvider, BackfillState, - PublicErrorPage, - WaitlistClaim, CliAuthCode, ]; const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'; @@ -248,8 +242,6 @@ export async function createTestApp(options: CreateTestAppOptions = {}): Promise RoutingModule, PlaygroundModule, SetupModule, - WaitlistModule, - CrmMetricsModule, ], providers: [{ provide: APP_GUARD, useClass: MockSessionGuard }], }) diff --git a/packages/backend/test/removed-saas-routes.e2e-spec.ts b/packages/backend/test/removed-saas-routes.e2e-spec.ts new file mode 100644 index 00000000..0be6ed52 --- /dev/null +++ b/packages/backend/test/removed-saas-routes.e2e-spec.ts @@ -0,0 +1,53 @@ +/** + * The Manifest Cloud and marketing surfaces removed in #15 are gone: the + * waitlist, the CRM metrics feed, GitHub stars, free models, the public and + * internal error pages, and the managed Gemini Free provider. Their routes + * answer 404 and Gemini Free can no longer be connected. + */ +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { createTestApp, TEST_API_KEY } from './helpers'; + +let app: INestApplication; + +beforeAll(async () => { + app = await createTestApp(); +}); + +afterAll(async () => { + await app.close(); +}); + +describe('removed SaaS routes', () => { + it.each([ + ['post', '/api/v1/waitlist/pivot/claim'], + ['post', '/api/v1/waitlist/autofix/claim'], + ['get', '/api/v1/internal/crm-metrics'], + ['get', '/api/v1/internal/crm-metrics/signups'], + ['get', '/api/v1/github/stars'], + ['get', '/api/v1/free-models'], + ['get', '/api/v1/public/error-pages'], + ['get', '/api/v1/public/error-pages/some-slug'], + ['get', '/api/v1/internal/error-pages/clusters'], + ['post', '/api/v1/internal/error-pages'], + ['post', '/api/v1/providers/gemini-free/ensure'], + ] as const)('%s %s answers 404', async (method, path) => { + const res = await request(app.getHttpServer()) + [method](path) + .set('x-api-key', TEST_API_KEY) + .set('x-internal-secret', 'x'.repeat(40)) + .send({ email: 'someone@example.com' }); + + expect(res.status).toBe(404); + }); + + it('rejects connecting the managed Gemini Free provider', async () => { + const res = await request(app.getHttpServer()) + .post('/api/v1/routing/test-agent/providers') + .set('x-api-key', TEST_API_KEY) + .send({ provider: 'gemini-free', apiKey: 'sk-virtual-key-123456' }); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toContain('provider must be one of'); + }); +}); diff --git a/packages/backend/test/waitlist.e2e-spec.ts b/packages/backend/test/waitlist.e2e-spec.ts deleted file mode 100644 index f5ba8164..00000000 --- a/packages/backend/test/waitlist.e2e-spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import request from 'supertest'; -import { createTestApp } from './helpers'; - -let app: INestApplication; - -beforeAll(async () => { - app = await createTestApp(); -}); - -afterAll(async () => { - await app.close(); -}); - -beforeEach(async () => { - const ds = app.get(DataSource); - await ds.query(`DELETE FROM waitlist_claims`); -}); - -describe('POST /api/v1/waitlist/pivot/claim', () => { - it('stores a claim with a normalized email and the declared cloud source', async () => { - await request(app.getHttpServer()) - .post('/api/v1/waitlist/pivot/claim') - .send({ email: ' Jane@Example.COM ', source: 'cloud' }) - .expect(200) - .expect({ ok: true }); - - const ds = app.get(DataSource); - const rows = await ds.query(`SELECT email, source FROM waitlist_claims`); - expect(rows).toEqual([{ email: 'jane@example.com', source: 'cloud' }]); - }); - - it('defaults a sourceless claim to self-hosted', async () => { - await request(app.getHttpServer()) - .post('/api/v1/waitlist/pivot/claim') - .send({ email: 'a@b.co' }) - .expect(200); - - const ds = app.get(DataSource); - const rows = await ds.query(`SELECT source FROM waitlist_claims`); - expect(rows).toEqual([{ source: 'self-hosted' }]); - }); - - it('infers cloud for a sourceless same-origin claim', async () => { - await request(app.getHttpServer()) - .post('/api/v1/waitlist/pivot/claim') - .set('Host', 'claims.test') - .set('Origin', 'http://claims.test') - .send({ email: 'stale@b.co' }) - .expect(200); - - const ds = app.get(DataSource); - const rows = await ds.query(`SELECT source FROM waitlist_claims`); - expect(rows).toEqual([{ source: 'cloud' }]); - }); - - it('never overwrites a website row, while still answering ok', async () => { - const ds = app.get(DataSource); - await ds.query( - `INSERT INTO waitlist_claims (email, source, claimed_at) VALUES ($1, $2, now() - interval '1 day')`, - ['site@b.co', 'website'], - ); - - await request(app.getHttpServer()) - .post('/api/v1/waitlist/pivot/claim') - .send({ email: 'site@b.co', source: 'cloud' }) - .expect(200) - .expect({ ok: true }); - - const rows = await ds.query( - `SELECT source, claimed_at < now() - interval '1 hour' AS untouched FROM waitlist_claims`, - ); - expect(rows).toEqual([{ source: 'website', untouched: true }]); - }); - - it('rejects a source outside cloud/self-hosted', async () => { - await request(app.getHttpServer()) - .post('/api/v1/waitlist/pivot/claim') - .send({ email: 'a@b.co', source: 'website' }) - .expect(400); - }); - - it('dedupes by email on a second claim', async () => { - const server = app.getHttpServer(); - await request(server).post('/api/v1/waitlist/pivot/claim').send({ email: 'a@b.co' }); - await request(server).post('/api/v1/waitlist/pivot/claim').send({ email: 'A@b.co' }); - - const ds = app.get(DataSource); - const rows = await ds.query(`SELECT email FROM waitlist_claims`); - expect(rows).toHaveLength(1); - }); - - it('lets the latest claim overwrite the source of an already-registered email', async () => { - const ds = app.get(DataSource); - await ds.query( - `INSERT INTO waitlist_claims (email, source, claimed_at) VALUES ($1, $2, now() - interval '1 day')`, - ['old@b.co', 'self-hosted'], - ); - - await request(app.getHttpServer()) - .post('/api/v1/waitlist/pivot/claim') - .send({ email: 'old@b.co', source: 'cloud' }) - .expect(200); - - const rows = await ds.query( - `SELECT email, source, claimed_at > now() - interval '1 hour' AS refreshed FROM waitlist_claims`, - ); - expect(rows).toEqual([{ email: 'old@b.co', source: 'cloud', refreshed: true }]); - }); - - it('rejects an invalid email with 400 and stores nothing', async () => { - await request(app.getHttpServer()) - .post('/api/v1/waitlist/pivot/claim') - .send({ email: 'not-an-email' }) - .expect(400); - - const ds = app.get(DataSource); - const rows = await ds.query(`SELECT email FROM waitlist_claims`); - expect(rows).toHaveLength(0); - }); - - it('rejects unknown fields with 400', async () => { - await request(app.getHttpServer()) - .post('/api/v1/waitlist/pivot/claim') - .send({ email: 'a@b.co', newsletter: true }) - .expect(400); - }); - - it('keeps the legacy autofix claim as a storing-nothing no-op', async () => { - await request(app.getHttpServer()) - .post('/api/v1/waitlist/autofix/claim') - .send({ email: 'a@b.co' }) - .expect(200) - .expect({ ok: true }); - - const ds = app.get(DataSource); - const rows = await ds.query(`SELECT email FROM waitlist_claims`); - expect(rows).toHaveLength(0); - }); -}); diff --git a/packages/cli/src/provider-catalog.gen.ts b/packages/cli/src/provider-catalog.gen.ts index 3a8f70fa..c9da937e 100644 --- a/packages/cli/src/provider-catalog.gen.ts +++ b/packages/cli/src/provider-catalog.gen.ts @@ -95,12 +95,6 @@ export const PROVIDER_CATALOG: readonly ProviderCatalogEntry[] = [ aliases: ['google'], authTypes: ['api_key', 'subscription'], }, - { - id: 'gemini-free', - displayName: 'Gemini Free', - aliases: ['gemini free'], - authTypes: ['api_key'], - }, { id: 'kiro', displayName: 'Kiro', diff --git a/packages/shared/__tests__/error-taxonomy.spec.ts b/packages/shared/__tests__/error-taxonomy.spec.ts index dc867d9a..f8e0bd6e 100644 --- a/packages/shared/__tests__/error-taxonomy.spec.ts +++ b/packages/shared/__tests__/error-taxonomy.spec.ts @@ -89,7 +89,6 @@ describe('classifyMessageError', () => { ['local_provider_unavailable', 'config', 'local_provider_unavailable'], ['key_expired', 'config', 'auth'], ['limit_exceeded', 'policy', 'limit_exceeded'], - ['plan_request_limit_exceeded', 'policy', 'plan_request_limit_exceeded'], ['manifest_rate_limited', 'policy', 'rate_limit'], ['manifest_ip_rate_limited', 'policy', 'rate_limit'], ['manifest_concurrency_limited', 'policy', 'rate_limit'], @@ -220,7 +219,7 @@ describe('classifyMessageError', () => { ).toEqual({ error_origin: 'config', error_class: 'no_provider_key', superseded: true }); }); - it('keeps legacy 402 request-quota rows distinct from user-configured limits', () => { + it('classifies a 402 limit stub as a user-configured limit (no plan quota class)', () => { expect( classifyMessageError({ status: 'error', @@ -229,9 +228,10 @@ describe('classifyMessageError', () => { }), ).toEqual({ error_origin: 'policy', - error_class: 'plan_request_limit_exceeded', + error_class: 'limit_exceeded', superseded: false, }); + expect(ERROR_CLASSES).not.toContain('plan_request_limit_exceeded'); }); }); diff --git a/packages/shared/__tests__/providers.spec.ts b/packages/shared/__tests__/providers.spec.ts index c92013cd..e68def9e 100644 --- a/packages/shared/__tests__/providers.spec.ts +++ b/packages/shared/__tests__/providers.spec.ts @@ -158,14 +158,10 @@ describe('SHARED_PROVIDER_BY_ID', () => { expect(groq!.openRouterPrefixes).toEqual([]); }); - it('registers Gemini Free separately from the Google provider', () => { - const provider = SHARED_PROVIDER_BY_ID.get('gemini-free'); - expect(provider).toBeDefined(); - expect(provider!.displayName).toBe('Gemini Free'); - expect(provider!.aliases).toEqual(['gemini free']); - expect(provider!.openRouterPrefixes).toEqual([]); - expect(provider!.keyPlaceholder).toBe('sk-...'); - expect(provider!.managedFree).toBe(true); + it('does not register the removed managed Gemini Free provider', () => { + expect(SHARED_PROVIDER_BY_ID.get('gemini-free')).toBeUndefined(); + expect(SHARED_PROVIDER_BY_ID_OR_ALIAS.get('gemini free')).toBeUndefined(); + expect(SHARED_PROVIDER_BY_ID_OR_ALIAS.get('gemini-free')).toBeUndefined(); }); it('nvidia has no openRouter prefixes (native NIM /models is authoritative)', () => { diff --git a/packages/shared/src/error-taxonomy.ts b/packages/shared/src/error-taxonomy.ts index 7a74b719..6f467ed3 100644 --- a/packages/shared/src/error-taxonomy.ts +++ b/packages/shared/src/error-taxonomy.ts @@ -150,7 +150,6 @@ export const ERROR_CLASSES = [ 'subscription_credentials_unusable', 'local_provider_unavailable', 'limit_exceeded', - 'plan_request_limit_exceeded', 'internal', ] as const; export type ErrorClass = (typeof ERROR_CLASSES)[number]; @@ -181,10 +180,6 @@ const MANIFEST_REASON_TO_CLASSIFICATION: Record< }, key_expired: { origin: 'config', errorClass: 'auth' }, limit_exceeded: { origin: 'policy', errorClass: 'limit_exceeded' }, - plan_request_limit_exceeded: { - origin: 'policy', - errorClass: 'plan_request_limit_exceeded', - }, // Three distinct limits (per-user / per-IP / concurrency) that used to // collapse into `manifest_rate_limited`. That legacy reason stays mapped so // rows written before the split keep classifying. @@ -263,13 +258,6 @@ export function classifyMessageError(signals: MessageErrorSignals): MessageError ? MANIFEST_REASON_TO_CLASSIFICATION[signals.routingReason] : undefined; if (manifest) { - if (signals.routingReason === 'limit_exceeded' && signals.errorHttpStatus === 402) { - return { - error_origin: 'policy', - error_class: 'plan_request_limit_exceeded', - superseded, - }; - } return { error_origin: manifest.origin, error_class: manifest.errorClass, superseded }; } diff --git a/packages/shared/src/providers.ts b/packages/shared/src/providers.ts index 5bf63c74..f326ad8f 100644 --- a/packages/shared/src/providers.ts +++ b/packages/shared/src/providers.ts @@ -33,8 +33,6 @@ export interface SharedProviderEntry { minKeyLength: number; /** Placeholder shown in the UI's API-key input. */ keyPlaceholder: string; - /** Whether Manifest provisions this provider through its managed free-provider flow. */ - managedFree?: boolean; /** * Local servers whose dashboard tile deep-links into the local-server * detail view (LM Studio, llama.cpp). The tile connects them as a custom @@ -259,19 +257,6 @@ export const SHARED_PROVIDERS: readonly SharedProviderEntry[] = [ minKeyLength: 30, keyPlaceholder: 'API key', }, - { - id: 'gemini-free', - displayName: 'Gemini Free', - aliases: ['gemini free'], - openRouterPrefixes: [], - requiresApiKey: true, - localOnly: false, - color: '#4285f4', - keyPrefix: 'sk-', - minKeyLength: 10, - keyPlaceholder: 'sk-...', - managedFree: true, - }, { id: 'kiro', displayName: 'Kiro',