Skip to content

feat: add multi tenant support to media plugin#107

Merged
olliethedev merged 11 commits intomainfrom
feat/media-plugin-multi-tenant
Apr 9, 2026
Merged

feat: add multi tenant support to media plugin#107
olliethedev merged 11 commits intomainfrom
feat/media-plugin-multi-tenant

Conversation

@olliethedev
Copy link
Copy Markdown
Collaborator

@olliethedev olliethedev commented Apr 8, 2026

Summary

  • add multi-tenant support to media plugin

Type of change

  • Bug fix
  • New plugin
  • Feature / enhancement to an existing plugin
  • Documentation
  • Chore / refactor / tooling

Checklist

  • pnpm build passes
  • pnpm typecheck passes
  • pnpm lint passes
  • Tests added or updated (unit and/or E2E)
  • Docs updated (docs/content/docs/) if consumer-facing types or behavior changed
  • All three codegen-projects create successfully and pass E2E tests
  • New plugin: submission checklist in CONTRIBUTING.md completed

Screenshots


Note

Medium Risk
Adds tenant-scoped filtering and access guards to the media HTTP API, which can affect visibility and 404/authorization behavior for existing integrations when enabled. Also introduces schema/type changes (tenantId) that require migrations and could impact downstream consumers expecting previous shapes.

Overview
Media plugin now supports opt-in multi-tenancy. Assets and folders gain an optional tenantId field in the DB schema and types, and the backend plugin accepts resolveTenantId to scope requests.

When configured, HTTP endpoints automatically filter GET /media/assets and GET /media/folders, tag new assets/folders created via upload/asset/folder endpoints, and guard update/delete and folderId assignment to prevent cross-tenant access (returns 404). A new DB getter getFolderByName(name, parentId?, tenantId?) is added and exported, with updated unit tests and docs.

Release workflow is updated to pin Node 22.22.1, update npm before publishing, and @btst/stack is bumped to 2.11.0.

Reviewed by Cursor Bugbot for commit d2ae98a. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
better-stack-docs Ready Ready Preview, Comment Apr 8, 2026 11:50pm
better-stack-playground Ready Ready Preview, Comment Apr 8, 2026 11:50pm

Request Review

Comment thread packages/stack/src/plugins/media/api/plugin.ts
Comment thread packages/stack/src/plugins/media/api/plugin.ts Outdated
…d remove unused tenantId query param from folder list

- Add resolveTenantId call to uploadVercelBlobEndpoint for auth side-effects, matching S3 token endpoint behavior
- Remove unused tenantId field from listFoldersEndpoint query schema (server-resolved tenantId is used instead)
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Schemas expose client-settable tenantId always overridden by server
    • Removed tenantId from AssetListQuerySchema, createAssetSchema, and createFolderSchema to eliminate misleading API contract and prevent potential security issues if server override is accidentally removed.
  • ✅ Fixed: tenantId in upload token schema is dead code
    • Removed unused tenantId field from uploadTokenRequestSchema as the endpoint never reads ctx.body.tenantId and only uses it for side-effect auth checks via resolveTenantId.
Preview (eff54d0a85)
diff --git a/packages/stack/src/plugins/media/__tests__/getters.test.ts b/packages/stack/src/plugins/media/__tests__/getters.test.ts
--- a/packages/stack/src/plugins/media/__tests__/getters.test.ts
+++ b/packages/stack/src/plugins/media/__tests__/getters.test.ts
@@ -8,6 +8,7 @@
 	getAssetById,
 	listFolders,
 	getFolderById,
+	getFolderByName,
 } from "../api/getters";
 
 const createTestAdapter = (): Adapter => {
@@ -162,6 +163,30 @@
 			expect(photoResult.items[0]!.filename).toBe("holiday-photo.jpg");
 		});
 
+		it("filters assets by tenantId", async () => {
+			await adapter.create({
+				model: "mediaAsset",
+				data: makeAsset({
+					filename: "tenant-a.jpg",
+					url: "https://example.com/a.jpg",
+				}),
+			});
+			const assetB = await adapter.create<{ id: string }>({
+				model: "mediaAsset",
+				data: {
+					...makeAsset({
+						filename: "tenant-b.jpg",
+						url: "https://example.com/b.jpg",
+					}),
+					tenantId: "tenant-b",
+				},
+			});
+
+			const result = await listAssets(adapter, { tenantId: "tenant-b" });
+			expect(result.items).toHaveLength(1);
+			expect(result.items[0]!.id).toBe(assetB.id);
+		});
+
 		it("paginates results with limit and offset", async () => {
 			for (let i = 0; i < 5; i++) {
 				await adapter.create({
@@ -231,6 +256,24 @@
 			expect(result[1]!.name).toBe("Zeta");
 		});
 
+		it("filters folders by tenantId", async () => {
+			await adapter.create({
+				model: "mediaFolder",
+				data: makeFolder({ name: "No Tenant" }),
+			});
+			const tenantFolder = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: {
+					...makeFolder({ name: "Tenant Folder" }),
+					tenantId: "tenant-x",
+				},
+			});
+
+			const result = await listFolders(adapter, { tenantId: "tenant-x" });
+			expect(result).toHaveLength(1);
+			expect(result[0]!.id).toBe(tenantFolder.id);
+		});
+
 		it("filters folders by parentId", async () => {
 			const root = await adapter.create<{ id: string }>({
 				model: "mediaFolder",
@@ -271,4 +314,116 @@
 			expect(result!.name).toBe("Test Folder");
 		});
 	});
+
+	// ── getFolderByName ───────────────────────────────────────────────────────
+
+	describe("getFolderByName", () => {
+		it("returns null when no folder exists", async () => {
+			const result = await getFolderByName(adapter, "nonexistent");
+			expect(result).toBeNull();
+		});
+
+		it("finds a root-level folder by name with no parentId constraint", async () => {
+			await adapter.create({
+				model: "mediaFolder",
+				data: makeFolder({ name: "blog-gen-profile-1" }),
+			});
+
+			const result = await getFolderByName(adapter, "blog-gen-profile-1");
+			expect(result).not.toBeNull();
+			expect(result!.name).toBe("blog-gen-profile-1");
+		});
+
+		it("returns null when name matches but parentId does not", async () => {
+			const parent = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Parent" }),
+			});
+			await adapter.create({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Child", parentId: parent.id }),
+			});
+
+			// Search for "Child" scoped to a different (non-existent) parent.
+			const result = await getFolderByName(adapter, "Child", "wrong-parent-id");
+			expect(result).toBeNull();
+		});
+
+		it("scopes lookup to root-level folders when parentId is null", async () => {
+			// Explicitly store parentId as null (mirrors how SQL adapters persist root folders).
+			const rootFolder = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: { ...makeFolder({ name: "Images" }), parentId: null },
+			});
+			// A nested folder with the same name.
+			await adapter.create({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Images", parentId: rootFolder.id }),
+			});
+
+			// Only the root-level "Images" (parentId = null) should be returned.
+			const result = await getFolderByName(adapter, "Images", null);
+			expect(result).not.toBeNull();
+			expect(result!.id).toBe(rootFolder.id);
+		});
+
+		it("finds a child folder scoped to the correct parentId", async () => {
+			const parentA = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Parent A" }),
+			});
+			const parentB = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Parent B" }),
+			});
+			const childOfA = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Child", parentId: parentA.id }),
+			});
+			await adapter.create({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Child", parentId: parentB.id }),
+			});
+
+			const result = await getFolderByName(adapter, "Child", parentA.id);
+			expect(result).not.toBeNull();
+			expect(result!.id).toBe(childOfA.id);
+		});
+
+		it("filters by tenantId when provided", async () => {
+			await adapter.create({
+				model: "mediaFolder",
+				data: { ...makeFolder({ name: "blog-gen-abc" }), tenantId: "tenant-1" },
+			});
+			const folderT2 = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: { ...makeFolder({ name: "blog-gen-abc" }), tenantId: "tenant-2" },
+			});
+
+			// Same name, different tenant — should only return the matching one.
+			const result = await getFolderByName(
+				adapter,
+				"blog-gen-abc",
+				undefined,
+				"tenant-2",
+			);
+			expect(result).not.toBeNull();
+			expect(result!.id).toBe(folderT2.id);
+		});
+
+		it("returns null when tenantId does not match", async () => {
+			await adapter.create({
+				model: "mediaFolder",
+				data: { ...makeFolder({ name: "blog-gen-xyz" }), tenantId: "tenant-1" },
+			});
+
+			const result = await getFolderByName(
+				adapter,
+				"blog-gen-xyz",
+				undefined,
+				"tenant-999",
+			);
+			expect(result).toBeNull();
+		});
+	});
 });

