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
37 changes: 35 additions & 2 deletions web/convex/auth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createClient, type AuthFunctions } from "@convex-dev/better-auth";
import { betterAuth, type BetterAuthOptions } from "better-auth";
import { convex, crossDomain } from "@convex-dev/better-auth/plugins";
import { admin } from "better-auth/plugins";
// import { magicLink } from "better-auth/plugins";
import { magicLink, admin } from "better-auth/plugins";
import { createAuthMiddleware, APIError } from "better-auth/api";
import { components, internal } from "./_generated/api";
import { query } from "./_generated/server";
import { v } from "convex/values";
Expand Down Expand Up @@ -64,6 +64,39 @@ export const createAuthOptions = (ctx: GenericCtx<DataModel>): BetterAuthOptions
emailAndPassword: {
enabled: true,
},
hooks: {
before: createAuthMiddleware(async (apiCtx) => {
if (apiCtx.path === "/sign-up/email") {
const body = apiCtx.body as any;
const code = body?.code;
if (!code) {
throw new APIError("BAD_REQUEST", {
message: "Invitation code is required.",
});
}
Comment on lines +69 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve the internal admin bootstrap path.

web/convex/init.ts still calls auth.api.signUpEmail without code, so this hook rejects createAdminUser before the admin can be created. Add a trusted internal bootstrap path or update admin provisioning so it does not hit the student invitation requirement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/convex/auth.ts` around lines 69 - 76, The /sign-up/email guard in
auth.api.signUpEmail is blocking internal admin bootstrap because
createAdminUser from auth.ts/init.ts calls it without an invitation code. Update
the sign-up hook to allow a trusted internal bootstrap path for admin
provisioning, or route createAdminUser through a separate admin-only path so the
student invitation code check is skipped for that flow. Use the existing
signUpEmail and createAdminUser symbols to keep the fix scoped correctly.


const invitation = await ctx.db
.query("invitationCodes")
.withIndex("by_code", (q) => q.eq("code", code))
.unique();

if (!invitation || !invitation.isValid || invitation.usesCount >= invitation.quantity) {
throw new APIError("BAD_REQUEST", {
message: "Invalid or expired invitation code.",
});
}

if (invitation.expiryDate) {
const expiry = new Date(invitation.expiryDate);
if (!isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) {
throw new APIError("BAD_REQUEST", {
message: "Invitation code has expired.",
});
}
}
Comment on lines +78 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Consume the invitation and create the gated profile after sign-up succeeds.

This hook only checks the code. It never increments usesCount, records whoUsed, or creates the userProfiles.by_token row now required by authenticatedQuery/authenticatedMutation, so valid users can be locked out and single-use codes can be reused. Move consumption/profile creation into a successful sign-up path and make the invite update atomic with the user/profile lifecycle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/convex/auth.ts` around lines 78 - 96, The invitation check in auth.ts
only validates the code and never consumes it, so update the successful sign-up
path to increment usesCount, record whoUsed, and create the required
userProfiles.by_token entry after signup completes. Make the invite consumption
and gated profile creation happen atomically with the user/profile lifecycle,
and reference the invitationCodes and userProfiles logic near the existing
invitation lookup and validation flow.

}
}),
},
plugins: [
convex({ authConfig, jwtExpirationSeconds: 60 * 60 * 24 }),
crossDomain({
Expand Down
40 changes: 22 additions & 18 deletions web/convex/courses.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,49 @@
import {
mutation,
query,
type MutationCtx,
type QueryCtx,
} from "./_generated/server";
import { v } from "convex/values";
import { zid } from "convex-helpers/server/zod4";
import { zMutation, zQuery } from "./functions";
import {
zAuthenticatedQuery,
zAuthenticatedMutation,
zAdminMutation,
authenticatedQuery,
authenticatedMutation,
adminQuery
} from "./functions";
import { courseFields } from "./validators";

// ---------------------------------------------------------------------------
// Course CRUD
// ---------------------------------------------------------------------------

/** Lists all courses, newest first. */
export const listCourses = zQuery({
export const listCourses = zAuthenticatedQuery({
args: {},
handler: async (ctx) => {
return await ctx.db.query("course").order("desc").collect();
},
});

/** Fetches a single course by id (null if it doesn't exist). */
export const getCourse = zQuery({
export const getCourse = zAuthenticatedQuery({
args: { id: zid("course") },
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});

/** Creates a course. Returns the new course id. */
export const createCourse = zMutation({
export const createCourse = zAdminMutation({
args: courseFields,
handler: async (ctx, args) => {
return await ctx.db.insert("course", args);
},
});

/** Updates a course's name and/or language. */
export const updateCourse = zMutation({
export const updateCourse = zAdminMutation({
args: {
id: zid("course"),
course_name: courseFields.course_name.optional(),
Expand All @@ -56,7 +61,7 @@ export const updateCourse = zMutation({
* Deletes a course and cascades: every lesson in the course (and each
* lesson's progress rows) is removed so nothing is left orphaned.
*/
export const deleteCourse = zMutation({
export const deleteCourse = zAdminMutation({
args: { id: zid("course") },
handler: async (ctx, { id }) => {
const lessons = await ctx.db
Expand All @@ -79,7 +84,7 @@ export const deleteCourse = zMutation({
});

// Return the last 100 tasks in a given task list.
export const getAllCourses = query({
export const getAllCourses = authenticatedQuery({
args: {},
handler: async (ctx, _args) => {
// take is not 100 - all
Expand All @@ -88,7 +93,7 @@ export const getAllCourses = query({
},
});

export const getQuestionById = query({
export const getQuestionById = authenticatedQuery({
args: { id: v.string() },
handler: async (ctx, args) => {
const id = ctx.db.normalizeId("questions", args.id);
Expand All @@ -115,10 +120,10 @@ async function getUserByToken(
* Returns a student's progress for every lesson they've started or completed.
* Each entry is { lessonId, status }. Lessons not present are "pending".
*/
export const getLessonProgress = query({
args: { tokenIdentifier: v.string() },
handler: async (ctx, args) => {
const user = await getUserByToken(ctx, args.tokenIdentifier);
export const getLessonProgress = authenticatedQuery({
args: {},
handler: async (ctx) => {
const user = await getUserByToken(ctx, ctx.user._id);
if (!user) return [];

const rows = await ctx.db
Expand All @@ -136,9 +141,8 @@ export const getLessonProgress = query({
* - "pending": removes the row (the UI default for lessons with no record).
* Looked up via the by_user_lesson index so it's a single-row upsert.
*/
export const setLessonStatus = mutation({
export const setLessonStatus = authenticatedMutation({
args: {
tokenIdentifier: v.string(),
lessonId: v.id("questions"),
status: v.union(
v.literal("in-progress"),
Expand All @@ -147,7 +151,7 @@ export const setLessonStatus = mutation({
),
},
handler: async (ctx, args) => {
const user = await getUserByToken(ctx, args.tokenIdentifier);
const user = await getUserByToken(ctx, ctx.user._id);
if (!user) {
throw new Error("Student not found.");
}
Expand Down Expand Up @@ -191,7 +195,7 @@ export const setLessonStatus = mutation({
* Admin view: how many students are in-progress vs completed for a lesson.
* Uses the by_lesson index so it scans only this lesson's progress rows.
*/
export const getLessonCompletionStats = query({
export const getLessonCompletionStats = adminQuery({
args: { lessonId: v.id("questions") },
handler: async (ctx, args) => {
const rows = await ctx.db
Expand Down
95 changes: 94 additions & 1 deletion web/convex/functions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,93 @@
import { zCustomMutation, zCustomQuery } from "convex-helpers/server/zod4";
import { NoOp } from "convex-helpers/server/customFunctions";
import { customMutation, customQuery, NoOp } from "convex-helpers/server/customFunctions";
import { mutation, query } from "./_generated/server";
import { authComponent } from "./auth";

/**
* Base query helper that requires user authentication.
* Resolves the authenticated user via `authComponent.safeGetAuthUser`.
* Also validates that the user has a profile record (in `userProfiles`),
* except for admin users.
*/
export const authenticatedQuery = customQuery(query, {
args: {},
input: async (ctx) => {
const user = await authComponent.safeGetAuthUser(ctx);
if (!user) {
throw new Error("Unauthenticated");
}

// Admins bypass the student profile requirement
if (user.role !== "admin") {
const profile = await ctx.db
.query("userProfiles")
.withIndex("by_token", (q) => q.eq("tokenIdentifier", user._id))
.unique();
if (!profile) {
throw new Error("Unauthorized: Student profile required. Please redeem an invitation code.");
}
}

return { ctx: { ...ctx, user }, args: {} };
},
});

/**
* Base mutation helper that requires user authentication.
* Resolves the authenticated user via `authComponent.safeGetAuthUser`.
* Also validates that the user has a profile record (in `userProfiles`),
* except for admin users.
*/
export const authenticatedMutation = customMutation(mutation, {
args: {},
input: async (ctx) => {
const user = await authComponent.safeGetAuthUser(ctx);
if (!user) {
throw new Error("Unauthenticated");
}

// Admins bypass the student profile requirement
if (user.role !== "admin") {
const profile = await ctx.db
.query("userProfiles")
.withIndex("by_token", (q) => q.eq("tokenIdentifier", user._id))
.unique();
if (!profile) {
throw new Error("Unauthorized: Student profile required. Please redeem an invitation code.");
}
}

return { ctx: { ...ctx, user }, args: {} };
},
});

/**
* Admin-only query helper.
* Requires user authentication and the role "admin".
*/
export const adminQuery = customQuery(authenticatedQuery, {
args: {},
input: async (ctx) => {
if (ctx.user.role !== "admin") {
throw new Error("Unauthorized: Admin privilege required");
}
return { ctx, args: {} };
},
});

/**
* Admin-only mutation helper.
* Requires user authentication and the role "admin".
*/
export const adminMutation = customMutation(authenticatedMutation, {
args: {},
input: async (ctx) => {
if (ctx.user.role !== "admin") {
throw new Error("Unauthorized: Admin privilege required");
}
return { ctx, args: {} };
},
});

/**
* Query/mutation builders that validate their `args` with Zod instead of
Expand All @@ -9,3 +96,9 @@ import { mutation, query } from "./_generated/server";
*/
export const zQuery = zCustomQuery(query, NoOp);
export const zMutation = zCustomMutation(mutation, NoOp);

export const zAuthenticatedQuery = zCustomQuery(authenticatedQuery, NoOp);
export const zAuthenticatedMutation = zCustomMutation(authenticatedMutation, NoOp);

export const zAdminQuery = zCustomQuery(adminQuery, NoOp);
export const zAdminMutation = zCustomMutation(adminMutation, NoOp);
10 changes: 5 additions & 5 deletions web/convex/init.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { mutation } from "./_generated/server";
import { internalMutation } from "./_generated/server";
import { createAuth, authComponent } from "./auth";
import { components } from "./_generated/api";

export const createAdminUser = mutation({
export const createAdminUser = internalMutation({
args: {},
handler: async (ctx) => {
const email = process.env.ADMIN_EMAIL;
Expand Down Expand Up @@ -42,7 +42,7 @@ export const createAdminUser = mutation({
},
});

export const removeAllAdminAccounts = mutation({
export const removeAllAdminAccounts = internalMutation({
args: {},
handler: async (ctx) => {
const email = process.env.ADMIN_EMAIL;
Expand Down Expand Up @@ -161,7 +161,7 @@ export const removeAllAdminAccounts = mutation({
},
});

export const listBetterAuthData = mutation({
export const listBetterAuthData = internalMutation({
args: {},
handler: async (ctx) => {
const paginationOpts = { cursor: null, numItems: 100 };
Expand All @@ -182,7 +182,7 @@ export const listBetterAuthData = mutation({
},
});

export const clearJwks = mutation({
export const clearJwks = internalMutation({
args: {},
handler: async (ctx) => {
const jwksPage = await ctx.runQuery(components.betterAuth.adapter.findMany, {
Expand Down
15 changes: 8 additions & 7 deletions web/convex/invitationCodes.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server";
import { v } from "convex/values";
import { adminQuery, adminMutation } from "./functions";

/**
* Creates a new invitation code.
* The usage limit (quantity) is hardcoded to 1.
*/
export const add = mutation({
export const add = adminMutation({
args: {
code: v.string(),
createdBy: v.optional(v.string()),
Expand Down Expand Up @@ -171,7 +172,7 @@ export const useCode = mutation({
/**
* Manually invalidates an invitation code.
*/
export const invalidateCode = mutation({
export const invalidateCode = adminMutation({
args: {
code: v.string(),
},
Expand All @@ -193,7 +194,7 @@ export const invalidateCode = mutation({
/**
* Lists all invitation codes. Intended for admin use.
*/
export const listAll = query({
export const listAll = adminQuery({
args: {},
handler: async (ctx) => {
return await ctx.db.query("invitationCodes").collect();
Expand All @@ -203,7 +204,7 @@ export const listAll = query({
/**
* Deletes an invitation code by its string code value.
*/
export const remove = mutation({
export const remove = adminMutation({
args: {
code: v.string(),
},
Expand Down Expand Up @@ -291,7 +292,7 @@ export const createUserAndUseCode = mutation({
/**
* Lists all invitation codes, sorted by creation time descending.
*/
export const list = query({
export const list = adminQuery({
args: {},
handler: async (ctx) => {
return await ctx.db.query("invitationCodes").order("desc").collect();
Expand All @@ -301,7 +302,7 @@ export const list = query({
/**
* Deletes an invitation code.
*/
export const deleteCode = mutation({
export const deleteCode = adminMutation({
args: {
id: v.id("invitationCodes"),
},
Expand All @@ -314,7 +315,7 @@ export const deleteCode = mutation({
/**
* Updates an invitation code's details.
*/
export const update = mutation({
export const update = adminMutation({
args: {
id: v.id("invitationCodes"),
code: v.optional(v.string()),
Expand Down
Loading
Loading