Lock the Chat API Contract for React

Build a stable chat API contract for a React client.

Introduction

30 Second Summary

Chat feels effortless when each action behaves predictably. Surprises behind the screen force the app to guess what happened.

In this project, you will turn your existing chat backend into a stable API contract for an upcoming React client. Focused integration tests will lock those promises in before frontend work begins.

What You'll Build

Your focused Jest suite finishes with every React-facing contract passing, so the client can rely on the backend without workarounds.

By the end of this project, you'll have:

  • A safe user search where a signed-in client finds another username without receiving email addresses or password hashes.
  • A reliable direct chat that returns the same conversation when React repeats a creation request.
  • A focused regression suite that proves one message shape across REST, Socket.IO, and history. The suite confirms bounded server-side validation before database work begins. Repeated valid refreshes keep the session active. Replayed old tokens are detected.
  • Secret Mission: Write a one-page frontend contract covering cookies, routes, payloads, errors, and Socket.IO events for the React project.

Are there any prerequisites?

You need the completed Parts 1 and 2 chat API repository running locally on Windows. No new tools or paid accounts are required.

Before We Start

The Windows-based chat-api repository from Parts 1 and 2 already has its Node.js dependencies, Docker Desktop, PostgreSQL, Cursor, Windows PowerShell, authentication, conversations, messaging, read receipts, Socket.IO, and Jest ready. This checkpoint commits you to turning that foundation into a stable API contract that the future React client can trust.

Protect the Working Baseline

Contract changes reach across several working backend paths. One small edit can disturb behavior the future React client already depends on.

A clean Git branch creates a safe rollback point. A recorded Jest result separates existing behavior from failures introduced later.

In this step, get ready to:
  • Confirm the repository starts from a clean working tree.
  • Create the frontend-contract branch without changing dependencies.
  • Capture the existing Jest suite result as the baseline.
Check the repository state

Uncommitted changes blur the line between earlier work and this contract pass. The short status view reveals every path that needs attention.

  • Return to the Windows PowerShell panel for your existing chat-api repository.
  • Inspect the working tree by running this command:
git status --short

What does this check show?

Git lists paths with pending changes. The --short option keeps that result compact.

A blank result means the working tree is clean. Listed paths belong to work that has not reached a commit.

PowerShell should return no paths when the repository is clean. Any listed path needs review before you create the branch.

  • Review each listed path in the Cursor window from earlier if the command shows output.
  • Commit every intentional pre-existing change with your usual Git workflow.
  • Repeat the same status check after committing your changes.

PowerShell should now return no paths. Your existing work is safely recorded.

Still seeing changed files?

  • Check whether each listed path belongs to work you want to keep.
  • Commit intentional work before creating the contract branch.
  • Help me understand the remaining Git changes.
Create the contract branch

A dedicated branch isolates the contract changes from the known working state. It gives you a clear rollback boundary for the whole project.

  • Create the contract branch by running this command:
git switch -c frontend-contract

What does this command do?

The -c option asks Git to create frontend-contract. Git switches the repository to that branch in the same operation.

Future contract edits now stay isolated from the branch that contains your working Part 2 backend.

Your rollback boundary is in place. PowerShell confirms the branch was created.

Does the branch already exist?

An earlier attempt may have created the intended branch. Pause before changing that existing branch.

Help me handle the existing branch safely.

This contract pass uses the dependencies already locked by Parts 1 and 2. Keeping the lockfile untouched prevents dependency drift from affecting your baseline.

  • Switch back to the Cursor window from earlier.
  • Select package-lock.json from the chat-api file tree.
  • Confirm Express remains locked at 5.2.1.
  • Confirm Jest remains locked at 30.4.2.
  • Return to the PowerShell panel from earlier.
  • Repeat the short status check from the first substep.

PowerShell should return no listed paths. This confirms package-lock.json remains unchanged.

Why skip installation?

The repository already contains every package this contract pass needs. Avoiding an installation keeps this step focused on backend behavior.

The unchanged lockfile also makes later test differences easier to trace.

Record the baseline test result

The existing suite uses the local PostgreSQL environment already running through Docker Desktop. Its current result becomes the reference for every contract test you add later.

Before you run the suite, do you expect the backend to pass cleanly or reveal a pre-existing test-data issue?

  • Capture the baseline suite result by running this command:
npm test

What does this command run?

The npm command runs the test script already defined by the repository. That script starts Jest with the project’s existing test settings.

No application code or dependency files change during this check.

Jest prints the baseline suite result with suite totals and test totals. A pass or a known data-dependent failure now gives you an honest reference point.

Seeing a rerun failure from old users?

Fixed test users from an earlier run can collide with rows still stored in the development database. Keep that observation separate from failures introduced by this project.

  • Capture the rerun failure in the text checkpoint below.
  • Keep the existing development data unchanged.
  • Help me classify this baseline test failure.

Baseline captured. You now have a trustworthy reference for every contract change.

Your working backend now has a clean rollback point and a recorded test baseline. Next, you’ll write one focused test that exposes the missing user-discovery contract.

Expose the Missing Contract

Your clean frontend-contract branch protects the working baseline. The recorded Jest result gives you a known point of comparison.

The upcoming React client needs a safe way to find another user before starting a conversation. The current API contract has no authenticated user-search route that provides this capability.

In this step, get ready to:
  • Create unique test users for the focused contract suite.
  • Define the authenticated user-search response with safe fields.
  • Run the focused test to expose the missing route.
Create unique test users

A focused integration test needs fresh accounts on every run. Unique usernames prevent previous development data from changing the result.

You will assemble one test suite in three small chunks. The final chunk closes the suite before you run it.

  • Switch back to Cursor with the existing chat-api repository.
  • Create frontendContract.test.js inside the existing __tests__ folder by using the file creation control in the sidebar.
  • Add the imports plus the unique user definitions by pasting this first chunk:
const { randomUUID } = require("node:crypto");
const request = require("supertest");
const {
  app,
  createTestUser,
  loginUser,
  getAuthCookie,
} = require("./setup");

describe("Frontend contract", () => {
  const runId = randomUUID().replace(/-/g, "").slice(0, 8);
  const userA = {
    email: `contract-a-${runId}@test.com`,
    username: `contractA_${runId}`,
    password: "Password123!",
  };
  const userB = {
    email: `contract-b-${runId}@test.com`,
    username: `contractB_${runId}`,
    password: "Password123!",
  };

  let userACookies;
  let userBId;

What does this setup define?

  • The randomUUID function creates a unique suffix for each test run.
  • The userA object represents the authenticated searcher.
  • The userB object represents the user that should appear in search results.
  • The remaining variables store user A's cookies plus user B's generated ID.
  • Save __tests__/frontendContract.test.js.
  • Confirm frontendContract.test.js is listed inside __tests__ in the sidebar.

Is the test file in the wrong folder?

Confirm the file path is __tests__/frontendContract.test.js inside the existing chat-api repository.

Move the file under __tests__ if it appears beside that folder. Help me place this Jest test in the correct folder.

The shared setup helpers already know how to create users plus authenticate requests. Reusing them keeps this test focused on the missing frontend contract.

  • Add the setup hook below the userBId declaration by pasting this chunk:
  beforeAll(async () => {
    await createTestUser(userA.email, userA.username, userA.password);
    await createTestUser(userB.email, userB.username, userB.password);

    const loginA = await loginUser(userA.email, userA.password);
    const loginB = await loginUser(userB.email, userB.password);

    userACookies = loginA.cookies;
    userBId = loginB.body.data.user.id;
  });

How does the test authenticate?

  • The setup hook creates both unique accounts before the test starts.
  • The first login supplies the cookie used to authenticate the search request.
  • The second login exposes the generated ID that the response must match.
  • Confirm the setup hook sits below the variable declarations inside describe("Frontend contract").

Do the setup helpers look unresolved?

Check that the import path remains ./setup. The existing helpers live beside this focused test.

Compare the imported names with createTestUser, loginUser, and getAuthCookie. Help me debug these existing test setup imports.

Define the safe search response

The test uses SuperTest to send an authenticated request through the existing Express application. Its assertions describe the exact user summary React can trust.

A safe match contains an ID plus a username plus an avatar URL. Private account fields stay outside the response.

  • Add the search test below the setup hook by pasting this final chunk:
  test("searches for another user without exposing private fields", async () => {
    const response = await request(app)
      .get(`/api/users/search?q=${userB.username.slice(0, 9)}`)
      .set("Cookie", getAuthCookie(userACookies));

    expect(response.status).toBe(200);
    expect(response.body.success).toBe(true);

    const match = response.body.data.users.find(
      (user) => user.id === userBId,
    );
    expect(match).toEqual({
      id: userBId,
      username: userB.username,
      avatar_url: null,
    });
    expect(match).not.toHaveProperty("email");
    expect(match).not.toHaveProperty("password_hash");
  });
});

What contract does this test lock?

  • The request targets GET /api/users/search?q=... through the exported Express application.
  • The cookie proves that user search requires authentication.
  • The expected match contains id, username, plus avatar_url.
  • The final assertions prevent email or password_hash from leaking into the frontend response.
  • Confirm the file ends with one closing line for the test plus one closing line for the surrounding suite.

