commit 6c0ca4e28be40ea8e4ee12b5fb59ef72f86829e0 Author: sirily Date: Tue Mar 10 14:03:52 2026 +0300 Initial import diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..74131ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +.pnpm-store +dist +build +coverage +.git +.env +.env.local +.env.*.local +*.log +.DS_Store +.vscode +.idea +.tmp +tmp diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aa22310 --- /dev/null +++ b/.env.example @@ -0,0 +1,46 @@ +# App +NODE_ENV=development +APP_BASE_URL=http://localhost:3000 +ADMIN_BASE_URL=http://localhost:3000/admin + +# Database +DATABASE_URL=postgresql://nproxy:nproxy@postgres:5432/nproxy + +# Auth +SESSION_SECRET=replace_me +PASSWORD_PEPPER=replace_me + +# Provider: nano banana +NANO_BANANA_API_BASE_URL=https://provider.example.com +NANO_BANANA_DEFAULT_MODEL=nano_banana + +# Crypto payments +PAYMENT_PROVIDER=example_processor +PAYMENT_PROVIDER_API_KEY=replace_me +PAYMENT_PROVIDER_WEBHOOK_SECRET=replace_me + +# Storage +S3_ENDPOINT=http://minio:9000 +S3_REGION=us-east-1 +S3_BUCKET=nproxy-assets +S3_ACCESS_KEY=replace_me +S3_SECRET_KEY=replace_me +S3_FORCE_PATH_STYLE=true + +# Telegram +TELEGRAM_BOT_TOKEN=replace_me +TELEGRAM_BOT_MODE=polling + +# Email +EMAIL_PROVIDER=example +EMAIL_FROM=no-reply@example.com +EMAIL_API_KEY=replace_me + +# Key pool +KEY_COOLDOWN_MINUTES=5 +KEY_FAILURES_BEFORE_MANUAL_REVIEW=10 +KEY_BALANCE_POLL_SECONDS=180 + +# Optional local object storage +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d158a9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +node_modules/ +.pnpm-store/ +.next/ +dist/ +build/ +coverage/ +.env +.env.local +.env.*.local +*.log +.DS_Store +.vscode/ +.idea/ +.tmp/ +tmp/ +packages/db/prisma/dev.db +packages/db/prisma/dev.db-journal diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4247117 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# AGENTS.md + +## Scope +This file governs the whole repository unless a deeper `AGENTS.md` overrides it. + +## Source of truth +Read these files before making architectural changes: +- `docs/plan/mvp-system-plan.md` +- `docs/architecture/system-overview.md` +- `docs/architecture/repository-layout.md` +- `docs/ops/deployment.md` +- `docs/ops/telegram-pairing.md` +- `docs/ops/provider-key-pool.md` + +## Repository intent +This repository is a TypeScript monorepo for `nproxy`, a crypto-subscription image gateway that: +- serves a B2C web product; +- routes image-generation requests to external providers; +- maintains a pool of provider API keys with failover, cooldown, and balance tracking; +- exposes admin operations through web admin and a Telegram bot; +- deploys on a single VPS with Docker Compose. + +## Top-level boundaries +- `apps/web`: public site, user dashboard, admin UI, and HTTP API surface. +- `apps/worker`: background jobs for generation, billing reconciliation, media cleanup, and key health checks. +- `apps/bot`: Telegram admin bot runtime. +- `apps/cli`: server-side operational CLI, including Telegram pairing commands. +- `packages/config`: typed environment loading and shared config. +- `packages/db`: Prisma schema, migrations, and database access helpers. +- `packages/domain`: business rules, state machines, and use-case orchestration. +- `packages/providers`: external provider adapters for image generation, payments, email, storage, and Telegram. +- `docs`: architecture, product, and operations documents. +- `infra`: deployment templates and reverse-proxy configuration. + +## Guardrails +- Keep business rules out of UI components. +- Keep provider-specific HTTP code out of domain services. +- Model user request attempts separately from provider-key attempts. +- Preserve the chosen deployment target: single VPS with Docker Compose. +- Preserve the chosen billing model for MVP: manual crypto invoice renewal. +- Preserve the chosen quota display contract: user-facing approximate buckets only. + +## Required follow-up when changing key areas +- If you change deployment assumptions, update `docs/ops/deployment.md`. +- If you change Telegram admin auth, update `docs/ops/telegram-pairing.md`. +- If you change failover, cooldown, or balance logic, update `docs/ops/provider-key-pool.md`. diff --git a/CODEX_STATUS.md b/CODEX_STATUS.md new file mode 100644 index 0000000..b6df0a8 --- /dev/null +++ b/CODEX_STATUS.md @@ -0,0 +1,193 @@ +# Codex Status + +Этот файл нужен как быстрый вход для следующего запуска Codex. + +## Текущее состояние +- Репозиторий уже не на стадии пустых заглушек: `web`, `worker`, `bot`, `cli`, `db`, `domain`, `providers` имеют рабочий runtime-код. +- Архитектурные границы пока соблюдены: + - бизнес-правила живут в `packages/domain` + - persistence и Prisma-транзакции живут в `packages/db` + - transport/integration adapters живут в `packages/providers` + - `apps/*` в основном собирают transport + use cases + +## Реализовано + +### `packages/domain` +- quota buckets `100/80/60/40/20/0` +- provider-key pool policy: + - round-robin selection + - retry vs stop decision + - cooldown / manual_review / out_of_funds transitions + - configurable manual-review threshold +- generation use cases: + - `createGenerationRequest` + - `markGenerationRequestSucceeded` +- auth helpers: + - email normalization/validation + - password validation + - password hashing/verification + - session token hashing + - password reset token hashing +- telegram pairing helpers: + - code normalization + - code hashing + - expiration check + +### `packages/db` +- Prisma schema and migration history for: + - users + - sessions + - password reset tokens + - subscriptions and plans + - invoices + - generation requests / attempts / assets + - usage ledger + - provider keys / status events / proxies + - Telegram pairing / allowlist / audit log +- bootstrap: + - default subscription plan seed + - migrate-time bootstrap entrypoint +- stores: + - `auth-store` + - `account-store` + - `billing-store` + - `generation-store` + - `worker-store` + - `telegram-pairing-store` + - `telegram-bot-store` + +### `packages/providers` +- simulated `nano_banana` adapter +- Telegram Bot API transport +- email transport +- payment provider adapter for invoice creation + +### `apps/web` +- auth/session endpoints: + - `POST /api/auth/register` + - `POST /api/auth/login` + - `POST /api/auth/password-reset/request` + - `POST /api/auth/password-reset/confirm` + - `POST /api/auth/logout` + - `GET /api/auth/me` + - `GET /api/auth/sessions` + - `DELETE /api/auth/sessions/:id` + - `POST /api/auth/logout-all` +- account and billing endpoints: + - `GET /api/account` + - `GET /api/billing/invoices` + - `POST /api/billing/invoices` + - `POST /api/admin/invoices/:id/mark-paid` +- generation endpoints: + - `POST /api/generations` + - `GET /api/generations/:id` +- uses cookie-based server sessions instead of temporary `x-user-id` + +### `apps/worker` +- polls queued generation requests +- claims one request at a time +- builds provider-key attempt order +- persists `GenerationAttempt` +- persists generated assets +- marks request `succeeded` / `failed` +- consumes quota only on success +- updates provider-key state and audit events +- supports proxy-first then direct fallback inside one key attempt +- runs cooldown recovery sweep back to `active` + +### `apps/cli` +- real Telegram pairing commands: + - `nproxy pair [--yes]` + - `nproxy pair list` + - `nproxy pair revoke [--yes]` + - `nproxy pair cleanup [--yes]` +- mutating commands require confirmation unless `--yes` +- successful mutations write audit logs + +### `apps/bot` +- Telegram long polling +- allowlist check +- pending pairing creation for unpaired users +- pairing code issuance +- system audit log on pairing initiation + +## Проверено +- `docker build -f infra/docker/web.Dockerfile .` проходит +- `docker build -f infra/docker/worker.Dockerfile .` проходит +- `docker build -f infra/docker/bot.Dockerfile .` проходит +- `docker build -f infra/docker/cli.Dockerfile .` проходит +- `docker build -f infra/docker/migrate.Dockerfile .` проходит +- `docker run --env-file .env.example` ранее успешно стартовал для `web`, `worker`, `bot` +- `prisma migrate deploy` ранее успешно проверялся против временного `postgres:16-alpine` + +## Что уже есть как product foundation +- регистрация и логин +- серверные сессии в БД +- password reset backend +- session management backend +- account overview backend +- billing invoice creation backend +- paid invoice -> subscription activation flow +- generation request lifecycle backend +- worker execution flow +- Telegram admin pairing flow + +## Что ещё отсутствует + +### Auth / account +- email verification +- device metadata / session rotation +- frontend account UI + +### Billing +- payment reconciliation worker flow +- invoice expiration / cancel flow +- webhook/provider callback handling +- полноценный billing history / admin payment operations surface + +### Generations +- реальный provider HTTP adapter вместо simulated `nano_banana` +- object storage upload/download path +- richer request/result payloads for frontend polling + +### Web product +- реальный frontend: + - landing + - dashboard + - billing pages + - chat UI + - admin UI + +### Bot / ops +- richer admin commands for allowed Telegram admins +- alerts / notifications +- provider health and billing events in bot output + +## Следующие шаги +1. Довести billing lifecycle: + - reconciliation flow + - invoice expiration/cancel + - webhook/provider callback handling +2. Заменить simulated image provider adapter на реальный transport adapter +3. Расширить `web` account/billing/generation API под реальный frontend +4. Добавить frontend surfaces поверх уже существующего backend +5. Расширить `bot` для operational alerts и admin commands + +## Ограничения и договорённости +- Не переносить бизнес-правила в `apps/*`. +- Provider-specific HTTP код должен оставаться в `packages/providers`. +- Сохранять разделение `GenerationRequest` и `GenerationAttempt`. +- Деплой остаётся `single VPS + Docker Compose`. +- User-facing quota остаётся approximate buckets only. + +## Полезные файлы +- `AGENTS.md` +- `docs/plan/mvp-system-plan.md` +- `docs/architecture/system-overview.md` +- `docs/ops/deployment.md` +- `docs/ops/provider-key-pool.md` +- `docs/ops/telegram-pairing.md` + +## Ограничение текущей среды Codex +- В текущем runtime нет локальных `node`, `npm`, `pnpm`, `corepack`, `tsc` в PATH. +- Проверка делалась через Docker-based builds. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec5d3c3 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# nproxy + +Planning scaffold for a crypto-subscription image gateway. + +The repository is intentionally light on runtime code. Its current purpose is to store: +- the agreed MVP plan; +- the repository layout for future implementation; +- operational notes for deployment, Telegram pairing, and provider key rotation; +- directory-scoped instructions so future Codex runs can implement against the same decisions. + +## Chosen baseline +- Product: B2C website +- Billing: one monthly plan, paid with crypto through a payment processor +- Model support: starts with `nano_banana` +- Generation modes: text-to-image and image-to-image +- Infra target: one VPS with Docker Compose +- Admin surfaces: web admin and Telegram bot +- Key management: multiple provider keys with round-robin routing, failover, cooldown, balance tracking, and optional per-key proxy + +## Main directories +- `apps/` runtime entrypoints +- `packages/` shared domain and adapter code +- `docs/` source-of-truth planning documents +- `infra/` deployment templates +- `scripts/` operational helpers + +## Read first +- `docs/plan/mvp-system-plan.md` +- `docs/architecture/system-overview.md` +- `docs/ops/deployment.md` diff --git a/apps/AGENTS.md b/apps/AGENTS.md new file mode 100644 index 0000000..d864a46 --- /dev/null +++ b/apps/AGENTS.md @@ -0,0 +1,9 @@ +# AGENTS.md + +## Scope +Applies within `apps/` unless a deeper file overrides it. + +## Rules +- Keep apps thin. Domain rules belong in `packages/domain`. +- App-local code may compose use cases but should not redefine billing, quota, or key-state logic. +- Any new runtime entrypoint must have a clear responsibility and own only transport concerns. diff --git a/apps/bot/AGENTS.md b/apps/bot/AGENTS.md new file mode 100644 index 0000000..2486c67 --- /dev/null +++ b/apps/bot/AGENTS.md @@ -0,0 +1,15 @@ +# AGENTS.md + +## Scope +Applies within `apps/bot`. + +## Responsibilities +- Telegram admin bot runtime +- allowlist checks +- alert delivery +- low-friction admin commands + +## Rules +- Pairing approval must never happen inside the bot runtime itself. +- The bot may initiate pending pairing, but only the server-side CLI completes it. +- Every command that changes state must produce an audit log entry. diff --git a/apps/bot/README.md b/apps/bot/README.md new file mode 100644 index 0000000..eb27565 --- /dev/null +++ b/apps/bot/README.md @@ -0,0 +1,3 @@ +# apps/bot + +Planned Telegram admin bot runtime. MVP should use long polling unless deployment requirements change. diff --git a/apps/bot/package.json b/apps/bot/package.json new file mode 100644 index 0000000..970c598 --- /dev/null +++ b/apps/bot/package.json @@ -0,0 +1,15 @@ +{ + "name": "@nproxy/bot", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/main.js" + }, + "dependencies": { + "@nproxy/config": "workspace:*", + "@nproxy/db": "workspace:*", + "@nproxy/providers": "workspace:*" + } +} diff --git a/apps/bot/src/.gitkeep b/apps/bot/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/bot/src/main.ts b/apps/bot/src/main.ts new file mode 100644 index 0000000..c222d5d --- /dev/null +++ b/apps/bot/src/main.ts @@ -0,0 +1,100 @@ +import { loadConfig } from "@nproxy/config"; +import { createPrismaTelegramBotStore, prisma } from "@nproxy/db"; +import { createTelegramBotApiTransport, type TelegramUpdate } from "@nproxy/providers"; + +const config = loadConfig(); +const telegramStore = createPrismaTelegramBotStore(prisma); +const telegramTransport = createTelegramBotApiTransport(config.telegram.botToken); +const pairingExpiresInMinutes = 15; +let nextUpdateOffset: number | undefined; +let isPolling = false; + +console.log( + JSON.stringify({ + service: "bot", + mode: config.telegram.botMode, + }), +); + +void pollLoop(); + +process.once("SIGTERM", async () => { + await prisma.$disconnect(); + process.exit(0); +}); + +process.once("SIGINT", async () => { + await prisma.$disconnect(); + process.exit(0); +}); + +async function pollLoop(): Promise { + if (isPolling) { + return; + } + + isPolling = true; + + try { + while (true) { + const updates = await telegramTransport.getUpdates({ + ...(nextUpdateOffset !== undefined ? { offset: nextUpdateOffset } : {}), + timeoutSeconds: 25, + }); + + for (const update of updates) { + await handleUpdate(update); + nextUpdateOffset = update.update_id + 1; + } + + if (updates.length === 0) { + console.log("bot polling heartbeat"); + } + } + } catch (error) { + console.error("bot polling failed", error); + setTimeout(() => { + isPolling = false; + void pollLoop(); + }, 5000); + } +} + +async function handleUpdate(update: TelegramUpdate): Promise { + const message = update.message; + const from = message?.from; + + if (!message || !from) { + return; + } + + const telegramUserId = String(from.id); + const displayNameSnapshot = [from.first_name, from.last_name].filter(Boolean).join(" "); + const isAllowed = await telegramStore.isTelegramAdminAllowed(telegramUserId); + + if (isAllowed) { + await telegramTransport.sendMessage({ + chatId: message.chat.id, + text: "Admin access is active.", + }); + return; + } + + const challenge = await telegramStore.getOrCreatePendingPairingChallenge( + { + telegramUserId, + ...(from.username ? { telegramUsername: from.username } : {}), + displayNameSnapshot: displayNameSnapshot || "Telegram user", + }, + pairingExpiresInMinutes, + ); + + await telegramTransport.sendMessage({ + chatId: message.chat.id, + text: [ + `Your pairing code is: ${challenge.code}`, + `Run nproxy pair ${challenge.code} on the server.`, + `Expires at ${challenge.expiresAt.toISOString()}.`, + ].join("\n"), + }); +} diff --git a/apps/bot/tsconfig.json b/apps/bot/tsconfig.json new file mode 100644 index 0000000..87d12db --- /dev/null +++ b/apps/bot/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md new file mode 100644 index 0000000..9c9278e --- /dev/null +++ b/apps/cli/AGENTS.md @@ -0,0 +1,14 @@ +# AGENTS.md + +## Scope +Applies within `apps/cli`. + +## Responsibilities +- server-side operational CLI commands +- Telegram pairing completion +- safe admin maintenance commands that are better run locally on the server + +## Rules +- Commands that mutate admin access must show the target identity and require explicit confirmation unless a non-interactive flag is provided. +- Pairing codes must be matched against hashed pending records. +- All successful mutating commands must write audit logs. diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000..d5b6e9e --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,9 @@ +# apps/cli + +Planned operator CLI. + +Expected early commands: +- `nproxy pair ` +- `nproxy pair list` +- `nproxy pair revoke ` +- `nproxy pair cleanup` diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..1397dc8 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,18 @@ +{ + "name": "@nproxy/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "nproxy": "./dist/main.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/main.js" + }, + "dependencies": { + "@nproxy/config": "workspace:*", + "@nproxy/db": "workspace:*", + "@nproxy/domain": "workspace:*" + } +} diff --git a/apps/cli/src/.gitkeep b/apps/cli/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts new file mode 100644 index 0000000..7c26b64 --- /dev/null +++ b/apps/cli/src/main.ts @@ -0,0 +1,233 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { loadConfig } from "@nproxy/config"; +import { createPrismaTelegramPairingStore, prisma } from "@nproxy/db"; +import { hashPairingCode, isPairingExpired, normalizePairingCode } from "@nproxy/domain"; + +const config = loadConfig(); +const pairingStore = createPrismaTelegramPairingStore(prisma); +const args = process.argv.slice(2); + +void main() + .catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); + +async function main(): Promise { + const [command, subcommandOrArgument, ...rest] = args; + + if (command !== "pair") { + printHelp(); + return; + } + + if (subcommandOrArgument === "list") { + await handlePairList(); + return; + } + + if (subcommandOrArgument === "cleanup") { + await handlePairCleanup(rest); + return; + } + + if (subcommandOrArgument === "revoke") { + await handlePairRevoke(rest[0], rest.slice(1)); + return; + } + + if (subcommandOrArgument) { + await handlePairComplete(subcommandOrArgument, rest); + return; + } + + printHelp(); +} + +async function handlePairComplete(code: string, flags: string[]): Promise { + const normalizedCode = normalizePairingCode(code); + const record = await pairingStore.findPendingPairingByCodeHash(hashPairingCode(normalizedCode)); + + if (!record) { + throw new Error(`Pending pairing not found for code ${normalizedCode}.`); + } + + if (isPairingExpired(record.expiresAt)) { + throw new Error(`Pairing code ${normalizedCode} has expired.`); + } + + printPairingIdentity(record); + + const confirmed = flags.includes("--yes") + ? true + : await confirm("Approve this Telegram admin pairing?"); + + if (!confirmed) { + console.log("pairing aborted"); + return; + } + + const completed = await pairingStore.completePendingPairing({ + pairingId: record.id, + actorRef: buildActorRef(), + }); + + console.log(`paired telegram_user_id=${completed.telegramUserId}`); +} + +async function handlePairList(): Promise { + const listing = await pairingStore.listTelegramPairings(); + + console.log("active telegram admins:"); + if (listing.activeAdmins.length === 0) { + console.log(" none"); + } else { + for (const entry of listing.activeAdmins) { + console.log( + [ + ` user_id=${entry.telegramUserId}`, + `display_name=${JSON.stringify(entry.displayNameSnapshot)}`, + ...(entry.telegramUsername ? [`username=@${entry.telegramUsername}`] : []), + `paired_at=${entry.pairedAt.toISOString()}`, + ].join(" "), + ); + } + } + + console.log("pending pairings:"); + if (listing.pending.length === 0) { + console.log(" none"); + return; + } + + const now = new Date(); + for (const record of listing.pending) { + console.log( + [ + ` pairing_id=${record.id}`, + `user_id=${record.telegramUserId}`, + `display_name=${JSON.stringify(record.displayNameSnapshot)}`, + ...(record.telegramUsername ? [`username=@${record.telegramUsername}`] : []), + `status=${record.status}`, + `expires_at=${record.expiresAt.toISOString()}`, + `expired=${isPairingExpired(record.expiresAt, now)}`, + ].join(" "), + ); + } +} + +async function handlePairRevoke( + telegramUserId: string | undefined, + flags: string[], +): Promise { + if (!telegramUserId) { + throw new Error("Missing telegram user id. Usage: nproxy pair revoke [--yes]"); + } + + const listing = await pairingStore.listTelegramPairings(); + const entry = listing.activeAdmins.find((item) => item.telegramUserId === telegramUserId); + + if (!entry) { + throw new Error(`Active telegram admin ${telegramUserId} was not found.`); + } + + console.log( + [ + "revoke target:", + `user_id=${entry.telegramUserId}`, + `display_name=${JSON.stringify(entry.displayNameSnapshot)}`, + ...(entry.telegramUsername ? [`username=@${entry.telegramUsername}`] : []), + ].join(" "), + ); + + const confirmed = flags.includes("--yes") + ? true + : await confirm("Revoke Telegram admin access?"); + + if (!confirmed) { + console.log("revoke aborted"); + return; + } + + await pairingStore.revokeTelegramAdmin({ + telegramUserId, + actorRef: buildActorRef(), + }); + + console.log(`revoked telegram_user_id=${telegramUserId}`); +} + +async function handlePairCleanup(flags: string[]): Promise { + const confirmed = flags.includes("--yes") + ? true + : await confirm("Mark all expired pending pairings as expired?"); + + if (!confirmed) { + console.log("cleanup aborted"); + return; + } + + const expiredCount = await pairingStore.cleanupExpiredPendingPairings({ + actorRef: buildActorRef(), + }); + + console.log(`expired_pairings=${expiredCount}`); +} + +async function confirm(prompt: string): Promise { + const readline = createInterface({ input: stdin, output: stdout }); + + try { + const answer = await readline.question(`${prompt} [y/N] `); + return answer.trim().toLowerCase() === "y"; + } finally { + readline.close(); + } +} + +function printPairingIdentity(record: { + telegramUserId: string; + displayNameSnapshot: string; + telegramUsername?: string; + expiresAt: Date; +}): void { + console.log( + [ + "pairing target:", + `user_id=${record.telegramUserId}`, + `display_name=${JSON.stringify(record.displayNameSnapshot)}`, + ...(record.telegramUsername ? [`username=@${record.telegramUsername}`] : []), + `expires_at=${record.expiresAt.toISOString()}`, + ].join(" "), + ); +} + +function buildActorRef(): string { + const username = + process.env.USER ?? + process.env.LOGNAME ?? + "unknown"; + const hostDigest = createHash("sha256") + .update(config.urls.appBaseUrl.toString()) + .digest("hex") + .slice(0, 8); + + return `${username}@${hostDigest}`; +} + +function printHelp(): void { + console.log("nproxy cli"); + console.log(`app=${config.urls.appBaseUrl.toString()}`); + console.log("available commands:"); + console.log(" nproxy pair [--yes]"); + console.log(" nproxy pair list"); + console.log(" nproxy pair revoke [--yes]"); + console.log(" nproxy pair cleanup [--yes]"); +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000..87d12db --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 0000000..55dd603 --- /dev/null +++ b/apps/web/AGENTS.md @@ -0,0 +1,16 @@ +# AGENTS.md + +## Scope +Applies within `apps/web`. + +## Responsibilities +- Public website +- User dashboard +- Chat UI +- Admin UI +- HTTP API entrypoints + +## Rules +- Keep React and route handlers focused on transport, rendering, and validation. +- Delegate subscription, quota, and provider-routing policy to shared packages. +- User-facing quota output must remain approximate unless the caller is an admin surface. diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..b10d55d --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,10 @@ +# apps/web + +Planned Next.js application for: +- public landing page +- authentication +- user dashboard +- chat UI +- billing pages +- admin web interface +- HTTP API routes diff --git a/apps/web/app/.gitkeep b/apps/web/app/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..038d206 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,16 @@ +{ + "name": "@nproxy/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/main.js" + }, + "dependencies": { + "@nproxy/config": "workspace:*", + "@nproxy/db": "workspace:*", + "@nproxy/domain": "workspace:*", + "@nproxy/providers": "workspace:*" + } +} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts new file mode 100644 index 0000000..8355fa4 --- /dev/null +++ b/apps/web/src/main.ts @@ -0,0 +1,782 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { loadConfig } from "@nproxy/config"; +import { + createPrismaAccountStore, + createPrismaAuthStore, + createPrismaBillingStore, + createPrismaGenerationStore, + prisma, +} from "@nproxy/db"; +import { createEmailTransport, createPaymentProviderAdapter } from "@nproxy/providers"; +import { + AuthError, + GenerationRequestError, + createGenerationRequest, + type CreateGenerationRequestInput, +} from "@nproxy/domain"; + +const config = loadConfig(); +const port = Number.parseInt(process.env.PORT ?? "3000", 10); +const accountStore = createPrismaAccountStore(prisma); +const authStore = createPrismaAuthStore(prisma); +const billingStore = createPrismaBillingStore(prisma); +const generationStore = createPrismaGenerationStore(prisma); +const emailTransport = createEmailTransport(config.email); +const paymentProviderAdapter = createPaymentProviderAdapter(config.payment); +const sessionCookieName = "nproxy_session"; + +const server = createServer(async (request, response) => { + try { + if (request.method === "GET" && request.url === "/healthz") { + sendJson(response, 200, { ok: true, service: "web" }); + return; + } + + if (request.method === "POST" && request.url === "/api/auth/register") { + const body = await readJsonBody(request); + const payload = readAuthPayload(body); + const session = await authStore.registerUser({ + email: payload.email, + password: payload.password, + passwordPepper: config.auth.passwordPepper, + }); + + sendJson( + response, + 201, + { user: serializeAuthenticatedUser(session.user) }, + createSessionCookie(session.token, session.expiresAt), + ); + return; + } + + if (request.method === "POST" && request.url === "/api/auth/login") { + const body = await readJsonBody(request); + const payload = readAuthPayload(body); + const session = await authStore.loginUser({ + email: payload.email, + password: payload.password, + passwordPepper: config.auth.passwordPepper, + }); + + sendJson( + response, + 200, + { user: serializeAuthenticatedUser(session.user) }, + createSessionCookie(session.token, session.expiresAt), + ); + return; + } + + if (request.method === "POST" && request.url === "/api/auth/password-reset/request") { + const body = await readJsonBody(request); + const payload = readEmailOnlyPayload(body); + const challenge = await authStore.createPasswordResetChallenge({ + email: payload.email, + }); + + if (challenge) { + const resetUrl = new URL("/reset-password", config.urls.appBaseUrl); + resetUrl.searchParams.set("token", challenge.token); + + await emailTransport.send({ + to: challenge.email, + subject: "Reset your nproxy password", + text: [ + "We received a request to reset your password.", + `Reset link: ${resetUrl.toString()}`, + `This link expires at ${challenge.expiresAt.toISOString()}.`, + ].join("\n"), + }); + } + + sendJson(response, 200, { + ok: true, + }); + return; + } + + if (request.method === "POST" && request.url === "/api/auth/password-reset/confirm") { + const body = await readJsonBody(request); + const payload = readPasswordResetConfirmPayload(body); + + await authStore.resetPassword({ + token: payload.token, + newPassword: payload.password, + passwordPepper: config.auth.passwordPepper, + }); + + sendJson(response, 200, { + ok: true, + }); + return; + } + + if (request.method === "POST" && request.url === "/api/auth/logout") { + const authenticatedSession = await requireAuthenticatedSession(request); + + await authStore.revokeSession(authenticatedSession.token); + + sendJson(response, 200, { ok: true }, clearSessionCookie()); + return; + } + + if (request.method === "GET" && request.url === "/api/auth/me") { + const authenticatedSession = await requireAuthenticatedSession(request); + sendJson(response, 200, { + user: serializeAuthenticatedUser(authenticatedSession.user), + session: serializeUserSession(authenticatedSession.session, authenticatedSession.session.id), + }); + return; + } + + if (request.method === "GET" && request.url === "/api/auth/sessions") { + const authenticatedSession = await requireAuthenticatedSession(request); + const sessions = await authStore.listUserSessions(authenticatedSession.user.id); + sendJson(response, 200, { + sessions: sessions.map((session) => + serializeUserSession(session, authenticatedSession.session.id), + ), + }); + return; + } + + if (request.method === "GET" && request.url === "/api/account") { + const authenticatedSession = await requireAuthenticatedSession(request); + const overview = await accountStore.getUserAccountOverview(authenticatedSession.user.id); + + if (!overview) { + sendJson(response, 404, { + error: { + code: "account_not_found", + message: "Account was not found.", + }, + }); + return; + } + + sendJson(response, 200, serializeAccountOverview(overview)); + return; + } + + if (request.method === "GET" && request.url === "/api/billing/invoices") { + const authenticatedSession = await requireAuthenticatedSession(request); + const invoices = await billingStore.listUserInvoices(authenticatedSession.user.id); + sendJson(response, 200, { + invoices: invoices.map(serializeBillingInvoice), + }); + return; + } + + if (request.method === "POST" && request.url === "/api/billing/invoices") { + const authenticatedSession = await requireAuthenticatedSession(request); + const invoice = await billingStore.createSubscriptionInvoice({ + userId: authenticatedSession.user.id, + paymentProvider: config.payment.provider, + paymentProviderAdapter, + }); + sendJson(response, 201, { + invoice: serializeBillingInvoice(invoice), + }); + return; + } + + if (request.method === "POST" && request.url === "/api/auth/logout-all") { + const authenticatedSession = await requireAuthenticatedSession(request); + const revokedCount = await authStore.revokeAllUserSessions({ + userId: authenticatedSession.user.id, + exceptSessionId: authenticatedSession.session.id, + }); + + sendJson(response, 200, { + ok: true, + revokedCount, + }); + return; + } + + if (request.method === "POST" && request.url) { + const invoiceMarkPaidMatch = request.url.match(/^\/api\/admin\/invoices\/([^/]+)\/mark-paid$/); + + if (invoiceMarkPaidMatch) { + const authenticatedSession = await requireAuthenticatedSession(request); + + if (!authenticatedSession.user.isAdmin) { + throw new HttpError(403, "forbidden", "Admin session is required."); + } + + const invoiceId = decodeURIComponent(invoiceMarkPaidMatch[1] ?? ""); + const invoice = await billingStore.markInvoicePaid({ invoiceId }); + sendJson(response, 200, { + invoice: serializeBillingInvoice(invoice), + }); + return; + } + } + + if (request.method === "DELETE" && request.url) { + const sessionMatch = request.url.match(/^\/api\/auth\/sessions\/([^/]+)$/); + + if (sessionMatch) { + const authenticatedSession = await requireAuthenticatedSession(request); + const sessionId = decodeURIComponent(sessionMatch[1] ?? ""); + + if (sessionId === authenticatedSession.session.id) { + await authStore.revokeSession(authenticatedSession.token); + sendJson(response, 200, { ok: true }, clearSessionCookie()); + return; + } + + const revoked = await authStore.revokeUserSession({ + userId: authenticatedSession.user.id, + sessionId, + }); + + if (!revoked) { + sendJson(response, 404, { + error: { + code: "session_not_found", + message: "Session was not found.", + }, + }); + return; + } + + sendJson(response, 200, { ok: true }); + return; + } + } + + if (request.method === "POST" && request.url === "/api/generations") { + const authenticatedSession = await requireAuthenticatedSession(request); + const body = await readJsonBody(request); + const requestInput = mapCreateGenerationRequestInput(body, authenticatedSession.user.id); + const result = await createGenerationRequest(generationStore, requestInput); + + sendJson(response, result.reusedExistingRequest ? 200 : 201, { + request: serializeGenerationRequest(result.request), + reusedExistingRequest: result.reusedExistingRequest, + approximateQuotaBucket: result.approximateQuotaBucket, + }); + return; + } + + if (request.method === "GET" && request.url) { + const match = request.url.match(/^\/api\/generations\/([^/]+)$/); + + if (match) { + const authenticatedSession = await requireAuthenticatedSession(request); + const requestId = decodeURIComponent(match[1] ?? ""); + const generationRequest = await generationStore.getGenerationRequest(requestId); + + if (!generationRequest || generationRequest.userId !== authenticatedSession.user.id) { + sendJson(response, 404, { + error: { + code: "not_found", + message: "Generation request was not found.", + }, + }); + return; + } + + sendJson(response, 200, { + request: serializeGenerationRequest(generationRequest), + }); + return; + } + } + + sendJson(response, 200, { + service: "web", + appBaseUrl: config.urls.appBaseUrl.toString(), + adminBaseUrl: config.urls.adminBaseUrl.toString(), + providerModel: config.provider.nanoBananaDefaultModel, + endpoints: { + register: "POST /api/auth/register", + login: "POST /api/auth/login", + passwordResetRequest: "POST /api/auth/password-reset/request", + passwordResetConfirm: "POST /api/auth/password-reset/confirm", + logout: "POST /api/auth/logout", + me: "GET /api/auth/me", + sessions: "GET /api/auth/sessions", + revokeSession: "DELETE /api/auth/sessions/:id", + logoutAll: "POST /api/auth/logout-all", + account: "GET /api/account", + listInvoices: "GET /api/billing/invoices", + createInvoice: "POST /api/billing/invoices", + adminMarkInvoicePaid: "POST /api/admin/invoices/:id/mark-paid", + createGenerationRequest: "POST /api/generations", + getGenerationRequest: "GET /api/generations/:id", + }, + }); + } catch (error) { + handleRequestError(response, error); + } +}); + +server.listen(port, "0.0.0.0", () => { + console.log(`web listening on ${port}`); +}); + +process.once("SIGTERM", async () => { + await prisma.$disconnect(); + process.exit(0); +}); + +process.once("SIGINT", async () => { + await prisma.$disconnect(); + process.exit(0); +}); + +async function readJsonBody( + request: IncomingMessage, +): Promise { + const chunks: Uint8Array[] = []; + + for await (const chunk of request) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + + if (chunks.length === 0) { + throw new HttpError(400, "invalid_json", "Request body must not be empty."); + } + + const rawBody = Buffer.concat(chunks).toString("utf8"); + + try { + return JSON.parse(rawBody) as unknown; + } catch { + throw new HttpError(400, "invalid_json", "Request body must be valid JSON."); + } +} + +function readAuthPayload(body: unknown): { email: string; password: string } { + if (!body || typeof body !== "object") { + throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); + } + + const payload = body as Record; + + return { + email: readRequiredString(payload.email, "email"), + password: readRequiredString(payload.password, "password"), + }; +} + +function readEmailOnlyPayload(body: unknown): { email: string } { + if (!body || typeof body !== "object") { + throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); + } + + const payload = body as Record; + + return { + email: readRequiredString(payload.email, "email"), + }; +} + +function readPasswordResetConfirmPayload( + body: unknown, +): { token: string; password: string } { + if (!body || typeof body !== "object") { + throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); + } + + const payload = body as Record; + + return { + token: readRequiredString(payload.token, "token"), + password: readRequiredString(payload.password, "password"), + }; +} + +function mapCreateGenerationRequestInput( + body: unknown, + userId: string, +): CreateGenerationRequestInput { + if (!body || typeof body !== "object") { + throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); + } + + const payload = body as Record; + + return { + userId, + mode: readGenerationMode(payload.mode), + providerModel: readRequiredString(payload.providerModel, "providerModel"), + prompt: readRequiredString(payload.prompt, "prompt"), + resolutionPreset: readRequiredString(payload.resolutionPreset, "resolutionPreset"), + batchSize: readRequiredInteger(payload.batchSize, "batchSize"), + ...(payload.sourceImageKey !== undefined + ? { sourceImageKey: readRequiredString(payload.sourceImageKey, "sourceImageKey") } + : {}), + ...(payload.imageStrength !== undefined + ? { imageStrength: readRequiredNumber(payload.imageStrength, "imageStrength") } + : {}), + ...(payload.idempotencyKey !== undefined + ? { idempotencyKey: readRequiredString(payload.idempotencyKey, "idempotencyKey") } + : {}), + }; +} + +function readGenerationMode(value: unknown): CreateGenerationRequestInput["mode"] { + if (value === "text_to_image" || value === "image_to_image") { + return value; + } + + throw new HttpError( + 400, + "invalid_mode", + 'mode must be "text_to_image" or "image_to_image".', + ); +} + +function readRequiredString(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new HttpError(400, "invalid_field", `${field} must be a non-empty string.`); + } + + return value; +} + +function readRequiredInteger(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new HttpError(400, "invalid_field", `${field} must be an integer.`); + } + + return value; +} + +function readRequiredNumber(value: unknown, field: string): number { + if (typeof value !== "number" || Number.isNaN(value)) { + throw new HttpError(400, "invalid_field", `${field} must be a number.`); + } + + return value; +} + +function serializeAuthenticatedUser(user: { + id: string; + email: string; + isAdmin: boolean; + createdAt: Date; +}) { + return { + id: user.id, + email: user.email, + isAdmin: user.isAdmin, + createdAt: user.createdAt.toISOString(), + }; +} + +function serializeUserSession( + session: { + id: string; + expiresAt: Date; + revokedAt?: Date; + lastSeenAt?: Date; + createdAt: Date; + }, + currentSessionId: string, +) { + return { + id: session.id, + expiresAt: session.expiresAt.toISOString(), + createdAt: session.createdAt.toISOString(), + isCurrent: session.id === currentSessionId, + ...(session.revokedAt ? { revokedAt: session.revokedAt.toISOString() } : {}), + ...(session.lastSeenAt ? { lastSeenAt: session.lastSeenAt.toISOString() } : {}), + }; +} + +function serializeAccountOverview(overview: { + user: { + id: string; + email: string; + isAdmin: boolean; + createdAt: Date; + }; + subscription: { + id: string; + status: string; + renewsManually: boolean; + activatedAt?: Date; + currentPeriodStart?: Date; + currentPeriodEnd?: Date; + canceledAt?: Date; + plan: { + id: string; + code: string; + displayName: string; + monthlyRequestLimit: number; + monthlyPriceUsd: number; + billingCurrency: string; + isActive: boolean; + }; + } | null; + quota: { + approximateBucket: number; + usedSuccessfulRequests: number; + monthlyRequestLimit: number; + } | null; +}) { + return { + user: serializeAuthenticatedUser(overview.user), + subscription: overview.subscription + ? { + id: overview.subscription.id, + status: overview.subscription.status, + renewsManually: overview.subscription.renewsManually, + ...(overview.subscription.activatedAt + ? { activatedAt: overview.subscription.activatedAt.toISOString() } + : {}), + ...(overview.subscription.currentPeriodStart + ? { currentPeriodStart: overview.subscription.currentPeriodStart.toISOString() } + : {}), + ...(overview.subscription.currentPeriodEnd + ? { currentPeriodEnd: overview.subscription.currentPeriodEnd.toISOString() } + : {}), + ...(overview.subscription.canceledAt + ? { canceledAt: overview.subscription.canceledAt.toISOString() } + : {}), + plan: overview.subscription.plan, + } + : null, + quota: overview.quota, + }; +} + +function serializeBillingInvoice(invoice: { + id: string; + subscriptionId?: string; + provider: string; + providerInvoiceId?: string; + status: string; + currency: string; + amountCrypto: number; + amountUsd?: number; + paymentAddress?: string; + expiresAt?: Date; + paidAt?: Date; + createdAt: Date; + updatedAt: Date; +}) { + return { + id: invoice.id, + provider: invoice.provider, + status: invoice.status, + currency: invoice.currency, + amountCrypto: invoice.amountCrypto, + createdAt: invoice.createdAt.toISOString(), + updatedAt: invoice.updatedAt.toISOString(), + ...(invoice.subscriptionId ? { subscriptionId: invoice.subscriptionId } : {}), + ...(invoice.providerInvoiceId ? { providerInvoiceId: invoice.providerInvoiceId } : {}), + ...(invoice.amountUsd !== undefined ? { amountUsd: invoice.amountUsd } : {}), + ...(invoice.paymentAddress ? { paymentAddress: invoice.paymentAddress } : {}), + ...(invoice.expiresAt ? { expiresAt: invoice.expiresAt.toISOString() } : {}), + ...(invoice.paidAt ? { paidAt: invoice.paidAt.toISOString() } : {}), + }; +} + +function serializeGenerationRequest(requestRecord: { + id: string; + userId: string; + mode: string; + status: string; + providerModel: string; + prompt: string; + sourceImageKey?: string; + resolutionPreset: string; + batchSize: number; + imageStrength?: number; + idempotencyKey?: string; + terminalErrorCode?: string; + terminalErrorText?: string; + requestedAt: Date; + startedAt?: Date; + completedAt?: Date; + createdAt: Date; + updatedAt: Date; +}) { + return { + id: requestRecord.id, + userId: requestRecord.userId, + mode: requestRecord.mode, + status: requestRecord.status, + providerModel: requestRecord.providerModel, + prompt: requestRecord.prompt, + resolutionPreset: requestRecord.resolutionPreset, + batchSize: requestRecord.batchSize, + requestedAt: requestRecord.requestedAt.toISOString(), + createdAt: requestRecord.createdAt.toISOString(), + updatedAt: requestRecord.updatedAt.toISOString(), + ...(requestRecord.sourceImageKey !== undefined + ? { sourceImageKey: requestRecord.sourceImageKey } + : {}), + ...(requestRecord.imageStrength !== undefined + ? { imageStrength: requestRecord.imageStrength } + : {}), + ...(requestRecord.idempotencyKey !== undefined + ? { idempotencyKey: requestRecord.idempotencyKey } + : {}), + ...(requestRecord.terminalErrorCode !== undefined + ? { terminalErrorCode: requestRecord.terminalErrorCode } + : {}), + ...(requestRecord.terminalErrorText !== undefined + ? { terminalErrorText: requestRecord.terminalErrorText } + : {}), + ...(requestRecord.startedAt !== undefined + ? { startedAt: requestRecord.startedAt.toISOString() } + : {}), + ...(requestRecord.completedAt !== undefined + ? { completedAt: requestRecord.completedAt.toISOString() } + : {}), + }; +} + +async function requireAuthenticatedSession(request: IncomingMessage) { + const sessionToken = readSessionToken(request); + + if (!sessionToken) { + throw new HttpError(401, "unauthorized", "Missing authenticated session."); + } + + const authenticatedSession = await authStore.getUserBySessionToken(sessionToken); + + if (!authenticatedSession) { + throw new HttpError(401, "unauthorized", "Authenticated session is invalid."); + } + + return { + ...authenticatedSession, + token: sessionToken, + }; +} + +function readSessionToken(request: IncomingMessage): string | null { + const cookieHeader = request.headers.cookie; + + if (!cookieHeader) { + return null; + } + + for (const part of cookieHeader.split(";")) { + const [rawName, ...rawValue] = part.trim().split("="); + + if (rawName === sessionCookieName) { + return decodeURIComponent(rawValue.join("=")); + } + } + + return null; +} + +function createSessionCookie(token: string, expiresAt: Date): string { + return [ + `${sessionCookieName}=${encodeURIComponent(token)}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + "Secure", + `Expires=${expiresAt.toUTCString()}`, + ].join("; "); +} + +function clearSessionCookie(): string { + return [ + `${sessionCookieName}=`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + "Secure", + "Expires=Thu, 01 Jan 1970 00:00:00 GMT", + ].join("; "); +} + +function handleRequestError( + response: ServerResponse, + error: unknown, +): void { + if (error instanceof HttpError) { + sendJson(response, error.statusCode, { + error: { + code: error.code, + message: error.message, + }, + }); + return; + } + + if (error instanceof AuthError) { + const statusCode = + error.code === "email_already_exists" + ? 409 + : error.code === "invalid_credentials" + ? 401 + : 400; + + sendJson(response, statusCode, { + error: { + code: error.code, + message: error.message, + }, + }); + return; + } + + if (error instanceof GenerationRequestError) { + const statusCode = + error.code === "missing_active_subscription" + ? 403 + : error.code === "quota_exhausted" + ? 409 + : error.code === "request_not_found" + ? 404 + : error.code === "request_not_completable" + ? 409 + : 400; + + sendJson(response, statusCode, { + error: { + code: error.code, + message: error.message, + }, + }); + return; + } + + console.error(error); + sendJson(response, 500, { + error: { + code: "internal_error", + message: "Internal server error.", + }, + }); +} + +function sendJson( + response: ServerResponse, + statusCode: number, + payload: unknown, + setCookie?: string, +): void { + response.writeHead(statusCode, { + "content-type": "application/json", + ...(setCookie ? { "set-cookie": setCookie } : {}), + }); + response.end(JSON.stringify(payload)); +} + +class HttpError extends Error { + constructor( + readonly statusCode: number, + readonly code: string, + message: string, + ) { + super(message); + } +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..87d12db --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/worker/AGENTS.md b/apps/worker/AGENTS.md new file mode 100644 index 0000000..67d6c45 --- /dev/null +++ b/apps/worker/AGENTS.md @@ -0,0 +1,17 @@ +# AGENTS.md + +## Scope +Applies within `apps/worker`. + +## Responsibilities +- generation job execution +- payment reconciliation +- media cleanup +- provider-key balance polling +- provider-key recovery checks +- alert and reminder jobs + +## Rules +- Persist every provider-key attempt. +- Consume quota only after a request succeeds. +- Keep retries idempotent and auditable. diff --git a/apps/worker/README.md b/apps/worker/README.md new file mode 100644 index 0000000..e02605d --- /dev/null +++ b/apps/worker/README.md @@ -0,0 +1,3 @@ +# apps/worker + +Planned worker runtime for queued and scheduled jobs. diff --git a/apps/worker/package.json b/apps/worker/package.json new file mode 100644 index 0000000..ed1ce97 --- /dev/null +++ b/apps/worker/package.json @@ -0,0 +1,16 @@ +{ + "name": "@nproxy/worker", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/main.js" + }, + "dependencies": { + "@nproxy/config": "workspace:*", + "@nproxy/db": "workspace:*", + "@nproxy/domain": "workspace:*", + "@nproxy/providers": "workspace:*" + } +} diff --git a/apps/worker/src/.gitkeep b/apps/worker/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts new file mode 100644 index 0000000..6777104 --- /dev/null +++ b/apps/worker/src/main.ts @@ -0,0 +1,150 @@ +import { loadConfig } from "@nproxy/config"; +import { createPrismaWorkerStore, prisma } from "@nproxy/db"; +import { createNanoBananaSimulatedAdapter } from "@nproxy/providers"; + +const config = loadConfig(); +const intervalMs = config.keyPool.balancePollSeconds * 1000; +const workerStore = createPrismaWorkerStore(prisma, { + cooldownMinutes: config.keyPool.cooldownMinutes, + failuresBeforeManualReview: config.keyPool.failuresBeforeManualReview, +}); +const nanoBananaAdapter = createNanoBananaSimulatedAdapter(); +let isTickRunning = false; + +console.log( + JSON.stringify({ + service: "worker", + balancePollSeconds: config.keyPool.balancePollSeconds, + providerModel: config.provider.nanoBananaDefaultModel, + }), +); + +setInterval(() => { + void runTick(); +}, intervalMs); + +void runTick(); + +process.once("SIGTERM", async () => { + await prisma.$disconnect(); + process.exit(0); +}); + +process.once("SIGINT", async () => { + await prisma.$disconnect(); + process.exit(0); +}); + +async function runTick(): Promise { + if (isTickRunning) { + console.log("worker tick skipped because previous tick is still running"); + return; + } + + isTickRunning = true; + + try { + const recovery = await workerStore.recoverCooldownProviderKeys(); + + if (recovery.recoveredCount > 0) { + console.log( + JSON.stringify({ + service: "worker", + event: "cooldown_keys_recovered", + recoveredCount: recovery.recoveredCount, + }), + ); + } + + const job = await workerStore.claimNextQueuedGenerationJob(); + + if (!job) { + console.log(`worker heartbeat interval=${intervalMs} no_queued_jobs=true`); + return; + } + + const result = await workerStore.processClaimedGenerationJob( + job, + async (request, providerKey) => { + if (providerKey.providerCode !== config.provider.nanoBananaDefaultModel) { + return { + ok: false as const, + usedProxy: false, + directFallbackUsed: false, + failureKind: "unknown" as const, + providerErrorCode: "unsupported_provider_model", + providerErrorText: `Unsupported provider model: ${providerKey.providerCode}`, + }; + } + + if (providerKey.proxyBaseUrl) { + const proxyResult = await nanoBananaAdapter.executeGeneration({ + request, + providerKey: { + id: providerKey.id, + providerCode: providerKey.providerCode, + label: providerKey.label, + apiKeyLastFour: providerKey.apiKeyLastFour, + }, + route: { + kind: "proxy", + proxyBaseUrl: providerKey.proxyBaseUrl, + }, + }); + + if (!proxyResult.ok && proxyResult.failureKind === "transport") { + const directResult = await nanoBananaAdapter.executeGeneration({ + request, + providerKey: { + id: providerKey.id, + providerCode: providerKey.providerCode, + label: providerKey.label, + apiKeyLastFour: providerKey.apiKeyLastFour, + }, + route: { + kind: "direct", + }, + }); + + return { + ...directResult, + usedProxy: true, + directFallbackUsed: true, + }; + } + + return { + ...proxyResult, + usedProxy: true, + directFallbackUsed: false, + }; + } + + const directResult = await nanoBananaAdapter.executeGeneration({ + request, + providerKey: { + id: providerKey.id, + providerCode: providerKey.providerCode, + label: providerKey.label, + apiKeyLastFour: providerKey.apiKeyLastFour, + }, + route: { + kind: "direct", + }, + }); + + return { + ...directResult, + usedProxy: false, + directFallbackUsed: false, + }; + }, + ); + + console.log(JSON.stringify({ service: "worker", event: "job_processed", ...result })); + } catch (error) { + console.error("worker tick failed", error); + } finally { + isTickRunning = false; + } +} diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json new file mode 100644 index 0000000..87d12db --- /dev/null +++ b/apps/worker/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..2e272b5 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,13 @@ +# AGENTS.md + +## Scope +Applies within `docs/`. + +## Purpose +Documentation in this directory is operational source-of-truth, not marketing copy. + +## Rules +- Keep docs aligned with actual architecture decisions. +- Prefer short, decision-complete documents over long narrative text. +- When architecture changes, update the relevant document in the same change. +- Do not store secrets or live credentials here. diff --git a/docs/architecture/repository-layout.md b/docs/architecture/repository-layout.md new file mode 100644 index 0000000..75a0bed --- /dev/null +++ b/docs/architecture/repository-layout.md @@ -0,0 +1,52 @@ +# Repository Layout + +## Tree +```text +. +|- apps/ +| |- web/ +| |- worker/ +| |- bot/ +| `- cli/ +|- packages/ +| |- config/ +| |- db/ +| |- domain/ +| `- providers/ +|- docs/ +| |- plan/ +| |- architecture/ +| `- ops/ +|- infra/ +| |- compose/ +| `- caddy/ +`- scripts/ +``` + +## Directory responsibilities +### `apps/web` +Owns the browser-facing product and HTTP API entrypoints. It should not own core business rules. + +### `apps/worker` +Owns asynchronous and scheduled work. It is the execution surface for image-generation jobs, cleanup, and health polling. + +### `apps/bot` +Owns Telegram admin interaction only. Business decisions still belong to `packages/domain`. + +### `apps/cli` +Owns operator-facing CLI commands such as `nproxy pair`, `nproxy pair list`, and `nproxy pair revoke`. + +### `packages/config` +Owns typed environment contracts and config normalization. + +### `packages/db` +Owns database schema, migrations, and data-access utilities. + +### `packages/domain` +Owns subscription logic, quota logic, key state transitions, and orchestration rules. + +### `packages/providers` +Owns provider-specific adapters and low-level HTTP calls. It should not decide business policy. + +### `infra` +Owns deployment templates and reverse-proxy configuration for the single-VPS Docker Compose target. diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md new file mode 100644 index 0000000..28f59ee --- /dev/null +++ b/docs/architecture/system-overview.md @@ -0,0 +1,34 @@ +# System Overview + +## Runtime components +- `apps/web`: public site, user dashboard, admin UI, HTTP API handlers +- `apps/worker`: background jobs for generation execution, reconciliation, cleanup, and health checks +- `apps/bot`: Telegram admin bot runtime +- `apps/cli`: operator commands executed on the server + +## Shared packages +- `packages/config`: environment parsing and config contracts +- `packages/db`: Prisma schema, migrations, data access helpers +- `packages/domain`: business rules and state machines +- `packages/providers`: external adapters for model APIs, payment processor, storage, email, and Telegram + +## Core request flow +1. User submits a generation request from the chat UI. +2. The web app validates auth, subscription, quota, and request shape. +3. The app stores a `GenerationRequest` and enqueues work. +4. The worker runs provider routing through the key pool. +5. The worker persists `GenerationAttempt` rows for each key-level attempt. +6. On the first success, the worker stores assets, marks the request succeeded, and consumes quota. +7. The web app exposes polling endpoints until the result is ready. + +## Data boundaries +- User-visible request lifecycle lives in `GenerationRequest`. +- Key-level retries live in `GenerationAttempt`. +- Quota accounting lives in `UsageLedgerEntry`. +- Provider key health lives in `ProviderKey` plus status-event history. + +## Failure handling +- Retryable provider failures are hidden from the user while eligible keys remain. +- User-caused provider failures are terminal for that request. +- Balance or quota exhaustion removes a key from active rotation. +- Provider-key state transitions must be audited. diff --git a/docs/ops/deployment.md b/docs/ops/deployment.md new file mode 100644 index 0000000..a371589 --- /dev/null +++ b/docs/ops/deployment.md @@ -0,0 +1,48 @@ +# Deployment Plan + +## Chosen target +Deploy on one VPS with Docker Compose. + +## Why this target +- The system has multiple long-lived components: web, worker, bot, database, and reverse proxy. +- Compose gives predictable service boundaries, easier upgrades, and easier recovery than manually managed host processes. +- It keeps the path open for later separation of web, worker, and bot without reworking the repository layout. + +## Expected services +- `migrate`: one-shot schema bootstrap job run before app services start +- `web`: Next.js app serving the site, dashboard, admin UI, and API routes +- `worker`: background job processor +- `bot`: Telegram admin bot runtime +- `postgres`: primary database +- `caddy`: TLS termination and reverse proxy +- optional `minio`: self-hosted object storage for single-server deployments + +## Deployment notes +- Run one Compose project on a single server. +- Keep persistent data in named volumes or external storage. +- Keep secrets in server-side environment files or a secret manager. +- Back up PostgreSQL and object storage separately. +- Prefer Telegram long polling in MVP to avoid an extra public webhook surface for the bot. + +## Upgrade strategy +- Build new images. +- Run the one-shot database schema job. +- Restart `web`, `worker`, and `bot` in the same Compose project. +- Roll back by redeploying the previous image set if schema changes are backward compatible. + +## Current database bootstrap state +- The current Compose template runs a `migrate` service before `web`, `worker`, and `bot`. +- The job runs `prisma migrate deploy` from the committed migration history. +- The same bootstrap job also ensures the default MVP `SubscriptionPlan` row exists after migrations. +- Schema changes must land with a new committed Prisma migration before deployment. + +## Initial operational checklist +- provision VPS +- install Docker and Compose plugin +- provision DNS and TLS +- provision PostgreSQL storage +- provision S3-compatible storage or enable local MinIO +- create `.env` +- deploy Compose stack +- run database migration job +- verify web health, worker job loop, and bot polling diff --git a/docs/ops/provider-key-pool.md b/docs/ops/provider-key-pool.md new file mode 100644 index 0000000..72c0cb9 --- /dev/null +++ b/docs/ops/provider-key-pool.md @@ -0,0 +1,67 @@ +# Provider Key Pool + +## Purpose +Route generation traffic through multiple provider API keys while hiding transient failures from end users. + +## Key selection +- Only keys in `active` state are eligible for first-pass routing. +- Requests start from the next active key by round robin. +- A single request must not attempt the same key twice. + +## Optional proxy behavior +- A key may have one optional proxy attached. +- If a proxy exists, the first attempt uses the proxy. +- If the proxy path fails with a transport error, retry the same key directly. +- Direct fallback does not bypass other business checks. +- Current runtime policy reads cooldown and manual-review thresholds from environment: + - `KEY_COOLDOWN_MINUTES` + - `KEY_FAILURES_BEFORE_MANUAL_REVIEW` + +## Retry rules +Retry on the next key only for: +- network errors +- connection failures +- timeouts +- provider `5xx` + +Do not retry on the next key for: +- validation errors +- unsupported inputs +- policy rejections +- other user-caused provider `4xx` + +## States +- `active` +- `cooldown` +- `out_of_funds` +- `manual_review` +- `disabled` + +## Transitions +- `active -> cooldown` on retryable failures +- `cooldown -> active` after successful automatic recheck +- `cooldown -> manual_review` after more than 10 consecutive retryable failures across recovery cycles +- `active|cooldown -> out_of_funds` on confirmed insufficient funds +- `out_of_funds -> active` only by manual admin action +- `manual_review -> active` only by manual admin action +- `active -> disabled` by manual admin action + +## Current runtime note +- The current worker implementation already applies proxy-first then direct fallback within one provider-key attempt. +- The current worker implementation writes `GenerationAttempt.usedProxy` and `GenerationAttempt.directFallbackUsed` for auditability. +- The current worker implementation also runs a background cooldown-recovery sweep and returns keys to `active` after `cooldownUntil` passes. + +## Balance tracking +- Primary source of truth is the provider balance API. +- Balance refresh runs periodically and also after relevant failures. +- Telegram admin output must show per-key balance snapshots and the count of keys in `out_of_funds`. + +## Admin expectations +Web admin and Telegram admin must both be able to: +- inspect key state +- inspect last error category and code +- inspect balance snapshot and refresh time +- enable or disable a key +- return a key from `manual_review` +- return a key from `out_of_funds` +- add a new key diff --git a/docs/ops/telegram-pairing.md b/docs/ops/telegram-pairing.md new file mode 100644 index 0000000..2498589 --- /dev/null +++ b/docs/ops/telegram-pairing.md @@ -0,0 +1,48 @@ +# Telegram Pairing Flow + +## Goal +Allow a new Telegram admin to be approved from the server console without editing the database manually. + +## Runtime behavior +### Unpaired user +1. A user opens the Telegram bot. +2. The bot checks whether `telegram_user_id` is present in the allowlist. +3. If not present, the bot creates a pending pairing record with: + - Telegram user ID + - Telegram username and display name snapshot + - pairing code hash + - expiration timestamp + - status `pending` +4. The bot replies with a message telling the user to run `nproxy pair ` on the server. + +Current runtime note: +- The current bot runtime uses Telegram long polling. +- On each message from an unpaired user, the bot rotates any previous pending code and issues a fresh pairing code. +- Pending pairing creation writes an audit-log entry with actor type `system`. + +### Pair completion +1. An operator runs `nproxy pair ` on the server. +2. The CLI looks up the pending pairing by code. +3. The CLI prints the target Telegram identity and asks for confirmation. +4. On confirmation, the CLI adds the Telegram user to the allowlist. +5. The CLI marks the pending pairing record as `completed`. +6. The CLI writes an admin action log entry. + +## Required CLI commands +- `nproxy pair ` +- `nproxy pair list` +- `nproxy pair revoke ` +- `nproxy pair cleanup` + +## Current CLI behavior +- `nproxy pair ` prints the Telegram identity and requires explicit confirmation unless `--yes` is provided. +- `nproxy pair list` prints active allowlist entries and pending pairing records. +- `nproxy pair revoke ` requires explicit confirmation unless `--yes` is provided. +- `nproxy pair cleanup` marks expired pending pairing records as `expired` and writes an audit log entry. + +## Security rules +- Pairing codes expire. +- Pairing codes are stored hashed, not in plaintext. +- Only the server-side CLI can complete a pairing. +- Telegram bot access is denied until allowlist membership exists. +- Every pairing and revocation action is auditable. diff --git a/docs/plan/mvp-system-plan.md b/docs/plan/mvp-system-plan.md new file mode 100644 index 0000000..c10232f --- /dev/null +++ b/docs/plan/mvp-system-plan.md @@ -0,0 +1,103 @@ +# MVP System Plan + +## Summary +Build `nproxy`, a B2C web product for image generation through external model APIs. The first model is `nano_banana`. Users register with `email + password`, pay a monthly crypto subscription, receive a monthly request limit, and use a chat-style interface for `text-to-image` and `image-to-image` generation. + +The service hides provider-key failures behind a routed key pool. A user request is attempted against one provider key at a time. Retryable failures move execution to the next eligible key. The user sees an error only after all eligible keys have been exhausted or the request fails for a terminal user-caused reason. + +## Confirmed MVP decisions +- One B2C website. +- One monthly subscription plan. +- Crypto checkout through a payment processor. +- Manual renewal in MVP. +- Text-to-image and image-to-image. +- User-facing synchronous experience implemented with polling over background execution. +- Approximate quota buckets only: `100/80/60/40/20/0`. +- Storage in S3-compatible object storage. +- One VPS deployment with Docker Compose. +- Web admin plus Telegram admin bot. +- Telegram admin onboarding through pairing on the server console. +- Multiple provider API keys with round-robin routing, cooldown, balance tracking, optional per-key proxy, and transparent failover. + +## Core product surfaces +### Public web +- landing page +- register / login / password reset +- dashboard with subscription state and approximate quota +- chat UI +- billing / checkout pages + +### Admin surfaces +- web admin for users, subscriptions, payments, generations, provider keys, proxies, and health +- Telegram bot for alerts and low-friction admin actions +- CLI for server-side operational commands, including Telegram pairing + +## Main backend domains +- auth +- billing +- subscriptions +- quota ledger +- conversations and generations +- provider routing +- provider key pool health +- asset storage +- admin audit +- notifications + +## Billing rules +- One active plan in MVP. +- Each user has an individual billing cycle based on successful activation timestamp. +- Limit resets on each successful cycle activation. +- One successful generation consumes one request. +- Failed generations do not consume quota. + +## Quota display contract +Backend tracks exact usage. Normal users see only an approximate bucket: +- `81-100%` remaining -> `100%` +- `61-80%` remaining -> `80%` +- `41-60%` remaining -> `60%` +- `21-40%` remaining -> `40%` +- `1-20%` remaining -> `20%` +- `0%` remaining -> `0%` + +## Generation controls in MVP +- mode: `text_to_image` or `image_to_image` +- resolution preset +- batch size +- image strength for `image_to_image` + +## Key pool behavior +- Start from the next `active` key by round robin. +- Use the key-specific proxy first if configured. +- If the proxy path fails with a transport error, retry the same key directly. +- Retry on the next key only for retryable failures: network, timeout, provider `5xx`. +- Do not retry on the next key for validation, policy, or other user-caused `4xx` errors. +- Move a key to `cooldown` on retryable failures. +- Default cooldown is `5 minutes`. +- After more than `10` consecutive retryable failures across cooldown recoveries, move the key to `manual_review`. +- Move a key to `out_of_funds` when the provider balance API or provider response shows insufficient funds. +- `out_of_funds` and `manual_review` keys return to service only through a manual admin action. + +## Telegram pairing +1. A Telegram user opens the bot. +2. If the user is not in the allowlist, the bot generates a short pairing code and stores a pending pairing record. +3. The bot tells the user to run `nproxy pair ` on the server. +4. The server-side CLI confirms the target user and adds the Telegram ID to the allowlist. +5. The pairing record is marked complete and the user gains bot access. + +## Deployment target +Single VPS with Docker Compose, expected services: +- `web` +- `worker` +- `bot` +- `postgres` +- `caddy` or `nginx` +- optional `minio` when object storage is self-hosted + +## Future-compatible boundaries +The codebase should be able to add later: +- more image providers +- more billing methods +- more subscription plans +- internal balance wallet +- recurring billing if the payment processor supports it natively diff --git a/infra/AGENTS.md b/infra/AGENTS.md new file mode 100644 index 0000000..c3dd36d --- /dev/null +++ b/infra/AGENTS.md @@ -0,0 +1,14 @@ +# AGENTS.md + +## Scope +Applies within `infra/`. + +## Responsibilities +- Docker Compose templates +- reverse-proxy configuration +- deploy-facing config examples + +## Rules +- Keep the single-VPS Compose deployment as the primary target until product scope changes. +- Do not assume Kubernetes or multi-host orchestration. +- Document every externally exposed port and persistent volume. diff --git a/infra/caddy/Caddyfile b/infra/caddy/Caddyfile new file mode 100644 index 0000000..5e6fe28 --- /dev/null +++ b/infra/caddy/Caddyfile @@ -0,0 +1,8 @@ +# Placeholder reverse-proxy config for the future Docker Compose deployment. +# Replace example.com and upstream targets during implementation. + +example.com { + encode zstd gzip + + reverse_proxy web:3000 +} diff --git a/infra/compose/README.md b/infra/compose/README.md new file mode 100644 index 0000000..a797fd9 --- /dev/null +++ b/infra/compose/README.md @@ -0,0 +1,17 @@ +# infra/compose + +Deployment templates for the chosen single-VPS Docker Compose target. + +## Services +- `migrate` +- `web` +- `worker` +- `bot` +- `postgres` +- `caddy` +- optional `minio` + +## Current state +- Runtime images now build the PNPM workspace in-container. +- Database bootstrap now runs as a one-shot `migrate` service before app startup. +- The next schema changes should be added as versioned Prisma migrations. diff --git a/infra/compose/docker-compose.example.yml b/infra/compose/docker-compose.example.yml new file mode 100644 index 0000000..2f7b387 --- /dev/null +++ b/infra/compose/docker-compose.example.yml @@ -0,0 +1,94 @@ +name: nproxy + +services: + migrate: + build: + context: ../.. + dockerfile: infra/docker/migrate.Dockerfile + env_file: + - ../../.env + depends_on: + postgres: + condition: service_healthy + restart: "no" + + web: + build: + context: ../.. + dockerfile: infra/docker/web.Dockerfile + env_file: + - ../../.env + depends_on: + migrate: + condition: service_completed_successfully + postgres: + condition: service_healthy + + worker: + build: + context: ../.. + dockerfile: infra/docker/worker.Dockerfile + env_file: + - ../../.env + depends_on: + migrate: + condition: service_completed_successfully + postgres: + condition: service_healthy + + bot: + build: + context: ../.. + dockerfile: infra/docker/bot.Dockerfile + env_file: + - ../../.env + depends_on: + migrate: + condition: service_completed_successfully + postgres: + condition: service_healthy + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: nproxy + POSTGRES_USER: nproxy + POSTGRES_PASSWORD: nproxy + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nproxy -d nproxy"] + interval: 10s + timeout: 5s + retries: 5 + volumes: + - postgres-data:/var/lib/postgresql/data + + caddy: + image: caddy:2 + depends_on: + - web + ports: + - "80:80" + - "443:443" + volumes: + - ../caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + + minio: + profiles: ["local-storage"] + image: minio/minio:latest + command: server /data --console-address :9001 + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio-data:/data + +volumes: + postgres-data: + caddy-data: + caddy-config: + minio-data: diff --git a/infra/docker/README.md b/infra/docker/README.md new file mode 100644 index 0000000..b9fbab1 --- /dev/null +++ b/infra/docker/README.md @@ -0,0 +1,13 @@ +# infra/docker + +Docker build definitions for the single-VPS Compose topology. + +## Implemented in this iteration +- Workspace-aware Node 22 images for `web`, `worker`, and `bot` +- Dedicated `migrate` image for schema bootstrap +- `corepack` + `pnpm` based install flow inside containers +- TypeScript build step for each runtime before container startup + +## Current limitations +- No production pruning yet +- No `cli` image yet diff --git a/infra/docker/bot.Dockerfile b/infra/docker/bot.Dockerfile new file mode 100644 index 0000000..1a518e1 --- /dev/null +++ b/infra/docker/bot.Dockerfile @@ -0,0 +1,32 @@ +FROM node:22-alpine + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH + +RUN corepack enable + +WORKDIR /app + +COPY package.json pnpm-workspace.yaml tsconfig.base.json ./ +COPY apps/bot/package.json apps/bot/package.json +COPY packages/config/package.json packages/config/package.json +COPY packages/db/package.json packages/db/package.json +COPY packages/domain/package.json packages/domain/package.json +COPY packages/providers/package.json packages/providers/package.json + +RUN pnpm install --no-frozen-lockfile + +COPY apps/bot apps/bot +COPY packages/config packages/config +COPY packages/db packages/db +COPY packages/domain packages/domain +COPY packages/providers packages/providers + +RUN pnpm --filter @nproxy/config build +RUN pnpm --filter @nproxy/domain build +RUN pnpm --filter @nproxy/providers build +RUN pnpm --filter @nproxy/db generate +RUN pnpm --filter @nproxy/db build +RUN pnpm --filter @nproxy/bot build + +CMD ["node", "apps/bot/dist/main.js"] diff --git a/infra/docker/cli.Dockerfile b/infra/docker/cli.Dockerfile new file mode 100644 index 0000000..105df7c --- /dev/null +++ b/infra/docker/cli.Dockerfile @@ -0,0 +1,32 @@ +FROM node:22-alpine + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH + +RUN corepack enable + +WORKDIR /app + +COPY package.json pnpm-workspace.yaml tsconfig.base.json ./ +COPY apps/cli/package.json apps/cli/package.json +COPY packages/config/package.json packages/config/package.json +COPY packages/db/package.json packages/db/package.json +COPY packages/domain/package.json packages/domain/package.json +COPY packages/providers/package.json packages/providers/package.json + +RUN pnpm install --no-frozen-lockfile + +COPY apps/cli apps/cli +COPY packages/config packages/config +COPY packages/db packages/db +COPY packages/domain packages/domain +COPY packages/providers packages/providers + +RUN pnpm --filter @nproxy/config build +RUN pnpm --filter @nproxy/domain build +RUN pnpm --filter @nproxy/providers build +RUN pnpm --filter @nproxy/db generate +RUN pnpm --filter @nproxy/db build +RUN pnpm --filter @nproxy/cli build + +CMD ["node", "apps/cli/dist/main.js"] diff --git a/infra/docker/migrate.Dockerfile b/infra/docker/migrate.Dockerfile new file mode 100644 index 0000000..119642c --- /dev/null +++ b/infra/docker/migrate.Dockerfile @@ -0,0 +1,26 @@ +FROM node:22-alpine + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH + +RUN corepack enable + +WORKDIR /app + +COPY package.json pnpm-workspace.yaml tsconfig.base.json ./ +COPY packages/db/package.json packages/db/package.json +COPY packages/domain/package.json packages/domain/package.json +COPY packages/providers/package.json packages/providers/package.json + +RUN pnpm install --no-frozen-lockfile + +COPY packages/db packages/db +COPY packages/domain packages/domain +COPY packages/providers packages/providers + +RUN pnpm --filter @nproxy/domain build +RUN pnpm --filter @nproxy/providers build +RUN pnpm --filter @nproxy/db generate +RUN pnpm --filter @nproxy/db build + +CMD ["sh", "-lc", "pnpm --filter @nproxy/db migrate:deploy && node packages/db/dist/bootstrap-main.js"] diff --git a/infra/docker/web.Dockerfile b/infra/docker/web.Dockerfile new file mode 100644 index 0000000..2476523 --- /dev/null +++ b/infra/docker/web.Dockerfile @@ -0,0 +1,34 @@ +FROM node:22-alpine + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH + +RUN corepack enable + +WORKDIR /app + +COPY package.json pnpm-workspace.yaml tsconfig.base.json ./ +COPY apps/web/package.json apps/web/package.json +COPY packages/config/package.json packages/config/package.json +COPY packages/db/package.json packages/db/package.json +COPY packages/domain/package.json packages/domain/package.json +COPY packages/providers/package.json packages/providers/package.json + +RUN pnpm install --no-frozen-lockfile + +COPY apps/web apps/web +COPY packages/config packages/config +COPY packages/db packages/db +COPY packages/domain packages/domain +COPY packages/providers packages/providers + +RUN pnpm --filter @nproxy/config build +RUN pnpm --filter @nproxy/domain build +RUN pnpm --filter @nproxy/providers build +RUN pnpm --filter @nproxy/db generate +RUN pnpm --filter @nproxy/db build +RUN pnpm --filter @nproxy/web build + +EXPOSE 3000 + +CMD ["node", "apps/web/dist/main.js"] diff --git a/infra/docker/worker.Dockerfile b/infra/docker/worker.Dockerfile new file mode 100644 index 0000000..2efdc5b --- /dev/null +++ b/infra/docker/worker.Dockerfile @@ -0,0 +1,32 @@ +FROM node:22-alpine + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH + +RUN corepack enable + +WORKDIR /app + +COPY package.json pnpm-workspace.yaml tsconfig.base.json ./ +COPY apps/worker/package.json apps/worker/package.json +COPY packages/config/package.json packages/config/package.json +COPY packages/db/package.json packages/db/package.json +COPY packages/domain/package.json packages/domain/package.json +COPY packages/providers/package.json packages/providers/package.json + +RUN pnpm install --no-frozen-lockfile + +COPY apps/worker apps/worker +COPY packages/config packages/config +COPY packages/db packages/db +COPY packages/domain packages/domain +COPY packages/providers packages/providers + +RUN pnpm --filter @nproxy/config build +RUN pnpm --filter @nproxy/domain build +RUN pnpm --filter @nproxy/providers build +RUN pnpm --filter @nproxy/db generate +RUN pnpm --filter @nproxy/db build +RUN pnpm --filter @nproxy/worker build + +CMD ["node", "apps/worker/dist/main.js"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..e873be4 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "nproxy", + "private": true, + "scripts": { + "build": "pnpm -r build", + "db:generate": "pnpm --filter @nproxy/db generate", + "db:migrate:deploy": "pnpm --filter @nproxy/db migrate:deploy", + "db:push": "pnpm --filter @nproxy/db db:push", + "typecheck": "tsc -p packages/domain/tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "typescript": "^5.7.3" + }, + "packageManager": "pnpm@10.0.0", + "workspaces": [ + "apps/*", + "packages/*" + ] +} diff --git a/packages/AGENTS.md b/packages/AGENTS.md new file mode 100644 index 0000000..9ff2fa1 --- /dev/null +++ b/packages/AGENTS.md @@ -0,0 +1,8 @@ +# AGENTS.md + +## Scope +Applies within `packages/` unless a deeper file overrides it. + +## Rules +- Shared packages are the source of business and integration logic. +- Keep boundaries clear: `domain` decides policy, `providers` talks to external systems, `db` owns schema. diff --git a/packages/config/README.md b/packages/config/README.md new file mode 100644 index 0000000..0c96490 --- /dev/null +++ b/packages/config/README.md @@ -0,0 +1,8 @@ +# packages/config + +Shared runtime configuration package for environment parsing and normalization. + +## Implemented in this iteration +- Typed environment loader +- Normalized app, database, provider, storage, Telegram, email, and key-pool settings +- Small helpers for required values, integers, booleans, and URL parsing diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..affdd20 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,21 @@ +{ + "name": "@nproxy/config", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "check": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/packages/config/src/.gitkeep b/packages/config/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 0000000..9ca7ed1 --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,160 @@ +export interface RuntimeUrls { + appBaseUrl: URL; + adminBaseUrl: URL; + nanoBananaApiBaseUrl: URL; + s3Endpoint: URL; +} + +export interface DatabaseConfig { + url: string; +} + +export interface AuthConfig { + sessionSecret: string; + passwordPepper: string; +} + +export interface ProviderConfig { + nanoBananaDefaultModel: string; +} + +export interface PaymentConfig { + provider: string; + apiKey: string; + webhookSecret: string; +} + +export interface StorageConfig { + region: string; + bucket: string; + accessKey: string; + secretKey: string; + forcePathStyle: boolean; +} + +export interface TelegramConfig { + botToken: string; + botMode: "polling"; +} + +export interface EmailConfig { + provider: string; + from: string; + apiKey: string; +} + +export interface KeyPoolConfig { + cooldownMinutes: number; + failuresBeforeManualReview: number; + balancePollSeconds: number; +} + +export interface AppRuntimeConfig { + nodeEnv: string; + urls: RuntimeUrls; + database: DatabaseConfig; + auth: AuthConfig; + provider: ProviderConfig; + payment: PaymentConfig; + storage: StorageConfig; + telegram: TelegramConfig; + email: EmailConfig; + keyPool: KeyPoolConfig; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppRuntimeConfig { + return { + nodeEnv: readString(env, "NODE_ENV"), + urls: { + appBaseUrl: readUrl(env, "APP_BASE_URL"), + adminBaseUrl: readUrl(env, "ADMIN_BASE_URL"), + nanoBananaApiBaseUrl: readUrl(env, "NANO_BANANA_API_BASE_URL"), + s3Endpoint: readUrl(env, "S3_ENDPOINT"), + }, + database: { + url: readString(env, "DATABASE_URL"), + }, + auth: { + sessionSecret: readString(env, "SESSION_SECRET"), + passwordPepper: readString(env, "PASSWORD_PEPPER"), + }, + provider: { + nanoBananaDefaultModel: readString(env, "NANO_BANANA_DEFAULT_MODEL"), + }, + payment: { + provider: readString(env, "PAYMENT_PROVIDER"), + apiKey: readString(env, "PAYMENT_PROVIDER_API_KEY"), + webhookSecret: readString(env, "PAYMENT_PROVIDER_WEBHOOK_SECRET"), + }, + storage: { + region: readString(env, "S3_REGION"), + bucket: readString(env, "S3_BUCKET"), + accessKey: readString(env, "S3_ACCESS_KEY"), + secretKey: readString(env, "S3_SECRET_KEY"), + forcePathStyle: readBoolean(env, "S3_FORCE_PATH_STYLE"), + }, + telegram: { + botToken: readString(env, "TELEGRAM_BOT_TOKEN"), + botMode: readTelegramMode(env, "TELEGRAM_BOT_MODE"), + }, + email: { + provider: readString(env, "EMAIL_PROVIDER"), + from: readString(env, "EMAIL_FROM"), + apiKey: readString(env, "EMAIL_API_KEY"), + }, + keyPool: { + cooldownMinutes: readInteger(env, "KEY_COOLDOWN_MINUTES"), + failuresBeforeManualReview: readInteger(env, "KEY_FAILURES_BEFORE_MANUAL_REVIEW"), + balancePollSeconds: readInteger(env, "KEY_BALANCE_POLL_SECONDS"), + }, + }; +} + +function readString(env: NodeJS.ProcessEnv, key: string): string { + const value = env[key]; + + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + + return value; +} + +function readInteger(env: NodeJS.ProcessEnv, key: string): number { + const value = readString(env, key); + const parsed = Number.parseInt(value, 10); + + if (!Number.isInteger(parsed)) { + throw new Error(`Environment variable ${key} must be an integer`); + } + + return parsed; +} + +function readBoolean(env: NodeJS.ProcessEnv, key: string): boolean { + const value = readString(env, key).toLowerCase(); + + if (value === "true") { + return true; + } + + if (value === "false") { + return false; + } + + throw new Error(`Environment variable ${key} must be "true" or "false"`); +} + +function readUrl(env: NodeJS.ProcessEnv, key: string): URL { + return new URL(readString(env, key)); +} + +function readTelegramMode(env: NodeJS.ProcessEnv, key: string): "polling" { + const value = readString(env, key); + + if (value !== "polling") { + throw new Error(`Environment variable ${key} must be "polling" for MVP`); + } + + return value; +} diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 0000000..87d12db --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/db/AGENTS.md b/packages/db/AGENTS.md new file mode 100644 index 0000000..5f6f4fb --- /dev/null +++ b/packages/db/AGENTS.md @@ -0,0 +1,15 @@ +# AGENTS.md + +## Scope +Applies within `packages/db`. + +## Responsibilities +- Prisma schema +- migrations +- database-level helpers +- shared transaction helpers + +## Rules +- Database schema is the canonical model for persisted state. +- Keep request-level and attempt-level generation data separate. +- Keep provider key status events auditable. diff --git a/packages/db/README.md b/packages/db/README.md new file mode 100644 index 0000000..8e5c4a7 --- /dev/null +++ b/packages/db/README.md @@ -0,0 +1,17 @@ +# packages/db + +Database package for `nproxy`. + +## Implemented in this iteration +- Prisma package scaffold +- Initial Prisma schema for MVP persisted state +- Shared schema path export for runtime tooling + +## Current scope +- Users and subscription state +- Manual crypto invoices +- Generation requests and provider-key attempts +- Usage ledger +- Provider keys, optional proxies, and auditable state events +- Telegram pairing and admin allowlist +- Admin audit log diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..3ee5f7c --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,34 @@ +{ + "name": "@nproxy/db", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "prisma" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "check": "prisma validate", + "db:push": "prisma db push", + "generate": "prisma generate", + "migrate:deploy": "prisma migrate deploy", + "format": "prisma format" + }, + "dependencies": { + "@nproxy/domain": "workspace:*", + "@nproxy/providers": "workspace:*", + "@prisma/client": "^6.5.0" + }, + "devDependencies": { + "prisma": "^6.5.0" + } +} diff --git a/packages/db/prisma/.gitkeep b/packages/db/prisma/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/db/prisma/migrations/20260309181500_init/migration.sql b/packages/db/prisma/migrations/20260309181500_init/migration.sql new file mode 100644 index 0000000..973267a --- /dev/null +++ b/packages/db/prisma/migrations/20260309181500_init/migration.sql @@ -0,0 +1,366 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateEnum +CREATE TYPE "SubscriptionStatus" AS ENUM ('pending_activation', 'active', 'past_due', 'canceled', 'expired'); + +-- CreateEnum +CREATE TYPE "PaymentInvoiceStatus" AS ENUM ('pending', 'paid', 'expired', 'canceled'); + +-- CreateEnum +CREATE TYPE "GenerationMode" AS ENUM ('text_to_image', 'image_to_image'); + +-- CreateEnum +CREATE TYPE "GenerationRequestStatus" AS ENUM ('queued', 'running', 'succeeded', 'failed', 'canceled'); + +-- CreateEnum +CREATE TYPE "GenerationAttemptStatus" AS ENUM ('started', 'succeeded', 'failed'); + +-- CreateEnum +CREATE TYPE "ProviderFailureCategory" AS ENUM ('transport', 'timeout', 'provider_5xx', 'provider_4xx_user', 'insufficient_funds', 'unknown'); + +-- CreateEnum +CREATE TYPE "ProviderKeyState" AS ENUM ('active', 'cooldown', 'out_of_funds', 'manual_review', 'disabled'); + +-- CreateEnum +CREATE TYPE "UsageLedgerEntryType" AS ENUM ('cycle_reset', 'generation_success', 'manual_adjustment', 'refund'); + +-- CreateEnum +CREATE TYPE "TelegramPairingStatus" AS ENUM ('pending', 'completed', 'expired', 'revoked'); + +-- CreateEnum +CREATE TYPE "AdminActorType" AS ENUM ('system', 'web_admin', 'telegram_admin', 'cli_operator'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "passwordResetVersion" INTEGER NOT NULL DEFAULT 0, + "isAdmin" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SubscriptionPlan" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "monthlyRequestLimit" INTEGER NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SubscriptionPlan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Subscription" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "planId" TEXT NOT NULL, + "status" "SubscriptionStatus" NOT NULL, + "renewsManually" BOOLEAN NOT NULL DEFAULT true, + "activatedAt" TIMESTAMP(3), + "currentPeriodStart" TIMESTAMP(3), + "currentPeriodEnd" TIMESTAMP(3), + "canceledAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PaymentInvoice" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "subscriptionId" TEXT, + "provider" TEXT NOT NULL, + "providerInvoiceId" TEXT, + "status" "PaymentInvoiceStatus" NOT NULL, + "currency" TEXT NOT NULL, + "amountCrypto" DECIMAL(20,8) NOT NULL, + "amountUsd" DECIMAL(12,2), + "paymentAddress" TEXT, + "expiresAt" TIMESTAMP(3), + "paidAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PaymentInvoice_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GenerationRequest" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "mode" "GenerationMode" NOT NULL, + "status" "GenerationRequestStatus" NOT NULL DEFAULT 'queued', + "providerModel" TEXT NOT NULL, + "prompt" TEXT NOT NULL, + "sourceImageKey" TEXT, + "resolutionPreset" TEXT NOT NULL, + "batchSize" INTEGER NOT NULL, + "imageStrength" DECIMAL(4,3), + "idempotencyKey" TEXT, + "terminalErrorCode" TEXT, + "terminalErrorText" TEXT, + "requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "startedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "GenerationRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GenerationAttempt" ( + "id" TEXT NOT NULL, + "generationRequestId" TEXT NOT NULL, + "providerKeyId" TEXT NOT NULL, + "attemptIndex" INTEGER NOT NULL, + "status" "GenerationAttemptStatus" NOT NULL DEFAULT 'started', + "usedProxy" BOOLEAN NOT NULL DEFAULT false, + "directFallbackUsed" BOOLEAN NOT NULL DEFAULT false, + "failureCategory" "ProviderFailureCategory", + "providerHttpStatus" INTEGER, + "providerErrorCode" TEXT, + "providerErrorText" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GenerationAttempt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GeneratedAsset" ( + "id" TEXT NOT NULL, + "generationRequestId" TEXT NOT NULL, + "objectKey" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "width" INTEGER, + "height" INTEGER, + "bytes" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GeneratedAsset_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UsageLedgerEntry" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "generationRequestId" TEXT, + "entryType" "UsageLedgerEntryType" NOT NULL, + "deltaRequests" INTEGER NOT NULL, + "cycleStartedAt" TIMESTAMP(3), + "cycleEndsAt" TIMESTAMP(3), + "note" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UsageLedgerEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProviderProxy" ( + "id" TEXT NOT NULL, + "label" TEXT NOT NULL, + "baseUrl" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProviderProxy_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProviderKey" ( + "id" TEXT NOT NULL, + "providerCode" TEXT NOT NULL, + "label" TEXT NOT NULL, + "apiKeyCiphertext" TEXT NOT NULL, + "apiKeyLastFour" TEXT NOT NULL, + "state" "ProviderKeyState" NOT NULL DEFAULT 'active', + "roundRobinOrder" INTEGER NOT NULL, + "consecutiveRetryableFailures" INTEGER NOT NULL DEFAULT 0, + "cooldownUntil" TIMESTAMP(3), + "lastErrorCategory" "ProviderFailureCategory", + "lastErrorCode" TEXT, + "lastErrorAt" TIMESTAMP(3), + "balanceMinorUnits" BIGINT, + "balanceCurrency" TEXT, + "balanceRefreshedAt" TIMESTAMP(3), + "proxyId" TEXT, + "disabledAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProviderKey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProviderKeyStatusEvent" ( + "id" TEXT NOT NULL, + "providerKeyId" TEXT NOT NULL, + "fromState" "ProviderKeyState", + "toState" "ProviderKeyState" NOT NULL, + "reason" TEXT NOT NULL, + "errorCategory" "ProviderFailureCategory", + "errorCode" TEXT, + "actorType" "AdminActorType" NOT NULL, + "actorRef" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ProviderKeyStatusEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TelegramPairing" ( + "id" TEXT NOT NULL, + "telegramUserId" TEXT NOT NULL, + "telegramUsername" TEXT, + "displayNameSnapshot" TEXT NOT NULL, + "codeHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "status" "TelegramPairingStatus" NOT NULL DEFAULT 'pending', + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TelegramPairing_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TelegramAdminAllowlistEntry" ( + "telegramUserId" TEXT NOT NULL, + "telegramUsername" TEXT, + "displayNameSnapshot" TEXT NOT NULL, + "pairedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedAt" TIMESTAMP(3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TelegramAdminAllowlistEntry_pkey" PRIMARY KEY ("telegramUserId") +); + +-- CreateTable +CREATE TABLE "AdminAuditLog" ( + "id" TEXT NOT NULL, + "actorType" "AdminActorType" NOT NULL, + "actorRef" TEXT, + "action" TEXT NOT NULL, + "targetType" TEXT NOT NULL, + "targetId" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminAuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "SubscriptionPlan_code_key" ON "SubscriptionPlan"("code"); + +-- CreateIndex +CREATE INDEX "Subscription_userId_status_idx" ON "Subscription"("userId", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "PaymentInvoice_providerInvoiceId_key" ON "PaymentInvoice"("providerInvoiceId"); + +-- CreateIndex +CREATE INDEX "PaymentInvoice_userId_status_idx" ON "PaymentInvoice"("userId", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "GenerationRequest_idempotencyKey_key" ON "GenerationRequest"("idempotencyKey"); + +-- CreateIndex +CREATE INDEX "GenerationRequest_userId_status_requestedAt_idx" ON "GenerationRequest"("userId", "status", "requestedAt"); + +-- CreateIndex +CREATE INDEX "GenerationAttempt_providerKeyId_startedAt_idx" ON "GenerationAttempt"("providerKeyId", "startedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "GenerationAttempt_generationRequestId_attemptIndex_key" ON "GenerationAttempt"("generationRequestId", "attemptIndex"); + +-- CreateIndex +CREATE UNIQUE INDEX "GeneratedAsset_objectKey_key" ON "GeneratedAsset"("objectKey"); + +-- CreateIndex +CREATE INDEX "GeneratedAsset_generationRequestId_idx" ON "GeneratedAsset"("generationRequestId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UsageLedgerEntry_generationRequestId_key" ON "UsageLedgerEntry"("generationRequestId"); + +-- CreateIndex +CREATE INDEX "UsageLedgerEntry_userId_createdAt_idx" ON "UsageLedgerEntry"("userId", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProviderProxy_label_key" ON "ProviderProxy"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProviderKey_label_key" ON "ProviderKey"("label"); + +-- CreateIndex +CREATE INDEX "ProviderKey_providerCode_state_roundRobinOrder_idx" ON "ProviderKey"("providerCode", "state", "roundRobinOrder"); + +-- CreateIndex +CREATE INDEX "ProviderKeyStatusEvent_providerKeyId_createdAt_idx" ON "ProviderKeyStatusEvent"("providerKeyId", "createdAt"); + +-- CreateIndex +CREATE INDEX "TelegramPairing_telegramUserId_status_idx" ON "TelegramPairing"("telegramUserId", "status"); + +-- CreateIndex +CREATE INDEX "TelegramPairing_expiresAt_status_idx" ON "TelegramPairing"("expiresAt", "status"); + +-- CreateIndex +CREATE INDEX "AdminAuditLog_targetType_targetId_createdAt_idx" ON "AdminAuditLog"("targetType", "targetId", "createdAt"); + +-- CreateIndex +CREATE INDEX "AdminAuditLog_actorType_createdAt_idx" ON "AdminAuditLog"("actorType", "createdAt"); + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_planId_fkey" FOREIGN KEY ("planId") REFERENCES "SubscriptionPlan"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PaymentInvoice" ADD CONSTRAINT "PaymentInvoice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PaymentInvoice" ADD CONSTRAINT "PaymentInvoice_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GenerationRequest" ADD CONSTRAINT "GenerationRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GenerationAttempt" ADD CONSTRAINT "GenerationAttempt_generationRequestId_fkey" FOREIGN KEY ("generationRequestId") REFERENCES "GenerationRequest"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GenerationAttempt" ADD CONSTRAINT "GenerationAttempt_providerKeyId_fkey" FOREIGN KEY ("providerKeyId") REFERENCES "ProviderKey"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GeneratedAsset" ADD CONSTRAINT "GeneratedAsset_generationRequestId_fkey" FOREIGN KEY ("generationRequestId") REFERENCES "GenerationRequest"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UsageLedgerEntry" ADD CONSTRAINT "UsageLedgerEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UsageLedgerEntry" ADD CONSTRAINT "UsageLedgerEntry_generationRequestId_fkey" FOREIGN KEY ("generationRequestId") REFERENCES "GenerationRequest"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProviderKey" ADD CONSTRAINT "ProviderKey_proxyId_fkey" FOREIGN KEY ("proxyId") REFERENCES "ProviderProxy"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProviderKeyStatusEvent" ADD CONSTRAINT "ProviderKeyStatusEvent_providerKeyId_fkey" FOREIGN KEY ("providerKeyId") REFERENCES "ProviderKey"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/packages/db/prisma/migrations/20260310122000_add_user_sessions/migration.sql b/packages/db/prisma/migrations/20260310122000_add_user_sessions/migration.sql new file mode 100644 index 0000000..a95f4ab --- /dev/null +++ b/packages/db/prisma/migrations/20260310122000_add_user_sessions/migration.sql @@ -0,0 +1,20 @@ +CREATE TABLE "UserSession" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "revokedAt" TIMESTAMP(3), + "lastSeenAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "UserSession_tokenHash_key" ON "UserSession"("tokenHash"); +CREATE INDEX "UserSession_userId_createdAt_idx" ON "UserSession"("userId", "createdAt"); +CREATE INDEX "UserSession_expiresAt_revokedAt_idx" ON "UserSession"("expiresAt", "revokedAt"); + +ALTER TABLE "UserSession" +ADD CONSTRAINT "UserSession_userId_fkey" +FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260310130000_add_password_reset_tokens/migration.sql b/packages/db/prisma/migrations/20260310130000_add_password_reset_tokens/migration.sql new file mode 100644 index 0000000..cf6a939 --- /dev/null +++ b/packages/db/prisma/migrations/20260310130000_add_password_reset_tokens/migration.sql @@ -0,0 +1,18 @@ +CREATE TABLE "PasswordResetToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "PasswordResetToken"("tokenHash"); +CREATE INDEX "PasswordResetToken_userId_createdAt_idx" ON "PasswordResetToken"("userId", "createdAt"); +CREATE INDEX "PasswordResetToken_expiresAt_consumedAt_idx" ON "PasswordResetToken"("expiresAt", "consumedAt"); + +ALTER TABLE "PasswordResetToken" +ADD CONSTRAINT "PasswordResetToken_userId_fkey" +FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260310134000_add_plan_pricing/migration.sql b/packages/db/prisma/migrations/20260310134000_add_plan_pricing/migration.sql new file mode 100644 index 0000000..967fe4b --- /dev/null +++ b/packages/db/prisma/migrations/20260310134000_add_plan_pricing/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "SubscriptionPlan" +ADD COLUMN "monthlyPriceUsd" DECIMAL(12,2) NOT NULL DEFAULT 9.99, +ADD COLUMN "billingCurrency" TEXT NOT NULL DEFAULT 'USDT'; diff --git a/packages/db/prisma/migrations/migration_lock.toml b/packages/db/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..41c0bc2 --- /dev/null +++ b/packages/db/prisma/migrations/migration_lock.toml @@ -0,0 +1,2 @@ +# Do not edit by hand unless you are intentionally resetting migration history. +provider = "postgresql" diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma new file mode 100644 index 0000000..3e9432e --- /dev/null +++ b/packages/db/prisma/schema.prisma @@ -0,0 +1,351 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum SubscriptionStatus { + pending_activation + active + past_due + canceled + expired +} + +enum PaymentInvoiceStatus { + pending + paid + expired + canceled +} + +enum GenerationMode { + text_to_image + image_to_image +} + +enum GenerationRequestStatus { + queued + running + succeeded + failed + canceled +} + +enum GenerationAttemptStatus { + started + succeeded + failed +} + +enum ProviderFailureCategory { + transport + timeout + provider_5xx + provider_4xx_user + insufficient_funds + unknown +} + +enum ProviderKeyState { + active + cooldown + out_of_funds + manual_review + disabled +} + +enum UsageLedgerEntryType { + cycle_reset + generation_success + manual_adjustment + refund +} + +enum TelegramPairingStatus { + pending + completed + expired + revoked +} + +enum AdminActorType { + system + web_admin + telegram_admin + cli_operator +} + +model User { + id String @id @default(cuid()) + email String @unique + passwordHash String + passwordResetVersion Int @default(0) + isAdmin Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + subscriptions Subscription[] + invoices PaymentInvoice[] + generationRequests GenerationRequest[] + usageLedgerEntries UsageLedgerEntry[] + sessions UserSession[] + passwordResetTokens PasswordResetToken[] +} + +model UserSession { + id String @id @default(cuid()) + userId String + tokenHash String @unique + expiresAt DateTime + revokedAt DateTime? + lastSeenAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@index([expiresAt, revokedAt]) +} + +model PasswordResetToken { + id String @id @default(cuid()) + userId String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@index([expiresAt, consumedAt]) +} + +model SubscriptionPlan { + id String @id @default(cuid()) + code String @unique + displayName String + monthlyRequestLimit Int + monthlyPriceUsd Decimal @db.Decimal(12, 2) + billingCurrency String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + subscriptions Subscription[] +} + +model Subscription { + id String @id @default(cuid()) + userId String + planId String + status SubscriptionStatus + renewsManually Boolean @default(true) + activatedAt DateTime? + currentPeriodStart DateTime? + currentPeriodEnd DateTime? + canceledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + plan SubscriptionPlan @relation(fields: [planId], references: [id], onDelete: Restrict) + invoices PaymentInvoice[] + + @@index([userId, status]) +} + +model PaymentInvoice { + id String @id @default(cuid()) + userId String + subscriptionId String? + provider String + providerInvoiceId String? @unique + status PaymentInvoiceStatus + currency String + amountCrypto Decimal @db.Decimal(20, 8) + amountUsd Decimal? @db.Decimal(12, 2) + paymentAddress String? + expiresAt DateTime? + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + subscription Subscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull) + + @@index([userId, status]) +} + +model GenerationRequest { + id String @id @default(cuid()) + userId String + mode GenerationMode + status GenerationRequestStatus @default(queued) + providerModel String + prompt String + sourceImageKey String? + resolutionPreset String + batchSize Int + imageStrength Decimal? @db.Decimal(4, 3) + idempotencyKey String? @unique + terminalErrorCode String? + terminalErrorText String? + requestedAt DateTime @default(now()) + startedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + attempts GenerationAttempt[] + assets GeneratedAsset[] + usageLedgerEntry UsageLedgerEntry? + + @@index([userId, status, requestedAt]) +} + +model GenerationAttempt { + id String @id @default(cuid()) + generationRequestId String + providerKeyId String + attemptIndex Int + status GenerationAttemptStatus @default(started) + usedProxy Boolean @default(false) + directFallbackUsed Boolean @default(false) + failureCategory ProviderFailureCategory? + providerHttpStatus Int? + providerErrorCode String? + providerErrorText String? + startedAt DateTime @default(now()) + completedAt DateTime? + createdAt DateTime @default(now()) + generationRequest GenerationRequest @relation(fields: [generationRequestId], references: [id], onDelete: Cascade) + providerKey ProviderKey @relation(fields: [providerKeyId], references: [id], onDelete: Restrict) + + @@unique([generationRequestId, attemptIndex]) + @@index([providerKeyId, startedAt]) +} + +model GeneratedAsset { + id String @id @default(cuid()) + generationRequestId String + objectKey String @unique + mimeType String + width Int? + height Int? + bytes Int? + createdAt DateTime @default(now()) + generationRequest GenerationRequest @relation(fields: [generationRequestId], references: [id], onDelete: Cascade) + + @@index([generationRequestId]) +} + +model UsageLedgerEntry { + id String @id @default(cuid()) + userId String + generationRequestId String? @unique + entryType UsageLedgerEntryType + deltaRequests Int + cycleStartedAt DateTime? + cycleEndsAt DateTime? + note String? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + generationRequest GenerationRequest? @relation(fields: [generationRequestId], references: [id], onDelete: SetNull) + + @@index([userId, createdAt]) +} + +model ProviderProxy { + id String @id @default(cuid()) + label String @unique + baseUrl String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + providerKeys ProviderKey[] +} + +model ProviderKey { + id String @id @default(cuid()) + providerCode String + label String @unique + apiKeyCiphertext String + apiKeyLastFour String + state ProviderKeyState @default(active) + roundRobinOrder Int + consecutiveRetryableFailures Int @default(0) + cooldownUntil DateTime? + lastErrorCategory ProviderFailureCategory? + lastErrorCode String? + lastErrorAt DateTime? + balanceMinorUnits BigInt? + balanceCurrency String? + balanceRefreshedAt DateTime? + proxyId String? + disabledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + proxy ProviderProxy? @relation(fields: [proxyId], references: [id], onDelete: SetNull) + attempts GenerationAttempt[] + statusEvents ProviderKeyStatusEvent[] + + @@index([providerCode, state, roundRobinOrder]) +} + +model ProviderKeyStatusEvent { + id String @id @default(cuid()) + providerKeyId String + fromState ProviderKeyState? + toState ProviderKeyState + reason String + errorCategory ProviderFailureCategory? + errorCode String? + actorType AdminActorType + actorRef String? + metadata Json? + createdAt DateTime @default(now()) + providerKey ProviderKey @relation(fields: [providerKeyId], references: [id], onDelete: Cascade) + + @@index([providerKeyId, createdAt]) +} + +model TelegramPairing { + id String @id @default(cuid()) + telegramUserId String + telegramUsername String? + displayNameSnapshot String + codeHash String + expiresAt DateTime + status TelegramPairingStatus @default(pending) + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([telegramUserId, status]) + @@index([expiresAt, status]) +} + +model TelegramAdminAllowlistEntry { + telegramUserId String @id + telegramUsername String? + displayNameSnapshot String + pairedAt DateTime @default(now()) + revokedAt DateTime? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model AdminAuditLog { + id String @id @default(cuid()) + actorType AdminActorType + actorRef String? + action String + targetType String + targetId String? + metadata Json? + createdAt DateTime @default(now()) + + @@index([targetType, targetId, createdAt]) + @@index([actorType, createdAt]) +} diff --git a/packages/db/src/account-store.ts b/packages/db/src/account-store.ts new file mode 100644 index 0000000..ce8d8b8 --- /dev/null +++ b/packages/db/src/account-store.ts @@ -0,0 +1,146 @@ +import { getApproximateQuotaBucket, type QuotaBucket } from "@nproxy/domain"; +import type { PrismaClient, SubscriptionStatus } from "@prisma/client"; +import { Prisma } from "@prisma/client"; +import { prisma as defaultPrisma } from "./prisma-client.js"; + +export interface UserAccountOverview { + user: { + id: string; + email: string; + isAdmin: boolean; + createdAt: Date; + }; + subscription: { + id: string; + status: SubscriptionStatus; + renewsManually: boolean; + activatedAt?: Date; + currentPeriodStart?: Date; + currentPeriodEnd?: Date; + canceledAt?: Date; + plan: { + id: string; + code: string; + displayName: string; + monthlyRequestLimit: number; + monthlyPriceUsd: number; + billingCurrency: string; + isActive: boolean; + }; + } | null; + quota: { + approximateBucket: QuotaBucket; + usedSuccessfulRequests: number; + monthlyRequestLimit: number; + } | null; +} + +export function createPrismaAccountStore(database: PrismaClient = defaultPrisma) { + return { + async getUserAccountOverview(userId: string): Promise { + const user = await database.user.findUnique({ + where: { + id: userId, + }, + }); + + if (!user) { + return null; + } + + const subscription = await database.subscription.findFirst({ + where: { + userId, + }, + include: { + plan: true, + }, + orderBy: [ + { currentPeriodEnd: "desc" }, + { createdAt: "desc" }, + ], + }); + + const quota = subscription + ? await buildQuotaSnapshot(database, userId, { + monthlyRequestLimit: subscription.plan.monthlyRequestLimit, + cycleStart: + subscription.currentPeriodStart ?? + subscription.activatedAt ?? + subscription.createdAt, + }) + : null; + + return { + user: { + id: user.id, + email: user.email, + isAdmin: user.isAdmin, + createdAt: user.createdAt, + }, + subscription: subscription + ? { + id: subscription.id, + status: subscription.status, + renewsManually: subscription.renewsManually, + ...(subscription.activatedAt ? { activatedAt: subscription.activatedAt } : {}), + ...(subscription.currentPeriodStart + ? { currentPeriodStart: subscription.currentPeriodStart } + : {}), + ...(subscription.currentPeriodEnd + ? { currentPeriodEnd: subscription.currentPeriodEnd } + : {}), + ...(subscription.canceledAt ? { canceledAt: subscription.canceledAt } : {}), + plan: { + id: subscription.plan.id, + code: subscription.plan.code, + displayName: subscription.plan.displayName, + monthlyRequestLimit: subscription.plan.monthlyRequestLimit, + monthlyPriceUsd: decimalToNumber(subscription.plan.monthlyPriceUsd), + billingCurrency: subscription.plan.billingCurrency, + isActive: subscription.plan.isActive, + }, + } + : null, + quota, + }; + }, + }; +} + +function decimalToNumber(value: Prisma.Decimal | { toNumber(): number }): number { + return value.toNumber(); +} + +async function buildQuotaSnapshot( + database: PrismaClient, + userId: string, + input: { + monthlyRequestLimit: number; + cycleStart: Date; + }, +): Promise { + const usageAggregation = await database.usageLedgerEntry.aggregate({ + where: { + userId, + entryType: "generation_success", + createdAt: { + gte: input.cycleStart, + }, + }, + _sum: { + deltaRequests: true, + }, + }); + + const usedSuccessfulRequests = usageAggregation._sum.deltaRequests ?? 0; + + return { + approximateBucket: getApproximateQuotaBucket({ + used: usedSuccessfulRequests, + limit: input.monthlyRequestLimit, + }), + usedSuccessfulRequests, + monthlyRequestLimit: input.monthlyRequestLimit, + }; +} diff --git a/packages/db/src/auth-store.ts b/packages/db/src/auth-store.ts new file mode 100644 index 0000000..237fa2c --- /dev/null +++ b/packages/db/src/auth-store.ts @@ -0,0 +1,399 @@ +import { + AuthError, + createPasswordResetToken, + createSessionToken, + hashPasswordResetToken, + hashPassword, + hashSessionToken, + normalizeEmail, + validateEmail, + validatePassword, + verifyPassword, +} from "@nproxy/domain"; +import type { PrismaClient } from "@prisma/client"; +import { prisma as defaultPrisma } from "./prisma-client.js"; + +export interface AuthenticatedUserRecord { + id: string; + email: string; + isAdmin: boolean; + createdAt: Date; +} + +export interface SessionRecord { + token: string; + user: AuthenticatedUserRecord; + expiresAt: Date; +} + +export interface UserSessionRecord { + id: string; + expiresAt: Date; + revokedAt?: Date; + lastSeenAt?: Date; + createdAt: Date; +} + +export interface AuthenticatedSessionRecord { + session: UserSessionRecord; + user: AuthenticatedUserRecord; +} + +export interface PasswordResetChallengeRecord { + email: string; + token: string; + expiresAt: Date; +} + +export function createPrismaAuthStore(database: PrismaClient = defaultPrisma) { + return { + async registerUser(input: { + email: string; + password: string; + passwordPepper: string; + sessionTtlDays?: number; + }): Promise { + const email = validateEmail(input.email); + const password = validatePassword(input.password); + const existing = await database.user.findUnique({ + where: { + email, + }, + }); + + if (existing) { + throw new AuthError("email_already_exists", `User ${email} already exists.`); + } + + const passwordHash = hashPassword(password, input.passwordPepper); + const token = createSessionToken(); + const tokenHash = hashSessionToken(token); + const expiresAt = addDays(new Date(), input.sessionTtlDays ?? 30); + + return database.$transaction(async (transaction) => { + const defaultPlan = await transaction.subscriptionPlan.findFirst({ + where: { + code: "mvp_monthly", + isActive: true, + }, + }); + + const user = await transaction.user.create({ + data: { + email, + passwordHash, + }, + }); + + await transaction.userSession.create({ + data: { + userId: user.id, + tokenHash, + expiresAt, + }, + }); + + if (defaultPlan) { + await transaction.subscription.create({ + data: { + userId: user.id, + planId: defaultPlan.id, + status: "pending_activation", + renewsManually: true, + }, + }); + } + + return { + token, + expiresAt, + user: mapAuthenticatedUser(user), + }; + }); + }, + + async loginUser(input: { + email: string; + password: string; + passwordPepper: string; + sessionTtlDays?: number; + }): Promise { + const email = normalizeEmail(input.email); + const user = await database.user.findUnique({ + where: { + email, + }, + }); + + if (!user || !verifyPassword(input.password, user.passwordHash, input.passwordPepper)) { + throw new AuthError("invalid_credentials", "Invalid email or password."); + } + + const token = createSessionToken(); + const tokenHash = hashSessionToken(token); + const expiresAt = addDays(new Date(), input.sessionTtlDays ?? 30); + + await database.userSession.create({ + data: { + userId: user.id, + tokenHash, + expiresAt, + }, + }); + + return { + token, + expiresAt, + user: mapAuthenticatedUser(user), + }; + }, + + async getUserBySessionToken( + sessionToken: string, + ): Promise { + const tokenHash = hashSessionToken(sessionToken); + const now = new Date(); + const session = await database.userSession.findUnique({ + where: { + tokenHash, + }, + include: { + user: true, + }, + }); + + if (!session || session.revokedAt || session.expiresAt <= now) { + return null; + } + + await database.userSession.update({ + where: { + id: session.id, + }, + data: { + lastSeenAt: now, + }, + }); + + return { + session: mapUserSession(session), + user: mapAuthenticatedUser(session.user), + }; + }, + + async revokeSession(sessionToken: string): Promise { + const tokenHash = hashSessionToken(sessionToken); + await database.userSession.updateMany({ + where: { + tokenHash, + revokedAt: null, + }, + data: { + revokedAt: new Date(), + }, + }); + }, + + async listUserSessions(userId: string): Promise { + const sessions = await database.userSession.findMany({ + where: { + userId, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return sessions.map(mapUserSession); + }, + + async revokeUserSession(input: { + userId: string; + sessionId: string; + }): Promise { + const result = await database.userSession.updateMany({ + where: { + id: input.sessionId, + userId: input.userId, + revokedAt: null, + }, + data: { + revokedAt: new Date(), + }, + }); + + return result.count > 0; + }, + + async revokeAllUserSessions(input: { + userId: string; + exceptSessionId?: string; + }): Promise { + const result = await database.userSession.updateMany({ + where: { + userId: input.userId, + revokedAt: null, + ...(input.exceptSessionId + ? { + id: { + not: input.exceptSessionId, + }, + } + : {}), + }, + data: { + revokedAt: new Date(), + }, + }); + + return result.count; + }, + + async createPasswordResetChallenge(input: { + email: string; + ttlMinutes?: number; + }): Promise { + const email = normalizeEmail(input.email); + const user = await database.user.findUnique({ + where: { + email, + }, + }); + + if (!user) { + return null; + } + + const token = createPasswordResetToken(); + const tokenHash = hashPasswordResetToken(token); + const expiresAt = addMinutes(new Date(), input.ttlMinutes ?? 30); + + await database.$transaction([ + database.passwordResetToken.updateMany({ + where: { + userId: user.id, + consumedAt: null, + }, + data: { + consumedAt: new Date(), + }, + }), + database.passwordResetToken.create({ + data: { + userId: user.id, + tokenHash, + expiresAt, + }, + }), + ]); + + return { + email: user.email, + token, + expiresAt, + }; + }, + + async resetPassword(input: { + token: string; + newPassword: string; + passwordPepper: string; + }): Promise { + const tokenHash = hashPasswordResetToken(input.token); + const newPassword = validatePassword(input.newPassword); + const passwordHash = hashPassword(newPassword, input.passwordPepper); + const now = new Date(); + + await database.$transaction(async (transaction) => { + const resetToken = await transaction.passwordResetToken.findUnique({ + where: { + tokenHash, + }, + include: { + user: true, + }, + }); + + if ( + !resetToken || + resetToken.consumedAt || + resetToken.expiresAt <= now + ) { + throw new AuthError( + "reset_token_invalid", + "Password reset token is invalid or expired.", + ); + } + + await transaction.user.update({ + where: { + id: resetToken.userId, + }, + data: { + passwordHash, + passwordResetVersion: { + increment: 1, + }, + }, + }); + + await transaction.passwordResetToken.update({ + where: { + id: resetToken.id, + }, + data: { + consumedAt: now, + }, + }); + + await transaction.userSession.updateMany({ + where: { + userId: resetToken.userId, + revokedAt: null, + }, + data: { + revokedAt: now, + }, + }); + }); + }, + }; +} + +function mapAuthenticatedUser(user: { + id: string; + email: string; + isAdmin: boolean; + createdAt: Date; +}): AuthenticatedUserRecord { + return { + id: user.id, + email: user.email, + isAdmin: user.isAdmin, + createdAt: user.createdAt, + }; +} + +function mapUserSession(session: { + id: string; + expiresAt: Date; + revokedAt: Date | null; + lastSeenAt: Date | null; + createdAt: Date; +}): UserSessionRecord { + return { + id: session.id, + expiresAt: session.expiresAt, + createdAt: session.createdAt, + ...(session.revokedAt ? { revokedAt: session.revokedAt } : {}), + ...(session.lastSeenAt ? { lastSeenAt: session.lastSeenAt } : {}), + }; +} + +function addDays(value: Date, days: number): Date { + return new Date(value.getTime() + days * 24 * 60 * 60 * 1000); +} + +function addMinutes(value: Date, minutes: number): Date { + return new Date(value.getTime() + minutes * 60 * 1000); +} diff --git a/packages/db/src/billing-store.ts b/packages/db/src/billing-store.ts new file mode 100644 index 0000000..bfda003 --- /dev/null +++ b/packages/db/src/billing-store.ts @@ -0,0 +1,254 @@ +import type { PaymentProviderAdapter } from "@nproxy/providers"; +import { Prisma, type PaymentInvoiceStatus, type PrismaClient, type SubscriptionStatus } from "@prisma/client"; +import { prisma as defaultPrisma } from "./prisma-client.js"; + +export interface BillingInvoiceRecord { + id: string; + subscriptionId?: string; + provider: string; + providerInvoiceId?: string; + status: PaymentInvoiceStatus; + currency: string; + amountCrypto: number; + amountUsd?: number; + paymentAddress?: string; + expiresAt?: Date; + paidAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface SubscriptionBillingRecord { + id: string; + status: SubscriptionStatus; + renewsManually: boolean; + activatedAt?: Date; + currentPeriodStart?: Date; + currentPeriodEnd?: Date; + canceledAt?: Date; + plan: { + id: string; + code: string; + displayName: string; + monthlyRequestLimit: number; + monthlyPriceUsd: number; + billingCurrency: string; + }; +} + +export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) { + return { + async listUserInvoices(userId: string): Promise { + const invoices = await database.paymentInvoice.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + }); + + return invoices.map(mapInvoice); + }, + + async getCurrentSubscription(userId: string): Promise { + const subscription = await database.subscription.findFirst({ + where: { userId }, + include: { plan: true }, + orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }], + }); + + return subscription ? mapSubscription(subscription) : null; + }, + + async createSubscriptionInvoice(input: { + userId: string; + paymentProvider: string; + paymentProviderAdapter: PaymentProviderAdapter; + }): Promise { + const subscription = await database.subscription.findFirst({ + where: { userId: input.userId }, + include: { plan: true }, + orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }], + }); + + if (!subscription) { + throw new Error("Subscription not found."); + } + + const existingPending = await database.paymentInvoice.findFirst({ + where: { + userId: input.userId, + subscriptionId: subscription.id, + status: "pending", + expiresAt: { + gt: new Date(), + }, + }, + orderBy: { createdAt: "desc" }, + }); + + if (existingPending) { + return mapInvoice(existingPending); + } + + const amountUsd = subscription.plan.monthlyPriceUsd.toNumber(); + const currency = subscription.plan.billingCurrency; + const amountCrypto = amountUsd; + const providerInvoice = await input.paymentProviderAdapter.createInvoice({ + userId: input.userId, + planCode: subscription.plan.code, + amountUsd, + amountCrypto, + currency, + }); + + const invoice = await database.paymentInvoice.create({ + data: { + userId: input.userId, + subscriptionId: subscription.id, + provider: input.paymentProvider, + providerInvoiceId: providerInvoice.providerInvoiceId, + status: "pending", + currency: providerInvoice.currency, + amountCrypto: new Prisma.Decimal(providerInvoice.amountCrypto), + amountUsd: new Prisma.Decimal(providerInvoice.amountUsd), + paymentAddress: providerInvoice.paymentAddress, + expiresAt: providerInvoice.expiresAt, + }, + }); + + return mapInvoice(invoice); + }, + + async markInvoicePaid(input: { + invoiceId: string; + }): Promise { + return database.$transaction(async (transaction) => { + const invoice = await transaction.paymentInvoice.findUnique({ + where: { id: input.invoiceId }, + include: { + subscription: { + include: { + plan: true, + }, + }, + }, + }); + + if (!invoice) { + throw new Error("Invoice not found."); + } + + const paidAt = invoice.paidAt ?? new Date(); + const updatedInvoice = + invoice.status === "paid" + ? invoice + : await transaction.paymentInvoice.update({ + where: { id: invoice.id }, + data: { + status: "paid", + paidAt, + }, + }); + + if (invoice.subscription) { + const periodStart = paidAt; + const periodEnd = addDays(periodStart, 30); + + await transaction.subscription.update({ + where: { id: invoice.subscription.id }, + data: { + status: "active", + activatedAt: invoice.subscription.activatedAt ?? paidAt, + currentPeriodStart: periodStart, + currentPeriodEnd: periodEnd, + canceledAt: null, + }, + }); + + await transaction.usageLedgerEntry.create({ + data: { + userId: invoice.userId, + entryType: "cycle_reset", + deltaRequests: 0, + cycleStartedAt: periodStart, + cycleEndsAt: periodEnd, + note: `Cycle activated from invoice ${invoice.id}.`, + }, + }); + } + + return mapInvoice(updatedInvoice); + }); + }, + }; +} + +function mapInvoice(invoice: { + id: string; + subscriptionId: string | null; + provider: string; + providerInvoiceId: string | null; + status: PaymentInvoiceStatus; + currency: string; + amountCrypto: Prisma.Decimal; + amountUsd: Prisma.Decimal | null; + paymentAddress: string | null; + expiresAt: Date | null; + paidAt: Date | null; + createdAt: Date; + updatedAt: Date; +}): BillingInvoiceRecord { + return { + id: invoice.id, + provider: invoice.provider, + status: invoice.status, + currency: invoice.currency, + amountCrypto: invoice.amountCrypto.toNumber(), + createdAt: invoice.createdAt, + updatedAt: invoice.updatedAt, + ...(invoice.subscriptionId ? { subscriptionId: invoice.subscriptionId } : {}), + ...(invoice.providerInvoiceId ? { providerInvoiceId: invoice.providerInvoiceId } : {}), + ...(invoice.amountUsd !== null ? { amountUsd: invoice.amountUsd.toNumber() } : {}), + ...(invoice.paymentAddress ? { paymentAddress: invoice.paymentAddress } : {}), + ...(invoice.expiresAt ? { expiresAt: invoice.expiresAt } : {}), + ...(invoice.paidAt ? { paidAt: invoice.paidAt } : {}), + }; +} + +function mapSubscription(subscription: { + id: string; + status: SubscriptionStatus; + renewsManually: boolean; + activatedAt: Date | null; + currentPeriodStart: Date | null; + currentPeriodEnd: Date | null; + canceledAt: Date | null; + plan: { + id: string; + code: string; + displayName: string; + monthlyRequestLimit: number; + monthlyPriceUsd: Prisma.Decimal; + billingCurrency: string; + }; +}): SubscriptionBillingRecord { + return { + id: subscription.id, + status: subscription.status, + renewsManually: subscription.renewsManually, + plan: { + id: subscription.plan.id, + code: subscription.plan.code, + displayName: subscription.plan.displayName, + monthlyRequestLimit: subscription.plan.monthlyRequestLimit, + monthlyPriceUsd: subscription.plan.monthlyPriceUsd.toNumber(), + billingCurrency: subscription.plan.billingCurrency, + }, + ...(subscription.activatedAt ? { activatedAt: subscription.activatedAt } : {}), + ...(subscription.currentPeriodStart ? { currentPeriodStart: subscription.currentPeriodStart } : {}), + ...(subscription.currentPeriodEnd ? { currentPeriodEnd: subscription.currentPeriodEnd } : {}), + ...(subscription.canceledAt ? { canceledAt: subscription.canceledAt } : {}), + }; +} + +function addDays(value: Date, days: number): Date { + return new Date(value.getTime() + days * 24 * 60 * 60 * 1000); +} diff --git a/packages/db/src/bootstrap-main.ts b/packages/db/src/bootstrap-main.ts new file mode 100644 index 0000000..4c6bb3d --- /dev/null +++ b/packages/db/src/bootstrap-main.ts @@ -0,0 +1,16 @@ +import { ensureDefaultSubscriptionPlan } from "./bootstrap.js"; +import { prisma } from "./prisma-client.js"; + +async function main(): Promise { + await ensureDefaultSubscriptionPlan(prisma); + console.log("default subscription plan ensured"); +} + +main() + .catch((error) => { + console.error("failed to ensure default subscription plan", error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/packages/db/src/bootstrap.ts b/packages/db/src/bootstrap.ts new file mode 100644 index 0000000..81d2082 --- /dev/null +++ b/packages/db/src/bootstrap.ts @@ -0,0 +1,50 @@ +import { Prisma, type PrismaClient } from "@prisma/client"; +import { prisma as defaultPrisma } from "./prisma-client.js"; + +export interface SubscriptionPlanSeedInput { + code: string; + displayName: string; + monthlyRequestLimit: number; + monthlyPriceUsd: number; + billingCurrency: string; +} + +export const defaultSubscriptionPlanSeed: SubscriptionPlanSeedInput = { + code: "mvp_monthly", + displayName: "MVP Monthly", + monthlyRequestLimit: 100, + monthlyPriceUsd: 9.99, + billingCurrency: "USDT", +}; + +export async function ensureSubscriptionPlan( + input: SubscriptionPlanSeedInput, + database: PrismaClient = defaultPrisma, +): Promise { + await database.subscriptionPlan.upsert({ + where: { + code: input.code, + }, + update: { + displayName: input.displayName, + monthlyRequestLimit: input.monthlyRequestLimit, + monthlyPriceUsd: new Prisma.Decimal(input.monthlyPriceUsd), + billingCurrency: input.billingCurrency, + isActive: true, + }, + create: { + code: input.code, + displayName: input.displayName, + monthlyRequestLimit: input.monthlyRequestLimit, + monthlyPriceUsd: new Prisma.Decimal(input.monthlyPriceUsd), + billingCurrency: input.billingCurrency, + isActive: true, + }, + }); +} + +export async function ensureDefaultSubscriptionPlan( + database: PrismaClient = defaultPrisma, +): Promise { + await ensureSubscriptionPlan(defaultSubscriptionPlanSeed, database); +} diff --git a/packages/db/src/generation-store.ts b/packages/db/src/generation-store.ts new file mode 100644 index 0000000..0fdfd11 --- /dev/null +++ b/packages/db/src/generation-store.ts @@ -0,0 +1,211 @@ +import { + type ActiveSubscriptionContext, + type CreateGenerationRequestInput, + type CreateGenerationRequestDeps, + type GenerationRequestRecord, + type MarkGenerationSucceededDeps, + type SuccessfulGenerationRecord, +} from "@nproxy/domain"; +import { Prisma, type PrismaClient } from "@prisma/client"; +import { prisma as defaultPrisma } from "./prisma-client.js"; + +export interface GenerationStore + extends CreateGenerationRequestDeps, + MarkGenerationSucceededDeps {} + +export function createPrismaGenerationStore( + database: PrismaClient = defaultPrisma, +): GenerationStore { + return { + async findReusableRequest(userId: string, idempotencyKey: string) { + const request = await database.generationRequest.findFirst({ + where: { + userId, + idempotencyKey, + }, + }); + + return request ? mapGenerationRequest(request) : null; + }, + + async findActiveSubscriptionContext( + userId: string, + ): Promise { + const subscription = await database.subscription.findFirst({ + where: { + userId, + status: "active", + }, + include: { + plan: true, + }, + orderBy: [ + { currentPeriodEnd: "desc" }, + { createdAt: "desc" }, + ], + }); + + if (!subscription) { + return null; + } + + const cycleStart = + subscription.currentPeriodStart ?? subscription.activatedAt ?? subscription.createdAt; + + const usageAggregation = await database.usageLedgerEntry.aggregate({ + where: { + userId, + entryType: "generation_success", + createdAt: { gte: cycleStart }, + }, + _sum: { + deltaRequests: true, + }, + }); + + return { + subscriptionId: subscription.id, + planId: subscription.planId, + monthlyRequestLimit: subscription.plan.monthlyRequestLimit, + usedSuccessfulRequests: usageAggregation._sum.deltaRequests ?? 0, + }; + }, + + async createGenerationRequest( + input: CreateGenerationRequestInput, + ): Promise { + const request = await database.generationRequest.create({ + data: { + userId: input.userId, + mode: input.mode, + providerModel: input.providerModel, + prompt: input.prompt.trim(), + resolutionPreset: input.resolutionPreset, + batchSize: input.batchSize, + ...(input.sourceImageKey !== undefined + ? { sourceImageKey: input.sourceImageKey } + : {}), + ...(input.imageStrength !== undefined + ? { imageStrength: new Prisma.Decimal(input.imageStrength) } + : {}), + ...(input.idempotencyKey !== undefined + ? { idempotencyKey: input.idempotencyKey } + : {}), + }, + }); + + return mapGenerationRequest(request); + }, + + async getGenerationRequest(requestId: string): Promise { + const request = await database.generationRequest.findUnique({ + where: { + id: requestId, + }, + }); + + return request ? mapGenerationRequest(request) : null; + }, + + async markGenerationSucceeded(requestId: string): Promise { + return database.$transaction(async (transaction) => { + const request = await transaction.generationRequest.findUnique({ + where: { + id: requestId, + }, + include: { + usageLedgerEntry: true, + }, + }); + + if (!request) { + throw new Error(`Generation request ${requestId} was not found.`); + } + + const completedAt = request.completedAt ?? new Date(); + const nextStatus = + request.status === "succeeded" ? request.status : "succeeded"; + + const updatedRequest = + request.status === "succeeded" && request.completedAt + ? request + : await transaction.generationRequest.update({ + where: { + id: requestId, + }, + data: { + status: nextStatus, + completedAt, + }, + }); + + if (!request.usageLedgerEntry) { + await transaction.usageLedgerEntry.create({ + data: { + userId: request.userId, + generationRequestId: request.id, + entryType: "generation_success", + deltaRequests: 1, + note: "Consumed after first successful generation result.", + }, + }); + } + + return { + request: mapGenerationRequest(updatedRequest), + quotaConsumed: !request.usageLedgerEntry, + }; + }); + }, + }; +} + +function mapGenerationRequest( + request: { + id: string; + userId: string; + mode: string; + status: string; + providerModel: string; + prompt: string; + sourceImageKey: string | null; + resolutionPreset: string; + batchSize: number; + imageStrength: Prisma.Decimal | null; + idempotencyKey: string | null; + terminalErrorCode: string | null; + terminalErrorText: string | null; + requestedAt: Date; + startedAt: Date | null; + completedAt: Date | null; + createdAt: Date; + updatedAt: Date; + }, +): GenerationRequestRecord { + return { + id: request.id, + userId: request.userId, + mode: request.mode as GenerationRequestRecord["mode"], + status: request.status as GenerationRequestRecord["status"], + providerModel: request.providerModel, + prompt: request.prompt, + resolutionPreset: request.resolutionPreset, + batchSize: request.batchSize, + requestedAt: request.requestedAt, + createdAt: request.createdAt, + updatedAt: request.updatedAt, + ...(request.sourceImageKey !== null ? { sourceImageKey: request.sourceImageKey } : {}), + ...(request.imageStrength !== null + ? { imageStrength: request.imageStrength.toNumber() } + : {}), + ...(request.idempotencyKey !== null ? { idempotencyKey: request.idempotencyKey } : {}), + ...(request.terminalErrorCode !== null + ? { terminalErrorCode: request.terminalErrorCode } + : {}), + ...(request.terminalErrorText !== null + ? { terminalErrorText: request.terminalErrorText } + : {}), + ...(request.startedAt !== null ? { startedAt: request.startedAt } : {}), + ...(request.completedAt !== null ? { completedAt: request.completedAt } : {}), + }; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..2502ca9 --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,10 @@ +export { prisma } from "./prisma-client.js"; +export { prismaSchemaPath } from "./schema-path.js"; +export * from "./account-store.js"; +export * from "./auth-store.js"; +export * from "./billing-store.js"; +export * from "./bootstrap.js"; +export * from "./generation-store.js"; +export * from "./telegram-bot-store.js"; +export * from "./telegram-pairing-store.js"; +export * from "./worker-store.js"; diff --git a/packages/db/src/prisma-client.ts b/packages/db/src/prisma-client.ts new file mode 100644 index 0000000..d3aa575 --- /dev/null +++ b/packages/db/src/prisma-client.ts @@ -0,0 +1,11 @@ +import { PrismaClient } from "@prisma/client"; + +const globalForPrisma = globalThis as { + prisma?: PrismaClient; +}; + +export const prisma = globalForPrisma.prisma ?? new PrismaClient(); + +if (process.env.NODE_ENV !== "production") { + globalForPrisma.prisma = prisma; +} diff --git a/packages/db/src/schema-path.ts b/packages/db/src/schema-path.ts new file mode 100644 index 0000000..946c608 --- /dev/null +++ b/packages/db/src/schema-path.ts @@ -0,0 +1 @@ +export const prismaSchemaPath = new URL("../prisma/schema.prisma", import.meta.url); diff --git a/packages/db/src/telegram-bot-store.ts b/packages/db/src/telegram-bot-store.ts new file mode 100644 index 0000000..5475036 --- /dev/null +++ b/packages/db/src/telegram-bot-store.ts @@ -0,0 +1,106 @@ +import { randomBytes } from "node:crypto"; +import { hashPairingCode, isPairingExpired } from "@nproxy/domain"; +import type { PrismaClient } from "@prisma/client"; +import { prisma as defaultPrisma } from "./prisma-client.js"; + +export interface TelegramUserSnapshot { + telegramUserId: string; + telegramUsername?: string; + displayNameSnapshot: string; +} + +export interface PendingPairingChallenge { + pairingId: string; + code: string; + expiresAt: Date; +} + +export function createPrismaTelegramBotStore(database: PrismaClient = defaultPrisma) { + return { + async isTelegramAdminAllowed(telegramUserId: string): Promise { + const entry = await database.telegramAdminAllowlistEntry.findUnique({ + where: { + telegramUserId, + }, + }); + + return Boolean(entry?.isActive); + }, + + async getOrCreatePendingPairingChallenge( + user: TelegramUserSnapshot, + expiresInMinutes: number, + ): Promise { + const now = new Date(); + const existing = await database.telegramPairing.findFirst({ + where: { + telegramUserId: user.telegramUserId, + status: "pending", + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (existing && !isPairingExpired(existing.expiresAt, now)) { + await database.telegramPairing.update({ + where: { + id: existing.id, + }, + data: { + status: "revoked", + }, + }); + } + + if (existing && isPairingExpired(existing.expiresAt, now)) { + await database.telegramPairing.update({ + where: { + id: existing.id, + }, + data: { + status: "expired", + }, + }); + } + + const code = generatePairingCode(); + const expiresAt = new Date(now.getTime() + expiresInMinutes * 60 * 1000); + const pairing = await database.telegramPairing.create({ + data: { + telegramUserId: user.telegramUserId, + ...(user.telegramUsername ? { telegramUsername: user.telegramUsername } : {}), + displayNameSnapshot: user.displayNameSnapshot, + codeHash: hashPairingCode(code), + expiresAt, + status: "pending", + }, + }); + + await database.adminAuditLog.create({ + data: { + actorType: "system", + action: "telegram_pair_pending_created", + targetType: "telegram_pairing", + targetId: pairing.id, + metadata: { + telegramUserId: user.telegramUserId, + telegramUsername: user.telegramUsername ?? null, + displayNameSnapshot: user.displayNameSnapshot, + expiresAt: expiresAt.toISOString(), + }, + }, + }); + + return { + pairingId: pairing.id, + code, + expiresAt, + }; + }, + }; +} + +function generatePairingCode(): string { + return randomBytes(4).toString("hex").toUpperCase(); +} diff --git a/packages/db/src/telegram-pairing-store.ts b/packages/db/src/telegram-pairing-store.ts new file mode 100644 index 0000000..b9b29c4 --- /dev/null +++ b/packages/db/src/telegram-pairing-store.ts @@ -0,0 +1,291 @@ +import { isPairingExpired } from "@nproxy/domain"; +import type { PrismaClient, TelegramPairingStatus } from "@prisma/client"; +import { prisma as defaultPrisma } from "./prisma-client.js"; + +export interface PendingTelegramPairingRecord { + id: string; + telegramUserId: string; + telegramUsername?: string; + displayNameSnapshot: string; + codeHash: string; + expiresAt: Date; + status: TelegramPairingStatus; + createdAt: Date; +} + +export interface ActiveTelegramAdminRecord { + telegramUserId: string; + telegramUsername?: string; + displayNameSnapshot: string; + pairedAt: Date; +} + +export function createPrismaTelegramPairingStore(database: PrismaClient = defaultPrisma) { + return { + async findPendingPairingByCodeHash( + codeHash: string, + ): Promise { + const record = await database.telegramPairing.findFirst({ + where: { + codeHash, + status: "pending", + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (!record) { + return null; + } + + return mapPendingPairingRecord(record); + }, + + async listTelegramPairings(): Promise<{ + pending: PendingTelegramPairingRecord[]; + activeAdmins: ActiveTelegramAdminRecord[]; + }> { + const [pending, activeAdmins] = await Promise.all([ + database.telegramPairing.findMany({ + where: { + status: "pending", + }, + orderBy: { + createdAt: "desc", + }, + }), + database.telegramAdminAllowlistEntry.findMany({ + where: { + isActive: true, + }, + orderBy: { + pairedAt: "desc", + }, + }), + ]); + + return { + pending: pending.map(mapPendingPairingRecord), + activeAdmins: activeAdmins.map((entry) => ({ + telegramUserId: entry.telegramUserId, + ...(entry.telegramUsername ? { telegramUsername: entry.telegramUsername } : {}), + displayNameSnapshot: entry.displayNameSnapshot, + pairedAt: entry.pairedAt, + })), + }; + }, + + async completePendingPairing(input: { + pairingId: string; + actorRef?: string; + }): Promise { + return database.$transaction(async (transaction) => { + const pairing = await transaction.telegramPairing.findUnique({ + where: { + id: input.pairingId, + }, + }); + + if (!pairing || pairing.status !== "pending") { + throw new Error("Pending pairing not found."); + } + + if (isPairingExpired(pairing.expiresAt)) { + await transaction.telegramPairing.update({ + where: { + id: pairing.id, + }, + data: { + status: "expired", + }, + }); + + throw new Error("Pairing code has expired."); + } + + const allowlistEntry = await transaction.telegramAdminAllowlistEntry.upsert({ + where: { + telegramUserId: pairing.telegramUserId, + }, + update: { + telegramUsername: pairing.telegramUsername, + displayNameSnapshot: pairing.displayNameSnapshot, + pairedAt: new Date(), + revokedAt: null, + isActive: true, + }, + create: { + telegramUserId: pairing.telegramUserId, + telegramUsername: pairing.telegramUsername, + displayNameSnapshot: pairing.displayNameSnapshot, + isActive: true, + }, + }); + + await transaction.telegramPairing.update({ + where: { + id: pairing.id, + }, + data: { + status: "completed", + completedAt: new Date(), + }, + }); + + await transaction.adminAuditLog.create({ + data: { + actorType: "cli_operator", + ...(input.actorRef ? { actorRef: input.actorRef } : {}), + action: "telegram_pair_complete", + targetType: "telegram_admin_allowlist_entry", + targetId: allowlistEntry.telegramUserId, + metadata: { + pairingId: pairing.id, + telegramUsername: pairing.telegramUsername, + displayNameSnapshot: pairing.displayNameSnapshot, + }, + }, + }); + + return { + telegramUserId: allowlistEntry.telegramUserId, + ...(allowlistEntry.telegramUsername + ? { telegramUsername: allowlistEntry.telegramUsername } + : {}), + displayNameSnapshot: allowlistEntry.displayNameSnapshot, + pairedAt: allowlistEntry.pairedAt, + }; + }); + }, + + async revokeTelegramAdmin(input: { + telegramUserId: string; + actorRef?: string; + }): Promise { + return database.$transaction(async (transaction) => { + const entry = await transaction.telegramAdminAllowlistEntry.findUnique({ + where: { + telegramUserId: input.telegramUserId, + }, + }); + + if (!entry || !entry.isActive) { + return null; + } + + const revokedAt = new Date(); + const updated = await transaction.telegramAdminAllowlistEntry.update({ + where: { + telegramUserId: input.telegramUserId, + }, + data: { + isActive: false, + revokedAt, + }, + }); + + await transaction.telegramPairing.updateMany({ + where: { + telegramUserId: input.telegramUserId, + status: "pending", + }, + data: { + status: "revoked", + }, + }); + + await transaction.adminAuditLog.create({ + data: { + actorType: "cli_operator", + ...(input.actorRef ? { actorRef: input.actorRef } : {}), + action: "telegram_pair_revoke", + targetType: "telegram_admin_allowlist_entry", + targetId: updated.telegramUserId, + metadata: { + telegramUsername: updated.telegramUsername, + displayNameSnapshot: updated.displayNameSnapshot, + }, + }, + }); + + return { + telegramUserId: updated.telegramUserId, + ...(updated.telegramUsername ? { telegramUsername: updated.telegramUsername } : {}), + displayNameSnapshot: updated.displayNameSnapshot, + pairedAt: updated.pairedAt, + }; + }); + }, + + async cleanupExpiredPendingPairings(input?: { + actorRef?: string; + now?: Date; + }): Promise { + const now = input?.now ?? new Date(); + const expired = await database.telegramPairing.findMany({ + where: { + status: "pending", + expiresAt: { + lte: now, + }, + }, + select: { + id: true, + }, + }); + + if (expired.length === 0) { + return 0; + } + + await database.$transaction([ + database.telegramPairing.updateMany({ + where: { + id: { + in: expired.map((item) => item.id), + }, + }, + data: { + status: "expired", + }, + }), + database.adminAuditLog.create({ + data: { + actorType: "cli_operator", + ...(input?.actorRef ? { actorRef: input.actorRef } : {}), + action: "telegram_pair_cleanup", + targetType: "telegram_pairing", + metadata: { + expiredCount: expired.length, + }, + }, + }), + ]); + + return expired.length; + }, + }; +} + +function mapPendingPairingRecord(record: { + id: string; + telegramUserId: string; + telegramUsername: string | null; + displayNameSnapshot: string; + codeHash: string; + expiresAt: Date; + status: TelegramPairingStatus; + createdAt: Date; +}): PendingTelegramPairingRecord { + return { + id: record.id, + telegramUserId: record.telegramUserId, + ...(record.telegramUsername ? { telegramUsername: record.telegramUsername } : {}), + displayNameSnapshot: record.displayNameSnapshot, + codeHash: record.codeHash, + expiresAt: record.expiresAt, + status: record.status, + createdAt: record.createdAt, + }; +} diff --git a/packages/db/src/worker-store.ts b/packages/db/src/worker-store.ts new file mode 100644 index 0000000..20aa75b --- /dev/null +++ b/packages/db/src/worker-store.ts @@ -0,0 +1,502 @@ +import { + buildAttemptPlan, + evaluateAttempt, + markGenerationRequestSucceeded, + type GenerationRequestRecord, + type ProviderFailureKind, + type ProviderKeySnapshot, +} from "@nproxy/domain"; +import type { AdminActorType, PrismaClient, ProviderKeyState } from "@prisma/client"; +import { prisma as defaultPrisma } from "./prisma-client.js"; +import type { GeneratedAssetPayload, ProviderExecutionResult } from "@nproxy/providers"; +import { createPrismaGenerationStore } from "./generation-store.js"; + +export interface WorkerGenerationRequest extends GenerationRequestRecord {} + +export interface WorkerProviderKey extends ProviderKeySnapshot { + providerCode: string; + label: string; + apiKeyLastFour: string; + roundRobinOrder: number; + proxyBaseUrl?: string; + proxyLabel?: string; +} + +export interface ClaimedGenerationJob { + request: WorkerGenerationRequest; + providerKeys: WorkerProviderKey[]; + lastUsedKeyId?: string; +} + +export interface ProcessGenerationJobResult { + requestId: string; + finalStatus: "succeeded" | "failed"; + attemptsCreated: number; + consumedQuota: boolean; +} + +export interface RecoverCooldownKeysResult { + recoveredCount: number; +} + +export type WorkerKeyExecutionResult = ProviderExecutionResult & { + usedProxy: boolean; + directFallbackUsed: boolean; +}; + +export interface WorkerExecutionPolicy { + cooldownMinutes: number; + failuresBeforeManualReview: number; +} + +const defaultWorkerExecutionPolicy: WorkerExecutionPolicy = { + cooldownMinutes: 5, + failuresBeforeManualReview: 10, +}; + +export function createPrismaWorkerStore( + database: PrismaClient = defaultPrisma, + policy: WorkerExecutionPolicy = defaultWorkerExecutionPolicy, +) { + const generationStore = createPrismaGenerationStore(database); + + return { + async recoverCooldownProviderKeys(now: Date = new Date()): Promise { + const eligibleKeys = await database.providerKey.findMany({ + where: { + state: "cooldown", + cooldownUntil: { + lte: now, + }, + }, + include: { + proxy: true, + }, + orderBy: { + cooldownUntil: "asc", + }, + }); + + for (const providerKey of eligibleKeys) { + await updateProviderKeyState(database, { + providerKey: mapWorkerProviderKey(providerKey), + toState: "active", + reason: "recovered", + nextConsecutiveRetryableFailures: providerKey.consecutiveRetryableFailures, + }); + } + + return { + recoveredCount: eligibleKeys.length, + }; + }, + + async claimNextQueuedGenerationJob(): Promise { + return database.$transaction(async (transaction) => { + const queuedRequest = await transaction.generationRequest.findFirst({ + where: { + status: "queued", + }, + orderBy: { + requestedAt: "asc", + }, + }); + + if (!queuedRequest) { + return null; + } + + const claimResult = await transaction.generationRequest.updateMany({ + where: { + id: queuedRequest.id, + status: "queued", + }, + data: { + status: "running", + startedAt: queuedRequest.startedAt ?? new Date(), + }, + }); + + if (claimResult.count === 0) { + return null; + } + + const request = await transaction.generationRequest.findUnique({ + where: { + id: queuedRequest.id, + }, + }); + + if (!request) { + return null; + } + + const providerKeys = await transaction.providerKey.findMany({ + where: { + providerCode: request.providerModel, + }, + include: { + proxy: true, + }, + orderBy: { + roundRobinOrder: "asc", + }, + }); + + const lastAttempt = await transaction.generationAttempt.findFirst({ + where: { + providerKey: { + providerCode: request.providerModel, + }, + }, + orderBy: { + startedAt: "desc", + }, + }); + + return { + request: mapGenerationRequest(request), + providerKeys: providerKeys.map(mapWorkerProviderKey), + ...(lastAttempt ? { lastUsedKeyId: lastAttempt.providerKeyId } : {}), + }; + }); + }, + + async processClaimedGenerationJob( + job: ClaimedGenerationJob, + executeWithKey: ( + request: WorkerGenerationRequest, + providerKey: WorkerProviderKey, + ) => Promise, + ): Promise { + const attemptPlan = buildAttemptPlan({ + keys: job.providerKeys, + ...(job.lastUsedKeyId ? { lastUsedKeyId: job.lastUsedKeyId } : {}), + }); + + if (attemptPlan.keyIdsInAttemptOrder.length === 0) { + await markRequestFailed( + database, + job.request.id, + "no_provider_keys", + "No active provider keys are available for the configured model.", + ); + + return { + requestId: job.request.id, + finalStatus: "failed", + attemptsCreated: 0, + consumedQuota: false, + }; + } + + let attemptsCreated = 0; + + for (const providerKeyId of attemptPlan.keyIdsInAttemptOrder) { + const providerKey = job.providerKeys.find((key) => key.id === providerKeyId); + + if (!providerKey) { + continue; + } + + attemptsCreated += 1; + + const executionResult = await executeWithKey(job.request, providerKey); + + const attempt = await database.generationAttempt.create({ + data: { + generationRequestId: job.request.id, + providerKeyId: providerKey.id, + attemptIndex: attemptsCreated, + status: executionResult.ok ? "succeeded" : "failed", + usedProxy: executionResult.usedProxy, + directFallbackUsed: executionResult.directFallbackUsed, + ...(executionResult.ok + ? {} + : { + failureCategory: mapFailureCategory(executionResult.failureKind), + providerHttpStatus: executionResult.providerHttpStatus ?? null, + providerErrorCode: executionResult.providerErrorCode ?? null, + providerErrorText: executionResult.providerErrorText ?? null, + }), + completedAt: new Date(), + }, + }); + + if (executionResult.ok) { + if (providerKey.state === "cooldown") { + await updateProviderKeyState(database, { + providerKey, + toState: "active", + reason: "recovered", + nextConsecutiveRetryableFailures: 0, + }); + } else if (providerKey.consecutiveRetryableFailures !== 0) { + await database.providerKey.update({ + where: { + id: providerKey.id, + }, + data: { + consecutiveRetryableFailures: 0, + lastErrorCategory: null, + lastErrorCode: null, + lastErrorAt: null, + cooldownUntil: null, + }, + }); + } + + await persistGeneratedAssets(database, job.request.id, executionResult.assets); + const successRecord = await markGenerationRequestSucceeded( + { + getGenerationRequest: generationStore.getGenerationRequest, + markGenerationSucceeded: generationStore.markGenerationSucceeded, + }, + attempt.generationRequestId, + ); + + return { + requestId: job.request.id, + finalStatus: "succeeded", + attemptsCreated, + consumedQuota: successRecord.quotaConsumed, + }; + } + + const evaluation = evaluateAttempt(providerKey, executionResult, { + failuresBeforeManualReview: policy.failuresBeforeManualReview, + }); + await updateProviderKeyState(database, { + providerKey, + toState: evaluation.transition.to, + reason: evaluation.transition.reason, + nextConsecutiveRetryableFailures: evaluation.nextConsecutiveRetryableFailures, + failureKind: executionResult.failureKind, + ...(executionResult.providerErrorCode !== undefined + ? { errorCode: executionResult.providerErrorCode } + : {}), + cooldownMinutes: policy.cooldownMinutes, + }); + + if (evaluation.retryDisposition === "stop_request") { + await markRequestFailed( + database, + job.request.id, + executionResult.providerErrorCode ?? "request_failed", + executionResult.providerErrorText ?? "Generation failed.", + ); + + return { + requestId: job.request.id, + finalStatus: "failed", + attemptsCreated, + consumedQuota: false, + }; + } + } + + await markRequestFailed( + database, + job.request.id, + "eligible_keys_exhausted", + "All eligible provider keys were exhausted by retryable failures.", + ); + + return { + requestId: job.request.id, + finalStatus: "failed", + attemptsCreated, + consumedQuota: false, + }; + }, + }; +} + +async function persistGeneratedAssets( + database: PrismaClient, + generationRequestId: string, + assets: GeneratedAssetPayload[], +): Promise { + for (const asset of assets) { + await database.generatedAsset.create({ + data: { + generationRequestId, + objectKey: asset.objectKey, + mimeType: asset.mimeType, + ...(asset.width !== undefined ? { width: asset.width } : {}), + ...(asset.height !== undefined ? { height: asset.height } : {}), + ...(asset.bytes !== undefined ? { bytes: asset.bytes } : {}), + }, + }); + } +} + +async function updateProviderKeyState( + database: PrismaClient, + input: { + providerKey: WorkerProviderKey; + toState: ProviderKeyState; + reason: string; + nextConsecutiveRetryableFailures: number; + cooldownMinutes?: number; + failureKind?: ProviderFailureKind; + errorCode?: string; + }, +): Promise { + const now = new Date(); + const fromState = input.providerKey.state; + const lastErrorCategory = input.failureKind + ? mapFailureCategory(input.failureKind) + : null; + + await database.providerKey.update({ + where: { + id: input.providerKey.id, + }, + data: { + state: input.toState, + consecutiveRetryableFailures: input.nextConsecutiveRetryableFailures, + cooldownUntil: + input.toState === "cooldown" + ? addMinutes(now, input.cooldownMinutes ?? 5) + : null, + lastErrorCategory, + lastErrorCode: input.errorCode ?? null, + lastErrorAt: input.failureKind ? now : null, + disabledAt: input.toState === "disabled" ? now : null, + }, + }); + + if (fromState !== input.toState || input.reason !== "none") { + await database.providerKeyStatusEvent.create({ + data: { + providerKeyId: input.providerKey.id, + fromState, + toState: input.toState, + reason: input.reason, + errorCategory: lastErrorCategory, + errorCode: input.errorCode ?? null, + actorType: "system" satisfies AdminActorType, + }, + }); + } +} + +async function markRequestFailed( + database: PrismaClient, + requestId: string, + terminalErrorCode: string, + terminalErrorText: string, +): Promise { + await database.generationRequest.update({ + where: { + id: requestId, + }, + data: { + status: "failed", + terminalErrorCode, + terminalErrorText, + completedAt: new Date(), + }, + }); +} + +function mapFailureCategory(failureKind: ProviderFailureKind) { + switch (failureKind) { + case "transport": + return "transport"; + case "timeout": + return "timeout"; + case "provider_5xx": + return "provider_5xx"; + case "provider_4xx_user": + return "provider_4xx_user"; + case "insufficient_funds": + return "insufficient_funds"; + case "unknown": + return "unknown"; + } +} + +function addMinutes(value: Date, minutes: number): Date { + return new Date(value.getTime() + minutes * 60 * 1000); +} + +function mapGenerationRequest(request: { + id: string; + userId: string; + mode: string; + status: string; + providerModel: string; + prompt: string; + sourceImageKey: string | null; + resolutionPreset: string; + batchSize: number; + imageStrength: { toNumber(): number } | null; + idempotencyKey: string | null; + terminalErrorCode: string | null; + terminalErrorText: string | null; + requestedAt: Date; + startedAt: Date | null; + completedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}): WorkerGenerationRequest { + return { + id: request.id, + userId: request.userId, + mode: request.mode as WorkerGenerationRequest["mode"], + status: request.status as WorkerGenerationRequest["status"], + providerModel: request.providerModel, + prompt: request.prompt, + resolutionPreset: request.resolutionPreset, + batchSize: request.batchSize, + requestedAt: request.requestedAt, + createdAt: request.createdAt, + updatedAt: request.updatedAt, + ...(request.sourceImageKey !== null ? { sourceImageKey: request.sourceImageKey } : {}), + ...(request.imageStrength !== null + ? { imageStrength: request.imageStrength.toNumber() } + : {}), + ...(request.idempotencyKey !== null ? { idempotencyKey: request.idempotencyKey } : {}), + ...(request.terminalErrorCode !== null + ? { terminalErrorCode: request.terminalErrorCode } + : {}), + ...(request.terminalErrorText !== null + ? { terminalErrorText: request.terminalErrorText } + : {}), + ...(request.startedAt !== null ? { startedAt: request.startedAt } : {}), + ...(request.completedAt !== null ? { completedAt: request.completedAt } : {}), + }; +} + +function mapWorkerProviderKey(providerKey: { + id: string; + providerCode: string; + label: string; + apiKeyLastFour: string; + state: string; + roundRobinOrder: number; + consecutiveRetryableFailures: number; + proxy: { + label: string; + baseUrl: string; + isActive: boolean; + } | null; +}): WorkerProviderKey { + return { + id: providerKey.id, + providerCode: providerKey.providerCode, + label: providerKey.label, + apiKeyLastFour: providerKey.apiKeyLastFour, + state: providerKey.state as WorkerProviderKey["state"], + roundRobinOrder: providerKey.roundRobinOrder, + consecutiveRetryableFailures: providerKey.consecutiveRetryableFailures, + ...(providerKey.proxy && providerKey.proxy.isActive + ? { + proxyBaseUrl: providerKey.proxy.baseUrl, + proxyLabel: providerKey.proxy.label, + } + : {}), + }; +} diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..87d12db --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/domain/AGENTS.md b/packages/domain/AGENTS.md new file mode 100644 index 0000000..a57f5b3 --- /dev/null +++ b/packages/domain/AGENTS.md @@ -0,0 +1,17 @@ +# AGENTS.md + +## Scope +Applies within `packages/domain`. + +## Responsibilities +- subscription lifecycle +- billing cycle rules +- quota ledger rules +- generation orchestration +- provider-key state transitions +- admin and pairing policies + +## Rules +- This package owns the business meaning of key states and retry decisions. +- Do not hide exact quota from admins, but never expose exact quota to normal users. +- A successful user request may have multiple provider attempts but may consume quota only once. diff --git a/packages/domain/README.md b/packages/domain/README.md new file mode 100644 index 0000000..bdb2e2a --- /dev/null +++ b/packages/domain/README.md @@ -0,0 +1,20 @@ +# packages/domain + +Business rules for `nproxy`. + +## Implemented in this iteration +- Approximate quota bucket contract: `100/80/60/40/20/0` +- Provider key pool round-robin active-key selection +- Provider attempt classification for retry vs terminal outcomes +- Key-state transition policy for cooldown/manual_review/out_of_funds + +## Current exports +- `getApproximateQuotaBucket` +- `isRetryableFailure` +- `evaluateAttempt` +- `selectActiveKeysRoundRobin` +- `buildAttemptPlan` + +## Notes +- Domain logic stays provider-agnostic. +- Transport code must live in `packages/providers`. diff --git a/packages/domain/package.json b/packages/domain/package.json new file mode 100644 index 0000000..cd8ff31 --- /dev/null +++ b/packages/domain/package.json @@ -0,0 +1,21 @@ +{ + "name": "@nproxy/domain", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "check": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/packages/domain/src/.gitkeep b/packages/domain/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/domain/src/auth.ts b/packages/domain/src/auth.ts new file mode 100644 index 0000000..c000eb5 --- /dev/null +++ b/packages/domain/src/auth.ts @@ -0,0 +1,97 @@ +import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto"; + +const PASSWORD_KEY_LENGTH = 64; +const PASSWORD_SALT_LENGTH = 16; + +export interface PasswordPolicyOptions { + minLength?: number; +} + +export class AuthError extends Error { + readonly code: + | "invalid_email" + | "invalid_password" + | "email_already_exists" + | "invalid_credentials" + | "session_not_found" + | "reset_token_invalid"; + + constructor(code: AuthError["code"], message: string) { + super(message); + this.code = code; + } +} + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export function validateEmail(email: string): string { + const normalized = normalizeEmail(email); + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) { + throw new AuthError("invalid_email", "Email must be a valid email address."); + } + + return normalized; +} + +export function validatePassword( + password: string, + options: PasswordPolicyOptions = {}, +): string { + const minLength = options.minLength ?? 8; + + if (password.length < minLength) { + throw new AuthError( + "invalid_password", + `Password must be at least ${minLength} characters long.`, + ); + } + + return password; +} + +export function hashPassword(password: string, pepper: string): string { + const salt = randomBytes(PASSWORD_SALT_LENGTH); + const derived = scryptSync(`${password}${pepper}`, salt, PASSWORD_KEY_LENGTH); + + return ["scrypt", salt.toString("hex"), derived.toString("hex")].join("$"); +} + +export function verifyPassword( + password: string, + passwordHash: string, + pepper: string, +): boolean { + const [algorithm, saltHex, hashHex] = passwordHash.split("$"); + + if (algorithm !== "scrypt" || !saltHex || !hashHex) { + return false; + } + + const expected = Buffer.from(hashHex, "hex"); + const actual = scryptSync( + `${password}${pepper}`, + Buffer.from(saltHex, "hex"), + expected.length, + ); + + return timingSafeEqual(expected, actual); +} + +export function createSessionToken(): string { + return randomBytes(32).toString("base64url"); +} + +export function hashSessionToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function createPasswordResetToken(): string { + return randomBytes(32).toString("base64url"); +} + +export function hashPasswordResetToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} diff --git a/packages/domain/src/generation.ts b/packages/domain/src/generation.ts new file mode 100644 index 0000000..9305cf8 --- /dev/null +++ b/packages/domain/src/generation.ts @@ -0,0 +1,221 @@ +import { getApproximateQuotaBucket, type QuotaBucket } from "./quota.js"; + +export type GenerationMode = "text_to_image" | "image_to_image"; + +export type GenerationRequestStatus = + | "queued" + | "running" + | "succeeded" + | "failed" + | "canceled"; + +export interface ActiveSubscriptionContext { + subscriptionId: string; + planId: string; + monthlyRequestLimit: number; + usedSuccessfulRequests: number; +} + +export interface GenerationRequestRecord { + id: string; + userId: string; + mode: GenerationMode; + status: GenerationRequestStatus; + providerModel: string; + prompt: string; + sourceImageKey?: string; + resolutionPreset: string; + batchSize: number; + imageStrength?: number; + idempotencyKey?: string; + terminalErrorCode?: string; + terminalErrorText?: string; + requestedAt: Date; + startedAt?: Date; + completedAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateGenerationRequestInput { + userId: string; + mode: GenerationMode; + providerModel: string; + prompt: string; + sourceImageKey?: string; + resolutionPreset: string; + batchSize: number; + imageStrength?: number; + idempotencyKey?: string; +} + +export interface CreateGenerationRequestResult { + request: GenerationRequestRecord; + reusedExistingRequest: boolean; + approximateQuotaBucket: QuotaBucket; +} + +export interface CreateGenerationRequestDeps { + findReusableRequest( + userId: string, + idempotencyKey: string, + ): Promise; + findActiveSubscriptionContext(userId: string): Promise; + createGenerationRequest( + input: CreateGenerationRequestInput, + ): Promise; +} + +export interface SuccessfulGenerationRecord { + request: GenerationRequestRecord; + quotaConsumed: boolean; +} + +export interface MarkGenerationSucceededDeps { + getGenerationRequest(requestId: string): Promise; + markGenerationSucceeded(requestId: string): Promise; +} + +export class GenerationRequestError extends Error { + readonly code: + | "missing_active_subscription" + | "quota_exhausted" + | "invalid_prompt" + | "invalid_batch_size" + | "missing_source_image" + | "unexpected_source_image" + | "missing_image_strength" + | "unexpected_image_strength" + | "request_not_found" + | "request_not_completable"; + + constructor(code: GenerationRequestError["code"], message: string) { + super(message); + this.code = code; + } +} + +export async function createGenerationRequest( + deps: CreateGenerationRequestDeps, + input: CreateGenerationRequestInput, +): Promise { + validateGenerationRequestInput(input); + + if (input.idempotencyKey) { + const existing = await deps.findReusableRequest(input.userId, input.idempotencyKey); + + if (existing) { + const subscription = await deps.findActiveSubscriptionContext(input.userId); + const approximateQuotaBucket = subscription + ? getApproximateQuotaBucket({ + used: subscription.usedSuccessfulRequests, + limit: subscription.monthlyRequestLimit, + }) + : 0; + + return { + request: existing, + reusedExistingRequest: true, + approximateQuotaBucket, + }; + } + } + + const subscription = await deps.findActiveSubscriptionContext(input.userId); + + if (!subscription) { + throw new GenerationRequestError( + "missing_active_subscription", + "An active subscription is required before creating generation requests.", + ); + } + + if (subscription.usedSuccessfulRequests >= subscription.monthlyRequestLimit) { + throw new GenerationRequestError( + "quota_exhausted", + "The current billing cycle has no remaining successful generation quota.", + ); + } + + const request = await deps.createGenerationRequest(input); + + return { + request, + reusedExistingRequest: false, + approximateQuotaBucket: getApproximateQuotaBucket({ + used: subscription.usedSuccessfulRequests, + limit: subscription.monthlyRequestLimit, + }), + }; +} + +export async function markGenerationRequestSucceeded( + deps: MarkGenerationSucceededDeps, + requestId: string, +): Promise { + const request = await deps.getGenerationRequest(requestId); + + if (!request) { + throw new GenerationRequestError( + "request_not_found", + `Generation request ${requestId} was not found.`, + ); + } + + if (request.status === "failed" || request.status === "canceled") { + throw new GenerationRequestError( + "request_not_completable", + `Generation request ${requestId} is terminal and cannot succeed.`, + ); + } + + return deps.markGenerationSucceeded(requestId); +} + +function validateGenerationRequestInput(input: CreateGenerationRequestInput): void { + if (input.prompt.trim().length === 0) { + throw new GenerationRequestError( + "invalid_prompt", + "Prompt must not be empty after trimming.", + ); + } + + if (!Number.isInteger(input.batchSize) || input.batchSize <= 0) { + throw new GenerationRequestError( + "invalid_batch_size", + "Batch size must be a positive integer.", + ); + } + + if (input.mode === "image_to_image") { + if (!input.sourceImageKey) { + throw new GenerationRequestError( + "missing_source_image", + "Image-to-image requests require a source image key.", + ); + } + + if (input.imageStrength === undefined) { + throw new GenerationRequestError( + "missing_image_strength", + "Image-to-image requests require image strength.", + ); + } + + return; + } + + if (input.sourceImageKey) { + throw new GenerationRequestError( + "unexpected_source_image", + "Text-to-image requests must not include a source image key.", + ); + } + + if (input.imageStrength !== undefined) { + throw new GenerationRequestError( + "unexpected_image_strength", + "Text-to-image requests must not include image strength.", + ); + } +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts new file mode 100644 index 0000000..92dfd17 --- /dev/null +++ b/packages/domain/src/index.ts @@ -0,0 +1,5 @@ +export * from "./quota.js"; +export * from "./provider-key-pool.js"; +export * from "./generation.js"; +export * from "./auth.js"; +export * from "./telegram-pairing.js"; diff --git a/packages/domain/src/provider-key-pool.ts b/packages/domain/src/provider-key-pool.ts new file mode 100644 index 0000000..2835882 --- /dev/null +++ b/packages/domain/src/provider-key-pool.ts @@ -0,0 +1,172 @@ +export type ProviderKeyState = + | "active" + | "cooldown" + | "out_of_funds" + | "manual_review" + | "disabled"; + +export interface ProviderKeySnapshot { + id: string; + state: ProviderKeyState; + consecutiveRetryableFailures: number; +} + +export type ProviderFailureKind = + | "transport" + | "timeout" + | "provider_5xx" + | "provider_4xx_user" + | "insufficient_funds" + | "unknown"; + +export interface ProviderAttemptResultSuccess { + ok: true; +} + +export interface ProviderAttemptResultFailure { + ok: false; + failureKind: ProviderFailureKind; +} + +export type ProviderAttemptResult = + | ProviderAttemptResultSuccess + | ProviderAttemptResultFailure; + +export type RetryDisposition = "retry_next_key" | "stop_request"; + +export interface KeyStateTransition { + from: ProviderKeyState; + to: ProviderKeyState; + reason: + | "retryable_failure" + | "insufficient_funds" + | "recovered" + | "manual_action" + | "none"; +} + +export interface EvaluateAttemptOutput { + retryDisposition: RetryDisposition; + transition: KeyStateTransition; + nextConsecutiveRetryableFailures: number; +} + +export interface EvaluateAttemptOptions { + failuresBeforeManualReview?: number; +} + +export function isRetryableFailure(kind: ProviderFailureKind): boolean { + return kind === "transport" || kind === "timeout" || kind === "provider_5xx"; +} + +export function evaluateAttempt( + key: ProviderKeySnapshot, + result: ProviderAttemptResult, + options: EvaluateAttemptOptions = {}, +): EvaluateAttemptOutput { + if (result.ok) { + return { + retryDisposition: "stop_request", + transition: { + from: key.state, + to: key.state === "cooldown" ? "active" : key.state, + reason: key.state === "cooldown" ? "recovered" : "none", + }, + nextConsecutiveRetryableFailures: 0, + }; + } + + if (result.failureKind === "insufficient_funds") { + return { + retryDisposition: "stop_request", + transition: { + from: key.state, + to: "out_of_funds", + reason: "insufficient_funds", + }, + nextConsecutiveRetryableFailures: key.consecutiveRetryableFailures, + }; + } + + if (isRetryableFailure(result.failureKind)) { + const nextFailures = key.consecutiveRetryableFailures + 1; + const failuresBeforeManualReview = options.failuresBeforeManualReview ?? 10; + const shouldEscalateToManualReview = + nextFailures > failuresBeforeManualReview; + + return { + retryDisposition: "retry_next_key", + transition: { + from: key.state, + to: shouldEscalateToManualReview ? "manual_review" : "cooldown", + reason: "retryable_failure", + }, + nextConsecutiveRetryableFailures: nextFailures, + }; + } + + return { + retryDisposition: "stop_request", + transition: { + from: key.state, + to: key.state, + reason: "none", + }, + nextConsecutiveRetryableFailures: key.consecutiveRetryableFailures, + }; +} + +export interface KeySelectionInput { + keys: ReadonlyArray; + lastUsedKeyId?: string; +} + +export interface KeySelectionOutput { + orderedActiveKeyIds: string[]; +} + +export function selectActiveKeysRoundRobin(input: KeySelectionInput): KeySelectionOutput { + const active = input.keys.filter((key) => key.state === "active"); + + if (active.length === 0) { + return { orderedActiveKeyIds: [] }; + } + + if (!input.lastUsedKeyId) { + return { + orderedActiveKeyIds: active.map((key) => key.id), + }; + } + + const lastIndex = active.findIndex((key) => key.id === input.lastUsedKeyId); + + if (lastIndex < 0) { + return { + orderedActiveKeyIds: active.map((key) => key.id), + }; + } + + const nextStart = (lastIndex + 1) % active.length; + const ordered = active.slice(nextStart).concat(active.slice(0, nextStart)); + + return { + orderedActiveKeyIds: ordered.map((key) => key.id), + }; +} + +export interface AttemptPlanInput { + keys: ReadonlyArray; + lastUsedKeyId?: string; +} + +export interface AttemptPlanOutput { + keyIdsInAttemptOrder: string[]; +} + +export function buildAttemptPlan(input: AttemptPlanInput): AttemptPlanOutput { + const selection = selectActiveKeysRoundRobin(input); + + return { + keyIdsInAttemptOrder: selection.orderedActiveKeyIds, + }; +} diff --git a/packages/domain/src/quota.ts b/packages/domain/src/quota.ts new file mode 100644 index 0000000..fce90e0 --- /dev/null +++ b/packages/domain/src/quota.ts @@ -0,0 +1,43 @@ +export type QuotaBucket = 100 | 80 | 60 | 40 | 20 | 0; + +export interface QuotaUsageInput { + used: number; + limit: number; +} + +export function getApproximateQuotaBucket(input: QuotaUsageInput): QuotaBucket { + const { used, limit } = input; + + if (limit <= 0) { + return 0; + } + + const safeUsed = clamp(used, 0, limit); + const remainingRatio = ((limit - safeUsed) / limit) * 100; + + if (remainingRatio >= 81) { + return 100; + } + + if (remainingRatio >= 61) { + return 80; + } + + if (remainingRatio >= 41) { + return 60; + } + + if (remainingRatio >= 21) { + return 40; + } + + if (remainingRatio > 0) { + return 20; + } + + return 0; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/packages/domain/src/telegram-pairing.ts b/packages/domain/src/telegram-pairing.ts new file mode 100644 index 0000000..99ec194 --- /dev/null +++ b/packages/domain/src/telegram-pairing.ts @@ -0,0 +1,13 @@ +import { createHash } from "node:crypto"; + +export function normalizePairingCode(code: string): string { + return code.trim().toUpperCase(); +} + +export function hashPairingCode(code: string): string { + return createHash("sha256").update(normalizePairingCode(code)).digest("hex"); +} + +export function isPairingExpired(expiresAt: Date, now: Date = new Date()): boolean { + return expiresAt.getTime() <= now.getTime(); +} diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json new file mode 100644 index 0000000..644c54a --- /dev/null +++ b/packages/domain/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/providers/AGENTS.md b/packages/providers/AGENTS.md new file mode 100644 index 0000000..1884a38 --- /dev/null +++ b/packages/providers/AGENTS.md @@ -0,0 +1,16 @@ +# AGENTS.md + +## Scope +Applies within `packages/providers`. + +## Responsibilities +- image provider adapters +- payment processor adapter +- storage adapter +- email adapter +- Telegram transport adapter + +## Rules +- Provider adapters classify errors but do not decide subscription or quota policy. +- Balance-fetch APIs for provider keys belong here. +- Distinguish proxy transport failures from provider API failures. diff --git a/packages/providers/README.md b/packages/providers/README.md new file mode 100644 index 0000000..b3ff521 --- /dev/null +++ b/packages/providers/README.md @@ -0,0 +1,10 @@ +# packages/providers + +Planned external adapter package. + +Expected ownership: +- `nano_banana` API adapter +- crypto payment processor adapter +- storage adapter +- email provider adapter +- Telegram transport adapter diff --git a/packages/providers/package.json b/packages/providers/package.json new file mode 100644 index 0000000..81e46dc --- /dev/null +++ b/packages/providers/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nproxy/providers", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@nproxy/domain": "workspace:*" + } +} diff --git a/packages/providers/src/.gitkeep b/packages/providers/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/providers/src/email.ts b/packages/providers/src/email.ts new file mode 100644 index 0000000..a5b12e2 --- /dev/null +++ b/packages/providers/src/email.ts @@ -0,0 +1,48 @@ +export interface SendEmailInput { + to: string; + subject: string; + text: string; +} + +export interface EmailTransport { + send(input: SendEmailInput): Promise; +} + +export function createEmailTransport(config: { + provider: string; + from: string; + apiKey: string; +}): EmailTransport { + if (config.provider === "example") { + return { + async send(input) { + console.log( + JSON.stringify({ + service: "email", + provider: config.provider, + from: config.from, + to: input.to, + subject: input.subject, + text: input.text, + }), + ); + }, + }; + } + + return { + async send(input) { + console.log( + JSON.stringify({ + service: "email", + provider: config.provider, + mode: "noop_fallback", + from: config.from, + to: input.to, + subject: input.subject, + text: input.text, + }), + ); + }, + }; +} diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts new file mode 100644 index 0000000..617ca0e --- /dev/null +++ b/packages/providers/src/index.ts @@ -0,0 +1,4 @@ +export * from "./email.js"; +export * from "./nano-banana.js"; +export * from "./payments.js"; +export * from "./telegram.js"; diff --git a/packages/providers/src/nano-banana.ts b/packages/providers/src/nano-banana.ts new file mode 100644 index 0000000..703157b --- /dev/null +++ b/packages/providers/src/nano-banana.ts @@ -0,0 +1,147 @@ +import type { + GenerationRequestRecord, + ProviderFailureKind, +} from "@nproxy/domain"; + +export interface ProviderExecutionKey { + id: string; + providerCode: string; + label: string; + apiKeyLastFour: string; +} + +export interface ProviderExecutionRoute { + kind: "proxy" | "direct"; + proxyBaseUrl?: string; +} + +export interface GeneratedAssetPayload { + objectKey: string; + mimeType: string; + width?: number; + height?: number; + bytes?: number; +} + +export interface SuccessfulGenerationExecution { + ok: true; + assets: GeneratedAssetPayload[]; +} + +export interface FailedGenerationExecution { + ok: false; + failureKind: ProviderFailureKind; + providerHttpStatus?: number; + providerErrorCode?: string; + providerErrorText?: string; +} + +export type ProviderExecutionResult = + | SuccessfulGenerationExecution + | FailedGenerationExecution; + +export interface NanoBananaAdapter { + executeGeneration(input: { + request: GenerationRequestRecord; + providerKey: ProviderExecutionKey; + route: ProviderExecutionRoute; + }): Promise; +} + +export function createNanoBananaSimulatedAdapter(): NanoBananaAdapter { + return { + async executeGeneration({ request, providerKey, route }) { + const lowerPrompt = request.prompt.toLowerCase(); + const simulatedFailure = matchSimulatedFailure(lowerPrompt, route.kind); + + if (simulatedFailure) { + return simulatedFailure; + } + + const assetCount = request.batchSize; + const safeKeySuffix = providerKey.apiKeyLastFour.replace(/[^a-z0-9]/gi, "").toLowerCase(); + const assets = Array.from({ length: assetCount }, (_, index) => ({ + objectKey: [ + "generated", + request.userId, + request.id, + `${index + 1}-${safeKeySuffix || "key"}.png`, + ].join("/"), + mimeType: "image/png", + width: 1024, + height: 1024, + bytes: 512_000, + })); + + return { + ok: true, + assets, + }; + }, + }; +} + +function matchSimulatedFailure( + prompt: string, + routeKind: ProviderExecutionRoute["kind"], +): FailedGenerationExecution | null { + if (routeKind === "proxy" && prompt.includes("[fail:proxy_transport]")) { + return buildFailure( + "transport", + 502, + "proxy_transport_error", + "Simulated proxy transport failure.", + ); + } + + if (prompt.includes("[fail:transport]")) { + return buildFailure("transport", 502, "transport_error", "Simulated transport failure."); + } + + if (prompt.includes("[fail:timeout]")) { + return buildFailure("timeout", 504, "timeout", "Simulated upstream timeout."); + } + + if (prompt.includes("[fail:provider_5xx]")) { + return buildFailure("provider_5xx", 503, "provider_5xx", "Simulated provider 5xx."); + } + + if (prompt.includes("[fail:provider_4xx_user]")) { + return buildFailure( + "provider_4xx_user", + 400, + "invalid_request", + "Simulated provider validation failure.", + ); + } + + if (prompt.includes("[fail:insufficient_funds]")) { + return buildFailure( + "insufficient_funds", + 402, + "insufficient_funds", + "Simulated provider balance exhaustion.", + ); + } + + if (prompt.includes("[fail:unknown]")) { + return buildFailure("unknown", 500, "unknown_error", "Simulated unknown provider failure."); + } + + return null; +} + +function buildFailure( + failureKind: ProviderFailureKind, + providerHttpStatus: number, + providerErrorCode: string, + providerErrorText: string, +): FailedGenerationExecution { + return { + ok: false, + failureKind, + providerHttpStatus, + providerErrorCode, + providerErrorText, + }; +} diff --git a/packages/providers/src/payments.ts b/packages/providers/src/payments.ts new file mode 100644 index 0000000..993395c --- /dev/null +++ b/packages/providers/src/payments.ts @@ -0,0 +1,55 @@ +import { randomUUID } from "node:crypto"; + +export interface PaymentInvoiceDraft { + userId: string; + planCode: string; + amountUsd: number; + amountCrypto: number; + currency: string; +} + +export interface CreatedProviderInvoice { + providerInvoiceId: string; + paymentAddress: string; + amountCrypto: number; + amountUsd: number; + currency: string; + expiresAt: Date; +} + +export interface PaymentProviderAdapter { + createInvoice(input: PaymentInvoiceDraft): Promise; +} + +export function createPaymentProviderAdapter(config: { + provider: string; + apiKey: string; +}): PaymentProviderAdapter { + if (config.provider === "example_processor") { + return { + async createInvoice(input) { + return { + providerInvoiceId: `inv_${randomUUID()}`, + paymentAddress: `example_${input.currency.toLowerCase()}_${randomUUID().slice(0, 16)}`, + amountCrypto: input.amountCrypto, + amountUsd: input.amountUsd, + currency: input.currency, + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + }; + }, + }; + } + + return { + async createInvoice(input) { + return { + providerInvoiceId: `noop_${randomUUID()}`, + paymentAddress: `noop_${input.currency.toLowerCase()}_${randomUUID().slice(0, 16)}`, + amountCrypto: input.amountCrypto, + amountUsd: input.amountUsd, + currency: input.currency, + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + }; + }, + }; +} diff --git a/packages/providers/src/telegram.ts b/packages/providers/src/telegram.ts new file mode 100644 index 0000000..6230ccb --- /dev/null +++ b/packages/providers/src/telegram.ts @@ -0,0 +1,88 @@ +export interface TelegramUpdateUser { + id: number; + username?: string; + first_name: string; + last_name?: string; +} + +export interface TelegramUpdateMessage { + message_id: number; + text?: string; + from?: TelegramUpdateUser; + chat: { + id: number; + }; +} + +export interface TelegramUpdate { + update_id: number; + message?: TelegramUpdateMessage; +} + +export interface TelegramBotTransport { + getUpdates(input: { + offset?: number; + timeoutSeconds: number; + }): Promise; + sendMessage(input: { + chatId: number; + text: string; + }): Promise; +} + +export function createTelegramBotApiTransport(botToken: string): TelegramBotTransport { + const baseUrl = `https://api.telegram.org/bot${botToken}`; + + return { + async getUpdates({ offset, timeoutSeconds }) { + const response = await fetch(`${baseUrl}/getUpdates`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + timeout: timeoutSeconds, + ...(offset !== undefined ? { offset } : {}), + }), + }); + + const payload = (await response.json()) as { + ok: boolean; + result?: TelegramUpdate[]; + description?: string; + }; + + if (!response.ok || !payload.ok || !payload.result) { + throw new Error( + payload.description ?? `Telegram getUpdates failed with status ${response.status}.`, + ); + } + + return payload.result; + }, + + async sendMessage({ chatId, text }) { + const response = await fetch(`${baseUrl}/sendMessage`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + chat_id: chatId, + text, + }), + }); + + const payload = (await response.json()) as { + ok: boolean; + description?: string; + }; + + if (!response.ok || !payload.ok) { + throw new Error( + payload.description ?? `Telegram sendMessage failed with status ${response.status}.`, + ); + } + }, + }; +} diff --git a/packages/providers/tsconfig.json b/packages/providers/tsconfig.json new file mode 100644 index 0000000..644c54a --- /dev/null +++ b/packages/providers/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..286cf7f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - apps/* + - packages/* diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..be1ab2c --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,3 @@ +# scripts + +Place small operational or developer helper scripts here. Avoid hiding business logic in shell scripts. diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..1b7357a --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "baseUrl": "." + } +}