Initial import
This commit is contained in:
9
apps/AGENTS.md
Normal file
9
apps/AGENTS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
Applies within `apps/` unless a deeper file overrides it.
|
||||
|
||||
## Rules
|
||||
- Keep apps thin. Domain rules belong in `packages/domain`.
|
||||
- App-local code may compose use cases but should not redefine billing, quota, or key-state logic.
|
||||
- Any new runtime entrypoint must have a clear responsibility and own only transport concerns.
|
||||
15
apps/bot/AGENTS.md
Normal file
15
apps/bot/AGENTS.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
Applies within `apps/bot`.
|
||||
|
||||
## Responsibilities
|
||||
- Telegram admin bot runtime
|
||||
- allowlist checks
|
||||
- alert delivery
|
||||
- low-friction admin commands
|
||||
|
||||
## Rules
|
||||
- Pairing approval must never happen inside the bot runtime itself.
|
||||
- The bot may initiate pending pairing, but only the server-side CLI completes it.
|
||||
- Every command that changes state must produce an audit log entry.
|
||||
3
apps/bot/README.md
Normal file
3
apps/bot/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# apps/bot
|
||||
|
||||
Planned Telegram admin bot runtime. MVP should use long polling unless deployment requirements change.
|
||||
15
apps/bot/package.json
Normal file
15
apps/bot/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nproxy/bot",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nproxy/config": "workspace:*",
|
||||
"@nproxy/db": "workspace:*",
|
||||
"@nproxy/providers": "workspace:*"
|
||||
}
|
||||
}
|
||||
0
apps/bot/src/.gitkeep
Normal file
0
apps/bot/src/.gitkeep
Normal file
100
apps/bot/src/main.ts
Normal file
100
apps/bot/src/main.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { loadConfig } from "@nproxy/config";
|
||||
import { createPrismaTelegramBotStore, prisma } from "@nproxy/db";
|
||||
import { createTelegramBotApiTransport, type TelegramUpdate } from "@nproxy/providers";
|
||||
|
||||
const config = loadConfig();
|
||||
const telegramStore = createPrismaTelegramBotStore(prisma);
|
||||
const telegramTransport = createTelegramBotApiTransport(config.telegram.botToken);
|
||||
const pairingExpiresInMinutes = 15;
|
||||
let nextUpdateOffset: number | undefined;
|
||||
let isPolling = false;
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
service: "bot",
|
||||
mode: config.telegram.botMode,
|
||||
}),
|
||||
);
|
||||
|
||||
void pollLoop();
|
||||
|
||||
process.once("SIGTERM", async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.once("SIGINT", async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
async function pollLoop(): Promise<void> {
|
||||
if (isPolling) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPolling = true;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const updates = await telegramTransport.getUpdates({
|
||||
...(nextUpdateOffset !== undefined ? { offset: nextUpdateOffset } : {}),
|
||||
timeoutSeconds: 25,
|
||||
});
|
||||
|
||||
for (const update of updates) {
|
||||
await handleUpdate(update);
|
||||
nextUpdateOffset = update.update_id + 1;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
console.log("bot polling heartbeat");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("bot polling failed", error);
|
||||
setTimeout(() => {
|
||||
isPolling = false;
|
||||
void pollLoop();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(update: TelegramUpdate): Promise<void> {
|
||||
const message = update.message;
|
||||
const from = message?.from;
|
||||
|
||||
if (!message || !from) {
|
||||
return;
|
||||
}
|
||||
|
||||
const telegramUserId = String(from.id);
|
||||
const displayNameSnapshot = [from.first_name, from.last_name].filter(Boolean).join(" ");
|
||||
const isAllowed = await telegramStore.isTelegramAdminAllowed(telegramUserId);
|
||||
|
||||
if (isAllowed) {
|
||||
await telegramTransport.sendMessage({
|
||||
chatId: message.chat.id,
|
||||
text: "Admin access is active.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const challenge = await telegramStore.getOrCreatePendingPairingChallenge(
|
||||
{
|
||||
telegramUserId,
|
||||
...(from.username ? { telegramUsername: from.username } : {}),
|
||||
displayNameSnapshot: displayNameSnapshot || "Telegram user",
|
||||
},
|
||||
pairingExpiresInMinutes,
|
||||
);
|
||||
|
||||
await telegramTransport.sendMessage({
|
||||
chatId: message.chat.id,
|
||||
text: [
|
||||
`Your pairing code is: ${challenge.code}`,
|
||||
`Run nproxy pair ${challenge.code} on the server.`,
|
||||
`Expires at ${challenge.expiresAt.toISOString()}.`,
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
12
apps/bot/tsconfig.json
Normal file
12
apps/bot/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
14
apps/cli/AGENTS.md
Normal file
14
apps/cli/AGENTS.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
Applies within `apps/cli`.
|
||||
|
||||
## Responsibilities
|
||||
- server-side operational CLI commands
|
||||
- Telegram pairing completion
|
||||
- safe admin maintenance commands that are better run locally on the server
|
||||
|
||||
## Rules
|
||||
- Commands that mutate admin access must show the target identity and require explicit confirmation unless a non-interactive flag is provided.
|
||||
- Pairing codes must be matched against hashed pending records.
|
||||
- All successful mutating commands must write audit logs.
|
||||
9
apps/cli/README.md
Normal file
9
apps/cli/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# apps/cli
|
||||
|
||||
Planned operator CLI.
|
||||
|
||||
Expected early commands:
|
||||
- `nproxy pair <code>`
|
||||
- `nproxy pair list`
|
||||
- `nproxy pair revoke <telegram-user-id>`
|
||||
- `nproxy pair cleanup`
|
||||
18
apps/cli/package.json
Normal file
18
apps/cli/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@nproxy/cli",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"nproxy": "./dist/main.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nproxy/config": "workspace:*",
|
||||
"@nproxy/db": "workspace:*",
|
||||
"@nproxy/domain": "workspace:*"
|
||||
}
|
||||
}
|
||||
0
apps/cli/src/.gitkeep
Normal file
0
apps/cli/src/.gitkeep
Normal file
233
apps/cli/src/main.ts
Normal file
233
apps/cli/src/main.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { loadConfig } from "@nproxy/config";
|
||||
import { createPrismaTelegramPairingStore, prisma } from "@nproxy/db";
|
||||
import { hashPairingCode, isPairingExpired, normalizePairingCode } from "@nproxy/domain";
|
||||
|
||||
const config = loadConfig();
|
||||
const pairingStore = createPrismaTelegramPairingStore(prisma);
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
void main()
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [command, subcommandOrArgument, ...rest] = args;
|
||||
|
||||
if (command !== "pair") {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommandOrArgument === "list") {
|
||||
await handlePairList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommandOrArgument === "cleanup") {
|
||||
await handlePairCleanup(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommandOrArgument === "revoke") {
|
||||
await handlePairRevoke(rest[0], rest.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommandOrArgument) {
|
||||
await handlePairComplete(subcommandOrArgument, rest);
|
||||
return;
|
||||
}
|
||||
|
||||
printHelp();
|
||||
}
|
||||
|
||||
async function handlePairComplete(code: string, flags: string[]): Promise<void> {
|
||||
const normalizedCode = normalizePairingCode(code);
|
||||
const record = await pairingStore.findPendingPairingByCodeHash(hashPairingCode(normalizedCode));
|
||||
|
||||
if (!record) {
|
||||
throw new Error(`Pending pairing not found for code ${normalizedCode}.`);
|
||||
}
|
||||
|
||||
if (isPairingExpired(record.expiresAt)) {
|
||||
throw new Error(`Pairing code ${normalizedCode} has expired.`);
|
||||
}
|
||||
|
||||
printPairingIdentity(record);
|
||||
|
||||
const confirmed = flags.includes("--yes")
|
||||
? true
|
||||
: await confirm("Approve this Telegram admin pairing?");
|
||||
|
||||
if (!confirmed) {
|
||||
console.log("pairing aborted");
|
||||
return;
|
||||
}
|
||||
|
||||
const completed = await pairingStore.completePendingPairing({
|
||||
pairingId: record.id,
|
||||
actorRef: buildActorRef(),
|
||||
});
|
||||
|
||||
console.log(`paired telegram_user_id=${completed.telegramUserId}`);
|
||||
}
|
||||
|
||||
async function handlePairList(): Promise<void> {
|
||||
const listing = await pairingStore.listTelegramPairings();
|
||||
|
||||
console.log("active telegram admins:");
|
||||
if (listing.activeAdmins.length === 0) {
|
||||
console.log(" none");
|
||||
} else {
|
||||
for (const entry of listing.activeAdmins) {
|
||||
console.log(
|
||||
[
|
||||
` user_id=${entry.telegramUserId}`,
|
||||
`display_name=${JSON.stringify(entry.displayNameSnapshot)}`,
|
||||
...(entry.telegramUsername ? [`username=@${entry.telegramUsername}`] : []),
|
||||
`paired_at=${entry.pairedAt.toISOString()}`,
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("pending pairings:");
|
||||
if (listing.pending.length === 0) {
|
||||
console.log(" none");
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
for (const record of listing.pending) {
|
||||
console.log(
|
||||
[
|
||||
` pairing_id=${record.id}`,
|
||||
`user_id=${record.telegramUserId}`,
|
||||
`display_name=${JSON.stringify(record.displayNameSnapshot)}`,
|
||||
...(record.telegramUsername ? [`username=@${record.telegramUsername}`] : []),
|
||||
`status=${record.status}`,
|
||||
`expires_at=${record.expiresAt.toISOString()}`,
|
||||
`expired=${isPairingExpired(record.expiresAt, now)}`,
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePairRevoke(
|
||||
telegramUserId: string | undefined,
|
||||
flags: string[],
|
||||
): Promise<void> {
|
||||
if (!telegramUserId) {
|
||||
throw new Error("Missing telegram user id. Usage: nproxy pair revoke <telegram-user-id> [--yes]");
|
||||
}
|
||||
|
||||
const listing = await pairingStore.listTelegramPairings();
|
||||
const entry = listing.activeAdmins.find((item) => item.telegramUserId === telegramUserId);
|
||||
|
||||
if (!entry) {
|
||||
throw new Error(`Active telegram admin ${telegramUserId} was not found.`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
[
|
||||
"revoke target:",
|
||||
`user_id=${entry.telegramUserId}`,
|
||||
`display_name=${JSON.stringify(entry.displayNameSnapshot)}`,
|
||||
...(entry.telegramUsername ? [`username=@${entry.telegramUsername}`] : []),
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
const confirmed = flags.includes("--yes")
|
||||
? true
|
||||
: await confirm("Revoke Telegram admin access?");
|
||||
|
||||
if (!confirmed) {
|
||||
console.log("revoke aborted");
|
||||
return;
|
||||
}
|
||||
|
||||
await pairingStore.revokeTelegramAdmin({
|
||||
telegramUserId,
|
||||
actorRef: buildActorRef(),
|
||||
});
|
||||
|
||||
console.log(`revoked telegram_user_id=${telegramUserId}`);
|
||||
}
|
||||
|
||||
async function handlePairCleanup(flags: string[]): Promise<void> {
|
||||
const confirmed = flags.includes("--yes")
|
||||
? true
|
||||
: await confirm("Mark all expired pending pairings as expired?");
|
||||
|
||||
if (!confirmed) {
|
||||
console.log("cleanup aborted");
|
||||
return;
|
||||
}
|
||||
|
||||
const expiredCount = await pairingStore.cleanupExpiredPendingPairings({
|
||||
actorRef: buildActorRef(),
|
||||
});
|
||||
|
||||
console.log(`expired_pairings=${expiredCount}`);
|
||||
}
|
||||
|
||||
async function confirm(prompt: string): Promise<boolean> {
|
||||
const readline = createInterface({ input: stdin, output: stdout });
|
||||
|
||||
try {
|
||||
const answer = await readline.question(`${prompt} [y/N] `);
|
||||
return answer.trim().toLowerCase() === "y";
|
||||
} finally {
|
||||
readline.close();
|
||||
}
|
||||
}
|
||||
|
||||
function printPairingIdentity(record: {
|
||||
telegramUserId: string;
|
||||
displayNameSnapshot: string;
|
||||
telegramUsername?: string;
|
||||
expiresAt: Date;
|
||||
}): void {
|
||||
console.log(
|
||||
[
|
||||
"pairing target:",
|
||||
`user_id=${record.telegramUserId}`,
|
||||
`display_name=${JSON.stringify(record.displayNameSnapshot)}`,
|
||||
...(record.telegramUsername ? [`username=@${record.telegramUsername}`] : []),
|
||||
`expires_at=${record.expiresAt.toISOString()}`,
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
function buildActorRef(): string {
|
||||
const username =
|
||||
process.env.USER ??
|
||||
process.env.LOGNAME ??
|
||||
"unknown";
|
||||
const hostDigest = createHash("sha256")
|
||||
.update(config.urls.appBaseUrl.toString())
|
||||
.digest("hex")
|
||||
.slice(0, 8);
|
||||
|
||||
return `${username}@${hostDigest}`;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log("nproxy cli");
|
||||
console.log(`app=${config.urls.appBaseUrl.toString()}`);
|
||||
console.log("available commands:");
|
||||
console.log(" nproxy pair <code> [--yes]");
|
||||
console.log(" nproxy pair list");
|
||||
console.log(" nproxy pair revoke <telegram-user-id> [--yes]");
|
||||
console.log(" nproxy pair cleanup [--yes]");
|
||||
}
|
||||
12
apps/cli/tsconfig.json
Normal file
12
apps/cli/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
16
apps/web/AGENTS.md
Normal file
16
apps/web/AGENTS.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
Applies within `apps/web`.
|
||||
|
||||
## Responsibilities
|
||||
- Public website
|
||||
- User dashboard
|
||||
- Chat UI
|
||||
- Admin UI
|
||||
- HTTP API entrypoints
|
||||
|
||||
## Rules
|
||||
- Keep React and route handlers focused on transport, rendering, and validation.
|
||||
- Delegate subscription, quota, and provider-routing policy to shared packages.
|
||||
- User-facing quota output must remain approximate unless the caller is an admin surface.
|
||||
10
apps/web/README.md
Normal file
10
apps/web/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# apps/web
|
||||
|
||||
Planned Next.js application for:
|
||||
- public landing page
|
||||
- authentication
|
||||
- user dashboard
|
||||
- chat UI
|
||||
- billing pages
|
||||
- admin web interface
|
||||
- HTTP API routes
|
||||
0
apps/web/app/.gitkeep
Normal file
0
apps/web/app/.gitkeep
Normal file
16
apps/web/package.json
Normal file
16
apps/web/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@nproxy/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nproxy/config": "workspace:*",
|
||||
"@nproxy/db": "workspace:*",
|
||||
"@nproxy/domain": "workspace:*",
|
||||
"@nproxy/providers": "workspace:*"
|
||||
}
|
||||
}
|
||||
782
apps/web/src/main.ts
Normal file
782
apps/web/src/main.ts
Normal file
@@ -0,0 +1,782 @@
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import { loadConfig } from "@nproxy/config";
|
||||
import {
|
||||
createPrismaAccountStore,
|
||||
createPrismaAuthStore,
|
||||
createPrismaBillingStore,
|
||||
createPrismaGenerationStore,
|
||||
prisma,
|
||||
} from "@nproxy/db";
|
||||
import { createEmailTransport, createPaymentProviderAdapter } from "@nproxy/providers";
|
||||
import {
|
||||
AuthError,
|
||||
GenerationRequestError,
|
||||
createGenerationRequest,
|
||||
type CreateGenerationRequestInput,
|
||||
} from "@nproxy/domain";
|
||||
|
||||
const config = loadConfig();
|
||||
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
|
||||
const accountStore = createPrismaAccountStore(prisma);
|
||||
const authStore = createPrismaAuthStore(prisma);
|
||||
const billingStore = createPrismaBillingStore(prisma);
|
||||
const generationStore = createPrismaGenerationStore(prisma);
|
||||
const emailTransport = createEmailTransport(config.email);
|
||||
const paymentProviderAdapter = createPaymentProviderAdapter(config.payment);
|
||||
const sessionCookieName = "nproxy_session";
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
if (request.method === "GET" && request.url === "/healthz") {
|
||||
sendJson(response, 200, { ok: true, service: "web" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/auth/register") {
|
||||
const body = await readJsonBody(request);
|
||||
const payload = readAuthPayload(body);
|
||||
const session = await authStore.registerUser({
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
passwordPepper: config.auth.passwordPepper,
|
||||
});
|
||||
|
||||
sendJson(
|
||||
response,
|
||||
201,
|
||||
{ user: serializeAuthenticatedUser(session.user) },
|
||||
createSessionCookie(session.token, session.expiresAt),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/auth/login") {
|
||||
const body = await readJsonBody(request);
|
||||
const payload = readAuthPayload(body);
|
||||
const session = await authStore.loginUser({
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
passwordPepper: config.auth.passwordPepper,
|
||||
});
|
||||
|
||||
sendJson(
|
||||
response,
|
||||
200,
|
||||
{ user: serializeAuthenticatedUser(session.user) },
|
||||
createSessionCookie(session.token, session.expiresAt),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/auth/password-reset/request") {
|
||||
const body = await readJsonBody(request);
|
||||
const payload = readEmailOnlyPayload(body);
|
||||
const challenge = await authStore.createPasswordResetChallenge({
|
||||
email: payload.email,
|
||||
});
|
||||
|
||||
if (challenge) {
|
||||
const resetUrl = new URL("/reset-password", config.urls.appBaseUrl);
|
||||
resetUrl.searchParams.set("token", challenge.token);
|
||||
|
||||
await emailTransport.send({
|
||||
to: challenge.email,
|
||||
subject: "Reset your nproxy password",
|
||||
text: [
|
||||
"We received a request to reset your password.",
|
||||
`Reset link: ${resetUrl.toString()}`,
|
||||
`This link expires at ${challenge.expiresAt.toISOString()}.`,
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/auth/password-reset/confirm") {
|
||||
const body = await readJsonBody(request);
|
||||
const payload = readPasswordResetConfirmPayload(body);
|
||||
|
||||
await authStore.resetPassword({
|
||||
token: payload.token,
|
||||
newPassword: payload.password,
|
||||
passwordPepper: config.auth.passwordPepper,
|
||||
});
|
||||
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/auth/logout") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
|
||||
await authStore.revokeSession(authenticatedSession.token);
|
||||
|
||||
sendJson(response, 200, { ok: true }, clearSessionCookie());
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && request.url === "/api/auth/me") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
sendJson(response, 200, {
|
||||
user: serializeAuthenticatedUser(authenticatedSession.user),
|
||||
session: serializeUserSession(authenticatedSession.session, authenticatedSession.session.id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && request.url === "/api/auth/sessions") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const sessions = await authStore.listUserSessions(authenticatedSession.user.id);
|
||||
sendJson(response, 200, {
|
||||
sessions: sessions.map((session) =>
|
||||
serializeUserSession(session, authenticatedSession.session.id),
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && request.url === "/api/account") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const overview = await accountStore.getUserAccountOverview(authenticatedSession.user.id);
|
||||
|
||||
if (!overview) {
|
||||
sendJson(response, 404, {
|
||||
error: {
|
||||
code: "account_not_found",
|
||||
message: "Account was not found.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, serializeAccountOverview(overview));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && request.url === "/api/billing/invoices") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const invoices = await billingStore.listUserInvoices(authenticatedSession.user.id);
|
||||
sendJson(response, 200, {
|
||||
invoices: invoices.map(serializeBillingInvoice),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/billing/invoices") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const invoice = await billingStore.createSubscriptionInvoice({
|
||||
userId: authenticatedSession.user.id,
|
||||
paymentProvider: config.payment.provider,
|
||||
paymentProviderAdapter,
|
||||
});
|
||||
sendJson(response, 201, {
|
||||
invoice: serializeBillingInvoice(invoice),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/auth/logout-all") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const revokedCount = await authStore.revokeAllUserSessions({
|
||||
userId: authenticatedSession.user.id,
|
||||
exceptSessionId: authenticatedSession.session.id,
|
||||
});
|
||||
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
revokedCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url) {
|
||||
const invoiceMarkPaidMatch = request.url.match(/^\/api\/admin\/invoices\/([^/]+)\/mark-paid$/);
|
||||
|
||||
if (invoiceMarkPaidMatch) {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
|
||||
if (!authenticatedSession.user.isAdmin) {
|
||||
throw new HttpError(403, "forbidden", "Admin session is required.");
|
||||
}
|
||||
|
||||
const invoiceId = decodeURIComponent(invoiceMarkPaidMatch[1] ?? "");
|
||||
const invoice = await billingStore.markInvoicePaid({ invoiceId });
|
||||
sendJson(response, 200, {
|
||||
invoice: serializeBillingInvoice(invoice),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === "DELETE" && request.url) {
|
||||
const sessionMatch = request.url.match(/^\/api\/auth\/sessions\/([^/]+)$/);
|
||||
|
||||
if (sessionMatch) {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const sessionId = decodeURIComponent(sessionMatch[1] ?? "");
|
||||
|
||||
if (sessionId === authenticatedSession.session.id) {
|
||||
await authStore.revokeSession(authenticatedSession.token);
|
||||
sendJson(response, 200, { ok: true }, clearSessionCookie());
|
||||
return;
|
||||
}
|
||||
|
||||
const revoked = await authStore.revokeUserSession({
|
||||
userId: authenticatedSession.user.id,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!revoked) {
|
||||
sendJson(response, 404, {
|
||||
error: {
|
||||
code: "session_not_found",
|
||||
message: "Session was not found.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/generations") {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const body = await readJsonBody(request);
|
||||
const requestInput = mapCreateGenerationRequestInput(body, authenticatedSession.user.id);
|
||||
const result = await createGenerationRequest(generationStore, requestInput);
|
||||
|
||||
sendJson(response, result.reusedExistingRequest ? 200 : 201, {
|
||||
request: serializeGenerationRequest(result.request),
|
||||
reusedExistingRequest: result.reusedExistingRequest,
|
||||
approximateQuotaBucket: result.approximateQuotaBucket,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && request.url) {
|
||||
const match = request.url.match(/^\/api\/generations\/([^/]+)$/);
|
||||
|
||||
if (match) {
|
||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||
const requestId = decodeURIComponent(match[1] ?? "");
|
||||
const generationRequest = await generationStore.getGenerationRequest(requestId);
|
||||
|
||||
if (!generationRequest || generationRequest.userId !== authenticatedSession.user.id) {
|
||||
sendJson(response, 404, {
|
||||
error: {
|
||||
code: "not_found",
|
||||
message: "Generation request was not found.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, {
|
||||
request: serializeGenerationRequest(generationRequest),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(response, 200, {
|
||||
service: "web",
|
||||
appBaseUrl: config.urls.appBaseUrl.toString(),
|
||||
adminBaseUrl: config.urls.adminBaseUrl.toString(),
|
||||
providerModel: config.provider.nanoBananaDefaultModel,
|
||||
endpoints: {
|
||||
register: "POST /api/auth/register",
|
||||
login: "POST /api/auth/login",
|
||||
passwordResetRequest: "POST /api/auth/password-reset/request",
|
||||
passwordResetConfirm: "POST /api/auth/password-reset/confirm",
|
||||
logout: "POST /api/auth/logout",
|
||||
me: "GET /api/auth/me",
|
||||
sessions: "GET /api/auth/sessions",
|
||||
revokeSession: "DELETE /api/auth/sessions/:id",
|
||||
logoutAll: "POST /api/auth/logout-all",
|
||||
account: "GET /api/account",
|
||||
listInvoices: "GET /api/billing/invoices",
|
||||
createInvoice: "POST /api/billing/invoices",
|
||||
adminMarkInvoicePaid: "POST /api/admin/invoices/:id/mark-paid",
|
||||
createGenerationRequest: "POST /api/generations",
|
||||
getGenerationRequest: "GET /api/generations/:id",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
handleRequestError(response, error);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`web listening on ${port}`);
|
||||
});
|
||||
|
||||
process.once("SIGTERM", async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.once("SIGINT", async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
async function readJsonBody(
|
||||
request: IncomingMessage,
|
||||
): Promise<unknown> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
for await (const chunk of request) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
throw new HttpError(400, "invalid_json", "Request body must not be empty.");
|
||||
}
|
||||
|
||||
const rawBody = Buffer.concat(chunks).toString("utf8");
|
||||
|
||||
try {
|
||||
return JSON.parse(rawBody) as unknown;
|
||||
} catch {
|
||||
throw new HttpError(400, "invalid_json", "Request body must be valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
function readAuthPayload(body: unknown): { email: string; password: string } {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new HttpError(400, "invalid_body", "Request body must be a JSON object.");
|
||||
}
|
||||
|
||||
const payload = body as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
email: readRequiredString(payload.email, "email"),
|
||||
password: readRequiredString(payload.password, "password"),
|
||||
};
|
||||
}
|
||||
|
||||
function readEmailOnlyPayload(body: unknown): { email: string } {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new HttpError(400, "invalid_body", "Request body must be a JSON object.");
|
||||
}
|
||||
|
||||
const payload = body as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
email: readRequiredString(payload.email, "email"),
|
||||
};
|
||||
}
|
||||
|
||||
function readPasswordResetConfirmPayload(
|
||||
body: unknown,
|
||||
): { token: string; password: string } {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new HttpError(400, "invalid_body", "Request body must be a JSON object.");
|
||||
}
|
||||
|
||||
const payload = body as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
token: readRequiredString(payload.token, "token"),
|
||||
password: readRequiredString(payload.password, "password"),
|
||||
};
|
||||
}
|
||||
|
||||
function mapCreateGenerationRequestInput(
|
||||
body: unknown,
|
||||
userId: string,
|
||||
): CreateGenerationRequestInput {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new HttpError(400, "invalid_body", "Request body must be a JSON object.");
|
||||
}
|
||||
|
||||
const payload = body as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
userId,
|
||||
mode: readGenerationMode(payload.mode),
|
||||
providerModel: readRequiredString(payload.providerModel, "providerModel"),
|
||||
prompt: readRequiredString(payload.prompt, "prompt"),
|
||||
resolutionPreset: readRequiredString(payload.resolutionPreset, "resolutionPreset"),
|
||||
batchSize: readRequiredInteger(payload.batchSize, "batchSize"),
|
||||
...(payload.sourceImageKey !== undefined
|
||||
? { sourceImageKey: readRequiredString(payload.sourceImageKey, "sourceImageKey") }
|
||||
: {}),
|
||||
...(payload.imageStrength !== undefined
|
||||
? { imageStrength: readRequiredNumber(payload.imageStrength, "imageStrength") }
|
||||
: {}),
|
||||
...(payload.idempotencyKey !== undefined
|
||||
? { idempotencyKey: readRequiredString(payload.idempotencyKey, "idempotencyKey") }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readGenerationMode(value: unknown): CreateGenerationRequestInput["mode"] {
|
||||
if (value === "text_to_image" || value === "image_to_image") {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new HttpError(
|
||||
400,
|
||||
"invalid_mode",
|
||||
'mode must be "text_to_image" or "image_to_image".',
|
||||
);
|
||||
}
|
||||
|
||||
function readRequiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new HttpError(400, "invalid_field", `${field} must be a non-empty string.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function readRequiredInteger(value: unknown, field: string): number {
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||
throw new HttpError(400, "invalid_field", `${field} must be an integer.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function readRequiredNumber(value: unknown, field: string): number {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
throw new HttpError(400, "invalid_field", `${field} must be a number.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function serializeAuthenticatedUser(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeUserSession(
|
||||
session: {
|
||||
id: string;
|
||||
expiresAt: Date;
|
||||
revokedAt?: Date;
|
||||
lastSeenAt?: Date;
|
||||
createdAt: Date;
|
||||
},
|
||||
currentSessionId: string,
|
||||
) {
|
||||
return {
|
||||
id: session.id,
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
isCurrent: session.id === currentSessionId,
|
||||
...(session.revokedAt ? { revokedAt: session.revokedAt.toISOString() } : {}),
|
||||
...(session.lastSeenAt ? { lastSeenAt: session.lastSeenAt.toISOString() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAccountOverview(overview: {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
subscription: {
|
||||
id: string;
|
||||
status: string;
|
||||
renewsManually: boolean;
|
||||
activatedAt?: Date;
|
||||
currentPeriodStart?: Date;
|
||||
currentPeriodEnd?: Date;
|
||||
canceledAt?: Date;
|
||||
plan: {
|
||||
id: string;
|
||||
code: string;
|
||||
displayName: string;
|
||||
monthlyRequestLimit: number;
|
||||
monthlyPriceUsd: number;
|
||||
billingCurrency: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
} | null;
|
||||
quota: {
|
||||
approximateBucket: number;
|
||||
usedSuccessfulRequests: number;
|
||||
monthlyRequestLimit: number;
|
||||
} | null;
|
||||
}) {
|
||||
return {
|
||||
user: serializeAuthenticatedUser(overview.user),
|
||||
subscription: overview.subscription
|
||||
? {
|
||||
id: overview.subscription.id,
|
||||
status: overview.subscription.status,
|
||||
renewsManually: overview.subscription.renewsManually,
|
||||
...(overview.subscription.activatedAt
|
||||
? { activatedAt: overview.subscription.activatedAt.toISOString() }
|
||||
: {}),
|
||||
...(overview.subscription.currentPeriodStart
|
||||
? { currentPeriodStart: overview.subscription.currentPeriodStart.toISOString() }
|
||||
: {}),
|
||||
...(overview.subscription.currentPeriodEnd
|
||||
? { currentPeriodEnd: overview.subscription.currentPeriodEnd.toISOString() }
|
||||
: {}),
|
||||
...(overview.subscription.canceledAt
|
||||
? { canceledAt: overview.subscription.canceledAt.toISOString() }
|
||||
: {}),
|
||||
plan: overview.subscription.plan,
|
||||
}
|
||||
: null,
|
||||
quota: overview.quota,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeBillingInvoice(invoice: {
|
||||
id: string;
|
||||
subscriptionId?: string;
|
||||
provider: string;
|
||||
providerInvoiceId?: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
amountCrypto: number;
|
||||
amountUsd?: number;
|
||||
paymentAddress?: string;
|
||||
expiresAt?: Date;
|
||||
paidAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: invoice.id,
|
||||
provider: invoice.provider,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
amountCrypto: invoice.amountCrypto,
|
||||
createdAt: invoice.createdAt.toISOString(),
|
||||
updatedAt: invoice.updatedAt.toISOString(),
|
||||
...(invoice.subscriptionId ? { subscriptionId: invoice.subscriptionId } : {}),
|
||||
...(invoice.providerInvoiceId ? { providerInvoiceId: invoice.providerInvoiceId } : {}),
|
||||
...(invoice.amountUsd !== undefined ? { amountUsd: invoice.amountUsd } : {}),
|
||||
...(invoice.paymentAddress ? { paymentAddress: invoice.paymentAddress } : {}),
|
||||
...(invoice.expiresAt ? { expiresAt: invoice.expiresAt.toISOString() } : {}),
|
||||
...(invoice.paidAt ? { paidAt: invoice.paidAt.toISOString() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeGenerationRequest(requestRecord: {
|
||||
id: string;
|
||||
userId: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
providerModel: string;
|
||||
prompt: string;
|
||||
sourceImageKey?: string;
|
||||
resolutionPreset: string;
|
||||
batchSize: number;
|
||||
imageStrength?: number;
|
||||
idempotencyKey?: string;
|
||||
terminalErrorCode?: string;
|
||||
terminalErrorText?: string;
|
||||
requestedAt: Date;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: requestRecord.id,
|
||||
userId: requestRecord.userId,
|
||||
mode: requestRecord.mode,
|
||||
status: requestRecord.status,
|
||||
providerModel: requestRecord.providerModel,
|
||||
prompt: requestRecord.prompt,
|
||||
resolutionPreset: requestRecord.resolutionPreset,
|
||||
batchSize: requestRecord.batchSize,
|
||||
requestedAt: requestRecord.requestedAt.toISOString(),
|
||||
createdAt: requestRecord.createdAt.toISOString(),
|
||||
updatedAt: requestRecord.updatedAt.toISOString(),
|
||||
...(requestRecord.sourceImageKey !== undefined
|
||||
? { sourceImageKey: requestRecord.sourceImageKey }
|
||||
: {}),
|
||||
...(requestRecord.imageStrength !== undefined
|
||||
? { imageStrength: requestRecord.imageStrength }
|
||||
: {}),
|
||||
...(requestRecord.idempotencyKey !== undefined
|
||||
? { idempotencyKey: requestRecord.idempotencyKey }
|
||||
: {}),
|
||||
...(requestRecord.terminalErrorCode !== undefined
|
||||
? { terminalErrorCode: requestRecord.terminalErrorCode }
|
||||
: {}),
|
||||
...(requestRecord.terminalErrorText !== undefined
|
||||
? { terminalErrorText: requestRecord.terminalErrorText }
|
||||
: {}),
|
||||
...(requestRecord.startedAt !== undefined
|
||||
? { startedAt: requestRecord.startedAt.toISOString() }
|
||||
: {}),
|
||||
...(requestRecord.completedAt !== undefined
|
||||
? { completedAt: requestRecord.completedAt.toISOString() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function requireAuthenticatedSession(request: IncomingMessage) {
|
||||
const sessionToken = readSessionToken(request);
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new HttpError(401, "unauthorized", "Missing authenticated session.");
|
||||
}
|
||||
|
||||
const authenticatedSession = await authStore.getUserBySessionToken(sessionToken);
|
||||
|
||||
if (!authenticatedSession) {
|
||||
throw new HttpError(401, "unauthorized", "Authenticated session is invalid.");
|
||||
}
|
||||
|
||||
return {
|
||||
...authenticatedSession,
|
||||
token: sessionToken,
|
||||
};
|
||||
}
|
||||
|
||||
function readSessionToken(request: IncomingMessage): string | null {
|
||||
const cookieHeader = request.headers.cookie;
|
||||
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const [rawName, ...rawValue] = part.trim().split("=");
|
||||
|
||||
if (rawName === sessionCookieName) {
|
||||
return decodeURIComponent(rawValue.join("="));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createSessionCookie(token: string, expiresAt: Date): string {
|
||||
return [
|
||||
`${sessionCookieName}=${encodeURIComponent(token)}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
"Secure",
|
||||
`Expires=${expiresAt.toUTCString()}`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function clearSessionCookie(): string {
|
||||
return [
|
||||
`${sessionCookieName}=`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
"Secure",
|
||||
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function handleRequestError(
|
||||
response: ServerResponse<IncomingMessage>,
|
||||
error: unknown,
|
||||
): void {
|
||||
if (error instanceof HttpError) {
|
||||
sendJson(response, error.statusCode, {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof AuthError) {
|
||||
const statusCode =
|
||||
error.code === "email_already_exists"
|
||||
? 409
|
||||
: error.code === "invalid_credentials"
|
||||
? 401
|
||||
: 400;
|
||||
|
||||
sendJson(response, statusCode, {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof GenerationRequestError) {
|
||||
const statusCode =
|
||||
error.code === "missing_active_subscription"
|
||||
? 403
|
||||
: error.code === "quota_exhausted"
|
||||
? 409
|
||||
: error.code === "request_not_found"
|
||||
? 404
|
||||
: error.code === "request_not_completable"
|
||||
? 409
|
||||
: 400;
|
||||
|
||||
sendJson(response, statusCode, {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendJson(response, 500, {
|
||||
error: {
|
||||
code: "internal_error",
|
||||
message: "Internal server error.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(
|
||||
response: ServerResponse<IncomingMessage>,
|
||||
statusCode: number,
|
||||
payload: unknown,
|
||||
setCookie?: string,
|
||||
): void {
|
||||
response.writeHead(statusCode, {
|
||||
"content-type": "application/json",
|
||||
...(setCookie ? { "set-cookie": setCookie } : {}),
|
||||
});
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
12
apps/web/tsconfig.json
Normal file
12
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
17
apps/worker/AGENTS.md
Normal file
17
apps/worker/AGENTS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
Applies within `apps/worker`.
|
||||
|
||||
## Responsibilities
|
||||
- generation job execution
|
||||
- payment reconciliation
|
||||
- media cleanup
|
||||
- provider-key balance polling
|
||||
- provider-key recovery checks
|
||||
- alert and reminder jobs
|
||||
|
||||
## Rules
|
||||
- Persist every provider-key attempt.
|
||||
- Consume quota only after a request succeeds.
|
||||
- Keep retries idempotent and auditable.
|
||||
3
apps/worker/README.md
Normal file
3
apps/worker/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# apps/worker
|
||||
|
||||
Planned worker runtime for queued and scheduled jobs.
|
||||
16
apps/worker/package.json
Normal file
16
apps/worker/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@nproxy/worker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nproxy/config": "workspace:*",
|
||||
"@nproxy/db": "workspace:*",
|
||||
"@nproxy/domain": "workspace:*",
|
||||
"@nproxy/providers": "workspace:*"
|
||||
}
|
||||
}
|
||||
0
apps/worker/src/.gitkeep
Normal file
0
apps/worker/src/.gitkeep
Normal file
150
apps/worker/src/main.ts
Normal file
150
apps/worker/src/main.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { loadConfig } from "@nproxy/config";
|
||||
import { createPrismaWorkerStore, prisma } from "@nproxy/db";
|
||||
import { createNanoBananaSimulatedAdapter } from "@nproxy/providers";
|
||||
|
||||
const config = loadConfig();
|
||||
const intervalMs = config.keyPool.balancePollSeconds * 1000;
|
||||
const workerStore = createPrismaWorkerStore(prisma, {
|
||||
cooldownMinutes: config.keyPool.cooldownMinutes,
|
||||
failuresBeforeManualReview: config.keyPool.failuresBeforeManualReview,
|
||||
});
|
||||
const nanoBananaAdapter = createNanoBananaSimulatedAdapter();
|
||||
let isTickRunning = false;
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
service: "worker",
|
||||
balancePollSeconds: config.keyPool.balancePollSeconds,
|
||||
providerModel: config.provider.nanoBananaDefaultModel,
|
||||
}),
|
||||
);
|
||||
|
||||
setInterval(() => {
|
||||
void runTick();
|
||||
}, intervalMs);
|
||||
|
||||
void runTick();
|
||||
|
||||
process.once("SIGTERM", async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.once("SIGINT", async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
async function runTick(): Promise<void> {
|
||||
if (isTickRunning) {
|
||||
console.log("worker tick skipped because previous tick is still running");
|
||||
return;
|
||||
}
|
||||
|
||||
isTickRunning = true;
|
||||
|
||||
try {
|
||||
const recovery = await workerStore.recoverCooldownProviderKeys();
|
||||
|
||||
if (recovery.recoveredCount > 0) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
service: "worker",
|
||||
event: "cooldown_keys_recovered",
|
||||
recoveredCount: recovery.recoveredCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const job = await workerStore.claimNextQueuedGenerationJob();
|
||||
|
||||
if (!job) {
|
||||
console.log(`worker heartbeat interval=${intervalMs} no_queued_jobs=true`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await workerStore.processClaimedGenerationJob(
|
||||
job,
|
||||
async (request, providerKey) => {
|
||||
if (providerKey.providerCode !== config.provider.nanoBananaDefaultModel) {
|
||||
return {
|
||||
ok: false as const,
|
||||
usedProxy: false,
|
||||
directFallbackUsed: false,
|
||||
failureKind: "unknown" as const,
|
||||
providerErrorCode: "unsupported_provider_model",
|
||||
providerErrorText: `Unsupported provider model: ${providerKey.providerCode}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (providerKey.proxyBaseUrl) {
|
||||
const proxyResult = await nanoBananaAdapter.executeGeneration({
|
||||
request,
|
||||
providerKey: {
|
||||
id: providerKey.id,
|
||||
providerCode: providerKey.providerCode,
|
||||
label: providerKey.label,
|
||||
apiKeyLastFour: providerKey.apiKeyLastFour,
|
||||
},
|
||||
route: {
|
||||
kind: "proxy",
|
||||
proxyBaseUrl: providerKey.proxyBaseUrl,
|
||||
},
|
||||
});
|
||||
|
||||
if (!proxyResult.ok && proxyResult.failureKind === "transport") {
|
||||
const directResult = await nanoBananaAdapter.executeGeneration({
|
||||
request,
|
||||
providerKey: {
|
||||
id: providerKey.id,
|
||||
providerCode: providerKey.providerCode,
|
||||
label: providerKey.label,
|
||||
apiKeyLastFour: providerKey.apiKeyLastFour,
|
||||
},
|
||||
route: {
|
||||
kind: "direct",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...directResult,
|
||||
usedProxy: true,
|
||||
directFallbackUsed: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...proxyResult,
|
||||
usedProxy: true,
|
||||
directFallbackUsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const directResult = await nanoBananaAdapter.executeGeneration({
|
||||
request,
|
||||
providerKey: {
|
||||
id: providerKey.id,
|
||||
providerCode: providerKey.providerCode,
|
||||
label: providerKey.label,
|
||||
apiKeyLastFour: providerKey.apiKeyLastFour,
|
||||
},
|
||||
route: {
|
||||
kind: "direct",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...directResult,
|
||||
usedProxy: false,
|
||||
directFallbackUsed: false,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({ service: "worker", event: "job_processed", ...result }));
|
||||
} catch (error) {
|
||||
console.error("worker tick failed", error);
|
||||
} finally {
|
||||
isTickRunning = false;
|
||||
}
|
||||
}
|
||||
12
apps/worker/tsconfig.json
Normal file
12
apps/worker/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user