Seeing incomplete syntax highlighting?

Check that every opening brace from the three chunks has a matching closing brace. The last two lines close the test plus the surrounding suite.

Keep all setup variables plus the test inside describe("Frontend contract"). Help me find an unmatched brace in this test file.

✔️ Awesome, I've got everything!

Your focused contract file now defines unique users plus one authenticated search test. Save the file before running it.

ⓧ I'd like to double check the full code

Compare __tests__/frontendContract.test.js with this complete version for the current step.

const { randomUUID } = require("node:crypto");
const request = require("supertest");
const {
  app,
  createTestUser,
  loginUser,
  getAuthCookie,
} = require("./setup");

describe("Frontend contract", () => {
  const runId = randomUUID().replace(/-/g, "").slice(0, 8);
  const userA = {
    email: `contract-a-${runId}@test.com`,
    username: `contractA_${runId}`,
    password: "Password123!",
  };
  const userB = {
    email: `contract-b-${runId}@test.com`,
    username: `contractB_${runId}`,
    password: "Password123!",
  };

  let userACookies;
  let userBId;

  beforeAll(async () => {
    await createTestUser(userA.email, userA.username, userA.password);
    await createTestUser(userB.email, userB.username, userB.password);

    const loginA = await loginUser(userA.email, userA.password);
    const loginB = await loginUser(userB.email, userB.password);

    userACookies = loginA.cookies;
    userBId = loginB.body.data.user.id;
  });

  test("searches for another user without exposing private fields", async () => {
    const response = await request(app)
      .get(`/api/users/search?q=${userB.username.slice(0, 9)}`)
      .set("Cookie", getAuthCookie(userACookies));

    expect(response.status).toBe(200);
    expect(response.body.success).toBe(true);

    const match = response.body.data.users.find(
      (user) => user.id === userBId,
    );
    expect(match).toEqual({
      id: userBId,
      username: userB.username,
      avatar_url: null,
    });
    expect(match).not.toHaveProperty("email");
    expect(match).not.toHaveProperty("password_hash");
  });
});
Run the focused contract test

The focused command isolates this new contract from the baseline suite. Its result shows whether the backend can already satisfy the user-search expectations.

  • Save __tests__/frontendContract.test.js.
  • Switch back to Windows PowerShell from the baseline check.

Before you run the test, do you expect the user-search expectations to pass or expose the missing route?

  • Run only the focused frontend contract test by entering this command:
npm test -- --runTestsByPath __tests__/frontendContract.test.js

This failure proves the gap

Jest reaches the new user-search contract. The test fails because the authenticated user-search route is missing.

This planned failure gives the next implementation step a precise target. React needs a successful response containing only the safe user summary.

Did the test stop before the search assertion?

  • Confirm Docker Desktop still has your local PostgreSQL container running.
  • Confirm PowerShell is still inside the existing chat-api repository.
  • Confirm __tests__/frontendContract.test.js is saved before rerunning the command.
  • Help me separate an environment failure from the expected missing-route failure.

You have turned a vague frontend blocker into a repeatable failing contract test. Next, you will add the smallest authenticated user-discovery path that makes this test pass.

Add User Discovery

The focused Jest test from the last step has isolated the missing user-discovery contract. Its planned 404 proves the React client has no safe search route to call.

You will validate each search term before the database query runs. You will then expose an authenticated route that returns bounded user summaries.

In this step, get ready to:
  • Add reusable validation helpers for the frontend contract.
  • Create a protected endpoint for username searches.
  • Pass the focused user-discovery integration test.
Add reusable request validation

Server-side validation rejects malformed input before it reaches a query. A shared validation module also gives later conversation and message routes the same boundaries.

  • In Cursor, select src/middleware/validate.js from the Explorer sidebar.
  • Place this UUID pattern above exports.validateRegistration:
const UUID_PATTERN =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

What does this pattern do?

The pattern describes the UUID format accepted by the API. Keeping it at the top lets several request validators reuse one definition.

  • Find the closing brace of exports.validateLogin.
  • Add these user-search helpers directly below it:
exports.isUuid = (value) =>
  typeof value === "string" && UUID_PATTERN.test(value);

exports.validateUserSearch = (query) => {
  const value = typeof query === "string" ? query.trim() : "";

  if (value.length < 2 || value.length > 30) {
    return { error: "Search must be between 2 and 30 characters" };
  }

  if (!/^[a-zA-Z0-9_]+$/.test(value)) {
    return { error: "Search can only contain letters, numbers, and underscores" };
  }

  return { value };
};

How is search input bounded?

  • The isUuid helper accepts string values that match the shared UUID pattern.
  • The validateUserSearch helper trims the query before checking it.
  • Search terms must contain between 2 and 30 characters.
  • The allowlist permits letters, numbers, and underscores.
  • Add the message validator directly below validateUserSearch:
exports.validateMessageInput = (body = {}) => {
  const content = typeof body.content === "string" ? body.content.trim() : "";
  const messageType = body.messageType || "text";

  if (content.length === 0 || content.length > 2000) {
    return { error: "Message must be between 1 and 2000 characters" };
  }

  if (messageType !== "text") {
    return { error: "Only text messages are supported" };
  }

  return { value: { content, messageType } };
};

What does the message validator protect?

The helper trims message content before enforcing its length. It also restricts the message type to the single format supported by this contract.

  • Save src/middleware/validate.js.
  • Check that the updated validation module still loads by running the focused test:
npm test -- --runTestsByPath __tests__/frontendContract.test.js

What should the test prove now?

Jest should still reach the planned missing-route failure. Reaching that assertion confirms the new validation code loaded without a syntax failure.

Seeing a validation syntax failure?

  • Check that UUID_PATTERN appears before every helper that uses it.
  • Confirm that each helper ends with its closing brace.
  • Ask for help with the exact line reported by Jest: Help me diagnose the syntax error in my validation module.
  • Add the pagination parser below validateMessageInput:
exports.parseMessagePagination = (query = {}) => {
  const limit = query.limit === undefined ? 50 : Number(query.limit);

  if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
    return { error: "limit must be an integer between 1 and 100" };
  }

  if (
    query.before !== undefined &&
    (typeof query.before !== "string" || Number.isNaN(Date.parse(query.before)))
  ) {
    return { error: "before must be a valid date-time string" };
  }

  return { value: { limit, before: query.before } };
};

How does pagination stay bounded?

  • The parser uses 50 when the request omits a limit.
  • A supplied limit must be an integer between 1 and 100.
  • An optional before value must contain a date-time string that JavaScript can parse.
  • Save src/middleware/validate.js.
  • Confirm that the complete validation module loads by rerunning the focused test:
npm test -- --runTestsByPath __tests__/frontendContract.test.js

What does this check establish?

The focused test should continue to reach the missing user-search route. That result confirms the complete validation module loads while the original contract gap remains isolated.

Create the protected search route

The controller turns a validated prefix into a parameterized PostgreSQL query. Its selected columns define the safe user summary that the frontend receives.

  • Select the src/controllers folder in Cursor's Explorer sidebar.
  • Create userController.js with the New File control.
  • Paste this first section into src/controllers/userController.js:
const { pool } = require("../config/db");
const { validateUserSearch } = require("../middleware/validate");

exports.searchUsers = async (req, res) => {
  const validation = validateUserSearch(req.query.q);

  if (validation.error) {
    return res.status(400).json({
      success: false,
      error: { message: validation.error, code: "VALIDATION_ERROR" },
    });
  }

  try {
    const result = await pool.query(
      `SELECT id, username, avatar_url
       FROM users
       WHERE id <> $1
         AND username ILIKE $2
       ORDER BY username ASC
       LIMIT 10`,
      [req.user.id, `${validation.value}%`],
    );

How does the query protect the contract?

  • Invalid searches receive a 400 response before the query runs.
  • The query selects only id, username, and avatar_url.
  • The authenticated user's ID is excluded from the results.
  • The ILIKE prefix match is case-insensitive.
  • The result limit keeps each search response to at most 10 users.
  • Continue directly below the open query with this response handling:
    return res.json({
      success: true,
      data: { users: result.rows },
    });
  } catch (error) {
    console.error("User search error:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Failed to search users", code: "SERVER_ERROR" },
    });
  }
};

How does the controller respond?

A successful search wraps the rows inside data.users. An unexpected database failure returns the shared server-error shape.

  • Save src/controllers/userController.js.
  • Select the src/routes folder in the Explorer sidebar.
  • Create users.js with the New File control.
  • Paste the protected router into src/routes/users.js:
const express = require("express");
const { authenticate } = require("../middleware/auth");
const userController = require("../controllers/userController");

const router = express.Router();

router.get("/search", authenticate, userController.searchUsers);

module.exports = router;

What does this router enforce?

The authenticate middleware runs before searchUsers. The controller therefore receives the signed-in user's ID through req.user.id.

  • Save src/routes/users.js.

