Initial import

This commit is contained in:
sirily
2026-03-10 14:03:52 +03:00
commit 6c0ca4e28b
102 changed files with 6598 additions and 0 deletions

15
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

100
apps/bot/src/main.ts Normal file
View 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
View 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
View 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
View 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
View 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
View File

233
apps/cli/src/main.ts Normal file
View 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
View 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
View 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
View 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
View File

16
apps/web/package.json Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
# apps/worker
Planned worker runtime for queued and scheduled jobs.

16
apps/worker/package.json Normal file
View 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
View File

150
apps/worker/src/main.ts Normal file
View 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
View 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
View 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.

View 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.

View 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
View 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

View 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

View 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.

View 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
View 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
View 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
View 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.

View 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
View 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

View 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"]

View 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"]

View 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"]

View 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"]

View 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
View 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
View 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.

View 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

View 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"
}
}

View File

View 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;
}

View 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
View 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
View 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
View 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"
}
}

View File

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -0,0 +1,2 @@
# Do not edit by hand unless you are intentionally resetting migration history.
provider = "postgresql"

View 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])
}

View 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,
};
}

View 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);
}

View 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);
}

View 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();
});

View 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);
}

View 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
View 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";

View 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;
}

View File

@@ -0,0 +1 @@
export const prismaSchemaPath = new URL("../prisma/schema.prisma", import.meta.url);

View 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();
}

View 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,
};
}

View 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
View 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
View 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
View 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`.

View 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"
}
}

View File

View 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");
}

View 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.",
);
}
}

View 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";

View 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,
};
}

View 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));
}

View 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();
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}

View 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.

View 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

View 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:*"
}
}

View File

View 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,
}),
);
},
};
}

View File

@@ -0,0 +1,4 @@
export * from "./email.js";
export * from "./nano-banana.js";
export * from "./payments.js";
export * from "./telegram.js";

View 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,
};
}

View 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),
};
},
};
}

View 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}.`,
);
}
},
};
}

View 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
View File

@@ -0,0 +1,3 @@
packages:
- apps/*
- packages/*

Some files were not shown because too many files have changed in this diff Show More