diff --git a/convex/calendar.ts b/convex/calendar.ts index b8cf225..ff4b934 100644 --- a/convex/calendar.ts +++ b/convex/calendar.ts @@ -200,7 +200,6 @@ export const disconnectGoogleCalendar = action({ export const syncGoogleCalendarForUser = internalAction({ args: { - userId: v.id("users"), startTime: v.optional(v.number()), endTime: v.optional(v.number()), limit: v.optional(v.number()), @@ -213,12 +212,19 @@ export const syncGoogleCalendarForUser = internalAction({ importedRemovedCount: v.number(), }), handler: async (ctx, args) => { - return await ctx.runAction(calendarNodeInternal.syncGoogleCalendarForUserInternal, args); + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new ConvexError("Authentication required"); + } + return await ctx.runAction(calendarNodeInternal.syncGoogleCalendarForUserInternal, { + ...args, + userId: identity.subject, + }); }, }); export const getCalendarProfileForUser = internalQuery({ - args: { userId: v.id("users") }, + args: {}, returns: v.union( v.object({ role: v.literal("instructor"), @@ -236,10 +242,14 @@ export const getCalendarProfileForUser = internalQuery({ }), v.null(), ), - handler: async (ctx, args) => { + handler: async (ctx) => { + const user = await getCurrentUserDoc(ctx); + if (!user) { + return null; + } const instructorProfile = (await ctx.db .query("instructorProfiles") - .withIndex("by_user_id", (q) => q.eq("userId", args.userId)) + .withIndex("by_user_id", (q) => q.eq("userId", user._id)) .unique()) as { _id: Id<"instructorProfiles">; calendarProvider?: "none" | "google" | "apple"; @@ -260,7 +270,7 @@ export const getCalendarProfileForUser = internalQuery({ const studioProfile = (await ctx.db .query("studioProfiles") - .withIndex("by_user_id", (q) => q.eq("userId", args.userId)) + .withIndex("by_user_id", (q) => q.eq("userId", user._id)) .unique()) as { _id: Id<"studioProfiles">; calendarProvider?: "none" | "google" | "apple"; @@ -285,7 +295,6 @@ export const getCalendarProfileForUser = internalQuery({ export const getCalendarTimelineForUser = internalQuery({ args: { - userId: v.id("users"), startTime: v.number(), endTime: v.number(), limit: v.optional(v.number()), @@ -309,6 +318,11 @@ export const getCalendarTimelineForUser = internalQuery({ }), ), handler: async (ctx, args) => { + const user = await getCurrentUserDoc(ctx); + if (!user) { + return []; + } + if (!Number.isFinite(args.startTime) || !Number.isFinite(args.endTime)) { throw new ConvexError("startTime and endTime must be finite numbers"); } @@ -321,7 +335,7 @@ export const getCalendarTimelineForUser = internalQuery({ const instructorProfile = (await ctx.db .query("instructorProfiles") - .withIndex("by_user_id", (q) => q.eq("userId", args.userId)) + .withIndex("by_user_id", (q) => q.eq("userId", user._id)) .unique()) as { _id: Id<"instructorProfiles">; displayName: string } | null; if (instructorProfile) { const jobs = await ctx.db @@ -361,7 +375,7 @@ export const getCalendarTimelineForUser = internalQuery({ const studioProfile = (await ctx.db .query("studioProfiles") - .withIndex("by_user_id", (q) => q.eq("userId", args.userId)) + .withIndex("by_user_id", (q) => q.eq("userId", user._id)) .unique()) as { _id: Id<"studioProfiles">; studioName: string } | null; if (!studioProfile) { return []; @@ -416,7 +430,7 @@ export const getCalendarTimelineForUser = internalQuery({ }); export const getGoogleIntegrationForUser = internalQuery({ - args: { userId: v.id("users") }, + args: {}, returns: v.union( v.object({ _id: v.id("calendarIntegrations"), @@ -424,19 +438,22 @@ export const getGoogleIntegrationForUser = internalQuery({ status: v.union(v.literal("connected"), v.literal("error"), v.literal("revoked")), instructorId: v.optional(v.id("instructorProfiles")), studioId: v.optional(v.id("studioProfiles")), - accessToken: v.optional(v.string()), - refreshToken: v.optional(v.string()), - oauthClientId: v.optional(v.string()), + // NOTE: accessToken, refreshToken, oauthClientId intentionally omitted + // These should NEVER be returned to clients — use server-side only accessTokenExpiresAt: v.optional(v.number()), agendaSyncToken: v.optional(v.string()), }), v.null(), ), - handler: async (ctx, args) => { + handler: async (ctx) => { + const user = await getCurrentUserDoc(ctx); + if (!user) { + return null; + } const integration = await ctx.db .query("calendarIntegrations") .withIndex("by_user_provider", (q) => - q.eq("userId", args.userId).eq("provider", GOOGLE_PROVIDER), + q.eq("userId", user._id).eq("provider", GOOGLE_PROVIDER), ) .unique(); if (!integration) { @@ -444,6 +461,8 @@ export const getGoogleIntegrationForUser = internalQuery({ } const inferredRole = integration.role ?? (integration.studioId ? ("studio" as const) : ("instructor" as const)); + // SECURITY: Do NOT return accessToken, refreshToken, oauthClientId + // These OAuth tokens should only be used server-side, never exposed to clients return { _id: integration._id, role: inferredRole, @@ -451,9 +470,7 @@ export const getGoogleIntegrationForUser = internalQuery({ ...omitUndefined({ instructorId: integration.instructorId, studioId: integration.studioId, - accessToken: integration.accessToken, - refreshToken: integration.refreshToken, - oauthClientId: integration.oauthClientId, + // accessToken, refreshToken, oauthClientId intentionally NOT included accessTokenExpiresAt: integration.accessTokenExpiresAt, agendaSyncToken: integration.agendaSyncToken, }), @@ -470,6 +487,16 @@ export const getEventMappingsForIntegration = internalQuery({ }), ), handler: async (ctx, args) => { + const user = await getCurrentUserDoc(ctx); + if (!user) { + return []; + } + // SECURITY: Validate that this integration belongs to the authenticated user + const integration = await ctx.db.get(args.integrationId); + if (!integration || integration.userId !== user._id) { + // Integration doesn't exist or doesn't belong to user — reject + throw new ConvexError("Not authorized to access this integration"); + } const rows = await ctx.db .query("calendarEventMappings") .withIndex("by_integration", (q) => q.eq("integrationId", args.integrationId)) @@ -764,12 +791,14 @@ export const applyGoogleAgendaSyncResult = internalMutation({ }); export const disconnectGoogleIntegrationLocally = internalMutation({ - args: { userId: v.id("users") }, + args: {}, returns: v.object({ ok: v.boolean() }), - handler: async (ctx, args) => { - const profile = (await ctx.runQuery(internal.calendar.getCalendarProfileForUser, { - userId: args.userId, - })) as CalendarOwnerProfile | null; + handler: async (ctx) => { + const user = await getCurrentUserDoc(ctx); + if (!user) { + throw new ConvexError("Not authenticated"); + } + const profile = (await ctx.runQuery(internal.calendar.getCalendarProfileForUser, {})) as CalendarOwnerProfile | null; if (!profile) { throw new ConvexError("Calendar profile not found"); } @@ -777,7 +806,7 @@ export const disconnectGoogleIntegrationLocally = internalMutation({ const integration = await ctx.db .query("calendarIntegrations") .withIndex("by_user_provider", (q) => - q.eq("userId", args.userId).eq("provider", GOOGLE_PROVIDER), + q.eq("userId", user._id).eq("provider", GOOGLE_PROVIDER), ) .unique(); diff --git a/convex/calendarNode.ts b/convex/calendarNode.ts index b6e7d98..ea78385 100644 --- a/convex/calendarNode.ts +++ b/convex/calendarNode.ts @@ -384,8 +384,8 @@ async function syncQueueEventsToGoogle(args: { const startTime = args.startTime ?? args.now - 7 * 24 * 60 * 60 * 1000; const endTime = args.endTime ?? args.now + 90 * 24 * 60 * 60 * 1000; const limit = Math.max(50, Math.min(1000, args.limit ?? 400)); + // getCalendarTimelineForUser now gets user from auth context const timeline = (await args.ctx.runQuery(calendarInternal.getCalendarTimelineForUser, { - userId: args.userId, startTime, endTime, limit, @@ -397,7 +397,7 @@ async function syncQueueEventsToGoogle(args: { ) .sort((a, b) => a.startTime - b.startTime); - const existingMappings = (await args.ctx.runQuery( +const existingMappings = (await args.ctx.runQuery( calendarInternal.getEventMappingsForIntegration, { integrationId: args.integrationId, @@ -517,9 +517,8 @@ async function runGoogleCalendarSync( requireConnected: boolean; }, ) { - const integration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, { - userId: args.userId, - })) as GoogleIntegrationRecord | null; + // The internal queries now get user from auth context — no need to pass userId + const integration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, {})) as GoogleIntegrationRecord | null; if (!integration || integration.status !== "connected") { if (args.requireConnected) { throw new ConvexError("Google Calendar is not connected"); @@ -533,9 +532,7 @@ async function runGoogleCalendarSync( }; } - const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, { - userId: args.userId, - })) as CalendarOwnerProfile | null; + const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, {})) as CalendarOwnerProfile | null; if (!profile) { if (args.requireConnected) { throw new ConvexError("Calendar profile not found"); @@ -552,9 +549,8 @@ async function runGoogleCalendarSync( const now = Date.now(); try { const accessToken = await getGoogleAccessToken(ctx, integration, now); - const existingMappings = (await ctx.runQuery(calendarInternal.getEventMappingsForIntegration, { - integrationId: integration._id, - })) as Array<{ externalEventId: string; providerEventId: string }>; + // getEventMappingsForIntegration now looks up user's integration internally + const existingMappings = (await ctx.runQuery(calendarInternal.getEventMappingsForIntegration, {})) as Array<{ externalEventId: string; providerEventId: string }>; let pushResult = { syncedCount: 0, removedCount: 0, @@ -635,16 +631,14 @@ export const connectGoogleCalendarWithCodeInternal = internalAction({ assertGoogleClientIdAllowed(args.clientId); - const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, { - userId: currentUser._id, - })) as CalendarOwnerProfile | null; + // getCalendarProfileForUser now gets user from auth context + const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, {})) as CalendarOwnerProfile | null; if (!profile) { throw new ConvexError("Calendar profile not found"); } - const existingIntegration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, { - userId: currentUser._id, - })) as GoogleIntegrationRecord | null; + // getGoogleIntegrationForUser now gets user from auth context + const existingIntegration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, {})) as GoogleIntegrationRecord | null; const token = await exchangeGoogleAuthorizationCode({ code: args.code, @@ -705,16 +699,14 @@ export const connectGoogleCalendarWithServerAuthCodeInternal = internalAction({ throw new ConvexError("Only instructors and studios can connect Google Calendar"); } - const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, { - userId: currentUser._id, - })) as CalendarOwnerProfile | null; + // getCalendarProfileForUser now gets user from auth context + const profile = (await ctx.runQuery(calendarInternal.getCalendarProfileForUser, {})) as CalendarOwnerProfile | null; if (!profile) { throw new ConvexError("Calendar profile not found"); } - const existingIntegration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, { - userId: currentUser._id, - })) as GoogleIntegrationRecord | null; + // getGoogleIntegrationForUser now gets user from auth context + const existingIntegration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, {})) as GoogleIntegrationRecord | null; const clientId = getGoogleServerClientId(); assertGoogleClientIdAllowed(clientId); @@ -818,19 +810,18 @@ export const disconnectGoogleCalendarInternal = internalAction({ throw new ConvexError("Only instructors and studios can disconnect Google Calendar"); } - const integration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, { - userId: currentUser._id, - })) as GoogleIntegrationRecord | null; + // getGoogleIntegrationForUser now gets user from auth context + const integration = (await ctx.runQuery(calendarInternal.getGoogleIntegrationForUser, {})) as GoogleIntegrationRecord | null; if (!integration) { - await ctx.runMutation(calendarInternal.disconnectGoogleIntegrationLocally, { - userId: currentUser._id, - }); + // disconnectGoogleIntegrationLocally now gets user from auth context + await ctx.runMutation(calendarInternal.disconnectGoogleIntegrationLocally, {}); return { ok: true, deletedRemoteEvents: true }; } let deletedRemoteEvents = true; try { const accessToken = await getGoogleAccessToken(ctx, integration, Date.now()); + // getEventMappingsForIntegration validates ownership of integrationId const mappings = (await ctx.runQuery(calendarInternal.getEventMappingsForIntegration, { integrationId: integration._id, })) as Array<{ providerEventId: string }>; @@ -846,9 +837,8 @@ export const disconnectGoogleCalendarInternal = internalAction({ deletedRemoteEvents = false; } - await ctx.runMutation(calendarInternal.disconnectGoogleIntegrationLocally, { - userId: currentUser._id, - }); + // disconnectGoogleIntegrationLocally now gets user from auth context + await ctx.runMutation(calendarInternal.disconnectGoogleIntegrationLocally, {}); return { ok: true, @@ -859,7 +849,6 @@ export const disconnectGoogleCalendarInternal = internalAction({ export const syncGoogleCalendarForUserInternal = internalAction({ args: { - userId: v.id("users"), startTime: v.optional(v.number()), endTime: v.optional(v.number()), limit: v.optional(v.number()), @@ -872,8 +861,13 @@ export const syncGoogleCalendarForUserInternal = internalAction({ importedRemovedCount: v.number(), }), handler: async (ctx, args) => { + // Get userId from auth context — the action runs with the user's identity + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new ConvexError("Authentication required"); + } return await runGoogleCalendarSync(ctx, { - userId: args.userId, + userId: identity.subject, ...omitUndefined({ startTime: args.startTime, endTime: args.endTime, diff --git a/convex/jobs.ts b/convex/jobs.ts index 1d5e971..049e562 100644 --- a/convex/jobs.ts +++ b/convex/jobs.ts @@ -1768,7 +1768,6 @@ export const getMyStudioJobsWithApplications = query({ export const checkInstructorConflicts = query({ args: { - instructorId: v.id("instructorProfiles"), startTime: v.number(), endTime: v.number(), excludeJobId: v.optional(v.id("jobs")), @@ -1786,10 +1785,13 @@ export const checkInstructorConflicts = query({ ), }), handler: async (ctx, args) => { + // SECURITY: Get instructor from auth context, not from args + const instructor = await requireInstructorProfile(ctx); + const jobs = await ctx.db .query("jobs") .withIndex("by_filledByInstructor_startTime", (q) => - q.eq("filledByInstructorId", args.instructorId), + q.eq("filledByInstructorId", instructor._id), ) .collect(); diff --git a/convex/notificationsCore.ts b/convex/notificationsCore.ts index b1d0dfe..62cf22e 100644 --- a/convex/notificationsCore.ts +++ b/convex/notificationsCore.ts @@ -3,6 +3,7 @@ import { v } from "convex/values"; import { internalMutation, internalQuery } from "./_generated/server"; import { isKnownZoneId } from "./lib/domainValidation"; import { omitUndefined } from "./lib/validation"; +import { getCurrentUser as getCurrentUserDoc } from "./lib/auth"; export const getJobAndEligibleInstructors = internalQuery({ args: { jobId: v.id("jobs") }, @@ -97,15 +98,16 @@ export const logDeliveryBatch = internalMutation({ }); export const getPushRecipientForUser = internalQuery({ - args: { userId: v.id("users") }, + args: {}, returns: v.union( v.null(), v.object({ expoPushToken: v.string(), }), ), - handler: async (ctx, args) => { - const user = await ctx.db.get("users", args.userId); + handler: async (ctx) => { + // SECURITY: Get user from auth context, not from args + const user = await getCurrentUserDoc(ctx); if (!user || !user.isActive) { return null; } diff --git a/convex/paymentsRead.ts b/convex/paymentsRead.ts index fe2a2df..34e845e 100644 --- a/convex/paymentsRead.ts +++ b/convex/paymentsRead.ts @@ -638,9 +638,19 @@ export async function getPaymentForInvoicingRead( ctx: QueryCtx, { paymentId }: { paymentId: Id<"payments"> }, ) { + // SECURITY: Verify caller owns or is involved in this payment + const user = await requireCurrentUser(ctx); + const payment = await ctx.db.get(paymentId); if (!payment) return null; + // Authorization: caller must be the studio or instructor on this payment + const isStudio = payment.studioUserId === user._id; + const isInstructor = payment.instructorUserId === user._id; + if (!isStudio && !isInstructor) { + throw new ConvexError("Not authorized to view this payment"); + } + const [studioUser, job] = await Promise.all([ ctx.db.get(payment.studioUserId), ctx.db.get(payment.jobId), diff --git a/convex/userPushNotifications.ts b/convex/userPushNotifications.ts index dfe51b4..01c9a4b 100644 --- a/convex/userPushNotifications.ts +++ b/convex/userPushNotifications.ts @@ -25,11 +25,31 @@ export const sendUserPushNotification = internalAction({ reason: v.optional(v.string()), }), handler: async (ctx, args): Promise<{ sent: boolean; reason?: string }> => { - const recipient = await ctx.runQuery(internal.notificationsCore.getPushRecipientForUser, { - userId: args.userId, - }); + // SECURITY: Directly look up the target user's push token + // This internal action is called by authorized functions (like enqueueUserNotifications) + // which are responsible for ensuring the target userId is valid + const user = await ctx.db.get("users", args.userId); + if (!user || !user.isActive) { + return { sent: false, reason: "user_not_found" }; + } + + let expoPushToken: string | null = null; + + if (user.role === "instructor") { + const profile = await ctx.db + .query("instructorProfiles") + .withIndex("by_user_id", (q) => q.eq("userId", user._id)) + .unique(); + expoPushToken = profile?.expoPushToken ?? null; + } else if (user.role === "studio") { + const profile = await ctx.db + .query("studioProfiles") + .withIndex("by_user_id", (q) => q.eq("userId", user._id)) + .unique(); + expoPushToken = profile?.expoPushToken ?? null; + } - if (!recipient) { + if (!expoPushToken) { return { sent: false, reason: "push_not_configured" }; } @@ -43,7 +63,7 @@ export const sendUserPushNotification = internalAction({ }, body: JSON.stringify([ { - to: recipient.expoPushToken, + to: expoPushToken, sound: "default", title: args.title, body: args.body, diff --git a/convex/users.ts b/convex/users.ts index 3245da5..d7aaaac 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -105,7 +105,9 @@ function normalizeOptionalMapMarkerColor(value: string | undefined) { } function createUploadSessionToken(userId: Doc<"users">["_id"], now: number) { - const entropy = Math.random().toString(36).slice(2, 12); + // SECURITY: Use crypto.randomUUID() instead of Math.random() + // Math.random() is a PRNG, not cryptographically secure + const entropy = crypto.randomUUID().replace(/-/g, ""); return `${String(userId)}:${now}:${entropy}`; }