The controller and router now define the endpoint. The server still needs a mount point before Express can match the request.

Before you rerun the test, do you expect Express to discover a router that has no mount point?

  • Check the current wiring state by running the focused test:
npm test -- --runTestsByPath __tests__/frontendContract.test.js

What should happen at this point?

Jest should still reach the planned 404. The controller exists now, so the remaining gap is the server mount.

Does the server fail before the assertion?

  • Check the relative import paths in src/controllers/userController.js.
  • Confirm that src/routes/users.js exports router.
  • Share the first Jest error with the assistant: Help me debug why my new user controller or router prevents the Jest app from loading.
Mount the route and prove the contract

A router becomes reachable when the server mounts it beneath a path prefix. Combining /api/users with /search creates the endpoint already used by the focused test.

  • Select src/server.js from Cursor's Explorer sidebar.
  • Find the messageRoutes import.
  • Add the userRoutes import directly below it:
const userRoutes = require("./routes/users");

Why import the router here?

The import gives the server access to the router exported from src/routes/users.js. The next edit assigns its URL prefix.

  • Find the app.use("/api/auth", authRoutes); line.
  • Add the user-route mount directly below it:
app.use("/api/users", userRoutes);

How does the final path form?

The server prefix joins the router's search path. Requests to GET /api/users/search?q=... now reach the authenticated controller.

  • Save src/server.js.

Before you run the final check, do you expect the authenticated search request to find the matching test user now?

  • Verify the complete user-discovery contract by running the focused test:
npm test -- --runTestsByPath __tests__/frontendContract.test.js

What should Jest confirm?

Jest should mark the focused user-search test as passed. The response should contain the matching user's id, username, and avatar_url without exposing private account fields.

That planned failure is resolved. React now has an authenticated discovery route with a stable safe-user shape.

Is the user-search test still failing?

  • Confirm that userRoutes is imported before the server uses it.
  • Check that the mount path is exactly /api/users.
  • Check that the router path is exactly /search.
  • Ask for targeted help with the failed assertion: Help me trace my authenticated user-search request from server mount to controller response.

✔️ Awesome, I've got everything

Your focused Jest test passes. Save every updated file before moving on.

ⓧ I'd like to double check the full code

Compare each updated backend file with these complete versions.

const UUID_PATTERN =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

exports.validateRegistration = (email, username, password) => {
  const errors = [];

  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.push("Valid email is required");
  }

  if (
    !username ||
    username.length < 3 ||
    username.length > 30 ||
    !/^[a-zA-Z0-9_]+$/.test(username)
  ) {
    errors.push("Username must be 3-30 alphanumeric characters");
  }

  if (!password || password.length < 8) {
    errors.push("Password must be at least 8 characters");
  }

  return errors;
};

exports.validateLogin = (email, password) => {
  const errors = [];

  if (!email) errors.push("Email is required");
  if (!password) errors.push("Password is required");

  return errors;
};

exports.isUuid = (value) =>
  typeof value === "string" && UUID_PATTERN.test(value);

exports.validateUserSearch = (query) => {
  const value = typeof query === "string" ? query.trim() : "";

  if (value.length < 2 || value.length > 30) {
    return { error: "Search must be between 2 and 30 characters" };
  }

  if (!/^[a-zA-Z0-9_]+$/.test(value)) {
    return { error: "Search can only contain letters, numbers, and underscores" };
  }

  return { value };
};

exports.validateMessageInput = (body = {}) => {
  const content = typeof body.content === "string" ? body.content.trim() : "";
  const messageType = body.messageType || "text";

  if (content.length === 0 || content.length > 2000) {
    return { error: "Message must be between 1 and 2000 characters" };
  }

  if (messageType !== "text") {
    return { error: "Only text messages are supported" };
  }

  return { value: { content, messageType } };
};

exports.parseMessagePagination = (query = {}) => {
  const limit = query.limit === undefined ? 50 : Number(query.limit);

  if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
    return { error: "limit must be an integer between 1 and 100" };
  }

  if (
    query.before !== undefined &&
    (typeof query.before !== "string" || Number.isNaN(Date.parse(query.before)))
  ) {
    return { error: "before must be a valid date-time string" };
  }

  return { value: { limit, before: query.before } };
};

What does the validation module own?

This file now centralizes the UUID, search, message, and pagination boundaries used by the frontend contract.

const { pool } = require("../config/db");
const { validateUserSearch } = require("../middleware/validate");

exports.searchUsers = async (req, res) => {
  const validation = validateUserSearch(req.query.q);

  if (validation.error) {
    return res.status(400).json({
      success: false,
      error: { message: validation.error, code: "VALIDATION_ERROR" },
    });
  }

  try {
    const result = await pool.query(
      `SELECT id, username, avatar_url
       FROM users
       WHERE id <> $1
         AND username ILIKE $2
       ORDER BY username ASC
       LIMIT 10`,
      [req.user.id, `${validation.value}%`],
    );

    return res.json({
      success: true,
      data: { users: result.rows },
    });
  } catch (error) {
    console.error("User search error:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Failed to search users", code: "SERVER_ERROR" },
    });
  }
};

What does the user controller own?

This controller validates the prefix and returns up to 10 safe user summaries. It excludes the authenticated user from the result.

const express = require("express");
const { authenticate } = require("../middleware/auth");
const userController = require("../controllers/userController");

const router = express.Router();

router.get("/search", authenticate, userController.searchUsers);

module.exports = router;

What does the users router own?

This router protects the search endpoint with the existing authentication middleware. It delegates valid authenticated requests to the user controller.

require("dotenv").config();
const express = require("express");
const { createServer } = require("node:http");
const { Server } = require("socket.io");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const authRoutes = require("./routes/auth");
const conversationRoutes = require("./routes/conversations");
const messageRoutes = require("./routes/messages");
const userRoutes = require("./routes/users");
const { pool } = require("./config/db");
const { setupSocket } = require("./socket");

const app = express();
const httpServer = createServer(app);
const PORT = process.env.PORT || 5000;

const corsOptions = {
  origin: process.env.CLIENT_URL || "http://localhost:3000",
  credentials: true,
};

app.use(cors(corsOptions));
app.use(express.json());
app.use(cookieParser());
app.use(express.static("public"));

app.get("/api/health", (req, res) => {
  res.json({ success: true, message: "Server is running" });
});

app.use("/api/auth", authRoutes);
app.use("/api/users", userRoutes);
app.use("/api/conversations", conversationRoutes);
app.use("/api/messages", messageRoutes);

const io = new Server(httpServer, {
  cors: corsOptions,
});

app.set("io", io);
setupSocket(io);

if (process.env.NODE_ENV !== "test") {
  httpServer.listen(PORT, async () => {
    try {
      const client = await pool.connect();
      console.log("Connected to PostgreSQL database");
      client.release();
    } catch (error) {
      console.error("Error connecting to PostgreSQL database:", error);
    }
    console.log(`Server is running on port ${PORT}`);
  });
}

module.exports = { app, httpServer, io };

What changed in the server?

The server imports the users router and mounts it after the authentication routes. The existing middleware, Socket.IO setup, database connection, and test exports remain unchanged.

Your backend can now discover another user without leaking private fields. Next, you will give each pair of users one reliable direct-conversation identity.

Make Conversations Deterministic

Your authenticated search route now gives React a safe way to discover another user. The next request creates the conversation that connects them.

Repeated clicks or network retries can currently create duplicate direct-message threads. A stable API contract needs one reliable conversation identity for each pair of users.

In this step, get ready to:
  • Add a database uniqueness guard for direct conversations.
  • Return one conversation summary shape from creation and listing.
  • Prove repeated requests reuse the same conversation.
Add the database uniqueness guard

A unique partial index lets PostgreSQL enforce one non-null direct key per user pair. The nullable column leaves group conversations outside this rule.

Why use a unique direct key?

An application-level lookup can race when two requests search before either request inserts a row. The unique index remains the final authority when requests arrive at the same time.

Sorting both user IDs creates the same key regardless of which user starts the conversation. That gives every pair one deterministic database identity.

  • In Cursor's Explorer sidebar, create a folder named migrations inside src/config.
  • Create 001_frontend_contract.sql inside src/config/migrations.
  • Paste the migration into src/config/migrations/001_frontend_contract.sql:
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS direct_key TEXT;

CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_direct_key
ON conversations (direct_key)
WHERE direct_key IS NOT NULL;

What does this migration do?

  • The direct_key column stores the sorted identity of a direct-message pair.
  • The IF NOT EXISTS clauses make repeated migration runs safe.
  • The WHERE direct_key IS NOT NULL predicate applies uniqueness only to keyed direct conversations.
  • Save src/config/migrations/001_frontend_contract.sql.
  • Confirm Cursor lists 001_frontend_contract.sql inside src/config/migrations.
  • Create a scripts folder in chat-api if that folder is not already listed.
  • Create migrateFrontendContract.js inside scripts.
  • Paste the migration runner into scripts/migrateFrontendContract.js:
require("dotenv").config();

const fs = require("node:fs");
const path = require("node:path");
const { pool } = require("../src/config/db");

