Skip to content
Open
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
9 changes: 7 additions & 2 deletions apps/api/src/controllers/paymentController.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { ok } from "../utils/response.js";
import { ok, fail } from "../utils/response.js";
import { createPaymentIntent } from "../services/paymentService.js";
import { createPaymentSchema } from "../validators/payment.js";

export async function createPayment(req, res) {
return ok(res, await createPaymentIntent(req.body), 201);
const result = createPaymentSchema.safeParse(req.body);
if (!result.success) {
return fail(res, result.error.issues.map(i => i.message).join("; "), 400);
}
return ok(res, await createPaymentIntent(result.data), 201);
}
5 changes: 3 additions & 2 deletions apps/api/src/services/authService.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { signAccessToken } from "../utils/jwt.js";

export async function registerUser(payload) {
// TODO: persist new user via Prisma
const id = `usr_${Date.now()}`;
return {
id: `usr_${Date.now()}`,
id,
email: payload.email,
role: payload.role,
token: signAccessToken({ sub: `usr_${Date.now()}`, role: payload.role })
token: signAccessToken({ sub: id, role: payload.role })
};
}

Expand Down
51 changes: 51 additions & 0 deletions apps/api/src/tests/authService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import { registerUser } from "../services/authService.js";
import { verifyAccessToken } from "../utils/jwt.js";

test("registerUser returns matching user id and token sub", async () => {
const user = await registerUser({ email: "test@example.com", role: "client" });

const decoded = verifyAccessToken(user.token);

assert.equal(user.id, decoded.sub, "token sub must equal the returned user id");
});

test("registerUser id matches token sub even when system clock advances between calls (regression: #2845)", async () => {
// Simulate the scenario where Date.now() returns different values on each call.
// Before the fix, the id used one timestamp and the JWT sub used another,
// causing a mismatch. After the fix, Date.now() is called only once and the
// stored id is reused for the JWT sub claim.
//
// We mock Date.now() to return `base` for the FIRST call (used by registerUser
// to generate the id) and a LATER timestamp for all subsequent calls (used
// internally by jsonwebtoken to set iat/exp). Even with clock drift between
// these calls, the id must match the token's sub because the fix stores the
// id in a local variable.
const originalDateNow = Date.now;
const base = Date.now();
let firstCall = true;

Date.now = () => {
if (firstCall) {
firstCall = false;
return base;
}
// Return a later time for JWT internal timestamps (iat/exp).
// This simulates the clock advancing between the id generation and
// the token signing that happened in the buggy version.
return base + 50_000;
};

try {
const user = await registerUser({ email: "test@example.com", role: "client" });
const decoded = verifyAccessToken(user.token);

// The id and sub both use the stored `id` variable, so they must match
// even though Date.now() returned different values.
assert.equal(user.id, decoded.sub, "token sub must match user id even if clock advances between calls");
assert.equal(user.id, `usr_${base}`, "user id must use the first timestamp");
} finally {
Date.now = originalDateNow;
}
});
8 changes: 8 additions & 0 deletions apps/api/src/validators/payment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { z } from "zod";

export const createPaymentSchema = z.object({
amount: z.number().positive("Amount must be positive").max(999999, "Amount too large"),
currency: z.enum(["usd", "eur", "gbp", "cny"]).default("usd"),
description: z.string().max(500).optional(),
metadata: z.record(z.string()).optional(),
});
Loading