fix: harden web runtime and follow-up auth/db security fixes (#21)

## Summary
- harden the web runtime with JSON body limits, stricter generation input validation, rate limiting, and trusted Origin/Referer checks for cookie-authenticated mutations
- redact password-reset tokens from debug email transport logs and fail closed for unsupported email providers
- scope generation idempotency keys per user with a Prisma migration and regression coverage

## Testing
- docker build -f infra/docker/web.Dockerfile -t nroxy-web-check .
- docker run --rm --entrypoint sh nroxy-web-check -lc "pnpm --filter @nproxy/providers test && pnpm --filter @nproxy/db test && pnpm --filter @nproxy/web test"

Closes #14
Closes #7
Closes #8

Co-authored-by: sirily <sirily@git.shararam.party>
Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
2026-03-11 16:28:56 +03:00
parent 9641678fa3
commit 1a7250467e
14 changed files with 924 additions and 202 deletions

View File

@@ -0,0 +1,9 @@
export class HttpError extends Error {
constructor(
readonly statusCode: number,
readonly code: string,
message: string,
) {
super(message);
}
}

View File

@@ -0,0 +1,118 @@
import assert from "node:assert/strict";
import type { IncomingMessage } from "node:http";
import test from "node:test";
import { HttpError } from "./http-error.js";
import {
InMemoryRateLimiter,
assertTrustedOrigin,
getRateLimitClientIp,
readJsonBody,
} from "./http-security.js";
test("readJsonBody parses JSON payloads within the configured limit", async () => {
const request = createMockRequest({
headers: {
"content-length": "17",
},
chunks: ['{"hello":"world"}'],
});
const body = await readJsonBody(request, { maxBytes: 64 });
assert.deepEqual(body, {
hello: "world",
});
});
test("readJsonBody rejects payloads larger than the configured limit", async () => {
const request = createMockRequest({
headers: {
"content-length": "128",
},
chunks: ['{"hello":"world"}'],
});
await assert.rejects(
readJsonBody(request, { maxBytes: 32 }),
(error: unknown) =>
error instanceof HttpError &&
error.statusCode === 413 &&
error.code === "payload_too_large",
);
});
test("InMemoryRateLimiter blocks after the configured request budget is exhausted", () => {
const limiter = new InMemoryRateLimiter();
const policy = {
id: "auth",
windowMs: 60_000,
maxRequests: 2,
};
assert.deepEqual(limiter.consume(policy, "ip:127.0.0.1", 1_000), { allowed: true });
assert.deepEqual(limiter.consume(policy, "ip:127.0.0.1", 2_000), { allowed: true });
const blocked = limiter.consume(policy, "ip:127.0.0.1", 3_000);
assert.equal(blocked.allowed, false);
assert.equal(blocked.retryAfterSeconds, 58);
});
test("InMemoryRateLimiter drops expired buckets during periodic sweeps", () => {
const limiter = new InMemoryRateLimiter();
const policy = {
id: "auth",
windowMs: 1_000,
maxRequests: 1,
};
assert.deepEqual(limiter.consume(policy, "stale", 1_000), { allowed: true });
for (let index = 0; index < 99; index += 1) {
limiter.consume(policy, `fresh-${index}`, 3_000);
}
assert.deepEqual(limiter.consume(policy, "stale", 3_000), { allowed: true });
});
test("assertTrustedOrigin accepts configured origins and requires browser origin metadata", () => {
const request = createMockRequest({
headers: {
origin: "https://app.nproxy.test",
"x-forwarded-for": "198.51.100.7, 198.51.100.8",
},
});
assert.doesNotThrow(() =>
assertTrustedOrigin(request, ["https://app.nproxy.test", "https://admin.nproxy.test"]),
);
assert.equal(getRateLimitClientIp(request), "198.51.100.7");
assert.throws(
() => assertTrustedOrigin(createMockRequest(), ["https://app.nproxy.test"]),
(error: unknown) =>
error instanceof HttpError &&
error.statusCode === 403 &&
error.code === "csrf_origin_required",
);
});
function createMockRequest(input?: {
headers?: IncomingMessage["headers"];
chunks?: Array<string | Uint8Array>;
remoteAddress?: string;
}): IncomingMessage {
const chunks = input?.chunks ?? [];
return {
headers: input?.headers ?? {},
socket: {
remoteAddress: input?.remoteAddress ?? "127.0.0.1",
},
async *[Symbol.asyncIterator]() {
for (const chunk of chunks) {
yield typeof chunk === "string" ? Buffer.from(chunk) : chunk;
}
},
} as IncomingMessage;
}

View File

@@ -0,0 +1,192 @@
import type { IncomingMessage } from "node:http";
import { HttpError } from "./http-error.js";
export interface JsonBodyOptions {
maxBytes: number;
}
export interface RateLimitPolicy {
id: string;
windowMs: number;
maxRequests: number;
}
export interface RateLimitDecision {
allowed: boolean;
retryAfterSeconds?: number;
}
interface RateLimitEntry {
count: number;
resetAt: number;
}
export async function readJsonBody(
request: IncomingMessage,
options: JsonBodyOptions,
): Promise<unknown> {
const declaredLength = readContentLength(request);
if (declaredLength !== null && declaredLength > options.maxBytes) {
throw new HttpError(
413,
"payload_too_large",
`Request body must not exceed ${options.maxBytes} bytes.`,
);
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
for await (const chunk of request) {
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
totalBytes += buffer.byteLength;
if (totalBytes > options.maxBytes) {
throw new HttpError(
413,
"payload_too_large",
`Request body must not exceed ${options.maxBytes} bytes.`,
);
}
chunks.push(buffer);
}
if (chunks.length === 0) {
throw new HttpError(400, "invalid_json", "Request body must not be empty.");
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
} catch {
throw new HttpError(400, "invalid_json", "Request body must be valid JSON.");
}
}
export class InMemoryRateLimiter {
private readonly entries = new Map<string, RateLimitEntry>();
private sweepCounter = 0;
consume(policy: RateLimitPolicy, key: string, now = Date.now()): RateLimitDecision {
this.sweepCounter += 1;
if (this.sweepCounter % 100 === 0) {
this.deleteExpiredEntries(now);
}
const bucketKey = `${policy.id}:${key}`;
const existing = this.entries.get(bucketKey);
if (!existing || existing.resetAt <= now) {
this.entries.set(bucketKey, {
count: 1,
resetAt: now + policy.windowMs,
});
return { allowed: true };
}
if (existing.count >= policy.maxRequests) {
return {
allowed: false,
retryAfterSeconds: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)),
};
}
existing.count += 1;
this.entries.set(bucketKey, existing);
return { allowed: true };
}
private deleteExpiredEntries(now: number): void {
for (const [bucketKey, entry] of this.entries.entries()) {
if (entry.resetAt <= now) {
this.entries.delete(bucketKey);
}
}
}
}
export function getRateLimitClientIp(request: IncomingMessage): string {
const forwardedFor = request.headers["x-forwarded-for"];
const headerValue = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
if (headerValue) {
const firstIp = headerValue
.split(",")
.map((part) => part.trim())
.find((part) => part.length > 0);
if (firstIp) {
return firstIp;
}
}
return request.socket.remoteAddress ?? "unknown";
}
export function assertTrustedOrigin(
request: IncomingMessage,
allowedOrigins: readonly string[],
): void {
const candidate = readRequestOrigin(request);
if (!candidate) {
throw new HttpError(
403,
"csrf_origin_required",
"State-changing requests must include an Origin or Referer header.",
);
}
if (!allowedOrigins.includes(candidate)) {
throw new HttpError(
403,
"csrf_origin_invalid",
"State-changing requests must originate from an allowed nproxy origin.",
);
}
}
function readRequestOrigin(request: IncomingMessage): string | null {
const originHeader = request.headers.origin;
const rawOrigin = Array.isArray(originHeader) ? originHeader[0] : originHeader;
if (rawOrigin) {
try {
return new URL(rawOrigin).origin;
} catch {
throw new HttpError(400, "invalid_origin", "Origin header must be a valid URL.");
}
}
const refererHeader = request.headers.referer;
const rawReferer = Array.isArray(refererHeader) ? refererHeader[0] : refererHeader;
if (!rawReferer) {
return null;
}
try {
return new URL(rawReferer).origin;
} catch {
throw new HttpError(400, "invalid_referer", "Referer header must be a valid URL.");
}
}
function readContentLength(request: IncomingMessage): number | null {
const contentLength = request.headers["content-length"];
const rawValue = Array.isArray(contentLength) ? contentLength[0] : contentLength;
if (!rawValue) {
return null;
}
const parsed = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new HttpError(400, "invalid_content_length", "Content-Length must be valid.");
}
return parsed;
}