async function migrate() {
  const migrationPath = path.join(
    __dirname,
    "..",
    "src",
    "config",
    "migrations",
    "001_frontend_contract.sql",
  );
  const sql = fs.readFileSync(migrationPath, "utf8");

  await pool.query(sql);
  console.log("Frontend contract migration complete");
}

migrate()
  .catch((error) => {
    console.error("Frontend contract migration failed:", error);
    process.exitCode = 1;
  })
  .finally(async () => {
    await pool.end();
  });

What does this runner do?

  • The runner loads the existing environment configuration before connecting to the database.
  • The migrationPath value points to the SQL file from any supported launch location.
  • The runner reads the SQL as text before passing it to the existing connection pool.
  • The finally block closes the pool after success or failure.
  • Save scripts/migrateFrontendContract.js.
  • Return to the PowerShell terminal from the previous step.
  • Apply the additive migration by running this command:
node scripts/migrateFrontendContract.js

What should I see?

PowerShell prints Frontend contract migration complete. This confirms the column and partial unique index now exist in your local database.

Good progress. Your database can now reject competing attempts to claim the same direct-conversation key.

Migration did not complete?

  • Confirm Docker Desktop still has the existing PostgreSQL container running.
  • Check that 001_frontend_contract.sql sits inside src/config/migrations.
  • Confirm the terminal is still using the local chat-api repository.
  • Ask for help with the migration output: Help me diagnose why my frontend contract migration failed.

✔️ The migration completed

Your additive migration is saved. Your local database now has the uniqueness guard required by the conversation controller.

ⓧ I'd like to double check the files

Compare your migration with this complete file.

ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS direct_key TEXT;

CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_direct_key
ON conversations (direct_key)
WHERE direct_key IS NOT NULL;

What should match?

The file adds one nullable column. It also creates one unique partial index for non-null direct keys.

Compare your runner with this complete file.

require("dotenv").config();

const fs = require("node:fs");
const path = require("node:path");
const { pool } = require("../src/config/db");

async function migrate() {
  const migrationPath = path.join(
    __dirname,
    "..",
    "src",
    "config",
    "migrations",
    "001_frontend_contract.sql",
  );
  const sql = fs.readFileSync(migrationPath, "utf8");

  await pool.query(sql);
  console.log("Frontend contract migration complete");
}

migrate()
  .catch((error) => {
    console.error("Frontend contract migration failed:", error);
    process.exitCode = 1;
  })
  .finally(async () => {
    await pool.end();
  });

What should match?

The runner resolves the migration path. It executes the SQL through the shared pool. It closes that pool when the attempt finishes.

Return one conversation summary

A database transaction keeps user verification and conversation creation inside one unit of work. The controller can reuse an existing direct conversation without exposing a different response shape.

How does legacy adoption work?

Existing direct conversations do not have a key because they predate this migration. The controller searches for an older two-person match before inserting anything new.

The oldest matching conversation receives the sorted key. Existing rows remain intact while future requests converge on the same conversation ID.

  • Update src/controllers/conversationController.js using one of the paths below.

✔️ I'll try it first

Use the contract requirements below to update the controller. Switch to the worked solution if you want to compare your implementation.

  • Import isUuid from the validation module.
  • Define MAX_GROUP_PARTICIPANTS as 50.
  • Define conversationSummarySql with participants and the latest message.
  • Validate participant IDs before starting database work.
  • Create or reuse direct conversations inside one transaction.
  • Use the shared summary query for creation and listing.
  • Emit conversation:created only when a conversation is new.

ⓧ Let me check my work

Compare your controller with this complete implementation.

const { pool } = require("../config/db");
const { isUuid } = require("../middleware/validate");

const MAX_GROUP_PARTICIPANTS = 50;

const conversationSummarySql = `
  SELECT
    c.id,
    c.title,
    c.is_group,
    c.created_at,
    c.updated_at,
    COALESCE(
      (
        SELECT json_agg(
          json_build_object(
            'id', u.id,
            'username', u.username,
            'avatar_url', u.avatar_url
          )
          ORDER BY u.username ASC
        )
        FROM participants p
        JOIN users u ON u.id = p.user_id
        WHERE p.conversation_id = c.id
      ),
      '[]'::json
    ) AS participants,
    (
      SELECT json_build_object(
        'id', m.id,
        'conversation_id', m.conversation_id,
        'sender_id', m.sender_id,
        'content', m.content,
        'message_type', m.message_type,
        'created_at', m.created_at,
        'edited_at', m.edited_at,
        'sender', json_build_object(
          'id', sender.id,
          'username', sender.username,
          'avatar_url', sender.avatar_url
        )
      )
      FROM messages m
      JOIN users sender ON sender.id = m.sender_id
      WHERE m.conversation_id = c.id
      ORDER BY m.created_at DESC, m.id DESC
      LIMIT 1
    ) AS last_message
  FROM conversations c
`;

async function getConversationSummary(db, conversationId) {
  const result = await db.query(
    `${conversationSummarySql} WHERE c.id = $1`,
    [conversationId],
  );
  return result.rows[0];
}

function validationError(res, message) {
  return res.status(400).json({
    success: false,
    error: { message, code: "VALIDATION_ERROR" },
  });
}

exports.createConversation = async (req, res) => {
  const { title, participantIds, isGroup } = req.body || {};
  const userId = req.user.id;

  if (typeof isGroup !== "boolean") {
    return validationError(res, "isGroup must be a boolean");
  }

  if (!Array.isArray(participantIds) || participantIds.length === 0) {
    return validationError(res, "participantIds must be a non-empty array");
  }

  if (!participantIds.every(isUuid)) {
    return validationError(res, "Every participant ID must be a valid UUID");
  }

  const allParticipants = [...new Set([userId, ...participantIds])];

  if (!isGroup && allParticipants.length !== 2) {
    return validationError(
      res,
      "Direct conversations must have exactly two participants",
    );
  }

  const cleanTitle = typeof title === "string" ? title.trim() : "";
  if (isGroup && (cleanTitle.length === 0 || cleanTitle.length > 100)) {
    return validationError(
      res,
      "Group titles must be between 1 and 100 characters",
    );
  }

  if (isGroup && allParticipants.length > MAX_GROUP_PARTICIPANTS) {
    return validationError(
      res,
      `Groups can contain at most ${MAX_GROUP_PARTICIPANTS} participants`,
    );
  }

  const client = await pool.connect();

  try {
    await client.query("BEGIN");

    const usersResult = await client.query(
      "SELECT id FROM users WHERE id = ANY($1::uuid[])",
      [allParticipants],
    );

    if (usersResult.rows.length !== allParticipants.length) {
      await client.query("ROLLBACK");
      return res.status(404).json({
        success: false,
        error: { message: "One or more participants do not exist", code: "USER_NOT_FOUND" },
      });
    }

    let conversationId;
    let created = true;

    if (isGroup) {
      const groupResult = await client.query(
        `INSERT INTO conversations (title, is_group)
         VALUES ($1, TRUE)
         RETURNING id`,
        [cleanTitle],
      );
      conversationId = groupResult.rows[0].id;
    } else {
      const directKey = [...allParticipants].sort().join(":");

      const keyedResult = await client.query(
        "SELECT id FROM conversations WHERE direct_key = $1",
        [directKey],
      );

      if (keyedResult.rows.length > 0) {
        conversationId = keyedResult.rows[0].id;
        created = false;
      } else {
        const legacyResult = await client.query(
          `SELECT c.id
           FROM conversations c
           JOIN participants p ON p.conversation_id = c.id
           WHERE c.is_group = FALSE
           GROUP BY c.id
           HAVING COUNT(*) = 2
              AND COUNT(*) FILTER (
                WHERE p.user_id = ANY($1::uuid[])
              ) = 2
           ORDER BY MIN(c.created_at) ASC
           LIMIT 1`,
          [allParticipants],
        );

        if (legacyResult.rows.length > 0) {
          conversationId = legacyResult.rows[0].id;
          await client.query(
            `UPDATE conversations
             SET direct_key = $1
             WHERE id = $2 AND direct_key IS NULL`,
            [directKey, conversationId],
          );
          created = false;
        } else {
          const insertResult = await client.query(
            `INSERT INTO conversations (title, is_group, direct_key)
             VALUES (NULL, FALSE, $1)
             ON CONFLICT DO NOTHING
             RETURNING id`,
            [directKey],
          );

          if (insertResult.rows.length > 0) {
            conversationId = insertResult.rows[0].id;
          } else {
            const concurrentResult = await client.query(
              "SELECT id FROM conversations WHERE direct_key = $1",
              [directKey],
            );
            conversationId = concurrentResult.rows[0].id;
            created = false;
          }
        }
      }
    }

    if (created) {
      await client.query(
        `INSERT INTO participants (conversation_id, user_id)
         SELECT $1, participant_id
         FROM unnest($2::uuid[]) AS participant_id`,
        [conversationId, allParticipants],
      );
    }

    const conversation = await getConversationSummary(client, conversationId);
    await client.query("COMMIT");

    if (created) {
      const io = req.app.get("io");
      for (const participantId of allParticipants) {
        const sockets = await io.in(`user:${participantId}`).fetchSockets();
        for (const socket of sockets) {
          await socket.join(`conversation:${conversationId}`);
        }
        io.to(`user:${participantId}`).emit(
          "conversation:created",
          conversation,
        );
      }
    }

    return res.status(created ? 201 : 200).json({
      success: true,
      data: conversation,
    });
  } catch (error) {
    await client.query("ROLLBACK");
    console.error("Error creating conversation:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Failed to create conversation", code: "SERVER_ERROR" },
    });
  } finally {
    client.release();
  }
};

