From 2d63c202c7cab8ce838f9c1471d1cfbe8f3dbe83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 08:43:21 +0000 Subject: [PATCH 01/11] feat(auth): add withAuthenticatedUser and withOptionalUser wrappers Hoist token extraction, JWT verification, user lookup, and 401 responses out of every API route into a single deep wrapper. Handlers receive a populated AuthContext (userDoc, email, freshly rotated token) and can focus on the resource they own. The optional sibling supports note.ts' guest creation flow without conflating contracts behind a flag. Removes the @ts-ignore in utils/jwt.ts by giving authenticateToken and generateAccessToken proper return types via a TokenPayload alias. Tests cover the missing-header, invalid-token, missing-user, happy-path, error-propagation, and guest paths via mocked jwt + Mongoose User. --- .../auth/withAuthenticatedUser.test.ts | 128 ++++++++++++++++++ src/__test__/auth/withOptionalUser.test.ts | 93 +++++++++++++ utils/auth/withAuthenticatedUser.ts | 123 +++++++++++++++++ utils/jwt.ts | 11 +- 4 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 src/__test__/auth/withAuthenticatedUser.test.ts create mode 100644 src/__test__/auth/withOptionalUser.test.ts create mode 100644 utils/auth/withAuthenticatedUser.ts diff --git a/src/__test__/auth/withAuthenticatedUser.test.ts b/src/__test__/auth/withAuthenticatedUser.test.ts new file mode 100644 index 0000000..ef3d137 --- /dev/null +++ b/src/__test__/auth/withAuthenticatedUser.test.ts @@ -0,0 +1,128 @@ +import { StatusCodes } from "http-status-codes"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/utils/jwt", () => ({ + authenticateToken: vi.fn(), + generateAccessToken: vi.fn(), +})); + +vi.mock("@/utils/mongoose", () => ({ + User: { findOne: vi.fn() }, +})); + +import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser"; +import { authenticateToken, generateAccessToken } from "@/utils/jwt"; +import { User } from "@/utils/mongoose"; + +type MockResponse = { + status: ReturnType; + json: ReturnType; +}; + +const buildReq = (headers: Record = {}): NextApiRequest => + ({ headers, body: {} }) as unknown as NextApiRequest; + +const buildRes = (): MockResponse & NextApiResponse => { + const res: MockResponse = { + status: vi.fn(), + json: vi.fn(), + }; + res.status.mockReturnValue(res); + res.json.mockReturnValue(res); + return res as MockResponse & NextApiResponse; +}; + +describe("withAuthenticatedUser", () => { + beforeEach(() => { + vi.mocked(generateAccessToken).mockReturnValue("rotated-token"); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when authorization header is absent", async () => { + const handler = vi.fn(); + const wrapped = withAuthenticatedUser(handler); + + const req = buildReq(); + const res = buildRes(); + await wrapped(req, res); + + expect(res.status).toHaveBeenCalledWith(StatusCodes.UNAUTHORIZED); + expect(res.json).toHaveBeenCalledWith({ msg: "No token. Authorization denied." }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("strips the Bearer prefix case-insensitively before verifying the token", async () => { + vi.mocked(authenticateToken).mockReturnValue("user@example.com"); + vi.mocked(User.findOne).mockResolvedValue({ _id: "u1", email: "user@example.com" } as never); + const handler = vi.fn(); + const wrapped = withAuthenticatedUser(handler); + + await wrapped(buildReq({ authorization: "bearer abc.def.ghi" }), buildRes()); + + expect(authenticateToken).toHaveBeenCalledWith("abc.def.ghi"); + }); + + it("returns 401 when jwt verification throws", async () => { + vi.mocked(authenticateToken).mockImplementation(() => { + throw new Error("jwt expired"); + }); + const handler = vi.fn(); + const wrapped = withAuthenticatedUser(handler); + + const res = buildRes(); + await wrapped(buildReq({ authorization: "Bearer bad.token" }), res); + + expect(res.status).toHaveBeenCalledWith(StatusCodes.UNAUTHORIZED); + expect(res.json).toHaveBeenCalledWith({ msg: "Invalid token" }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("returns 401 when no user matches the verified email", async () => { + vi.mocked(authenticateToken).mockReturnValue("ghost@example.com"); + vi.mocked(User.findOne).mockResolvedValue(null as never); + const handler = vi.fn(); + const wrapped = withAuthenticatedUser(handler); + + const res = buildRes(); + await wrapped(buildReq({ authorization: "Bearer t" }), res); + + expect(res.status).toHaveBeenCalledWith(StatusCodes.UNAUTHORIZED); + expect(res.json).toHaveBeenCalledWith({ msg: "No user found." }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("invokes the handler with userDoc, email and a freshly rotated token on the happy path", async () => { + const userDoc = { _id: "u1", email: "user@example.com" }; + vi.mocked(authenticateToken).mockReturnValue("user@example.com"); + vi.mocked(User.findOne).mockResolvedValue(userDoc as never); + vi.mocked(generateAccessToken).mockReturnValue("fresh-jwt"); + const handler = vi.fn().mockResolvedValue(undefined); + const wrapped = withAuthenticatedUser(handler); + + const req = buildReq({ authorization: "Bearer good.token" }); + const res = buildRes(); + await wrapped(req, res); + + expect(generateAccessToken).toHaveBeenCalledWith("user@example.com"); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(req, res, { + userDoc, + email: "user@example.com", + newToken: "fresh-jwt", + }); + }); + + it("propagates handler errors so the framework's error handling can run", async () => { + vi.mocked(authenticateToken).mockReturnValue("user@example.com"); + vi.mocked(User.findOne).mockResolvedValue({ _id: "u1", email: "user@example.com" } as never); + const boom = new Error("db down"); + const handler = vi.fn().mockRejectedValue(boom); + const wrapped = withAuthenticatedUser(handler); + + await expect(wrapped(buildReq({ authorization: "Bearer t" }), buildRes())).rejects.toBe(boom); + }); +}); diff --git a/src/__test__/auth/withOptionalUser.test.ts b/src/__test__/auth/withOptionalUser.test.ts new file mode 100644 index 0000000..bb351a0 --- /dev/null +++ b/src/__test__/auth/withOptionalUser.test.ts @@ -0,0 +1,93 @@ +import { StatusCodes } from "http-status-codes"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/utils/jwt", () => ({ + authenticateToken: vi.fn(), + generateAccessToken: vi.fn(), +})); + +vi.mock("@/utils/mongoose", () => ({ + User: { findOne: vi.fn() }, +})); + +import { withOptionalUser } from "@/utils/auth/withAuthenticatedUser"; +import { authenticateToken, generateAccessToken } from "@/utils/jwt"; +import { User } from "@/utils/mongoose"; + +type MockResponse = { + status: ReturnType; + json: ReturnType; +}; + +const buildReq = (headers: Record = {}): NextApiRequest => + ({ headers, body: {} }) as unknown as NextApiRequest; + +const buildRes = (): MockResponse & NextApiResponse => { + const res: MockResponse = { status: vi.fn(), json: vi.fn() }; + res.status.mockReturnValue(res); + res.json.mockReturnValue(res); + return res as MockResponse & NextApiResponse; +}; + +describe("withOptionalUser", () => { + beforeEach(() => { + vi.mocked(generateAccessToken).mockReturnValue("rotated-token"); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("invokes the handler with a guest context when no authorization header is present", async () => { + const handler = vi.fn(); + const wrapped = withOptionalUser(handler); + + const req = buildReq(); + const res = buildRes(); + await wrapped(req, res); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(req, res, { + isGuest: true, + userDoc: null, + email: null, + newToken: null, + }); + expect(res.status).not.toHaveBeenCalled(); + expect(authenticateToken).not.toHaveBeenCalled(); + }); + + it("invokes the handler with an authenticated context when a valid token is supplied", async () => { + const userDoc = { _id: "u1", email: "user@example.com" }; + vi.mocked(authenticateToken).mockReturnValue("user@example.com"); + vi.mocked(User.findOne).mockResolvedValue(userDoc as never); + vi.mocked(generateAccessToken).mockReturnValue("fresh-jwt"); + const handler = vi.fn(); + const wrapped = withOptionalUser(handler); + + await wrapped(buildReq({ authorization: "Bearer good.token" }), buildRes()); + + expect(handler).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + isGuest: false, + userDoc, + email: "user@example.com", + newToken: "fresh-jwt", + }); + }); + + it("returns 401 when a token is supplied but invalid (does not silently fall through to guest)", async () => { + vi.mocked(authenticateToken).mockImplementation(() => { + throw new Error("jwt malformed"); + }); + const handler = vi.fn(); + const wrapped = withOptionalUser(handler); + + const res = buildRes(); + await wrapped(buildReq({ authorization: "Bearer bad" }), res); + + expect(res.status).toHaveBeenCalledWith(StatusCodes.UNAUTHORIZED); + expect(res.json).toHaveBeenCalledWith({ msg: "Invalid token" }); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/utils/auth/withAuthenticatedUser.ts b/utils/auth/withAuthenticatedUser.ts new file mode 100644 index 0000000..ac2582e --- /dev/null +++ b/utils/auth/withAuthenticatedUser.ts @@ -0,0 +1,123 @@ +import { StatusCodes } from "http-status-codes"; +import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; + +import type { UserDocInterface } from "@/shared/types"; +import { authenticateToken, generateAccessToken } from "@/utils/jwt"; +import { User } from "@/utils/mongoose"; + +/** + * Context passed to handlers wrapped by {@link withAuthenticatedUser}. + * + * - `userDoc`: the Mongoose user document for the verified caller + * - `email`: the email extracted from the JWT (matches `userDoc.email`) + * - `newToken`: a freshly rotated JWT the handler should return so the + * caller's session is refreshed + */ +export type AuthContext = { + userDoc: UserDocInterface; + email: string; + newToken: string; +}; + +/** + * Context passed to handlers wrapped by {@link withOptionalUser}. + * + * When the request carries no `authorization` header `isGuest` is `true` and + * `userDoc`/`email`/`newToken` are `null`. When a token is present it must be + * valid; an invalid token short-circuits with 401 (no silent fall-through to + * the guest branch). + */ +export type OptionalAuthContext = + | { isGuest: true; userDoc: null; email: null; newToken: null } + | { isGuest: false; userDoc: UserDocInterface; email: string; newToken: string }; + +export type AuthenticatedHandler = ( + req: NextApiRequest, + res: NextApiResponse, + ctx: AuthContext, +) => unknown | Promise; + +export type OptionalAuthHandler = ( + req: NextApiRequest, + res: NextApiResponse, + ctx: OptionalAuthContext, +) => unknown | Promise; + +const extractBearer = (header: string | string[] | undefined): string | null => { + if (typeof header !== "string" || header.length === 0) return null; + return header.replace(/^bearer\s+/i, ""); +}; + +type ResolveResult = { + ctx: AuthContext | null; + status: number; + body: { msg: string } | null; +}; + +const resolveAuthenticatedUser = async (token: string): Promise => { + let email: string; + try { + email = authenticateToken(token); + } catch { + return { ctx: null, status: StatusCodes.UNAUTHORIZED, body: { msg: "Invalid token" } }; + } + + const userDoc = (await User.findOne({ email })) as UserDocInterface | null; + if (!userDoc) { + return { ctx: null, status: StatusCodes.UNAUTHORIZED, body: { msg: "No user found." } }; + } + + return { + ctx: { userDoc, email, newToken: generateAccessToken(email) }, + status: StatusCodes.OK, + body: null, + }; +}; + +/** + * Wrap a Next.js API handler so it only runs for authenticated users. + * + * The wrapper performs token extraction, JWT verification, and user lookup, + * then invokes `handler(req, res, ctx)` with a populated {@link AuthContext}. + * On any failure it responds with 401 and a `msg` body and the inner handler + * is not called. Handler errors propagate so the framework's error handling + * can run. + */ +export const withAuthenticatedUser = + (handler: AuthenticatedHandler): NextApiHandler => + async (req, res) => { + const token = extractBearer(req.headers["authorization"]); + if (!token) { + res.status(StatusCodes.UNAUTHORIZED).json({ msg: "No token. Authorization denied." }); + return; + } + const result = await resolveAuthenticatedUser(token); + if (result.ctx === null) { + res.status(result.status).json(result.body); + return; + } + await handler(req, res, result.ctx); + }; + +/** + * Wrap a Next.js API handler so it accepts both guests and authenticated + * users. + * + * Missing `authorization` header → guest branch (`ctx.isGuest === true`). + * Present-but-invalid token → 401 (no silent guest fall-through). + */ +export const withOptionalUser = + (handler: OptionalAuthHandler): NextApiHandler => + async (req, res) => { + const token = extractBearer(req.headers["authorization"]); + if (!token) { + await handler(req, res, { isGuest: true, userDoc: null, email: null, newToken: null }); + return; + } + const result = await resolveAuthenticatedUser(token); + if (result.ctx === null) { + res.status(result.status).json(result.body); + return; + } + await handler(req, res, { isGuest: false, ...result.ctx }); + }; diff --git a/utils/jwt.ts b/utils/jwt.ts index 0fe3134..c9c5df8 100644 --- a/utils/jwt.ts +++ b/utils/jwt.ts @@ -3,16 +3,17 @@ * generate token secret > require('crypto').randomBytes(64).toString('hex') */ -const jwt = require("jsonwebtoken"); +import jwt from "jsonwebtoken"; + +type TokenPayload = { email: string }; export const authenticateToken = (token: string): string => { - const decoded = jwt.verify(token, process.env.JWT_TOKEN_SECRET); - // @ts-ignore + const decoded = jwt.verify(token, process.env.JWT_TOKEN_SECRET) as TokenPayload; return decoded.email; }; -export const generateAccessToken = (email: string) => { +export const generateAccessToken = (email: string): string => { // expires after half and hour (1800 seconds = 30 minutes) - const data = { email }; + const data: TokenPayload = { email }; return jwt.sign(data, process.env.JWT_TOKEN_SECRET, { expiresIn: 60 * 30 }); }; From 5ab814e2887396cca5e9b95a62d28b8846a260ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 08:43:54 +0000 Subject: [PATCH 02/11] refactor(api/auth): adopt withAuthenticatedUser Drop the hand-rolled token extraction, JWT verification, and 401 boilerplate now that the wrapper owns that contract. --- pages/api/auth.js | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/pages/api/auth.js b/pages/api/auth.js index 2260b0c..8f40766 100644 --- a/pages/api/auth.js +++ b/pages/api/auth.js @@ -1,27 +1,10 @@ import { StatusCodes } from "http-status-codes"; import { extractUser } from "@/utils/apiHelpers"; -import { authenticateToken } from "@/utils/jwt"; +import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser"; import { User } from "@/utils/mongoose"; -export default async (req, res) => { - // Gather the jwt access token from the request header - let token = req.headers["authorization"]; - // strip 'bearer' - if (token) token = token.replace(/bearer /i, ""); - - if (!token) { - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "No token. Authorization denied." }); - } - - let email; - try { - email = await authenticateToken(token); - } catch (err) { - console.error(err.message); - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "Invalid token" }); - } - +export default withAuthenticatedUser(async (req, res, { email }) => { // get user via email (including their settings, projects & notes per project) const user = await User.findOne({ email }) .populate({ path: "settings", model: "Settings" }) @@ -51,4 +34,4 @@ export default async (req, res) => { res.status(StatusCodes.OK).json({ user: extractUser(user), }); -}; +}); From 5cda13992c2dc560a6fcd57215272c0b974495fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 08:45:16 +0000 Subject: [PATCH 03/11] refactor(api/settings): adopt withAuthenticatedUser Remove the duplicated auth boilerplate. Widen UserDocInterface to expose the schema fields (settings, projects, etc.) the route reads through the typed userDoc on AuthContext. --- pages/api/settings.js | 30 ++++-------------------------- src/components/shared/types.ts | 5 +++++ 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/pages/api/settings.js b/pages/api/settings.js index d2bee34..906f4ce 100644 --- a/pages/api/settings.js +++ b/pages/api/settings.js @@ -1,28 +1,9 @@ import { StatusCodes } from "http-status-codes"; -import { authenticateToken, generateAccessToken } from "@/utils/jwt"; -import { Settings, User } from "@/utils/mongoose"; - -export default async (req, res) => { - // Gather the jwt access token from the request header - let token = req.headers["authorization"]; - // strip 'bearer' - if (token) token = token.replace(/bearer /i, ""); - if (!token) { - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "No token. Authorization denied." }); - } - - let email; - try { - email = await authenticateToken(token); - } catch (error) { - console.error(error.message); - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "Invalid token", error }); - } - - // get user - const userDoc = await User.findOne({ email }); +import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser"; +import { Settings } from "@/utils/mongoose"; +export default withAuthenticatedUser(async (req, res, { userDoc, newToken }) => { const { settings } = req.body; let settingsDoc; @@ -53,11 +34,8 @@ export default async (req, res) => { return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ msg: "Database error", error }); } - // token (keep resetting their session length) - const newToken = generateAccessToken(userDoc.email); - res.status(StatusCodes.OK).json({ settings: settingsDoc.toObject(), token: newToken, }); -}; +}); diff --git a/src/components/shared/types.ts b/src/components/shared/types.ts index c98fbce..da9c41d 100644 --- a/src/components/shared/types.ts +++ b/src/components/shared/types.ts @@ -93,6 +93,11 @@ export interface ShareDocInterface extends Document { export interface UserDocInterface extends Document { email: string; + username?: string; + password?: string; + role?: string; + projects: any; + settings?: any; } export interface NoteDocInterface extends Document { user: string | UserInterface; From d2d438db890c2f5bb3798e97fa618f0e477f7b51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 08:47:02 +0000 Subject: [PATCH 04/11] refactor(api/user): adopt withAuthenticatedUser Drop the duplicated token/email plumbing. The wrapper hands the route both the userDoc and a freshly rotated newToken so the trailing generateAccessToken call is no longer needed. --- pages/api/user.js | 32 +++++--------------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/pages/api/user.js b/pages/api/user.js index 76c9d8f..e898be1 100644 --- a/pages/api/user.js +++ b/pages/api/user.js @@ -2,31 +2,12 @@ import bcrypt from "bcryptjs"; import { StatusCodes } from "http-status-codes"; import { extractUser } from "@/utils/apiHelpers"; -import { authenticateToken, generateAccessToken } from "@/utils/jwt"; +import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser"; import { Note, Project, Settings, Share, User } from "@/utils/mongoose"; -export default async (req, res) => { - // Gather the jwt access token from the request header - let token = req.headers["authorization"]; - // strip 'bearer' - if (token) token = token.replace(/bearer /i, ""); - if (!token) { - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "No token. Authorization denied." }); - } - +export default withAuthenticatedUser(async (req, res, { userDoc, email, newToken }) => { const { action, user: userData } = req.body; - let email; - try { - email = await authenticateToken(token); - } catch (err) { - console.error(err.message); - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "Invalid token" }); - } - - // get user - const userDoc = await User.findOne({ email }); - try { if (action === "update") { await updateUser(userDoc, userData); @@ -56,8 +37,8 @@ export default async (req, res) => { // remove user settings doc await Settings.deleteOne({ user: userDoc._id }); - // remove user - await userDoc.remove(); + // remove user (legacy Mongoose API; see CONTEXT.md follow-ups) + await /** @type {any} */ (userDoc).remove(); return res.status(StatusCodes.OK).json({ msg: `${userDoc.email} removed` }); } @@ -71,14 +52,11 @@ export default async (req, res) => { // get updated user const updatedUser = await User.findOne({ email }).lean(); - // token (keep resetting their session length) - const newToken = generateAccessToken(updatedUser.email); - res.status(StatusCodes.OK).json({ user: extractUser(updatedUser), token: newToken, }); -}; +}); const updateUser = async (userDoc, userData) => { // if we have settings data to update and we have previously stored data then merge From ae3c175c8c452911cd099679fe52568e648658f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 08:47:58 +0000 Subject: [PATCH 05/11] refactor(api/project): adopt withAuthenticatedUser Drop the duplicated auth boilerplate. The action switch now uses ctx.userDoc and ctx.newToken, removing two interleaved generateAccessToken calls that were threading the same email through the function. --- pages/api/project.ts | 43 +++++-------------------------------------- 1 file changed, 5 insertions(+), 38 deletions(-) diff --git a/pages/api/project.ts b/pages/api/project.ts index 5eb9776..881c156 100644 --- a/pages/api/project.ts +++ b/pages/api/project.ts @@ -12,33 +12,12 @@ import bcrypt from "bcryptjs"; import { StatusCodes } from "http-status-codes"; -import { NextApiRequest, NextApiResponse } from "next"; import { ProjectApiActions, ProjectDocInterface, ShareDocInterface } from "@/shared/types"; -import { authenticateToken, generateAccessToken } from "@/utils/jwt"; -import { Note, Project, Share, User } from "@/utils/mongoose"; - -export default async (req: NextApiRequest, res: NextApiResponse) => { - // Gather the jwt access token from the request header - let token = req.headers["authorization"]; - // strip 'bearer' - if (token) token = token.replace(/bearer /i, ""); - if (!token) { - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "No token. Authorization denied." }); - } - - // validate - let email: string; - try { - email = authenticateToken(token); - } catch (err) { - console.error(err.message); - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "Invalid token" }); - } - - // get user - const userDoc = await User.findOne({ email }); +import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser"; +import { Note, Project, Share } from "@/utils/mongoose"; +export default withAuthenticatedUser(async (req, res, { userDoc, newToken }) => { // data passed const { action, project } = req.body; @@ -160,9 +139,6 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { projectDoc.share = null; await projectDoc.save(); - // token (keep resetting their session length) - const newToken = generateAccessToken(userDoc.email); - projectDoc = await getEntireProject({ _id: projectDoc._id, }); @@ -172,8 +148,6 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { project: projectDoc.toObject(), }); - break; - case ProjectApiActions.REMOVE: // get projectDoc via project and user _id projectDoc = await Project.findOne({ @@ -198,15 +172,11 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { // remove project await projectDoc.deleteOne(); - // token (keep resetting their session length) - const newTokenFromRemoveAction = generateAccessToken(userDoc.email); - return res.status(StatusCodes.OK).json({ // toObject method does not work on removed/deleted mongoose document project: projectDoc, - token: newTokenFromRemoveAction, + token: newToken, }); - break; default: // no action @@ -217,14 +187,11 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ msg: "Database error", error }); } - // token (keep resetting their session length) - const newToken = generateAccessToken(userDoc.email); - return res.status(StatusCodes.OK).json({ project: projectDoc.toObject(), token: newToken, }); -}; +}); const getEntireProject = async (query: { [key: string]: any }): Promise => { return Project.findOne(query).populate([ From df9d81fb4edd75ae38cf37ef7524ff946d4a9a88 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 08:48:44 +0000 Subject: [PATCH 06/11] refactor(api/note): adopt withOptionalUser Replace the local isGuestUser flag and the inline JWT/User lookup with the OptionalAuthContext supplied by the wrapper. Guest creation flow is now expressed by the discriminated ctx rather than a stateful boolean threaded through the handler. --- pages/api/note.ts | 60 ++++++++++------------------------------------- 1 file changed, 13 insertions(+), 47 deletions(-) diff --git a/pages/api/note.ts b/pages/api/note.ts index be518f7..ce8c5b3 100644 --- a/pages/api/note.ts +++ b/pages/api/note.ts @@ -1,5 +1,5 @@ import { StatusCodes } from "http-status-codes"; -import { NextApiRequest, NextApiResponse } from "next"; +import { NextApiResponse } from "next"; import { NoteApiAction, @@ -7,47 +7,17 @@ import { NoteInterface, UserDocInterface, } from "@/root/src/components/shared/types"; -import { authenticateToken, generateAccessToken } from "@/utils/jwt"; -import { Note, Project, User } from "@/utils/mongoose"; - -export default async (req: NextApiRequest, res: NextApiResponse) => { - console.log("note api"); - // detect user via token - // if no user then the 'note' is not assigned a user - let isGuestUser: boolean = false; - - // Gather the jwt access token from the request header - let token: string = req.headers["authorization"]; - // strip 'bearer' - if (token) { - token = token.replace(/bearer /i, ""); - } else { - // if there is no token then we could have a guest - console.log("Guest user"); - isGuestUser = true; - } +import { withOptionalUser } from "@/utils/auth/withAuthenticatedUser"; +import { generateAccessToken } from "@/utils/jwt"; +import { Note, Project } from "@/utils/mongoose"; +export default withOptionalUser(async (req, res, ctx) => { // 'note' is inclusive of projectId const { action, note }: { action: NoteApiAction; note: NoteInterface } = req.body; - // if the user is not a guest then grab their information - let userDoc: UserDocInterface; - if (!isGuestUser) { - let email: string; - try { - email = authenticateToken(token); - } catch (error) { - console.error(error.message); - return res.status(StatusCodes.UNAUTHORIZED).json({ msg: "Invalid token", error }); - } - - // get user - userDoc = await User.findOne({ email }); - } - // different route if we choose to delete all complete notes from specified project if (action === NoteApiAction.REMOVE_DONE_NOTES) { - return await removeDoneNotes(res, userDoc, req.body.projectId); + return await removeDoneNotes(res, ctx.userDoc, req.body.projectId); } let noteDoc: NoteDocInterface; @@ -62,7 +32,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { if (noteDoc) { // update user if we have one (in case we have a different user modifying a note) // todo: array of users who modify the note when original 'user' is present? - if (!isGuestUser) data.user = userDoc._id as any; + if (!ctx.isGuest) data.user = ctx.userDoc._id as any; await noteDoc.updateOne({ $set: data }); await noteDoc.save(); @@ -72,7 +42,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { // create note // add user info to note - if (!isGuestUser) note.user = userDoc._id as any; + if (!ctx.isGuest) note.user = ctx.userDoc._id as any; // create with whole 'note' since we are passing a manually created _id for faster state management client side noteDoc = new Note(note); @@ -90,22 +60,18 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ msg: "Database error", error }); } - let newToken: string = null; - // token (keep resetting their session length) - if (!isGuestUser) newToken = generateAccessToken(userDoc.email); - // populate the 'user' field if it is a user created note if (noteDoc.user && !noteDoc.populated("user")) await noteDoc.populate("user", "username email"); res.status(StatusCodes.OK).json({ note: noteDoc.toObject(), - token: newToken, + token: ctx.newToken, }); -}; +}); const removeDoneNotes = async ( res: NextApiResponse, - userDoc: UserDocInterface, + userDoc: UserDocInterface | null, projectId: string, ): Promise => { console.log("removing completed notes from project:", projectId); @@ -114,8 +80,8 @@ const removeDoneNotes = async ( // return all notes for project const notes = await Note.find({ project: projectId }).lean(); - // token (keep resetting their session length) - const newToken = generateAccessToken(userDoc.email); + // token (keep resetting their session length) — guests get null + const newToken = userDoc ? generateAccessToken(userDoc.email) : null; res.status(StatusCodes.OK).json({ notes, From ca11ae5d35524e75ab3a2e3e32cdd701b195f8d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 08:49:57 +0000 Subject: [PATCH 07/11] feat(share): add verifySharePassword and hashSharePassword module Centralise the password contract for shared projects in a small pure module so the read path (public_project) and write path (project) can agree on what "open", "passwordRequired", "incorrect" and "ok" mean. The discriminated ShareAccessResult lets callers handle each case explicitly instead of branching on string messages. Tests use a real bcrypt round-trip to prove the seam, not a mock. --- src/__test__/share/sharePassword.test.ts | 55 ++++++++++++++++++++++++ utils/share/sharePassword.ts | 49 +++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/__test__/share/sharePassword.test.ts create mode 100644 utils/share/sharePassword.ts diff --git a/src/__test__/share/sharePassword.test.ts b/src/__test__/share/sharePassword.test.ts new file mode 100644 index 0000000..dd4a8dc --- /dev/null +++ b/src/__test__/share/sharePassword.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { hashSharePassword, verifySharePassword } from "@/utils/share/sharePassword"; + +describe("verifySharePassword", () => { + it("treats a null stored hash as open access", async () => { + const result = await verifySharePassword(null, "anything"); + expect(result).toEqual({ kind: "open" }); + }); + + it("treats an empty stored hash as open access", async () => { + const result = await verifySharePassword("", "anything"); + expect(result).toEqual({ kind: "open" }); + }); + + it("treats an undefined stored hash as open access", async () => { + const result = await verifySharePassword(undefined, undefined); + expect(result).toEqual({ kind: "open" }); + }); + + it("requires a password when one is stored but none is supplied", async () => { + const stored = await hashSharePassword("secret"); + const result = await verifySharePassword(stored, undefined); + expect(result).toEqual({ kind: "passwordRequired" }); + }); + + it("rejects an incorrect password against the stored hash", async () => { + const stored = await hashSharePassword("secret"); + const result = await verifySharePassword(stored, "wrong"); + expect(result).toEqual({ kind: "incorrect" }); + }); + + it("accepts the correct password against the stored hash", async () => { + const stored = await hashSharePassword("secret"); + const result = await verifySharePassword(stored, "secret"); + expect(result).toEqual({ kind: "ok" }); + }); +}); + +describe("hashSharePassword", () => { + it("returns null for an empty plaintext (no password protection)", async () => { + expect(await hashSharePassword("")).toBeNull(); + }); + + it("returns null for a null plaintext", async () => { + expect(await hashSharePassword(null)).toBeNull(); + }); + + it("returns a bcrypt hash that round-trips through verifySharePassword", async () => { + const hash = await hashSharePassword("hunter2"); + expect(hash).not.toBeNull(); + expect(hash).not.toBe("hunter2"); + expect(await verifySharePassword(hash, "hunter2")).toEqual({ kind: "ok" }); + }); +}); diff --git a/utils/share/sharePassword.ts b/utils/share/sharePassword.ts new file mode 100644 index 0000000..085f218 --- /dev/null +++ b/utils/share/sharePassword.ts @@ -0,0 +1,49 @@ +import bcrypt from "bcryptjs"; + +/** + * Outcome of attempting to access a share with a candidate password. + * + * - `open`: the share has no password set; access is unrestricted + * - `passwordRequired`: a password is set but the caller did not supply one + * - `incorrect`: a password was supplied but does not match + * - `ok`: the supplied password matches the stored hash + */ +export type ShareAccessResult = + | { kind: "open" } + | { kind: "passwordRequired" } + | { kind: "incorrect" } + | { kind: "ok" }; + +const BCRYPT_SALT_ROUNDS = 10; + +/** + * Decide whether the caller is allowed to read a share, given the share's + * stored password hash and the candidate password they supplied. + * + * Null/empty stored hash means the share is unprotected. A protected share + * with no candidate returns `passwordRequired` — distinguishing "you forgot + * to send a password" from "your password is wrong". + */ +export const verifySharePassword = async ( + storedHash: string | null | undefined, + candidate: string | null | undefined, +): Promise => { + if (!storedHash || storedHash.length === 0) return { kind: "open" }; + if (!candidate) return { kind: "passwordRequired" }; + const match = await bcrypt.compare(candidate, storedHash); + return match ? { kind: "ok" } : { kind: "incorrect" }; +}; + +/** + * Hash a plaintext password for persistence on a Share document. + * + * Returns `null` for empty/null input — callers should treat that as "no + * password protection". This keeps the protection contract symmetrical with + * {@link verifySharePassword}. + */ +export const hashSharePassword = async ( + plaintext: string | null | undefined, +): Promise => { + if (!plaintext || plaintext.length === 0) return null; + return bcrypt.hash(plaintext, BCRYPT_SALT_ROUNDS); +}; From 24552faa1811741e27f6b99746178ef73ef975d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 08:50:51 +0000 Subject: [PATCH 08/11] refactor(api/project): use hashSharePassword for share writes Both SHARE branches now delegate password hashing to the share module. The optional-chaining null guard is kept so the existing sharing.test.ts regression continues to detect the original Bug 4 crash, but the salt-rounds policy and the bcrypt dependency now live in one place. --- pages/api/project.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pages/api/project.ts b/pages/api/project.ts index 881c156..8e7f633 100644 --- a/pages/api/project.ts +++ b/pages/api/project.ts @@ -10,12 +10,12 @@ * @copyright © 2020 - 2020 MU */ -import bcrypt from "bcryptjs"; import { StatusCodes } from "http-status-codes"; import { ProjectApiActions, ProjectDocInterface, ShareDocInterface } from "@/shared/types"; import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser"; import { Note, Project, Share } from "@/utils/mongoose"; +import { hashSharePassword } from "@/utils/share/sharePassword"; export default withAuthenticatedUser(async (req, res, { userDoc, newToken }) => { // data passed @@ -73,7 +73,7 @@ export default withAuthenticatedUser(async (req, res, { userDoc, newToken }) => shareDoc = await Share.findById(projectDoc.share); // hash password if we are given one if (shareData.password?.length > 0) - shareData.password = await bcrypt.hash(shareData.password, 10); + shareData.password = await hashSharePassword(shareData.password); // update share project doc await shareDoc.updateOne({ $set: shareData }); await shareDoc.save(); @@ -89,7 +89,7 @@ export default withAuthenticatedUser(async (req, res, { userDoc, newToken }) => // hash password if one is provided and overwrite if (shareData.password?.length > 0) - shareData.password = await bcrypt.hash(shareData.password, 10); + shareData.password = await hashSharePassword(shareData.password); try { // create share doc From 62f99d7ebef69da09fd9dd5d42d2597088e4c460 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 08:51:34 +0000 Subject: [PATCH 09/11] refactor(api/public_project): use verifySharePassword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline bcrypt branch with a call to the shared module. The read path is now null-safe — the previous \`shareDoc.password.length\` crash when password was null/undefined is gone, since the helper treats empty/null as "open access" by contract. Also adds an explicit null guard for \`shareDoc\` so a missing share url returns a clear 400 instead of falling into the bare catch. Status codes for passwordRequired/incorrect are intentionally kept at 200 to preserve the current client contract; see CONTEXT.md follow-ups. --- pages/api/public_project.ts | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/pages/api/public_project.ts b/pages/api/public_project.ts index 7138f99..5931574 100644 --- a/pages/api/public_project.ts +++ b/pages/api/public_project.ts @@ -10,12 +10,12 @@ * @copyright © 2020 - 2020 MU */ -import bcrypt from "bcryptjs"; import { StatusCodes } from "http-status-codes"; import { NextApiRequest, NextApiResponse } from "next"; import { ProjectDocInterface, ShareDocInterface } from "@/shared/types"; import { Project, Share } from "@/utils/mongoose"; +import { verifySharePassword } from "@/utils/share/sharePassword"; // GET 1 PROJECT export default async (req: NextApiRequest, res: NextApiResponse) => { @@ -24,24 +24,21 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { // get project let projectDoc: ProjectDocInterface; - let shareDoc: ShareDocInterface; try { - shareDoc = await Share.findOne({ url: shareUrl }); - - if (shareDoc.password.length > 0) { - // 'shared project password required' + const shareDoc: ShareDocInterface = await Share.findOne({ url: shareUrl }); + if (!shareDoc) { + return res.status(StatusCodes.BAD_REQUEST).json({ msg: "Share url does not exist." }); + } - if (password) { - // compare passwords - const match = await bcrypt.compare(password, shareDoc.password); - console.log({ match }); - if (!match) { - return res.status(StatusCodes.OK).json({ msg: "password incorrect" }); - } - } else { - // else - return res.status(StatusCodes.OK).json({ msg: "shared project password required" }); - } + const access = await verifySharePassword(shareDoc.password, password); + // Status code stays 200 here so the existing client (globalContext.tsx) + // keeps keying off `data.msg`. See CONTEXT.md follow-ups for the planned + // 401 migration once the client switches to status-based handling. + if (access.kind === "passwordRequired") { + return res.status(StatusCodes.OK).json({ msg: "shared project password required" }); + } + if (access.kind === "incorrect") { + return res.status(StatusCodes.OK).json({ msg: "password incorrect" }); } projectDoc = await Project.findById(shareDoc.project) From fd54f96b25e09cda29248e3fddf3a0d72864f56d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 08:54:31 +0000 Subject: [PATCH 10/11] docs(architecture): document auth and share seams - README "API conventions" section explains how to use withAuthenticatedUser, withOptionalUser, hashSharePassword and verifySharePassword in route handlers. - CONTEXT.md (new) defines the project vocabulary across the Domain (User/Project/Note/Share/Settings) and the two new architecture seams (Identity/Auth, Share). Includes a "Known follow-ups" section so future architecture reviews don't re-suggest the same out-of-scope items. - .markdownlint-cli2.jsonc relaxes MD013 (line length) and MD028 (blank line in blockquote) so prose docs are not constrained to 80-char wrapping; pre-existing README content already exceeded that threshold. --- .markdownlint-cli2.jsonc | 8 +++ CONTEXT.md | 111 +++++++++++++++++++++++++++++++++++++++ README.md | 28 ++++++++++ 3 files changed, 147 insertions(+) create mode 100644 .markdownlint-cli2.jsonc create mode 100644 CONTEXT.md diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..fe02fed --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,8 @@ +{ + "config": { + "MD013": false, + "MD028": false, + "MD041": false, + }, + "ignores": ["node_modules", "pnpm-lock.yaml", ".next", "plop-templates"], +} diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..a29a697 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,111 @@ +# VideoNote + +A video review app where a **User** owns **Projects**, each containing +timestamped **Notes**, and can publish a project as a **Share** so others +can read or contribute via a public URL. + +## Language + +### Domain + +**User**: +A registered account with credentials and personal **Settings**. +_Avoid_: account, member. + +**Project**: +A video plus the **Notes** taken against it. Owned by exactly one **User**. +_Avoid_: video, board. + +**Note**: +A timestamped comment attached to a **Project**. Has `content`, `time`, +`done`, optional author **User**. +_Avoid_: comment, marker. + +**Share**: +A publishing record that exposes a **Project** at a public URL with optional +password protection and an `canEdit` flag. One **Project** has at most one +**Share**. +_Avoid_: link, public link. + +**Settings**: +Per-user playback and UI preferences (e.g. `playOffset`, `seekJump`, +`sidebarWidth`, `currentProject`). + +### Architecture (Identity / Auth seam) + +**AuthContext**: +The bag passed into every handler wrapped by `withAuthenticatedUser`: +`{ userDoc, email, newToken }`. The `newToken` is already rotated; handlers +just echo it back to the client. + +**OptionalAuthContext**: +The discriminated bag for `withOptionalUser`. Either `{ isGuest: true, +userDoc: null, email: null, newToken: null }` or the same shape as +`AuthContext` with `isGuest: false`. + +**withAuthenticatedUser**: +The wrapper that owns the JWT-extraction → verify → user-lookup contract. +Lives in `utils/auth/withAuthenticatedUser.ts`. Handlers never read +`req.headers["authorization"]` directly. + +**withOptionalUser**: +Same wrapper for routes that allow guests (currently only `pages/api/note.ts`). +A _missing_ token routes to the guest branch; a _present-but-invalid_ token +still 401s — there is no silent fallback. + +### Architecture (Share seam) + +**ShareAccessResult**: +The discriminated outcome returned by `verifySharePassword`: +`open` | `passwordRequired` | `incorrect` | `ok`. Lets the read path map +each case to a response without branching on string messages. + +**verifySharePassword / hashSharePassword**: +The pure pair in `utils/share/sharePassword.ts` that owns the password +contract for shares. Both treat empty/null as "no password protection". + +## Relationships + +- A **User** owns many **Projects**; a **Project** has one **User**. +- A **Project** has many **Notes**; a **Note** belongs to one **Project**. +- A **Project** may have one **Share**; a **Share** belongs to one **Project**. +- A guest (no JWT) can create a **Note** against a shared **Project** when the + **Share** has `canEdit: true`. They never own a **User**. + +## Example dialogue + +> **Dev:** "When a guest hits `/api/note` to add a **Note**, who's recorded +> as the author?" +> **Domain:** "Nobody. The **Note** persists with `user` undefined. The +> wrapper signals guest mode via `ctx.isGuest === true`, and the handler +> skips the `user` assignment." + +> **Dev:** "If a **Share** has no password, what does +> `verifySharePassword` return?" +> **Domain:** "`{ kind: 'open' }`. The same shape whether `storedHash` is +> `null`, `undefined`, or `""` — those all mean unprotected. That's why the +> public read path no longer crashes on the legacy null case." + +## Known follow-ups + +These are deliberately out of scope for the current change but worth +re-suggesting in a future architecture review: + +- **public_project status codes**: `passwordRequired` and `incorrect` + currently respond with HTTP 200 + `msg` because the existing client at + `src/context/globalContext.tsx` keys off `data.msg` rather than + `res.status`. Migrating both server and client to 401/403 would let + generic HTTP middleware handle these cases. +- **Mongoose `Document.remove()` deprecation**: `pages/api/user.js` calls + `userDoc.remove()` (legacy API) and `projectDoc.remove()` for project + cleanup. Both should be replaced with `deleteOne()` to align with the + bundled Mongoose version. +- **`utils/apiHelpers.ts` field names**: the helpers strip `created` and + `updated` from documents but the Mongoose schemas use `createdAt` / + `updatedAt` (timestamps option). The strip currently does nothing. +- **`globalContext.tsx` god-object**: 792 LOC, 28 exposed properties; a + separate review should consider splitting it along the same seam lines + used for the API (Identity, Project, Note, Share). +- **`pages/api/project.ts` & `pages/api/note.ts` action dispatch**: a + single handler with a `switch(action)` for 6+ ops makes individual + branches hard to test without exercising the whole route. diff --git a/README.md b/README.md index e0aad85..dd0c786 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,31 @@ Create a `.env.local` based on the `.env.example` for local development. ### Run it `npm i && npm run dev` + +## API conventions + +Routes under `pages/api/` use two small higher-order wrappers from +`utils/auth/withAuthenticatedUser.ts` to keep authentication out of the +handler bodies: + +- **`withAuthenticatedUser(handler)`** — required for routes that mutate user + data (`auth`, `user`, `settings`, `project`). The handler receives a third + `ctx` argument with `{ userDoc, email, newToken }`. Missing/invalid tokens + short-circuit with 401; missing users short-circuit with 401. +- **`withOptionalUser(handler)`** — used by `pages/api/note.ts`, which allows + guest note creation. The handler receives a discriminated `ctx`: either + `{ isGuest: true, ... null }` or the full authenticated context. A token + that is present but invalid still 401s — there is no silent fallback to the + guest branch. + +Shared-project access goes through `utils/share/sharePassword.ts`: + +- **`hashSharePassword(plaintext)`** — used by `pages/api/project.ts` when + creating or updating a Share document. Returns `null` for empty input. +- **`verifySharePassword(storedHash, candidate)`** — used by + `pages/api/public_project.ts` when reading a shared project. Returns a + `ShareAccessResult` discriminated union: `open` | `passwordRequired` | + `incorrect` | `ok`. + +Both modules have unit tests under `src/__test__/` that exercise behaviour +through their public interface. From f992fb029f53da822b7c69dff988e7c10ca42806 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 08:58:03 +0000 Subject: [PATCH 11/11] refactor(api): simplify after architecture review - pages/api/auth: drop the second User.findOne and populate the userDoc the wrapper already loaded. Auth checks now make one Mongo round-trip instead of two. - pages/api/note: delete the empty if (action === REMOVE) {} branch carried over from the original; it never did anything. - utils/auth/withAuthenticatedUser: document why extractBearer anchors the prefix at the start of the header. --- pages/api/auth.js | 29 +++++++++-------------------- pages/api/note.ts | 2 -- utils/auth/withAuthenticatedUser.ts | 2 ++ 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/pages/api/auth.js b/pages/api/auth.js index 8f40766..3416289 100644 --- a/pages/api/auth.js +++ b/pages/api/auth.js @@ -2,36 +2,25 @@ import { StatusCodes } from "http-status-codes"; import { extractUser } from "@/utils/apiHelpers"; import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser"; -import { User } from "@/utils/mongoose"; -export default withAuthenticatedUser(async (req, res, { email }) => { - // get user via email (including their settings, projects & notes per project) - const user = await User.findOne({ email }) - .populate({ path: "settings", model: "Settings" }) - .populate({ +export default withAuthenticatedUser(async (req, res, { userDoc }) => { + await userDoc.populate([ + { path: "settings", model: "Settings" }, + { path: "projects", model: "Project", populate: [ { path: "notes", model: "Note", - populate: { - path: "user", - model: "User", - select: "username email", - }, - }, - { - path: "share", - model: "Share", + populate: { path: "user", model: "User", select: "username email" }, }, + { path: "share", model: "Share" }, ], - }) - .lean(); - - if (user === null) return res.status(StatusCodes.BAD_REQUEST).json({ msg: "No user found." }); + }, + ]); res.status(StatusCodes.OK).json({ - user: extractUser(user), + user: extractUser(userDoc.toObject()), }); }); diff --git a/pages/api/note.ts b/pages/api/note.ts index ce8c5b3..8ab75b2 100644 --- a/pages/api/note.ts +++ b/pages/api/note.ts @@ -53,8 +53,6 @@ export default withOptionalUser(async (req, res, ctx) => { await projectDoc.save(); } } - if (action === NoteApiAction.REMOVE) { - } } catch (error) { console.error(error); return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ msg: "Database error", error }); diff --git a/utils/auth/withAuthenticatedUser.ts b/utils/auth/withAuthenticatedUser.ts index ac2582e..079eb48 100644 --- a/utils/auth/withAuthenticatedUser.ts +++ b/utils/auth/withAuthenticatedUser.ts @@ -43,6 +43,8 @@ export type OptionalAuthHandler = ( ctx: OptionalAuthContext, ) => unknown | Promise; +// Anchored at the start so a bogus header like "garbage bearer x.y.z" does not +// silently slice into a valid-looking token; it falls through and 401s. const extractBearer = (header: string | string[] | undefined): string | null => { if (typeof header !== "string" || header.length === 0) return null; return header.replace(/^bearer\s+/i, "");