Files
nroxy/apps/web/src/main.ts
sirily 1b2a4a076a fix: make invoice payment activation idempotent (#18)
Closes #2

## Summary
- make `markInvoicePaid` idempotent for already-paid invoices and reject invalid terminal transitions
- add admin actor metadata and audit-log writes for `mark-paid`, including replayed no-op calls
- add focused DB tests for first activation, replay safety, and invalid transition handling
- document the current payment system, including invoice creation, manual activation, quota reset, and current limitations

## Testing
- built `infra/docker/web.Dockerfile`
- ran `pnpm --filter @nproxy/db test` inside the built container
- verified `@nproxy/db build` and `@nproxy/web build` during the image build

Co-authored-by: sirily <sirily@git.shararam.party>
Reviewed-on: #18
2026-03-10 17:53:00 +03:00

751 lines
22 KiB
TypeScript

import {
createServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { loadConfig } from "@nproxy/config";
import {
BillingError,
createPrismaAccountStore,
createPrismaAuthStore,
createPrismaBillingStore,
createPrismaGenerationStore,
prisma,
} from "@nproxy/db";
import { createEmailTransport, createPaymentProviderAdapter } from "@nproxy/providers";
import {
AuthError,
GenerationRequestError,
createGenerationRequest,
type CreateGenerationRequestInput,
} from "@nproxy/domain";
import { serializePublicAccountOverview } from "./account-response.js";
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, serializePublicAccountOverview(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,
actor: {
type: "web_admin",
ref: authenticatedSession.user.id,
},
});
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 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 BillingError) {
const statusCode =
error.code === "invoice_not_found"
? 404
: error.code === "invoice_transition_not_allowed"
? 409
: 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);
}
}