exports.getUserConversations = async (req, res) => {
  try {
    const result = await pool.query(
      `${conversationSummarySql}
       WHERE EXISTS (
         SELECT 1
         FROM participants current_participant
         WHERE current_participant.conversation_id = c.id
           AND current_participant.user_id = $1
       )
       ORDER BY c.updated_at DESC, c.id DESC`,
      [req.user.id],
    );

    return res.json({ success: true, data: result.rows });
  } catch (error) {
    console.error("Error fetching conversations:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Failed to fetch conversations", code: "SERVER_ERROR" },
    });
  }
};

What does this controller guarantee?

  • Validation rejects malformed participant data before opening the transaction.
  • The sorted directKey gives the same pair the same lookup value.
  • The unique index resolves concurrent insert attempts without creating another conversation.
  • The shared query returns matching participant and latest-message fields from creation and listing.
  • Save src/controllers/conversationController.js.
  • Confirm the existing search contract still passes by running the focused suite:
npm test -- --runTestsByPath __tests__/frontendContract.test.js

What should I see?

Jest reports the existing user-search contract as passing. This confirms the controller replacement did not disturb the working discovery route.

Did the focused suite stop passing?

  • Confirm every template string in conversationSummarySql has its closing backtick.
  • Check that isUuid is imported from ../middleware/validate.
  • Compare the transaction's opening and closing calls with the worked solution.
  • Ask for help with the first failing result: Help me debug my deterministic conversation controller.
Prove repeat requests reuse the conversation

A focused Jest assertion turns idempotency into a regression contract. The test sends the same participant pair twice before comparing status codes and conversation IDs.

  • In __tests__/frontendContract.test.js, replace the setup declarations and beforeAll block with this version:
  let userACookies;
  let userAId;
  let userBId;
  let conversationId;
  let firstConversationResponse;

  beforeAll(async () => {
    await createTestUser(userA.email, userA.username, userA.password);
    await createTestUser(userB.email, userB.username, userB.password);

    const loginA = await loginUser(userA.email, userA.password);
    const loginB = await loginUser(userB.email, userB.password);

    userACookies = loginA.cookies;
    userAId = loginA.body.data.user.id;
    userBId = loginB.body.data.user.id;

    firstConversationResponse = await request(app)
      .post("/api/conversations")
      .set("Cookie", getAuthCookie(userACookies))
      .send({ participantIds: [userBId], isGroup: false });

    conversationId = firstConversationResponse.body.data?.id;
  });

What does this setup prepare?

  • Unique users keep repeated local test runs independent from earlier records.
  • The authenticated request stores the first response for later status and shape checks.
  • The returned conversation ID becomes the expected identity for the repeated request.
  • Add this deterministic-conversation test below the existing user-search test:
  test("reuses the same direct conversation and summary shape", async () => {
    const repeatedResponse = await request(app)
      .post("/api/conversations")
      .set("Cookie", getAuthCookie(userACookies))
      .send({ participantIds: [userBId], isGroup: false });

    expect(firstConversationResponse.status).toBe(201);
    expect(repeatedResponse.status).toBe(200);
    expect(repeatedResponse.body.data.id).toBe(conversationId);
    expect(repeatedResponse.body.data).toEqual(
      expect.objectContaining({
        id: conversationId,
        title: null,
        is_group: false,
        participants: expect.any(Array),
        last_message: null,
      }),
    );
  });

What does this test prove?

  • The first request must return 201 because it creates the conversation.
  • The repeated request must return 200 because it reuses the existing conversation.
  • Both responses must point to the same conversation ID.
  • The repeated response must expose the frontend summary fields used by the conversation list.

Before you run the focused suite, do you expect the second request to create another row or reuse the first conversation?

  • Save __tests__/frontendContract.test.js.
  • Run the focused contract suite:
npm test -- --runTestsByPath __tests__/frontendContract.test.js

What should I see?

Jest reports both focused contract tests as passing. The first direct-conversation request returns 201. The repeated request returns 200 with the same ID.

That is the duplicate-thread risk closed. React can now treat a user pair as one stable conversation.

Is the repeated request failing?

  • Confirm the migration completed before this test created its first conversation.
  • Check that directKey sorts allParticipants before joining the IDs.
  • Confirm the reused path sets created to false.
  • Ask for help with the failed assertion: Help me debug why repeated direct-conversation requests differ.

✔️ Both contract tests pass

Your search contract still passes. Your deterministic-conversation test now protects the new identity and response-shape guarantees.

ⓧ I'd like to double check the full test

Compare your focused test file with this cumulative version.

const { randomUUID } = require("node:crypto");
const request = require("supertest");
const {
  app,
  createTestUser,
  loginUser,
  getAuthCookie,
} = require("./setup");

describe("Frontend contract", () => {
  const runId = randomUUID().replace(/-/g, "").slice(0, 8);
  const userA = {
    email: `contract-a-${runId}@test.com`,
    username: `contractA_${runId}`,
    password: "Password123!",
  };
  const userB = {
    email: `contract-b-${runId}@test.com`,
    username: `contractB_${runId}`,
    password: "Password123!",
  };

  let userACookies;
  let userAId;
  let userBId;
  let conversationId;
  let firstConversationResponse;

  beforeAll(async () => {
    await createTestUser(userA.email, userA.username, userA.password);
    await createTestUser(userB.email, userB.username, userB.password);

    const loginA = await loginUser(userA.email, userA.password);
    const loginB = await loginUser(userB.email, userB.password);

    userACookies = loginA.cookies;
    userAId = loginA.body.data.user.id;
    userBId = loginB.body.data.user.id;

    firstConversationResponse = await request(app)
      .post("/api/conversations")
      .set("Cookie", getAuthCookie(userACookies))
      .send({ participantIds: [userBId], isGroup: false });

    conversationId = firstConversationResponse.body.data?.id;
  });

  test("searches for another user without exposing private fields", async () => {
    const response = await request(app)
      .get(`/api/users/search?q=${userB.username.slice(0, 9)}`)
      .set("Cookie", getAuthCookie(userACookies));

    expect(response.status).toBe(200);
    expect(response.body.success).toBe(true);

    const match = response.body.data.users.find(
      (user) => user.id === userBId,
    );
    expect(match).toEqual({
      id: userBId,
      username: userB.username,
      avatar_url: null,
    });
    expect(match).not.toHaveProperty("email");
    expect(match).not.toHaveProperty("password_hash");
  });

  test("reuses the same direct conversation and summary shape", async () => {
    const repeatedResponse = await request(app)
      .post("/api/conversations")
      .set("Cookie", getAuthCookie(userACookies))
      .send({ participantIds: [userBId], isGroup: false });

    expect(firstConversationResponse.status).toBe(201);
    expect(repeatedResponse.status).toBe(200);
    expect(repeatedResponse.body.data.id).toBe(conversationId);
    expect(repeatedResponse.body.data).toEqual(
      expect.objectContaining({
        id: conversationId,
        title: null,
        is_group: false,
        participants: expect.any(Array),
        last_message: null,
      }),
    );
  });
});

What does this file cover?

The first test protects frontend-safe user discovery. The second test protects direct-conversation idempotency and the shared summary shape.

Your backend now gives every direct-message pair one dependable conversation identity. Next, you will align message payloads and refresh-token rotation with the same frontend contract.

Stabilize Messages and Sessions

Your deterministic conversation contract now gives React one reliable thread for each pair of users. The next contract boundary covers every message that moves through HTTP or Socket.IO.

React should receive one message shape from send requests, history requests, and real-time events. A legitimate second refresh must also keep the session alive while replay of an old token remains detectable.

In this step, get ready to:
  • Return the same sender-inclusive message object from HTTP and Socket.IO paths.
  • Rotate refresh tokens repeatedly within one locked token family.
  • Prove message validation and session behavior with focused integration tests.
Unify message payloads and validation

A data transfer object gives each delivery path the same message fields. Early server-side validation also rejects malformed identifiers and unbounded pagination before PostgreSQL receives a query.

  • In the Explorer sidebar in Cursor, select src/controllers/messageController.js.
  • Replace the file contents with the complete version in the double-check tab below.

✔️ I updated the message controller

Continue once src/controllers/messageController.js matches the complete controller in the next tab.

ⓧ I'd like to double check the full code

const { pool } = require("../config/db");
const {
  isUuid,
  validateMessageInput,
  parseMessagePagination,
} = require("../middleware/validate");

