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