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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"config": {
"MD013": false,
"MD028": false,
"MD041": false,
},
"ignores": ["node_modules", "pnpm-lock.yaml", ".next", "plop-templates"],
}
111 changes: 111 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
50 changes: 11 additions & 39 deletions pages/api/auth.js
Original file line number Diff line number Diff line change
@@ -1,54 +1,26 @@
import { StatusCodes } from "http-status-codes";

import { extractUser } from "@/utils/apiHelpers";
import { authenticateToken } from "@/utils/jwt";
import { User } from "@/utils/mongoose";
import { withAuthenticatedUser } from "@/utils/auth/withAuthenticatedUser";

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" });
}

// 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()),
});
};
});
62 changes: 13 additions & 49 deletions pages/api/note.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,23 @@
import { StatusCodes } from "http-status-codes";
import { NextApiRequest, NextApiResponse } from "next";
import { NextApiResponse } from "next";

import {
NoteApiAction,
NoteDocInterface,
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;
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -83,29 +53,23 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
await projectDoc.save();
}
}
if (action === NoteApiAction.REMOVE) {
}
} catch (error) {
console.error(error);
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<void> => {
console.log("removing completed notes from project:", projectId);
Expand All @@ -114,8 +78,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,
Expand Down
Loading