commit 0ced12cb819c858f257d24ce5e95b61e5c165c92 Author: Shinwoo PARK Date: Fri May 1 01:33:57 2026 +0900 feat: bootstrap codexdash app diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8e47349 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +JWT_SECRET=change-me +ENCRYPTION_SECRET=change-me-32-characters-minimum +DATABASE_URL=file:./dev.db +VITE_API_BASE_URL=http://localhost:3001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97f0601 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules +.pnpm-store +.env +.env.* +!.env.example +coverage +dist +build +*.log +apps/api/prisma/dev.db +apps/api/prisma/dev.db-journal +apps/api/prisma/dev.db-shm +apps/api/prisma/dev.db-wal +apps/web/components.json +apps/web/src/components/ui diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b685b6 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# CodexDash + +CodexDash is a mobile-first dashboard for monitoring multiple OpenAI Codex accounts from one place. + +## Stack + +- **Frontend:** React + Vite + TypeScript + Tailwind CSS + shadcn/ui-style components +- **Backend:** NestJS +- **Database:** Prisma + SQLite +- **Auth:** CodexDash email/password auth with JWT + +## What it does + +- Create a CodexDash account and sign in +- Connect multiple OpenAI Codex sessions under one CodexDash account +- Refresh `https://chatgpt.com/backend-api/api/codex/usage` for each connected OpenAI account +- Merge numeric usage fields into one aggregate dashboard +- Inspect each connected account individually with raw API payload details + +## Important note about "OpenAI Codex login" + +OpenAI does not expose a simple third-party OAuth flow for this usage endpoint. + +This MVP implements OpenAI account connection as a **session-based login flow**: + +1. Sign in to `chatgpt.com` in your browser +2. Copy the authenticated `Cookie` header +3. Paste it into the **Connect OpenAI account** dialog in CodexDash + +The backend encrypts the cookie header before storing it in SQLite. + +## Local development + +```bash +pnpm install +pnpm --filter @codexdash/api exec prisma generate +cd apps/api && DATABASE_URL=file:./dev.db pnpm exec prisma db push +cd ../.. +pnpm --filter @codexdash/api start:dev +pnpm --filter @codexdash/web dev --host 0.0.0.0 +``` + +## Environment variables + +### `apps/api/.env` + +```env +JWT_SECRET=dev-jwt-secret-for-codexdash +ENCRYPTION_SECRET=dev-encryption-secret-for-codexdash-32chars +DATABASE_URL=file:./dev.db +``` + +### `apps/web/.env` + +```env +VITE_API_BASE_URL=http://localhost:3001 +``` + +## Verification + +```bash +pnpm lint +pnpm test +pnpm build +curl http://localhost:3001/health +``` + +## API overview + +- `POST /auth/register` +- `POST /auth/login` +- `GET /auth/me` +- `GET /codex/accounts` +- `POST /codex/accounts` +- `DELETE /codex/accounts/:accountId` +- `GET /codex/usage-summary` diff --git a/apps/api/.prettierrc b/apps/api/.prettierrc new file mode 100644 index 0000000..a20502b --- /dev/null +++ b/apps/api/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..d30c946 --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,98 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Discord +Backers on Open Collective +Sponsors on Open Collective + Donate us + Support us + Follow us on Twitter +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Project setup + +```bash +$ pnpm install +``` + +## Compile and run the project + +```bash +# development +$ pnpm run start + +# watch mode +$ pnpm run start:dev + +# production mode +$ pnpm run start:prod +``` + +## Run tests + +```bash +# unit tests +$ pnpm run test + +# e2e tests +$ pnpm run test:e2e + +# test coverage +$ pnpm run test:cov +``` + +## Deployment + +When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. + +If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: + +```bash +$ pnpm install -g @nestjs/mau +$ mau deploy +``` + +With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. + +## Resources + +Check out a few resources that may come in handy when working with NestJS: + +- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. +- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). +- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). +- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. +- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). +- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). +- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). +- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs new file mode 100644 index 0000000..4e9f827 --- /dev/null +++ b/apps/api/eslint.config.mjs @@ -0,0 +1,35 @@ +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['eslint.config.mjs'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + sourceType: 'commonjs', + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + "prettier/prettier": ["error", { endOfLine: "auto" }], + }, + }, +); diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..babf738 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,69 @@ +{ + "name": "@codexdash/api", + "version": "0.1.0", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,test}/**/*.ts\" --fix", + "test": "jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "prisma:generate": "prisma generate", + "prisma:push": "prisma db push" + }, + "dependencies": { + "@codexdash/shared-types": "workspace:*", + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.1", + "@nestjs/platform-express": "^11.0.1", + "@prisma/client": "6.16.2", + "argon2": "^0.44.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^24.0.0", + "@types/supertest": "^7.0.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^17.0.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "prisma": "6.16.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": ["**/*.(t|j)s"], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} \ No newline at end of file diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..4b2587e --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,35 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + email String @unique + passwordHash String + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accounts OpenAiAccount[] +} + +model OpenAiAccount { + id String @id @default(cuid()) + userId String + label String + emailHint String? + encryptedCookie String + lastUsageJson Json? + lastSyncedAt DateTime? + lastError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} \ No newline at end of file diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts new file mode 100644 index 0000000..e03c639 --- /dev/null +++ b/apps/api/src/app.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller() +export class AppController { + @Get('health') + health() { + return { + ok: true, + service: 'codexdash-api', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..29d1fae --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AppController } from './app.controller'; +import { AuthModule } from './auth/auth.module'; +import { CodexModule } from './codex/codex.module'; +import { PrismaModule } from './prisma/prisma.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, + AuthModule, + CodexModule, + ], + controllers: [AppController], + providers: [], +}) +export class AppModule {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..673d339 --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { + CurrentUser, + type AuthenticatedUser, +} from '../common/current-user.decorator'; +import { JwtAuthGuard } from '../common/jwt-auth.guard'; +import { AuthService } from './auth.service'; +import { LoginDto } from './dto/login.dto'; +import { RegisterDto } from './dto/register.dto'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('register') + register(@Body() dto: RegisterDto) { + return this.authService.register(dto); + } + + @Post('login') + login(@Body() dto: LoginDto) { + return this.authService.login(dto); + } + + @UseGuards(JwtAuthGuard) + @Get('me') + me(@CurrentUser() user: AuthenticatedUser) { + return this.authService.me(user.sub); + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..e3f1138 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from '../common/jwt-auth.guard'; + +@Module({ + imports: [ + ConfigModule, + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET') ?? 'change-me', + signOptions: { expiresIn: '30d' }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtAuthGuard], + exports: [JwtModule, JwtAuthGuard], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..de19262 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,84 @@ +import { + ConflictException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as argon2 from 'argon2'; +import { AuthResponse, UserProfile } from '@codexdash/shared-types'; +import { PrismaService } from '../prisma/prisma.service'; +import { LoginDto } from './dto/login.dto'; +import { RegisterDto } from './dto/register.dto'; + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwtService: JwtService, + ) {} + + async register(dto: RegisterDto): Promise { + const existing = await this.prisma.user.findUnique({ + where: { email: dto.email.toLowerCase() }, + }); + + if (existing) { + throw new ConflictException('Email already registered'); + } + + const user = await this.prisma.user.create({ + data: { + email: dto.email.toLowerCase(), + name: dto.name.trim(), + passwordHash: await argon2.hash(dto.password), + }, + }); + + return this.toAuthResponse(user); + } + + async login(dto: LoginDto): Promise { + const user = await this.prisma.user.findUnique({ + where: { email: dto.email.toLowerCase() }, + }); + + if (!user || !(await argon2.verify(user.passwordHash, dto.password))) { + throw new UnauthorizedException('Invalid email or password'); + } + + return this.toAuthResponse(user); + } + + async me(userId: string): Promise { + const user = await this.prisma.user.findUniqueOrThrow({ + where: { id: userId }, + }); + + return { + id: user.id, + email: user.email, + name: user.name, + createdAt: user.createdAt.toISOString(), + }; + } + + private async toAuthResponse(user: { + id: string; + email: string; + name: string; + createdAt: Date; + }): Promise { + return { + token: await this.jwtService.signAsync({ + sub: user.id, + email: user.email, + }), + user: { + id: user.id, + email: user.email, + name: user.name, + createdAt: user.createdAt.toISOString(), + }, + }; + } +} diff --git a/apps/api/src/auth/dto/login.dto.ts b/apps/api/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..462f034 --- /dev/null +++ b/apps/api/src/auth/dto/login.dto.ts @@ -0,0 +1,11 @@ +import { IsEmail, IsString, MinLength } from 'class-validator'; +import { LoginInput } from '@codexdash/shared-types'; + +export class LoginDto implements LoginInput { + @IsEmail() + email!: string; + + @IsString() + @MinLength(8) + password!: string; +} diff --git a/apps/api/src/auth/dto/register.dto.ts b/apps/api/src/auth/dto/register.dto.ts new file mode 100644 index 0000000..c42caf7 --- /dev/null +++ b/apps/api/src/auth/dto/register.dto.ts @@ -0,0 +1,15 @@ +import { IsEmail, IsString, MinLength } from 'class-validator'; +import { RegisterInput } from '@codexdash/shared-types'; + +export class RegisterDto implements RegisterInput { + @IsEmail() + email!: string; + + @IsString() + @MinLength(8) + password!: string; + + @IsString() + @MinLength(2) + name!: string; +} diff --git a/apps/api/src/codex/codex.controller.ts b/apps/api/src/codex/codex.controller.ts new file mode 100644 index 0000000..b0e7025 --- /dev/null +++ b/apps/api/src/codex/codex.controller.ts @@ -0,0 +1,52 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + CurrentUser, + type AuthenticatedUser, +} from '../common/current-user.decorator'; +import { JwtAuthGuard } from '../common/jwt-auth.guard'; +import { CodexService } from './codex.service'; +import { ConnectAccountDto } from './dto/connect-account.dto'; + +@UseGuards(JwtAuthGuard) +@Controller('codex') +export class CodexController { + constructor(private readonly codexService: CodexService) {} + + @Get('accounts') + listAccounts(@CurrentUser() user: AuthenticatedUser) { + return this.codexService.listAccounts(user.sub); + } + + @Post('accounts') + connectAccount( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: ConnectAccountDto, + ) { + return this.codexService.connectAccount(user.sub, dto); + } + + @Delete('accounts/:accountId') + deleteAccount( + @CurrentUser() user: AuthenticatedUser, + @Param('accountId') accountId: string, + ) { + return this.codexService.deleteAccount(user.sub, accountId); + } + + @Get('usage-summary') + getUsageSummary( + @CurrentUser() user: AuthenticatedUser, + @Query('refresh') refresh?: string, + ) { + return this.codexService.getUsageSummary(user.sub, refresh !== 'false'); + } +} diff --git a/apps/api/src/codex/codex.module.ts b/apps/api/src/codex/codex.module.ts new file mode 100644 index 0000000..bce16dd --- /dev/null +++ b/apps/api/src/codex/codex.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { CodexController } from './codex.controller'; +import { CodexService } from './codex.service'; + +@Module({ + imports: [AuthModule], + controllers: [CodexController], + providers: [CodexService], +}) +export class CodexModule {} diff --git a/apps/api/src/codex/codex.service.ts b/apps/api/src/codex/codex.service.ts new file mode 100644 index 0000000..ed2fca2 --- /dev/null +++ b/apps/api/src/codex/codex.service.ts @@ -0,0 +1,172 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Prisma } from '@prisma/client'; +import { ConnectedAccount, UsageSummary } from '@codexdash/shared-types'; +import { decryptString, encryptString } from '../common/crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { ConnectAccountDto } from './dto/connect-account.dto'; +import { aggregateUsagePayloads } from './usage-aggregator'; + +type UsageApiResponse = Record; + +@Injectable() +export class CodexService { + constructor( + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + ) {} + + async connectAccount( + userId: string, + dto: ConnectAccountDto, + ): Promise { + const usage = await this.fetchUsage(dto.cookieHeader); + const account = await this.prisma.openAiAccount.create({ + data: { + userId, + label: dto.label.trim(), + emailHint: dto.emailHint?.trim() || null, + encryptedCookie: encryptString( + dto.cookieHeader, + this.getEncryptionSecret(), + ), + lastUsageJson: usage as Prisma.InputJsonValue, + lastSyncedAt: new Date(), + }, + }); + + return this.toAccountView(account); + } + + async listAccounts(userId: string) { + const accounts = await this.prisma.openAiAccount.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + + return accounts.map((account) => this.toAccountView(account)); + } + + async deleteAccount(userId: string, accountId: string) { + const account = await this.prisma.openAiAccount.findFirst({ + where: { id: accountId, userId }, + }); + if (!account) { + throw new NotFoundException('Connected account not found'); + } + + await this.prisma.openAiAccount.delete({ where: { id: accountId } }); + return { ok: true }; + } + + async getUsageSummary(userId: string, refresh = true): Promise { + const accounts = await this.prisma.openAiAccount.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + + const resolvedAccounts = await Promise.all( + accounts.map(async (account) => { + if (!refresh) { + return account; + } + + try { + const usage = await this.fetchUsage( + decryptString(account.encryptedCookie, this.getEncryptionSecret()), + ); + return this.prisma.openAiAccount.update({ + where: { id: account.id }, + data: { + lastUsageJson: usage as Prisma.InputJsonValue, + lastSyncedAt: new Date(), + lastError: null, + }, + }); + } catch (error) { + return this.prisma.openAiAccount.update({ + where: { id: account.id }, + data: { + lastError: + error instanceof Error ? error.message : 'Unknown error', + }, + }); + } + }), + ); + + const accountViews = resolvedAccounts.map((account) => + this.toAccountView(account), + ); + + return { + aggregatedUsage: aggregateUsagePayloads( + accountViews.map((account) => account.usage), + ), + accounts: accountViews, + totals: { + totalAccounts: accountViews.length, + activeAccounts: accountViews.filter( + (account) => account.status === 'active', + ).length, + erroredAccounts: accountViews.filter( + (account) => account.status === 'error', + ).length, + }, + refreshedAt: new Date().toISOString(), + }; + } + + private async fetchUsage(cookieHeader: string): Promise { + const response = await fetch( + 'https://chatgpt.com/backend-api/api/codex/usage', + { + headers: { + accept: 'application/json', + cookie: cookieHeader, + 'user-agent': 'CodexDash/0.1 (+https://example.invalid)', + }, + }, + ); + + if (!response.ok) { + throw new BadRequestException( + `Codex usage request failed with status ${response.status}`, + ); + } + + return (await response.json()) as UsageApiResponse; + } + + private getEncryptionSecret() { + return ( + this.configService.get('ENCRYPTION_SECRET') ?? + 'change-me-32-characters-minimum' + ); + } + + private toAccountView(account: { + id: string; + label: string; + emailHint: string | null; + lastUsageJson: unknown; + lastSyncedAt: Date | null; + lastError: string | null; + createdAt: Date; + }): ConnectedAccount { + return { + id: account.id, + label: account.label, + emailHint: account.emailHint, + status: account.lastError ? 'error' : 'active', + lastSyncedAt: account.lastSyncedAt?.toISOString() ?? null, + lastError: account.lastError, + usage: (account.lastUsageJson as Record | null) ?? null, + createdAt: account.createdAt.toISOString(), + }; + } +} diff --git a/apps/api/src/codex/dto/connect-account.dto.ts b/apps/api/src/codex/dto/connect-account.dto.ts new file mode 100644 index 0000000..5bee186 --- /dev/null +++ b/apps/api/src/codex/dto/connect-account.dto.ts @@ -0,0 +1,16 @@ +import { IsOptional, IsString, MinLength } from 'class-validator'; +import { ConnectAccountInput } from '@codexdash/shared-types'; + +export class ConnectAccountDto implements ConnectAccountInput { + @IsString() + @MinLength(2) + label!: string; + + @IsOptional() + @IsString() + emailHint?: string; + + @IsString() + @MinLength(20) + cookieHeader!: string; +} diff --git a/apps/api/src/codex/usage-aggregator.spec.ts b/apps/api/src/codex/usage-aggregator.spec.ts new file mode 100644 index 0000000..7e47318 --- /dev/null +++ b/apps/api/src/codex/usage-aggregator.spec.ts @@ -0,0 +1,24 @@ +import { aggregateUsagePayloads } from './usage-aggregator'; + +describe('aggregateUsagePayloads', () => { + it('sums nested numeric values while preserving useful metadata', () => { + const result = aggregateUsagePayloads([ + { + limit: { used: 10, remaining: 90, unit: 'requests' }, + plan: 'pro', + buckets: [{ used: 2 }, { used: 3 }], + }, + { + limit: { used: 25, remaining: 75, unit: 'requests' }, + plan: 'pro', + buckets: [{ used: 4 }, { used: 1 }], + }, + ]); + + expect(result).toEqual({ + limit: { used: 35, remaining: 165, unit: 'requests' }, + plan: 'pro', + buckets: [{ used: 6 }, { used: 4 }], + }); + }); +}); diff --git a/apps/api/src/codex/usage-aggregator.ts b/apps/api/src/codex/usage-aggregator.ts new file mode 100644 index 0000000..cbe0a45 --- /dev/null +++ b/apps/api/src/codex/usage-aggregator.ts @@ -0,0 +1,60 @@ +type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +const isObject = (value: JsonValue): value is { [key: string]: JsonValue } => + typeof value === 'object' && value !== null && !Array.isArray(value); + +function mergeValues(values: JsonValue[]): JsonValue { + const filtered = values.filter((value) => value !== null); + if (filtered.length === 0) { + return null; + } + + if (filtered.every((value) => typeof value === 'number')) { + return filtered.reduce((sum, value) => sum + value, 0); + } + + if (filtered.every((value) => Array.isArray(value))) { + const arrays = filtered as JsonValue[][]; + const maxLength = Math.max(...arrays.map((array) => array.length)); + return Array.from({ length: maxLength }, (_, index) => + mergeValues(arrays.map((array) => array[index] ?? null)), + ); + } + + if (filtered.every((value) => isObject(value))) { + const keys = [...new Set(filtered.flatMap((value) => Object.keys(value)))]; + return Object.fromEntries( + keys.map((key) => [ + key, + mergeValues( + filtered.map( + (value) => (value as Record)[key] ?? null, + ), + ), + ]), + ); + } + + const unique = [ + ...new Set(filtered.map((value) => JSON.stringify(value))), + ].map((value) => JSON.parse(value) as JsonValue); + return unique.length === 1 ? unique[0] : unique[0]; +} + +export function aggregateUsagePayloads( + payloads: Array | null | undefined>, +) { + const normalized = payloads.filter(Boolean) as Array>; + + if (normalized.length === 0) { + return null; + } + + return mergeValues(normalized as JsonValue[]) as Record; +} diff --git a/apps/api/src/common/crypto.ts b/apps/api/src/common/crypto.ts new file mode 100644 index 0000000..6040a30 --- /dev/null +++ b/apps/api/src/common/crypto.ts @@ -0,0 +1,37 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'node:crypto'; + +const ALGORITHM = 'aes-256-gcm'; + +function getKey(secret: string) { + return createHash('sha256').update(secret).digest(); +} + +export function encryptString(value: string, secret: string) { + const iv = randomBytes(12); + const cipher = createCipheriv(ALGORITHM, getKey(secret), iv); + const encrypted = Buffer.concat([ + cipher.update(value, 'utf8'), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + return Buffer.concat([iv, tag, encrypted]).toString('base64url'); +} + +export function decryptString(value: string, secret: string) { + const buffer = Buffer.from(value, 'base64url'); + const iv = buffer.subarray(0, 12); + const tag = buffer.subarray(12, 28); + const encrypted = buffer.subarray(28); + const decipher = createDecipheriv(ALGORITHM, getKey(secret), iv); + decipher.setAuthTag(tag); + + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString( + 'utf8', + ); +} diff --git a/apps/api/src/common/current-user.decorator.ts b/apps/api/src/common/current-user.decorator.ts new file mode 100644 index 0000000..3f9bd62 --- /dev/null +++ b/apps/api/src/common/current-user.decorator.ts @@ -0,0 +1,16 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import type { Request } from 'express'; + +export type AuthenticatedUser = { + sub: string; + email: string; +}; + +type RequestWithUser = Request & { user?: AuthenticatedUser }; + +export const CurrentUser = createParamDecorator( + (_: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user as AuthenticatedUser; + }, +); diff --git a/apps/api/src/common/jwt-auth.guard.ts b/apps/api/src/common/jwt-auth.guard.ts new file mode 100644 index 0000000..e100cf4 --- /dev/null +++ b/apps/api/src/common/jwt-auth.guard.ts @@ -0,0 +1,42 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import type { Request } from 'express'; +import type { AuthenticatedUser } from './current-user.decorator'; + +type RequestWithUser = Request & { user?: AuthenticatedUser }; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authorization = request.headers.authorization; + + if (!authorization?.startsWith('Bearer ')) { + throw new UnauthorizedException('Missing bearer token'); + } + + try { + const token = authorization.slice('Bearer '.length); + request.user = await this.jwtService.verifyAsync( + token, + { + secret: this.configService.get('JWT_SECRET') ?? 'change-me', + }, + ); + return true; + } catch { + throw new UnauthorizedException('Invalid token'); + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..63f1cae --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,18 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.enableCors({ origin: true, credentials: true }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + await app.listen(process.env.PORT ?? 3001); +} +void bootstrap(); diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..ba00c9f --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts new file mode 100644 index 0000000..a767839 --- /dev/null +++ b/apps/api/test/app.e2e-spec.ts @@ -0,0 +1,29 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); + + afterEach(async () => { + await app.close(); + }); +}); diff --git a/apps/api/test/jest-e2e.json b/apps/api/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/apps/api/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..57f9635 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "esModuleInterop": true, + "isolatedModules": true, + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..71999ba --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,17 @@ + + + + + + + + CodexDash + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..54c526d --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,49 @@ +{ + "name": "@codexdash/web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@codexdash/shared-types": "workspace:*", + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", + "@tanstack/react-query": "^5.90.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.554.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.66.0", + "react-router-dom": "^7.9.6", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "zod": "^4.1.12" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.1.14", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "tailwindcss": "^4.1.14", + "tw-animate-css": "^1.3.8", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10" + } +} \ No newline at end of file diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..2fed597 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,431 @@ +import { useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { AuthResponse, LoginInput, RegisterInput } from '@codexdash/shared-types'; +import { Activity, CirclePlus, Gauge, LogOut, RefreshCw, ShieldCheck, Trash2 } from 'lucide-react'; +import { toast, Toaster } from 'sonner'; +import { api } from '@/lib/api'; +import { clearToken, getToken, setToken } from '@/lib/storage'; +import { flattenNumericMetrics, formatDate, titleizeMetric } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Progress } from '@/components/ui/progress'; +import { Separator } from '@/components/ui/separator'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { JsonViewer } from '@/components/json-viewer'; + +const registerSchema = z.object({ + name: z.string().min(2), + email: z.email(), + password: z.string().min(8), +}); + +const loginSchema = z.object({ + email: z.email(), + password: z.string().min(8), +}); + +const connectSchema = z.object({ + label: z.string().min(2), + emailHint: z.string().optional(), + cookieHeader: z.string().min(20), +}); + +function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthResponse) => void }) { + const [mode, setMode] = useState<'login' | 'register'>('register'); + const schema = mode === 'register' ? registerSchema : loginSchema; + const form = useForm<{ name?: string; email: string; password: string }>({ + resolver: zodResolver(schema), + defaultValues: { email: '', password: '', ...(mode === 'register' ? { name: '' } : {}) }, + }); + + const mutation = useMutation({ + mutationFn: async (values: { name?: string; email: string; password: string }) => { + return mode === 'register' + ? api.register(values as RegisterInput) + : api.login({ email: values.email, password: values.password } as LoginInput); + }, + onSuccess: (response) => { + setToken(response.token); + onAuthenticated(response); + toast.success(mode === 'register' ? 'Welcome to CodexDash.' : 'Signed in successfully.'); + }, + onError: (error: Error) => toast.error(error.message), + }); + + return ( +
+
+ + +
+ Mobile-first Codex monitor +
+

