Initial import
This commit is contained in:
17
packages/domain/AGENTS.md
Normal file
17
packages/domain/AGENTS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
Applies within `packages/domain`.
|
||||
|
||||
## Responsibilities
|
||||
- subscription lifecycle
|
||||
- billing cycle rules
|
||||
- quota ledger rules
|
||||
- generation orchestration
|
||||
- provider-key state transitions
|
||||
- admin and pairing policies
|
||||
|
||||
## Rules
|
||||
- This package owns the business meaning of key states and retry decisions.
|
||||
- Do not hide exact quota from admins, but never expose exact quota to normal users.
|
||||
- A successful user request may have multiple provider attempts but may consume quota only once.
|
||||
20
packages/domain/README.md
Normal file
20
packages/domain/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# packages/domain
|
||||
|
||||
Business rules for `nproxy`.
|
||||
|
||||
## Implemented in this iteration
|
||||
- Approximate quota bucket contract: `100/80/60/40/20/0`
|
||||
- Provider key pool round-robin active-key selection
|
||||
- Provider attempt classification for retry vs terminal outcomes
|
||||
- Key-state transition policy for cooldown/manual_review/out_of_funds
|
||||
|
||||
## Current exports
|
||||
- `getApproximateQuotaBucket`
|
||||
- `isRetryableFailure`
|
||||
- `evaluateAttempt`
|
||||
- `selectActiveKeysRoundRobin`
|
||||
- `buildAttemptPlan`
|
||||
|
||||
## Notes
|
||||
- Domain logic stays provider-agnostic.
|
||||
- Transport code must live in `packages/providers`.
|
||||
21
packages/domain/package.json
Normal file
21
packages/domain/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@nproxy/domain",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "tsc -p tsconfig.json --noEmit"
|
||||
}
|
||||
}
|
||||
0
packages/domain/src/.gitkeep
Normal file
0
packages/domain/src/.gitkeep
Normal file
97
packages/domain/src/auth.ts
Normal file
97
packages/domain/src/auth.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto";
|
||||
|
||||
const PASSWORD_KEY_LENGTH = 64;
|
||||
const PASSWORD_SALT_LENGTH = 16;
|
||||
|
||||
export interface PasswordPolicyOptions {
|
||||
minLength?: number;
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
readonly code:
|
||||
| "invalid_email"
|
||||
| "invalid_password"
|
||||
| "email_already_exists"
|
||||
| "invalid_credentials"
|
||||
| "session_not_found"
|
||||
| "reset_token_invalid";
|
||||
|
||||
constructor(code: AuthError["code"], message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function validateEmail(email: string): string {
|
||||
const normalized = normalizeEmail(email);
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
|
||||
throw new AuthError("invalid_email", "Email must be a valid email address.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function validatePassword(
|
||||
password: string,
|
||||
options: PasswordPolicyOptions = {},
|
||||
): string {
|
||||
const minLength = options.minLength ?? 8;
|
||||
|
||||
if (password.length < minLength) {
|
||||
throw new AuthError(
|
||||
"invalid_password",
|
||||
`Password must be at least ${minLength} characters long.`,
|
||||
);
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
export function hashPassword(password: string, pepper: string): string {
|
||||
const salt = randomBytes(PASSWORD_SALT_LENGTH);
|
||||
const derived = scryptSync(`${password}${pepper}`, salt, PASSWORD_KEY_LENGTH);
|
||||
|
||||
return ["scrypt", salt.toString("hex"), derived.toString("hex")].join("$");
|
||||
}
|
||||
|
||||
export function verifyPassword(
|
||||
password: string,
|
||||
passwordHash: string,
|
||||
pepper: string,
|
||||
): boolean {
|
||||
const [algorithm, saltHex, hashHex] = passwordHash.split("$");
|
||||
|
||||
if (algorithm !== "scrypt" || !saltHex || !hashHex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = Buffer.from(hashHex, "hex");
|
||||
const actual = scryptSync(
|
||||
`${password}${pepper}`,
|
||||
Buffer.from(saltHex, "hex"),
|
||||
expected.length,
|
||||
);
|
||||
|
||||
return timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
export function createSessionToken(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
export function hashSessionToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function createPasswordResetToken(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
export function hashPasswordResetToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
221
packages/domain/src/generation.ts
Normal file
221
packages/domain/src/generation.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { getApproximateQuotaBucket, type QuotaBucket } from "./quota.js";
|
||||
|
||||
export type GenerationMode = "text_to_image" | "image_to_image";
|
||||
|
||||
export type GenerationRequestStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "canceled";
|
||||
|
||||
export interface ActiveSubscriptionContext {
|
||||
subscriptionId: string;
|
||||
planId: string;
|
||||
monthlyRequestLimit: number;
|
||||
usedSuccessfulRequests: number;
|
||||
}
|
||||
|
||||
export interface GenerationRequestRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
mode: GenerationMode;
|
||||
status: GenerationRequestStatus;
|
||||
providerModel: string;
|
||||
prompt: string;
|
||||
sourceImageKey?: string;
|
||||
resolutionPreset: string;
|
||||
batchSize: number;
|
||||
imageStrength?: number;
|
||||
idempotencyKey?: string;
|
||||
terminalErrorCode?: string;
|
||||
terminalErrorText?: string;
|
||||
requestedAt: Date;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateGenerationRequestInput {
|
||||
userId: string;
|
||||
mode: GenerationMode;
|
||||
providerModel: string;
|
||||
prompt: string;
|
||||
sourceImageKey?: string;
|
||||
resolutionPreset: string;
|
||||
batchSize: number;
|
||||
imageStrength?: number;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export interface CreateGenerationRequestResult {
|
||||
request: GenerationRequestRecord;
|
||||
reusedExistingRequest: boolean;
|
||||
approximateQuotaBucket: QuotaBucket;
|
||||
}
|
||||
|
||||
export interface CreateGenerationRequestDeps {
|
||||
findReusableRequest(
|
||||
userId: string,
|
||||
idempotencyKey: string,
|
||||
): Promise<GenerationRequestRecord | null>;
|
||||
findActiveSubscriptionContext(userId: string): Promise<ActiveSubscriptionContext | null>;
|
||||
createGenerationRequest(
|
||||
input: CreateGenerationRequestInput,
|
||||
): Promise<GenerationRequestRecord>;
|
||||
}
|
||||
|
||||
export interface SuccessfulGenerationRecord {
|
||||
request: GenerationRequestRecord;
|
||||
quotaConsumed: boolean;
|
||||
}
|
||||
|
||||
export interface MarkGenerationSucceededDeps {
|
||||
getGenerationRequest(requestId: string): Promise<GenerationRequestRecord | null>;
|
||||
markGenerationSucceeded(requestId: string): Promise<SuccessfulGenerationRecord>;
|
||||
}
|
||||
|
||||
export class GenerationRequestError extends Error {
|
||||
readonly code:
|
||||
| "missing_active_subscription"
|
||||
| "quota_exhausted"
|
||||
| "invalid_prompt"
|
||||
| "invalid_batch_size"
|
||||
| "missing_source_image"
|
||||
| "unexpected_source_image"
|
||||
| "missing_image_strength"
|
||||
| "unexpected_image_strength"
|
||||
| "request_not_found"
|
||||
| "request_not_completable";
|
||||
|
||||
constructor(code: GenerationRequestError["code"], message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGenerationRequest(
|
||||
deps: CreateGenerationRequestDeps,
|
||||
input: CreateGenerationRequestInput,
|
||||
): Promise<CreateGenerationRequestResult> {
|
||||
validateGenerationRequestInput(input);
|
||||
|
||||
if (input.idempotencyKey) {
|
||||
const existing = await deps.findReusableRequest(input.userId, input.idempotencyKey);
|
||||
|
||||
if (existing) {
|
||||
const subscription = await deps.findActiveSubscriptionContext(input.userId);
|
||||
const approximateQuotaBucket = subscription
|
||||
? getApproximateQuotaBucket({
|
||||
used: subscription.usedSuccessfulRequests,
|
||||
limit: subscription.monthlyRequestLimit,
|
||||
})
|
||||
: 0;
|
||||
|
||||
return {
|
||||
request: existing,
|
||||
reusedExistingRequest: true,
|
||||
approximateQuotaBucket,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = await deps.findActiveSubscriptionContext(input.userId);
|
||||
|
||||
if (!subscription) {
|
||||
throw new GenerationRequestError(
|
||||
"missing_active_subscription",
|
||||
"An active subscription is required before creating generation requests.",
|
||||
);
|
||||
}
|
||||
|
||||
if (subscription.usedSuccessfulRequests >= subscription.monthlyRequestLimit) {
|
||||
throw new GenerationRequestError(
|
||||
"quota_exhausted",
|
||||
"The current billing cycle has no remaining successful generation quota.",
|
||||
);
|
||||
}
|
||||
|
||||
const request = await deps.createGenerationRequest(input);
|
||||
|
||||
return {
|
||||
request,
|
||||
reusedExistingRequest: false,
|
||||
approximateQuotaBucket: getApproximateQuotaBucket({
|
||||
used: subscription.usedSuccessfulRequests,
|
||||
limit: subscription.monthlyRequestLimit,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function markGenerationRequestSucceeded(
|
||||
deps: MarkGenerationSucceededDeps,
|
||||
requestId: string,
|
||||
): Promise<SuccessfulGenerationRecord> {
|
||||
const request = await deps.getGenerationRequest(requestId);
|
||||
|
||||
if (!request) {
|
||||
throw new GenerationRequestError(
|
||||
"request_not_found",
|
||||
`Generation request ${requestId} was not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (request.status === "failed" || request.status === "canceled") {
|
||||
throw new GenerationRequestError(
|
||||
"request_not_completable",
|
||||
`Generation request ${requestId} is terminal and cannot succeed.`,
|
||||
);
|
||||
}
|
||||
|
||||
return deps.markGenerationSucceeded(requestId);
|
||||
}
|
||||
|
||||
function validateGenerationRequestInput(input: CreateGenerationRequestInput): void {
|
||||
if (input.prompt.trim().length === 0) {
|
||||
throw new GenerationRequestError(
|
||||
"invalid_prompt",
|
||||
"Prompt must not be empty after trimming.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(input.batchSize) || input.batchSize <= 0) {
|
||||
throw new GenerationRequestError(
|
||||
"invalid_batch_size",
|
||||
"Batch size must be a positive integer.",
|
||||
);
|
||||
}
|
||||
|
||||
if (input.mode === "image_to_image") {
|
||||
if (!input.sourceImageKey) {
|
||||
throw new GenerationRequestError(
|
||||
"missing_source_image",
|
||||
"Image-to-image requests require a source image key.",
|
||||
);
|
||||
}
|
||||
|
||||
if (input.imageStrength === undefined) {
|
||||
throw new GenerationRequestError(
|
||||
"missing_image_strength",
|
||||
"Image-to-image requests require image strength.",
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.sourceImageKey) {
|
||||
throw new GenerationRequestError(
|
||||
"unexpected_source_image",
|
||||
"Text-to-image requests must not include a source image key.",
|
||||
);
|
||||
}
|
||||
|
||||
if (input.imageStrength !== undefined) {
|
||||
throw new GenerationRequestError(
|
||||
"unexpected_image_strength",
|
||||
"Text-to-image requests must not include image strength.",
|
||||
);
|
||||
}
|
||||
}
|
||||
5
packages/domain/src/index.ts
Normal file
5
packages/domain/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./quota.js";
|
||||
export * from "./provider-key-pool.js";
|
||||
export * from "./generation.js";
|
||||
export * from "./auth.js";
|
||||
export * from "./telegram-pairing.js";
|
||||
172
packages/domain/src/provider-key-pool.ts
Normal file
172
packages/domain/src/provider-key-pool.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
export type ProviderKeyState =
|
||||
| "active"
|
||||
| "cooldown"
|
||||
| "out_of_funds"
|
||||
| "manual_review"
|
||||
| "disabled";
|
||||
|
||||
export interface ProviderKeySnapshot {
|
||||
id: string;
|
||||
state: ProviderKeyState;
|
||||
consecutiveRetryableFailures: number;
|
||||
}
|
||||
|
||||
export type ProviderFailureKind =
|
||||
| "transport"
|
||||
| "timeout"
|
||||
| "provider_5xx"
|
||||
| "provider_4xx_user"
|
||||
| "insufficient_funds"
|
||||
| "unknown";
|
||||
|
||||
export interface ProviderAttemptResultSuccess {
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export interface ProviderAttemptResultFailure {
|
||||
ok: false;
|
||||
failureKind: ProviderFailureKind;
|
||||
}
|
||||
|
||||
export type ProviderAttemptResult =
|
||||
| ProviderAttemptResultSuccess
|
||||
| ProviderAttemptResultFailure;
|
||||
|
||||
export type RetryDisposition = "retry_next_key" | "stop_request";
|
||||
|
||||
export interface KeyStateTransition {
|
||||
from: ProviderKeyState;
|
||||
to: ProviderKeyState;
|
||||
reason:
|
||||
| "retryable_failure"
|
||||
| "insufficient_funds"
|
||||
| "recovered"
|
||||
| "manual_action"
|
||||
| "none";
|
||||
}
|
||||
|
||||
export interface EvaluateAttemptOutput {
|
||||
retryDisposition: RetryDisposition;
|
||||
transition: KeyStateTransition;
|
||||
nextConsecutiveRetryableFailures: number;
|
||||
}
|
||||
|
||||
export interface EvaluateAttemptOptions {
|
||||
failuresBeforeManualReview?: number;
|
||||
}
|
||||
|
||||
export function isRetryableFailure(kind: ProviderFailureKind): boolean {
|
||||
return kind === "transport" || kind === "timeout" || kind === "provider_5xx";
|
||||
}
|
||||
|
||||
export function evaluateAttempt(
|
||||
key: ProviderKeySnapshot,
|
||||
result: ProviderAttemptResult,
|
||||
options: EvaluateAttemptOptions = {},
|
||||
): EvaluateAttemptOutput {
|
||||
if (result.ok) {
|
||||
return {
|
||||
retryDisposition: "stop_request",
|
||||
transition: {
|
||||
from: key.state,
|
||||
to: key.state === "cooldown" ? "active" : key.state,
|
||||
reason: key.state === "cooldown" ? "recovered" : "none",
|
||||
},
|
||||
nextConsecutiveRetryableFailures: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.failureKind === "insufficient_funds") {
|
||||
return {
|
||||
retryDisposition: "stop_request",
|
||||
transition: {
|
||||
from: key.state,
|
||||
to: "out_of_funds",
|
||||
reason: "insufficient_funds",
|
||||
},
|
||||
nextConsecutiveRetryableFailures: key.consecutiveRetryableFailures,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRetryableFailure(result.failureKind)) {
|
||||
const nextFailures = key.consecutiveRetryableFailures + 1;
|
||||
const failuresBeforeManualReview = options.failuresBeforeManualReview ?? 10;
|
||||
const shouldEscalateToManualReview =
|
||||
nextFailures > failuresBeforeManualReview;
|
||||
|
||||
return {
|
||||
retryDisposition: "retry_next_key",
|
||||
transition: {
|
||||
from: key.state,
|
||||
to: shouldEscalateToManualReview ? "manual_review" : "cooldown",
|
||||
reason: "retryable_failure",
|
||||
},
|
||||
nextConsecutiveRetryableFailures: nextFailures,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
retryDisposition: "stop_request",
|
||||
transition: {
|
||||
from: key.state,
|
||||
to: key.state,
|
||||
reason: "none",
|
||||
},
|
||||
nextConsecutiveRetryableFailures: key.consecutiveRetryableFailures,
|
||||
};
|
||||
}
|
||||
|
||||
export interface KeySelectionInput {
|
||||
keys: ReadonlyArray<ProviderKeySnapshot>;
|
||||
lastUsedKeyId?: string;
|
||||
}
|
||||
|
||||
export interface KeySelectionOutput {
|
||||
orderedActiveKeyIds: string[];
|
||||
}
|
||||
|
||||
export function selectActiveKeysRoundRobin(input: KeySelectionInput): KeySelectionOutput {
|
||||
const active = input.keys.filter((key) => key.state === "active");
|
||||
|
||||
if (active.length === 0) {
|
||||
return { orderedActiveKeyIds: [] };
|
||||
}
|
||||
|
||||
if (!input.lastUsedKeyId) {
|
||||
return {
|
||||
orderedActiveKeyIds: active.map((key) => key.id),
|
||||
};
|
||||
}
|
||||
|
||||
const lastIndex = active.findIndex((key) => key.id === input.lastUsedKeyId);
|
||||
|
||||
if (lastIndex < 0) {
|
||||
return {
|
||||
orderedActiveKeyIds: active.map((key) => key.id),
|
||||
};
|
||||
}
|
||||
|
||||
const nextStart = (lastIndex + 1) % active.length;
|
||||
const ordered = active.slice(nextStart).concat(active.slice(0, nextStart));
|
||||
|
||||
return {
|
||||
orderedActiveKeyIds: ordered.map((key) => key.id),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AttemptPlanInput {
|
||||
keys: ReadonlyArray<ProviderKeySnapshot>;
|
||||
lastUsedKeyId?: string;
|
||||
}
|
||||
|
||||
export interface AttemptPlanOutput {
|
||||
keyIdsInAttemptOrder: string[];
|
||||
}
|
||||
|
||||
export function buildAttemptPlan(input: AttemptPlanInput): AttemptPlanOutput {
|
||||
const selection = selectActiveKeysRoundRobin(input);
|
||||
|
||||
return {
|
||||
keyIdsInAttemptOrder: selection.orderedActiveKeyIds,
|
||||
};
|
||||
}
|
||||
43
packages/domain/src/quota.ts
Normal file
43
packages/domain/src/quota.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type QuotaBucket = 100 | 80 | 60 | 40 | 20 | 0;
|
||||
|
||||
export interface QuotaUsageInput {
|
||||
used: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export function getApproximateQuotaBucket(input: QuotaUsageInput): QuotaBucket {
|
||||
const { used, limit } = input;
|
||||
|
||||
if (limit <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const safeUsed = clamp(used, 0, limit);
|
||||
const remainingRatio = ((limit - safeUsed) / limit) * 100;
|
||||
|
||||
if (remainingRatio >= 81) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (remainingRatio >= 61) {
|
||||
return 80;
|
||||
}
|
||||
|
||||
if (remainingRatio >= 41) {
|
||||
return 60;
|
||||
}
|
||||
|
||||
if (remainingRatio >= 21) {
|
||||
return 40;
|
||||
}
|
||||
|
||||
if (remainingRatio > 0) {
|
||||
return 20;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
13
packages/domain/src/telegram-pairing.ts
Normal file
13
packages/domain/src/telegram-pairing.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function normalizePairingCode(code: string): string {
|
||||
return code.trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function hashPairingCode(code: string): string {
|
||||
return createHash("sha256").update(normalizePairingCode(code)).digest("hex");
|
||||
}
|
||||
|
||||
export function isPairingExpired(expiresAt: Date, now: Date = new Date()): boolean {
|
||||
return expiresAt.getTime() <= now.getTime();
|
||||
}
|
||||
11
packages/domain/tsconfig.json
Normal file
11
packages/domain/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user