View File

@@ -17,9 +17,23 @@ import {
AuthError,
GenerationRequestError,
createGenerationRequest,
type CreateGenerationRequestInput,
} from "@nproxy/domain";
import { serializePublicAccountOverview } from "./account-response.js";
import { HttpError } from "./http-error.js";
import {
InMemoryRateLimiter,
assertTrustedOrigin,
getRateLimitClientIp,
readJsonBody,
type RateLimitPolicy,
} from "./http-security.js";
import {
defaultGenerationRequestConstraints,
mapCreateGenerationRequestInput,
readAuthPayload,
readEmailOnlyPayload,
readPasswordResetConfirmPayload,
} from "./request-parsing.js";
const config = loadConfig();
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
@@ -30,6 +44,23 @@ const generationStore = createPrismaGenerationStore(prisma);
const emailTransport = createEmailTransport(config.email);
const paymentProviderAdapter = createPaymentProviderAdapter(config.payment);
const sessionCookieName = "nproxy_session";
const maxJsonBodyBytes = 16 * 1024;
const rateLimiter = new InMemoryRateLimiter();
const mutationAllowedOrigins = [config.urls.appBaseUrl.origin, config.urls.adminBaseUrl.origin];
const authRateLimitPolicy: RateLimitPolicy = {
id: "auth",
windowMs: 60_000,
maxRequests: 10,
};
const generationRateLimitPolicy: RateLimitPolicy = {
id: "generation",
windowMs: 60_000,
maxRequests: 30,
};
const generationRequestConstraints = {
...defaultGenerationRequestConstraints,
providerModels: [config.provider.nanoBananaDefaultModel],
};
const server = createServer(async (request, response) => {
try {
@@ -39,7 +70,9 @@ const server = createServer(async (request, response) => {
}
if (request.method === "POST" && request.url === "/api/auth/register") {
const body = await readJsonBody(request);
assertTrustedOrigin(request, mutationAllowedOrigins);
enforceRateLimit(request, authRateLimitPolicy);
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
const payload = readAuthPayload(body);
const session = await authStore.registerUser({
email: payload.email,
@@ -57,7 +90,9 @@ const server = createServer(async (request, response) => {
}
if (request.method === "POST" && request.url === "/api/auth/login") {
const body = await readJsonBody(request);
assertTrustedOrigin(request, mutationAllowedOrigins);
enforceRateLimit(request, authRateLimitPolicy);
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
const payload = readAuthPayload(body);
const session = await authStore.loginUser({
email: payload.email,
@@ -75,7 +110,9 @@ const server = createServer(async (request, response) => {
}
if (request.method === "POST" && request.url === "/api/auth/password-reset/request") {
const body = await readJsonBody(request);
assertTrustedOrigin(request, mutationAllowedOrigins);
enforceRateLimit(request, authRateLimitPolicy);
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
const payload = readEmailOnlyPayload(body);
const challenge = await authStore.createPasswordResetChallenge({
email: payload.email,
@@ -103,7 +140,9 @@ const server = createServer(async (request, response) => {
}
if (request.method === "POST" && request.url === "/api/auth/password-reset/confirm") {
const body = await readJsonBody(request);
assertTrustedOrigin(request, mutationAllowedOrigins);
enforceRateLimit(request, authRateLimitPolicy);
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
const payload = readPasswordResetConfirmPayload(body);
await authStore.resetPassword({
@@ -120,6 +159,7 @@ const server = createServer(async (request, response) => {
if (request.method === "POST" && request.url === "/api/auth/logout") {
const authenticatedSession = await requireAuthenticatedSession(request);
assertTrustedOrigin(request, mutationAllowedOrigins);
await authStore.revokeSession(authenticatedSession.token);
@@ -176,6 +216,7 @@ const server = createServer(async (request, response) => {
if (request.method === "POST" && request.url === "/api/billing/invoices") {
const authenticatedSession = await requireAuthenticatedSession(request);
assertTrustedOrigin(request, mutationAllowedOrigins);
const invoice = await billingStore.createSubscriptionInvoice({
userId: authenticatedSession.user.id,
paymentProvider: config.payment.provider,
@@ -189,6 +230,7 @@ const server = createServer(async (request, response) => {
if (request.method === "POST" && request.url === "/api/auth/logout-all") {
const authenticatedSession = await requireAuthenticatedSession(request);
assertTrustedOrigin(request, mutationAllowedOrigins);
const revokedCount = await authStore.revokeAllUserSessions({
userId: authenticatedSession.user.id,
exceptSessionId: authenticatedSession.session.id,
@@ -206,6 +248,7 @@ const server = createServer(async (request, response) => {
if (invoiceMarkPaidMatch) {
const authenticatedSession = await requireAuthenticatedSession(request);
assertTrustedOrigin(request, mutationAllowedOrigins);
if (!authenticatedSession.user.isAdmin) {
throw new HttpError(403, "forbidden", "Admin session is required.");
@@ -231,6 +274,7 @@ const server = createServer(async (request, response) => {
if (sessionMatch) {
const authenticatedSession = await requireAuthenticatedSession(request);
assertTrustedOrigin(request, mutationAllowedOrigins);
const sessionId = decodeURIComponent(sessionMatch[1] ?? "");
if (sessionId === authenticatedSession.session.id) {
@@ -261,8 +305,18 @@ const server = createServer(async (request, response) => {
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);
assertTrustedOrigin(request, mutationAllowedOrigins);
enforceRateLimit(
request,
generationRateLimitPolicy,
`user:${authenticatedSession.user.id}:${getRateLimitClientIp(request)}`,
);
const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes });
const requestInput = mapCreateGenerationRequestInput(
body,
authenticatedSession.user.id,
generationRequestConstraints,
);
const result = await createGenerationRequest(generationStore, requestInput);
sendJson(response, result.reusedExistingRequest ? 200 : 201, {
@@ -340,133 +394,6 @@ process.once("SIGINT", async () => {
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;
@@ -631,7 +558,7 @@ function createSessionCookie(token: string, expiresAt: Date): string {
`${sessionCookieName}=${encodeURIComponent(token)}`,
"Path=/",
"HttpOnly",
"SameSite=Lax",
"SameSite=Strict",
"Secure",
`Expires=${expiresAt.toUTCString()}`,
].join("; ");
@@ -642,7 +569,7 @@ function clearSessionCookie(): string {
`${sessionCookieName}=`,
"Path=/",
"HttpOnly",
"SameSite=Lax",
"SameSite=Strict",
"Secure",
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
].join("; ");
@@ -739,12 +666,18 @@ function sendJson(
response.end(JSON.stringify(payload));
}
class HttpError extends Error {
constructor(
readonly statusCode: number,
readonly code: string,
message: string,
) {
super(message);
function enforceRateLimit(
request: IncomingMessage,
policy: RateLimitPolicy,
key = getRateLimitClientIp(request),
): void {
const result = rateLimiter.consume(policy, key);
if (!result.allowed) {
throw new HttpError(
429,
"rate_limited",
`Retry after ${result.retryAfterSeconds ?? 1} seconds.`,
);
}
}

View File

@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import test from "node:test";
import { HttpError } from "./http-error.js";
import {
defaultGenerationRequestConstraints,
mapCreateGenerationRequestInput,
} from "./request-parsing.js";
const generationRequestConstraints = {
...defaultGenerationRequestConstraints,
providerModels: ["nano-banana"],
};
test("mapCreateGenerationRequestInput accepts supported generation options", () => {
const parsed = mapCreateGenerationRequestInput(
{
mode: "image_to_image",
providerModel: "nano-banana",
prompt: "sharpen this",
resolutionPreset: "1024",
batchSize: 2,
sourceImageKey: "uploads/source.png",
imageStrength: 0.4,
idempotencyKey: "req-1",
},
"user_1",
generationRequestConstraints,
);
assert.deepEqual(parsed, {
userId: "user_1",
mode: "image_to_image",
providerModel: "nano-banana",
prompt: "sharpen this",
resolutionPreset: "1024",
batchSize: 2,
sourceImageKey: "uploads/source.png",
imageStrength: 0.4,
idempotencyKey: "req-1",
});
});
test("mapCreateGenerationRequestInput rejects unsupported provider models", () => {
assert.throws(
() =>
mapCreateGenerationRequestInput(
{
mode: "text_to_image",
providerModel: "other-model",
prompt: "hello",
resolutionPreset: "1024",
batchSize: 1,
},
"user_1",
generationRequestConstraints,
),
(error: unknown) =>
error instanceof HttpError &&
error.statusCode === 400 &&
error.message === "providerModel must be one of: nano-banana.",
);
});
test("mapCreateGenerationRequestInput rejects unsupported resolution presets", () => {
assert.throws(
() =>
mapCreateGenerationRequestInput(
{
mode: "text_to_image",
providerModel: "nano-banana",
prompt: "hello",
resolutionPreset: "2048",
batchSize: 1,
},
"user_1",
generationRequestConstraints,
),
(error: unknown) =>
error instanceof HttpError &&
error.statusCode === 400 &&
error.message === "resolutionPreset must be one of: 1024.",
);
});
test("mapCreateGenerationRequestInput rejects batch sizes outside the allowed range", () => {
assert.throws(
() =>
mapCreateGenerationRequestInput(
{
mode: "text_to_image",
providerModel: "nano-banana",
prompt: "hello",
resolutionPreset: "1024",
batchSize: 5,
},
"user_1",
generationRequestConstraints,
),
(error: unknown) =>
error instanceof HttpError &&
error.statusCode === 400 &&
error.message === "batchSize must be between 1 and 4.",
);
});
test("mapCreateGenerationRequestInput rejects image strengths outside the supported range", () => {
assert.throws(
() =>
mapCreateGenerationRequestInput(
{
mode: "image_to_image",
providerModel: "nano-banana",
prompt: "hello",
resolutionPreset: "1024",
batchSize: 1,
sourceImageKey: "uploads/source.png",
imageStrength: 1.5,
},
"user_1",
generationRequestConstraints,
),
(error: unknown) =>
error instanceof HttpError &&
error.statusCode === 400 &&
error.message === "imageStrength must be between 0 and 1.",
);
});

View File

@@ -0,0 +1,184 @@
import type { CreateGenerationRequestInput } from "@nproxy/domain";
import { HttpError } from "./http-error.js";
export interface GenerationRequestConstraints {
providerModels: readonly string[];
resolutionPresets: readonly string[];
minBatchSize: number;
maxBatchSize: number;
minImageStrength: number;
maxImageStrength: number;
}
export const defaultGenerationRequestConstraints: Omit<
GenerationRequestConstraints,
"providerModels"
> = {
resolutionPresets: ["1024"],
minBatchSize: 1,
maxBatchSize: 4,
minImageStrength: 0,
maxImageStrength: 1,
};
export function readAuthPayload(body: unknown): { email: string; password: string } {
const payload = readObjectBody(body);
return {
email: readRequiredString(payload.email, "email"),
password: readRequiredString(payload.password, "password"),
};
}
export function readEmailOnlyPayload(body: unknown): { email: string } {
const payload = readObjectBody(body);
return {
email: readRequiredString(payload.email, "email"),
};
}
export function readPasswordResetConfirmPayload(
body: unknown,
): { token: string; password: string } {
const payload = readObjectBody(body);
return {
token: readRequiredString(payload.token, "token"),
password: readRequiredString(payload.password, "password"),
};
}
export function mapCreateGenerationRequestInput(
body: unknown,
userId: string,
constraints: GenerationRequestConstraints,
): CreateGenerationRequestInput {
const payload = readObjectBody(body);
return {
userId,
mode: readGenerationMode(payload.mode),
providerModel: readSupportedString(
payload.providerModel,
"providerModel",
constraints.providerModels,
),
prompt: readRequiredString(payload.prompt, "prompt"),
resolutionPreset: readSupportedString(
payload.resolutionPreset,
"resolutionPreset",
constraints.resolutionPresets,
),
batchSize: readBoundedInteger(
payload.batchSize,
"batchSize",
constraints.minBatchSize,
constraints.maxBatchSize,
),
...(payload.sourceImageKey !== undefined
? { sourceImageKey: readRequiredString(payload.sourceImageKey, "sourceImageKey") }
: {}),
...(payload.imageStrength !== undefined
? {
imageStrength: readBoundedNumber(
payload.imageStrength,
"imageStrength",
constraints.minImageStrength,
constraints.maxImageStrength,
),
}
: {}),
...(payload.idempotencyKey !== undefined
? { idempotencyKey: readRequiredString(payload.idempotencyKey, "idempotencyKey") }
: {}),
};
}
function readObjectBody(body: unknown): Record<string, unknown> {
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new HttpError(400, "invalid_body", "Request body must be a JSON object.");
}
return body as Record<string, unknown>;
}
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 readSupportedString(
value: unknown,
field: string,
supportedValues: readonly string[],
): string {
const parsed = readRequiredString(value, field);
if (!supportedValues.includes(parsed)) {
throw new HttpError(
400,
"invalid_field",
`${field} must be one of: ${supportedValues.join(", ")}.`,
);
}
return parsed;
}
function readBoundedInteger(
value: unknown,
field: string,
min: number,
max: number,
): number {
if (typeof value !== "number" || !Number.isInteger(value)) {
throw new HttpError(400, "invalid_field", `${field} must be an integer.`);
}
if (value < min || value > max) {
throw new HttpError(
400,
"invalid_field",
`${field} must be between ${min} and ${max}.`,
);
}
return value;
}
function readBoundedNumber(
value: unknown,
field: string,
min: number,
max: number,
): number {
if (typeof value !== "number" || Number.isNaN(value)) {
throw new HttpError(400, "invalid_field", `${field} must be a number.`);
}
if (value < min || value > max) {
throw new HttpError(
400,
"invalid_field",
`${field} must be between ${min} and ${max}.`,
);
}
return value;
}

View File

@@ -32,3 +32,8 @@
- User-caused provider failures are terminal for that request.
- Balance or quota exhaustion removes a key from active rotation.
- Provider-key state transitions must be audited.
## Web session posture
- Browser sessions use `Secure`, `HttpOnly`, `SameSite=Strict` cookies.
- State-changing cookie-authenticated endpoints accept requests only from the configured app/admin origins and require browser `Origin` or `Referer` metadata.
- The current API posture assumes a same-origin browser client. If cross-site embeds or third-party POST flows are introduced later, add an explicit CSRF token mechanism instead of relaxing the cookie/origin checks.

View File

@@ -23,6 +23,8 @@ Deploy on one VPS with Docker Compose.
- Keep secrets in server-side environment files or a secret manager.
- Back up PostgreSQL and object storage separately.
- Prefer Telegram long polling to avoid an extra public webhook surface for the bot.
- In non-production environments, set `EMAIL_PROVIDER=example` only when you explicitly want the built-in debug transport. It logs redacted email previews and must never emit live password-reset tokens.
- Do not rely on implicit email fallbacks. Unsupported providers now fail fast at startup so misconfigured deployments do not silently drop password-reset or billing mail.
## Upgrade strategy
- Build new images.

View File

@@ -0,0 +1,4 @@
DROP INDEX "GenerationRequest_idempotencyKey_key";
CREATE UNIQUE INDEX "GenerationRequest_userId_idempotencyKey_key"
ON "GenerationRequest"("userId", "idempotencyKey");

View File

@@ -187,7 +187,7 @@ model GenerationRequest {
resolutionPreset String
batchSize Int
imageStrength Decimal? @db.Decimal(4, 3)
idempotencyKey String? @unique
idempotencyKey String?
terminalErrorCode String?
terminalErrorText String?
requestedAt DateTime @default(now())
@@ -200,6 +200,7 @@ model GenerationRequest {
assets GeneratedAsset[]
usageLedgerEntry UsageLedgerEntry?
@@unique([userId, idempotencyKey])
@@index([userId, status, requestedAt])
}

View File

@@ -6,10 +6,12 @@ import { createPrismaGenerationStore } from "./generation-store.js";
test("createGenerationRequest rejects an expired active subscription and marks it expired", async () => {
const database = createGenerationDatabase({
subscription: createSubscriptionFixture({
status: "active",
currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"),
}),
subscriptions: [
createSubscriptionFixture({
status: "active",
currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"),
}),
],
});
const store = createPrismaGenerationStore(database.client);
@@ -29,11 +31,67 @@ test("createGenerationRequest rejects an expired active subscription and marks i
assert.equal(database.calls.subscriptionUpdateMany.length, 1);
assert.equal(database.calls.generationRequestCreate.length, 0);
assert.equal(database.state.subscription?.status, "expired");
assert.equal(database.state.subscriptions[0]?.status, "expired");
});
test("createGenerationRequest scopes idempotency-key reuse per user", async () => {
const database = createGenerationDatabase({
subscriptions: [
createSubscriptionFixture({
userId: "user_1",
status: "active",
currentPeriodEnd: new Date("2026-04-10T11:59:59.000Z"),
}),
createSubscriptionFixture({
id: "subscription_2",
userId: "user_2",
status: "active",
currentPeriodEnd: new Date("2026-04-10T11:59:59.000Z"),
}),
],
generationRequests: [
createGenerationRequestFixture({
id: "request_existing",
userId: "user_1",
idempotencyKey: "shared-key",
}),
],
});
const store = createPrismaGenerationStore(database.client);
const reused = await createGenerationRequest(store, {
userId: "user_1",
mode: "text_to_image",
providerModel: "nano-banana",
prompt: "hello",
resolutionPreset: "1024",
batchSize: 1,
idempotencyKey: "shared-key",
});
assert.equal(reused.reusedExistingRequest, true);
assert.equal(reused.request.id, "request_existing");
const created = await createGenerationRequest(store, {
userId: "user_2",
mode: "text_to_image",
providerModel: "nano-banana",
prompt: "hello",
resolutionPreset: "1024",
batchSize: 1,
idempotencyKey: "shared-key",
});
assert.equal(created.reusedExistingRequest, false);
assert.equal(created.request.userId, "user_2");
assert.equal(created.request.idempotencyKey, "shared-key");
assert.equal(database.calls.generationRequestCreate.length, 1);
assert.equal(database.state.generationRequests.length, 2);
});
function createGenerationDatabase(input: {
subscription: ReturnType<typeof createSubscriptionFixture> | null;
subscriptions: Array<ReturnType<typeof createSubscriptionFixture>>;
generationRequests?: Array<ReturnType<typeof createGenerationRequestFixture>>;
}) {
const calls = {
subscriptionUpdateMany: [] as Array<Record<string, unknown>>,
@@ -41,7 +99,8 @@ function createGenerationDatabase(input: {
};
const state = {
subscription: input.subscription,
subscriptions: [...input.subscriptions],
generationRequests: [...(input.generationRequests ?? [])],
};
const client = {
@@ -51,15 +110,19 @@ function createGenerationDatabase(input: {
}: {
where: { userId: string; status?: "active" };
}) => {
if (!state.subscription || state.subscription.userId !== where.userId) {
return null;
}
return (
state.subscriptions.find((subscription) => {
if (subscription.userId !== where.userId) {
return false;
}
if (where.status && state.subscription.status !== where.status) {
return null;
}
if (where.status && subscription.status !== where.status) {
return false;
}
return state.subscription;
return true;
}) ?? null
);
},
updateMany: async ({
where,
@@ -70,19 +133,21 @@ function createGenerationDatabase(input: {
}) => {
calls.subscriptionUpdateMany.push({ where, data });
if (
state.subscription &&
state.subscription.id === where.id &&
state.subscription.status === where.status
) {
state.subscription = {
...state.subscription,
let updatedCount = 0;
state.subscriptions = state.subscriptions.map((subscription) => {
if (subscription.id !== where.id || subscription.status !== where.status) {
return subscription;
}
updatedCount += 1;
return {
...subscription,
status: data.status,
};
return { count: 1 };
}
});
return { count: 0 };
return { count: updatedCount };
},
},
usageLedgerEntry: {
@@ -95,28 +160,37 @@ function createGenerationDatabase(input: {
generationRequest: {
create: async ({ data }: { data: Record<string, unknown> }) => {
calls.generationRequestCreate.push({ data });
return {
id: "request_1",
const request = createGenerationRequestFixture({
id: `request_${state.generationRequests.length + 1}`,
userId: data.userId as string,
mode: data.mode as string,
status: "queued",
providerModel: data.providerModel as string,
prompt: data.prompt as string,
sourceImageKey: null,
resolutionPreset: data.resolutionPreset as string,
batchSize: data.batchSize as number,
imageStrength: null,
idempotencyKey: null,
terminalErrorCode: null,
terminalErrorText: null,
requestedAt: new Date("2026-03-10T12:00:00.000Z"),
startedAt: null,
completedAt: null,
createdAt: new Date("2026-03-10T12:00:00.000Z"),
updatedAt: new Date("2026-03-10T12:00:00.000Z"),
};
...(data.sourceImageKey !== undefined
? { sourceImageKey: data.sourceImageKey as string }
: {}),
...(data.imageStrength !== undefined
? { imageStrength: data.imageStrength as Prisma.Decimal }
: {}),
...(data.idempotencyKey !== undefined
? { idempotencyKey: data.idempotencyKey as string }
: {}),
});
state.generationRequests.push(request);
return request;
},
findFirst: async () => null,
findFirst: async ({
where,
}: {
where: { userId: string; idempotencyKey: string };
}) =>
state.generationRequests.find(
(request) =>
request.userId === where.userId &&
request.idempotencyKey === where.idempotencyKey,
) ?? null,
},
} as unknown as Parameters<typeof createPrismaGenerationStore>[0];
@@ -128,12 +202,14 @@ function createGenerationDatabase(input: {
}
function createSubscriptionFixture(input: {
id?: string;
userId?: string;
status: "active" | "expired" | "past_due";
currentPeriodEnd: Date;
}) {
return {
id: "subscription_1",
userId: "user_1",
id: input.id ?? "subscription_1",
userId: input.userId ?? "user_1",
planId: "plan_1",
status: input.status,
renewsManually: true,
@@ -156,3 +232,38 @@ function createSubscriptionFixture(input: {
},
};
}
function createGenerationRequestFixture(input: {
id: string;
userId: string;
mode?: string;
status?: string;
providerModel?: string;
prompt?: string;
sourceImageKey?: string;
resolutionPreset?: string;
batchSize?: number;
imageStrength?: Prisma.Decimal;
idempotencyKey?: string;
}) {
return {
id: input.id,
userId: input.userId,
mode: input.mode ?? "text_to_image",
status: input.status ?? "queued",
providerModel: input.providerModel ?? "nano-banana",
prompt: input.prompt ?? "hello",
sourceImageKey: input.sourceImageKey ?? null,
resolutionPreset: input.resolutionPreset ?? "1024",
batchSize: input.batchSize ?? 1,
imageStrength: input.imageStrength ?? null,
idempotencyKey: input.idempotencyKey ?? null,
terminalErrorCode: null,
terminalErrorText: null,
requestedAt: new Date("2026-03-10T12:00:00.000Z"),
startedAt: null,
completedAt: null,
createdAt: new Date("2026-03-10T12:00:00.000Z"),
updatedAt: new Date("2026-03-10T12:00:00.000Z"),
};
}

View File

@@ -16,7 +16,9 @@
],
"scripts": {
"build": "tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit"
"check": "tsc -p tsconfig.json --noEmit",
"pretest": "pnpm build",
"test": "node --test dist/**/*.test.js"
},
"dependencies": {
"@nproxy/domain": "workspace:*"

View File

@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createEmailTransport } from "./email.js";
test("example email transport redacts password-reset tokens before logging", async () => {
const transport = createEmailTransport({
provider: "example",
from: "noreply@nproxy.test",
apiKey: "unused",
});
const logMessages: string[] = [];
const originalConsoleLog = console.log;
console.log = (message?: unknown) => {
logMessages.push(String(message ?? ""));
};
try {
await transport.send({
to: "user@example.com",
subject: "Reset your password",
text: "Reset link: https://app.nproxy.test/reset-password?token=secret-token-123",
});
} finally {
console.log = originalConsoleLog;
}
assert.equal(logMessages.length, 1);
assert.match(logMessages[0] ?? "", /"mode":"debug_redacted"/);
assert.match(logMessages[0] ?? "", /token=\[REDACTED\]/);
assert.doesNotMatch(logMessages[0] ?? "", /secret-token-123/);
});
test("unsupported email providers fail closed", () => {
assert.throws(
() =>
createEmailTransport({
provider: "smtp",
from: "noreply@nproxy.test",
apiKey: "unused",
}),
/Unsupported email provider: smtp/,
);
});

View File

@@ -20,29 +20,20 @@ export function createEmailTransport(config: {
JSON.stringify({
service: "email",
provider: config.provider,
mode: "debug_redacted",
from: config.from,
to: input.to,
subject: input.subject,
text: input.text,
textPreview: redactEmailText(input.text),
}),
);
},
};
}
return {
async send(input) {
console.log(
JSON.stringify({
service: "email",
provider: config.provider,
mode: "noop_fallback",
from: config.from,
to: input.to,
subject: input.subject,
text: input.text,
}),
);
},
};
throw new Error(`Unsupported email provider: ${config.provider}`);
}
function redactEmailText(text: string): string {
return text.replace(/([?&]token=)[^&\s]+/gi, "$1[REDACTED]");
}