function badRequest(res, message) {
  return res.status(400).json({
    success: false,
    error: { message, code: "VALIDATION_ERROR" },
  });
}

async function isParticipant(db, conversationId, userId) {
  const result = await db.query(
    `SELECT id
     FROM participants
     WHERE conversation_id = $1 AND user_id = $2`,
    [conversationId, userId],
  );
  return result.rows.length > 0;
}

exports.sendMessage = async (req, res) => {
  const { conversationId } = req.params;
  const senderId = req.user.id;

  if (!isUuid(conversationId)) {
    return badRequest(res, "conversationId must be a valid UUID");
  }

  const validation = validateMessageInput(req.body);
  if (validation.error) {
    return badRequest(res, validation.error);
  }

  const client = await pool.connect();

  try {
    await client.query("BEGIN");

    if (!(await isParticipant(client, conversationId, senderId))) {
      await client.query("ROLLBACK");
      return res.status(403).json({
        success: false,
        error: {
          message: "You are not a participant in this conversation",
          code: "FORBIDDEN",
        },
      });
    }

    const messageResult = await client.query(
      `INSERT INTO messages (
         conversation_id,
         sender_id,
         content,
         message_type
       )
       VALUES ($1, $2, $3, $4)
       RETURNING id, conversation_id, sender_id, content,
                 message_type, created_at, edited_at`,
      [
        conversationId,
        senderId,
        validation.value.content,
        validation.value.messageType,
      ],
    );

    await client.query(
      "UPDATE conversations SET updated_at = NOW() WHERE id = $1",
      [conversationId],
    );

    const senderResult = await client.query(
      "SELECT id, username, avatar_url FROM users WHERE id = $1",
      [senderId],
    );

    const message = {
      ...messageResult.rows[0],
      sender: senderResult.rows[0],
    };

    await client.query("COMMIT");

    const io = req.app.get("io");
    io.to(`conversation:${conversationId}`).emit("message:new", message);

    return res.status(201).json({ success: true, data: message });
  } catch (error) {
    await client.query("ROLLBACK");
    console.error("Error sending message:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Failed to send message", code: "SERVER_ERROR" },
    });
  } finally {
    client.release();
  }
};

exports.getMessages = async (req, res) => {
  const { conversationId } = req.params;

  if (!isUuid(conversationId)) {
    return badRequest(res, "conversationId must be a valid UUID");
  }

  const pagination = parseMessagePagination(req.query);
  if (pagination.error) {
    return badRequest(res, pagination.error);
  }

  try {
    if (!(await isParticipant(pool, conversationId, req.user.id))) {
      return res.status(403).json({
        success: false,
        error: {
          message: "You are not a participant in this conversation",
          code: "FORBIDDEN",
        },
      });
    }

    let query = `
      SELECT
        m.id,
        m.conversation_id,
        m.sender_id,
        m.content,
        m.message_type,
        m.created_at,
        m.edited_at,
        json_build_object(
          'id', u.id,
          'username', u.username,
          'avatar_url', u.avatar_url
        ) AS sender
      FROM messages m
      JOIN users u ON u.id = m.sender_id
      WHERE m.conversation_id = $1`;
    const params = [conversationId];

    if (pagination.value.before) {
      params.push(pagination.value.before);
      query += ` AND m.created_at < $${params.length}`;
    }

    params.push(pagination.value.limit);
    query += ` ORDER BY m.created_at DESC, m.id DESC LIMIT $${params.length}`;

    const result = await pool.query(query, params);
    return res.json({ success: true, data: result.rows });
  } catch (error) {
    console.error("Error fetching messages:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Failed to fetch messages", code: "SERVER_ERROR" },
    });
  }
};

exports.markAsRead = async (req, res) => {
  const { messageId } = req.params;
  const userId = req.user.id;

  if (!isUuid(messageId)) {
    return badRequest(res, "messageId must be a valid UUID");
  }

  try {
    const messageResult = await pool.query(
      "SELECT conversation_id FROM messages WHERE id = $1",
      [messageId],
    );

    if (messageResult.rows.length === 0) {
      return res.status(404).json({
        success: false,
        error: { message: "Message not found", code: "MESSAGE_NOT_FOUND" },
      });
    }

    const conversationId = messageResult.rows[0].conversation_id;
    if (!(await isParticipant(pool, conversationId, userId))) {
      return res.status(403).json({
        success: false,
        error: {
          message: "You are not a participant in this conversation",
          code: "FORBIDDEN",
        },
      });
    }

    const insertResult = await pool.query(
      `INSERT INTO message_reads (message_id, user_id)
       VALUES ($1, $2)
       ON CONFLICT (message_id, user_id) DO NOTHING
       RETURNING read_at`,
      [messageId, userId],
    );

    let readAt = insertResult.rows[0]?.read_at;
    if (!readAt) {
      const existingResult = await pool.query(
        `SELECT read_at
         FROM message_reads
         WHERE message_id = $1 AND user_id = $2`,
        [messageId, userId],
      );
      readAt = existingResult.rows[0].read_at;
    }

    const receipt = { messageId, userId, readAt };

    if (insertResult.rows.length > 0) {
      const io = req.app.get("io");
      io.to(`conversation:${conversationId}`).emit("message:read", receipt);
    }

    return res.json({ success: true, data: receipt });
  } catch (error) {
    console.error("Error marking message as read:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Failed to mark message as read", code: "SERVER_ERROR" },
    });
  }
};

exports.getReadReceipts = async (req, res) => {
  const { messageId } = req.params;
  const userId = req.user.id;

  if (!isUuid(messageId)) {
    return badRequest(res, "messageId must be a valid UUID");
  }

  try {
    const messageResult = await pool.query(
      "SELECT conversation_id FROM messages WHERE id = $1",
      [messageId],
    );

    if (messageResult.rows.length === 0) {
      return res.status(404).json({
        success: false,
        error: { message: "Message not found", code: "MESSAGE_NOT_FOUND" },
      });
    }

    const conversationId = messageResult.rows[0].conversation_id;
    if (!(await isParticipant(pool, conversationId, userId))) {
      return res.status(403).json({
        success: false,
        error: {
          message: "You are not a participant in this conversation",
          code: "FORBIDDEN",
        },
      });
    }

    const result = await pool.query(
      `SELECT
         mr.message_id,
         mr.user_id,
         mr.read_at,
         json_build_object(
           'id', u.id,
           'username', u.username,
           'avatar_url', u.avatar_url
         ) AS user
       FROM message_reads mr
       JOIN users u ON u.id = mr.user_id
       WHERE mr.message_id = $1
       ORDER BY mr.read_at ASC, mr.id ASC`,
      [messageId],
    );

    return res.json({ success: true, data: result.rows });
  } catch (error) {
    console.error("Error fetching read receipts:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Failed to fetch read receipts", code: "SERVER_ERROR" },
    });
  }
};

What does this controller change?

  • The send path builds one message object with a nested sender summary.
  • The history query returns the same message fields in deterministic order.
  • The route handlers validate UUIDs and pagination before membership or message queries run.
  • The read-receipt handlers emit message:read only when a new receipt is stored.
  • Save src/controllers/messageController.js.
  • Check the existing contract tests for regressions by running:
npm test -- --runTestsByPath __tests__/frontendContract.test.js

What does this command check?

The focused Jest command runs only __tests__/frontendContract.test.js. The current search and deterministic-conversation tests should remain green after the controller replacement.

You should see the existing focused tests pass. This confirms that the new message controller loads without breaking the contracts you already secured.

Does the focused suite fail to load?

  • Check that the validation import includes isUuid, validateMessageInput, and parseMessagePagination.
  • Confirm that every controller function ends with its closing brace and parenthesis.
  • Ask for help with the controller error.
Correct refresh-token family rotation

Refresh-token rotation keeps a relationship between each old token and its replacement. Matching the presented token before checking revocation lets a current token rotate normally while replay of an older token revokes only its own family.

  • In the Explorer sidebar, select src/controllers/authController.js.
  • Replace the file contents with the complete version in the double-check tab below.

✔️ I updated refresh rotation

Continue once the refresh controller matches the complete version in the next tab.

ⓧ I'd like to double check the full code

const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const { pool } = require("../config/db");
const {
  validateRegistration,
  validateLogin,
} = require("../middleware/validate");

const generateAccessToken = (user) =>
  jwt.sign(
    { id: user.id, email: user.email },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: "15m" },
  );

const generateRefreshToken = (user, familyId) =>
  jwt.sign(
    { id: user.id, family: familyId },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: "7d" },
  );

const setTokenCookies = (res, accessToken, refreshToken) => {
  res.cookie("accessToken", accessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "strict",
    maxAge: 15 * 60 * 1000,
  });
  res.cookie("refreshToken", refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "strict",
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });
};

const clearTokenCookies = (res) => {
  res.clearCookie("accessToken");
  res.clearCookie("refreshToken");
};

