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

Merged
sirily merged 4 commits from fix/api-runtime-security-controls into master 2026-03-11 16:28:56 +03:00
3 changed files with 158 additions and 42 deletions
Showing only changes of commit 0c05b091d0 - Show all commits

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({
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,
status: data.status,
};
return { count: 1 };
let updatedCount = 0;
state.subscriptions = state.subscriptions.map((subscription) => {
if (subscription.id !== where.id || subscription.status !== where.status) {
return subscription;
}
return { count: 0 };
updatedCount += 1;
return {
...subscription,
status: data.status,
};
});
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"),
};
}