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

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