diff --git a/packages/stack/src/plugins/media/__tests__/mutations.test.ts b/packages/stack/src/plugins/media/__tests__/mutations.test.ts
--- a/packages/stack/src/plugins/media/__tests__/mutations.test.ts
+++ b/packages/stack/src/plugins/media/__tests__/mutations.test.ts
@@ -59,6 +59,21 @@
 			expect(asset.alt).toBe("A beautiful photo");
 		});
 
+		it("persists tenantId when provided", async () => {
+			const asset = await createAsset(adapter, {
+				...assetInput,
+				tenantId: "profile-abc",
+			});
+
+			expect(asset.tenantId).toBe("profile-abc");
+		});
+
+		it("leaves tenantId absent when not provided", async () => {
+			const asset = await createAsset(adapter, assetInput);
+
+			expect(asset.tenantId == null).toBe(true);
+		});
+
 		it("creates multiple independent assets", async () => {
 			await createAsset(adapter, {
 				...assetInput,
@@ -194,6 +209,21 @@
 			expect(folder.createdAt).toBeInstanceOf(Date);
 		});
 
+		it("persists tenantId when provided", async () => {
+			const folder = await createFolder(adapter, {
+				name: "Tenant Folder",
+				tenantId: "profile-xyz",
+			});
+
+			expect(folder.tenantId).toBe("profile-xyz");
+		});
+
+		it("leaves tenantId absent when not provided", async () => {
+			const folder = await createFolder(adapter, { name: "No Tenant" });
+
+			expect(folder.tenantId == null).toBe(true);
+		});
+
 		it("creates a nested folder with parentId", async () => {
 			const parent = await createFolder(adapter, { name: "Root" });
 			const child = await createFolder(adapter, {

diff --git a/packages/stack/src/plugins/media/api/getters.ts b/packages/stack/src/plugins/media/api/getters.ts
--- a/packages/stack/src/plugins/media/api/getters.ts
+++ b/packages/stack/src/plugins/media/api/getters.ts
@@ -10,6 +10,7 @@
 	query?: string;
 	offset?: number;
 	limit?: number;
+	tenantId?: string;
 }
 
 /**
@@ -27,6 +28,7 @@
  */
 export interface FolderListParams {
 	parentId?: string | null;
+	tenantId?: string;
 }
 
 /**
@@ -64,6 +66,14 @@
 		});
 	}
 
+	if (query.tenantId !== undefined) {
+		whereConditions.push({
+			field: "tenantId",
+			value: query.tenantId,
+			operator: "eq" as const,
+		});
+	}
+
 	const needsInMemoryFilter = !!query.query;
 	const dbWhere = whereConditions.length > 0 ? whereConditions : undefined;
 
@@ -136,22 +146,31 @@
 	adapter: Adapter,
 	params?: FolderListParams,
 ): Promise<Folder[]> {
-	// Only add a where clause when parentId is explicitly provided as a string.
-	// Passing undefined (or no params) returns all folders unfiltered.
-	const where =
-		params?.parentId !== undefined
-			? [
-					{
-						field: "parentId",
-						value: params.parentId,
-						operator: "eq" as const,
-					},
-				]
-			: undefined;
+	const whereConditions: Array<{
+		field: string;
+		value: string | null;
+		operator: "eq";
+	}> = [];
 
+	if (params?.parentId !== undefined) {
+		whereConditions.push({
+			field: "parentId",
+			value: params.parentId,
+			operator: "eq" as const,
+		});
+	}
+
+	if (params?.tenantId !== undefined) {
+		whereConditions.push({
+			field: "tenantId",
+			value: params.tenantId,
+			operator: "eq" as const,
+		});
+	}
+
 	return adapter.findMany<Folder>({
 		model: "mediaFolder",
-		where,
+		where: whereConditions.length > 0 ? whereConditions : undefined,
 		sortBy: { field: "name", direction: "asc" },
 	});
 }
@@ -172,3 +191,35 @@
 		where: [{ field: "id", value: id, operator: "eq" as const }],
 	});
 }
+
+/**
+ * Find a single folder by name, optionally scoped to a specific parent and/or tenant.
+ * Pass `null` for `parentId` to search only root-level folders.
+ * Pass `undefined` for `parentId` to search regardless of parent.
+ * Returns `null` if no matching folder is found.
+ *
+ * Pure DB function — no hooks, no HTTP context.
+ * The caller is responsible for any access-control checks.
+ */
+export async function getFolderByName(
+	adapter: Adapter,
+	name: string,
+	parentId?: string | null,
+	tenantId?: string,
+): Promise<Folder | null> {
+	const where: Array<{
+		field: string;
+		value: string | null;
+		operator: "eq";
+	}> = [{ field: "name", value: name, operator: "eq" as const }];
+
+	if (parentId !== undefined) {
+		where.push({ field: "parentId", value: parentId, operator: "eq" as const });
+	}
+
+	if (tenantId !== undefined) {
+		where.push({ field: "tenantId", value: tenantId, operator: "eq" as const });
+	}
+
+	return adapter.findOne<Folder>({ model: "mediaFolder", where });
+}

diff --git a/packages/stack/src/plugins/media/api/index.ts b/packages/stack/src/plugins/media/api/index.ts
--- a/packages/stack/src/plugins/media/api/index.ts
+++ b/packages/stack/src/plugins/media/api/index.ts
@@ -5,6 +5,7 @@
 	getAssetById,
 	listFolders,
 	getFolderById,
+	getFolderByName,
 	type AssetListParams,
 	type AssetListResult,
 	type FolderListParams,

diff --git a/packages/stack/src/plugins/media/api/mutations.ts b/packages/stack/src/plugins/media/api/mutations.ts
--- a/packages/stack/src/plugins/media/api/mutations.ts
+++ b/packages/stack/src/plugins/media/api/mutations.ts
@@ -12,6 +12,7 @@
 	url: string;
 	folderId?: string;
 	alt?: string;
+	tenantId?: string;
 }
 
 /**
@@ -28,6 +29,7 @@
 export interface CreateFolderInput {
 	name: string;
 	parentId?: string;
+	tenantId?: string;
 }
 
 /**
@@ -51,6 +53,7 @@
 			url: input.url,
 			folderId: input.folderId,
 			alt: input.alt,
+			tenantId: input.tenantId,
 			createdAt: new Date(),
 		},
 	});
@@ -114,6 +117,7 @@
 		data: {
 			name: input.name,
 			parentId: input.parentId,
+			tenantId: input.tenantId,
 			createdAt: new Date(),
 		},
 	});

diff --git a/packages/stack/src/plugins/media/api/plugin.ts b/packages/stack/src/plugins/media/api/plugin.ts
--- a/packages/stack/src/plugins/media/api/plugin.ts
+++ b/packages/stack/src/plugins/media/api/plugin.ts
@@ -15,6 +15,7 @@
 	getAssetById,
 	listFolders,
 	getFolderById,
+	getFolderByName,
 } from "./getters";
 import {
 	createAsset,
@@ -187,6 +188,26 @@
 	 * Optional lifecycle hooks for the media backend plugin.
 	 */
 	hooks?: MediaBackendHooks;
+
+	/**
+	 * Optional function to resolve a tenant ID from the incoming request context.
+	 *
+	 * When provided, all asset and folder list/create operations through the HTTP
+	 * API are automatically scoped to the returned tenant ID:
+	 * - GET /media/assets  →  filters results to the resolved tenant
+	 * - POST /media/assets →  tags the created asset with the resolved tenant
+	 * - POST /media/upload →  tags the uploaded asset with the resolved tenant
+	 * - GET /media/folders →  filters results to the resolved tenant
+	 * - POST /media/folders → tags the created folder with the resolved tenant
+	 *
+	 * When absent, no tenant filtering is applied (existing behaviour).
+	 *
+	 * Returning `null` or `undefined` means "no tenant" for this request —
+	 * the operation proceeds without tenant scoping (useful for super-admin routes).
+	 */
+	resolveTenantId?: (
+		context: MediaApiContext,
+	) => Promise<string | null | undefined> | string | null | undefined;
 }
 
 /**
@@ -222,6 +243,11 @@
 			listFolders: (params?: Parameters<typeof listFolders>[1]) =>
 				listFolders(adapter, params),
 			getFolderById: (id: string) => getFolderById(adapter, id),
+			getFolderByName: (
+				name: string,
+				parentId?: string | null,
+				tenantId?: string,
+			) => getFolderByName(adapter, name, parentId, tenantId),
 		}),
 
 		routes: (adapter: Adapter) => {
@@ -231,6 +257,7 @@
 				allowedMimeTypes,
 				allowedUrlPrefixes,
 				hooks,
+				resolveTenantId,
 			} = config;
 
 			function validateMimeType(mimeType: string, ctx: { error: Function }) {
@@ -261,6 +288,10 @@
 					const { query, headers } = ctx;
 					const context: MediaApiContext = { query, headers };
 
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					if (hooks?.onBeforeListAssets) {
 						await runHookWithShim(
 							() => hooks.onBeforeListAssets!(query, context),
@@ -269,7 +300,7 @@
 						);
 					}
 
-					return listAssets(adapter, query);
+					return listAssets(adapter, { ...query, tenantId });
 				},
 			);
 
@@ -285,6 +316,10 @@
 						headers: ctx.headers,
 					};
 
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					if (hooks?.onBeforeUpload) {
 						await runHookWithShim(
 							() =>
@@ -356,7 +391,7 @@
 						}
 					}
 
-					const asset = await createAsset(adapter, ctx.body);
+					const asset = await createAsset(adapter, { ...ctx.body, tenantId });
 
 					if (hooks?.onAfterUpload) {
 						await hooks.onAfterUpload(asset, context);
@@ -475,6 +510,10 @@
 						headers: ctx.headers,
 					};
 
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					if (hooks?.onBeforeListFolders) {
 						await runHookWithShim(
 							() => hooks.onBeforeListFolders!(filter, context),
@@ -483,7 +522,7 @@
 						);
 					}
 
-					return listFolders(adapter, filter);
+					return listFolders(adapter, { ...filter, tenantId });
 				},
 			);
 
@@ -499,6 +538,10 @@
 						headers: ctx.headers,
 					};
 
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					if (hooks?.onBeforeCreateFolder) {
 						await runHookWithShim(
 							() => hooks.onBeforeCreateFolder!(ctx.body, context),
@@ -507,7 +550,7 @@
 						);
 					}
 
-					return createFolder(adapter, ctx.body);
+					return createFolder(adapter, { ...ctx.body, tenantId });
 				},
 			);
 
@@ -625,6 +668,10 @@
 
 					const context: MediaApiContext = { headers: ctx.headers };
 
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					if (hooks?.onBeforeUpload) {
 						await runHookWithShim(
 							() =>
@@ -680,6 +727,7 @@
 							size: file.size,
 							url,
 							folderId,
+							tenantId,
 						});
 					} catch (err) {
 						try {
@@ -721,6 +769,13 @@
 						headers: ctx.headers,
 					};
 
+					// Resolve tenant for hook side-effects (e.g. auth checks). The token
+					// response does not embed tenantId — the follow-up POST /media/assets
+					// call tags the asset automatically via resolveTenantId.
+					if (resolveTenantId) {
+						await resolveTenantId(context);
+					}
+
 					if (hooks?.onBeforeUpload) {
 						await runHookWithShim(
 							() =>
@@ -782,6 +837,13 @@
 
 					const context: MediaApiContext = { headers: ctx.headers };
 
+					// Resolve tenant for hook side-effects (e.g. auth checks). The token
+					// response does not embed tenantId — the follow-up POST /media/assets
+					// call tags the asset automatically via resolveTenantId.
+					if (resolveTenantId) {
+						await resolveTenantId(context);
+					}
+
 					if (!ctx.request) {
 						throw ctx.error(400, {
 							message: "Request object is not available",

diff --git a/packages/stack/src/plugins/media/db.ts b/packages/stack/src/plugins/media/db.ts
--- a/packages/stack/src/plugins/media/db.ts
+++ b/packages/stack/src/plugins/media/db.ts
@@ -40,6 +40,10 @@
 				type: "string",
 				required: false,
 			},
+			tenantId: {
+				type: "string",
+				required: false,
+			},
 			createdAt: {
 				type: "date",
 				defaultValue: () => new Date(),
@@ -61,6 +65,10 @@
 					field: "id",
 				},
 			},
+			tenantId: {
+				type: "string",
+				required: false,
+			},
 			createdAt: {
 				type: "date",
 				defaultValue: () => new Date(),

diff --git a/packages/stack/src/plugins/media/types.ts b/packages/stack/src/plugins/media/types.ts
--- a/packages/stack/src/plugins/media/types.ts
+++ b/packages/stack/src/plugins/media/types.ts
@@ -7,6 +7,7 @@
 	url: string;
 	folderId?: string;
 	alt?: string;
+	tenantId?: string | null;
 	createdAt: Date;
 };
 
@@ -14,6 +15,7 @@
 	id: string;
 	name: string;
 	parentId?: string;
+	tenantId?: string | null;
 	createdAt: Date;
 };
 

diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -68,345 +68,6 @@
         specifier: 'catalog:'
         version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2)
 
-  codegen-projects/nextjs:
-    dependencies:
-      '@ai-sdk/openai':
-        specifier: ^2.0.68
-        version: 2.0.102(zod@4.2.1)
-      '@btst/adapter-memory':
-        specifier: ^2.1.1
-        version: 2.1.1(f192704742852fd2d8da67ecfef2ca74)
-      '@btst/stack':
-        specifier: workspace:*
-        version: link:../../packages/stack
-      '@tanstack/react-query':
-        specifier: ^5.90.2
-        version: 5.90.10(react@19.2.4)
-      '@tanstack/react-query-devtools':
-        specifier: ^5.90.2
-        version: 5.96.2(@tanstack/react-query@5.90.10(react@19.2.4))(react@19.2.4)
-      ai:
-        specifier: ^5.0.94
-        version: 5.0.94(zod@4.2.1)
-      class-variance-authority:
-        specifier: ^0.7.1
-        version: 0.7.1
-      clsx:
-        specifier: ^2.1.1
-        version: 2.1.1
-      lucide-react:
-        specifier: ^0.545.0
-        version: 0.545.0(react@19.2.4)
-      next:
-        specifier: 16.1.7
-        version: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-      next-themes:
-        specifier: ^0.4.6
-        version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-      radix-ui:
-        specifier: ^1.4.3
-        version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-      react:
-        specifier: ^19.2.0
-        version: 19.2.4
-      react-dom:
-        specifier: ^19.2.4
-        version: 19.2.4(react@19.2.4)
-      shadcn:
-        specifier: ^4.2.0
-        version: 4.2.0(@types/node@25.5.0)(typescript@5.9.3)
-      sonner:
-        specifier: ^2.0.7
-        version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-      tailwind-merge:
-        specifier: ^3.5.0
-        version: 3.5.0
-      tw-animate-css:
-        specifier: ^1.4.0
-        version: 1.4.0
-      zod:
-        specifier: ^4.2.0
-        version: 4.2.1
-    devDependencies:
-      '@eslint/eslintrc':
-        specifier: ^3
-        version: 3.3.5
-      '@tailwindcss/postcss':
-        specifier: ^4.2.1
-        version: 4.2.2
-      '@types/node':
-        specifier: ^25.5.0
-        version: 25.5.0
-      '@types/react':
-        specifier: ^19.2.14
-        version: 19.2.14
-      '@types/react-dom':
-        specifier: ^19.2.3
-        version: 19.2.3(@types/react@19.2.14)
-      eslint:
-        specifier: ^9.39.4
-        version: 9.39.4(jiti@2.6.1)
-      eslint-config-next:
-        specifier: 16.1.7
-        version: 16.1.7(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
-      postcss:
-        specifier: ^8
-        version: 8.5.6
-      prettier:
-        specifier: ^3.8.1
-        version: 3.8.1
-      prettier-plugin-tailwindcss:
-        specifier: ^0.7.2
-        version: 0.7.2(prettier@3.8.1)
-      tailwindcss:
-        specifier: ^4.2.1
-        version: 4.2.2
-      typescript:
-        specifier: ^5.9.3
-        version: 5.9.3
-
-  codegen-projects/react-router:
-    dependencies:
-      '@ai-sdk/openai':
-        specifier: ^2.0.68
-        version: 2.0.102(zod@4.2.1)
-      '@btst/adapter-memory':
-        specifier: ^2.1.1
-        version: 2.1.1(a358799d0bce7985df4b9221f7491a45)
-      '@btst/stack':
-        specifier: workspace:*
-        version: link:../../packages/stack
-      '@fontsource-variable/geist':
-        specifier: ^5.2.8
-        version: 5.2.8
-      '@react-router/node':
-        specifier: 7.13.1
-        version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
-      '@react-router/serve':
-        specifier: 7.13.1
-        version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
-      '@tanstack/react-query':
-        specifier: ^5.90.2
-        version: 5.90.10(react@19.2.4)
-      '@tanstack/react-query-devtools':
-        specifier: ^5.90.2
-        version: 5.96.2(@tanstack/react-query@5.90.10(react@19.2.4))(react@19.2.4)
-      ai:
-        specifier: ^5.0.94
-        version: 5.0.94(zod@4.2.1)
-      class-variance-authority:
-        specifier: ^0.7.1
-        version: 0.7.1
-      clsx:
-        specifier: ^2.1.1
-        version: 2.1.1
-      isbot:
-        specifier: ^5.1.36
-        version: 5.1.37
-      lucide-react:
-        specifier: ^0.545.0
-        version: 0.545.0(react@19.2.4)
-      next-themes:
-        specifier: ^0.4.6
-        version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-      radix-ui:
-        specifier: ^1.4.3
-        version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-      react:
-        specifier: ^19.2.0
-        version: 19.2.4
-      react-dom:
-        specifier: ^19.2.4
-        version: 19.2.4(react@19.2.4)
-      react-router:
-        specifier: 7.13.1
-        version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-      shadcn:
-        specifier: ^4.2.0
-        version: 4.2.0(@types/node@22.19.17)(typescript@5.9.3)
-      sonner:
-        specifier: ^2.0.7
-        version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-      tailwind-merge:
-        specifier: ^3.5.0
-        version: 3.5.0
-      tw-animate-css:
-        specifier: ^1.4.0
... diff truncated: showing 800 of 5832 lines

You can send follow-ups to the cloud agent here.

Comment thread packages/stack/src/plugins/media/schemas.ts Outdated
Comment thread packages/stack/src/plugins/media/schemas.ts Outdated
Remove tenantId field from AssetListQuerySchema, createAssetSchema,
createFolderSchema, and uploadTokenRequestSchema. These HTTP-facing
schemas should not accept tenantId from clients since the server
always resolves and overrides it via resolveTenantId().

This provides defense-in-depth at the validation layer and prevents
misleading API documentation that suggests clients can control tenant
assignment. The tenantId parameter remains available in the underlying
mutation/getter functions where it is properly used.

Fixes: 6e48cde9-8755-4f5c-8854-1bf313ba831a, fc25ce77-68a0-45c3-a09b-8910d9264c92
Comment thread packages/stack/src/plugins/media/api/plugin.ts
Comment thread packages/stack/src/plugins/media/api/plugin.ts
Comment thread packages/stack/src/plugins/media/api/plugin.ts
Comment thread packages/stack/src/plugins/media/api/plugin.ts
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Shadcn registry validated — no registry changes detected.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Missing parent folder tenant validation in createFolder endpoint
    • Added parent folder tenant validation check following the same pattern used in all other endpoints to prevent cross-tenant parent-child relationships.
Preview (17e617b209)
diff --git a/packages/stack/registry/btst-media.json b/packages/stack/registry/btst-media.json
--- a/packages/stack/registry/btst-media.json
+++ b/packages/stack/registry/btst-media.json
@@ -19,7 +19,7 @@
     {
       "path": "btst/media/types.ts",
       "type": "registry:lib",
-      "content": "export type Asset = {\n\tid: string;\n\tfilename: string;\n\toriginalName: string;\n\tmimeType: string;\n\tsize: number;\n\turl: string;\n\tfolderId?: string;\n\talt?: string;\n\tcreatedAt: Date;\n};\n\nexport type Folder = {\n\tid: string;\n\tname: string;\n\tparentId?: string;\n\tcreatedAt: Date;\n};\n\nexport interface SerializedAsset extends Omit<Asset, \"createdAt\"> {\n\tcreatedAt: string;\n}\n\nexport interface SerializedFolder extends Omit<Folder, \"createdAt\"> {\n\tcreatedAt: string;\n}\n",
+      "content": "export type Asset = {\n\tid: string;\n\tfilename: string;\n\toriginalName: string;\n\tmimeType: string;\n\tsize: number;\n\turl: string;\n\tfolderId?: string;\n\talt?: string;\n\ttenantId?: string | null;\n\tcreatedAt: Date;\n};\n\nexport type Folder = {\n\tid: string;\n\tname: string;\n\tparentId?: string;\n\ttenantId?: string | null;\n\tcreatedAt: Date;\n};\n\nexport interface SerializedAsset extends Omit<Asset, \"createdAt\"> {\n\tcreatedAt: string;\n}\n\nexport interface SerializedFolder extends Omit<Folder, \"createdAt\"> {\n\tcreatedAt: string;\n}\n",
       "target": "src/components/btst/media/types.ts"
     },
     {

diff --git a/packages/stack/src/plugins/media/__tests__/getters.test.ts b/packages/stack/src/plugins/media/__tests__/getters.test.ts
--- a/packages/stack/src/plugins/media/__tests__/getters.test.ts
+++ b/packages/stack/src/plugins/media/__tests__/getters.test.ts
@@ -8,6 +8,7 @@
 	getAssetById,
 	listFolders,
 	getFolderById,
+	getFolderByName,
 } from "../api/getters";
 
 const createTestAdapter = (): Adapter => {
@@ -162,6 +163,30 @@
 			expect(photoResult.items[0]!.filename).toBe("holiday-photo.jpg");
 		});
 
+		it("filters assets by tenantId", async () => {
+			await adapter.create({
+				model: "mediaAsset",
+				data: makeAsset({
+					filename: "tenant-a.jpg",
+					url: "https://example.com/a.jpg",
+				}),
+			});
+			const assetB = await adapter.create<{ id: string }>({
+				model: "mediaAsset",
+				data: {
+					...makeAsset({
+						filename: "tenant-b.jpg",
+						url: "https://example.com/b.jpg",
+					}),
+					tenantId: "tenant-b",
+				},
+			});
+
+			const result = await listAssets(adapter, { tenantId: "tenant-b" });
+			expect(result.items).toHaveLength(1);
+			expect(result.items[0]!.id).toBe(assetB.id);
+		});
+
 		it("paginates results with limit and offset", async () => {
 			for (let i = 0; i < 5; i++) {
 				await adapter.create({
@@ -231,6 +256,24 @@
 			expect(result[1]!.name).toBe("Zeta");
 		});
 
+		it("filters folders by tenantId", async () => {
+			await adapter.create({
+				model: "mediaFolder",
+				data: makeFolder({ name: "No Tenant" }),
+			});
+			const tenantFolder = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: {
+					...makeFolder({ name: "Tenant Folder" }),
+					tenantId: "tenant-x",
+				},
+			});
+
+			const result = await listFolders(adapter, { tenantId: "tenant-x" });
+			expect(result).toHaveLength(1);
+			expect(result[0]!.id).toBe(tenantFolder.id);
+		});
+
 		it("filters folders by parentId", async () => {
 			const root = await adapter.create<{ id: string }>({
 				model: "mediaFolder",
@@ -271,4 +314,116 @@
 			expect(result!.name).toBe("Test Folder");
 		});
 	});
+
+	// ── getFolderByName ───────────────────────────────────────────────────────
+
+	describe("getFolderByName", () => {
+		it("returns null when no folder exists", async () => {
+			const result = await getFolderByName(adapter, "nonexistent");
+			expect(result).toBeNull();
+		});
+
+		it("finds a root-level folder by name with no parentId constraint", async () => {
+			await adapter.create({
+				model: "mediaFolder",
+				data: makeFolder({ name: "blog-gen-profile-1" }),
+			});
+
+			const result = await getFolderByName(adapter, "blog-gen-profile-1");
+			expect(result).not.toBeNull();
+			expect(result!.name).toBe("blog-gen-profile-1");
+		});
+
+		it("returns null when name matches but parentId does not", async () => {
+			const parent = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Parent" }),
+			});
+			await adapter.create({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Child", parentId: parent.id }),
+			});
+
+			// Search for "Child" scoped to a different (non-existent) parent.
+			const result = await getFolderByName(adapter, "Child", "wrong-parent-id");
+			expect(result).toBeNull();
+		});
+
+		it("scopes lookup to root-level folders when parentId is null", async () => {
+			// Explicitly store parentId as null (mirrors how SQL adapters persist root folders).
+			const rootFolder = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: { ...makeFolder({ name: "Images" }), parentId: null },
+			});
+			// A nested folder with the same name.
+			await adapter.create({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Images", parentId: rootFolder.id }),
+			});
+
+			// Only the root-level "Images" (parentId = null) should be returned.
+			const result = await getFolderByName(adapter, "Images", null);
+			expect(result).not.toBeNull();
+			expect(result!.id).toBe(rootFolder.id);
+		});
+
+		it("finds a child folder scoped to the correct parentId", async () => {
+			const parentA = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Parent A" }),
+			});
+			const parentB = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Parent B" }),
+			});
+			const childOfA = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Child", parentId: parentA.id }),
+			});
+			await adapter.create({
+				model: "mediaFolder",
+				data: makeFolder({ name: "Child", parentId: parentB.id }),
+			});
+
+			const result = await getFolderByName(adapter, "Child", parentA.id);
+			expect(result).not.toBeNull();
+			expect(result!.id).toBe(childOfA.id);
+		});
+
+		it("filters by tenantId when provided", async () => {
+			await adapter.create({
+				model: "mediaFolder",
+				data: { ...makeFolder({ name: "blog-gen-abc" }), tenantId: "tenant-1" },
+			});
+			const folderT2 = await adapter.create<{ id: string }>({
+				model: "mediaFolder",
+				data: { ...makeFolder({ name: "blog-gen-abc" }), tenantId: "tenant-2" },
+			});
+
+			// Same name, different tenant — should only return the matching one.
+			const result = await getFolderByName(
+				adapter,
+				"blog-gen-abc",
+				undefined,
+				"tenant-2",
+			);
+			expect(result).not.toBeNull();
+			expect(result!.id).toBe(folderT2.id);
+		});
+
+		it("returns null when tenantId does not match", async () => {
+			await adapter.create({
+				model: "mediaFolder",
+				data: { ...makeFolder({ name: "blog-gen-xyz" }), tenantId: "tenant-1" },
+			});
+
+			const result = await getFolderByName(
+				adapter,
+				"blog-gen-xyz",
+				undefined,
+				"tenant-999",
+			);
+			expect(result).toBeNull();
+		});
+	});
 });

diff --git a/packages/stack/src/plugins/media/__tests__/mutations.test.ts b/packages/stack/src/plugins/media/__tests__/mutations.test.ts
--- a/packages/stack/src/plugins/media/__tests__/mutations.test.ts
+++ b/packages/stack/src/plugins/media/__tests__/mutations.test.ts
@@ -59,6 +59,21 @@
 			expect(asset.alt).toBe("A beautiful photo");
 		});
 
+		it("persists tenantId when provided", async () => {
+			const asset = await createAsset(adapter, {
+				...assetInput,
+				tenantId: "profile-abc",
+			});
+
+			expect(asset.tenantId).toBe("profile-abc");
+		});
+
+		it("leaves tenantId absent when not provided", async () => {
+			const asset = await createAsset(adapter, assetInput);
+
+			expect(asset.tenantId == null).toBe(true);
+		});
+
 		it("creates multiple independent assets", async () => {
 			await createAsset(adapter, {
 				...assetInput,
@@ -194,6 +209,21 @@
 			expect(folder.createdAt).toBeInstanceOf(Date);
 		});
 
+		it("persists tenantId when provided", async () => {
+			const folder = await createFolder(adapter, {
+				name: "Tenant Folder",
+				tenantId: "profile-xyz",
+			});
+
+			expect(folder.tenantId).toBe("profile-xyz");
+		});
+
+		it("leaves tenantId absent when not provided", async () => {
+			const folder = await createFolder(adapter, { name: "No Tenant" });
+
+			expect(folder.tenantId == null).toBe(true);
+		});
+
 		it("creates a nested folder with parentId", async () => {
 			const parent = await createFolder(adapter, { name: "Root" });
 			const child = await createFolder(adapter, {

diff --git a/packages/stack/src/plugins/media/api/getters.ts b/packages/stack/src/plugins/media/api/getters.ts
--- a/packages/stack/src/plugins/media/api/getters.ts
+++ b/packages/stack/src/plugins/media/api/getters.ts
@@ -10,6 +10,7 @@
 	query?: string;
 	offset?: number;
 	limit?: number;
+	tenantId?: string;
 }
 
 /**
@@ -27,6 +28,7 @@
  */
 export interface FolderListParams {
 	parentId?: string | null;
+	tenantId?: string;
 }
 
 /**
@@ -64,6 +66,14 @@
 		});
 	}
 
+	if (query.tenantId !== undefined) {
+		whereConditions.push({
+			field: "tenantId",
+			value: query.tenantId,
+			operator: "eq" as const,
+		});
+	}
+
 	const needsInMemoryFilter = !!query.query;
 	const dbWhere = whereConditions.length > 0 ? whereConditions : undefined;
 
@@ -136,22 +146,31 @@
 	adapter: Adapter,
 	params?: FolderListParams,
 ): Promise<Folder[]> {
-	// Only add a where clause when parentId is explicitly provided as a string.
-	// Passing undefined (or no params) returns all folders unfiltered.
-	const where =
-		params?.parentId !== undefined
-			? [
-					{
-						field: "parentId",
-						value: params.parentId,
-						operator: "eq" as const,
-					},
-				]
-			: undefined;
+	const whereConditions: Array<{
+		field: string;
+		value: string | null;
+		operator: "eq";
+	}> = [];
 
+	if (params?.parentId !== undefined) {
+		whereConditions.push({
+			field: "parentId",
+			value: params.parentId,
+			operator: "eq" as const,
+		});
+	}
+
+	if (params?.tenantId !== undefined) {
+		whereConditions.push({
+			field: "tenantId",
+			value: params.tenantId,
+			operator: "eq" as const,
+		});
+	}
+
 	return adapter.findMany<Folder>({
 		model: "mediaFolder",
-		where,
+		where: whereConditions.length > 0 ? whereConditions : undefined,
 		sortBy: { field: "name", direction: "asc" },
 	});
 }
@@ -172,3 +191,35 @@
 		where: [{ field: "id", value: id, operator: "eq" as const }],
 	});
 }
+
+/**
+ * Find a single folder by name, optionally scoped to a specific parent and/or tenant.
+ * Pass `null` for `parentId` to search only root-level folders.
+ * Pass `undefined` for `parentId` to search regardless of parent.
+ * Returns `null` if no matching folder is found.
+ *
+ * Pure DB function — no hooks, no HTTP context.
+ * The caller is responsible for any access-control checks.
+ */
+export async function getFolderByName(
+	adapter: Adapter,
+	name: string,
+	parentId?: string | null,
+	tenantId?: string,
+): Promise<Folder | null> {
+	const where: Array<{
+		field: string;
+		value: string | null;
+		operator: "eq";
+	}> = [{ field: "name", value: name, operator: "eq" as const }];
+
+	if (parentId !== undefined) {
+		where.push({ field: "parentId", value: parentId, operator: "eq" as const });
+	}
+
+	if (tenantId !== undefined) {
+		where.push({ field: "tenantId", value: tenantId, operator: "eq" as const });
+	}
+
+	return adapter.findOne<Folder>({ model: "mediaFolder", where });
+}

diff --git a/packages/stack/src/plugins/media/api/index.ts b/packages/stack/src/plugins/media/api/index.ts
--- a/packages/stack/src/plugins/media/api/index.ts
+++ b/packages/stack/src/plugins/media/api/index.ts
@@ -5,6 +5,7 @@
 	getAssetById,
 	listFolders,
 	getFolderById,
+	getFolderByName,
 	type AssetListParams,
 	type AssetListResult,
 	type FolderListParams,

diff --git a/packages/stack/src/plugins/media/api/mutations.ts b/packages/stack/src/plugins/media/api/mutations.ts
--- a/packages/stack/src/plugins/media/api/mutations.ts
+++ b/packages/stack/src/plugins/media/api/mutations.ts
@@ -12,6 +12,7 @@
 	url: string;
 	folderId?: string;
 	alt?: string;
+	tenantId?: string;
 }
 
 /**
@@ -28,6 +29,7 @@
 export interface CreateFolderInput {
 	name: string;
 	parentId?: string;
+	tenantId?: string;
 }
 
 /**
@@ -51,6 +53,7 @@
 			url: input.url,
 			folderId: input.folderId,
 			alt: input.alt,
+			tenantId: input.tenantId,
 			createdAt: new Date(),
 		},
 	});
@@ -114,6 +117,7 @@
 		data: {
 			name: input.name,
 			parentId: input.parentId,
+			tenantId: input.tenantId,
 			createdAt: new Date(),
 		},
 	});

diff --git a/packages/stack/src/plugins/media/api/plugin.ts b/packages/stack/src/plugins/media/api/plugin.ts
--- a/packages/stack/src/plugins/media/api/plugin.ts
+++ b/packages/stack/src/plugins/media/api/plugin.ts
@@ -15,6 +15,7 @@
 	getAssetById,
 	listFolders,
 	getFolderById,
+	getFolderByName,
 } from "./getters";
 import {
 	createAsset,
@@ -187,6 +188,26 @@
 	 * Optional lifecycle hooks for the media backend plugin.
 	 */
 	hooks?: MediaBackendHooks;
+
+	/**
+	 * Optional function to resolve a tenant ID from the incoming request context.
+	 *
+	 * When provided, all asset and folder list/create operations through the HTTP
+	 * API are automatically scoped to the returned tenant ID:
+	 * - GET /media/assets  →  filters results to the resolved tenant
+	 * - POST /media/assets →  tags the created asset with the resolved tenant
+	 * - POST /media/upload →  tags the uploaded asset with the resolved tenant
+	 * - GET /media/folders →  filters results to the resolved tenant
+	 * - POST /media/folders → tags the created folder with the resolved tenant
+	 *
+	 * When absent, no tenant filtering is applied (existing behaviour).
+	 *
+	 * Returning `null` or `undefined` means "no tenant" for this request —
+	 * the operation proceeds without tenant scoping (useful for super-admin routes).
+	 */
+	resolveTenantId?: (
+		context: MediaApiContext,
+	) => Promise<string | null | undefined> | string | null | undefined;
 }
 
 /**
@@ -222,6 +243,11 @@
 			listFolders: (params?: Parameters<typeof listFolders>[1]) =>
 				listFolders(adapter, params),
 			getFolderById: (id: string) => getFolderById(adapter, id),
+			getFolderByName: (
+				name: string,
+				parentId?: string | null,
+				tenantId?: string,
+			) => getFolderByName(adapter, name, parentId, tenantId),
 		}),
 
 		routes: (adapter: Adapter) => {
@@ -231,6 +257,7 @@
 				allowedMimeTypes,
 				allowedUrlPrefixes,
 				hooks,
+				resolveTenantId,
 			} = config;
 
 			function validateMimeType(mimeType: string, ctx: { error: Function }) {
@@ -261,6 +288,10 @@
 					const { query, headers } = ctx;
 					const context: MediaApiContext = { query, headers };
 
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					if (hooks?.onBeforeListAssets) {
 						await runHookWithShim(
 							() => hooks.onBeforeListAssets!(query, context),
@@ -269,7 +300,7 @@
 						);
 					}
 
-					return listAssets(adapter, query);
+					return listAssets(adapter, { ...query, tenantId });
 				},
 			);
 
@@ -285,6 +316,10 @@
 						headers: ctx.headers,
 					};
 
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					if (hooks?.onBeforeUpload) {
 						await runHookWithShim(
 							() =>
@@ -354,9 +389,12 @@
 						if (!folder) {
 							throw ctx.error(404, { message: "Folder not found" });
 						}
+						if (tenantId !== undefined && folder.tenantId !== tenantId) {
+							throw ctx.error(404, { message: "Folder not found" });
+						}
 					}
 
-					const asset = await createAsset(adapter, ctx.body);
+					const asset = await createAsset(adapter, { ...ctx.body, tenantId });
 
 					if (hooks?.onAfterUpload) {
 						await hooks.onAfterUpload(asset, context);
@@ -384,6 +422,14 @@
 						headers: ctx.headers,
 					};
 
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
+					if (tenantId !== undefined && existing.tenantId !== tenantId) {
+						throw ctx.error(404, { message: "Asset not found" });
+					}
+
 					if (hooks?.onBeforeUpdateAsset) {
 						await runHookWithShim(
 							() => hooks.onBeforeUpdateAsset!(existing, ctx.body, context),
@@ -397,6 +443,9 @@
 						if (!folder) {
 							throw ctx.error(404, { message: "Folder not found" });
 						}
+						if (tenantId !== undefined && folder.tenantId !== tenantId) {
+							throw ctx.error(404, { message: "Folder not found" });
+						}
 					}
 
 					const updated = await updateAsset(adapter, ctx.params.id, ctx.body);
@@ -419,11 +468,19 @@
 						headers: ctx.headers,
 					};
 
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					const asset = await getAssetById(adapter, ctx.params.id);
 					if (!asset) {
 						throw ctx.error(404, { message: "Asset not found" });
 					}
 
+					if (tenantId !== undefined && asset.tenantId !== tenantId) {
+						throw ctx.error(404, { message: "Asset not found" });
+					}
+
 					if (hooks?.onBeforeDelete) {
 						await runHookWithShim(
 							() => hooks.onBeforeDelete!(asset, context),
@@ -475,6 +532,10 @@
 						headers: ctx.headers,
 					};
 
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					if (hooks?.onBeforeListFolders) {
 						await runHookWithShim(
 							() => hooks.onBeforeListFolders!(filter, context),
@@ -483,7 +544,7 @@
 						);
 					}
 
-					return listFolders(adapter, filter);
+					return listFolders(adapter, { ...filter, tenantId });
 				},
 			);
 
@@ -499,6 +560,10 @@
 						headers: ctx.headers,
 					};
 
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					if (hooks?.onBeforeCreateFolder) {
 						await runHookWithShim(
 							() => hooks.onBeforeCreateFolder!(ctx.body, context),
@@ -507,7 +572,17 @@
 						);
 					}
 
-					return createFolder(adapter, ctx.body);
+					if (ctx.body.parentId) {
+						const folder = await getFolderById(adapter, ctx.body.parentId);
+						if (!folder) {
+							throw ctx.error(404, { message: "Folder not found" });
+						}
+						if (tenantId !== undefined && folder.tenantId !== tenantId) {
+							throw ctx.error(404, { message: "Folder not found" });
+						}
+					}
+
+					return createFolder(adapter, { ...ctx.body, tenantId });
 				},
 			);
 
@@ -517,15 +592,23 @@
 					method: "DELETE",
 				},
 				async (ctx) => {
+					const context: MediaApiContext = {
+						params: ctx.params,
+						headers: ctx.headers,
+					};
+
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					const folder = await getFolderById(adapter, ctx.params.id);
 					if (!folder) {
 						throw ctx.error(404, { message: "Folder not found" });
 					}
 
-					const context: MediaApiContext = {
-						params: ctx.params,
-						headers: ctx.headers,
-					};
+					if (tenantId !== undefined && folder.tenantId !== tenantId) {
+						throw ctx.error(404, { message: "Folder not found" });
+					}
 
 					if (hooks?.onBeforeDeleteFolder) {
 						await runHookWithShim(
@@ -625,6 +708,10 @@
 
 					const context: MediaApiContext = { headers: ctx.headers };
 
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					if (hooks?.onBeforeUpload) {
 						await runHookWithShim(
 							() =>
@@ -660,6 +747,9 @@
 						if (!folder) {
 							throw ctx.error(404, { message: "Folder not found" });
 						}
+						if (tenantId !== undefined && folder.tenantId !== tenantId) {
+							throw ctx.error(404, { message: "Folder not found" });
+						}
 					}
 
 					const { url } = await storageAdapter.upload(buffer, {
@@ -680,6 +770,7 @@
 							size: file.size,
 							url,
 							folderId,
+							tenantId,
 						});
 					} catch (err) {
 						try {
@@ -721,6 +812,13 @@
 						headers: ctx.headers,
 					};
 
+					// Resolve tenant for auth checks and folder ownership validation.
+					// The token response does not embed tenantId — the follow-up POST /media/assets
+					// call tags the asset automatically via resolveTenantId.
+					const tenantId = resolveTenantId
+						? ((await resolveTenantId(context)) ?? undefined)
+						: undefined;
+
 					if (hooks?.onBeforeUpload) {
 						await runHookWithShim(
 							() =>
@@ -753,6 +851,9 @@
 								message: "Folder not found",
 							});
 						}
+						if (tenantId !== undefined && folder.tenantId !== tenantId) {
+							throw ctx.error(404, { message: "Folder not found" });
+						}
 						folderId = folder.id;
 					}
 					const filename = sanitizeS3KeySegment(ctx.body.filename);
@@ -782,6 +883,13 @@
 
 					const context: MediaApiContext = { headers: ctx.headers };
 
+					// Resolve tenant for hook side-effects (e.g. auth checks). The token
+					// response does not embed tenantId — the follow-up POST /media/assets
+					// call tags the asset automatically via resolveTenantId.
+					if (resolveTenantId) {
+						await resolveTenantId(context);
+					}
+
 					if (!ctx.request) {
 						throw ctx.error(400, {
 							message: "Request object is not available",

diff --git a/packages/stack/src/plugins/media/db.ts b/packages/stack/src/plugins/media/db.ts
--- a/packages/stack/src/plugins/media/db.ts
+++ b/packages/stack/src/plugins/media/db.ts
@@ -40,6 +40,10 @@
 				type: "string",
 				required: false,
 			},
+			tenantId: {
+				type: "string",
+				required: false,
+			},
 			createdAt: {
 				type: "date",
 				defaultValue: () => new Date(),
@@ -61,6 +65,10 @@
 					field: "id",
 				},
 			},
+			tenantId: {
+				type: "string",
+				required: false,
+			},
 			createdAt: {
 				type: "date",
 				defaultValue: () => new Date(),

diff --git a/packages/stack/src/plugins/media/types.ts b/packages/stack/src/plugins/media/types.ts
--- a/packages/stack/src/plugins/media/types.ts
+++ b/packages/stack/src/plugins/media/types.ts
@@ -7,6 +7,7 @@
 	url: string;
 	folderId?: string;
 	alt?: string;
+	tenantId?: string | null;
 	createdAt: Date;
 };
 
@@ -14,6 +15,7 @@
 	id: string;
 	name: string;
 	parentId?: string;
+	tenantId?: string | null;
 	createdAt: Date;
 };
 

diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -68,345 +68,6 @@
         specifier: 'catalog:'
         version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2)
 
-  codegen-projects/nextjs:
-    dependencies:
-      '@ai-sdk/openai':
-        specifier: ^2.0.68
-        version: 2.0.102(zod@4.2.1)
-      '@btst/adapter-memory':
-        specifier: ^2.1.1
-        version: 2.1.1(f192704742852fd2d8da67ecfef2ca74)
-      '@btst/stack':
-        specifier: workspace:*
-        version: link:../../packages/stack
-      '@tanstack/react-query':
-        specifier: ^5.90.2
-        version: 5.90.10(react@19.2.4)
-      '@tanstack/react-query-devtools':
-        specifier: ^5.90.2
-        version: 5.96.2(@tanstack/react-query@5.90.10(react@19.2.4))(react@19.2.4)
-      ai:
-        specifier: ^5.0.94
-        version: 5.0.94(zod@4.2.1)
-      class-variance-authority:
-        specifier: ^0.7.1
-        version: 0.7.1
-      clsx:
-        specifier: ^2.1.1
-        version: 2.1.1
-      lucide-react:
-        specifier: ^0.545.0
-        version: 0.545.0(react@19.2.4)
-      next:
-        specifier: 16.1.7
-        version: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-      next-themes:
-        specifier: ^0.4.6
-        version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-      radix-ui:
-        specifier: ^1.4.3
-        version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-      react:
-        specifier: ^19.2.0
-        version: 19.2.4
-      react-dom:
-        specifier: ^19.2.4
... diff truncated: showing 800 of 5953 lines

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 05c006b. Configure here.

Comment thread packages/stack/src/plugins/media/api/plugin.ts
cursoragent and others added 2 commits April 8, 2026 23:16
Validate that parent folder belongs to the same tenant when creating a nested folder to prevent cross-tenant parent-child relationships and maintain tenant isolation.
@olliethedev olliethedev merged commit 4d16d11 into main Apr 9, 2026
9 checks passed
@olliethedev olliethedev deleted the feat/media-plugin-multi-tenant branch April 9, 2026 00:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants