diff --git a/CHANGELOG.md b/CHANGELOG.md index 01f67225..dab94df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to LynxPrompt will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - February 2026 + +### BREAKING CHANGES +- **Self-hostable platform**: LynxPrompt is now a self-hostable platform. Companies can deploy on their own infrastructure. +- **Subscription tiers removed**: All features are available to all users. No more Free/Pro/Max/Teams tiers. +- **GlitchTip removed**: Error tracking is now optional via generic `SENTRY_DSN` env var. +- **ClickHouse removed**: Analytics engine fully removed from the stack. +- **Database consolidation**: Default deployment uses a single PostgreSQL database. + +### Added +- **Feature flags system**: All features configurable via environment variables (`ENABLE_AI`, `ENABLE_BLOG`, `ENABLE_STRIPE`, etc.). +- **Self-hosting docker-compose**: New `docker-compose.selfhost.yml` for minimal 1-Postgres deployment. +- **Custom branding**: `APP_NAME`, `APP_URL`, `APP_LOGO_URL` env vars for white-labeling. +- **Dynamic CSP headers**: Content-Security-Policy built from enabled services at startup. +- **Auto-migration on startup**: `entrypoint.sh` runs Prisma migrations automatically. +- **FeatureFlagsProvider**: Client-side React context for feature flag access. +- **Self-hosting documentation**: Comprehensive `/docs/self-hosting` guide with env var reference. +- **Health check enhancement**: `/api/health` now checks database connectivity. +- **Configurable auth**: Toggle GitHub/Google OAuth, Email, Passkeys, SSO, Turnstile, and user registration independently. +- **Configurable AI**: `ENABLE_AI`, `AI_MODEL` env vars control AI feature availability. +- **Configurable content modules**: Blog and support forum toggled via env vars (default off). +- **Registration control**: `ENABLE_USER_REGISTRATION=false` for invite-only instances. + +### Changed +- **Stripe is optional**: Marketplace payments controlled by `ENABLE_STRIPE`. When enabled, platform commission routes through LynxPrompt's Stripe by default. +- **SSO promoted to first-class**: No longer gated behind Teams subscription. +- **Team management simplified**: Billing removed, kept as organizational grouping. +- **Sentry is optional**: Only initializes when DSN is configured. +- **Umami script URL configurable**: `UMAMI_SCRIPT_URL` env var replaces hardcoded URL. +- **CLI simplified**: Removed plan display from `whoami` and `wizard`. +- **README rewritten**: Self-hostable platform positioning with deployment guide. + +### Removed +- **Pricing page**: `/pricing` route deleted. +- **Subscription billing**: Stripe subscription checkout, webhooks for subscriptions, plan change API. +- **TeamBillingRecord model**: Schema and all references removed. +- **Upgrade CTAs**: All "Upgrade to Teams" prompts removed from UI, CLI, and docs. +- **GlitchTip infrastructure**: Containers, Caddy entry, DNS record deleted. +- **ClickHouse**: All analytics code, env vars, docker-compose service removed. +- **Percona pg_tde**: Replaced with standard PostgreSQL in dev docker-compose. + +--- + ## [1.6.0] - February 2026 ### Added diff --git a/Dockerfile b/Dockerfile index 648283da..85919990 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,9 +52,11 @@ RUN npx prisma generate --config=prisma/prisma.config-app.ts & \ ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY ARG NEXT_PUBLIC_SENTRY_DSN +ARG UMAMI_SCRIPT_URL ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID} ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY} ENV NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN} +ENV UMAMI_SCRIPT_URL=${UMAMI_SCRIPT_URL} ENV NEXT_TELEMETRY_DISABLED=1 ENV TSC_COMPILE_ON_ERROR=true # Disable Turbopack for production builds (fixes font resolution issues in Next.js 16) diff --git a/README.md b/README.md index d4aa812c..cfd1b3b3 100644 --- a/README.md +++ b/README.md @@ -1,248 +1,189 @@ - LynxPrompt + LynxPrompt # LynxPrompt -> **Your universal AI config hub** +> **Self-hostable AI config management for teams and individuals** [![Website](https://img.shields.io/badge/🌐_Website-lynxprompt.com-6366f1?style=flat-square)](https://lynxprompt.com) [![npm](https://img.shields.io/npm/v/lynxprompt?style=flat-square&logo=npm&label=CLI)](https://www.npmjs.com/package/lynxprompt) -[![Status](https://img.shields.io/badge/🟒_Status-Operational-22c55e?style=flat-square)](https://status.lynxprompt.com) [![License](https://img.shields.io/badge/πŸ“œ_License-GPL--3.0-blue?style=flat-square)](LICENSE) [![GitHub Stars](https://img.shields.io/github/stars/GeiserX/LynxPrompt?style=flat-square&logo=github)](https://github.com/GeiserX/LynxPrompt) +[![Docker](https://img.shields.io/badge/🐳_Docker-Self--Host-2496ED?style=flat-square)](https://github.com/GeiserX/LynxPrompt) --- ## What is LynxPrompt? -LynxPrompt is a web platform and CLI for generating and sharing **AI IDE configuration files** and **commands (workflows)**. Instead of manually writing `AGENTS.md`, `CLAUDE.md`, or `.cursor/rules/` for every project, use our wizard or browse community blueprints. +LynxPrompt is a **self-hostable platform** for managing AI IDE configuration files β€” `AGENTS.md`, `.cursor/rules/`, `CLAUDE.md`, slash commands, and 30+ other formats. Deploy it on your own infrastructure and give your team a central hub to create, share, and standardize AI coding assistant configurations across every project. -- **AI Configs** β€” Rules and instructions that define how AI assistants behave in your project -- **Commands** β€” Slash commands (`.cursor/commands/`, `.claude/commands/`) that execute specific workflows on demand +Instead of manually writing configuration files for every project and every AI tool, use LynxPrompt to: -**🌐 Live at:** [lynxprompt.com](https://lynxprompt.com) +- **Generate** configs through an interactive wizard (web or CLI) +- **Share** blueprints through a private or federated marketplace +- **Standardize** AI behavior across projects with reusable templates +- **Export** to any supported format with one click ---- - -## Supported AI IDEs & Tools - -LynxPrompt supports **30+ AI coding assistants** across all major platforms: - -### Popular Platforms - -| Platform | Config File | Status | -|----------|-------------|:------:| -| **Cursor** | `.cursor/rules/` | βœ… | -| **Claude Code** | `CLAUDE.md` / `AGENTS.md` | βœ… | -| **GitHub Copilot** | `.github/copilot-instructions.md` | βœ… | -| **Windsurf** | `.windsurfrules` | βœ… | -| **Zed** | `.zed/instructions.md` | βœ… | -| **Aider** | `AIDER.md` | βœ… | -| **Antigravity** (Google) | `GEMINI.md` | βœ… | - -### Editor Extensions - -| Platform | Config File | Status | -|----------|-------------|:------:| -| **Cline** | `.clinerules` | βœ… | -| **Roo Code** | `.roo/rules/` | βœ… | -| **Continue.dev** | `.continue/config.json` | βœ… | -| **Sourcegraph Cody** | `.cody/config.json` | βœ… | -| **Amazon Q** | `.amazonq/rules/` | βœ… | -| **Tabnine** | `.tabnine.yaml` | βœ… | -| **Supermaven** | `.supermaven/config.json` | βœ… | -| **CodeGPT** | `.codegpt/config.json` | βœ… | -| **Augment Code** | `.augment/rules/` | βœ… | -| **Kilo Code** | `.kilocode/rules/` | βœ… | -| **JetBrains Junie** | `.junie/guidelines.md` | βœ… | - -### CLI Tools & Other - -| Platform | Config File | Status | -|----------|-------------|:------:| -| **Goose** | `.goosehints` | βœ… | -| **Warp AI** | `WARP.md` | βœ… | -| **Gemini CLI** | `GEMINI.md` | βœ… | -| **OpenHands** | `.openhands/microagents/repo.md` | βœ… | -| **Kiro** (AWS) | `.kiro/steering/` | βœ… | -| **Trae AI** (ByteDance) | `.trae/rules/` | βœ… | -| **Firebase Studio** | `.idx/` | βœ… | -| **Void** | `.void/config.json` | βœ… | -| **Open Code** | `opencode.json` | βœ… | - -### Universal Format - -Use `AGENTS.md` as a universal format that works with: -- Claude Code, Aider, Devin, SWE-agent, and most AI coding tools -- Readable by humans and AI alike -- Future-proof and editor-agnostic - ---- - -## Supported Commands (Workflows) - -Commands are slash commands/workflows you invoke with `/command-name`. LynxPrompt supports creating and sharing commands for: - -| Platform | Command Location | Status | -|----------|------------------|:------:| -| **Cursor** | `.cursor/commands/` | βœ… | -| **Claude Code** | `.claude/commands/` | βœ… | -| **Windsurf** | `.windsurf/workflows/` | βœ… | -| **GitHub Copilot** | `.github/copilot/prompts/` | βœ… | -| **Continue.dev** | `.continue/prompts/` | βœ… | -| **Open Code** | `.opencode/commands/` | βœ… | +LynxPrompt is **free and open-source**. Self-host it for personal use, or deploy it within your organization to enforce coding standards, share institutional knowledge, and ensure consistent AI assistant behavior across your engineering teams. A hosted instance is also available at [lynxprompt.com](https://lynxprompt.com) for those who prefer not to self-host. --- -## Features - -### [Configuration Wizard](https://lynxprompt.com/docs/wizard) +## Key Features -The heart of LynxPrompt β€” a step-by-step generator that creates AI config files tailored to your project: +### Universal AI Config Hub -- πŸ” **Auto-detect** β€” Detects your tech stack, frameworks, databases, and repo info from GitHub/GitLab URLs -- 🧩 **Dynamic Sections** β€” Tech stack, code style, testing, CI/CD, branch strategy, security rules, and more -- ⚠️ **Sensitive Data Detection** β€” Warns about potential secrets or credentials before you share -- πŸ’Ύ **Wizard Drafts** β€” Auto-saves your progress so you can continue later -- πŸ”„ **Multiple Formats** β€” Export to any supported AI IDE format with one click -- πŸ‘» **Guest Mode** β€” Use the wizard without signing up (login required to save/share) +Supports **30+ AI coding assistants** β€” Cursor, Claude Code, GitHub Copilot, Windsurf, Zed, Aider, Gemini CLI, Cline, Roo Code, Amazon Q, JetBrains Junie, and many more. Write once, export to any format. -### [Blueprint Marketplace](https://lynxprompt.com/docs/marketplace) +### Blueprint Marketplace -Browse, share, and sell AI configurations and commands: +Internal or federated marketplace for sharing AI configurations and slash commands within your organization. Browse, search, favorite, and reuse blueprints across teams. -- πŸ“‚ **Two Types** β€” AI Configs (rules/instructions) and Commands (slash commands/workflows) -- 🏷️ **Categories & Tags** β€” Filter by category, platform, and tags -- πŸ”Ž **Search** β€” Full-text search across all blueprints -- ❀️ **Favorites** β€” Save blueprints to your favorites list -- πŸ’° **Paid Blueprints** β€” Sell your blueprints and earn from your expertise -- πŸ‘€ **Public Profiles** β€” Author pages with social links and all their blueprints +### Interactive Wizard -### [Blueprints, Commands & Workflows](https://lynxprompt.com/docs/blueprints) +Step-by-step config generator available on both web and CLI. Auto-detects your tech stack, frameworks, and repo structure from GitHub/GitLab URLs. Supports template variables, monorepo hierarchies, and draft auto-saving. -Both AI configs and slash commands share powerful features: +### Configurable Authentication -- πŸ“ **Template Variables** β€” Use `[[VARIABLE]]` placeholders for dynamic inputs -- πŸ“œ **Versioning** β€” Track changes with changelogs, update published blueprints -- πŸ”„ **Multi-format Export** β€” Download for any supported IDE or transform to a different format +Flexible auth to fit your environment: -### [Teams](https://lynxprompt.com/docs/marketplace/pricing) +- **OAuth** β€” GitHub, Google +- **Email** β€” Passwordless magic link login +- **Passkeys** β€” WebAuthn biometric/hardware key authentication +- **SSO** β€” SAML, OIDC, and LDAP for enterprise identity providers -Collaborate on AI configurations and commands within your organization: +### Optional AI-Powered Editing -- πŸ‘₯ **Team Blueprints** β€” Share blueprints only with team members -- πŸ’³ **Centralized Billing** β€” Single invoice for the entire team -- πŸ€– **AI Editing** β€” AI-assisted blueprint creation and editing +Enable AI-assisted blueprint creation and editing with your own Anthropic API key. Entirely optional β€” works fully without it. -### [Monorepo Support](https://lynxprompt.com/docs/blueprints/hierarchy) +### Full REST API + CLI Tool -First-class support for monorepo architectures: +Programmatic access for automation and CI/CD integration. Generate API tokens, fetch blueprints, search, and download via REST. The CLI (`lynxp`) mirrors the full web platform feature set. -- 🌳 **Hierarchy** β€” Define parent-child relationships between AGENTS.md files -- πŸ” **Auto-detect** β€” CLI detects AGENTS.md files in subfolders and offers bulk hierarchy creation +### Self-Hostable with Docker Compose -### [API Access](https://lynxprompt.com/docs/api) +Single `docker compose up` to run the entire stack. PostgreSQL included. Auto-runs database migrations on startup. Toggle every feature via environment variables. -Programmatic access for automation and integrations: +--- -- 🌐 **Public API** β€” Fetch blueprints, search, and download via REST API -- πŸ”‘ **API Tokens** β€” Generate tokens for authenticated access +## Quick Start (Self-Hosting) -### [Seller Payouts](https://lynxprompt.com/docs/marketplace/payouts) +```bash +# 1. Create a .env file +cat > .env < { + if (!action || action === "show") { + console.log(); + console.log(chalk.cyan("βš™οΈ LynxPrompt CLI Configuration")); + console.log(); + console.log( + chalk.white(" API URL: ") + chalk.green(getApiUrl()), + ); + console.log( + chalk.white(" Config: ") + chalk.gray(getConfigPath()), + ); + if (process.env.LYNXPROMPT_API_URL) { + console.log( + chalk.gray( + " (API URL overridden by LYNXPROMPT_API_URL env var)", + ), + ); + } + console.log(); + return; + } + + if (action === "set-url") { + if (!valueArg) { + console.error( + chalk.red("Usage: lynxp config set-url "), + ); + process.exit(1); + } + try { + new URL(valueArg); + } catch { + console.error(chalk.red(`Invalid URL: ${valueArg}`)); + process.exit(1); + } + const clean = valueArg.replace(/\/+$/, ""); + setApiUrl(clean); + console.log( + chalk.green("βœ“") + + ` API URL set to ${chalk.cyan(clean)}`, + ); + console.log( + chalk.gray(" Run 'lynxp login' to authenticate with this instance."), + ); + return; + } + + if (action === "reset-url") { + setApiUrl("https://lynxprompt.com"); + console.log( + chalk.green("βœ“") + + " API URL reset to " + + chalk.cyan("https://lynxprompt.com"), + ); + return; + } + + if (action === "path") { + console.log(getConfigPath()); + return; + } + + console.error(chalk.red(`Unknown config action: ${action}`)); + console.error(chalk.gray("Available: show, set-url , reset-url, path")); + process.exit(1); +} diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index d03cdae1..44745191 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -130,38 +130,25 @@ interface UserInfo { } function displayWelcome(user: UserInfo): void { - const plan = user.plan?.toUpperCase() || "FREE"; const name = user.name || user.email.split("@")[0]; - // Plan colors and emojis - simplified to Users and Teams only - const planConfig: Record string; emoji: string; badge: string }> = { - FREE: { color: chalk.gray, emoji: "πŸ†“", badge: "Users" }, - TEAMS: { color: chalk.cyan, emoji: "πŸ‘₯", badge: "Teams" }, - }; - - // Map legacy PRO/MAX to FREE (Users) - const effectivePlan = plan === "PRO" || plan === "MAX" ? "FREE" : plan; - const config = planConfig[effectivePlan] || planConfig.FREE; - const W = 46; // inner width (46 to make square with 48 total) + const W = 46; const b = chalk.bold; const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - s.length)); console.log(); console.log(b("β”Œ" + "─".repeat(W) + "┐")); console.log(b("β”‚") + " ".repeat(W) + b("β”‚")); - console.log(b("β”‚") + pad(` ${config.emoji} Welcome to LynxPrompt CLI!`, W) + b("β”‚")); + console.log(b("β”‚") + pad(" 🐱 Welcome to LynxPrompt CLI!", W) + b("β”‚")); console.log(b("β”‚") + " ".repeat(W) + b("β”‚")); console.log(b("β”‚") + pad(` User: ${name}`, W) + b("β”‚")); - console.log(b("β”‚") + pad(` Plan: ${config.badge}`, W) + b("β”‚")); console.log(b("β”‚") + " ".repeat(W) + b("β”‚")); console.log(b("β””" + "─".repeat(W) + "β”˜")); console.log(); - // Show capabilities based on plan console.log(chalk.bold("πŸ“‹ Your CLI Capabilities:")); console.log(); - // All users get these console.log(chalk.green(" βœ“") + " " + chalk.white("lynxp wizard") + chalk.gray(" - Interactive config wizard")); console.log(chalk.green(" βœ“") + " " + chalk.white("lynxp list") + chalk.gray(" - List your blueprints")); console.log(chalk.green(" βœ“") + " " + chalk.white("lynxp pull ") + chalk.gray(" - Download blueprints")); @@ -171,14 +158,6 @@ function displayWelcome(user: UserInfo): void { console.log(chalk.green(" βœ“") + " " + chalk.white("lynxp whoami") + chalk.gray(" - Show account info")); console.log(chalk.green(" βœ“") + " " + chalk.white("lynxp logout") + chalk.gray(" - Sign out of CLI")); - // Plan-specific features - Teams users get extra features - if (effectivePlan === "TEAMS") { - console.log(); - console.log(chalk.cyan(" ⚑") + " " + chalk.white("AI-powered editing") + chalk.gray(" - AI assistant for configs")); - console.log(chalk.cyan(" πŸ‘₯") + " " + chalk.white("Team blueprints") + chalk.gray(" - Share with your team")); - console.log(chalk.cyan(" πŸ‘₯") + " " + chalk.white("SSO integration") + chalk.gray(" - Enterprise authentication")); - } - console.log(); console.log(chalk.gray("Token stored securely. Run ") + chalk.cyan("lynxp --help") + chalk.gray(" to see all commands.")); console.log(); diff --git a/cli/src/commands/whoami.ts b/cli/src/commands/whoami.ts index 65712b3d..062ff5d3 100644 --- a/cli/src/commands/whoami.ts +++ b/cli/src/commands/whoami.ts @@ -20,7 +20,7 @@ export async function whoamiCommand(): Promise { id: user.id, email: user.email, name: user.name, - plan: user.subscription.plan, + plan: user.plan, }); console.log(); @@ -33,14 +33,6 @@ export async function whoamiCommand(): Promise { if (user.display_name) { console.log(` ${chalk.gray("Display:")} ${user.display_name}`); } - console.log(` ${chalk.gray("Plan:")} ${formatPlan(user.subscription.plan)}`); - if (user.subscription.status) { - console.log(` ${chalk.gray("Status:")} ${user.subscription.status}`); - } - if (user.subscription.current_period_end) { - const endDate = new Date(user.subscription.current_period_end); - console.log(` ${chalk.gray("Renews:")} ${endDate.toLocaleDateString()}`); - } console.log(); console.log(` ${chalk.gray("Blueprints:")} ${user.stats.blueprints_count}`); console.log(` ${chalk.gray("Member since:")} ${new Date(user.created_at).toLocaleDateString()}`); @@ -52,8 +44,7 @@ export async function whoamiCommand(): Promise { if (error.statusCode === 401) { console.error(chalk.red("Your session has expired. Please run 'lynxprompt login' again.")); } else if (error.statusCode === 403) { - console.error(chalk.red("API access error. Please check your subscription.")); - console.error(chalk.gray("Visit https://lynxprompt.com/pricing for plan details.")); + console.error(chalk.red("API access error.")); } else { console.error(chalk.red(`Error: ${error.message}`)); } @@ -64,19 +55,6 @@ export async function whoamiCommand(): Promise { } } -function formatPlan(plan: string): string { - const planColors: Record string> = { - FREE: chalk.gray, - TEAMS: chalk.cyan, - }; - - // Map legacy PRO/MAX to FREE display - const displayPlan = plan === "PRO" || plan === "MAX" ? "FREE" : plan; - const displayName = displayPlan === "FREE" ? "Users" : displayPlan; - const colorFn = planColors[displayPlan] || chalk.white; - return colorFn(displayName); -} - diff --git a/cli/src/commands/wizard.ts b/cli/src/commands/wizard.ts index 7ea04741..7844657a 100644 --- a/cli/src/commands/wizard.ts +++ b/cli/src/commands/wizard.ts @@ -1142,18 +1142,13 @@ async function runWizardWithDraftProtection(options: WizardOptions): Promise")} ${chalk.gray("Point CLI to a self-hosted instance")} + ${chalk.white("$ lynxp config reset-url")} ${chalk.gray("Reset to https://lynxprompt.com")} + ${chalk.white("$ lynxp config path")} ${chalk.gray("Show config file location")} + ${chalk.gray("Docs: https://lynxprompt.com/docs/cli")} ` ); diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml new file mode 100644 index 00000000..2071d735 --- /dev/null +++ b/docker-compose.selfhost.yml @@ -0,0 +1,48 @@ +# docker-compose.selfhost.yml β€” Minimal self-hosted LynxPrompt deployment +# Usage: docker compose -f docker-compose.selfhost.yml up -d +# +# Required env vars (set in .env or environment): +# NEXTAUTH_SECRET β€” generate with: openssl rand -base64 32 +# ADMIN_EMAIL β€” your email (auto-promoted to superadmin) +# +# Optional: +# DB_PASSWORD β€” database password (default: changeme) +# APP_URL β€” your domain (default: http://localhost:3000) + +services: + postgres: + image: postgres:18-alpine + restart: unless-stopped + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_DB: lynxprompt + POSTGRES_USER: lynxprompt + POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U lynxprompt -d lynxprompt"] + interval: 5s + timeout: 5s + retries: 5 + + lynxprompt: + image: drumsergio/lynxprompt:2.0.0 + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL_APP: postgresql://lynxprompt:${DB_PASSWORD:-changeme}@postgres:5432/lynxprompt?schema=public + DATABASE_URL_USERS: postgresql://lynxprompt:${DB_PASSWORD:-changeme}@postgres:5432/lynxprompt?schema=public + DATABASE_URL_BLOG: postgresql://lynxprompt:${DB_PASSWORD:-changeme}@postgres:5432/lynxprompt?schema=public + DATABASE_URL_SUPPORT: postgresql://lynxprompt:${DB_PASSWORD:-changeme}@postgres:5432/lynxprompt?schema=public + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + NEXTAUTH_URL: ${APP_URL:-http://localhost:3000} + APP_URL: ${APP_URL:-http://localhost:3000} + SUPERADMIN_EMAIL: ${ADMIN_EMAIL:-} + NODE_ENV: production + +volumes: + pgdata: diff --git a/docker-compose.yml b/docker-compose.yml index 4de70325..efc034b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,14 +13,11 @@ services: # ========================================================================== # APP DATABASE - System templates, platforms, languages - # Uses Percona PostgreSQL 18 with pg_tde for Transparent Data Encryption # ========================================================================== postgres-app: - image: percona/percona-distribution-postgresql:18 + image: postgres:18-alpine container_name: lynxprompt-postgres-app restart: unless-stopped - command: > - -c shared_preload_libraries=pg_tde logging: driver: "json-file" options: @@ -30,9 +27,9 @@ services: POSTGRES_DB: lynxprompt_app POSTGRES_USER: lynxprompt_app POSTGRES_PASSWORD: ${POSTGRES_APP_PASSWORD:-dev_app_password_change_me} + PGDATA: /data/pgdata volumes: - - postgres_app_data:/data/db - - postgres_app_keyring:/keyring + - postgres_app_data:/data networks: - lynxprompt healthcheck: @@ -51,14 +48,11 @@ services: # ========================================================================== # USER DATABASE - Golden data (users, sessions, projects) - # Uses Percona PostgreSQL 18 with pg_tde for Transparent Data Encryption # ========================================================================== postgres-users: - image: percona/percona-distribution-postgresql:18 + image: postgres:18-alpine container_name: lynxprompt-postgres-users restart: unless-stopped - command: > - -c shared_preload_libraries=pg_tde logging: driver: "json-file" options: @@ -68,9 +62,9 @@ services: POSTGRES_DB: lynxprompt_users POSTGRES_USER: lynxprompt_users POSTGRES_PASSWORD: ${POSTGRES_USERS_PASSWORD:-dev_users_password_change_me} + PGDATA: /data/pgdata volumes: - - postgres_users_data:/data/db - - postgres_users_keyring:/keyring + - postgres_users_data:/data networks: - lynxprompt healthcheck: @@ -234,20 +228,37 @@ services: TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-} # Anthropic API for AI features (configure in .env) ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + AI_MODEL: ${AI_MODEL:-claude-3-5-haiku-latest} # Sentry/GlitchTip (configure in .env) SENTRY_DSN: ${SENTRY_DSN:-} NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-} + # Analytics + UMAMI_SCRIPT_URL: ${UMAMI_SCRIPT_URL:-} + NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-} + # Feature flags + ENABLE_GITHUB_OAUTH: ${ENABLE_GITHUB_OAUTH:-false} + ENABLE_GOOGLE_OAUTH: ${ENABLE_GOOGLE_OAUTH:-false} + ENABLE_EMAIL_AUTH: ${ENABLE_EMAIL_AUTH:-true} + ENABLE_PASSKEYS: ${ENABLE_PASSKEYS:-true} + ENABLE_TURNSTILE: ${ENABLE_TURNSTILE:-false} + ENABLE_SSO: ${ENABLE_SSO:-false} + ENABLE_USER_REGISTRATION: ${ENABLE_USER_REGISTRATION:-true} + ENABLE_AI: ${ENABLE_AI:-false} + ENABLE_BLOG: ${ENABLE_BLOG:-false} + ENABLE_SUPPORT_FORUM: ${ENABLE_SUPPORT_FORUM:-false} + ENABLE_STRIPE: ${ENABLE_STRIPE:-false} + # Branding + APP_NAME: ${APP_NAME:-LynxPrompt} + APP_URL: ${APP_URL:-http://localhost:3000} + APP_LOGO_URL: ${APP_LOGO_URL:-} # App NODE_ENV: ${NODE_ENV:-development} - MOCK: ${MOCK:-false} SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL:-} UPLOAD_DIR: /data/uploads/blog volumes: postgres_app_data: - postgres_app_keyring: postgres_users_data: - postgres_users_keyring: postgres_blog_data: postgres_support_data: uploads_data: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3ca1c5fe..5b0d9778 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,6 +2,167 @@ This document tracks planned features, improvements, and business decisions for LynxPrompt. +--- + +## πŸ”₯ v2.0 β€” Self-Hostable Platform Transformation (February 2026) + +### Vision + +LynxPrompt v2.0 pivots from a SaaS product to a **self-hostable platform** that companies can deploy on their own premises. The core product becomes open and deployable, with monetization shifting to marketplace commission and optional services. + +Companies deploy their own instance to manage AI IDE configurations (AGENTS.md, .cursor/rules/, etc.) internally. All features are available to all users β€” no tier gating. + +### Key Decisions (Confirmed) + +#### 1. Remove Pricing & Teams Subscription Tier +- Delete the `/pricing` page entirely +- Remove `SubscriptionPlan` enum and all tier-gating logic +- All features (AI, SSO, wizard, API) available to everyone +- Keep `Team` model for organizational grouping (useful for companies) +- Remove `TeamBillingRecord`, Stripe subscription fields from Team +- Remove all "Upgrade to Teams" CTAs from web UI, CLI, and docs + +#### 2. Stripe: Optional with Platform Commission +- `ENABLE_STRIPE=false` by default β€” when disabled, all blueprints are free +- When enabled, **the default Stripe account is LynxPrompt's (hardcoded)** +- Any deployment that enables paid marketplace blueprints routes payments through LynxPrompt's Stripe account β€” LynxPrompt earns the 30% platform commission +- Companies that want their own Stripe account must provide their own keys explicitly +- This is the monetization model for the open-source platform: free to deploy, LynxPrompt earns from marketplace transactions across all federated instances + +#### 3. Remove GlitchTip (Error Tracking) +- Remove all GlitchTip-specific code and hardcoded DSNs +- Make Sentry integration optional β€” works if `SENTRY_DSN` is set, skip entirely if not +- Delete GlitchTip infrastructure (containers, Caddy entry, DNS record, gitea repo) +- Keep `@sentry/nextjs` as optional for companies that want their own Sentry/GlitchTip + +#### 4. Remove ClickHouse (Analytics) +- Remove all ClickHouse code (analytics lib, API routes, env vars, docker-compose service) +- ClickHouse was used for trending templates, search stats, wizard funnel +- Keep ClickHouse as a wizard database option (it's a valid DB users might configure) + +#### 5. Feature Toggles via Environment Variables +All features configurable via env vars for maximum deployment flexibility: + +**Auth:** +- `ENABLE_GITHUB_OAUTH=false` β€” show/hide GitHub login +- `ENABLE_GOOGLE_OAUTH=false` β€” show/hide Google login +- `ENABLE_EMAIL_AUTH=true` β€” magic link / email login +- `ENABLE_PASSKEYS=true` β€” WebAuthn passkeys +- `ENABLE_TURNSTILE=false` β€” Cloudflare Turnstile CAPTCHA +- `ENABLE_SSO=false` β€” SAML/OIDC/LDAP (promoted from Teams-only to first-class) +- `ENABLE_USER_REGISTRATION=true` β€” set false for invite-only instances + +**AI:** +- `ENABLE_AI=false` β€” master toggle for all AI features (editing, wizard assistant) +- `ANTHROPIC_API_KEY` β€” required when AI enabled +- `AI_MODEL=claude-3-5-haiku-latest` β€” configurable model + +**Content:** +- `ENABLE_BLOG=false` β€” blog nav item and routes +- `ENABLE_SUPPORT_FORUM=false` β€” support forum nav item and routes + +**Marketplace:** +- `ENABLE_STRIPE=false` β€” paid blueprints and Stripe checkout + +**Analytics:** +- `UMAMI_SCRIPT_URL` β€” configurable Umami script URL +- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` β€” Umami website ID (already exists) + +**Branding:** +- `APP_NAME=LynxPrompt` β€” app title in header, emails, meta tags +- `APP_URL=http://localhost:3000` β€” base URL +- `APP_LOGO_URL` β€” custom logo URL + +#### 6. Database Consolidation +- **Default**: single PostgreSQL database (all 4 Prisma schemas share one DB) +- **Advanced**: users can split into separate databases via different `DATABASE_URL_*` vars +- Remove Percona pg_tde from development docker-compose β€” use standard `postgres:18-alpine` everywhere +- Keep Percona pg_tde only in production/dev-server docker-compose (gitea) where it's already deployed +- New `docker-compose.selfhost.yml`: 1 Postgres + 1 LynxPrompt container + +#### 7. Dynamic CSP Headers +- Build Content-Security-Policy in `proxy.ts` based on enabled services +- Only include Umami, Turnstile, Sentry domains when those services are configured +- Cleaner security headers for minimal deployments + +#### 8. Hardcoded URL Audit +- Replace all `lynxprompt.com` hardcoded references with `APP_URL` env var +- Affects: email templates, fallback URLs, image references, API fallbacks, structured data + +#### 9. Health Check Enhancement +- `/api/health` checks actual DB connectivity, not just returns 200 +- Critical for container orchestration and monitoring + +#### 10. Auto-Migration on Startup +- `entrypoint.sh` runs `prisma migrate deploy` for all schemas on container start +- Idempotent β€” safe for every restart +- Companies don't need to run migrations manually + +### Federated Interconnect (Planned v2.x) + +A decentralized blueprint sharing network across LynxPrompt instances. + +**Concept:** +- Each instance opts in via `ENABLE_FEDERATION=true` (default `true`) +- Instances register themselves in a central registry (GitHub Gist or lightweight discovery service) +- Each instance publishes its public blueprints via a standardized API +- When browsing the marketplace, users see blueprints from all federated instances +- Each blueprint shows its origin domain (e.g., "from lynxprompt.com", "from acme-corp.internal") +- Results are lazy-loaded to keep the marketplace responsive + +**Architecture:** +- Central registry: a JSON file (Gist or static endpoint) listing participating instances + - Each instance writes its URL, name, and public API endpoint + - Registry is polled periodically by each instance +- Federation API: `/api/v1/federation/blueprints` β€” returns public blueprints +- Blueprint metadata includes `origin_domain`, `origin_instance_name` +- Rate limiting and API key exchange for security +- Instance verification (domain ownership check) + +**Stripe Integration with Federation:** +- When a user purchases a paid blueprint from a remote federated instance, the payment routes through the origin instance's Stripe (which defaults to LynxPrompt's account) +- This means LynxPrompt earns commission on all marketplace transactions across the entire federation + +**Testing Plan:** +- Use prod (lynxprompt.com) and dev (dev.lynxprompt.com) as the first two federated instances +- Dev instance should show prod blueprints with "from lynxprompt.com" label +- Validate lazy loading, search across instances, and cross-instance purchases + +**Implementation Phases:** +1. Define federation API schema and protocol +2. Build the central registry mechanism +3. Implement federation client (fetching remote blueprints) +4. UI for federated blueprints (origin badges, lazy loading) +5. Cross-instance purchasing via Stripe +6. Admin controls (allowlist/blocklist federated instances) + +### Documentation Changes for v2.0 + +- Delete: pricing page, pricing docs, billing FAQ +- Rewrite: AI features docs (remove "Teams-only" language) +- Rewrite: marketplace selling docs (Stripe optional) +- Add: self-hosting guide with env var reference +- Add: `docker-compose.selfhost.yml` quick start +- Rewrite: README.md (self-hostable platform positioning) +- Update: CLI docs (configurable server URL for self-hosted) + +### Infrastructure Changes for v2.0 + +- Delete GlitchTip stack from watchtower (containers + gitea repo + Caddy entry + DNS) +- Update prod docker-compose (gitea/watchtower/lynxprompt/) with all new feature flags enabled +- Update dev docker-compose (gitea/geiserback/lynxprompt-dev/) with all new feature flags enabled +- Bump image tag to `drumsergio/lynxprompt:2.0.0` +- Remove Sentry DSN env vars from prod/dev docker-compose + +### Version + +- Web app: 2.0.0 +- CLI: 2.0.0 +- Single PR: `feat/v2.0-self-hosting` β†’ `main` +- Preservation branch: `sergio-before-internationalization` (captures pre-v2.0 state) + +--- + ## 🏒 Business & Legal Foundation ### Entity & Operator @@ -61,7 +222,7 @@ Per EU Consumer Rights Directive, digital content can waive 14-day withdrawal IF - [x] GDPR Article 6 legal basis (Contract + Legitimate Interest) - [x] Physical address disclosure - [x] "No DPO appointed" statement -- [x] Third-party processors detailed (GitHub, Google, Stripe, Umami, Anthropic, GlitchTip) +- [x] Third-party processors detailed (GitHub, Google, Stripe, Umami, Anthropic, ~~GlitchTip~~ removed in v2.0) - [x] Umami: self-hosted in EU, cookieless, legitimate interest basis - [x] International transfers + SCCs - [x] No automated decision-making statement @@ -126,6 +287,7 @@ Per EU Consumer Rights Directive, digital content can waive 14-day withdrawal IF - [x] Project scaffolding with Next.js 15, React 19, TypeScript - [x] PostgreSQL database with Prisma ORM (dual-database architecture) +- [x] ClickHouse for analytics (self-hosted EU) - [x] Umami analytics (self-hosted EU, cookieless) - [x] Authentication with NextAuth.js (GitHub, Google, Magic Link, Passkeys) - [x] Homepage with platform carousel @@ -295,23 +457,11 @@ Based on GitHub's recommended agents, offer one-click presets: | `@api-agent` | Builds API endpoints | `npm run dev`, `curl` tests | Modify routes, ask before schema changes | | `@deploy-agent` | Handles dev deployments | `npm run build`, `docker build` | Only deploy to dev, require approval | -#### Wizard Tiers (Feature Gating) βœ… IMPLEMENTED - -| Feature | Free | Pro | Max | -| -------------------------------------- | ---- | --- | --- | -| Basic wizard steps | βœ… | βœ… | βœ… | -| Intermediate wizard steps | ❌ | βœ… | βœ… | -| Advanced wizard steps | ❌ | ❌ | βœ… | -| All community blueprints (including paid) | ❌ | ❌ | βœ… | +#### ~~Wizard Tiers (Feature Gating)~~ β€” REMOVED in v2.0 -**Wizard Step Tiers (Updated):** -- **Basic** (Free): Project Info, Tech Stack, Platforms, Generate -- **Intermediate** (Pro): + Repository, Release Strategy, Commands -- **Advanced** (Max): + Persona, Code Style, Boundaries, Agent Presets +> **v2.0 Change:** All wizard steps are available to all users. No tier gating. -**Admin Privileges:** -- ADMIN and SUPERADMIN roles automatically receive MAX tier (no payment required) -- Displayed as "Admin" badge in billing section +All wizard steps (Basic, Intermediate, Advanced) are accessible to everyone. #### User Dashboard @@ -436,7 +586,7 @@ When downloading, user sees: #### Template Analytics -- [ ] Track template downloads/usage +- [ ] Track template downloads/usage ~~(ClickHouse)~~ (alternative TBD post-v2.0) - [ ] Show trending templates - [ ] Usage statistics for template authors - [ ] Revenue reports for paid templates @@ -445,58 +595,22 @@ When downloading, user sees: ## πŸ’° Monetization Strategy -### Subscription Tiers - -| Tier | Monthly | Annual (10% off) | Features | -| --------- | -------------- | ---------------- | -------------------------------------------------------------- | -| **Free** | €0/month | €0/year | Basic templates, limited wizard features | -| **Pro** | €5/month | €54/year | Intermediate repo wizards, priority support | -| **Max** | €20/month | €216/year | Advanced wizards + ALL community prompts (including paid ones) | -| **Teams** | €10/seat/month | €108/seat/year | Everything in Max + team features, SSO, centralized billing | +### ~~Subscription Tiers~~ β€” REMOVED in v2.0 -#### Key Subscription Rules +> **v2.0 Change:** All subscription tiers (Free/Pro/Max/Teams) have been removed. All features are available to all users. The monetization model is now marketplace commission (see v2.0 section above). -- **Users (free)**: Full wizard access, all platforms, API, sell blueprints -- **Teams**: Everything in Users + AI features, SSO, team-shared blueprints +Previously: -#### Billing Intervals +| Tier | Status | +|------|--------| +| Free | Now the only tier β€” all features included | +| Pro | Removed | +| Max | Removed | +| Teams | Removed (Team org model kept for grouping, billing removed) | -- **Monthly**: Can be canceled anytime. Access continues until end of billing period. -- **Annual**: 10% discount. Cannot be canceled mid-cycle (yearly commitment). Access continues until year ends. +### ~~Teams Tier~~ β€” REMOVED in v2.0 -### Teams Tier Details βœ… IMPLEMENTED - -| Setting | Value | -|---------|-------| -| Price | €10/seat/month | -| Minimum seats | 3 | -| Maximum seats | Unlimited | -| Color | Teal/Cyan gradient | -| AI usage limit | €5/user/month | - -#### Teams Features - -- **Team-shared blueprints**: Share blueprints privately within your team -- **Blueprint visibility**: Private, Team, or Public options -- **SSO authentication**: SAML 2.0, OpenID Connect, LDAP/Active Directory -- **Centralized billing**: One admin pays for all seats -- **Active user billing**: Only pay for users who logged in during the billing period -- **Roles**: ADMIN (full control) and MEMBER (team access) -- **Multiple admins**: Teams can have multiple administrators -- **Pro-rated billing**: Adding seats mid-cycle charges prorated amount -- **Credits**: Unused seats generate credits for next cycle - -#### Teams Billing Logic - -``` -Monthly Bill = €10 Γ— MAX(active_users, 3) - -Where: -- active_users = users who logged in during the billing period -- Minimum 3 seats always billed (even if only 2 active) -- Mid-cycle additions: (€10 / 30 days) Γ— days_remaining Γ— new_seats -- Credits: (billed_seats - active_seats) Γ— €10 β†’ next cycle -``` +> Team management (members, invitations, SSO) is kept as an organizational feature but is no longer a paid tier. SSO is promoted to a first-class feature available to all instances via `ENABLE_SSO` env var. ### Template Marketplace Pricing @@ -733,7 +847,7 @@ POST /api/generate - Generate config files from wizard data - [ ] Redis for caching/sessions - [ ] S3/R2 for file storage (template assets, user uploads) -- [x] GlitchTip error tracking (self-hosted at glitchtip.lynxprompt.com) +- [x] ~~GlitchTip error tracking~~ β†’ **Removed in v2.0** (Sentry optional via env var) - [x] Status page (Uptime Kuma) at status.lynxprompt.com - [ ] CDN for static assets - [ ] Database backups automation @@ -741,8 +855,9 @@ POST /api/generate - Generate config files from wizard data ### Current Infrastructure -- [x] PostgreSQL (4 databases: app, users, blog, support) -- [x] Umami (self-hosted EU, cookieless analytics) +- [x] PostgreSQL (4 databases: app, users, blog, support) β€” **v2.0: single DB default, multi-DB optional** +- [x] ~~ClickHouse (self-hosted EU, analytics)~~ β†’ **Removed in v2.0** +- [x] Umami (self-hosted EU, cookieless analytics) β€” **v2.0: configurable via env var** - [x] Docker deployment with GitOps (Portainer) - [x] Cloudflare DDoS protection and WAF - [x] TLS 1.3 encryption in transit @@ -758,7 +873,7 @@ POST /api/generate - Generate config files from wizard data - [ ] Annual third-party penetration test - [ ] Bug bounty program (HackerOne or similar) -> **Note:** GlitchTip is preferred over Sentry for self-hosted error tracking. It keeps all data in EU. +> **Note (v2.0):** GlitchTip and ClickHouse have been removed. Error tracking is optional via generic Sentry DSN env var. Companies can point to their own Sentry/GlitchTip instance if desired. --- @@ -1477,7 +1592,7 @@ This enables: - Multi-language support (i18n) - only when user base justifies - **Cryptocurrency payments (Bitcoin, Ethereum, USDC) via Coinbase Commerce** - Custom integrations (Slack, Teams notifications) -- White-label solutions for enterprise +- ~~White-label solutions for enterprise~~ β†’ **Partially addressed in v2.0** (custom branding via `APP_NAME`, `APP_LOGO_URL` env vars) ### Completed Ideas βœ… - ~~Annual subscription discount~~ β†’ 10% discount (~1.2 months free) diff --git a/entrypoint.sh b/entrypoint.sh index a3edc694..71eeac6f 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -3,7 +3,11 @@ set -e echo "Starting LynxPrompt..." -# Note: Database migrations should be run manually or via a separate init container -# Tables are expected to already exist. Use prisma db push locally before deploying. +echo "Running database migrations..." +npx prisma migrate deploy --config=prisma/prisma.config-app.ts 2>&1 || echo "App DB migration: no pending migrations" +npx prisma migrate deploy --config=prisma/prisma.config-users.ts 2>&1 || echo "Users DB migration: no pending migrations" +npx prisma migrate deploy --config=prisma/prisma.config-blog.ts 2>&1 || echo "Blog DB migration: no pending migrations" +npx prisma migrate deploy --config=prisma/prisma.config-support.ts 2>&1 || echo "Support DB migration: no pending migrations" +echo "Migrations complete." exec node server.js diff --git a/env.example b/env.example index 77ca6b78..c52bee54 100644 --- a/env.example +++ b/env.example @@ -1,111 +1,94 @@ # ============================================================================= -# LynxPrompt Environment Variables +# LynxPrompt v2.0 Environment Variables # Copy this file to .env and fill in your values # ============================================================================= # ============================================================================= -# DATABASES (Four-database architecture) +# DATABASE # ============================================================================= -# For docker-compose: passwords must match POSTGRES_*_PASSWORD below -# For local dev without docker: adjust hosts/ports as needed +# By default, all schemas use a single PostgreSQL database. +# For advanced setups, you can point each to a different database. +DATABASE_URL_APP=postgresql://lynxprompt:changeme@localhost:5432/lynxprompt?schema=public +DATABASE_URL_USERS=postgresql://lynxprompt:changeme@localhost:5432/lynxprompt?schema=public +DATABASE_URL_BLOG=postgresql://lynxprompt:changeme@localhost:5432/lynxprompt?schema=public +DATABASE_URL_SUPPORT=postgresql://lynxprompt:changeme@localhost:5432/lynxprompt?schema=public -# APP Database - System templates, platforms, languages (can be recreated from seed) -DATABASE_URL_APP=postgresql://lynxprompt_app:dev_app_password_change_me@localhost:5432/lynxprompt_app?schema=public - -# USERS Database - User data, sessions, preferences (GOLDEN - critical backups!) -DATABASE_URL_USERS=postgresql://lynxprompt_users:dev_users_password_change_me@localhost:5433/lynxprompt_users?schema=public - -# BLOG Database - Blog posts and content -DATABASE_URL_BLOG=postgresql://lynxprompt_blog:dev_blog_password_change_me@localhost:5434/lynxprompt_blog?schema=public - -# SUPPORT Database - Feedback forum data (bugs, suggestions, votes) -DATABASE_URL_SUPPORT=postgresql://lynxprompt_support:dev_support_password_change_me@localhost:5435/lynxprompt_support?schema=public - -# Database passwords for docker-compose (must match URLs above) -POSTGRES_APP_PASSWORD=dev_app_password_change_me -POSTGRES_USERS_PASSWORD=dev_users_password_change_me -POSTGRES_BLOG_PASSWORD=dev_blog_password_change_me -POSTGRES_SUPPORT_PASSWORD=dev_support_password_change_me +# Password for docker-compose PostgreSQL container +POSTGRES_PASSWORD=changeme # ============================================================================= -# NEXTAUTH - Authentication +# AUTHENTICATION # ============================================================================= -# Generate with: openssl rand -base64 32 -NEXTAUTH_SECRET=your-super-secret-key-change-this-in-production +# Required: generate with `openssl rand -base64 32` +NEXTAUTH_SECRET=your-super-secret-key-change-this NEXTAUTH_URL=http://localhost:3000 -# GitHub OAuth -# Create at: https://github.com/settings/developers -# - Set Homepage URL to: http://localhost:3000 -# - Set Authorization callback URL to: http://localhost:3000/api/auth/callback/github +# GitHub OAuth (optional, set ENABLE_GITHUB_OAUTH=true to activate) GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -# Google OAuth (optional) -# Create at: https://console.cloud.google.com/apis/credentials -# - Set Authorized redirect URIs to: http://localhost:3000/api/auth/callback/google +# Google OAuth (optional, set ENABLE_GOOGLE_OAUTH=true to activate) GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -# ============================================================================= -# EMAIL / SMTP (for magic links) -# ============================================================================= -# For Gmail: Enable 2FA and create App Password at https://myaccount.google.com/apppasswords +# Email / SMTP (for magic link login) SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= -SMTP_FROM=noreply@lynxprompt.com +SMTP_FROM=noreply@example.com SMTP_FROM_NAME=LynxPrompt # ============================================================================= -# APPLICATION +# FEATURE FLAGS (all have sensible defaults for self-hosting) # ============================================================================= -NODE_ENV=development -MOCK=false +# Auth methods +ENABLE_GITHUB_OAUTH=false +ENABLE_GOOGLE_OAUTH=false +ENABLE_EMAIL_AUTH=true +ENABLE_PASSKEYS=true +ENABLE_TURNSTILE=false +ENABLE_SSO=false +ENABLE_USER_REGISTRATION=true -# Auto-promote this email to SUPERADMIN on first sign-in -SUPERADMIN_EMAIL= - -# ============================================================================= -# STRIPE - Payment Processing -# ============================================================================= -# Get keys from: https://dashboard.stripe.com/apikeys -STRIPE_SECRET_KEY=sk_test_... -STRIPE_WEBHOOK_SECRET=whsec_... +# AI features (requires ANTHROPIC_API_KEY) +ENABLE_AI=false +AI_MODEL=claude-3-5-haiku-latest +ANTHROPIC_API_KEY= -# Monthly price IDs (create in Stripe Dashboard) -STRIPE_PRICE_PRO_MONTHLY=price_... -STRIPE_PRICE_MAX_MONTHLY=price_... -STRIPE_PRICE_TEAMS_SEAT_MONTHLY=price_... +# Content modules +ENABLE_BLOG=false +ENABLE_SUPPORT_FORUM=false -# Annual price IDs (10% discount: Pro €54/year, Max €216/year, Teams €324/seat/year) -STRIPE_PRICE_PRO_ANNUAL=price_... -STRIPE_PRICE_MAX_ANNUAL=price_... -STRIPE_PRICE_TEAMS_SEAT_ANNUAL=price_... +# Marketplace payments (Stripe) +ENABLE_STRIPE=false +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= # ============================================================================= -# CLOUDFLARE TURNSTILE - Anti-bot Protection +# BRANDING # ============================================================================= -# Get keys from: https://dash.cloudflare.com/turnstile -NEXT_PUBLIC_TURNSTILE_SITE_KEY= -TURNSTILE_SECRET_KEY= +APP_NAME=LynxPrompt +APP_URL=http://localhost:3000 +APP_LOGO_URL= # ============================================================================= -# AI FEATURES +# ADMIN # ============================================================================= -# Anthropic API for AI-powered blueprint editing (MAX tier) -ANTHROPIC_API_KEY= +# Auto-promote this email to SUPERADMIN on first sign-in +SUPERADMIN_EMAIL= # ============================================================================= -# ERROR TRACKING (optional) +# OPTIONAL SERVICES # ============================================================================= -# Sentry or GlitchTip DSN +# Cloudflare Turnstile (requires ENABLE_TURNSTILE=true) +NEXT_PUBLIC_TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= + +# Error tracking (Sentry-compatible, e.g. GlitchTip) SENTRY_DSN= NEXT_PUBLIC_SENTRY_DSN= -# ============================================================================= -# ANALYTICS (optional) -# ============================================================================= -# Umami (privacy-focused, self-hosted) +# Analytics (Umami, self-hosted) +UMAMI_SCRIPT_URL= NEXT_PUBLIC_UMAMI_WEBSITE_ID= diff --git a/next.config.ts b/next.config.ts index f3676568..f64dd5a0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,15 @@ import type { NextConfig } from "next"; +function getAppUrlHostname(): string | null { + const appUrl = process.env.APP_URL || process.env.NEXTAUTH_URL; + if (!appUrl) return null; + try { + return new URL(appUrl).hostname; + } catch { + return null; + } +} + const nextConfig: NextConfig = { // Enable React strict mode for highlighting potential problems reactStrictMode: true, @@ -27,6 +37,9 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "lynxprompt.com", }, + ...(getAppUrlHostname() && getAppUrlHostname() !== "lynxprompt.com" && getAppUrlHostname() !== "localhost" + ? [{ protocol: "https" as const, hostname: getAppUrlHostname()! }] + : []), ], }, diff --git a/package-lock.json b/package-lock.json index 847e9978..695f716c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lynxprompt", - "version": "1.6.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lynxprompt", - "version": "1.6.0", + "version": "2.0.0", "license": "GPL-3.0", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 0443ec9f..88176206 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lynxprompt", - "version": "1.6.1", + "version": "2.0.0", "private": true, "description": "LynxPrompt - Transform your development setup into a mouse-click experience with AI IDE configuration generation", "author": "LynxPrompt Contributors", diff --git a/prisma/migrations/20260225110000_remove_stripe_billing/migration.sql b/prisma/migrations/20260225110000_remove_stripe_billing/migration.sql new file mode 100644 index 00000000..9d63c827 --- /dev/null +++ b/prisma/migrations/20260225110000_remove_stripe_billing/migration.sql @@ -0,0 +1,25 @@ +-- Remove TeamBillingRecord table +DROP TABLE IF EXISTS "TeamBillingRecord"; + +-- Remove Stripe subscription fields from User +ALTER TABLE "User" DROP COLUMN IF EXISTS "stripeCustomerId"; +ALTER TABLE "User" DROP COLUMN IF EXISTS "stripeSubscriptionId"; +ALTER TABLE "User" DROP COLUMN IF EXISTS "subscriptionStatus"; +ALTER TABLE "User" DROP COLUMN IF EXISTS "subscriptionInterval"; +ALTER TABLE "User" DROP COLUMN IF EXISTS "currentPeriodEnd"; +ALTER TABLE "User" DROP COLUMN IF EXISTS "cancelAtPeriodEnd"; + +-- Remove indexes on dropped User columns +DROP INDEX IF EXISTS "User_stripeCustomerId_key"; +DROP INDEX IF EXISTS "User_stripeSubscriptionId_key"; + +-- Remove Stripe subscription fields from Team +ALTER TABLE "Team" DROP COLUMN IF EXISTS "stripeCustomerId"; +ALTER TABLE "Team" DROP COLUMN IF EXISTS "stripeSubscriptionId"; +ALTER TABLE "Team" DROP COLUMN IF EXISTS "subscriptionInterval"; +ALTER TABLE "Team" DROP COLUMN IF EXISTS "billingCycleStart"; +ALTER TABLE "Team" DROP COLUMN IF EXISTS "aiUsageLimitPerUser"; + +-- Remove indexes on dropped Team columns +DROP INDEX IF EXISTS "Team_stripeCustomerId_key"; +DROP INDEX IF EXISTS "Team_stripeSubscriptionId_key"; diff --git a/prisma/migrations/create-lynxprompt-team.ts b/prisma/migrations/create-lynxprompt-team.ts index 827341b9..4feafa75 100644 --- a/prisma/migrations/create-lynxprompt-team.ts +++ b/prisma/migrations/create-lynxprompt-team.ts @@ -124,8 +124,6 @@ async function createLynxPromptTeam() { name: TEAM_NAME, slug: TEAM_SLUG, maxSeats: 10, - subscriptionInterval: "annual", - aiUsageLimitPerUser: 10000, members: { create: { userId: adminUser.id, @@ -147,7 +145,6 @@ async function createLynxPromptTeam() { console.log(` Name: ${team.name}`); console.log(` Slug: ${team.slug}`); console.log(` Max Seats: ${team.maxSeats}`); - console.log(` Billing: ${team.subscriptionInterval}`); console.log(` Members:`); team.members.forEach((m) => { console.log(` - ${m.user.email} (${m.role})`); diff --git a/prisma/schema-users.prisma b/prisma/schema-users.prisma index 814eed61..9c16b4f8 100644 --- a/prisma/schema-users.prisma +++ b/prisma/schema-users.prisma @@ -92,16 +92,10 @@ model User { socialMastodon String? // Mastodon handle (e.g., @user@instance.social) socialDiscord String? // Discord username - // Subscription & Billing (Stripe) - stripeCustomerId String? @unique // Stripe Customer ID - stripeSubscriptionId String? // Stripe Subscription ID + // Subscription (billing removed - all users get full access) subscriptionPlan SubscriptionPlan @default(FREE) - subscriptionStatus String? // active, canceled, past_due, unpaid, trialing - subscriptionInterval String? // monthly or annual - currentPeriodEnd DateTime? // When current billing period ends - cancelAtPeriodEnd Boolean @default(false) - // AI Usage Tracking (for MAX users) + // AI Usage Tracking aiTokensUsedThisPeriod Int @default(0) // Total tokens used this billing period aiUsageResetAt DateTime? // When to reset token count (aligned with billing period) aiLastRequestAt DateTime? // For rate limiting @@ -116,7 +110,7 @@ model User { // Seller Payout Settings paypalEmail String? // PayPal email for receiving payouts - // Activity tracking (for Teams billing - track last login) + // Activity tracking lastLoginAt DateTime? // Last time user logged in (updated on session creation) // Relations @@ -139,8 +133,6 @@ model User { @@index([email]) @@index([role]) - @@index([stripeCustomerId]) - @@index([subscriptionPlan]) @@index([lastLoginAt]) } @@ -151,10 +143,8 @@ enum UserRole { } enum SubscriptionPlan { - FREE // Standard user - all features except AI - TEAMS // Teams user - all features including AI, SSO, team sharing - // DEPRECATED: PRO and MAX no longer used - all users get full wizard access - // Keeping enum values would require database migration, so we just use FREE for all non-Teams users + FREE // All users - everyone gets full access + TEAMS // Kept for DB backwards compatibility (no longer used for gating) } // ============================================================================ @@ -191,15 +181,8 @@ model Team { slug String @unique // URL-friendly identifier (e.g., "acme-corp") logo String? // Team logo URL (uploaded image) - // Billing - handled by team admin(s) - stripeCustomerId String? @unique // Stripe Customer ID for the team - stripeSubscriptionId String? // Stripe Subscription ID (per-seat) - subscriptionInterval String? // monthly or annual - billingCycleStart DateTime? // When current billing cycle started - maxSeats Int @default(3) // Prepaid seats (minimum 3) - - // AI Usage Settings (Teams get higher limits) - aiUsageLimitPerUser Int @default(500) // €5 = 500 cents max AI spend per user/month + // Organization limits + maxSeats Int @default(50) // Max members in the team createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -208,12 +191,10 @@ model Team { members TeamMember[] invitations TeamInvitation[] ssoConfig TeamSSOConfig? - billingRecords TeamBillingRecord[] blueprints UserTemplate[] // Team-shared blueprints purchases BlueprintPurchase[] // Templates purchased by team members (shared with all) @@index([slug]) - @@index([stripeCustomerId]) } model TeamMember { @@ -289,37 +270,6 @@ model TeamSSOConfig { team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) } -model TeamBillingRecord { - id String @id @default(cuid()) - teamId String - - // Billing period - periodStart DateTime - periodEnd DateTime - - // Seat counts for this period - totalSeats Int // Max seats configured - activeSeats Int // Users who logged in during period - billedSeats Int // MAX(activeSeats, 3) - what was actually charged - - // Financial - amountBilled Int // Total amount in cents (billedSeats Γ— €10) - currency String @default("EUR") - stripeInvoiceId String? // Reference to Stripe invoice - - // Credits (for unused seats from previous cycle) - creditApplied Int @default(0) // Credit in cents applied to this invoice - creditGenerated Int @default(0) // Credit generated for next cycle (if active < billed) - - createdAt DateTime @default(now()) - - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - - @@index([teamId]) - @@index([periodStart]) - @@index([periodEnd]) -} - // ============================================================================ // PASSKEYS (WebAuthn Authenticators) // ============================================================================ diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 765b145b..2359b336 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -1,19 +1,10 @@ -// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - import * as Sentry from "@sentry/nextjs"; -Sentry.init({ - // GlitchTip DSN - configured via environment variable - dsn: process.env.SENTRY_DSN, - - // Only enable in production - enabled: process.env.NODE_ENV === "production", - - // Performance Monitoring - adjust based on traffic - tracesSampleRate: 0.1, // 10% of transactions - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, -}); - +if (process.env.SENTRY_DSN && process.env.NODE_ENV === "production") { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + enabled: true, + tracesSampleRate: 0.1, + debug: false, + }); +} diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 9ae6bb5d..9f2e3301 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -1,54 +1,41 @@ -// This file configures the initialization of Sentry on the server. -// The config you add here will be used whenever the server handles a request. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - import * as Sentry from "@sentry/nextjs"; -Sentry.init({ - // GlitchTip DSN - configured via environment variable - dsn: process.env.SENTRY_DSN, - - // Only enable in production - enabled: process.env.NODE_ENV === "production", - - // Performance Monitoring - adjust based on traffic - tracesSampleRate: 0.1, // 10% of transactions - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - - // Filter out sensitive data from error reports - beforeSend(event) { - // Remove potentially sensitive headers - if (event.request?.headers) { - const sensitiveHeaders = [ - "authorization", - "cookie", - "x-api-key", - "x-auth-token", - ]; - sensitiveHeaders.forEach((header) => { - if (event.request?.headers?.[header]) { - event.request.headers[header] = "[Filtered]"; - } - }); - } - - // Remove potentially sensitive data from breadcrumbs - if (event.breadcrumbs) { - event.breadcrumbs = event.breadcrumbs.map((breadcrumb) => { - if ( - breadcrumb.data && - typeof breadcrumb.data === "object" && - "password" in breadcrumb.data - ) { - breadcrumb.data.password = "[Filtered]"; - } - return breadcrumb; - }); - } - - return event; - }, -}); - +if (process.env.SENTRY_DSN && process.env.NODE_ENV === "production") { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + enabled: true, + tracesSampleRate: 0.1, + debug: false, + + beforeSend(event) { + if (event.request?.headers) { + const sensitiveHeaders = [ + "authorization", + "cookie", + "x-api-key", + "x-auth-token", + ]; + sensitiveHeaders.forEach((header) => { + if (event.request?.headers?.[header]) { + event.request.headers[header] = "[Filtered]"; + } + }); + } + + if (event.breadcrumbs) { + event.breadcrumbs = event.breadcrumbs.map((breadcrumb) => { + if ( + breadcrumb.data && + typeof breadcrumb.data === "object" && + "password" in breadcrumb.data + ) { + breadcrumb.data.password = "[Filtered]"; + } + return breadcrumb; + }); + } + + return event; + }, + }); +} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index e85950f1..b8f930d5 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import Image from "next/image"; import type { Metadata } from "next"; +import { APP_NAME, APP_URL } from "@/lib/feature-flags"; import { Button } from "@/components/ui/button"; import { Heart, @@ -39,7 +40,7 @@ export const metadata: Metadata = { "Born from frustrationβ€”AI IDE configs are tedious. LynxPrompt makes it effortless.", }, alternates: { - canonical: "https://lynxprompt.com/about", + canonical: `${APP_URL}/about`, }, }; diff --git a/src/app/api/ai/edit-blueprint/route.ts b/src/app/api/ai/edit-blueprint/route.ts index 7a450a98..91a4496a 100644 --- a/src/app/api/ai/edit-blueprint/route.ts +++ b/src/app/api/ai/edit-blueprint/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; -import { authenticateRequest, isTeams } from "@/lib/api-auth"; +import { authenticateRequest } from "@/lib/api-auth"; import { prismaUsers } from "@/lib/db-users"; +import { ENABLE_AI, AI_MODEL } from "@/lib/feature-flags"; import Anthropic from "@anthropic-ai/sdk"; // Cost tracking constants (in tokens) @@ -53,6 +54,13 @@ STRICT RULES: Output ONLY the formatted content that can be added to an AI configuration file.`; export async function POST(request: Request) { + if (!ENABLE_AI) { + return NextResponse.json( + { error: "AI features are not enabled on this instance" }, + { status: 404 } + ); + } + try { // Authenticate via session OR Bearer token const auth = await authenticateRequest(request); @@ -64,17 +72,13 @@ export async function POST(request: Request) { ); } - // Check if user is Teams subscriber (AI features are Teams-only) const user = await prismaUsers.user.findUnique({ where: { id: auth.user.id }, select: { - subscriptionPlan: true, - role: true, aiTokensUsedThisPeriod: true, aiUsageResetAt: true, aiLastRequestAt: true, aiRequestsThisMinute: true, - currentPeriodEnd: true, }, }); @@ -82,13 +86,6 @@ export async function POST(request: Request) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } - if (!isTeams(auth.user)) { - return NextResponse.json( - { error: "AI editing is only available for Teams subscribers" }, - { status: 403 } - ); - } - // Check rate limiting const now = new Date(); const lastRequest = user.aiLastRequestAt; @@ -116,7 +113,7 @@ export async function POST(request: Request) { // Check usage reset (aligned with billing period) let tokensUsed = user.aiTokensUsedThisPeriod; - const resetAt = user.aiUsageResetAt || user.currentPeriodEnd; + const resetAt = user.aiUsageResetAt; if (resetAt && now > resetAt) { // Reset usage at billing period end @@ -193,7 +190,7 @@ export async function POST(request: Request) { const anthropic = new Anthropic({ apiKey }); const response = await anthropic.messages.create({ - model: "claude-3-5-haiku-latest", + model: AI_MODEL, max_tokens: isWizardMode ? 200 : 8000, // Use content blocks with cache_control for prompt caching system: [ @@ -254,7 +251,7 @@ export async function POST(request: Request) { aiTokensUsedThisPeriod: tokensUsed + costUnits, aiLastRequestAt: now, aiRequestsThisMinute: requestsThisMinute, - aiUsageResetAt: user.currentPeriodEnd || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), // Default: 30 days + aiUsageResetAt: user.aiUsageResetAt || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), }, }); diff --git a/src/app/api/auth/passkey/authenticate/options/route.ts b/src/app/api/auth/passkey/authenticate/options/route.ts index be76a186..2a5da9ce 100644 --- a/src/app/api/auth/passkey/authenticate/options/route.ts +++ b/src/app/api/auth/passkey/authenticate/options/route.ts @@ -1,12 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; import { webAuthnConfig } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; +import { ENABLE_PASSKEYS } from "@/lib/feature-flags"; import { generateAuthenticationOptions, type AuthenticatorTransportFuture, } from "@simplewebauthn/server"; export async function POST(request: NextRequest) { + if (!ENABLE_PASSKEYS) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + try { const { email } = await request.json(); diff --git a/src/app/api/auth/passkey/authenticate/verify/route.ts b/src/app/api/auth/passkey/authenticate/verify/route.ts index f18326fc..efdd9c90 100644 --- a/src/app/api/auth/passkey/authenticate/verify/route.ts +++ b/src/app/api/auth/passkey/authenticate/verify/route.ts @@ -2,9 +2,14 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions, webAuthnConfig } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; +import { ENABLE_PASSKEYS } from "@/lib/feature-flags"; import { verifyAuthenticationResponse } from "@simplewebauthn/server"; export async function POST(request: NextRequest) { + if (!ENABLE_PASSKEYS) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + try { const session = await getServerSession(authOptions); diff --git a/src/app/api/auth/passkey/list/route.ts b/src/app/api/auth/passkey/list/route.ts index e068119f..33d9d0d9 100644 --- a/src/app/api/auth/passkey/list/route.ts +++ b/src/app/api/auth/passkey/list/route.ts @@ -2,8 +2,13 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; +import { ENABLE_PASSKEYS } from "@/lib/feature-flags"; export async function GET() { + if (!ENABLE_PASSKEYS) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + try { const session = await getServerSession(authOptions); @@ -35,6 +40,10 @@ export async function GET() { } export async function DELETE(request: Request) { + if (!ENABLE_PASSKEYS) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + try { const session = await getServerSession(authOptions); diff --git a/src/app/api/auth/passkey/register/options/route.ts b/src/app/api/auth/passkey/register/options/route.ts index a28074c2..f167e5d0 100644 --- a/src/app/api/auth/passkey/register/options/route.ts +++ b/src/app/api/auth/passkey/register/options/route.ts @@ -2,12 +2,17 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions, webAuthnConfig } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; +import { ENABLE_PASSKEYS } from "@/lib/feature-flags"; import { generateRegistrationOptions, type AuthenticatorTransportFuture, } from "@simplewebauthn/server"; export async function POST() { + if (!ENABLE_PASSKEYS) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + try { const session = await getServerSession(authOptions); diff --git a/src/app/api/auth/passkey/register/verify/route.ts b/src/app/api/auth/passkey/register/verify/route.ts index df81ceb0..ee909a01 100644 --- a/src/app/api/auth/passkey/register/verify/route.ts +++ b/src/app/api/auth/passkey/register/verify/route.ts @@ -2,17 +2,21 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions, webAuthnConfig } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; +import { ENABLE_PASSKEYS } from "@/lib/feature-flags"; import { verifyRegistrationResponse } from "@simplewebauthn/server"; -// SECURITY: Sanitize user input to prevent XSS function sanitizeString(input: string, maxLength: number = 100): string { return input - .replace(/[<>'"&]/g, "") // Remove potentially dangerous characters + .replace(/[<>'"&]/g, "") .trim() - .slice(0, maxLength); // Limit length + .slice(0, maxLength); } export async function POST(request: NextRequest) { + if (!ENABLE_PASSKEYS) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + try { const session = await getServerSession(authOptions); diff --git a/src/app/api/auth/sso/initiate/route.ts b/src/app/api/auth/sso/initiate/route.ts index 3e9a69bc..317cf79a 100644 --- a/src/app/api/auth/sso/initiate/route.ts +++ b/src/app/api/auth/sso/initiate/route.ts @@ -1,21 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { prismaUsers } from "@/lib/db-users"; +import { ENABLE_SSO } from "@/lib/feature-flags"; /** * POST /api/auth/sso/initiate - Initiate SSO authentication - * Body: { teamSlug: string, callbackUrl: string } - * - * This endpoint handles the SSO flow initiation: - * - SAML: Generates AuthnRequest and redirects to IdP - * - OIDC: Redirects to authorization endpoint - * - LDAP: Returns form for username/password (handled client-side) - * - * TODO: Implement actual SSO provider integrations: - * - SAML: Use @node-saml/node-saml - * - OIDC: Use openid-client or NextAuth OIDC provider - * - LDAP: Use ldapjs for direct authentication */ export async function POST(request: NextRequest) { + if (!ENABLE_SSO) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + try { const { teamSlug, callbackUrl } = await request.json(); diff --git a/src/app/api/auth/sso/lookup/route.ts b/src/app/api/auth/sso/lookup/route.ts index 92fd06cf..8aae845d 100644 --- a/src/app/api/auth/sso/lookup/route.ts +++ b/src/app/api/auth/sso/lookup/route.ts @@ -1,15 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { prismaUsers } from "@/lib/db-users"; +import { ENABLE_SSO } from "@/lib/feature-flags"; /** * POST /api/auth/sso/lookup - Check if an email domain has SSO configured - * Body: { email: string } - * - * Returns: - * - { hasSSO: false } if no SSO for this domain - * - { hasSSO: true, teamSlug, teamName, provider } if SSO is configured */ export async function POST(request: NextRequest) { + if (!ENABLE_SSO) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + try { const { email } = await request.json(); diff --git a/src/app/api/billing/change-plan/route.ts b/src/app/api/billing/change-plan/route.ts deleted file mode 100644 index 3e3ab041..00000000 --- a/src/app/api/billing/change-plan/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; -import { ensureStripe, getPlanFromPriceId, getIntervalFromPriceId } from "@/lib/stripe"; -import { prismaUsers } from "@/lib/db-users"; - -/** - * Change subscription plan (DEPRECATED) - * - * This endpoint was used for upgrading/downgrading between Pro and Max plans. - * Since January 2026, we only have Users (free) and Teams plans, so plan - * changes are no longer supported via API. Users should contact support. - */ -export async function POST() { - // Plan changes are no longer supported as we only have Users (free) and Teams - // This endpoint is kept for backwards compatibility but will always return an error - return NextResponse.json( - { error: "Plan changes are no longer supported. Please contact support to modify your subscription." }, - { status: 400 } - ); -} - -/** - * GET: Check current plan and any pending changes - */ -export async function GET() { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json( - { error: "Authentication required" }, - { status: 401 } - ); - } - - const user = await prismaUsers.user.findUnique({ - where: { id: session.user.id }, - select: { - stripeSubscriptionId: true, - subscriptionPlan: true, - subscriptionStatus: true, - currentPeriodEnd: true, - cancelAtPeriodEnd: true, - }, - }); - - if (!user?.stripeSubscriptionId) { - return NextResponse.json({ - currentPlan: user?.subscriptionPlan?.toLowerCase() || "free", - status: user?.subscriptionStatus || null, - pendingChange: null, - }); - } - - const stripe = ensureStripe(); - const subscriptionResponse = await stripe.subscriptions.retrieve(user.stripeSubscriptionId); - const subscription = subscriptionResponse as unknown as { - status: string; - current_period_end: number; - items: { data: Array<{ price: { id: string } }> }; - metadata?: Record; - cancel_at_period_end: boolean; - }; - - // Check for scheduled changes - const scheduledDowngrade = subscription.metadata?.scheduledDowngrade; - const currentPriceId = subscription.items.data[0]?.price?.id; - const currentPlan = currentPriceId ? getPlanFromPriceId(currentPriceId) : "free"; - const interval = currentPriceId ? getIntervalFromPriceId(currentPriceId) : "monthly"; - - return NextResponse.json({ - currentPlan, - interval, - status: subscription.status, - currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(), - cancelAtPeriodEnd: subscription.cancel_at_period_end, - pendingChange: scheduledDowngrade || null, - }); - } catch (error) { - console.error("Error getting subscription info:", error); - return NextResponse.json( - { error: "Failed to get subscription info" }, - { status: 500 } - ); - } -} diff --git a/src/app/api/billing/checkout/route.ts b/src/app/api/billing/checkout/route.ts index 45dd2060..03a96851 100644 --- a/src/app/api/billing/checkout/route.ts +++ b/src/app/api/billing/checkout/route.ts @@ -1,148 +1,20 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; -import { ensureStripe, getPriceIdForPlan, type SubscriptionPlan, type BillingInterval } from "@/lib/stripe"; -import { prismaUsers } from "@/lib/db-users"; - -export async function POST(request: NextRequest) { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id || !session.user.email) { - return NextResponse.json( - { error: "Authentication required" }, - { status: 401 } - ); - } - - const { plan, interval, euDigitalContentConsent } = (await request.json()) as { - plan: SubscriptionPlan; - interval?: BillingInterval; - euDigitalContentConsent?: boolean; - }; - - // Default to monthly if not specified - const billingInterval: BillingInterval = interval === "annual" ? "annual" : "monthly"; - - if (!plan || plan !== "teams") { - return NextResponse.json( - { error: "Invalid plan selected. Only Teams plan is available for subscription." }, - { status: 400 } - ); - } - - // EU Digital Content Directive compliance - // User must consent to immediate access and waive 14-day withdrawal right - if (!euDigitalContentConsent) { - return NextResponse.json( - { error: "You must consent to immediate access and waive your withdrawal right to proceed." }, - { status: 400 } - ); - } - - const priceId = getPriceIdForPlan(plan, billingInterval); - if (!priceId) { - return NextResponse.json( - { error: `Stripe price not configured for ${plan} ${billingInterval} plan` }, - { status: 500 } - ); - } - - const stripe = ensureStripe(); - - // Get or create Stripe customer - const user = await prismaUsers.user.findUnique({ - where: { id: session.user.id }, - select: { - stripeCustomerId: true, - stripeSubscriptionId: true, - subscriptionStatus: true, - email: true, - name: true - }, - }); - - // If user already has an active subscription, they should use plan change API - if (user?.stripeSubscriptionId && - (user.subscriptionStatus === "active" || user.subscriptionStatus === "trialing")) { - return NextResponse.json( - { - error: "You already have an active subscription. Use the change plan option instead.", - hasActiveSubscription: true, - redirectTo: "/api/billing/change-plan" - }, - { status: 400 } - ); - } - - let customerId = user?.stripeCustomerId; - - if (!customerId) { - // Create new Stripe customer - const customer = await stripe.customers.create({ - email: session.user.email, - name: user?.name || undefined, - metadata: { - userId: session.user.id, - }, - }); - customerId = customer.id; - - // Save customer ID to database - await prismaUsers.user.update({ - where: { id: session.user.id }, - data: { stripeCustomerId: customerId }, - }); - } - - // Create Stripe Checkout session - const checkoutSession = await stripe.checkout.sessions.create({ - customer: customerId, - mode: "subscription", - payment_method_types: ["card"], - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - success_url: `${process.env.NEXTAUTH_URL}/settings?tab=billing&success=true`, - cancel_url: `${process.env.NEXTAUTH_URL}/settings?tab=billing&canceled=true`, - metadata: { - userId: session.user.id, - plan: plan, - interval: billingInterval, - // EU Digital Content Directive consent tracking - euDigitalContentConsent: "true", - consentTimestamp: new Date().toISOString(), - }, - subscription_data: { - metadata: { - userId: session.user.id, - plan: plan, - interval: billingInterval, - euDigitalContentConsent: "true", - consentTimestamp: new Date().toISOString(), - }, - }, - // Allow promotion codes - allow_promotion_codes: true, - // Collect billing address for VAT - billing_address_collection: "required", - // Automatic tax calculation (if configured) - automatic_tax: { enabled: false }, // Enable when Stripe Tax is configured - }); - - return NextResponse.json({ url: checkoutSession.url }); - } catch (error) { - console.error("Error creating checkout session:", error); +import { NextResponse } from "next/server"; +import { ENABLE_STRIPE } from "@/lib/feature-flags"; + +/** + * Subscription checkout has been removed. + * This endpoint is kept for future marketplace checkout if needed. + */ +export async function POST() { + if (!ENABLE_STRIPE) { return NextResponse.json( - { error: "Failed to create checkout session" }, - { status: 500 } + { error: "Payments are not enabled on this instance" }, + { status: 404 } ); } -} - - - + return NextResponse.json( + { error: "Subscription billing is no longer available. All features are free for all users." }, + { status: 410 } + ); +} diff --git a/src/app/api/billing/portal/route.ts b/src/app/api/billing/portal/route.ts index d9b023b9..5de00702 100644 --- a/src/app/api/billing/portal/route.ts +++ b/src/app/api/billing/portal/route.ts @@ -1,50 +1,21 @@ import { NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; -import { ensureStripe } from "@/lib/stripe"; -import { prismaUsers } from "@/lib/db-users"; +import { ENABLE_STRIPE } from "@/lib/feature-flags"; +/** + * Stripe Customer Portal for marketplace customers. + * Subscription billing has been removed, but marketplace customers + * may still need to manage their payment methods. + */ export async function POST() { - try { - const session = await getServerSession(authOptions); - - if (!session?.user?.id) { - return NextResponse.json( - { error: "Authentication required" }, - { status: 401 } - ); - } - - const user = await prismaUsers.user.findUnique({ - where: { id: session.user.id }, - select: { stripeCustomerId: true }, - }); - - if (!user?.stripeCustomerId) { - return NextResponse.json( - { error: "No billing account found" }, - { status: 400 } - ); - } - - const stripe = ensureStripe(); - - // Create Stripe Customer Portal session - const portalSession = await stripe.billingPortal.sessions.create({ - customer: user.stripeCustomerId, - return_url: `${process.env.NEXTAUTH_URL}/settings?tab=billing`, - }); - - return NextResponse.json({ url: portalSession.url }); - } catch (error) { - console.error("Error creating portal session:", error); + if (!ENABLE_STRIPE) { return NextResponse.json( - { error: "Failed to create portal session" }, - { status: 500 } + { error: "Payments are not enabled on this instance" }, + { status: 404 } ); } -} - - - + return NextResponse.json( + { error: "Billing portal is no longer available." }, + { status: 410 } + ); +} diff --git a/src/app/api/billing/status/route.ts b/src/app/api/billing/status/route.ts index 5d1e1d96..0d2420ef 100644 --- a/src/app/api/billing/status/route.ts +++ b/src/app/api/billing/status/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; -import { ensureStripe, getPlanFromPriceId, getIntervalFromPriceId } from "@/lib/stripe"; export async function GET() { try { @@ -19,13 +18,6 @@ export async function GET() { where: { id: session.user.id }, select: { role: true, - subscriptionPlan: true, - subscriptionStatus: true, - subscriptionInterval: true, - currentPeriodEnd: true, - cancelAtPeriodEnd: true, - stripeCustomerId: true, - stripeSubscriptionId: true, teamMemberships: { include: { team: { @@ -34,8 +26,6 @@ export async function GET() { name: true, slug: true, logo: true, - stripeSubscriptionId: true, - billingCycleStart: true, }, }, }, @@ -50,69 +40,14 @@ export async function GET() { ); } - // Check if user is part of a team const teamMembership = user.teamMemberships[0]; - const isTeamsUser = user.subscriptionPlan === "TEAMS" || !!teamMembership; - - // Admins and Superadmins get Teams tier features for free const isAdmin = user.role === "ADMIN" || user.role === "SUPERADMIN"; - - // Determine effective plan - let effectivePlan: string; - if (isAdmin) { - effectivePlan = "teams"; - } else if (isTeamsUser) { - effectivePlan = "teams"; - } else { - effectivePlan = user.subscriptionPlan.toLowerCase(); - } - - // Check for pending changes if user has active subscription - let pendingChange: string | null = null; - let actualCurrentPlan = effectivePlan; - let billingInterval: "monthly" | "annual" = (user.subscriptionInterval as "monthly" | "annual") || "monthly"; - - // For non-Teams, non-Admin users with Stripe subscription - if (!isAdmin && !isTeamsUser && user.stripeSubscriptionId) { - try { - const stripe = ensureStripe(); - const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId); - - // Get the currently active plan and interval from Stripe - const currentPriceId = subscription.items.data[0]?.price?.id; - if (currentPriceId) { - actualCurrentPlan = getPlanFromPriceId(currentPriceId); - billingInterval = getIntervalFromPriceId(currentPriceId); - } - - // Check for scheduled downgrade in metadata - if (subscription.metadata?.scheduledDowngrade) { - pendingChange = subscription.metadata.scheduledDowngrade; - } - } catch (stripeError) { - // If we can't reach Stripe, just use the DB values - console.error("Error fetching subscription from Stripe:", stripeError); - } - } - - // Teams users are considered active if they're part of a team (billing is handled at team level) - const hasActiveSubscription = isAdmin || isTeamsUser || (!!user.stripeSubscriptionId && - (user.subscriptionStatus === "active" || user.subscriptionStatus === "trialing")); return NextResponse.json({ - plan: actualCurrentPlan, - interval: billingInterval, - status: isAdmin || isTeamsUser ? "active" : user.subscriptionStatus, - currentPeriodEnd: isAdmin ? null : (isTeamsUser ? teamMembership?.team?.billingCycleStart : user.currentPeriodEnd), - cancelAtPeriodEnd: isAdmin || isTeamsUser ? false : user.cancelAtPeriodEnd, - hasStripeAccount: !!user.stripeCustomerId, - hasActiveSubscription, - isAdmin, // Flag for UI to show "Admin" badge instead of plan - isTeamsUser, // Flag for UI to show "Teams" badge - pendingChange, // For showing scheduled downgrades - isAnnual: billingInterval === "annual", // Convenience flag for UI - // Teams-specific data - team: isTeamsUser && teamMembership ? { + plan: "free", + isAdmin, + isTeamsUser: !!teamMembership, + team: teamMembership ? { id: teamMembership.team.id, name: teamMembership.team.name, slug: teamMembership.team.slug, @@ -128,7 +63,3 @@ export async function GET() { ); } } - - - - diff --git a/src/app/api/billing/webhook/route.ts b/src/app/api/billing/webhook/route.ts index 350524a2..578c4497 100644 --- a/src/app/api/billing/webhook/route.ts +++ b/src/app/api/billing/webhook/route.ts @@ -1,12 +1,20 @@ import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import Stripe from "stripe"; -import { ensureStripe, getPlanFromPriceId, getIntervalFromPriceId } from "@/lib/stripe"; +import { ensureStripe } from "@/lib/stripe"; import { prismaUsers } from "@/lib/db-users"; +import { ENABLE_STRIPE } from "@/lib/feature-flags"; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; export async function POST(request: NextRequest) { + if (!ENABLE_STRIPE) { + return NextResponse.json( + { error: "Payments are not enabled on this instance" }, + { status: 404 } + ); + } + if (!webhookSecret) { console.error("STRIPE_WEBHOOK_SECRET is not set"); return NextResponse.json( @@ -43,51 +51,12 @@ export async function POST(request: NextRequest) { switch (event.type) { case "checkout.session.completed": { const session = event.data.object as Stripe.Checkout.Session; - // Handle blueprint purchases (one-time payments) if (session.metadata?.type === "blueprint_purchase") { await handleBlueprintPurchase(session); - } else { - // Handle subscription checkout - await handleCheckoutCompleted(session); } break; } - case "customer.subscription.created": - case "customer.subscription.updated": { - const subscription = event.data.object as Stripe.Subscription; - // Check if this is a Teams subscription (has teamId in metadata) - if (subscription.metadata?.teamId) { - await handleTeamsSubscriptionChange(subscription); - } else { - await handleSubscriptionChange(subscription); - } - break; - } - - case "customer.subscription.deleted": { - const subscription = event.data.object as Stripe.Subscription; - // Check if this is a Teams subscription - if (subscription.metadata?.teamId) { - await handleTeamsSubscriptionDeleted(subscription); - } else { - await handleSubscriptionDeleted(subscription); - } - break; - } - - case "invoice.payment_failed": { - const invoice = event.data.object as Stripe.Invoice; - await handlePaymentFailed(invoice); - break; - } - - case "invoice.payment_succeeded": { - const invoice = event.data.object as Stripe.Invoice; - await handlePaymentSucceeded(invoice); - break; - } - default: console.log(`Unhandled event type: ${event.type}`); } @@ -102,272 +71,11 @@ export async function POST(request: NextRequest) { } } -async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { - // Check if this is a Teams checkout - if (session.metadata?.type === "teams") { - await handleTeamsCheckoutCompleted(session); - return; - } - - const userId = session.metadata?.userId; - if (!userId) { - console.error("No userId in checkout session metadata"); - return; - } - - // Subscription will be updated by subscription.created/updated webhook - console.log(`Checkout completed for user ${userId}`); -} - -async function handleTeamsCheckoutCompleted(session: Stripe.Checkout.Session) { - const { teamName, teamSlug, creatorUserId } = session.metadata || {}; - const subscriptionId = session.subscription as string; - const customerId = session.customer as string; - - if (!teamName || !teamSlug || !creatorUserId) { - console.error("Missing team metadata in checkout session", session.metadata); - return; - } - - // Check if team already exists (in case of duplicate webhook) - const existingTeam = await prismaUsers.team.findUnique({ - where: { slug: teamSlug }, - }); - - if (existingTeam) { - console.log(`Team ${teamSlug} already exists, skipping creation`); - return; - } - - // Get subscription details for billing info - const stripe = ensureStripe(); - const subscription = await stripe.subscriptions.retrieve(subscriptionId); - - // Get the interval from the subscription - const interval = subscription.items.data[0]?.plan?.interval === "year" ? "annual" : "monthly"; - - // Create the team - const team = await prismaUsers.team.create({ - data: { - name: teamName, - slug: teamSlug, - stripeCustomerId: customerId, - stripeSubscriptionId: subscriptionId, - subscriptionInterval: interval, - maxSeats: subscription.items.data[0]?.quantity || 3, - billingCycleStart: new Date(), - members: { - create: { - userId: creatorUserId, - role: "ADMIN", - isActiveThisCycle: true, - lastActiveAt: new Date(), - }, - }, - }, - }); - - // Update the subscription metadata with the team ID - await stripe.subscriptions.update(subscriptionId, { - metadata: { - teamId: team.id, - teamSlug: team.slug, - }, - }); - - // Update the creator's subscription plan to TEAMS - await prismaUsers.user.update({ - where: { id: creatorUserId }, - data: { - subscriptionPlan: "TEAMS", - }, - }); - - console.log(`Team "${teamName}" created successfully with ID ${team.id}`); -} - -async function handleSubscriptionChange(subscription: Stripe.Subscription) { - const userId = subscription.metadata?.userId; - const customerId = subscription.customer as string; - - // Find user by customer ID or metadata - const user = await prismaUsers.user.findFirst({ - where: userId ? { id: userId } : { stripeCustomerId: customerId }, - }); - - if (!user) { - console.error(`User not found for subscription ${subscription.id}`); - return; - } - - // Get the plan and interval from the first subscription item - const priceId = subscription.items.data[0]?.price?.id; - const plan = priceId ? getPlanFromPriceId(priceId) : "free"; - const interval = priceId ? getIntervalFromPriceId(priceId) : "monthly"; - - // Map subscription status - // Note: PRO and MAX are legacy - new subscriptions only have FREE or TEAMS - // Legacy PRO/MAX subscriptions are treated as FREE (full wizard access now) - type SubscriptionPlan = "FREE" | "TEAMS"; - const planMap: Record = { - free: "FREE", - pro: "FREE", // Legacy - map to FREE (they get full wizard access anyway) - max: "FREE", // Legacy - map to FREE (they get full wizard access anyway) - teams: "TEAMS", - }; - - // Get current period end - cast to access the property - const sub = subscription as unknown as { current_period_end?: number }; - const currentPeriodEnd = sub.current_period_end; - - // Determine effective plan based on subscription status - // If subscription is not active/trialing, user should be on FREE - const isActiveSubscription = - subscription.status === "active" || - subscription.status === "trialing"; - - const effectivePlan: SubscriptionPlan = isActiveSubscription - ? (planMap[plan] || "FREE") - : "FREE"; - - await prismaUsers.user.update({ - where: { id: user.id }, - data: { - stripeSubscriptionId: subscription.id, - stripeCustomerId: customerId, - subscriptionPlan: effectivePlan, - subscriptionStatus: subscription.status, - subscriptionInterval: interval, - currentPeriodEnd: currentPeriodEnd - ? new Date(currentPeriodEnd * 1000) - : null, - cancelAtPeriodEnd: subscription.cancel_at_period_end, - }, - }); - - console.log(`Updated subscription for user ${user.id}: ${effectivePlan} ${interval} (status: ${subscription.status})`); -} - -async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { - const customerId = subscription.customer as string; - - const user = await prismaUsers.user.findFirst({ - where: { stripeCustomerId: customerId }, - }); - - if (!user) { - console.error(`User not found for customer ${customerId}`); - return; - } - - await prismaUsers.user.update({ - where: { id: user.id }, - data: { - subscriptionPlan: "FREE", - subscriptionStatus: "canceled", - stripeSubscriptionId: null, - cancelAtPeriodEnd: false, - }, - }); - - console.log(`Subscription deleted for user ${user.id}`); -} - -async function handlePaymentFailed(invoice: Stripe.Invoice) { - const customerId = invoice.customer as string; - - const user = await prismaUsers.user.findFirst({ - where: { stripeCustomerId: customerId }, - }); - - if (user) { - await prismaUsers.user.update({ - where: { id: user.id }, - data: { subscriptionStatus: "past_due" }, - }); - console.log(`Payment failed for user ${user.id}`); - } -} - -async function handlePaymentSucceeded(invoice: Stripe.Invoice) { - const customerId = invoice.customer as string; - const inv = invoice as unknown as { subscription?: string; billing_reason?: string }; - const subscriptionId = inv.subscription; - - const user = await prismaUsers.user.findFirst({ - where: { stripeCustomerId: customerId }, - }); - - if (!user) return; - - // Update status if was past_due - if (user.subscriptionStatus === "past_due") { - await prismaUsers.user.update({ - where: { id: user.id }, - data: { subscriptionStatus: "active" }, - }); - console.log(`Payment succeeded for user ${user.id}`); - } - - // Check for scheduled downgrade on renewal invoices - if (subscriptionId && inv.billing_reason === "subscription_cycle") { - try { - const stripe = ensureStripe(); - const subscription = await stripe.subscriptions.retrieve(subscriptionId); - - const scheduledDowngrade = subscription.metadata?.scheduledDowngrade; - const scheduledDowngradePrice = subscription.metadata?.scheduledDowngradePrice; - - if (scheduledDowngrade && scheduledDowngradePrice) { - // Apply the downgrade now - await stripe.subscriptions.update(subscriptionId, { - items: [ - { - id: subscription.items.data[0].id, - price: scheduledDowngradePrice, - }, - ], - proration_behavior: "none", // Already at new billing cycle - metadata: { - ...subscription.metadata, - scheduledDowngrade: null, - scheduledDowngradePrice: null, - plan: scheduledDowngrade, - }, - }); - - // Update database - // Note: Legacy PRO/MAX mapped to FREE - only TEAMS is a paid tier now - type SubscriptionPlan = "FREE" | "TEAMS"; - const planMap: Record = { - pro: "FREE", // Legacy - max: "FREE", // Legacy - free: "FREE", - teams: "TEAMS", - }; - - await prismaUsers.user.update({ - where: { id: user.id }, - data: { - subscriptionPlan: planMap[scheduledDowngrade] || "FREE", - }, - }); - - console.log(`Applied scheduled downgrade to ${scheduledDowngrade} for user ${user.id}`); - } - } catch (error) { - console.error("Error applying scheduled downgrade:", error); - } - } -} - -// Platform owner email - payments go directly to the platform's Stripe account -const PLATFORM_OWNER_EMAIL = "dev@lynxprompt.com"; +import { PLATFORM_OWNER_EMAIL } from "@/lib/feature-flags"; async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { const { templateId, userId, originalPrice, paidPrice, isMaxDiscount, currency, teamId } = session.metadata || {}; - // Backwards compatibility with old purchases that only have 'price' const price = originalPrice || session.metadata?.price; const actualPaid = paidPrice || price; @@ -379,8 +87,6 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { const originalPriceInCents = parseInt(price, 10); const paidPriceInCents = parseInt(actualPaid, 10); - // Check if the template belongs to the platform owner - // For platform owner blueprints, all revenue stays with platform (no payout needed) const template = await prismaUsers.userTemplate.findUnique({ where: { id: templateId }, select: { @@ -392,16 +98,11 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { const isPlatformOwnerTemplate = template?.user?.email === PLATFORM_OWNER_EMAIL; - // Author gets 70% of ORIGINAL price, UNLESS it's the platform owner's template - // Platform owner's revenue goes directly to Stripe (no payout needed) const authorShare = isPlatformOwnerTemplate ? 0 : Math.floor(originalPriceInCents * 0.7); - // Platform fee is what's left from what was paid const platformFee = paidPriceInCents - authorShare; - // Get current version info for the purchase record const purchaseVersion = template?.publishedVersion || template?.currentVersion || 1; - // Find the version record ID for linking const versionRecord = await prismaUsers.userTemplateVersion.findUnique({ where: { templateId_version: { @@ -413,7 +114,6 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { }); try { - // Create purchase record - if teamId is present, this is a team purchase const purchaseData: { userId: string; templateId: string; @@ -428,16 +128,15 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { } = { userId, templateId, - amount: paidPriceInCents, // What was actually paid + amount: paidPriceInCents, currency: currency || "EUR", stripePaymentId: session.payment_intent as string, - authorShare, // 70% of original price - platformFee, // Remaining (20% if discounted, 30% if not) + authorShare, + platformFee, versionId: versionRecord?.id || undefined, versionNumber: purchaseVersion, }; - // If purchased by a team member, add teamId (makes it available to entire team) if (teamId) { purchaseData.teamId = teamId; } @@ -446,7 +145,6 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { data: purchaseData, }); - // Update template revenue with original price (for author earnings tracking) await prismaUsers.userTemplate.update({ where: { id: templateId }, data: { @@ -458,7 +156,6 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { const teamInfo = teamId ? ` (team: ${teamId})` : ""; console.log(`Blueprint purchase: ${templateId} by ${userId}${teamInfo} - paid: ${paidPriceInCents} cents, original: ${originalPriceInCents} cents (author: ${authorShare}, platform: ${platformFee}, max discount: ${isMaxDiscount === "true"})`); } catch (error) { - // Handle duplicate purchase (race condition) if ((error as { code?: string }).code === "P2002") { console.log(`Duplicate purchase attempt for ${templateId} by ${userId}`); } else { @@ -466,100 +163,3 @@ async function handleBlueprintPurchase(session: Stripe.Checkout.Session) { } } } - -/** - * Handle Teams subscription changes - */ -async function handleTeamsSubscriptionChange(subscription: Stripe.Subscription) { - const teamId = subscription.metadata?.teamId; - const customerId = subscription.customer as string; - - // Find team by teamId or customer ID - const team = await prismaUsers.team.findFirst({ - where: teamId ? { id: teamId } : { stripeCustomerId: customerId }, - }); - - if (!team) { - console.error(`Team not found for subscription ${subscription.id}`); - return; - } - - // Get seat count from subscription - const seatCount = subscription.items.data[0]?.quantity || 3; - - // Get current period info - const sub = subscription as unknown as { current_period_start?: number; current_period_end?: number }; - const billingCycleStart = sub.current_period_start - ? new Date(sub.current_period_start * 1000) - : null; - - await prismaUsers.team.update({ - where: { id: team.id }, - data: { - stripeSubscriptionId: subscription.id, - stripeCustomerId: customerId, - maxSeats: seatCount, - billingCycleStart, - }, - }); - - // If subscription became active, mark team members as TEAMS plan - if (subscription.status === "active") { - const members = await prismaUsers.teamMember.findMany({ - where: { teamId: team.id }, - select: { userId: true }, - }); - - // Update all team members to TEAMS plan - await prismaUsers.user.updateMany({ - where: { id: { in: members.map(m => m.userId) } }, - data: { subscriptionPlan: "TEAMS" }, - }); - - console.log(`Team ${team.id} activated: ${members.length} members upgraded to TEAMS plan`); - } - - console.log(`Updated Teams subscription for team ${team.id}: ${seatCount} seats (status: ${subscription.status})`); -} - -/** - * Handle Teams subscription deletion - */ -async function handleTeamsSubscriptionDeleted(subscription: Stripe.Subscription) { - const customerId = subscription.customer as string; - - const team = await prismaUsers.team.findFirst({ - where: { stripeCustomerId: customerId }, - }); - - if (!team) { - console.error(`Team not found for customer ${customerId}`); - return; - } - - // Get all team members - const members = await prismaUsers.teamMember.findMany({ - where: { teamId: team.id }, - select: { userId: true }, - }); - - // Downgrade all team members to FREE plan - await prismaUsers.user.updateMany({ - where: { id: { in: members.map(m => m.userId) } }, - data: { subscriptionPlan: "FREE" }, - }); - - await prismaUsers.team.update({ - where: { id: team.id }, - data: { - stripeSubscriptionId: null, - billingCycleStart: null, - }, - }); - - console.log(`Teams subscription canceled for team ${team.id}: ${members.length} members downgraded to FREE`); -} - - - - diff --git a/src/app/api/blog/rss/route.ts b/src/app/api/blog/rss/route.ts index 0eb201a4..2ace0c40 100644 --- a/src/app/api/blog/rss/route.ts +++ b/src/app/api/blog/rss/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { prismaBlog } from "@/lib/db-blog"; import { prismaUsers } from "@/lib/db-users"; +import { APP_URL, APP_NAME } from "@/lib/feature-flags"; // Escape special XML characters function escapeXml(text: string): string { @@ -38,8 +39,8 @@ function markdownToPlainText(markdown: string): string { export async function GET() { try { - const siteUrl = process.env.NEXT_PUBLIC_APP_URL || "https://lynxprompt.com"; - const siteName = "LynxPrompt"; + const siteUrl = APP_URL; + const siteName = APP_NAME; const siteDescription = "Updates, tutorials, and insights about AI coding assistants and LynxPrompt."; diff --git a/src/app/api/blueprints/route.ts b/src/app/api/blueprints/route.ts index 40193882..dbe5b5f2 100644 --- a/src/app/api/blueprints/route.ts +++ b/src/app/api/blueprints/route.ts @@ -390,16 +390,6 @@ export async function POST(request: NextRequest) { // } // } - // Check if user can create paid blueprints (Teams only) - if (price !== null && price !== undefined && price > 0) { - if (!isTeamsUser) { - return NextResponse.json( - { error: "Only Teams subscribers can create paid blueprints. Upgrade to Teams to unlock this feature." }, - { status: 403 } - ); - } - } - // Validation if (!name || typeof name !== "string" || name.trim().length < 3) { return NextResponse.json( diff --git a/src/app/api/config/public/route.ts b/src/app/api/config/public/route.ts index 7cf9bf35..e6c99e9c 100644 --- a/src/app/api/config/public/route.ts +++ b/src/app/api/config/public/route.ts @@ -1,17 +1,10 @@ import { NextResponse } from "next/server"; +import { getPublicFlags } from "@/lib/feature-flags"; -/** - * Public config endpoint - returns NEXT_PUBLIC_* values at runtime - * This allows these values to be set as regular env vars instead of build args - */ export async function GET() { return NextResponse.json({ turnstileSiteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || null, umamiWebsiteId: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || null, + ...getPublicFlags(), }); } - - - - - diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts index bb5677cd..770f7032 100644 --- a/src/app/api/contact/route.ts +++ b/src/app/api/contact/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { createTransport } from "nodemailer"; import { z } from "zod"; +import { APP_NAME, APP_URL, CONTACT_EMAIL } from "@/lib/feature-flags"; const contactSchema = z.object({ name: z.string().min(1, "Name is required").max(100), @@ -46,10 +47,10 @@ export async function POST(request: Request) { // Send email await transporter.sendMail({ - from: process.env.SMTP_FROM || "noreply@lynxprompt.com", - to: "info@lynxprompt.com", + from: process.env.SMTP_FROM || `noreply@${new URL(APP_URL).hostname}`, + to: CONTACT_EMAIL || `info@${new URL(APP_URL).hostname}`, replyTo: email, - subject: `[LynxPrompt Contact] ${subject}`, + subject: `[${APP_NAME} Contact] ${subject}`, text: `Name: ${name}\nEmail: ${email}\n\nMessage:\n${message}`, html: ` diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 0e2a4421..a7bac1f7 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,18 +1,11 @@ import { NextResponse } from "next/server"; +import { prismaUsers } from "@/lib/db-users"; export async function GET() { - return NextResponse.json({ status: "ok" }, { status: 200 }); + try { + await prismaUsers.$queryRaw`SELECT 1`; + return NextResponse.json({ status: "ok", db: "connected" }, { status: 200 }); + } catch { + return NextResponse.json({ status: "error", db: "disconnected" }, { status: 503 }); + } } - - - - - - - - - - - - - diff --git a/src/app/api/seller/earnings/route.ts b/src/app/api/seller/earnings/route.ts index 5b1f7d9d..28c5ba48 100644 --- a/src/app/api/seller/earnings/route.ts +++ b/src/app/api/seller/earnings/route.ts @@ -3,8 +3,7 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; -// Platform owner email - payments go directly to platform's Stripe account -const PLATFORM_OWNER_EMAIL = "dev@lynxprompt.com"; +import { PLATFORM_OWNER_EMAIL } from "@/lib/feature-flags"; // GET /api/seller/earnings - Get seller earnings summary export async function GET() { diff --git a/src/app/api/seller/payout-request/route.ts b/src/app/api/seller/payout-request/route.ts index 1ef85365..8e0b3f3a 100644 --- a/src/app/api/seller/payout-request/route.ts +++ b/src/app/api/seller/payout-request/route.ts @@ -5,8 +5,7 @@ import { prismaUsers } from "@/lib/db-users"; const MINIMUM_PAYOUT = 1000; // €10.00 in cents -// Platform owner email - payments go directly to platform's Stripe account, no payouts needed -const PLATFORM_OWNER_EMAIL = "dev@lynxprompt.com"; +import { PLATFORM_OWNER_EMAIL } from "@/lib/feature-flags"; // GET /api/seller/payout-request - Get payout history export async function GET() { diff --git a/src/app/api/teams/[teamId]/billing/route.ts b/src/app/api/teams/[teamId]/billing/route.ts index 624d382f..ee859c48 100644 --- a/src/app/api/teams/[teamId]/billing/route.ts +++ b/src/app/api/teams/[teamId]/billing/route.ts @@ -2,23 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; -import { ensureStripe, STRIPE_PRICE_IDS } from "@/lib/stripe"; -import { TEAMS_MIN_SEATS, TEAMS_PRICE_PER_SEAT, calculateProratedAmount } from "@/lib/subscription"; -import { z } from "zod"; -// Validation schemas -const createSubscriptionSchema = z.object({ - seats: z.number().min(TEAMS_MIN_SEATS, `Minimum ${TEAMS_MIN_SEATS} seats required`), - euDigitalContentConsent: z.boolean(), -}); - -const updateSeatsSchema = z.object({ - seats: z.number().min(TEAMS_MIN_SEATS, `Minimum ${TEAMS_MIN_SEATS} seats required`), -}); - -/** - * Helper: Check if user is a team admin - */ async function isTeamAdmin(userId: string, teamId: string): Promise { const membership = await prismaUsers.teamMember.findUnique({ where: { @@ -29,7 +13,8 @@ async function isTeamAdmin(userId: string, teamId: string): Promise { } /** - * GET /api/teams/[teamId]/billing - Get billing info (admin only) + * GET /api/teams/[teamId]/billing - Get team info (admin only) + * Subscription billing has been removed. Returns basic team membership info. */ export async function GET( request: NextRequest, @@ -43,10 +28,9 @@ export async function GET( const { teamId } = await params; - // Check if user is a team admin if (!(await isTeamAdmin(session.user.id, teamId))) { return NextResponse.json( - { error: "Only team admins can view billing" }, + { error: "Only team admins can view team info" }, { status: 403 } ); } @@ -56,14 +40,9 @@ export async function GET( include: { members: { select: { - isActiveThisCycle: true, lastActiveAt: true, }, }, - billingRecords: { - orderBy: { periodStart: "desc" }, - take: 6, // Last 6 billing periods - }, }, }); @@ -71,425 +50,20 @@ export async function GET( return NextResponse.json({ error: "Team not found" }, { status: 404 }); } - // Calculate active users - const activeMembers = team.members.filter((m) => m.isActiveThisCycle).length; const totalMembers = team.members.length; - // Get Stripe subscription details if exists - let stripeSubscription = null; - if (team.stripeSubscriptionId) { - try { - const stripe = ensureStripe(); - const sub = await stripe.subscriptions.retrieve(team.stripeSubscriptionId); - stripeSubscription = { - status: sub.status, - currentPeriodEnd: new Date((sub as unknown as { current_period_end: number }).current_period_end * 1000), - cancelAtPeriodEnd: sub.cancel_at_period_end, - quantity: sub.items.data[0]?.quantity || 0, - }; - } catch (e) { - console.error("Error fetching Stripe subscription:", e); - } - } - - // Calculate next bill estimate - const billableSeats = Math.max(activeMembers, TEAMS_MIN_SEATS); - const nextBillEstimate = billableSeats * TEAMS_PRICE_PER_SEAT; - return NextResponse.json({ billing: { - stripeCustomerId: team.stripeCustomerId, - stripeSubscriptionId: team.stripeSubscriptionId, - subscription: stripeSubscription, maxSeats: team.maxSeats, totalMembers, - activeMembers, - billableSeats, - nextBillEstimate, - nextBillEstimateFormatted: `€${(nextBillEstimate / 100).toFixed(2)}`, - billingCycleStart: team.billingCycleStart, - aiUsageLimitPerUser: team.aiUsageLimitPerUser, - }, - history: team.billingRecords.map((r) => ({ - id: r.id, - periodStart: r.periodStart, - periodEnd: r.periodEnd, - totalSeats: r.totalSeats, - activeSeats: r.activeSeats, - billedSeats: r.billedSeats, - amountBilled: r.amountBilled, - amountFormatted: `€${(r.amountBilled / 100).toFixed(2)}`, - creditApplied: r.creditApplied, - creditGenerated: r.creditGenerated, - })), - }); - } catch (error) { - console.error("Error fetching billing:", error); - return NextResponse.json( - { error: "Failed to fetch billing information" }, - { status: 500 } - ); - } -} - -/** - * POST /api/teams/[teamId]/billing - Create Teams subscription (admin only) - */ -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ teamId: string }> } -) { - try { - const session = await getServerSession(authOptions); - if (!session?.user?.id || !session.user.email) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { teamId } = await params; - - // Check if user is a team admin - if (!(await isTeamAdmin(session.user.id, teamId))) { - return NextResponse.json( - { error: "Only team admins can manage billing" }, - { status: 403 } - ); - } - - const body = await request.json(); - const validation = createSubscriptionSchema.safeParse(body); - - if (!validation.success) { - return NextResponse.json( - { error: "Invalid input", details: validation.error.flatten() }, - { status: 400 } - ); - } - - const { seats, euDigitalContentConsent } = validation.data; - - if (!euDigitalContentConsent) { - return NextResponse.json( - { error: "You must consent to immediate access and waive your withdrawal right to proceed." }, - { status: 400 } - ); - } - - const team = await prismaUsers.team.findUnique({ - where: { id: teamId }, - }); - - if (!team) { - return NextResponse.json({ error: "Team not found" }, { status: 404 }); - } - - if (team.stripeSubscriptionId) { - return NextResponse.json( - { error: "Team already has an active subscription. Use PATCH to update seats." }, - { status: 400 } - ); - } - - const stripe = ensureStripe(); - - // Get or create Stripe customer for the team - let customerId = team.stripeCustomerId; - - if (!customerId) { - const customer = await stripe.customers.create({ - email: session.user.email, - name: team.name, - metadata: { - teamId: team.id, - teamSlug: team.slug, - adminUserId: session.user.id, - }, - }); - customerId = customer.id; - } - - // Create checkout session for Teams subscription - const priceId = STRIPE_PRICE_IDS.teams_seat_monthly; - if (!priceId) { - return NextResponse.json( - { error: "Teams pricing is not configured" }, - { status: 500 } - ); - } - - const checkoutSession = await stripe.checkout.sessions.create({ - customer: customerId, - mode: "subscription", - payment_method_types: ["card"], - line_items: [ - { - price: priceId, - quantity: seats, // Per-seat billing with quantity - }, - ], - success_url: `${process.env.NEXTAUTH_URL}/teams/${team.slug}/manage?billing=success`, - cancel_url: `${process.env.NEXTAUTH_URL}/teams/${team.slug}/manage?billing=canceled`, - metadata: { - teamId: team.id, - seats: seats.toString(), - euDigitalContentConsent: "true", - consentTimestamp: new Date().toISOString(), - }, - subscription_data: { - metadata: { - teamId: team.id, - seats: seats.toString(), - }, - }, - allow_promotion_codes: true, - billing_address_collection: "required", - }); - - // Update team with customer ID and max seats - await prismaUsers.team.update({ - where: { id: teamId }, - data: { - stripeCustomerId: customerId, - maxSeats: seats, }, + history: [], }); - - return NextResponse.json({ url: checkoutSession.url }); } catch (error) { - console.error("Error creating subscription:", error); + console.error("Error fetching team info:", error); return NextResponse.json( - { error: "Failed to create subscription" }, + { error: "Failed to fetch team information" }, { status: 500 } ); } } - -/** - * PATCH /api/teams/[teamId]/billing - Update seat count (admin only) - */ -export async function PATCH( - request: NextRequest, - { params }: { params: Promise<{ teamId: string }> } -) { - try { - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { teamId } = await params; - - // Check if user is a team admin - if (!(await isTeamAdmin(session.user.id, teamId))) { - return NextResponse.json( - { error: "Only team admins can update billing" }, - { status: 403 } - ); - } - - const body = await request.json(); - const validation = updateSeatsSchema.safeParse(body); - - if (!validation.success) { - return NextResponse.json( - { error: "Invalid input", details: validation.error.flatten() }, - { status: 400 } - ); - } - - const { seats } = validation.data; - - const team = await prismaUsers.team.findUnique({ - where: { id: teamId }, - include: { - _count: { select: { members: true } }, - }, - }); - - if (!team) { - return NextResponse.json({ error: "Team not found" }, { status: 404 }); - } - - if (!team.stripeSubscriptionId) { - return NextResponse.json( - { error: "No active subscription. Use POST to create one." }, - { status: 400 } - ); - } - - // Can't reduce seats below current member count - if (seats < team._count.members) { - return NextResponse.json( - { error: `Cannot reduce seats below current member count (${team._count.members}). Remove members first.` }, - { status: 400 } - ); - } - - const stripe = ensureStripe(); - - // Get current subscription - const subscription = await stripe.subscriptions.retrieve(team.stripeSubscriptionId); - const subscriptionItemId = subscription.items.data[0]?.id; - - if (!subscriptionItemId) { - return NextResponse.json( - { error: "Subscription item not found" }, - { status: 500 } - ); - } - - const currentSeats = subscription.items.data[0]?.quantity || TEAMS_MIN_SEATS; - const isIncreasing = seats > currentSeats; - const seatDifference = seats - currentSeats; - - // Calculate if this is the same day as billing cycle start - const now = new Date(); - const cycleStart = team.billingCycleStart ? new Date(team.billingCycleStart) : new Date(); - - // Check if same day (comparing year, month, day) - const isSameDayAsCycleStart = - now.getFullYear() === cycleStart.getFullYear() && - now.getMonth() === cycleStart.getMonth() && - now.getDate() === cycleStart.getDate(); - - // Determine proration behavior: - // - Increasing seats on same day as cycle start: no proration (full price) - // - Increasing seats mid-cycle: prorate - // - Decreasing seats: no proration, takes effect next cycle - let prorationBehavior: "create_prorations" | "none" | "always_invoice" = "none"; - - if (isIncreasing) { - if (isSameDayAsCycleStart) { - // Same day = full price, use always_invoice to charge immediately - prorationBehavior = "always_invoice"; - } else { - // Mid-cycle = prorate - prorationBehavior = "create_prorations"; - } - } - // Decreasing: prorationBehavior stays "none" (no refund, change takes effect at renewal) - - // Update subscription quantity - await stripe.subscriptions.update(team.stripeSubscriptionId, { - items: [ - { - id: subscriptionItemId, - quantity: seats, - }, - ], - proration_behavior: prorationBehavior, - metadata: { - ...subscription.metadata, - seats: seats.toString(), - }, - }); - - // Update team max seats - await prismaUsers.team.update({ - where: { id: teamId }, - data: { maxSeats: seats }, - }); - - // Calculate amount for response - let chargeAmount = 0; - let chargeNote = ""; - - if (isIncreasing) { - if (isSameDayAsCycleStart) { - // Full price for new seats - chargeAmount = seatDifference * TEAMS_PRICE_PER_SEAT; - chargeNote = "Full amount charged for new seats (same day as billing cycle start)"; - } else { - // Calculate prorated amount - const cycleEnd = new Date(cycleStart); - cycleEnd.setMonth(cycleEnd.getMonth() + 1); - - const totalDays = Math.ceil((cycleEnd.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24)); - const daysRemaining = Math.ceil((cycleEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); - - chargeAmount = calculateProratedAmount( - Math.max(0, daysRemaining), - totalDays, - seatDifference - ); - chargeNote = `Prorated amount charged for ${daysRemaining} remaining days`; - } - } else { - chargeNote = "Seat reduction will take effect at the next billing cycle. No refund for current period."; - } - - return NextResponse.json({ - message: `Seat count updated from ${currentSeats} to ${seats}`, - previousSeats: currentSeats, - newSeats: seats, - chargeAmount: isIncreasing ? chargeAmount : 0, - chargeAmountFormatted: isIncreasing ? `€${(chargeAmount / 100).toFixed(2)}` : "€0.00", - note: chargeNote, - isSameDayAsCycleStart, - }); - } catch (error) { - console.error("Error updating seats:", error); - return NextResponse.json( - { error: "Failed to update seat count" }, - { status: 500 } - ); - } -} - -/** - * DELETE /api/teams/[teamId]/billing - Cancel subscription (admin only) - */ -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ teamId: string }> } -) { - try { - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { teamId } = await params; - - // Check if user is a team admin - if (!(await isTeamAdmin(session.user.id, teamId))) { - return NextResponse.json( - { error: "Only team admins can cancel subscription" }, - { status: 403 } - ); - } - - const team = await prismaUsers.team.findUnique({ - where: { id: teamId }, - }); - - if (!team) { - return NextResponse.json({ error: "Team not found" }, { status: 404 }); - } - - if (!team.stripeSubscriptionId) { - return NextResponse.json( - { error: "No active subscription to cancel" }, - { status: 400 } - ); - } - - const stripe = ensureStripe(); - - // Cancel at period end (don't immediately revoke access) - await stripe.subscriptions.update(team.stripeSubscriptionId, { - cancel_at_period_end: true, - }); - - return NextResponse.json({ - message: "Subscription will be canceled at the end of the current billing period", - note: "Team members will retain access until then", - }); - } catch (error) { - console.error("Error canceling subscription:", error); - return NextResponse.json( - { error: "Failed to cancel subscription" }, - { status: 500 } - ); - } -} - diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts index 9348b787..f4d1c030 100644 --- a/src/app/api/teams/[teamId]/route.ts +++ b/src/app/api/teams/[teamId]/route.ts @@ -104,11 +104,6 @@ export async function GET( slug: team.slug, logo: team.logo, maxSeats: team.maxSeats, - subscriptionInterval: team.subscriptionInterval, // "monthly" or "annual" - billingCycleStart: team.billingCycleStart, - stripeCustomerId: team.stripeCustomerId, - stripeSubscriptionId: team.stripeSubscriptionId, - aiUsageLimitPerUser: team.aiUsageLimitPerUser, createdAt: team.createdAt, memberCount: team._count.members, blueprintCount: team._count.blueprints, @@ -222,30 +217,13 @@ export async function DELETE( ); } - // Get all team members to downgrade their subscriptions - const members = await prismaUsers.teamMember.findMany({ - where: { teamId }, - select: { userId: true }, - }); - // Delete the team (cascades to members, invitations, etc.) await prismaUsers.team.delete({ where: { id: teamId }, }); - // Downgrade all former team members to FREE plan - await prismaUsers.user.updateMany({ - where: { - id: { in: members.map((m) => m.userId) }, - subscriptionPlan: "TEAMS", - }, - data: { - subscriptionPlan: "FREE", - }, - }); - return NextResponse.json({ - message: "Team deleted successfully. All members have been downgraded to Free plan.", + message: "Team deleted successfully.", }); } catch (error) { console.error("Error deleting team:", error); diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index 8a5f4aee..cb268c7c 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -2,19 +2,13 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prismaUsers } from "@/lib/db-users"; -import { ensureStripe, STRIPE_PRICE_IDS } from "@/lib/stripe"; import { z } from "zod"; -const MIN_SEATS = 3; - -// Validation schema for team creation const createTeamSchema = z.object({ name: z.string().min(2).max(100), slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, { message: "Slug must be lowercase alphanumeric with hyphens only", }), - interval: z.enum(["monthly", "annual"]).optional().default("monthly"), - seats: z.number().min(MIN_SEATS, `Minimum ${MIN_SEATS} seats required`).optional().default(MIN_SEATS), }); /** @@ -47,7 +41,6 @@ export async function GET() { role: m.role, memberCount: m.team._count.members, joinedAt: m.joinedAt, - billingCycleStart: m.team.billingCycleStart, maxSeats: m.team.maxSeats, })); @@ -62,7 +55,7 @@ export async function GET() { } /** - * POST /api/teams - Create a new team (redirects to Stripe checkout) + * POST /api/teams - Create a new team (no billing required) */ export async function POST(request: NextRequest) { try { @@ -81,7 +74,7 @@ export async function POST(request: NextRequest) { ); } - const { name, slug, interval, seats } = validation.data; + const { name, slug } = validation.data; // Check if user is already in a team const existingMembership = await prismaUsers.teamMember.findFirst({ @@ -111,76 +104,26 @@ export async function POST(request: NextRequest) { ); } - // Get or create Stripe customer - const stripe = ensureStripe(); - let stripeCustomerId = await prismaUsers.user.findUnique({ - where: { id: session.user.id }, - select: { stripeCustomerId: true }, - }).then(u => u?.stripeCustomerId); - - if (!stripeCustomerId) { - const customer = await stripe.customers.create({ - email: session.user.email, - name: session.user.name || undefined, - metadata: { - userId: session.user.id, - }, - }); - stripeCustomerId = customer.id; - - await prismaUsers.user.update({ - where: { id: session.user.id }, - data: { stripeCustomerId }, - }); - } - - // Determine price ID based on interval - const priceId = interval === "annual" - ? STRIPE_PRICE_IDS.teams_seat_annual - : STRIPE_PRICE_IDS.teams_seat_monthly; - - if (!priceId) { - return NextResponse.json( - { error: "Teams pricing not configured. Please contact support." }, - { status: 500 } - ); - } - - // Create Stripe checkout session for Teams subscription - const checkoutSession = await stripe.checkout.sessions.create({ - customer: stripeCustomerId, - mode: "subscription", - payment_method_types: ["card"], - line_items: [ - { - price: priceId, - quantity: seats, - }, - ], - subscription_data: { - metadata: { - teamName: name, - teamSlug: slug, - creatorUserId: session.user.id, - type: "teams", - seats: seats.toString(), + // Create team directly (no billing) + const team = await prismaUsers.team.create({ + data: { + name, + slug, + members: { + create: { + userId: session.user.id, + role: "ADMIN", + }, }, }, - metadata: { - teamName: name, - teamSlug: slug, - creatorUserId: session.user.id, - type: "teams", - seats: seats.toString(), - }, - success_url: `${process.env.NEXTAUTH_URL}/teams/${slug}?success=true`, - cancel_url: `${process.env.NEXTAUTH_URL}/teams?cancelled=true`, - allow_promotion_codes: true, }); return NextResponse.json({ - checkoutUrl: checkoutSession.url, - sessionId: checkoutSession.id, + team: { + id: team.id, + name: team.name, + slug: team.slug, + }, }); } catch (error) { console.error("Error creating team:", error); @@ -190,4 +133,3 @@ export async function POST(request: NextRequest) { ); } } - diff --git a/src/app/api/v1/blueprints/[id]/route.ts b/src/app/api/v1/blueprints/[id]/route.ts index 0ecef61b..5ab5495a 100644 --- a/src/app/api/v1/blueprints/[id]/route.ts +++ b/src/app/api/v1/blueprints/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createHash } from "crypto"; import { prismaUsers } from "@/lib/db-users"; +import { APP_URL } from "@/lib/feature-flags"; import { validateApiToken, checkTokenExpiration, @@ -36,7 +37,7 @@ export async function GET( { error: "Token expired", expired_at: expirationCheck.expiredAt?.toISOString(), - message: "Your API token has expired. Please generate a new token at https://lynxprompt.com/settings?tab=api-tokens", + message: `Your API token has expired. Please generate a new token at ${APP_URL}/settings?tab=api-tokens`, }, { status: 401 } ); @@ -180,7 +181,7 @@ export async function PUT( { error: "Token expired", expired_at: expirationCheck.expiredAt?.toISOString(), - message: "Your API token has expired. Please generate a new token at https://lynxprompt.com/settings?tab=api-tokens", + message: `Your API token has expired. Please generate a new token at ${APP_URL}/settings?tab=api-tokens`, }, { status: 401 } ); @@ -391,7 +392,7 @@ export async function DELETE( { error: "Token expired", expired_at: expirationCheck.expiredAt?.toISOString(), - message: "Your API token has expired. Please generate a new token at https://lynxprompt.com/settings?tab=api-tokens", + message: `Your API token has expired. Please generate a new token at ${APP_URL}/settings?tab=api-tokens`, }, { status: 401 } ); diff --git a/src/app/api/v1/blueprints/route.ts b/src/app/api/v1/blueprints/route.ts index 8f4835b3..895e46bf 100644 --- a/src/app/api/v1/blueprints/route.ts +++ b/src/app/api/v1/blueprints/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createHash } from "crypto"; import { prismaUsers } from "@/lib/db-users"; +import { APP_URL } from "@/lib/feature-flags"; import { validateApiToken, checkTokenExpiration, @@ -38,7 +39,7 @@ export async function GET(request: NextRequest) { { error: "Token expired", expired_at: expirationCheck.expiredAt?.toISOString(), - message: "Your API token has expired. Please generate a new token at https://lynxprompt.com/settings?tab=api-tokens", + message: `Your API token has expired. Please generate a new token at ${APP_URL}/settings?tab=api-tokens`, }, { status: 401 } ); @@ -179,7 +180,7 @@ export async function POST(request: NextRequest) { { error: "Token expired", expired_at: expirationCheck.expiredAt?.toISOString(), - message: "Your API token has expired. Please generate a new token at https://lynxprompt.com/settings?tab=api-tokens", + message: `Your API token has expired. Please generate a new token at ${APP_URL}/settings?tab=api-tokens`, }, { status: 401 } ); diff --git a/src/app/api/v1/hierarchies/[id]/route.ts b/src/app/api/v1/hierarchies/[id]/route.ts index 283f946f..1b4df32d 100644 --- a/src/app/api/v1/hierarchies/[id]/route.ts +++ b/src/app/api/v1/hierarchies/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { prismaUsers } from "@/lib/db-users"; +import { APP_URL } from "@/lib/feature-flags"; import { validateApiToken, checkTokenExpiration, @@ -28,7 +29,7 @@ export async function GET( { error: "Token expired", expired_at: expirationCheck.expiredAt?.toISOString(), - message: "Your API token has expired. Please generate a new token at https://lynxprompt.com/settings?tab=api-tokens", + message: `Your API token has expired. Please generate a new token at ${APP_URL}/settings?tab=api-tokens`, }, { status: 401 } ); @@ -208,7 +209,7 @@ export async function PATCH( { error: "Token expired", expired_at: expirationCheck.expiredAt?.toISOString(), - message: "Your API token has expired. Please generate a new token at https://lynxprompt.com/settings?tab=api-tokens", + message: `Your API token has expired. Please generate a new token at ${APP_URL}/settings?tab=api-tokens`, }, { status: 401 } ); @@ -338,7 +339,7 @@ export async function DELETE( { error: "Token expired", expired_at: expirationCheck.expiredAt?.toISOString(), - message: "Your API token has expired. Please generate a new token at https://lynxprompt.com/settings?tab=api-tokens", + message: `Your API token has expired. Please generate a new token at ${APP_URL}/settings?tab=api-tokens`, }, { status: 401 } ); diff --git a/src/app/api/v1/hierarchies/route.ts b/src/app/api/v1/hierarchies/route.ts index 188e1c26..4f939834 100644 --- a/src/app/api/v1/hierarchies/route.ts +++ b/src/app/api/v1/hierarchies/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { prismaUsers } from "@/lib/db-users"; +import { APP_URL } from "@/lib/feature-flags"; import { validateApiToken, checkTokenExpiration, @@ -28,7 +29,7 @@ export async function GET(request: NextRequest) { { error: "Token expired", expired_at: expirationCheck.expiredAt?.toISOString(), - message: "Your API token has expired. Please generate a new token at https://lynxprompt.com/settings?tab=api-tokens", + message: `Your API token has expired. Please generate a new token at ${APP_URL}/settings?tab=api-tokens`, }, { status: 401 } ); @@ -135,7 +136,7 @@ export async function POST(request: NextRequest) { { error: "Token expired", expired_at: expirationCheck.expiredAt?.toISOString(), - message: "Your API token has expired. Please generate a new token at https://lynxprompt.com/settings?tab=api-tokens", + message: `Your API token has expired. Please generate a new token at ${APP_URL}/settings?tab=api-tokens`, }, { status: 401 } ); diff --git a/src/app/api/v1/user/route.ts b/src/app/api/v1/user/route.ts index c100f156..ccb6a753 100644 --- a/src/app/api/v1/user/route.ts +++ b/src/app/api/v1/user/route.ts @@ -6,6 +6,7 @@ import { hasPermission, canUseApi, } from "@/lib/api-tokens"; +import { APP_URL } from "@/lib/feature-flags"; /** * GET /api/v1/user @@ -22,7 +23,7 @@ export async function GET(request: NextRequest) { { error: "Token expired", expired_at: expirationCheck.expiredAt?.toISOString(), - message: "Your API token has expired. Please generate a new token at https://lynxprompt.com/settings?tab=api-tokens", + message: `Your API token has expired. Please generate a new token at ${APP_URL}/settings?tab=api-tokens`, }, { status: 401 } ); @@ -68,11 +69,7 @@ export async function GET(request: NextRequest) { persona: true, skillLevel: true, subscriptionPlan: true, - subscriptionStatus: true, - subscriptionInterval: true, - currentPeriodEnd: true, createdAt: true, - // Count user's blueprints _count: { select: { templates: true, @@ -96,12 +93,7 @@ export async function GET(request: NextRequest) { display_name: user.displayName, persona: user.persona, skill_level: user.skillLevel, - subscription: { - plan: user.subscriptionPlan, - status: user.subscriptionStatus, - interval: user.subscriptionInterval, - current_period_end: user.currentPeriodEnd?.toISOString() || null, - }, + plan: user.subscriptionPlan, stats: { blueprints_count: user._count.templates, }, diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index b2b14a43..ecd4f429 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -8,12 +8,21 @@ import { Button } from "@/components/ui/button"; import { Mail, Github, Chrome, ArrowLeft, Loader2, Terminal, CheckCircle, Building2, KeyRound } from "lucide-react"; import { Logo } from "@/components/logo"; import { Turnstile } from "@/components/turnstile"; +import { useFeatureFlags } from "@/components/providers/feature-flags-provider"; function SignInContent() { const searchParams = useSearchParams(); const error = searchParams.get("error"); const cliSession = searchParams.get("cli_session"); const { data: session, status } = useSession(); + const { + enableGithubOAuth, + enableGoogleOAuth, + enableEmailAuth, + enableTurnstile, + enableSSO, + enableUserRegistration, + } = useFeatureFlags(); // ALL useState hooks must be declared before any conditional returns (React rules of hooks) const [cliAuthComplete, setCliAuthComplete] = useState(false); @@ -134,8 +143,7 @@ function SignInContent() { return "/dashboard"; // Default safe redirect })(); - // Turnstile is always enabled for magic link (component handles bypass internally) - const turnstileEnabled = true; + const turnstileEnabled = enableTurnstile; const handleMagicLink = async (e: React.FormEvent) => { e.preventDefault(); @@ -325,9 +333,11 @@ function SignInContent() {
{error === "OAuthAccountNotLinked" ? "This email is already associated with another account." - : error === "Configuration" - ? "Server configuration error. Please try again later." - : "An error occurred. Please try again."} + : error === "RegistrationDisabled" + ? "New account registration is currently disabled. Only existing users can sign in." + : error === "Configuration" + ? "Server configuration error. Please try again later." + : "An error occurred. Please try again."}
)} @@ -340,47 +350,53 @@ function SignInContent() { {/* OAuth Buttons */}
- - + {enableGithubOAuth && ( + + )} + {enableGoogleOAuth && ( + + )} {/* Teams SSO */} - + {enableSSO && ( + + )} {/* SSO Expanded Section */} - {showSSO && ( + {enableSSO && showSSO && (

Enter your work email to sign in with your organization's SSO. @@ -450,15 +466,17 @@ function SignInContent() { )}

- {/* Divider */} -
-
- or -
-
+ {/* Divider - only show if both OAuth and email are enabled */} + {(enableGithubOAuth || enableGoogleOAuth || enableSSO) && enableEmailAuth && ( +
+
+ or +
+
+ )} {/* Magic Link */} -
+ {enableEmailAuth &&
@@ -515,7 +533,14 @@ function SignInContent() { {magicLinkError}
)} - + } + + {/* Registration disabled notice */} + {!enableUserRegistration && ( +

+ New account registration is currently disabled. Only existing users can sign in. +

+ )} {/* Terms notice - consent required for new users on separate page */}

diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 80e2f2de..1d13b550 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -7,6 +7,7 @@ import type { Metadata } from "next"; import { authOptions } from "@/lib/auth"; import { prismaBlog } from "@/lib/db-blog"; import { prismaUsers } from "@/lib/db-users"; +import { APP_NAME, APP_URL, APP_LOGO_URL } from "@/lib/feature-flags"; import { Button } from "@/components/ui/button"; import { ArrowLeft, @@ -73,7 +74,7 @@ export async function generateMetadata({ images: post.coverImage ? [post.coverImage] : undefined, }, alternates: { - canonical: `https://lynxprompt.com/blog/${slug}`, + canonical: `${APP_URL}/blog/${slug}`, }, }; } @@ -229,25 +230,25 @@ export default async function BlogPostPage({ params }: PageProps) { "@type": "Article", headline: post.title, description: post.excerpt || post.title, - image: post.coverImage || "https://lynxprompt.com/og-image.png", + image: post.coverImage || `${APP_URL}/og-image.png`, datePublished: (post.publishedAt || post.createdAt).toISOString(), dateModified: post.updatedAt?.toISOString() || (post.publishedAt || post.createdAt).toISOString(), author: { "@type": "Person", name: authorName, - url: `https://lynxprompt.com/users/${post.authorId}`, + url: `${APP_URL}/users/${post.authorId}`, }, publisher: { "@type": "Organization", - name: "LynxPrompt", + name: APP_NAME, logo: { "@type": "ImageObject", - url: "https://lynxprompt.com/og-image.png", + url: APP_LOGO_URL || `${APP_URL}/og-image.png`, }, }, mainEntityOfPage: { "@type": "WebPage", - "@id": `https://lynxprompt.com/blog/${slug}`, + "@id": `${APP_URL}/blog/${slug}`, }, keywords: post.tags.join(", "), }; @@ -264,9 +265,6 @@ export default async function BlogPostPage({ params }: PageProps) {

@@ -146,7 +147,7 @@ export default function BlueprintsApiDocsPage() {
                 {`curl -H "Authorization: Bearer lp_your_token" \\
-     https://lynxprompt.com/api/v1/blueprints/bp_clw2m8k0x0001`}
+     ${APP_URL}/api/v1/blueprints/bp_clw2m8k0x0001`}
               
@@ -202,7 +203,7 @@ export default function BlueprintsApiDocsPage() {

Example Request

-                {`curl -X POST https://lynxprompt.com/api/v1/blueprints \\
+                {`curl -X POST ${APP_URL}/api/v1/blueprints \\
      -H "Authorization: Bearer lp_your_token" \\
      -H "Content-Type: application/json" \\
      -d '{
@@ -245,7 +246,7 @@ export default function BlueprintsApiDocsPage() {
             

Example Request

-                {`curl -X PUT https://lynxprompt.com/api/v1/blueprints/bp_clw2m8k0x0001 \\
+                {`curl -X PUT ${APP_URL}/api/v1/blueprints/bp_clw2m8k0x0001 \\
      -H "Authorization: Bearer lp_your_token" \\
      -H "Content-Type: application/json" \\
      -d '{
@@ -285,7 +286,7 @@ export default function BlueprintsApiDocsPage() {
             

Example Request

-                {`curl -X DELETE https://lynxprompt.com/api/v1/blueprints/bp_clw2m8k0x0001 \\
+                {`curl -X DELETE ${APP_URL}/api/v1/blueprints/bp_clw2m8k0x0001 \\
      -H "Authorization: Bearer lp_your_token"`}
               
@@ -317,7 +318,7 @@ export default function BlueprintsApiDocsPage() { export LYNXPROMPT_API_TOKEN="lp_your_token_here" # Update blueprint from file -curl -X PUT https://lynxprompt.com/api/v1/blueprints/bp_your_id \\ +curl -X PUT ${APP_URL}/api/v1/blueprints/bp_your_id \\ -H "Authorization: Bearer $LYNXPROMPT_API_TOKEN" \\ -H "Content-Type: application/json" \\ -d "{\\"content\\": $(cat .cursor/rules | jq -Rs .)}" @@ -342,7 +343,7 @@ $env:LYNXPROMPT_API_TOKEN = "lp_your_token_here" # Read file and update blueprint $content = (Get-Content ".cursor\\rules" -Raw) -replace '"', '\\"' $body = @{ content = $content } | ConvertTo-Json -Invoke-RestMethod -Uri "https://lynxprompt.com/api/v1/blueprints/bp_your_id" \` +Invoke-RestMethod -Uri "${APP_URL}/api/v1/blueprints/bp_your_id" \` -Method PUT \` -Headers @{ "Authorization" = "Bearer $env:LYNXPROMPT_API_TOKEN"; "Content-Type" = "application/json" } \` -Body $body @@ -387,7 +388,7 @@ jobs: - name: Sync to LynxPrompt run: | - curl -X PUT https://lynxprompt.com/api/v1/blueprints/bp_your_id \\ + curl -X PUT ${APP_URL}/api/v1/blueprints/bp_your_id \\ -H "Authorization: Bearer \${{ secrets.LYNXPROMPT_API_TOKEN }}" \\ -H "Content-Type: application/json" \\ -d "{\\"content\\": $(cat .cursor/rules | jq -Rs .)}" diff --git a/src/app/docs/api/page.tsx b/src/app/docs/api/page.tsx index aba5c6db..8e35d75f 100644 --- a/src/app/docs/api/page.tsx +++ b/src/app/docs/api/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { APP_URL } from "@/lib/feature-flags"; import { Button } from "@/components/ui/button"; import { Key, ArrowRight, FileCode, User, Shield, Clock } from "lucide-react"; @@ -108,7 +109,7 @@ export default function ApiDocsPage() {
                   {`curl -H "Authorization: Bearer lp_your_token_here" \\
-     https://lynxprompt.com/api/v1/blueprints`}
+     ${APP_URL}/api/v1/blueprints`}
                 
@@ -136,7 +137,7 @@ export default function ApiDocsPage() {

- https://lynxprompt.com/api/v1 + {APP_URL}/api/v1
diff --git a/src/app/docs/api/user/page.tsx b/src/app/docs/api/user/page.tsx index f9b31dce..8d3e53f6 100644 --- a/src/app/docs/api/user/page.tsx +++ b/src/app/docs/api/user/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { APP_URL } from "@/lib/feature-flags"; import { User } from "lucide-react"; export default function UserApiDocsPage() { @@ -49,7 +50,7 @@ export default function UserApiDocsPage() {
                 {`curl -H "Authorization: Bearer lp_your_token" \\
-     https://lynxprompt.com/api/v1/user`}
+     ${APP_URL}/api/v1/user`}
               
diff --git a/src/app/docs/cli/authentication/page.tsx b/src/app/docs/cli/authentication/page.tsx index dc9aabed..0b7a80d5 100644 --- a/src/app/docs/cli/authentication/page.tsx +++ b/src/app/docs/cli/authentication/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import type { Metadata } from "next"; +import { APP_URL } from "@/lib/feature-flags"; import { Key, ArrowRight, Shield, Terminal, LogOut, User, AlertTriangle } from "lucide-react"; export const metadata: Metadata = { @@ -38,7 +39,7 @@ export default function CliAuthenticationPage() { {`$ lynxprompt login πŸ” Opening browser to authenticate... - https://lynxprompt.com/auth/signin?cli_session=abc123 + ${APP_URL}/auth/signin?cli_session=abc123 Waiting for authentication... βœ“ diff --git a/src/app/docs/cli/commands/page.tsx b/src/app/docs/cli/commands/page.tsx index 4ee385c3..da038e2c 100644 --- a/src/app/docs/cli/commands/page.tsx +++ b/src/app/docs/cli/commands/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import type { Metadata } from "next"; +import { APP_URL } from "@/lib/feature-flags"; import { Command, FileCode, Search, Download, User, LogIn, LogOut, Info, ArrowRight, Layers, Cloud, ArrowUp, ArrowDown, Link2, Unlink, CheckCircle, FileSearch, Sparkles, Scan, GitMerge, ArrowRightLeft, FolderSearch } from "lucide-react"; export const metadata: Metadata = { @@ -873,7 +874,7 @@ Options: LYNXPROMPT_API_URL - Custom API URL (default: https://lynxprompt.com) + Custom API URL (default: {APP_URL}) diff --git a/src/app/docs/faq/billing/page.tsx b/src/app/docs/faq/billing/page.tsx index a2f476aa..0aca18cd 100644 --- a/src/app/docs/faq/billing/page.tsx +++ b/src/app/docs/faq/billing/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; -export default function BillingFAQPage() { +export default function MarketplacePaymentsFAQPage() { return (
{/* Header */} @@ -10,140 +10,70 @@ export default function BillingFAQPage() { FAQ / - Billing & Subscriptions + Marketplace Payments

- Billing & Subscriptions + Marketplace Payments

- Common questions about payments, subscriptions, and refunds. + Common questions about buying and selling blueprints on the + marketplace.

{/* FAQ items */}
-

What payment methods do you accept?

+

Is LynxPrompt free to use?

- We accept all major credit cards (Visa, Mastercard, American - Express) through Stripe. + Yes. All features β€” the wizard, blueprints, AI editing, API access, + and CLI β€” are free. The only payments that exist are optional + marketplace purchases when a blueprint creator sets a price.

-

Can I cancel my subscription?

-

- Monthly subscriptions: Yes, you can cancel anytime from{" "} - - Settings β†’ Billing - - . You'll retain access until the end of your billing period. -

- Annual subscriptions: Annual plans are a yearly commitment and cannot be - canceled mid-cycle. You keep full access until the year ends, but no refunds are provided for - the remaining period. -

-
- -
-

Do you offer refunds?

-

- Subscriptions: We don't offer refunds for - partial months, but you can cancel anytime and keep access until the - period ends. -
-
- Blueprint purchases: Per EU digital content - regulations, refunds aren't available after download because - you consent to immediate access. Refunds may be considered for - non-delivery or technical issues. -

-
- -
-

What's the difference between Users and Teams?

-

- Users (Free): Full wizard access, all platforms, API access, - ability to sell blueprints (70% revenue), browse and download blueprints. -
-
- Teams (€10/seat/month): Everything in Users, plus AI-powered - blueprint editing, team-shared blueprints, SSO (SAML, OIDC, LDAP), - centralized billing, and 10% off all paid blueprint purchases. -

-
- -
-

Do you offer annual billing?

-

- Yes! You can choose between monthly and annual billing on our{" "} - - pricing page - - . Annual plans offer a 10% discount compared to monthly billing: -

- Teams: €108/seat/year (€9/seat/month) vs €10/seat/month -

- Note: Annual subscriptions are a yearly commitment and cannot be canceled or refunded mid-cycle. -

-
- -
-

How do I upgrade or downgrade?

+

+ How do marketplace payments work? +

- Go to{" "} - - Settings β†’ Billing - {" "} - and click "Change Plan". Changes take effect immediately, - and you'll be charged the prorated difference. + When the platform has payments enabled (ENABLE_STRIPE=true), + blueprint creators can set a price (minimum €5) on their blueprints. + Buyers pay through Stripe at checkout. The seller receives 70% and + the platform takes a 30% commission.

-

Where can I see my invoices?

+

What payment methods are accepted?

- Click "Manage Billing" in{" "} - - Settings β†’ Billing - {" "} - to access Stripe's customer portal, where you can view and - download all invoices. + All major credit cards (Visa, Mastercard, American Express) through + Stripe. The exact amount is shown before you confirm.

-

- I'm having trouble with payment. What should I do? -

+

Can I get a refund for a blueprint purchase?

- First, check that your card details are correct in the billing - portal. If the problem persists, contact{" "} + Per EU digital content regulations, refunds aren't available + after download because you consent to immediate access. Refunds may + be considered for non-delivery or technical issues β€” contact{" "} support@lynxprompt.com - {" "} - with your account email and we'll help resolve the issue. + + .

How do seller payouts work?

- If you sell blueprints, you can request a payout when you have at - least €5 in available earnings. Payouts are sent via PayPal and - typically process within 3-5 business days. See{" "} + When you have at least €5 in available earnings, you can request a + payout. Payouts are sent via PayPal and typically process within 3-5 + business days. See{" "}

-

What currency do you charge in?

+

What currency are prices in?

All prices are in Euros (EUR). Stripe automatically converts to your - card's currency at checkout. You'll see the converted - amount before confirming. + card's currency at checkout. +

+
+ +
+

+ Are payments available on self-hosted instances? +

+

+ Only if the instance administrator enables Stripe by setting{" "} + ENABLE_STRIPE=true{" "} + and providing valid Stripe API keys. Without this, the marketplace + is free-only and all blueprints can be downloaded at no cost.

@@ -182,4 +123,3 @@ export default function BillingFAQPage() {
); } - diff --git a/src/app/docs/layout.tsx b/src/app/docs/layout.tsx index 99824573..d399967a 100644 --- a/src/app/docs/layout.tsx +++ b/src/app/docs/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { APP_NAME, APP_URL } from "@/lib/feature-flags"; import { PageHeader } from "@/components/page-header"; import { Footer } from "@/components/footer"; import { DocsSidebar, DocsSidebarMobile } from "@/components/docs-sidebar"; @@ -6,7 +7,7 @@ import { DocsToc } from "@/components/docs-toc"; export const metadata: Metadata = { title: { - template: "%s | LynxPrompt Docs", + template: `%s | ${APP_NAME} Docs`, default: "Documentation", }, description: @@ -22,13 +23,13 @@ export const metadata: Metadata = { "AGENTS.md", ], openGraph: { - title: "Documentation - LynxPrompt", + title: `Documentation - ${APP_NAME}`, description: "Learn how to create AI IDE configurations with LynxPrompt.", type: "website", }, alternates: { - canonical: "https://lynxprompt.com/docs", + canonical: `${APP_URL}/docs`, }, }; diff --git a/src/app/docs/marketplace/page.tsx b/src/app/docs/marketplace/page.tsx index 47e6e7e9..e4d7d524 100644 --- a/src/app/docs/marketplace/page.tsx +++ b/src/app/docs/marketplace/page.tsx @@ -180,10 +180,10 @@ export default function MarketplaceOverviewPage() {

- View Pricing + Get Started
diff --git a/src/app/docs/marketplace/pricing/page.tsx b/src/app/docs/marketplace/pricing/page.tsx index 24f46879..44d7c69e 100644 --- a/src/app/docs/marketplace/pricing/page.tsx +++ b/src/app/docs/marketplace/pricing/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; +import { Check, ArrowRight } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Check, ArrowRight, X } from "lucide-react"; export default function PricingPage() { return ( @@ -12,191 +12,108 @@ export default function PricingPage() { Marketplace / - Pricing & Plans + Pricing
-

Pricing & Plans

+

Pricing

- LynxPrompt offers full wizard access to everyone. Teams adds AI assistance and enterprise features. + LynxPrompt is free and open-source. All core features are available to + every user at no cost.

- {/* Plans comparison */} + {/* Free features */}
-

Subscription Plans

-
- {/* Users (Free) */} -
-
-
- Most Popular -
-

Users

-
- €0 - /forever -
+

Everything is Free

+
+
+

All Features Included

+
+ €0 + /forever
-
    -
  • - - Full wizard (all steps) -
  • -
  • - - All 16+ platform outputs -
  • -
  • - - API access for blueprints -
  • -
  • - - Create & store blueprints -
  • -
  • - - Save wizard drafts -
  • -
  • - - Sell blueprints (70% revenue) -
  • -
  • - - Browse & download blueprints -
  • -
  • - - AI-powered editing -
  • -
  • - - Team-shared blueprints -
  • -
  • - - SSO integration -
  • -
-
+
    +
  • + + Full wizard (all steps, all platform outputs) +
  • +
  • + + Create, store & share blueprints +
  • +
  • + + Browse & download community blueprints +
  • +
  • + + AI-powered editing & wizard assistant (when enabled) +
  • +
  • + + REST API access +
  • +
  • + + CLI tool +
  • +
  • + + Sell blueprints on the marketplace (70% revenue) +
  • +
  • + + Self-hosting support +
  • +
+
+
- {/* Teams */} -
-
-
- Enterprise -
-

Teams

-
- €10 - /seat/month -
-

- Minimum 3 seats β€’ 10% off annual -

+ {/* Marketplace payments */} +
+

Marketplace Payments

+

+ When marketplace payments are enabled (ENABLE_STRIPE=true), + blueprint creators can set prices on their blueprints. Buyers pay through Stripe at checkout. +

+
+
+
+

Seller receives

+

70%

+
+
+

Platform commission

+

30%

+
+
+

Minimum price

+

€5

-
    -
  • - - Everything in Users -
  • -
  • - - AI-powered blueprint editing -
  • -
  • - - AI wizard assistant -
  • -
  • - - Team-shared blueprints -
  • -
  • - - SSO (SAML, OIDC, LDAP) -
  • -
  • - - Centralized billing -
  • -
  • - - Only pay for active users -
  • -
  • - - Extended AI usage limits -
  • -
  • - - Priority support -
  • -
-
+

+ If ENABLE_STRIPE is + not set, the marketplace is browse-only and all blueprints are free to download. +

- {/* FAQ */} + {/* Self-hosting note */}
-

Common Questions

-
-
-

Why is most of LynxPrompt free?

-

- We believe everyone should have access to great AI IDE configurations. The full wizard, - all platform outputs, API access, and selling blueprints are all free. Teams is for - organizations that need AI assistance (which costs us money to provide) and enterprise features. -

-
-
-

What payment methods do you accept?

-

- We accept all major credit cards through Stripe. -

-
-
-

Do you offer annual billing?

-

- Yes! Annual Teams billing offers a 10% discount. Annual subscriptions - cannot be canceled mid-cycle but provide significant savings. -

-
-
-

How does Teams billing work?

-

- Teams is billed at €10 per seat per month (€9/seat/month with annual), - with a minimum of 3 seats. You only pay for active users β€” team members who - haven't logged in during the billing cycle aren't charged. Pro-rated billing applies - when adding seats mid-cycle. -

-
-
-

What SSO providers does Teams support?

-

- Teams supports SAML 2.0 (Okta, Azure AD, OneLogin), - OpenID Connect (Google Workspace, Auth0), and - LDAP/Active Directory. Team admins configure SSO from - the team settings dashboard. -

-
-
-

Can team members share blueprints privately?

-

- Yes! Team blueprints have three visibility levels: Private (only you), - Team (all team members), or Public (everyone). - Great for sharing internal coding standards and company-specific configurations. -

-
+

Self-Hosting

+
+

+ Running your own instance? All features work out of the box. AI features + require an ANTHROPIC_API_KEY, + and marketplace payments require Stripe credentials. See the{" "} + + Self-Hosting Guide + {" "} + for full configuration details. +

@@ -209,8 +126,8 @@ export default function PricingPage() {

diff --git a/src/app/docs/marketplace/selling/page.tsx b/src/app/docs/marketplace/selling/page.tsx index c36eb539..64030f41 100644 --- a/src/app/docs/marketplace/selling/page.tsx +++ b/src/app/docs/marketplace/selling/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { DollarSign, Check, ArrowRight } from "lucide-react"; +import { Check, ArrowRight } from "lucide-react"; export default function SellingBlueprintsPage() { return ( @@ -18,8 +18,18 @@ export default function SellingBlueprintsPage() { Selling Blueprints

- Turn your AI configuration expertise into income. All users - can create and sell premium blueprints. + Turn your AI configuration expertise into income. Any user can create + and sell paid blueprints when marketplace payments are enabled. +

+ + + {/* Stripe note */} +
+

+ Note: Selling paid blueprints requires the platform to + have ENABLE_STRIPE=true{" "} + with valid Stripe API keys configured. On self-hosted instances without + Stripe, you can still share blueprints for free.

@@ -85,7 +95,7 @@ export default function SellingBlueprintsPage() {

Your Earnings

- You keep 70% of every sale. Here's what that looks like: + You keep 70% of every sale. The platform takes a 30% commission.

@@ -199,11 +209,3 @@ export default function SellingBlueprintsPage() { ); } - - - - - - - - diff --git a/src/app/docs/self-hosting/page.tsx b/src/app/docs/self-hosting/page.tsx new file mode 100644 index 00000000..0cea9c73 --- /dev/null +++ b/src/app/docs/self-hosting/page.tsx @@ -0,0 +1,626 @@ +import Link from "next/link"; +import { Server, Database, Shield, Sparkles, Paintbrush, Terminal, HeartPulse } from "lucide-react"; + +export default function SelfHostingPage() { + return ( +
+ {/* Header */} +
+
+
+ +
+

Self-Hosting

+
+

+ Run your own LynxPrompt instance with full control over data, + features, and branding. A single Docker Compose file gets you + started in minutes. +

+
+ + {/* Quick start */} +
+

Quick Start

+

+ The fastest way to get LynxPrompt running is with the provided{" "} + docker-compose.selfhost.yml. +

+
+
+
+ 1 +
+
+

Create an environment file

+
+                {`# .env
+NEXTAUTH_SECRET=$(openssl rand -base64 32)
+ADMIN_EMAIL=you@example.com
+APP_URL=https://lynxprompt.yourcompany.com`}
+              
+
+
+
+
+ 2 +
+
+

Start the services

+
+                {`docker compose -f docker-compose.selfhost.yml up -d`}
+              
+
+
+
+
+ 3 +
+
+

Open the app

+

+ Navigate to{" "} + http://localhost:3000{" "} + (or your configured APP_URL). + The first user matching{" "} + ADMIN_EMAIL{" "} + is automatically promoted to superadmin. +

+
+
+
+ +
+

+ Requirements: Docker Engine 24+ and Docker Compose v2. + The default compose file uses a single PostgreSQL instance and + exposes port 3000. +

+
+
+ + {/* Environment variables */} +
+

Environment Variables

+

+ All configuration is done through environment variables. Only{" "} + NEXTAUTH_SECRET is + strictly required. +

+ + {/* Core */} +

Core

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultDescription
NEXTAUTH_SECRETβ€”Session encryption key. Required. Generate with openssl rand -base64 32
APP_URLhttp://localhost:3000Public URL of the instance (used for callbacks, emails, and CLI)
NEXTAUTH_URLsame as APP_URLNextAuth callback URL. Usually the same as APP_URL
SUPERADMIN_EMAILβ€”Email auto-promoted to superadmin on first sign-in
NODE_ENVproductionSet to production for self-hosted deployments
+
+ + {/* Database */} +

Database

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultDescription
DATABASE_URL_APPβ€”Main application database (blueprints, marketplace)
DATABASE_URL_USERSβ€”User accounts, sessions, authentication
DATABASE_URL_BLOGβ€”Blog content (when ENABLE_BLOG is on)
DATABASE_URL_SUPPORTβ€”Support forum data (when ENABLE_SUPPORT_FORUM is on)
+
+ + {/* Auth */} +

Authentication

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultDescription
ENABLE_EMAIL_AUTHtrueMagic link email sign-in (requires SMTP)
ENABLE_PASSKEYStrueWebAuthn passkey sign-in
ENABLE_GITHUB_OAUTHfalseGitHub OAuth (requires GITHUB_ID + GITHUB_SECRET)
ENABLE_GOOGLE_OAUTHfalseGoogle OAuth (requires GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET)
ENABLE_SSOfalseEnterprise SSO (SAML, OIDC, LDAP)
ENABLE_USER_REGISTRATIONtrueAllow new user registration. Set to false for invite-only
ENABLE_TURNSTILEfalseCloudflare Turnstile CAPTCHA on sign-up
+
+ + {/* AI */} +

AI Features

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultDescription
ENABLE_AIfalseEnable AI editing & wizard assistant
ANTHROPIC_API_KEYβ€”Anthropic API key (required when ENABLE_AI is true)
AI_MODELclaude-3-5-haiku-latestAnthropic model to use for AI features
+
+ + {/* Marketplace */} +

Marketplace

+
+ + + + + + + + + + + + + + + +
VariableDefaultDescription
ENABLE_STRIPEfalseEnable paid blueprint purchases (requires Stripe keys)
+
+ + {/* Branding */} +

Branding & Content

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultDescription
APP_NAMELynxPromptApplication name shown in UI, emails, and metadata
APP_LOGO_URLβ€”URL to a custom logo image
CONTACT_EMAILβ€”Displayed as contact email in the UI
STATUS_PAGE_URLβ€”Link to your status page (e.g., Upptime, Kuma)
ENABLE_BLOGfalseEnable the built-in blog
ENABLE_SUPPORT_FORUMfalseEnable the support forum
UMAMI_SCRIPT_URLβ€”Umami analytics script URL (self-hosted analytics)
+
+
+ + {/* Authentication */} +
+
+ +

Authentication Configuration

+
+

+ Out of the box, LynxPrompt supports passkeys and email magic links. Add + OAuth providers or lock down registration as needed. +

+
+
+

Passkeys (default: on)

+

+ WebAuthn passkeys work immediately with no extra configuration. + Requires HTTPS in production for browser WebAuthn APIs. +

+
+
+

Email Magic Links (default: on)

+

+ Requires an SMTP server. Set{" "} + EMAIL_SERVER and{" "} + EMAIL_FROM in your + environment. Without SMTP, disable with{" "} + ENABLE_EMAIL_AUTH=false. +

+
+
+

GitHub OAuth

+

+ Set ENABLE_GITHUB_OAUTH=true,{" "} + GITHUB_ID, and{" "} + GITHUB_SECRET. + Create an OAuth App at{" "} + GitHub β†’ Settings β†’ Developer settings β†’ OAuth Apps. + Set the callback URL to{" "} + {`{APP_URL}/api/auth/callback/github`}. +

+
+
+

Google OAuth

+

+ Set ENABLE_GOOGLE_OAUTH=true,{" "} + GOOGLE_CLIENT_ID, and{" "} + GOOGLE_CLIENT_SECRET. + Configure in Google Cloud Console with redirect URI{" "} + {`{APP_URL}/api/auth/callback/google`}. +

+
+
+

Invite-Only Mode

+

+ Set ENABLE_USER_REGISTRATION=false{" "} + to prevent new sign-ups. Existing users can still sign in. Admins can + invite users via the admin panel. +

+
+
+
+ + {/* AI Features */} +
+
+ +

AI Features Setup

+
+

+ AI-powered blueprint editing and wizard assistance are opt-in. +

+
+
+
+ 1 +
+
+

Get an Anthropic API Key

+

+ Sign up at{" "} + + console.anthropic.com + {" "} + and create an API key. +

+
+
+
+
+ 2 +
+
+

Set environment variables

+
+                {`ENABLE_AI=true
+ANTHROPIC_API_KEY=sk-ant-...
+AI_MODEL=claude-3-5-haiku-latest  # optional`}
+              
+
+
+
+
+ 3 +
+
+

Restart the container

+

+ AI buttons will appear automatically in the UI for all users. +

+
+
+
+
+

+ Cost note: AI requests are billed by Anthropic to your + API key. LynxPrompt does not add any surcharge. Monitor usage at the + Anthropic dashboard. +

+
+
+ + {/* Custom branding */} +
+
+ +

Custom Branding

+
+

+ White-label LynxPrompt for your organization. +

+
+          {`APP_NAME=MyCompany Prompts
+APP_LOGO_URL=https://cdn.mycompany.com/logo.svg
+CONTACT_EMAIL=support@mycompany.com
+STATUS_PAGE_URL=https://status.mycompany.com`}
+        
+

+ The app name is used throughout the UI, in page titles, email + templates, and OpenGraph metadata. The logo replaces the default + LynxPrompt logo in the header and email templates. +

+
+ + {/* Database architecture */} +
+
+ +

Database Architecture

+
+

+ LynxPrompt uses four Prisma clients, each with its own connection + string. This allows flexible deployment topologies. +

+
+
+

Single Database (recommended)

+

+ Point all four DATABASE_URL_* variables to the + same PostgreSQL database. This is the default in{" "} + docker-compose.selfhost.yml and is the simplest + setup. +

+
+              {`DATABASE_URL_APP=postgresql://...
+DATABASE_URL_USERS=postgresql://...   # same
+DATABASE_URL_BLOG=postgresql://...    # same
+DATABASE_URL_SUPPORT=postgresql://... # same`}
+            
+
+
+

Multi-Database

+

+ For larger deployments, split databases by concern. Each client + connects to a separate database or server, allowing independent + scaling and backup strategies. +

+
+              {`DATABASE_URL_APP=postgresql://app-db/lynx
+DATABASE_URL_USERS=postgresql://auth-db/users
+DATABASE_URL_BLOG=postgresql://blog-db/blog
+DATABASE_URL_SUPPORT=postgresql://sup-db/forum`}
+            
+
+
+
+ + {/* CLI Setup */} +
+
+ +

CLI for Self-Hosted Instances

+
+

+ The{" "} + + LynxPrompt CLI + {" "} + works with self-hosted instances. After installing the CLI, point it to + your instance: +

+
+          {`lynxprompt config set api-url https://lynxprompt.yourcompany.com
+lynxprompt login`}
+        
+

+ This stores the API URL locally. All subsequent CLI commands (push, + pull, sync) will target your self-hosted instance instead of the + public service. +

+
+ + {/* Health check */} +
+
+ +

Health Check

+
+

+ LynxPrompt exposes a health endpoint for monitoring and orchestration: +

+
+          {`GET /api/health
+
+# Healthy response (200):
+{"status":"ok","db":"connected"}
+
+# Unhealthy response (503):
+{"status":"error","db":"disconnected"}`}
+        
+

+ Use this endpoint in Docker health checks, Kubernetes liveness probes, + or external monitoring tools like Uptime Kuma. +

+
+          {`# Docker Compose healthcheck example
+healthcheck:
+  test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
+  interval: 30s
+  timeout: 5s
+  retries: 3`}
+        
+
+ + {/* Next steps */} +
+

Next Steps

+
+ + +
+

Install the CLI

+

+ npm, Homebrew, Chocolatey, or Snap +

+
+ + + +
+

AI Features

+

+ Learn what AI can do +

+
+ + + +
+

API Reference

+

+ Integrate programmatically +

+
+ + + +
+

Pricing

+

+ Marketplace payment details +

+
+ +
+
+ + ); +} diff --git a/src/app/dpa/page.tsx b/src/app/dpa/page.tsx index 16b6e64c..b04cfa67 100644 --- a/src/app/dpa/page.tsx +++ b/src/app/dpa/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import type { Metadata } from "next"; import { FileSignature } from "lucide-react"; +import { APP_URL } from "@/lib/feature-flags"; import { PageHeader } from "@/components/page-header"; import { Footer } from "@/components/footer"; @@ -21,7 +22,7 @@ export const metadata: Metadata = { type: "website", }, alternates: { - canonical: "https://lynxprompt.com/dpa", + canonical: `${APP_URL}/dpa`, }, robots: { index: true, diff --git a/src/app/error.tsx b/src/app/error.tsx index 81e232a5..0f67b68e 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect } from "react"; +import { useFeatureFlags } from "@/components/providers/feature-flags-provider"; export default function Error({ error, @@ -9,6 +10,8 @@ export default function Error({ error: Error & { digest?: string }; reset: () => void; }) { + const flags = useFeatureFlags(); + useEffect(() => { console.error("Application error:", error); }, [error]); @@ -60,18 +63,20 @@ export default function Error({ {/* Status Link */} -

- Check our{" "} - - status page - {" "} - for updates. -

+ {flags.statusPageUrl && ( +

+ Check our{" "} + + status page + {" "} + for updates. +

+ )} {/* Error digest for debugging */} {error.digest && ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6c5a0148..cf36a4a1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,8 +5,10 @@ import { ThemeProvider } from "@/components/providers/theme-provider"; import { SessionProvider } from "@/components/providers/session-provider"; import { SentryProvider } from "@/components/providers/sentry-provider"; +import { FeatureFlagsProvider } from "@/components/providers/feature-flags-provider"; import { Toaster } from "@/components/ui/sonner"; import { CookieBanner } from "@/components/cookie-banner"; +import { APP_NAME, APP_URL, UMAMI_SCRIPT_URL } from "@/lib/feature-flags"; import "./globals.css"; const inter = Inter({ @@ -20,10 +22,10 @@ const jetbrainsMono = JetBrains_Mono({ }); export const metadata: Metadata = { - metadataBase: new URL("https://lynxprompt.com"), + metadataBase: new URL(APP_URL), title: { - default: "LynxPrompt - AI IDE Configuration Generator", - template: "%s | LynxPrompt", + default: `${APP_NAME} - AI IDE Configuration Generator`, + template: `%s | ${APP_NAME}`, }, description: "Transform your development setup into a mouse-click experience. Generate .cursorrules, CLAUDE.md, and more with smart conditional logic.", @@ -43,7 +45,7 @@ export const metadata: Metadata = { ], authors: [{ name: "GeiserCloud", url: "https://geiser.cloud" }], creator: "GeiserCloud", - publisher: "LynxPrompt", + publisher: APP_NAME, icons: { icon: [ { url: "/favicon.ico", sizes: "any" }, @@ -61,28 +63,28 @@ export const metadata: Metadata = { appleWebApp: { capable: true, statusBarStyle: "default", - title: "LynxPrompt", + title: APP_NAME, }, openGraph: { - title: "LynxPrompt β€” AI Rule/Configuration Files Hub", + title: `${APP_NAME} β€” AI Rule/Configuration Files Hub`, description: "AI IDE/Tools rule config generator via WebUI or CLI. Generate, browse, store & share AGENTS.md, CLAUDE.md, and more.", type: "website", - siteName: "LynxPrompt", + siteName: APP_NAME, locale: "en_US", - url: "https://lynxprompt.com", + url: APP_URL, images: [ { url: "/og-image.png", width: 1280, height: 640, - alt: "LynxPrompt - AI IDE/Tools rule config generator", + alt: `${APP_NAME} - AI IDE/Tools rule config generator`, }, ], }, twitter: { card: "summary_large_image", - title: "LynxPrompt β€” AI Rule/Configuration Files Hub", + title: `${APP_NAME} β€” AI Rule/Configuration Files Hub`, description: "AI IDE/Tools rule config generator via WebUI or CLI. Generate, browse, store & share AGENTS.md, CLAUDE.md, and more.", images: ["/og-image.png"], @@ -100,7 +102,7 @@ export const metadata: Metadata = { }, }, alternates: { - canonical: "https://lynxprompt.com", + canonical: APP_URL, types: { "application/rss+xml": "/api/blog/rss", }, @@ -120,9 +122,9 @@ export default function RootLayout({ {/* Umami Analytics - privacy-focused, cookieless */} - {process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && ( + {process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (UMAMI_SCRIPT_URL || "https://umami.lynxprompt.com/script.js") && (