exports.register = async (req, res) => {
  try {
    const { email, username, password } = req.body;
    const errors = validateRegistration(email, username, password);

    if (errors.length > 0) {
      return res.status(400).json({
        success: false,
        error: { message: errors[0], code: "VALIDATION_ERROR" },
      });
    }

    const existingUser = await pool.query(
      "SELECT id FROM users WHERE email = $1 OR username = $2",
      [email, username],
    );

    if (existingUser.rows.length > 0) {
      return res.status(409).json({
        success: false,
        error: {
          message: "Email or username already exists",
          code: "DUPLICATE_USER",
        },
      });
    }

    const salt = await bcrypt.genSalt(10);
    const passwordHash = await bcrypt.hash(password, salt);
    const result = await pool.query(
      `INSERT INTO users (email, username, password_hash)
       VALUES ($1, $2, $3)
       RETURNING id, email, username, created_at`,
      [email, username, passwordHash],
    );
    const user = result.rows[0];

    const familyId = require("crypto").randomUUID();
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user, familyId);
    const refreshTokenHash = await bcrypt.hash(refreshToken, 10);

    await pool.query(
      `INSERT INTO refresh_tokens (
         user_id,
         token_hash,
         family_id,
         expires_at
       )
       VALUES ($1, $2, $3, NOW() + INTERVAL '7 days')`,
      [user.id, refreshTokenHash, familyId],
    );

    setTokenCookies(res, accessToken, refreshToken);
    return res.status(201).json({
      success: true,
      data: {
        user: {
          id: user.id,
          email: user.email,
          username: user.username,
          created_at: user.created_at,
        },
      },
    });
  } catch (error) {
    console.error("Register error:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Internal server error", code: "SERVER_ERROR" },
    });
  }
};

exports.login = async (req, res) => {
  try {
    const { email, password } = req.body;
    const errors = validateLogin(email, password);

    if (errors.length > 0) {
      return res.status(400).json({
        success: false,
        error: { message: errors[0], code: "VALIDATION_ERROR" },
      });
    }

    const result = await pool.query("SELECT * FROM users WHERE email = $1", [
      email,
    ]);

    if (result.rows.length === 0) {
      return res.status(401).json({
        success: false,
        error: {
          message: "Invalid credentials",
          code: "INVALID_CREDENTIALS",
        },
      });
    }

    const user = result.rows[0];
    if (!(await bcrypt.compare(password, user.password_hash))) {
      return res.status(401).json({
        success: false,
        error: {
          message: "Invalid credentials",
          code: "INVALID_CREDENTIALS",
        },
      });
    }

    const familyId = require("crypto").randomUUID();
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user, familyId);
    const refreshTokenHash = await bcrypt.hash(refreshToken, 10);

    await pool.query(
      `INSERT INTO refresh_tokens (
         user_id,
         token_hash,
         family_id,
         expires_at
       )
       VALUES ($1, $2, $3, NOW() + INTERVAL '7 days')`,
      [user.id, refreshTokenHash, familyId],
    );

    setTokenCookies(res, accessToken, refreshToken);
    return res.json({
      success: true,
      data: {
        user: {
          id: user.id,
          email: user.email,
          username: user.username,
          created_at: user.created_at,
        },
      },
    });
  } catch (error) {
    console.error("Login error:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Internal server error", code: "SERVER_ERROR" },
    });
  }
};

exports.logout = async (req, res) => {
  try {
    await pool.query("DELETE FROM refresh_tokens WHERE user_id = $1", [
      req.user.id,
    ]);
    clearTokenCookies(res);
    return res.json({ success: true, message: "Logged out successfully" });
  } catch (error) {
    console.error("Logout error:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Internal server error", code: "SERVER_ERROR" },
    });
  }
};

exports.getMe = async (req, res) => {
  try {
    const result = await pool.query(
      `SELECT id, email, username, avatar_url, created_at
       FROM users
       WHERE id = $1`,
      [req.user.id],
    );

    if (result.rows.length === 0) {
      return res.status(404).json({
        success: false,
        error: { message: "User not found", code: "USER_NOT_FOUND" },
      });
    }

    return res.json({ success: true, data: { user: result.rows[0] } });
  } catch (error) {
    console.error("GetMe error:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Internal server error", code: "SERVER_ERROR" },
    });
  }
};

exports.refresh = async (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (!refreshToken) {
    return res.status(401).json({
      success: false,
      error: { message: "No refresh token provided", code: "NO_TOKEN" },
    });
  }

  let decoded;
  try {
    decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
  } catch (error) {
    clearTokenCookies(res);
    return res.status(401).json({
      success: false,
      error: { message: "Invalid refresh token", code: "INVALID_TOKEN" },
    });
  }

  const client = await pool.connect();

  try {
    await client.query("BEGIN");

    const familyResult = await client.query(
      `SELECT *
       FROM refresh_tokens
       WHERE family_id = $1 AND user_id = $2
       FOR UPDATE`,
      [decoded.family, decoded.id],
    );

    let matchedToken = null;
    for (const storedToken of familyResult.rows) {
      if (await bcrypt.compare(refreshToken, storedToken.token_hash)) {
        matchedToken = storedToken;
        break;
      }
    }

    if (!matchedToken) {
      await client.query("ROLLBACK");
      return res.status(401).json({
        success: false,
        error: {
          message: "Refresh token not recognized",
          code: "TOKEN_NOT_FOUND",
        },
      });
    }

    if (matchedToken.is_revoked) {
      await client.query(
        "DELETE FROM refresh_tokens WHERE family_id = $1",
        [decoded.family],
      );
      await client.query("COMMIT");
      clearTokenCookies(res);
      return res.status(401).json({
        success: false,
        error: {
          message: "Token reuse detected. This session was revoked.",
          code: "TOKEN_THEFT",
        },
      });
    }

    if (new Date(matchedToken.expires_at).getTime() <= Date.now()) {
      await client.query(
        "DELETE FROM refresh_tokens WHERE family_id = $1",
        [decoded.family],
      );
      await client.query("COMMIT");
      clearTokenCookies(res);
      return res.status(401).json({
        success: false,
        error: { message: "Refresh token expired", code: "INVALID_TOKEN" },
      });
    }

    const userResult = await client.query(
      "SELECT id, email, username FROM users WHERE id = $1",
      [decoded.id],
    );

    if (userResult.rows.length === 0) {
      await client.query("ROLLBACK");
      clearTokenCookies(res);
      return res.status(401).json({
        success: false,
        error: { message: "User not found", code: "USER_NOT_FOUND" },
      });
    }

    await client.query(
      "UPDATE refresh_tokens SET is_revoked = TRUE WHERE id = $1",
      [matchedToken.id],
    );

    const user = userResult.rows[0];
    const newAccessToken = generateAccessToken(user);
    const newRefreshToken = generateRefreshToken(user, decoded.family);
    const newRefreshTokenHash = await bcrypt.hash(newRefreshToken, 10);

    await client.query(
      `INSERT INTO refresh_tokens (
         user_id,
         token_hash,
         family_id,
         is_revoked,
         expires_at
       )
       VALUES ($1, $2, $3, FALSE, $4)`,
      [
        decoded.id,
        newRefreshTokenHash,
        decoded.family,
        matchedToken.expires_at,
      ],
    );

    await client.query("COMMIT");
    setTokenCookies(res, newAccessToken, newRefreshToken);
    return res.json({ success: true, message: "Token refreshed" });
  } catch (error) {
    await client.query("ROLLBACK");
    console.error("Refresh error:", error);
    return res.status(500).json({
      success: false,
      error: { message: "Internal server error", code: "SERVER_ERROR" },
    });
  } finally {
    client.release();
  }
};

How does rotation stay safe?

  • The transaction locks rows for one user and one token family before rotation begins.
  • The bcrypt comparison identifies the exact token presented by the client.
  • A valid rotation revokes the matched token and preserves its existing family expiry.
  • Replay of a revoked token deletes only its matching family and clears the session cookies.
  • Save src/controllers/authController.js.
  • Check the focused suite again by running:
npm test -- --runTestsByPath __tests__/frontendContract.test.js

What does this regression check prove?

The focused suite confirms that the authentication controller still loads beside the established search and conversation contracts. The completed tests in the next substep exercise the new rotation behavior directly.

You should see the existing focused tests remain green. The controller is ready for repeated-refresh and replay coverage.

Seeing an authentication controller failure?

  • Confirm the refresh function reads req.cookies.refreshToken before verifying the token.
  • Check that every transaction path ends with COMMIT or ROLLBACK.
  • Ask for help with the refresh failure.
Complete the focused contract suite

The final integration tests treat the backend as a client-facing interface. They compare complete message objects, probe malformed input, rotate a session twice, and replay the original refresh cookie.

  • In the Explorer sidebar, select __tests__/frontendContract.test.js.
  • Replace the file contents with the complete version in the double-check tab below.

✔️ I completed the contract tests

Continue once the focused suite contains all five contract tests shown in the next tab.

ⓧ I'd like to double check the full code

const { randomUUID } = require("node:crypto");
const request = require("supertest");
const {
  app,
  createTestUser,
  loginUser,
  getAuthCookie,
} = require("./setup");

