Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/database/migrations/0003_add_idempotency_keys.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS "idempotency_keys" (
"key" varchar(64) PRIMARY KEY,
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"endpoint" varchar(255) NOT NULL,
"request_hash" varchar(64) NOT NULL,
"response_status" integer,
"response_body" jsonb,
"tx_hash" varchar(64),
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"expires_at" timestamp with time zone NOT NULL
);

CREATE INDEX IF NOT EXISTS "idx_idempotency_expires" ON "idempotency_keys" ("expires_at");
22 changes: 22 additions & 0 deletions src/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,25 @@ export const credentials = pgTable(
),
]
);

// ─── Idempotency Keys ─────────────────────────────────────────────────────

export const idempotencyKeys = pgTable(
"idempotency_keys",
{
key: varchar("key", { length: 64 }).primaryKey(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
endpoint: varchar("endpoint", { length: 255 }).notNull(),
requestHash: varchar("request_hash", { length: 64 }).notNull(),
responseStatus: integer("response_status"),
responseBody: jsonb("response_body"),
txHash: varchar("tx_hash", { length: 64 }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
},
(table) => [index("idx_idempotency_expires").on(table.expiresAt)]
);
26 changes: 26 additions & 0 deletions src/jobs/cleanup-idempotency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { cleanupIdempotencyKeys } from "../middleware/idempotency.js";
import { logger } from "../utils/logger.js";

let cleanupInterval: ReturnType<typeof setInterval> | null = null;

export function startIdempotencyCleanup(): void {
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour

cleanupInterval = setInterval(async () => {
try {
const deleted = await cleanupIdempotencyKeys();
if (deleted > 0) {
logger.info({ deleted }, "Cleaned up expired idempotency keys");
}
} catch (err) {
logger.error({ err }, "Failed to clean up idempotency keys");
}
}, CLEANUP_INTERVAL_MS);
}

export function stopIdempotencyCleanup(): void {
if (cleanupInterval) {
clearInterval(cleanupInterval);
cleanupInterval = null;
}
}
66 changes: 66 additions & 0 deletions src/middleware/idempotency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { db } from "../config/database.js";
import { idempotencyKeys } from "../database/schema.js";
import { eq, and, lt } from "drizzle-orm";

Check warning on line 3 in src/middleware/idempotency.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

'and' is defined but never used. Allowed unused vars must match /^_/u
import { sha256Hash } from "../utils/crypto.js";
import { ConflictError } from "../utils/errors.js";

export async function checkIdempotency(
key: string,
userId: string,
endpoint: string,
requestBody: unknown
): Promise<{ cached: boolean; response?: { status: number; body: unknown } }> {
const requestHash = sha256Hash(JSON.stringify(requestBody));

const existing = await db.query.idempotencyKeys.findFirst({
where: eq(idempotencyKeys.key, key),
});

if (existing) {
if (existing.requestHash === requestHash && existing.responseBody) {
return {
cached: true,
response: {
status: existing.responseStatus ?? 200,
body: existing.responseBody,
},
};
}
throw new ConflictError("Idempotency key reused with different request body");
}

await db.insert(idempotencyKeys).values({
key,
userId,
endpoint,
requestHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});

return { cached: false };
}

export async function storeIdempotentResponse(
key: string,
status: number,
body: unknown,
txHash?: string
): Promise<void> {
await db
.update(idempotencyKeys)
.set({
responseStatus: status,
responseBody: body,
txHash: txHash ?? null,
})
.where(eq(idempotencyKeys.key, key));
}

export async function cleanupIdempotencyKeys(): Promise<number> {
const result = await db
.delete(idempotencyKeys)
.where(lt(idempotencyKeys.expiresAt, new Date()))
.returning({ key: idempotencyKeys.key });

return result.length;
}
50 changes: 45 additions & 5 deletions src/modules/credentials/credential.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { FastifyRequest, FastifyReply } from "fastify";
import { credentialService } from "./credential.service.js";
import type { AuthenticatedRequest } from "../../middleware/auth.js";
import type { MintCredentialBody } from "./credential.types.js";
import {
checkIdempotency,
storeIdempotentResponse,
} from "../../middleware/idempotency.js";

export class CredentialController {
/**
Expand All @@ -13,14 +17,50 @@ export class CredentialController {
reply: FastifyReply
): Promise<void> {
const { authUser } = request as AuthenticatedRequest;
const { courseId, submissionId } = (request as any).validatedBody;
const result = await credentialService.mint(
const { courseId, submissionId, idempotencyKey } = (request as any).validatedBody;

const { cached, response } = await checkIdempotency(
idempotencyKey,
authUser.id,
courseId,
submissionId
"/credentials/mint",
request.body
);

reply.status(201).send({ success: true, data: result });
if (cached) {
reply.status(response!.status).send(response!.body);
return;
}

try {
const result = await credentialService.mint(
authUser.id,
courseId,
submissionId
);

await storeIdempotentResponse(
idempotencyKey,
201,
{ success: true, data: result },
result.mintTxHash
);

reply.status(201).send({ success: true, data: result });
} catch (err: unknown) {
const statusCode =
err && typeof err === "object" && "statusCode" in err
? (err as { statusCode: number }).statusCode
: 500;
const message =
err instanceof Error ? err.message : "Internal server error";

await storeIdempotentResponse(idempotencyKey, statusCode, {
success: false,
error: message,
});

throw err;
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/modules/credentials/credential.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { z } from "zod";
export const mintCredentialSchema = z.object({
courseId: z.string().uuid("Invalid course ID"),
submissionId: z.string().uuid("Invalid submission ID"),
idempotencyKey: z.string().min(16).max(64),
});

// ─── Types ──────────────────────────────────────────────────────────────────
Expand Down
46 changes: 43 additions & 3 deletions src/modules/rewards/reward.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { FastifyRequest, FastifyReply } from "fastify";
import { rewardService } from "./reward.service.js";
import type { AuthenticatedRequest } from "../../middleware/auth.js";
import type { ClaimRewardBody } from "./reward.types.js";
import {
checkIdempotency,
storeIdempotentResponse,
} from "../../middleware/idempotency.js";

export class RewardController {
/**
Expand All @@ -13,10 +17,46 @@ export class RewardController {
reply: FastifyReply
): Promise<void> {
const { authUser } = request as AuthenticatedRequest;
const { submissionId } = (request as any).validatedBody;
const result = await rewardService.claimReward(authUser.id, submissionId);
const { submissionId, idempotencyKey } = (request as any).validatedBody;

reply.send({ success: true, data: result });
const { cached, response } = await checkIdempotency(
idempotencyKey,
authUser.id,
"/rewards/claim",
request.body
);

if (cached) {
reply.status(response!.status).send(response!.body);
return;
}

try {
const result = await rewardService.claimReward(authUser.id, submissionId);

await storeIdempotentResponse(
idempotencyKey,
200,
{ success: true, data: result },
result.txHash ?? undefined
);

reply.send({ success: true, data: result });
} catch (err: unknown) {
const statusCode =
err && typeof err === "object" && "statusCode" in err
? (err as { statusCode: number }).statusCode
: 500;
const message =
err instanceof Error ? err.message : "Internal server error";

await storeIdempotentResponse(idempotencyKey, statusCode, {
success: false,
error: message,
});

throw err;
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/modules/rewards/reward.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from "zod";

export const claimRewardSchema = z.object({
submissionId: z.string().uuid("Invalid submission ID"),
idempotencyKey: z.string().min(16).max(64),
});

// ─── Types ──────────────────────────────────────────────────────────────────
Expand Down
6 changes: 6 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {
stopRetryProcessor,
type RetryJob,
} from "./services/retry-queue.js";
import {
startIdempotencyCleanup,
stopIdempotencyCleanup,
} from "./jobs/cleanup-idempotency.js";
import { processRewardClaim } from "./modules/rewards/reward.service.js";
import { warmCourseCache } from "./cache/warmer.js";

Expand Down Expand Up @@ -161,6 +165,7 @@ async function start() {
const app = await buildApp();

startRetryProcessor(processRetryJob);
startIdempotencyCleanup();

try {
await warmCourseCache();
Expand All @@ -177,6 +182,7 @@ async function start() {
const shutdown = async (signal: string) => {
logger.info({ signal }, "Received shutdown signal");
stopRetryProcessor();
stopIdempotencyCleanup();
await app.close();
await closeDatabase();
await closeRedis();
Expand Down
22 changes: 22 additions & 0 deletions tests/e2e/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ describe("Credentials API", () => {
payload: {
courseId: "00000000-0000-0000-0000-000000000001",
submissionId: "00000000-0000-0000-0000-000000000002",
idempotencyKey: "test-key-credentials-mint-unauth",
},
});

Expand All @@ -77,6 +78,7 @@ describe("Credentials API", () => {
payload: {
courseId: "00000000-0000-0000-0000-000000000001",
submissionId: "00000000-0000-0000-0000-000000000001",
idempotencyKey: "test-key-credentials-mint-valid",
},
});

Expand All @@ -101,6 +103,7 @@ describe("Credentials API", () => {
payload: {
courseId: "00000000-0000-0000-0000-000000000001",
submissionId: "00000000-0000-0000-0000-000000000099",
idempotencyKey: "test-key-credentials-mint-notpassed",
},
});

Expand All @@ -123,6 +126,7 @@ describe("Credentials API", () => {
payload: {
courseId: "00000000-0000-0000-0000-000000000001",
submissionId: "00000000-0000-0000-0000-000000000001",
idempotencyKey: "test-key-credentials-mint-001",
},
});

Expand All @@ -135,6 +139,7 @@ describe("Credentials API", () => {
payload: {
courseId: "00000000-0000-0000-0000-000000000001",
submissionId: "00000000-0000-0000-0000-000000000001",
idempotencyKey: "test-key-credentials-mint-002",
},
});

Expand All @@ -145,5 +150,22 @@ describe("Credentials API", () => {
}
}
});

it("should reject request without idempotency key", async () => {
const token = createToken();

const response = await app.inject({
method: "POST",
url: "/api/credentials/mint",
headers: { authorization: `Bearer ${token}` },
payload: {
courseId: "00000000-0000-0000-0000-000000000001",
submissionId: "00000000-0000-0000-0000-000000000002",
},
});

// Auth may reject (401) or validation may reject missing idempotencyKey (400)
expect([400, 401]).toContain(response.statusCode);
});
});
});
Loading
Loading