Initial import
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.tmp
|
||||||
|
tmp
|
||||||
46
.env.example
Normal file
46
.env.example
Normal file
@@ -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
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -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
|
||||||
46
AGENTS.md
Normal file
46
AGENTS.md
Normal file
@@ -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`.
|
||||||
193
CODEX_STATUS.md
Normal file
193
CODEX_STATUS.md
Normal file
@@ -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 <code> [--yes]`
|
||||||
|
- `nproxy pair list`
|
||||||
|
- `nproxy pair revoke <telegram-user-id> [--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.
|
||||||
30
README.md
Normal file
30
README.md
Normal file
@@ -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`
|
||||||
9
apps/AGENTS.md
Normal file
9
apps/AGENTS.md
Normal file
@@ -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.
|
||||||
15
apps/bot/AGENTS.md
Normal file
15
apps/bot/AGENTS.md
Normal file
@@ -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.
|
||||||
3
apps/bot/README.md
Normal file
3
apps/bot/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# apps/bot
|
||||||
|
|
||||||
|
Planned Telegram admin bot runtime. MVP should use long polling unless deployment requirements change.
|
||||||
15
apps/bot/package.json
Normal file
15
apps/bot/package.json
Normal file
@@ -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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
apps/bot/src/.gitkeep
Normal file
0
apps/bot/src/.gitkeep
Normal file
100
apps/bot/src/main.ts
Normal file
100
apps/bot/src/main.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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"),
|
||||||
|
});
|
||||||
|
}
|
||||||
12
apps/bot/tsconfig.json
Normal file
12
apps/bot/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
14
apps/cli/AGENTS.md
Normal file
14
apps/cli/AGENTS.md
Normal file
@@ -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.
|
||||||
9
apps/cli/README.md
Normal file
9
apps/cli/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# apps/cli
|
||||||
|
|
||||||
|
Planned operator CLI.
|
||||||
|
|
||||||
|
Expected early commands:
|
||||||
|
- `nproxy pair <code>`
|
||||||
|
- `nproxy pair list`
|
||||||
|
- `nproxy pair revoke <telegram-user-id>`
|
||||||
|
- `nproxy pair cleanup`
|
||||||
18
apps/cli/package.json
Normal file
18
apps/cli/package.json
Normal file
@@ -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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
apps/cli/src/.gitkeep
Normal file
0
apps/cli/src/.gitkeep
Normal file
233
apps/cli/src/main.ts
Normal file
233
apps/cli/src/main.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (!telegramUserId) {
|
||||||
|
throw new Error("Missing telegram user id. Usage: nproxy pair revoke <telegram-user-id> [--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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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 <code> [--yes]");
|
||||||
|
console.log(" nproxy pair list");
|
||||||
|
console.log(" nproxy pair revoke <telegram-user-id> [--yes]");
|
||||||
|
console.log(" nproxy pair cleanup [--yes]");
|
||||||
|
}
|
||||||
12
apps/cli/tsconfig.json
Normal file
12
apps/cli/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
16
apps/web/AGENTS.md
Normal file
16
apps/web/AGENTS.md
Normal file
@@ -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.
|
||||||
10
apps/web/README.md
Normal file
10
apps/web/README.md
Normal file
@@ -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
|
||||||
0
apps/web/app/.gitkeep
Normal file
0
apps/web/app/.gitkeep
Normal file
16
apps/web/package.json
Normal file
16
apps/web/package.json
Normal file
@@ -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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
782
apps/web/src/main.ts
Normal file
782
apps/web/src/main.ts
Normal file
@@ -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<unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<IncomingMessage>,
|
||||||
|
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<IncomingMessage>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/web/tsconfig.json
Normal file
12
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
17
apps/worker/AGENTS.md
Normal file
17
apps/worker/AGENTS.md
Normal file
@@ -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.
|
||||||
3
apps/worker/README.md
Normal file
3
apps/worker/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# apps/worker
|
||||||
|
|
||||||
|
Planned worker runtime for queued and scheduled jobs.
|
||||||
16
apps/worker/package.json
Normal file
16
apps/worker/package.json
Normal file
@@ -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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
apps/worker/src/.gitkeep
Normal file
0
apps/worker/src/.gitkeep
Normal file
150
apps/worker/src/main.ts
Normal file
150
apps/worker/src/main.ts
Normal file
@@ -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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/worker/tsconfig.json
Normal file
12
apps/worker/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
13
docs/AGENTS.md
Normal file
13
docs/AGENTS.md
Normal file
@@ -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.
|
||||||
52
docs/architecture/repository-layout.md
Normal file
52
docs/architecture/repository-layout.md
Normal file
@@ -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.
|
||||||
34
docs/architecture/system-overview.md
Normal file
34
docs/architecture/system-overview.md
Normal file
@@ -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.
|
||||||
48
docs/ops/deployment.md
Normal file
48
docs/ops/deployment.md
Normal file
@@ -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
|
||||||
67
docs/ops/provider-key-pool.md
Normal file
67
docs/ops/provider-key-pool.md
Normal file
@@ -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
|
||||||
48
docs/ops/telegram-pairing.md
Normal file
48
docs/ops/telegram-pairing.md
Normal file
@@ -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 <code>` 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 <code>` 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 <code>`
|
||||||
|
- `nproxy pair list`
|
||||||
|
- `nproxy pair revoke <telegram-user-id>`
|
||||||
|
- `nproxy pair cleanup`
|
||||||
|
|
||||||
|
## Current CLI behavior
|
||||||
|
- `nproxy pair <code>` 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 <telegram-user-id>` 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.
|
||||||
103
docs/plan/mvp-system-plan.md
Normal file
103
docs/plan/mvp-system-plan.md
Normal file
@@ -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 <code>` 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
|
||||||
14
infra/AGENTS.md
Normal file
14
infra/AGENTS.md
Normal file
@@ -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.
|
||||||
8
infra/caddy/Caddyfile
Normal file
8
infra/caddy/Caddyfile
Normal file
@@ -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
|
||||||
|
}
|
||||||
17
infra/compose/README.md
Normal file
17
infra/compose/README.md
Normal file
@@ -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.
|
||||||
94
infra/compose/docker-compose.example.yml
Normal file
94
infra/compose/docker-compose.example.yml
Normal file
@@ -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:
|
||||||
13
infra/docker/README.md
Normal file
13
infra/docker/README.md
Normal file
@@ -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
|
||||||
32
infra/docker/bot.Dockerfile
Normal file
32
infra/docker/bot.Dockerfile
Normal file
@@ -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"]
|
||||||
32
infra/docker/cli.Dockerfile
Normal file
32
infra/docker/cli.Dockerfile
Normal file
@@ -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"]
|
||||||
26
infra/docker/migrate.Dockerfile
Normal file
26
infra/docker/migrate.Dockerfile
Normal file
@@ -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"]
|
||||||
34
infra/docker/web.Dockerfile
Normal file
34
infra/docker/web.Dockerfile
Normal file
@@ -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"]
|
||||||
32
infra/docker/worker.Dockerfile
Normal file
32
infra/docker/worker.Dockerfile
Normal file
@@ -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"]
|
||||||
20
package.json
Normal file
20
package.json
Normal file
@@ -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/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
8
packages/AGENTS.md
Normal file
8
packages/AGENTS.md
Normal file
@@ -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.
|
||||||
8
packages/config/README.md
Normal file
8
packages/config/README.md
Normal file
@@ -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
|
||||||
21
packages/config/package.json
Normal file
21
packages/config/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
packages/config/src/.gitkeep
Normal file
0
packages/config/src/.gitkeep
Normal file
160
packages/config/src/index.ts
Normal file
160
packages/config/src/index.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
12
packages/config/tsconfig.json
Normal file
12
packages/config/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
15
packages/db/AGENTS.md
Normal file
15
packages/db/AGENTS.md
Normal file
@@ -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.
|
||||||
17
packages/db/README.md
Normal file
17
packages/db/README.md
Normal file
@@ -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
|
||||||
34
packages/db/package.json
Normal file
34
packages/db/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
packages/db/prisma/.gitkeep
Normal file
0
packages/db/prisma/.gitkeep
Normal file
366
packages/db/prisma/migrations/20260309181500_init/migration.sql
Normal file
366
packages/db/prisma/migrations/20260309181500_init/migration.sql
Normal file
@@ -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;
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
2
packages/db/prisma/migrations/migration_lock.toml
Normal file
2
packages/db/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Do not edit by hand unless you are intentionally resetting migration history.
|
||||||
|
provider = "postgresql"
|
||||||
351
packages/db/prisma/schema.prisma
Normal file
351
packages/db/prisma/schema.prisma
Normal file
@@ -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])
|
||||||
|
}
|
||||||
146
packages/db/src/account-store.ts
Normal file
146
packages/db/src/account-store.ts
Normal file
@@ -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<UserAccountOverview | null> {
|
||||||
|
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<UserAccountOverview["quota"]> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
399
packages/db/src/auth-store.ts
Normal file
399
packages/db/src/auth-store.ts
Normal file
@@ -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<SessionRecord> {
|
||||||
|
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<SessionRecord> {
|
||||||
|
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<AuthenticatedSessionRecord | null> {
|
||||||
|
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<void> {
|
||||||
|
const tokenHash = hashSessionToken(sessionToken);
|
||||||
|
await database.userSession.updateMany({
|
||||||
|
where: {
|
||||||
|
tokenHash,
|
||||||
|
revokedAt: null,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
revokedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async listUserSessions(userId: string): Promise<UserSessionRecord[]> {
|
||||||
|
const sessions = await database.userSession.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return sessions.map(mapUserSession);
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokeUserSession(input: {
|
||||||
|
userId: string;
|
||||||
|
sessionId: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
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<number> {
|
||||||
|
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<PasswordResetChallengeRecord | null> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
254
packages/db/src/billing-store.ts
Normal file
254
packages/db/src/billing-store.ts
Normal file
@@ -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<BillingInvoiceRecord[]> {
|
||||||
|
const invoices = await database.paymentInvoice.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return invoices.map(mapInvoice);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCurrentSubscription(userId: string): Promise<SubscriptionBillingRecord | null> {
|
||||||
|
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<BillingInvoiceRecord> {
|
||||||
|
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<BillingInvoiceRecord> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
16
packages/db/src/bootstrap-main.ts
Normal file
16
packages/db/src/bootstrap-main.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { ensureDefaultSubscriptionPlan } from "./bootstrap.js";
|
||||||
|
import { prisma } from "./prisma-client.js";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
50
packages/db/src/bootstrap.ts
Normal file
50
packages/db/src/bootstrap.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
await ensureSubscriptionPlan(defaultSubscriptionPlanSeed, database);
|
||||||
|
}
|
||||||
211
packages/db/src/generation-store.ts
Normal file
211
packages/db/src/generation-store.ts
Normal file
@@ -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<ActiveSubscriptionContext | null> {
|
||||||
|
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<GenerationRequestRecord> {
|
||||||
|
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<GenerationRequestRecord | null> {
|
||||||
|
const request = await database.generationRequest.findUnique({
|
||||||
|
where: {
|
||||||
|
id: requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return request ? mapGenerationRequest(request) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async markGenerationSucceeded(requestId: string): Promise<SuccessfulGenerationRecord> {
|
||||||
|
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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
10
packages/db/src/index.ts
Normal file
10
packages/db/src/index.ts
Normal file
@@ -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";
|
||||||
11
packages/db/src/prisma-client.ts
Normal file
11
packages/db/src/prisma-client.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
1
packages/db/src/schema-path.ts
Normal file
1
packages/db/src/schema-path.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const prismaSchemaPath = new URL("../prisma/schema.prisma", import.meta.url);
|
||||||
106
packages/db/src/telegram-bot-store.ts
Normal file
106
packages/db/src/telegram-bot-store.ts
Normal file
@@ -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<boolean> {
|
||||||
|
const entry = await database.telegramAdminAllowlistEntry.findUnique({
|
||||||
|
where: {
|
||||||
|
telegramUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Boolean(entry?.isActive);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getOrCreatePendingPairingChallenge(
|
||||||
|
user: TelegramUserSnapshot,
|
||||||
|
expiresInMinutes: number,
|
||||||
|
): Promise<PendingPairingChallenge> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
291
packages/db/src/telegram-pairing-store.ts
Normal file
291
packages/db/src/telegram-pairing-store.ts
Normal file
@@ -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<PendingTelegramPairingRecord | null> {
|
||||||
|
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<ActiveTelegramAdminRecord> {
|
||||||
|
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<ActiveTelegramAdminRecord | null> {
|
||||||
|
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<number> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
502
packages/db/src/worker-store.ts
Normal file
502
packages/db/src/worker-store.ts
Normal file
@@ -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<RecoverCooldownKeysResult> {
|
||||||
|
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<ClaimedGenerationJob | null> {
|
||||||
|
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<WorkerKeyExecutionResult>,
|
||||||
|
): Promise<ProcessGenerationJobResult> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
packages/db/tsconfig.json
Normal file
12
packages/db/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
17
packages/domain/AGENTS.md
Normal file
17
packages/domain/AGENTS.md
Normal file
@@ -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.
|
||||||
20
packages/domain/README.md
Normal file
20
packages/domain/README.md
Normal file
@@ -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`.
|
||||||
21
packages/domain/package.json
Normal file
21
packages/domain/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
packages/domain/src/.gitkeep
Normal file
0
packages/domain/src/.gitkeep
Normal file
97
packages/domain/src/auth.ts
Normal file
97
packages/domain/src/auth.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
221
packages/domain/src/generation.ts
Normal file
221
packages/domain/src/generation.ts
Normal file
@@ -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<GenerationRequestRecord | null>;
|
||||||
|
findActiveSubscriptionContext(userId: string): Promise<ActiveSubscriptionContext | null>;
|
||||||
|
createGenerationRequest(
|
||||||
|
input: CreateGenerationRequestInput,
|
||||||
|
): Promise<GenerationRequestRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuccessfulGenerationRecord {
|
||||||
|
request: GenerationRequestRecord;
|
||||||
|
quotaConsumed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkGenerationSucceededDeps {
|
||||||
|
getGenerationRequest(requestId: string): Promise<GenerationRequestRecord | null>;
|
||||||
|
markGenerationSucceeded(requestId: string): Promise<SuccessfulGenerationRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CreateGenerationRequestResult> {
|
||||||
|
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<SuccessfulGenerationRecord> {
|
||||||
|
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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/domain/src/index.ts
Normal file
5
packages/domain/src/index.ts
Normal file
@@ -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";
|
||||||
172
packages/domain/src/provider-key-pool.ts
Normal file
172
packages/domain/src/provider-key-pool.ts
Normal file
@@ -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<ProviderKeySnapshot>;
|
||||||
|
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<ProviderKeySnapshot>;
|
||||||
|
lastUsedKeyId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttemptPlanOutput {
|
||||||
|
keyIdsInAttemptOrder: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAttemptPlan(input: AttemptPlanInput): AttemptPlanOutput {
|
||||||
|
const selection = selectActiveKeysRoundRobin(input);
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyIdsInAttemptOrder: selection.orderedActiveKeyIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
43
packages/domain/src/quota.ts
Normal file
43
packages/domain/src/quota.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
13
packages/domain/src/telegram-pairing.ts
Normal file
13
packages/domain/src/telegram-pairing.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
11
packages/domain/tsconfig.json
Normal file
11
packages/domain/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
16
packages/providers/AGENTS.md
Normal file
16
packages/providers/AGENTS.md
Normal file
@@ -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.
|
||||||
10
packages/providers/README.md
Normal file
10
packages/providers/README.md
Normal file
@@ -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
|
||||||
24
packages/providers/package.json
Normal file
24
packages/providers/package.json
Normal file
@@ -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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
packages/providers/src/.gitkeep
Normal file
0
packages/providers/src/.gitkeep
Normal file
48
packages/providers/src/email.ts
Normal file
48
packages/providers/src/email.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export interface SendEmailInput {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailTransport {
|
||||||
|
send(input: SendEmailInput): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
4
packages/providers/src/index.ts
Normal file
4
packages/providers/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./email.js";
|
||||||
|
export * from "./nano-banana.js";
|
||||||
|
export * from "./payments.js";
|
||||||
|
export * from "./telegram.js";
|
||||||
147
packages/providers/src/nano-banana.ts
Normal file
147
packages/providers/src/nano-banana.ts
Normal file
@@ -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<ProviderExecutionResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
55
packages/providers/src/payments.ts
Normal file
55
packages/providers/src/payments.ts
Normal file
@@ -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<CreatedProviderInvoice>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
88
packages/providers/src/telegram.ts
Normal file
88
packages/providers/src/telegram.ts
Normal file
@@ -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<TelegramUpdate[]>;
|
||||||
|
sendMessage(input: {
|
||||||
|
chatId: number;
|
||||||
|
text: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
11
packages/providers/tsconfig.json
Normal file
11
packages/providers/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- apps/*
|
||||||
|
- packages/*
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user