Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ jobs:

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

- name: Update npm
run: npm install -g npm@latest

- run: pnpm install

- name: Copy README files to published packages
Expand Down
70 changes: 68 additions & 2 deletions docs/content/docs/plugins/media.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,71 @@ export function ProductImageField({
}
```

## Multi-tenancy

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.

### How it works

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:

- **Filters** `GET /media/assets` and `GET /media/folders` to the resolved tenant.
- **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`.
- **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).
- **Guards `folderId` assignments** — supplying a `folderId` that belongs to a different tenant is rejected with 404 on all create and upload endpoints.

Returning `null` or `undefined` from `resolveTenantId` disables scoping for that request, which is useful for super-admin routes.

### Configuration

```ts title="lib/stack.ts"
import { stack } from "@btst/stack"
import { mediaBackendPlugin, s3Adapter } from "@btst/stack/plugins/media/api"
import { getSession } from "@/lib/auth"

const { handler, dbSchema } = stack({
basePath: "/api/data",
plugins: {
media: mediaBackendPlugin({
storageAdapter: s3Adapter({ /* ... */ }),
resolveTenantId: async (context) => {
const session = await getSession(context.headers as Headers)
if (!session) throw new Error("Unauthorized")
// Return the business profile ID as the tenant key.
return session.user.activeProfileId ?? null
},
}),
},
adapter: (db) => createPrismaAdapter(prisma, db),
})
```

When `resolveTenantId` throws, the request is rejected exactly like any other hook error. Use this to enforce authentication before any media operation is allowed.

### Get-or-create folders per tenant

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:

```ts
import { stack } from "@btst/stack"

const { api } = stack({ /* ... */ })

async function getOrCreateProfileFolder(profileId: string) {
const name = `blog-gen-${profileId}`
const existing = await api.media.getFolderByName(name, null, profileId)

if (existing) return existing

return api.media.createFolder(adapter, {
name,
tenantId: profileId,
})
}
```

`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.

## API Reference

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

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

**Available direct mutations:**

Expand Down
2 changes: 1 addition & 1 deletion packages/stack/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@btst/stack",
"version": "2.10.1",
"version": "2.11.0",
"description": "A composable, plugin-based library for building full-stack applications.",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/registry/btst-media.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
{
Expand Down
155 changes: 155 additions & 0 deletions packages/stack/src/plugins/media/__tests__/getters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getAssetById,
listFolders,
getFolderById,
getFolderByName,
} from "../api/getters";

const createTestAdapter = (): Adapter => {
Expand Down Expand Up @@ -162,6 +163,30 @@ describe("media getters", () => {
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({
Expand Down Expand Up @@ -231,6 +256,24 @@ describe("media getters", () => {
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",
Expand Down Expand Up @@ -271,4 +314,116 @@ describe("media getters", () => {
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();
});
});
});
30 changes: 30 additions & 0 deletions packages/stack/src/plugins/media/__tests__/mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ describe("media mutations", () => {
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,
Expand Down Expand Up @@ -194,6 +209,21 @@ describe("media mutations", () => {
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, {
Expand Down
Loading
Loading