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

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