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
751 lines
22 KiB
TypeScript
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);
|
|
}
|
|
}
|