feat: add multi tenant support to media plugin#107
Merged
olliethedev merged 11 commits intomainfrom Apr 9, 2026
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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)
There was a problem hiding this comment.
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
tenantIdalways 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:
tenantIdin 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 linesYou can send follow-ups to the cloud agent here.
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
Contributor
|
✅ Shadcn registry validated — no registry changes detected. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
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 linesYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 05c006b. Configure here.
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.
…lliethedev/better-stack into feat/media-plugin-multi-tenant
…npm + bump stack version
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Type of change
Checklist
pnpm buildpassespnpm typecheckpassespnpm lintpassesdocs/content/docs/) if consumer-facing types or behavior changedScreenshots
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
tenantIdfield in the DB schema and types, and the backend plugin acceptsresolveTenantIdto scope requests.When configured, HTTP endpoints automatically filter
GET /media/assetsandGET /media/folders, tag new assets/folders created via upload/asset/folder endpoints, and guard update/delete andfolderIdassignment to prevent cross-tenant access (returns404). A new DB gettergetFolderByName(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/stackis bumped to2.11.0.Reviewed by Cursor Bugbot for commit d2ae98a. Bugbot is set up for automated code reviews on this repo. Configure here.