+ CodexDash keeps every Codex account in one gorgeous live dashboard. +

+

+ Sign into CodexDash, attach multiple OpenAI Codex sessions, and view combined limits, + remaining usage, raw API payloads, and per-account drilldowns from a single responsive UI. +

+
+
+
+ {[ + { icon: Gauge, title: 'Unified usage', desc: 'Merge multiple OpenAI accounts into one overview.' }, + { icon: ShieldCheck, title: 'Stored safely', desc: 'Session cookie headers are encrypted at rest.' }, + { icon: Activity, title: 'Live detail', desc: 'See refreshed usage plus raw usage payloads.' }, + ].map((item) => ( +
+ +
{item.title}
+
{item.desc}
+
+ ))} +
+
+
+ + + + {mode === 'register' ? 'Create your account' : 'Welcome back'} + + {mode === 'register' + ? 'Start with your CodexDash account, then connect OpenAI Codex sessions inside the dashboard.' + : 'Log in to continue monitoring your combined Codex usage.'} + + + +
mutation.mutate(values))}> + {mode === 'register' ? ( +
+ + +

{String(form.formState.errors.name?.message ?? '')}

+
+ ) : null} +
+ + +

{String(form.formState.errors.email?.message ?? '')}

+
+
+ + +

{String(form.formState.errors.password?.message ?? '')}

+
+ +
+ + + +
+

+ OpenAI account connection is implemented as a session-based Codex login: after signing into + chatgpt.com in your browser, paste the authenticated Cookie{' '} + header into the connect flow. +

+ +
+
+
+
+
+ ); +} + +function ConnectAccountDialog() { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const form = useForm>({ + resolver: zodResolver(connectSchema), + defaultValues: { label: '', emailHint: '', cookieHeader: '' }, + }); + + const mutation = useMutation({ + mutationFn: api.connectAccount, + onSuccess: () => { + toast.success('OpenAI Codex session connected.'); + setOpen(false); + form.reset(); + void queryClient.invalidateQueries({ queryKey: ['usage-summary'] }); + }, + onError: (error: Error) => toast.error(error.message), + }); + + return ( + + + + + + + Connect an OpenAI Codex session + + Paste the authenticated Cookie{' '} + header from a signed-in chatgpt.com{' '} + session. CodexDash will use it to call the official usage endpoint. + + +
mutation.mutate(values))}> +
+ + +

{String(form.formState.errors.label?.message ?? '')}

+
+
+ + +
+
+ +