Skip to content

Commit 4d16d11

Browse files
authored
Merge pull request #107 from better-stack-ai/feat/media-plugin-multi-tenant
feat: add multi tenant support to media plugin
2 parents 3cbe27f + d2ae98a commit 4d16d11

13 files changed

Lines changed: 655 additions & 3225 deletions

File tree

.github/workflows/release.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ jobs:
2323

2424
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
2525
with:
26-
node-version-file: '.nvmrc'
26+
node-version: 22.22.1
2727
registry-url: 'https://registry.npmjs.org'
2828

29+
- name: Update npm
30+
run: npm install -g npm@latest
31+
2932
- run: pnpm install
3033

3134
- name: Copy README files to published packages

docs/content/docs/plugins/media.mdx

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,71 @@ export function ProductImageField({
376376
}
377377
```
378378

379+
## Multi-tenancy
380+
381+
The media plugin has first-class support for scoping assets and folders to a tenant — a user, organisation, or any other entity that should see only its own media. This is completely opt-in: if you do not configure `resolveTenantId`, the plugin behaves exactly as before.
382+
383+
### How it works
384+
385+
Assets and folders each carry an optional `tenantId` column. The `resolveTenantId` hook is called on every HTTP request and returns the tenant identifier for that request. The plugin then:
386+
387+
- **Filters** `GET /media/assets` and `GET /media/folders` to the resolved tenant.
388+
- **Tags** newly created assets and folders with the resolved tenant on `POST /media/assets`, `POST /media/upload`, `POST /media/upload/token`, and `POST /media/folders`.
389+
- **Guards** `PATCH /media/assets/:id`, `DELETE /media/assets/:id`, and `DELETE /media/folders/:id` — if the resolved `tenantId` does not match the resource's `tenantId`, the endpoint returns **404** (not 403, to avoid leaking that the ID exists).
390+
- **Guards `folderId` assignments** — supplying a `folderId` that belongs to a different tenant is rejected with 404 on all create and upload endpoints.
391+
392+
Returning `null` or `undefined` from `resolveTenantId` disables scoping for that request, which is useful for super-admin routes.
393+
394+
### Configuration
395+
396+
```ts title="lib/stack.ts"
397+
import { stack } from "@btst/stack"
398+
import { mediaBackendPlugin, s3Adapter } from "@btst/stack/plugins/media/api"
399+
import { getSession } from "@/lib/auth"
400+
401+
const { handler, dbSchema } = stack({
402+
basePath: "/api/data",
403+
plugins: {
404+
media: mediaBackendPlugin({
405+
storageAdapter: s3Adapter({ /* ... */ }),
406+
resolveTenantId: async (context) => {
407+
const session = await getSession(context.headers as Headers)
408+
if (!session) throw new Error("Unauthorized")
409+
// Return the business profile ID as the tenant key.
410+
return session.user.activeProfileId ?? null
411+
},
412+
}),
413+
},
414+
adapter: (db) => createPrismaAdapter(prisma, db),
415+
})
416+
```
417+
418+
When `resolveTenantId` throws, the request is rejected exactly like any other hook error. Use this to enforce authentication before any media operation is allowed.
419+
420+
### Get-or-create folders per tenant
421+
422+
A common pattern in workflow automation is to find a tenant's folder by name, creating it if it does not exist yet. Use `getFolderByName` from the server-side `api` factory:
423+
424+
```ts
425+
import { stack } from "@btst/stack"
426+
427+
const { api } = stack({ /* ... */ })
428+
429+
async function getOrCreateProfileFolder(profileId: string) {
430+
const name = `blog-gen-${profileId}`
431+
const existing = await api.media.getFolderByName(name, null, profileId)
432+
433+
if (existing) return existing
434+
435+
return api.media.createFolder(adapter, {
436+
name,
437+
tenantId: profileId,
438+
})
439+
}
440+
```
441+
442+
`getFolderByName(name, parentId?, tenantId?)` scopes the lookup to a name, an optional parent folder, and an optional tenant. Pass `null` for `parentId` to search only root-level folders.
443+
379444
## API Reference
380445

381446
### Backend (`@btst/stack/plugins/media/api`)
@@ -549,10 +614,11 @@ Like other BTST plugins, the Media plugin supports two server-side access patter
549614

550615
| Function | Description |
551616
| --- | --- |
552-
| `listAssets(params?)` | List assets with pagination, search, MIME filtering, and folder filtering |
617+
| `listAssets(params?)` | List assets with pagination, search, MIME filtering, folder filtering, and optional tenant scoping |
553618
| `getAssetById(id)` | Fetch a single asset by ID |
554-
| `listFolders(params?)` | List folders, optionally scoped to a parent folder |
619+
| `listFolders(params?)` | List folders, optionally scoped to a parent folder and/or tenant |
555620
| `getFolderById(id)` | Fetch a single folder by ID |
621+
| `getFolderByName(name, parentId?, tenantId?)` | Find a folder by name, optionally scoped to a parent and/or tenant. Returns `null` if not found. |
556622

557623
**Available direct mutations:**
558624

packages/stack/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@btst/stack",
3-
"version": "2.10.1",
3+
"version": "2.11.0",
44
"description": "A composable, plugin-based library for building full-stack applications.",
55
"repository": {
66
"type": "git",

packages/stack/registry/btst-media.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
{
2020
"path": "btst/media/types.ts",
2121
"type": "registry:lib",
22-
"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",
22+
"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",
2323
"target": "src/components/btst/media/types.ts"
2424
},
2525
{

packages/stack/src/plugins/media/__tests__/getters.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getAssetById,
99
listFolders,
1010
getFolderById,
11+
getFolderByName,
1112
} from "../api/getters";
1213

1314
const createTestAdapter = (): Adapter => {
@@ -162,6 +163,30 @@ describe("media getters", () => {
162163
expect(photoResult.items[0]!.filename).toBe("holiday-photo.jpg");
163164
});
164165

166+
it("filters assets by tenantId", async () => {
167+
await adapter.create({
168+
model: "mediaAsset",
169+
data: makeAsset({
170+
filename: "tenant-a.jpg",
171+
url: "https://example.com/a.jpg",
172+
}),
173+
});
174+
const assetB = await adapter.create<{ id: string }>({
175+
model: "mediaAsset",
176+
data: {
177+
...makeAsset({
178+
filename: "tenant-b.jpg",
179+
url: "https://example.com/b.jpg",
180+
}),
181+
tenantId: "tenant-b",
182+
},
183+
});
184+
185+
const result = await listAssets(adapter, { tenantId: "tenant-b" });
186+
expect(result.items).toHaveLength(1);
187+
expect(result.items[0]!.id).toBe(assetB.id);
188+
});
189+
165190
it("paginates results with limit and offset", async () => {
166191
for (let i = 0; i < 5; i++) {
167192
await adapter.create({
@@ -231,6 +256,24 @@ describe("media getters", () => {
231256
expect(result[1]!.name).toBe("Zeta");
232257
});
233258

259+
it("filters folders by tenantId", async () => {
260+
await adapter.create({
261+
model: "mediaFolder",
262+
data: makeFolder({ name: "No Tenant" }),
263+
});
264+
const tenantFolder = await adapter.create<{ id: string }>({
265+
model: "mediaFolder",
266+
data: {
267+
...makeFolder({ name: "Tenant Folder" }),
268+
tenantId: "tenant-x",
269+
},
270+
});
271+
272+
const result = await listFolders(adapter, { tenantId: "tenant-x" });
273+
expect(result).toHaveLength(1);
274+
expect(result[0]!.id).toBe(tenantFolder.id);
275+
});
276+
234277
it("filters folders by parentId", async () => {
235278
const root = await adapter.create<{ id: string }>({
236279
model: "mediaFolder",
@@ -271,4 +314,116 @@ describe("media getters", () => {
271314
expect(result!.name).toBe("Test Folder");
272315
});
273316
});
317+
318+
// ── getFolderByName ───────────────────────────────────────────────────────
319+
320+
describe("getFolderByName", () => {
321+
it("returns null when no folder exists", async () => {
322+
const result = await getFolderByName(adapter, "nonexistent");
323+
expect(result).toBeNull();
324+
});
325+
326+
it("finds a root-level folder by name with no parentId constraint", async () => {
327+
await adapter.create({
328+
model: "mediaFolder",
329+
data: makeFolder({ name: "blog-gen-profile-1" }),
330+
});
331+
332+
const result = await getFolderByName(adapter, "blog-gen-profile-1");
333+
expect(result).not.toBeNull();
334+
expect(result!.name).toBe("blog-gen-profile-1");
335+
});
336+
337+
it("returns null when name matches but parentId does not", async () => {
338+
const parent = await adapter.create<{ id: string }>({
339+
model: "mediaFolder",
340+
data: makeFolder({ name: "Parent" }),
341+
});
342+
await adapter.create({
343+
model: "mediaFolder",
344+
data: makeFolder({ name: "Child", parentId: parent.id }),
345+
});
346+
347+
// Search for "Child" scoped to a different (non-existent) parent.
348+
const result = await getFolderByName(adapter, "Child", "wrong-parent-id");
349+
expect(result).toBeNull();
350+
});
351+
352+
it("scopes lookup to root-level folders when parentId is null", async () => {
353+
// Explicitly store parentId as null (mirrors how SQL adapters persist root folders).
354+
const rootFolder = await adapter.create<{ id: string }>({
355+
model: "mediaFolder",
356+
data: { ...makeFolder({ name: "Images" }), parentId: null },
357+
});
358+
// A nested folder with the same name.
359+
await adapter.create({
360+
model: "mediaFolder",
361+
data: makeFolder({ name: "Images", parentId: rootFolder.id }),
362+
});
363+
364+
// Only the root-level "Images" (parentId = null) should be returned.
365+
const result = await getFolderByName(adapter, "Images", null);
366+
expect(result).not.toBeNull();
367+
expect(result!.id).toBe(rootFolder.id);
368+
});
369+
370+
it("finds a child folder scoped to the correct parentId", async () => {
371+
const parentA = await adapter.create<{ id: string }>({
372+
model: "mediaFolder",
373+
data: makeFolder({ name: "Parent A" }),
374+
});
375+
const parentB = await adapter.create<{ id: string }>({
376+
model: "mediaFolder",
377+
data: makeFolder({ name: "Parent B" }),
378+
});
379+
const childOfA = await adapter.create<{ id: string }>({
380+
model: "mediaFolder",
381+
data: makeFolder({ name: "Child", parentId: parentA.id }),
382+
});
383+
await adapter.create({
384+
model: "mediaFolder",
385+
data: makeFolder({ name: "Child", parentId: parentB.id }),
386+
});
387+
388+
const result = await getFolderByName(adapter, "Child", parentA.id);
389+
expect(result).not.toBeNull();
390+
expect(result!.id).toBe(childOfA.id);
391+
});
392+
393+
it("filters by tenantId when provided", async () => {
394+
await adapter.create({
395+
model: "mediaFolder",
396+
data: { ...makeFolder({ name: "blog-gen-abc" }), tenantId: "tenant-1" },
397+
});
398+
const folderT2 = await adapter.create<{ id: string }>({
399+
model: "mediaFolder",
400+
data: { ...makeFolder({ name: "blog-gen-abc" }), tenantId: "tenant-2" },
401+
});
402+
403+
// Same name, different tenant — should only return the matching one.
404+
const result = await getFolderByName(
405+
adapter,
406+
"blog-gen-abc",
407+
undefined,
408+
"tenant-2",
409+
);
410+
expect(result).not.toBeNull();
411+
expect(result!.id).toBe(folderT2.id);
412+
});
413+
414+
it("returns null when tenantId does not match", async () => {
415+
await adapter.create({
416+
model: "mediaFolder",
417+
data: { ...makeFolder({ name: "blog-gen-xyz" }), tenantId: "tenant-1" },
418+
});
419+
420+
const result = await getFolderByName(
421+
adapter,
422+
"blog-gen-xyz",
423+
undefined,
424+
"tenant-999",
425+
);
426+
expect(result).toBeNull();
427+
});
428+
});
274429
});

packages/stack/src/plugins/media/__tests__/mutations.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,21 @@ describe("media mutations", () => {
5959
expect(asset.alt).toBe("A beautiful photo");
6060
});
6161

62+
it("persists tenantId when provided", async () => {
63+
const asset = await createAsset(adapter, {
64+
...assetInput,
65+
tenantId: "profile-abc",
66+
});
67+
68+
expect(asset.tenantId).toBe("profile-abc");
69+
});
70+
71+
it("leaves tenantId absent when not provided", async () => {
72+
const asset = await createAsset(adapter, assetInput);
73+
74+
expect(asset.tenantId == null).toBe(true);
75+
});
76+
6277
it("creates multiple independent assets", async () => {
6378
await createAsset(adapter, {
6479
...assetInput,
@@ -194,6 +209,21 @@ describe("media mutations", () => {
194209
expect(folder.createdAt).toBeInstanceOf(Date);
195210
});
196211

212+
it("persists tenantId when provided", async () => {
213+
const folder = await createFolder(adapter, {
214+
name: "Tenant Folder",
215+
tenantId: "profile-xyz",
216+
});
217+
218+
expect(folder.tenantId).toBe("profile-xyz");
219+
});
220+
221+
it("leaves tenantId absent when not provided", async () => {
222+
const folder = await createFolder(adapter, { name: "No Tenant" });
223+
224+
expect(folder.tenantId == null).toBe(true);
225+
});
226+
197227
it("creates a nested folder with parentId", async () => {
198228
const parent = await createFolder(adapter, { name: "Root" });
199229
const child = await createFolder(adapter, {

0 commit comments

Comments
 (0)