From 8916142e046257ed6f80a4f27e97466fe1e4b57f Mon Sep 17 00:00:00 2001 From: kenneth Date: Mon, 15 Sep 2025 21:44:41 +0000 Subject: [PATCH] feat: add auth to convex fns --- bun.lock | 9 ++++--- convex/_generated/api.d.ts | 4 +++ convex/files.ts | 49 ++++++++++++++++++++++++------------- convex/functions.ts | 39 +++++++++++++++++++++++++++++ convex/model/directories.ts | 21 +++++++++++++++- convex/model/error.ts | 1 + convex/model/user.ts | 38 ++++++++++++++++++++++++++++ convex/schema.ts | 10 +++----- convex/users.ts | 27 +++++++++++++++----- package.json | 1 + 10 files changed, 166 insertions(+), 33 deletions(-) create mode 100644 convex/functions.ts create mode 100644 convex/model/user.ts diff --git a/bun.lock b/bun.lock index d92b1db..c85967f 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "convex": "^1.27.0", + "convex-helpers": "^0.1.104", "jotai": "^2.14.0", "lucide-react": "^0.544.0", "next-themes": "^0.4.6", @@ -453,6 +454,8 @@ "convex": ["convex@1.27.0", "", { "dependencies": { "esbuild": "0.25.4", "jwt-decode": "^4.0.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-IHkqZX3GtY4nKFPTAR4mvWHHhDiQX9PM7EjpEv0pJWoMoq0On6oOL3iZ7Xz4Ls96dF7WJd4AjfitJsg2hUnLSQ=="], + "convex-helpers": ["convex-helpers@0.1.104", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.24.0", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.22.4 || ^4.0.15" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-7CYvx7T3K6n+McDTK4ZQaQNNGBzq5aWezpjzsKbOxPXx7oNcTP9wrpef3JxeXWFzkByJv5hRCjseh9B7eNJ7Ig=="], + "cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], @@ -523,7 +526,7 @@ "jotai": ["jotai@2.14.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-JQkNkTnqjk1BlSUjHfXi+pGG/573bVN104gp6CymhrWDseZGDReTNniWrLhJ+zXbM6pH+82+UNJ2vwYQUkQMWQ=="], - "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -679,8 +682,6 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -695,6 +696,8 @@ "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "tsdown/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index b1ef5ab..46e2aeb 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -14,8 +14,10 @@ import type { FunctionReference, } from "convex/server"; import type * as files from "../files.js"; +import type * as functions from "../functions.js"; import type * as model_directories from "../model/directories.js"; import type * as model_error from "../model/error.js"; +import type * as model_user from "../model/user.js"; import type * as users from "../users.js"; /** @@ -28,8 +30,10 @@ import type * as users from "../users.js"; */ declare const fullApi: ApiFromModules<{ files: typeof files; + functions: typeof functions; "model/directories": typeof model_directories; "model/error": typeof model_error; + "model/user": typeof model_user; users: typeof users; }>; export declare const api: FilterApi< diff --git a/convex/files.ts b/convex/files.ts index 0787c92..ffac908 100644 --- a/convex/files.ts +++ b/convex/files.ts @@ -1,16 +1,17 @@ import { v } from "convex/values" import type { Id } from "./_generated/dataModel" -import { mutation, query } from "./_generated/server" +import { authenticatedMutation, authenticatedQuery } from "./functions" import type { DirectoryItem } from "./model/directories" import * as Directories from "./model/directories" -export const generateUploadUrl = mutation({ +export const generateUploadUrl = authenticatedMutation({ handler: async (ctx) => { + // ctx.user and ctx.identity are automatically available return await ctx.storage.generateUploadUrl() }, }) -export const fetchFiles = query({ +export const fetchFiles = authenticatedQuery({ args: { directoryId: v.optional(v.id("directories")), }, @@ -18,49 +19,51 @@ export const fetchFiles = query({ return await ctx.db .query("files") .withIndex("byDirectoryId", (q) => q.eq("directoryId", directoryId)) + .filter((q) => q.eq(q.field("userId"), ctx.user._id)) .collect() }, }) -export const fetchDirectoryContent = query({ +export const fetchDirectoryContent = authenticatedQuery({ args: { directoryId: v.optional(v.id("directories")), }, handler: async (ctx, { directoryId }): Promise => { - return await Directories.fetchContent(ctx, directoryId) + return await Directories.fetchContent(ctx, directoryId, ctx.user._id) }, }) -export const createDirectory = mutation({ +export const createDirectory = authenticatedMutation({ args: { name: v.string(), directoryId: v.optional(v.id("directories")), }, handler: async (ctx, { name, directoryId }): Promise> => { - return await Directories.create(ctx, { name, parentId: directoryId }) + return await Directories.create(ctx, { + name, + parentId: directoryId, + userId: ctx.user._id, + }) }, }) -export const saveFile = mutation({ +export const saveFile = authenticatedMutation({ args: { name: v.string(), size: v.number(), directoryId: v.optional(v.id("directories")), storageId: v.id("_storage"), - userId: v.id("users"), mimeType: v.optional(v.string()), }, - handler: async ( - ctx, - { name, storageId, directoryId, userId, size, mimeType }, - ) => { + handler: async (ctx, { name, storageId, directoryId, size, mimeType }) => { const now = new Date().toISOString() + await ctx.db.insert("files", { name, size, storageId, directoryId, - userId, + userId: ctx.user._id, mimeType, createdAt: now, updatedAt: now, @@ -68,24 +71,36 @@ export const saveFile = mutation({ }, }) -export const moveToTrash = mutation({ +export const moveToTrash = authenticatedMutation({ args: { kind: v.union(v.literal("file"), v.literal("directory")), itemId: v.union(v.id("files"), v.id("directories")), }, handler: async (ctx, { itemId, kind }) => { + // Verify ownership before allowing deletion switch (kind) { - case "file": + case "file": { + const file = await ctx.db.get(itemId as Id<"files">) + if (!file || file.userId !== ctx.user._id) { + throw new Error("File not found or access denied") + } await ctx.db.patch(itemId, { deletedAt: new Date().toISOString(), }) break - case "directory": + } + case "directory": { + const directory = await ctx.db.get(itemId as Id<"directories">) + if (!directory || directory.userId !== ctx.user._id) { + throw new Error("Directory not found or access denied") + } await Directories.moveToTrashRecursive( ctx, itemId as Id<"directories">, + ctx.user._id, ) break + } } return itemId diff --git a/convex/functions.ts b/convex/functions.ts new file mode 100644 index 0000000..3fd5ba6 --- /dev/null +++ b/convex/functions.ts @@ -0,0 +1,39 @@ +import { + customMutation, + customQuery, +} from "convex-helpers/server/customFunctions" +import { mutation, query } from "./_generated/server" +import { userIdentityOrThrow, userOrThrow } from "./model/user" + +/** + * Custom query that automatically provides authenticated user context + * Throws an error if the user is not authenticated + */ +export const authenticatedQuery = customQuery(query, { + args: {}, + input: async (ctx, args) => { + const user = await userOrThrow(ctx) + const identity = await userIdentityOrThrow(ctx) + return { + ctx: { ...ctx, user, identity }, + args, + } + }, +}) + +/** + * Custom mutation that automatically provides authenticated user context + * Throws an error if the user is not authenticated + */ +export const authenticatedMutation = customMutation(mutation, { + args: {}, + input: async (ctx, args) => { + const user = await userOrThrow(ctx) + const identity = await userIdentityOrThrow(ctx) + + return { + ctx: { ...ctx, user, identity }, + args, + } + }, +}) diff --git a/convex/model/directories.ts b/convex/model/directories.ts index eaf52c8..fa1ced0 100644 --- a/convex/model/directories.ts +++ b/convex/model/directories.ts @@ -18,6 +18,7 @@ export type DirectoryItemKind = DirectoryItem["kind"] export async function fetchContent( ctx: QueryCtx, directoryId?: Id<"directories">, + userId?: Id<"users">, ): Promise { const [files, directories] = await Promise.all([ ctx.db @@ -25,11 +26,13 @@ export async function fetchContent( .withIndex("byDirectoryId", (q) => q.eq("directoryId", directoryId).eq("deletedAt", undefined), ) + .filter((q) => userId ? q.eq(q.field("userId"), userId) : q.neq(q.field("userId"), null)) .collect(), ctx.db .query("directories") .withIndex("byParentId", (q) => q.eq("parentId", directoryId)) .filter((q) => q.eq(q.field("deletedAt"), undefined)) + .filter((q) => userId ? q.eq(q.field("userId"), userId) : q.neq(q.field("userId"), null)) .collect(), ]) @@ -46,13 +49,25 @@ export async function fetchContent( export async function create( ctx: MutationCtx, - { name, parentId }: { name: string; parentId?: Id<"directories"> }, + { name, parentId, userId }: { name: string; parentId?: Id<"directories">; userId: Id<"users"> }, ): Promise> { + // Check if parent directory exists and belongs to user + if (parentId) { + const parentDir = await ctx.db.get(parentId) + if (!parentDir || parentDir.userId !== userId) { + throw Err.create( + Err.Code.DirectoryExists, + "Parent directory not found or access denied", + ) + } + } + const existing = await ctx.db .query("directories") .withIndex("uniqueDirectoryInDirectory", (q) => q.eq("parentId", parentId).eq("name", name), ) + .filter((q) => q.eq(q.field("userId"), userId)) .first() if (existing) { @@ -66,6 +81,7 @@ export async function create( return await ctx.db.insert("directories", { name, parentId, + userId, createdAt: now, updatedAt: now, }) @@ -74,6 +90,7 @@ export async function create( export async function moveToTrashRecursive( ctx: MutationCtx, directoryId: Id<"directories">, + userId: Id<"users">, ): Promise { const now = new Date().toISOString() @@ -93,6 +110,7 @@ export async function moveToTrashRecursive( .eq("directoryId", currentDirectoryId) .eq("deletedAt", undefined), ) + .filter((q) => q.eq(q.field("userId"), userId)) .collect() for (const file of files) { @@ -104,6 +122,7 @@ export async function moveToTrashRecursive( .withIndex("byParentId", (q) => q.eq("parentId", currentDirectoryId).eq("deletedAt", undefined), ) + .filter((q) => q.eq(q.field("userId"), userId)) .collect() for (const subdirectory of subdirectories) { diff --git a/convex/model/error.ts b/convex/model/error.ts index 4e6046e..108b8e4 100644 --- a/convex/model/error.ts +++ b/convex/model/error.ts @@ -4,6 +4,7 @@ export enum Code { DirectoryExists = "DirectoryExists", FileExists = "FileExists", Internal = "Internal", + Unauthenticated = "Unauthenticated", } export type ApplicationError = ConvexError<{ code: Code; message: string }> diff --git a/convex/model/user.ts b/convex/model/user.ts new file mode 100644 index 0000000..6f5fb23 --- /dev/null +++ b/convex/model/user.ts @@ -0,0 +1,38 @@ +import type { MutationCtx, QueryCtx } from "../_generated/server" +import * as Err from "./error" + +/** + * Get the current authenticated user identity + * Throws an error if the user is not authenticated */ +export async function userIdentityOrThrow(ctx: QueryCtx | MutationCtx) { + const identity = await ctx.auth.getUserIdentity() + + if (!identity) { + throw Err.create(Err.Code.Unauthenticated, "Not authenticated") + } + + return identity +} + +/** + * Get internal user document from JWT authentication + * Throws an error if the user is not authenticated + */ +export async function userOrThrow(ctx: QueryCtx | MutationCtx) { + const identity = await userIdentityOrThrow(ctx) + + // Look for existing user by JWT subject + const user = await ctx.db + .query("users") + .withIndex("byJwtSubject", (q) => q.eq("jwtSubject", identity.subject)) + .first() + + if (!user) { + throw Err.create( + Err.Code.Unauthenticated, + "User not found - please sync user first", + ) + } + + return user +} diff --git a/convex/schema.ts b/convex/schema.ts index 2d12816..68416d8 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -3,11 +3,8 @@ import { v } from "convex/values" const schema = defineSchema({ users: defineTable({ - name: v.string(), - email: v.string(), - createdAt: v.string(), - updatedAt: v.string(), - }), + jwtSubject: v.optional(v.string()), // JWT subject from identity provider (optional for migration) + }).index("byJwtSubject", ["jwtSubject"]), // Unique index for JWT subject lookup files: defineTable({ storageId: v.id("_storage"), userId: v.id("users"), @@ -20,6 +17,7 @@ const schema = defineSchema({ deletedAt: v.optional(v.string()), }) .index("byDirectoryId", ["directoryId", "deletedAt"]) + .index("byUserId", ["userId", "deletedAt"]) .index("byDeletedAt", ["deletedAt"]) .index("uniqueFileInDirectory", ["directoryId", "name", "deletedAt"]), directories: defineTable({ @@ -30,7 +28,7 @@ const schema = defineSchema({ updatedAt: v.string(), deletedAt: v.optional(v.string()), }) - .index("byUserId", ["userId"]) + .index("byUserId", ["userId", "deletedAt"]) .index("byParentId", ["parentId", "deletedAt"]) .index("uniqueDirectoryInDirectory", ["parentId", "name", "deletedAt"]), }) diff --git a/convex/users.ts b/convex/users.ts index ea8baa1..a06af6a 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,10 +1,25 @@ -import type { Id } from "./_generated/dataModel" -import { query } from "./_generated/server" +import { mutation } from "./_generated/server" +import { authenticatedQuery } from "./functions" +import { getOrCreateUser, userIdentityOrThrow } from "./model/user" -export const getCurrentUser = query({ +export const getCurrentUser = authenticatedQuery({ handler: async (ctx) => { - return await ctx.db.get( - "j574n657f521n19v1stnr88ysd7qhbs1" as Id<"users">, - ) + // ctx.user is the internal Convex user document + return ctx.user + }, +}) + +export const syncUser = mutation({ + handler: async (ctx) => { + // This function creates or updates the internal user from identity provider + const userId = await getOrCreateUser(ctx) + const identity = await userIdentityOrThrow(ctx) + + return { + userId, + jwtSubject: identity.subject, + name: identity.name, + email: identity.email, + } }, }) diff --git a/package.json b/package.json index 146d6a6..a466d51 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "convex": "^1.27.0", + "convex-helpers": "^0.1.104", "jotai": "^2.14.0", "lucide-react": "^0.544.0", "next-themes": "^0.4.6",