function cookieHeader(cookies = []) {
  return cookies.map((cookie) => cookie.split(";")[0]).join("; ");
}

describe("Frontend contract", () => {
  const runId = randomUUID().replace(/-/g, "").slice(0, 8);
  const userA = {
    email: `contract-a-${runId}@test.com`,
    username: `contractA_${runId}`,
    password: "Password123!",
  };
  const userB = {
    email: `contract-b-${runId}@test.com`,
    username: `contractB_${runId}`,
    password: "Password123!",
  };

  let userACookies;
  let userAId;
  let userBId;
  let conversationId;
  let firstConversationResponse;

  beforeAll(async () => {
    await createTestUser(userA.email, userA.username, userA.password);
    await createTestUser(userB.email, userB.username, userB.password);

    const loginA = await loginUser(userA.email, userA.password);
    const loginB = await loginUser(userB.email, userB.password);

    userACookies = loginA.cookies;
    userAId = loginA.body.data.user.id;
    userBId = loginB.body.data.user.id;

    firstConversationResponse = await request(app)
      .post("/api/conversations")
      .set("Cookie", getAuthCookie(userACookies))
      .send({ participantIds: [userBId], isGroup: false });

    conversationId = firstConversationResponse.body.data?.id;
  });

  test("searches for another user without exposing private fields", async () => {
    const response = await request(app)
      .get(`/api/users/search?q=${userB.username.slice(0, 9)}`)
      .set("Cookie", getAuthCookie(userACookies));

    expect(response.status).toBe(200);
    expect(response.body.success).toBe(true);

    const match = response.body.data.users.find(
      (user) => user.id === userBId,
    );
    expect(match).toEqual({
      id: userBId,
      username: userB.username,
      avatar_url: null,
    });
    expect(match).not.toHaveProperty("email");
    expect(match).not.toHaveProperty("password_hash");
  });

  test("reuses the same direct conversation and summary shape", async () => {
    const repeatedResponse = await request(app)
      .post("/api/conversations")
      .set("Cookie", getAuthCookie(userACookies))
      .send({ participantIds: [userBId], isGroup: false });

    expect(firstConversationResponse.status).toBe(201);
    expect(repeatedResponse.status).toBe(200);
    expect(repeatedResponse.body.data.id).toBe(conversationId);
    expect(repeatedResponse.body.data).toEqual(
      expect.objectContaining({
        id: conversationId,
        title: null,
        is_group: false,
        participants: expect.any(Array),
        last_message: null,
      }),
    );
  });

  test("returns the same message shape from send and history", async () => {
    const sendResponse = await request(app)
      .post(`/api/messages/${conversationId}`)
      .set("Cookie", getAuthCookie(userACookies))
      .send({ content: "React can trust this payload" });

    expect(sendResponse.status).toBe(201);
    expect(sendResponse.body.data.sender).toEqual({
      id: userAId,
      username: userA.username,
      avatar_url: null,
    });

    const historyResponse = await request(app)
      .get(`/api/messages/${conversationId}?limit=10`)
      .set("Cookie", getAuthCookie(userACookies));

    expect(historyResponse.status).toBe(200);
    const storedMessage = historyResponse.body.data.find(
      (message) => message.id === sendResponse.body.data.id,
    );
    expect(storedMessage).toEqual(sendResponse.body.data);
  });

  test("rejects malformed IDs and unbounded pagination", async () => {
    const invalidIdResponse = await request(app)
      .get("/api/messages/not-a-uuid")
      .set("Cookie", getAuthCookie(userACookies));
    const invalidLimitResponse = await request(app)
      .get(`/api/messages/${conversationId}?limit=1000`)
      .set("Cookie", getAuthCookie(userACookies));

    expect(invalidIdResponse.status).toBe(400);
    expect(invalidLimitResponse.status).toBe(400);
    expect(invalidLimitResponse.body.error.code).toBe("VALIDATION_ERROR");
  });

  test("rotates repeatedly and detects replay of an old refresh token", async () => {
    const refreshUser = {
      email: `refresh-${runId}@test.com`,
      username: `refresh_${runId}`,
      password: "Password123!",
    };

    const registration = await request(app)
      .post("/api/auth/register")
      .send(refreshUser);
    const initialCookies = registration.headers["set-cookie"];

    const firstRefresh = await request(app)
      .post("/api/auth/refresh")
      .set("Cookie", cookieHeader(initialCookies));
    expect(firstRefresh.status).toBe(200);

    const secondRefresh = await request(app)
      .post("/api/auth/refresh")
      .set("Cookie", cookieHeader(firstRefresh.headers["set-cookie"]));
    expect(secondRefresh.status).toBe(200);

    const replay = await request(app)
      .post("/api/auth/refresh")
      .set("Cookie", cookieHeader(initialCookies));
    expect(replay.status).toBe(401);
    expect(replay.body.error.code).toBe("TOKEN_THEFT");
  });
});

What does the suite prove?

  • Unique test users keep reruns independent from old development data.
  • The message test compares the stored history object with the exact send response.
  • The validation test expects 400 responses before malformed values reach database work.
  • The rotation test uses each replacement cookie once before replaying the original cookie.
  • Save __tests__/frontendContract.test.js.

Before you run the suite, do you expect the second legitimate refresh to succeed before the original token replay is rejected?

  • Run the complete focused contract suite with:
npm test -- --runTestsByPath __tests__/frontendContract.test.js

What should this run confirm?

The command runs the exact React-facing integration file. Passing results confirm safe search, deterministic conversations, message parity, bounded validation, repeated refresh rotation, and old-token replay detection.

You should see all focused contract tests pass. The message test confirms identical send and history objects while the session test confirms two successful refreshes followed by a rejected replay.

Still seeing a focused test failure?

  • Check that Docker Desktop still has the local PostgreSQL container running.
  • Confirm the migration has already added direct_key before the conversation setup runs.
  • Compare the failing expectation with the response body printed by Jest.
  • Ask for help with the failing contract test.

That closes the contract gap. React can now consume stable message objects and keep valid sessions alive without hiding backend inconsistencies in component state.

Secret mission

Publish the Frontend Contract

Turn your tested backend behavior into a frontend handoff that another developer can use without reading controller code. You will publish one reference covering requests, responses, cookies, events, errors, and React integration rules.

Clean Up Your Resources

Clean Up Your Resources

This project uses your existing local Docker Desktop setup. Your PostgreSQL database stores everything on your machine.

There are no ongoing cloud charges or paid services. Decide whether to keep the contract ready for React development, pause the local database, or remove the project changes.

Resources you used:

  • The local Git branch frontend-contract containing the complete backend contract implementation.
  • The additive migration file src/config/migrations/001_frontend_contract.sql.
  • The migration runner scripts/migrateFrontendContract.js.
  • The applied conversations.direct_key database column.
  • The applied idx_conversations_direct_key partial unique index.
  • The optional React integration reference in docs/frontend-contract.md.

Keep everything running

No action is needed. Choose this option if you plan to continue directly into the React project.

  • Leave frontend-contract checked out so the complete backend contract remains available.
  • Keep the local PostgreSQL container running in Docker Desktop so the API remains ready for requests.
  • Retain the applied migration so repeated direct-conversation requests continue using direct_key.
  • Keep docs/frontend-contract.md as the integration checklist for the React client.

Pause - I'll come back to this later

Pausing keeps the contract implementation intact while freeing the resources used by the running database container.

  • In Docker Desktop, stop the existing local PostgreSQL container.
  • Keep the frontend-contract branch on your machine.

Your database volume remains available for the next project. The migration and existing rows are still there when you start the container again.

Delete - I don't want to use this again

Deleting frontend-contract removes the only local copy of any unmerged contract work. Confirm that you no longer need the implementation before continuing.

  • In Cursor, use the Git controls to switch from frontend-contract to the branch you used before this project.
  • Use the same Git controls to delete the local frontend-contract branch.
  • Confirm that Cursor no longer lists frontend-contract among your local branches.

Database Cleanup Is Optional

The migration is additive. Existing conversation rows remain intact.

Remove the index and column only if you also intend to abandon the deterministic direct-conversation contract.

  • Return to the PostgreSQL client you used in the earlier chat API projects.
  • Remove the index named idx_conversations_direct_key from the local database.
  • Remove the direct_key column from the conversations table.
  • Inspect the conversations table structure. You should no longer see direct_key or idx_conversations_direct_key.

Nice Work!

Nice Work!

Outstanding work! Your chat backend now gives the upcoming React client a tested API contract it can trust.

You've learned how to:

  • Build authenticated user discovery that returns safe user summaries. Enforce one direct conversation identity per user pair with PostgreSQL-backed uniqueness.
  • Use one message payload for REST sends. Return that payload from message history. Emit it through Socket.IO events. Reject malformed input with bounded server-side validation before database work begins.
  • Correct refresh-token rotation so legitimate sessions can refresh repeatedly. Detect replay of an old token at the family level. Protect these behaviors with focused integration tests.
  • Secret Mission: Published a one-page frontend contract reference that gives the React build one source of truth from authentication cookies through real-time events.

Ready to quiz yourself?