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:
9
apps/web/src/http-error.ts
Normal file
9
apps/web/src/http-error.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
118
apps/web/src/http-security.test.ts
Normal file
118
apps/web/src/http-security.test.ts
Normal 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;
|
||||
}
|
||||
192
apps/web/src/http-security.ts
Normal file
192
apps/web/src/http-security.ts
Normal 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;
|
||||
}
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
127
apps/web/src/request-parsing.test.ts
Normal file
127
apps/web/src/request-parsing.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
184
apps/web/src/request-parsing.ts
Normal file
184
apps/web/src/request-parsing.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX "GenerationRequest_idempotencyKey_key";
|
||||
|
||||
CREATE UNIQUE INDEX "GenerationRequest_userId_idempotencyKey_key"
|
||||
ON "GenerationRequest"("userId", "idempotencyKey");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
43
packages/providers/src/email.test.ts
Normal file
43
packages/providers/src/email.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
@@ -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]");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user