diff --git a/README.md b/README.md index 55a4cbfda..55852df2e 100644 --- a/README.md +++ b/README.md @@ -1,361 +1,192 @@ - -

- - Logo +

+ + Comp AI logo +

Comp AI

+

The agentic compliance platform.

+

Get SOC 2 and ISO 27001 audit-ready in record time, backed by enterprise-grade cybersecurity.

+ +

+ Product Hunt + GitHub Stars + License + Commits per month +

-

Comp AI

- -

- The open-source compliance platform. -
- Learn more » -
-
- Discord - · - Website - · - Documentation - · +

+ Website · + Docs · + Discord · + Roadmap · Issues - · - Roadmap

-

- -

- Product Hunt - Github Stars - License - Commits-per-month - -

- -## About - -### AI that handles compliance for you in hours. - -Comp AI is the fastest way to get compliant with frameworks like SOC 2, ISO 27001, HIPAA and GDPR. Comp AI automates evidence collection, policy management, and control implementation while keeping you in control of your data and infrastructure. - -## Recognition - -#### [ProductHunt](https://www.producthunt.com/posts/comp-ai) - -Comp AI - The open source Vanta & Drata alternative | Product Hunt - -#### [Vercel](https://vercel.com/) - - - Vercel OSS Program - - -### Built With - -- [Next.js](https://nextjs.org/?ref=trycomp.ai) -- [Trigger.dev](https://trigger.dev/?ref=trycomp.ai) -- [Prisma](https://prisma.io/?ref=trycomp.ai) -- [Tailwind CSS](https://tailwindcss.com/?ref=trycomp.ai) -- [Upstash](https://upstash.com/?ref=trycomp.ai) -- [Vercel](https://vercel.com/?ref=trycomp.ai) +
-## Contact us +## Overview -Contact our founders at hello@trycomp.ai to learn more about how we can help you achieve compliance. +Comp AI automates compliance end-to-end: AI agents collect evidence from 500+ integrations, generate policies from your business context, and continuously monitor your security posture — all from a single, open-source platform. -## Stay Up-to-Date +- `apps/app` — main web app (Next.js 16, port `3000`) +- `apps/api` — backend API (NestJS, port `3001`) +- `apps/portal` — customer portal (Next.js 16, port `3002`) +- `apps/docs` — documentation site -Get access to the cloud hosted version of [Comp AI](https://trycomp.ai). +## Contents -## Getting Started +- [Quick start](#quick-start) +- [Monorepo layout](#monorepo-layout) +- [Run commands](#run-commands) +- [Environment setup](#environment-setup) +- [Database](#database) +- [Package publishing](#package-publishing) +- [Contributing](#contributing) +- [License](#license) -To get a local copy up and running, please follow these simple steps. +## Quick start ### Prerequisites -Here is what you need to be able to run Comp AI. +- Node.js `>=20` +- Bun `>=1.1.36` +- Docker (for Postgres) -- Node.js (Version: >=20.x) -- Bun (Version: >=1.1.36) -- Postgres (Version: >=15.x) +### Bootstrap -## Development - -To get the project working locally with all integrations, follow these extended development steps - -### Setup - -## Add environment variables and fill them out with your credentials +```bash +git clone https://github.com/trycompai/comp.git +cd comp +bun install -```sh +# Copy env files cp apps/app/.env.example apps/app/.env cp apps/portal/.env.example apps/portal/.env cp packages/db/.env.example packages/db/.env -``` - -## Get code running locally - -1. Clone the repo -```sh -git clone https://github.com/trycompai/comp.git -``` +# Start database +cd packages/db +bun run docker:up +bun run db:migrate +cd ../.. -2. Navigate to the project directory +# Generate Prisma clients +bun run db:generate -```sh -cd comp +# Start all apps +bun run dev ``` -3. Install dependencies using Bun +### Local endpoints -```sh -bun install -``` +- App: `http://localhost:3000` +- API: `http://localhost:3001` +- Portal: `http://localhost:3002` -4. Get Database Running +## Monorepo layout -```sh -cd packages/db -bun run docker:up # Spin up docker container -bun run db:migrate # Run migrations -``` +```text +apps/ + app/ Next.js main application + api/ NestJS backend API + portal/ Next.js customer portal + docs/ Documentation site + device-agent/ Electron desktop agent -5. Generate Prisma Types for each app - -```sh -cd apps/app -bun run db:generate -cd ../portal -bun run db:generate -cd ../api -bun run db:generate +packages/ + db/ Prisma schema, client, migrations + ui/ Shared component library + email/ Email templates (React Email) + kv/ Key-value store (Upstash Redis) + analytics/ Analytics utilities + auth/ Authentication (Better Auth) + integrations/ Third-party integrations + integration-platform/ Integration platform core + utils/ Shared utilities ``` -6. Run all apps in parallel from the root directory +## Run commands -```sh +```bash +# Development (all apps) bun run dev -``` ---- +# Generate Prisma clients (required after schema changes or pulling) +bun run db:generate -### Environment Setup +# Build all +bun run build -Create the following `.env` files and fill them out with your credentials +# Lint and type check +bun run lint +bun run check-types -- `comp/apps/app/.env` -- `comp/apps/portal/.env` -- `comp/packages/db/.env` +# Tests +bun run test +``` -You can copy from the `.env.example` files: +## Environment setup -### Linux / macOS +Create `.env` files from the examples: -```sh +```bash cp apps/app/.env.example apps/app/.env cp apps/portal/.env.example apps/portal/.env cp packages/db/.env.example packages/db/.env ``` -### Windows (Command Prompt) - -```cmd -copy apps\app\.env.example apps\app\.env -copy apps\portal\.env.example apps\portal\.env -copy packages\db\.env.example packages\db\.env -``` - -### Windows (PowerShell) - -```powershell -Copy-Item apps\app\.env.example -Destination apps\app\.env -Copy-Item apps\portal\.env.example -Destination apps\portal\.env -Copy-Item packages\db\.env.example -Destination packages\db\.env -``` - -Additionally, ensure the following required environment variables are added to `.env` in `comp/apps/app/.env`: +Required variables for `apps/app/.env`: ```env -AUTH_SECRET="" # Use `openssl rand -base64 32` to generate -DATABASE_URL="postgresql://user:password@host:port/database" -RESEND_API_KEY="" # Resend (https://resend.com/api-keys) - Resend Dashboard -> API Keys +AUTH_SECRET="" # openssl rand -base64 32 +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/comp" +RESEND_API_KEY="" # From https://resend.com/api-keys NEXT_PUBLIC_PORTAL_URL="http://localhost:3002" -REVALIDATION_SECRET="" # Use `openssl rand -base64 32` to generate +REVALIDATION_SECRET="" # openssl rand -base64 32 ``` -> ✅ Make sure you have all of these variables in your `.env` file. -> If you're copying from `.env.example`, it might be missing the last two (`NEXT_PUBLIC_PORTAL_URL` and `REVALIDATION_SECRET`), so be sure to add them manually. - -Some environment variables may not load correctly from `.env` — in such cases, **hard-code** the values directly in the relevant files (see Hardcoding section below). - ---- - -### Cloud & Auth Configuration +### Third-party services -#### 1. Trigger.dev +- **Google OAuth** — [Cloud Console](https://console.cloud.google.com/auth/clients). Add redirect URIs for `localhost:3000` and `localhost:3002`. +- **Trigger.dev** — [cloud.trigger.dev](https://cloud.trigger.dev). Create a project and set the project ID in `apps/app/trigger.config.ts`. +- **Upstash Redis** — [console.upstash.com](https://console.upstash.com). Create a Redis database and add the URL/token to `.env`. -- Create an account on [https://cloud.trigger.dev](https://cloud.trigger.dev) -- Create a project and copy the Project ID -- In `comp/apps/app/trigger.config.ts`, set: - ```ts - project: 'proj_****az***ywb**ob*'; - ``` +## Database -#### 2. Google OAuth - -- Go to [Google Cloud OAuth Console](https://console.cloud.google.com/auth/clients) -- Create an OAuth client: - - Type: Web Application - - Name: `comp_app` # You can choose a different name if you prefer! -- Add these **Authorized Redirect URIs**: - - ``` - http://localhost - http://localhost:3000 - http://localhost:3002 - http://localhost:3000/api/auth/callback/google - http://localhost:3002/api/auth/callback/google - http://localhost:3000/auth - http://localhost:3002/auth - ``` - -- After creating the app, copy the `GOOGLE_ID` and `GOOGLE_SECRET` - - Add them to your `.env` files - - If that doesn’t work, hard-code them in: - ``` - comp/apps/portal/src/app/lib/auth.ts - ``` - -#### 3. Redis (Upstash) - -- Go to [https://console.upstash.com](https://console.upstash.com) -- Create a Redis database -- Copy the **Redis URL** and **TOKEN** -- Add them to your `.env` file, or hard-code them if the environment variables are not being recognized in: - ``` - comp/packages/kv/src/index.ts - ``` - ---- - -### Database Setup - -Start and initialize the PostgreSQL database using Docker: - -1. Start the database: - - ```sh - cd packages/db - bun docker:up - ``` - -2. Default credentials: - - Database name: `comp` - - Username: `postgres` - - Password: `postgres` - -3. To change the default password: - - ```sql - ALTER USER postgres WITH PASSWORD 'new_password'; - ``` - -4. If you encounter the following error: - - ``` - HINT: No function matches the given name and argument types... - ``` - - Run the fix: - - ```sh - psql "postgresql://postgres:@localhost:5432/comp" -f ./packages/db/prisma/functionDefinition.sql - ``` - - Expected output: `CREATE FUNCTION` - - > 💡 `comp` is the database name. Make sure to use the correct **port** and **database name** for your setup. - -5. Apply schema and seed: - -```sh - # Generate Prisma client - bun db:generate - - # Push the schema to the database - bun db:push - - # Optional: Seed the database with initial data - bun db:seed -``` - -Other useful database commands: - -```sh -# Open Prisma Studio to view/edit data -bun db:studio - -# Run database migrations -bun db:migrate - -# Stop the database container -bun docker:down - -# Remove the database container and volume -bun docker:clean -``` - ---- +```bash +cd packages/db -### Start Development +# Start Postgres (Docker) +bun run docker:up -Once everything is configured: +# Run migrations +bun run db:migrate -```sh -bun run dev -``` +# Generate Prisma client +bun run db:generate -Or use the Turbo repo script: +# Push schema (no migration) +bun run db:push -```sh -turbo dev -``` +# Seed data +bun run db:seed -> 💡 Make sure you have Turbo installed. If not, you can install it using Bun: +# Open Prisma Studio +bun run db:studio -```sh -bun add -g turbo +# Stop / remove database +bun run docker:down +bun run docker:clean ``` -🎉 Yay! You now have a working local instance of Comp AI! 🚀 - -## Deployment - -### Docker +Default credentials: `postgres:postgres@localhost:5432/comp` -Steps to deploy Comp AI on Docker are coming soon. +## Package publishing -### Vercel +Published to npm via semantic-release on merges to `release`: -Steps to deploy Comp AI on Vercel are coming soon. - -## 📦 Package Publishing - -This repository uses semantic-release to automatically publish packages to npm when merging to the `release` branch. The following packages are published: - -- `@trycompai/db` - Database utilities with Prisma client -- `@trycompai/email` - Email templates and components -- `@trycompai/kv` - Key-value store utilities using Upstash Redis -- `@trycompai/ui` - UI component library with Tailwind CSS - -### Setup - -1. **NPM Token**: Add your npm token as `NPM_TOKEN` in GitHub repository secrets -2. **Release Branch**: Create and merge PRs into the `release` branch to trigger publishing -3. **Versioning**: Uses conventional commits for automatic version bumping - -### Usage +- `@trycompai/db` — Database utilities +- `@trycompai/email` — Email templates +- `@trycompai/kv` — Key-value store +- `@trycompai/ui` — Component library ```bash # Install a published package @@ -363,37 +194,24 @@ npm install @trycompai/ui # Use in your project import { Button } from '@trycompai/ui/button' -import { client } from '@trycompai/kv' ``` -### Development - -```bash -# Build all packages -bun run build +## Recognition -# Build specific package -bun run -F @trycompai/ui build +Comp AI — #1 Product of the Day -# Test packages locally -bun run release:packages --dry-run -``` +Vercel OSS Program -## Contributors +## Contributing -## Repo Activity - -![Alt](https://repobeats.axiom.co/api/embed/1371c2fe20e274ff1e0e8d4ca225455dea609cb9.svg 'Repobeats analytics image') - - +![Repo activity](https://repobeats.axiom.co/api/embed/1371c2fe20e274ff1e0e8d4ca225455dea609cb9.svg 'Repobeats analytics image') ## License -Comp AI, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition"]). +Comp AI, Inc. is a commercial open source company. The core technology (99%) is licensed under [AGPLv3](https://opensource.org/license/agpl-v3). Enterprise features under `/ee` require a commercial license. See [LICENSE](https://github.com/trycompai/comp/blob/main/LICENSE) for details. -> [!TIP] -> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/trycompai/comp/discussions) if you feel like something is wrong. +Open a [discussion](https://github.com/trycompai/comp/discussions) if you have questions about what's open vs commercial. diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx new file mode 100644 index 000000000..893748b3c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AlertTriangle, CheckCircle2, Loader2, Shield, ShieldAlert } from 'lucide-react'; + +interface Finding { + severity: 'critical' | 'high' | 'medium' | 'low'; + title: string; + location: string; +} + +const findings: Finding[] = [ + { severity: 'critical', title: 'SQL Injection in /api/users', location: 'POST /api/users?search=' }, + { severity: 'high', title: 'Stored XSS in comments', location: 'POST /api/comments' }, + { severity: 'high', title: 'Broken access control', location: 'GET /api/admin/settings' }, + { severity: 'medium', title: 'Missing rate limiting', location: 'POST /api/auth/login' }, + { severity: 'medium', title: 'Insecure CORS policy', location: 'Origin: *' }, + { severity: 'low', title: 'Missing security headers', location: 'X-Frame-Options' }, +]; + +const agents = [ + 'Reconnaissance', + 'Authentication testing', + 'Injection testing', + 'Access control audit', + 'Configuration review', +]; + +const severityColors = { + critical: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400', + high: 'bg-orange-100 text-orange-700 dark:bg-orange-950/40 dark:text-orange-400', + medium: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950/40 dark:text-yellow-400', + low: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400', +}; + +export function PentestPreviewAnimation() { + const [progress, setProgress] = useState(0); + const [currentAgent, setCurrentAgent] = useState(0); + const [visibleFindings, setVisibleFindings] = useState(0); + const [phase, setPhase] = useState<'scanning' | 'complete'>('scanning'); + + useEffect(() => { + const totalDuration = 8000; + const interval = 50; + let elapsed = 0; + + let pausing = false; + + const timer = setInterval(() => { + if (pausing) return; + + elapsed += interval; + const t = elapsed / totalDuration; + + if (t >= 1) { + setPhase('complete'); + setProgress(100); + setVisibleFindings(findings.length); + setCurrentAgent(agents.length - 1); + + // Pause, then reset once + pausing = true; + setTimeout(() => { + elapsed = 0; + pausing = false; + setPhase('scanning'); + setProgress(0); + setVisibleFindings(0); + setCurrentAgent(0); + }, 3000); + return; + } + + // Progress with easing + const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + setProgress(Math.round(eased * 100)); + + // Cycle through agents + setCurrentAgent(Math.min(Math.floor(t * agents.length), agents.length - 1)); + + // Reveal findings progressively + setVisibleFindings(Math.floor(t * (findings.length + 1))); + }, interval); + + return () => clearInterval(timer); + }, []); + + const isComplete = phase === 'complete'; + + return ( +
+ {/* Header */} +
+
+ {isComplete ? ( + + ) : ( + + )} + app.acme.com + {isComplete ? ( + + Complete + + ) : ( + + Running + + )} +
+ {progress}% +
+ + {/* Progress bar */} +
+
+
+ + {/* Current agent */} + {!isComplete && ( +
+ + {agents[currentAgent]}… + {currentAgent + 1}/{agents.length} agents +
+ )} + + {isComplete && ( +
+ + Scan complete — {findings.length} findings + 5/5 agents +
+ )} + + {/* Findings */} +
+ {findings.slice(0, visibleFindings).map((finding, i) => ( +
+
+ +
+

{finding.title}

+

{finding.location}

+
+
+ + {finding.severity} + +
+ ))} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx index 8f3968fc0..50f2d2645 100644 --- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx +++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx @@ -9,7 +9,7 @@ import type { Organization } from '@db'; import { AnimatePresence, motion } from 'framer-motion'; import { AlertCircle, Loader2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import Balancer from 'react-wrap-balancer'; + import { usePostPaymentOnboarding } from '../hooks/usePostPaymentOnboarding'; interface PostPaymentOnboardingProps { @@ -157,12 +157,12 @@ export function PostPaymentOnboarding({

- {step?.question || ''} + {step?.question || ''}

- Our AI will personalize the platform based on your answers. + Our AI will personalize the platform based on your answers.

diff --git a/apps/app/src/app/api/webhooks/stripe-pentest/route.test.ts b/apps/app/src/app/api/webhooks/stripe-pentest/route.test.ts new file mode 100644 index 000000000..b9b4baab4 --- /dev/null +++ b/apps/app/src/app/api/webhooks/stripe-pentest/route.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { POST } from './route'; +import type Stripe from 'stripe'; + +// ─── Mocks ──────────────────────────────────────────────── + +const constructEventMock = vi.fn(); +const subscriptionsRetrieveMock = vi.fn(); + +vi.mock('@/lib/stripe', () => ({ + stripe: { + webhooks: { constructEvent: (...args: unknown[]) => constructEventMock(...args) }, + subscriptions: { retrieve: (...args: unknown[]) => subscriptionsRetrieveMock(...args) }, + }, +})); + +vi.mock('@/env.mjs', () => ({ + env: { STRIPE_PENTEST_WEBHOOK_SECRET: 'whsec_test_secret' }, +})); + +const dbBillingFindFirstMock = vi.fn(); +const dbPentestSubUpsertMock = vi.fn(); +const dbPentestSubUpdateManyMock = vi.fn(); + +vi.mock('@db', () => ({ + db: { + organizationBilling: { + findFirst: (...args: unknown[]) => dbBillingFindFirstMock(...args), + }, + pentestSubscription: { + upsert: (...args: unknown[]) => dbPentestSubUpsertMock(...args), + updateMany: (...args: unknown[]) => dbPentestSubUpdateManyMock(...args), + }, + }, +})); + +const headersMock = vi.fn(); +vi.mock('next/headers', () => ({ + headers: () => headersMock(), +})); + +// ─── Helpers ────────────────────────────────────────────── + +function makeRequest(body: string): Request { + return new Request('http://localhost/api/webhooks/stripe-pentest', { + method: 'POST', + body, + }); +} + +function makeStripeEvent(type: string, data: unknown): Stripe.Event { + return { + id: 'evt_test', + type, + data: { object: data }, + object: 'event', + api_version: '2026-01-28.clover', + created: Date.now() / 1000, + livemode: false, + pending_webhooks: 0, + request: null, + } as unknown as Stripe.Event; +} + +const PERIOD_START = 1709568000; // Mar 4 2024 +const PERIOD_END = 1712246400; // Apr 4 2024 + +function makeSubscriptionItem(priceId = 'price_test') { + return { + price: { id: priceId }, + current_period_start: PERIOD_START, + current_period_end: PERIOD_END, + }; +} + +// ─── Tests ──────────────────────────────────────────────── + +describe('Stripe Pentest Webhook', () => { + beforeEach(() => { + vi.clearAllMocks(); + headersMock.mockReturnValue(new Headers({ 'stripe-signature': 'sig_test' })); + }); + + it('returns 400 when stripe-signature header is missing', async () => { + headersMock.mockReturnValue(new Headers()); + const res = await POST(makeRequest('{}')); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('Missing stripe-signature header'); + }); + + it('returns 400 when signature verification fails', async () => { + constructEventMock.mockImplementation(() => { + throw new Error('Invalid signature'); + }); + const res = await POST(makeRequest('{}')); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('Invalid signature'); + }); + + // ── checkout.session.completed ────────────────────────── + + describe('checkout.session.completed', () => { + it('creates pentest subscription on successful checkout', async () => { + const event = makeStripeEvent('checkout.session.completed', { + mode: 'subscription', + customer: 'cus_test', + subscription: 'sub_test', + }); + constructEventMock.mockReturnValue(event); + dbBillingFindFirstMock.mockResolvedValue({ + id: 'billing_1', + organizationId: 'org_1', + }); + subscriptionsRetrieveMock.mockResolvedValue({ + status: 'active', + items: { data: [makeSubscriptionItem()] }, + }); + dbPentestSubUpsertMock.mockResolvedValue({}); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + + expect(dbBillingFindFirstMock).toHaveBeenCalledWith({ + where: { stripeCustomerId: 'cus_test' }, + }); + expect(subscriptionsRetrieveMock).toHaveBeenCalledWith('sub_test'); + expect(dbPentestSubUpsertMock).toHaveBeenCalledWith( + expect.objectContaining({ + where: { organizationId: 'org_1' }, + create: expect.objectContaining({ + organizationId: 'org_1', + organizationBillingId: 'billing_1', + stripeSubscriptionId: 'sub_test', + stripePriceId: 'price_test', + status: 'active', + }), + }), + ); + }); + + it('ignores non-subscription checkout sessions', async () => { + const event = makeStripeEvent('checkout.session.completed', { + mode: 'payment', + customer: 'cus_test', + }); + constructEventMock.mockReturnValue(event); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + expect(dbBillingFindFirstMock).not.toHaveBeenCalled(); + }); + + it('ignores checkout for unknown customer', async () => { + const event = makeStripeEvent('checkout.session.completed', { + mode: 'subscription', + customer: 'cus_unknown', + subscription: 'sub_test', + }); + constructEventMock.mockReturnValue(event); + dbBillingFindFirstMock.mockResolvedValue(null); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + expect(dbPentestSubUpsertMock).not.toHaveBeenCalled(); + }); + }); + + // ── customer.subscription.updated ─────────────────────── + + describe('customer.subscription.updated', () => { + it('updates subscription status and period dates', async () => { + const event = makeStripeEvent('customer.subscription.updated', { + id: 'sub_test', + status: 'active', + items: { data: [makeSubscriptionItem()] }, + }); + constructEventMock.mockReturnValue(event); + dbPentestSubUpdateManyMock.mockResolvedValue({ count: 1 }); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + + expect(dbPentestSubUpdateManyMock).toHaveBeenCalledWith({ + where: { stripeSubscriptionId: 'sub_test' }, + data: { + status: 'active', + currentPeriodStart: new Date(PERIOD_START * 1000), + currentPeriodEnd: new Date(PERIOD_END * 1000), + }, + }); + }); + + it('stores past_due status from Stripe', async () => { + const event = makeStripeEvent('customer.subscription.updated', { + id: 'sub_test', + status: 'past_due', + items: { data: [makeSubscriptionItem()] }, + }); + constructEventMock.mockReturnValue(event); + dbPentestSubUpdateManyMock.mockResolvedValue({ count: 1 }); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + + expect(dbPentestSubUpdateManyMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'past_due' }), + }), + ); + }); + }); + + // ── customer.subscription.deleted ─────────────────────── + + describe('customer.subscription.deleted', () => { + it('sets status to cancelled', async () => { + const event = makeStripeEvent('customer.subscription.deleted', { + id: 'sub_test', + }); + constructEventMock.mockReturnValue(event); + dbPentestSubUpdateManyMock.mockResolvedValue({ count: 1 }); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + + expect(dbPentestSubUpdateManyMock).toHaveBeenCalledWith({ + where: { stripeSubscriptionId: 'sub_test' }, + data: { status: 'cancelled' }, + }); + }); + }); + + // ── unknown events ────────────────────────────────────── + + it('ignores unknown event types', async () => { + const event = makeStripeEvent('invoice.paid', { id: 'inv_test' }); + constructEventMock.mockReturnValue(event); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + expect(dbPentestSubUpsertMock).not.toHaveBeenCalled(); + expect(dbPentestSubUpdateManyMock).not.toHaveBeenCalled(); + }); + + // ── error handling ────────────────────────────────────── + + it('returns 500 when handler throws', async () => { + const event = makeStripeEvent('customer.subscription.deleted', { + id: 'sub_test', + }); + constructEventMock.mockReturnValue(event); + dbPentestSubUpdateManyMock.mockRejectedValue(new Error('DB error')); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe('Webhook handler failed'); + }); +}); diff --git a/packages/db/prisma.config.ts b/packages/db/prisma.config.ts index 6e980e465..b2bb85e4f 100644 --- a/packages/db/prisma.config.ts +++ b/packages/db/prisma.config.ts @@ -1,3 +1,4 @@ +import "dotenv/config"; import { defineConfig } from "prisma/config"; export default defineConfig({ diff --git a/packages/db/prisma/schema/pentest-subscription.prisma b/packages/db/prisma/schema/pentest-subscription.prisma index 791632998..44d44bfd0 100644 --- a/packages/db/prisma/schema/pentest-subscription.prisma +++ b/packages/db/prisma/schema/pentest-subscription.prisma @@ -6,7 +6,7 @@ model PentestSubscription { stripePriceId String @map("stripe_price_id") stripeOveragePriceId String? @map("stripe_overage_price_id") status String @default("active") // active | cancelled | past_due - includedRunsPerPeriod Int @default(3) @map("included_runs_per_period") + includedRunsPerPeriod Int @default(1) @map("included_runs_per_period") currentPeriodStart DateTime @map("current_period_start") currentPeriodEnd DateTime @map("current_period_end") createdAt DateTime @default(now()) @map("created_at") diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 6cefbbfcd..46d173cec 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -25,6 +25,7 @@ export * from './dialog'; export * from './drawer'; export * from './dropdown-menu'; export * from './empty-card'; +export * from './module-gate'; export * from './form'; export * from './hover-card'; export * from './icons'; diff --git a/packages/ui/src/components/module-gate.tsx b/packages/ui/src/components/module-gate.tsx new file mode 100644 index 000000000..488611631 --- /dev/null +++ b/packages/ui/src/components/module-gate.tsx @@ -0,0 +1,78 @@ +import { Check } from 'lucide-react'; +import type React from 'react'; +import { cn } from '../utils/cn'; + +interface ModuleGateProps { + /** Small uppercase label above the headline (e.g. "Penetration Testing") */ + label: string; + /** Bold headline — should sell the outcome, not describe the feature */ + title: string; + /** One-sentence value prop */ + description: string; + /** Checklist of what's included */ + features?: string[]; + /** Primary CTA */ + action: React.ReactNode; + /** Optional secondary CTA rendered next to the primary */ + secondaryAction?: React.ReactNode; + /** Optional product preview shown below the CTA — rendered inside a dark app-chrome frame */ + preview?: React.ReactNode; + className?: string; +} + +export function ModuleGate({ + label, + title, + description, + features, + action, + secondaryAction, + preview, + className, +}: ModuleGateProps) { + return ( +
+
+

+ {label} +

+

{title}

+

{description}

+
+ + {features && features.length > 0 && ( +
    + {features.map((item) => ( +
  • + + {item} +
  • + ))} +
+ )} + +
+ {action} + {secondaryAction} +
+ + {preview && ( +
+ {/* Dark chrome bar */} +
+ + + +
+ {/* Content area */} +
+ {preview} +
+
+ )} +
+ ); +} diff --git a/packages/ui/src/globals.css b/packages/ui/src/globals.css index 4c53e9662..3653680ca 100644 --- a/packages/ui/src/globals.css +++ b/packages/ui/src/globals.css @@ -9,7 +9,7 @@ --card-foreground: 0 0% 3.9%; --popover: 0 0% 100%; --popover-foreground: 0 0% 3.9%; - --primary: 165 100% 15%; + --primary: 162 100% 24%; --primary-foreground: 0 0% 98%; --secondary: 0 0% 96.1%; --secondary-foreground: 0 0% 9%; @@ -23,17 +23,17 @@ --warning-foreground: 26 83% 14%; --border: 0 0% 89.8%; --input: 0 0% 89.8%; - --ring: 165 100% 15%; + --ring: 162 100% 24%; --radius: 0.5rem; - --chart-primary: 165 100% 15%; + --chart-primary: 162 100% 24%; --chart-positive: 163 64% 21%; /* Green: use for published, or reviewed = the items are in a good state */ --chart-neutral: 45 89% 53%; /* Yellow: use for draft(s) and actions that need reviewing soon */ --chart-warning: 220 14% 53%; /* Gray: deleted, archived - states that have been closed */ --chart-destructive: 0 85% 60%; /* Red: use for needs review, something that needs action immediately */ --chart-other: 196 80% 45%; /* Blue: use for other states */ - --chart-closed: #004c3a; + --chart-closed: #007A55; --chart-pending: #0ba5e9; --chart-open: #ffc007; --chart-archived: #64748b; @@ -41,37 +41,37 @@ } .dark { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 165 100% 15%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; + --background: 0 0% 7%; + --foreground: 0 0% 98%; + --card: 0 0% 12%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 12%; + --popover-foreground: 0 0% 98%; + --primary: 155 100% 43%; + --primary-foreground: 166 100% 9%; + --secondary: 0 0% 15%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 15%; + --muted-foreground: 0 0% 64%; + --accent: 0 0% 20%; + --accent-foreground: 0 0% 98%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; --warning: 45 93% 47%; --warning-foreground: 26 83% 14%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 165 100% 15%; + --border: 0 0% 18%; + --input: 0 0% 18%; + --ring: 155 100% 43%; --radius: 0.5rem; - --chart-primary: 165 100% 15%; - --chart-positive: 163 64% 21%; /* Green: use for published, or reviewed = the items are in a good state */ + --chart-primary: 155 100% 43%; + --chart-positive: 155 100% 43%; /* Green: use for published, or reviewed = the items are in a good state */ --chart-neutral: 45 89% 53%; /* Yellow: use for draft(s) and actions that need reviewing soon */ --chart-warning: 220 14% 53%; /* Gray: deleted, archived - states that have been closed */ --chart-destructive: 0 85% 60%; /* Red: use for needs review, something that needs action immediately */ --chart-other: 196 80% 45%; /* Blue: use for other states */ - --chart-closed: #004c3a; + --chart-closed: #00DB80; --chart-pending: #0ba5e9; --chart-open: #ffc007; --chart-archived: #64748b;