diff --git a/e2e/tests/smoke.chat.spec.ts b/e2e/tests/smoke.chat.spec.ts index cccecdf..3ce4a02 100644 --- a/e2e/tests/smoke.chat.spec.ts +++ b/e2e/tests/smoke.chat.spec.ts @@ -144,13 +144,21 @@ test.describe("AI Chat Plugin", () => { }) => { // This test verifies that multiple messages in a conversation stay together // and don't create separate history items (fixes the bug where every message - // created a new conversation) + // created a new conversation). + // + // A unique run ID is embedded in the first message so that conversations + // created by previous retry attempts don't bleed into the sidebar count + // assertion. The in-memory adapter persists state across Playwright retries + // within the same server process, so a fixed message text would cause + // multiple conversations with the same title to accumulate. + const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + const firstMessage = `First msg in conv ${runId}`; await page.goto("/pages/chat"); // Send first message const input = page.getByPlaceholder("Type a message..."); - await input.fill("First message in conversation"); + await input.fill(firstMessage); await page.keyboard.press("Enter"); // Wait for first AI response @@ -158,7 +166,7 @@ test.describe("AI Chat Plugin", () => { page .locator('[data-testid="chat-interface"]') .locator('[aria-label="AI response"]'), - ).toBeVisible({ timeout: 30000 }); + ).toBeVisible({ timeout: 45000 }); // Wait for navigation to new conversation URL await page.waitForURL(/\/pages\/chat\/[a-zA-Z0-9]+/, { timeout: 10000 }); @@ -179,9 +187,7 @@ test.describe("AI Chat Plugin", () => { // Both messages should still be visible in the same conversation await expect( - page - .locator('[data-testid="chat-interface"]') - .getByText("First message in conversation"), + page.locator('[data-testid="chat-interface"]').getByText(firstMessage), ).toBeVisible({ timeout: 10000 }); await expect( page @@ -189,11 +195,11 @@ test.describe("AI Chat Plugin", () => { .getByText("Second message in same conversation"), ).toBeVisible({ timeout: 10000 }); - // There should only be ONE conversation in the sidebar with "First message" - // (the title is based on the first message) - const sidebarConversations = page.locator( - 'button:has-text("First message in conversation")', - ); + // There should be exactly ONE conversation in the sidebar matching this + // run's unique first message. Searching by the unique runId ensures + // conversations from previous retry attempts (which used a different runId) + // are not counted. + const sidebarConversations = page.locator(`button:has-text("${runId}")`); await expect(sidebarConversations).toHaveCount(1, { timeout: 5000 }); }); diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index 0994dac..1dd4317 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -44,6 +44,11 @@ export default defineBuildConfig({ "react/jsx-runtime", "@tanstack/react-query", "sonner", + // optional peerDependencies (media plugin) + "@vercel/blob", + "@vercel/blob/server", + "@aws-sdk/client-s3", + "@aws-sdk/s3-request-presigner", // test/build-time deps kept external "vitest", "@vitest/runner", @@ -110,6 +115,8 @@ export default defineBuildConfig({ "./src/plugins/comments/client/components/index.tsx", "./src/plugins/comments/client/hooks/index.tsx", "./src/plugins/comments/query-keys.ts", + // media plugin entries + "./src/plugins/media/api/index.ts", "./src/components/auto-form/index.ts", "./src/components/stepped-auto-form/index.ts", "./src/components/multi-select/index.ts", diff --git a/packages/stack/knip.json b/packages/stack/knip.json index 7e6b89c..64e1f77 100644 --- a/packages/stack/knip.json +++ b/packages/stack/knip.json @@ -40,6 +40,7 @@ "src/plugins/kanban/client/components/index.tsx", "src/plugins/kanban/client/hooks/index.tsx", "src/plugins/kanban/query-keys.ts", + "src/plugins/media/api/index.ts", "build.config.ts", "vitest.config.mts", "scripts/build-registry.ts", @@ -63,6 +64,9 @@ "remark-gfm", "remark-math", "tailwindcss", - "@tailwindcss/typography" + "@tailwindcss/typography", + "@aws-sdk/client-s3", + "@aws-sdk/s3-request-presigner", + "@vercel/blob" ] } diff --git a/packages/stack/package.json b/packages/stack/package.json index 9d08154..cb0ba9c 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -414,6 +414,16 @@ } }, "./plugins/comments/css": "./dist/plugins/comments/style.css", + "./plugins/media/api": { + "import": { + "types": "./dist/plugins/media/api/index.d.ts", + "default": "./dist/plugins/media/api/index.mjs" + }, + "require": { + "types": "./dist/plugins/media/api/index.d.cts", + "default": "./dist/plugins/media/api/index.cjs" + } + }, "./plugins/route-docs/client": { "import": { "types": "./dist/plugins/route-docs/client/index.d.ts", @@ -610,6 +620,9 @@ "plugins/comments/query-keys": [ "./dist/plugins/comments/query-keys.d.ts" ], + "plugins/media/api": [ + "./dist/plugins/media/api/index.d.ts" + ], "plugins/route-docs/client": [ "./dist/plugins/route-docs/client/index.d.ts" ], @@ -646,13 +659,17 @@ }, "peerDependencies": { "@ai-sdk/react": ">=2.0.0", + "@aws-sdk/client-s3": ">=3.0.0", + "@aws-sdk/s3-request-presigner": ">=3.0.0", "@btst/yar": ">=1.2.0", "@hookform/resolvers": ">=5.0.0", "@radix-ui/react-dialog": ">=1.1.0", "@radix-ui/react-label": ">=2.1.0", "@radix-ui/react-slot": ">=1.1.0", "@radix-ui/react-switch": ">=1.1.0", + "@tailwindcss/typography": ">=0.5.0", "@tanstack/react-query": "^5.0.0", + "@vercel/blob": ">=0.14.0", "ai": ">=5.0.0", "better-call": ">=1.3.2", "class-variance-authority": ">=0.7.0", @@ -675,25 +692,38 @@ "sonner": ">=2.0.0", "tailwind-merge": ">=2.6.0", "tailwindcss": ">=3.0.0", - "@tailwindcss/typography": ">=0.5.0", "zod": ">=4.2.0" }, + "peerDependenciesMeta": { + "@vercel/blob": { + "optional": true + }, + "@aws-sdk/client-s3": { + "optional": true + }, + "@aws-sdk/s3-request-presigner": { + "optional": true + } + }, "devDependencies": { - "tsx": "catalog:", "@ai-sdk/react": "^2.0.94", + "@aws-sdk/client-s3": "^3.1011.0", + "@aws-sdk/s3-request-presigner": "^3.1011.0", "@btst/adapter-memory": "2.1.1", "@btst/yar": "1.2.0", "@types/react": "^19.0.0", "@types/slug": "^5.0.9", + "@vercel/blob": "^0.27.3", "@workspace/ui": "workspace:*", "ai": "^5.0.94", "better-call": "catalog:", + "knip": "^5.61.2", "react": "^19.1.1", "react-dom": "^19.1.1", "react-error-boundary": "^4.1.2", - "knip": "^5.61.2", "rollup-plugin-preserve-directives": "0.4.0", "rollup-plugin-visualizer": "^5.12.0", + "tsx": "catalog:", "typescript": "catalog:", "unbuild": "catalog:", "vitest": "catalog:", diff --git a/packages/stack/src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts b/packages/stack/src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts new file mode 100644 index 0000000..86a4ba3 --- /dev/null +++ b/packages/stack/src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts @@ -0,0 +1,9 @@ +/** + * Stub for @vercel/blob/server — used in tests only. + * The real module is an optional peer dependency. + */ +export async function handleUpload(_options: unknown): Promise { + throw new Error( + "handleUpload is not available in the installed @vercel/blob version. Use a version that exports @vercel/blob/server.", + ); +} diff --git a/packages/stack/src/plugins/media/__tests__/getters.test.ts b/packages/stack/src/plugins/media/__tests__/getters.test.ts new file mode 100644 index 0000000..8d525c2 --- /dev/null +++ b/packages/stack/src/plugins/media/__tests__/getters.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createMemoryAdapter } from "@btst/adapter-memory"; +import { defineDb } from "@btst/db"; +import type { DBAdapter as Adapter } from "@btst/db"; +import { mediaSchema } from "../db"; +import { + listAssets, + getAssetById, + listFolders, + getFolderById, +} from "../api/getters"; + +const createTestAdapter = (): Adapter => { + const db = defineDb({}).use(mediaSchema); + return createMemoryAdapter(db)({}); +}; + +const makeAsset = ( + overrides: Partial<{ + filename: string; + originalName: string; + mimeType: string; + size: number; + url: string; + folderId: string | undefined; + alt: string | undefined; + }> = {}, +) => ({ + filename: "image.jpg", + originalName: "My Image.jpg", + mimeType: "image/jpeg", + size: 1024, + url: "https://example.com/image.jpg", + folderId: undefined, + alt: undefined, + createdAt: new Date(), + ...overrides, +}); + +const makeFolder = ( + overrides: Partial<{ + name: string; + parentId: string | undefined; + }> = {}, +) => ({ + name: "My Folder", + parentId: undefined, + createdAt: new Date(), + ...overrides, +}); + +describe("media getters", () => { + let adapter: Adapter; + + beforeEach(() => { + adapter = createTestAdapter(); + }); + + // ── listAssets ──────────────────────────────────────────────────────────── + + describe("listAssets", () => { + it("returns empty result when no assets exist", async () => { + const result = await listAssets(adapter); + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + }); + + it("returns all assets with correct fields", async () => { + await adapter.create({ + model: "mediaAsset", + data: makeAsset({ + filename: "photo.jpg", + url: "https://example.com/photo.jpg", + }), + }); + + const result = await listAssets(adapter); + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.items[0]!.filename).toBe("photo.jpg"); + expect(result.items[0]!.mimeType).toBe("image/jpeg"); + }); + + it("filters assets by folderId", async () => { + await adapter.create({ + model: "mediaFolder", + data: makeFolder({ name: "Photos" }), + }); + const folder = await adapter.findOne<{ id: string }>({ + model: "mediaFolder", + where: [{ field: "name", value: "Photos", operator: "eq" }], + }); + + await adapter.create({ + model: "mediaAsset", + data: makeAsset({ filename: "in-folder.jpg", folderId: folder!.id }), + }); + await adapter.create({ + model: "mediaAsset", + data: makeAsset({ filename: "no-folder.jpg" }), + }); + + const result = await listAssets(adapter, { folderId: folder!.id }); + expect(result.items).toHaveLength(1); + expect(result.items[0]!.filename).toBe("in-folder.jpg"); + }); + + it("filters assets by mimeType", async () => { + await adapter.create({ + model: "mediaAsset", + data: makeAsset({ filename: "image.jpg", mimeType: "image/jpeg" }), + }); + await adapter.create({ + model: "mediaAsset", + data: makeAsset({ filename: "doc.pdf", mimeType: "application/pdf" }), + }); + + const images = await listAssets(adapter, { mimeType: "image/jpeg" }); + expect(images.items).toHaveLength(1); + expect(images.items[0]!.filename).toBe("image.jpg"); + + const pdfs = await listAssets(adapter, { mimeType: "application/pdf" }); + expect(pdfs.items).toHaveLength(1); + expect(pdfs.items[0]!.filename).toBe("doc.pdf"); + }); + + it("searches assets by query string across filename, originalName, and alt", async () => { + await adapter.create({ + model: "mediaAsset", + data: makeAsset({ + filename: "holiday-photo.jpg", + originalName: "Holiday Photo.jpg", + alt: "Beach sunset", + }), + }); + await adapter.create({ + model: "mediaAsset", + data: makeAsset({ + filename: "logo.png", + originalName: "Company Logo.png", + alt: "Brand logo", + }), + }); + + const holidayResult = await listAssets(adapter, { query: "holiday" }); + expect(holidayResult.items).toHaveLength(1); + expect(holidayResult.items[0]!.filename).toBe("holiday-photo.jpg"); + + // "beach" matches only the alt text of the first asset + const beachResult = await listAssets(adapter, { query: "beach" }); + expect(beachResult.items).toHaveLength(1); + expect(beachResult.items[0]!.alt).toBe("Beach sunset"); + + // "logo" matches only the second asset (filename, originalName, alt) + const logoResult = await listAssets(adapter, { query: "logo" }); + expect(logoResult.items).toHaveLength(1); + expect(logoResult.items[0]!.filename).toBe("logo.png"); + + // "photo" matches only the first asset via filename and originalName + const photoResult = await listAssets(adapter, { query: "photo" }); + expect(photoResult.items).toHaveLength(1); + expect(photoResult.items[0]!.filename).toBe("holiday-photo.jpg"); + }); + + it("paginates results with limit and offset", async () => { + for (let i = 0; i < 5; i++) { + await adapter.create({ + model: "mediaAsset", + data: makeAsset({ + filename: `asset-${i}.jpg`, + url: `https://example.com/${i}.jpg`, + }), + }); + } + + const page1 = await listAssets(adapter, { limit: 2, offset: 0 }); + expect(page1.items).toHaveLength(2); + expect(page1.total).toBe(5); + + const page2 = await listAssets(adapter, { limit: 2, offset: 2 }); + expect(page2.items).toHaveLength(2); + expect(page2.total).toBe(5); + + const page3 = await listAssets(adapter, { limit: 2, offset: 4 }); + expect(page3.items).toHaveLength(1); + expect(page3.total).toBe(5); + }); + }); + + // ── getAssetById ───────────────────────────────────────────────────────── + + describe("getAssetById", () => { + it("returns null when asset does not exist", async () => { + const result = await getAssetById(adapter, "nonexistent-id"); + expect(result).toBeNull(); + }); + + it("returns the asset by ID", async () => { + const created = await adapter.create<{ id: string }>({ + model: "mediaAsset", + data: makeAsset({ filename: "found.jpg" }), + }); + + const result = await getAssetById(adapter, created.id); + expect(result).not.toBeNull(); + expect(result!.filename).toBe("found.jpg"); + }); + }); + + // ── listFolders ─────────────────────────────────────────────────────────── + + describe("listFolders", () => { + it("returns empty array when no folders exist", async () => { + const result = await listFolders(adapter); + expect(result).toEqual([]); + }); + + it("returns all folders sorted by name", async () => { + await adapter.create({ + model: "mediaFolder", + data: makeFolder({ name: "Zeta" }), + }); + await adapter.create({ + model: "mediaFolder", + data: makeFolder({ name: "Alpha" }), + }); + + const result = await listFolders(adapter); + expect(result).toHaveLength(2); + expect(result[0]!.name).toBe("Alpha"); + expect(result[1]!.name).toBe("Zeta"); + }); + + it("filters folders by parentId", async () => { + const root = await adapter.create<{ id: string }>({ + model: "mediaFolder", + data: makeFolder({ name: "Root" }), + }); + await adapter.create({ + model: "mediaFolder", + data: makeFolder({ name: "Child", parentId: root.id }), + }); + + // No params — returns ALL folders + const allFolders = await listFolders(adapter); + expect(allFolders).toHaveLength(2); + + // Filter children of root + const childFolders = await listFolders(adapter, { parentId: root.id }); + expect(childFolders).toHaveLength(1); + expect(childFolders[0]!.name).toBe("Child"); + }); + }); + + // ── getFolderById ───────────────────────────────────────────────────────── + + describe("getFolderById", () => { + it("returns null when folder does not exist", async () => { + const result = await getFolderById(adapter, "nonexistent-id"); + expect(result).toBeNull(); + }); + + it("returns the folder by ID", async () => { + const created = await adapter.create<{ id: string }>({ + model: "mediaFolder", + data: makeFolder({ name: "Test Folder" }), + }); + + const result = await getFolderById(adapter, created.id); + expect(result).not.toBeNull(); + expect(result!.name).toBe("Test Folder"); + }); + }); +}); diff --git a/packages/stack/src/plugins/media/__tests__/mutations.test.ts b/packages/stack/src/plugins/media/__tests__/mutations.test.ts new file mode 100644 index 0000000..2d5c530 --- /dev/null +++ b/packages/stack/src/plugins/media/__tests__/mutations.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createMemoryAdapter } from "@btst/adapter-memory"; +import { defineDb } from "@btst/db"; +import type { DBAdapter as Adapter } from "@btst/db"; +import { mediaSchema } from "../db"; +import type { Asset, Folder } from "../types"; +import { + createAsset, + updateAsset, + deleteAsset, + createFolder, + deleteFolder, +} from "../api/mutations"; +import { getAssetById, getFolderById } from "../api/getters"; + +const createTestAdapter = (): Adapter => { + const db = defineDb({}).use(mediaSchema); + return createMemoryAdapter(db)({}); +}; + +const assetInput = { + filename: "photo.jpg", + originalName: "My Photo.jpg", + mimeType: "image/jpeg", + size: 2048, + url: "https://example.com/photo.jpg", +}; + +describe("media mutations", () => { + let adapter: Adapter; + + beforeEach(() => { + adapter = createTestAdapter(); + }); + + // ── createAsset ─────────────────────────────────────────────────────────── + + describe("createAsset", () => { + it("creates an asset with required fields", async () => { + const asset = await createAsset(adapter, assetInput); + + expect(asset.id).toBeDefined(); + expect(asset.filename).toBe("photo.jpg"); + expect(asset.originalName).toBe("My Photo.jpg"); + expect(asset.mimeType).toBe("image/jpeg"); + expect(asset.size).toBe(2048); + expect(asset.url).toBe("https://example.com/photo.jpg"); + expect(asset.createdAt).toBeInstanceOf(Date); + }); + + it("creates an asset with optional fields", async () => { + const asset = await createAsset(adapter, { + ...assetInput, + folderId: "folder-123", + alt: "A beautiful photo", + }); + + expect(asset.folderId).toBe("folder-123"); + expect(asset.alt).toBe("A beautiful photo"); + }); + + it("creates multiple independent assets", async () => { + await createAsset(adapter, { + ...assetInput, + filename: "a.jpg", + url: "https://example.com/a.jpg", + }); + await createAsset(adapter, { + ...assetInput, + filename: "b.jpg", + url: "https://example.com/b.jpg", + }); + + const all = await adapter.findMany({ model: "mediaAsset" }); + expect(all).toHaveLength(2); + }); + }); + + // ── updateAsset ─────────────────────────────────────────────────────────── + + describe("updateAsset", () => { + it("updates the alt text of an asset", async () => { + const asset = await createAsset(adapter, assetInput); + const updated = await updateAsset(adapter, asset.id, { + alt: "Updated alt text", + }); + + expect(updated).not.toBeNull(); + expect(updated!.alt).toBe("Updated alt text"); + }); + + it("updates the folderId of an asset", async () => { + const asset = await createAsset(adapter, assetInput); + const updated = await updateAsset(adapter, asset.id, { + folderId: "folder-abc", + }); + + expect(updated!.folderId).toBe("folder-abc"); + }); + + it("returns the updated asset when only folderId is changed", async () => { + const asset = await createAsset(adapter, { + ...assetInput, + folderId: "folder-old", + }); + const updated = await updateAsset(adapter, asset.id, { + folderId: "folder-new", + }); + + expect(updated).not.toBeNull(); + expect(updated!.folderId).toBe("folder-new"); + }); + + it("clears the folder association when folderId is null", async () => { + const asset = await createAsset(adapter, { + ...assetInput, + folderId: "folder-to-clear", + }); + expect(asset.folderId).toBe("folder-to-clear"); + + const updated = await updateAsset(adapter, asset.id, { folderId: null }); + expect(updated).not.toBeNull(); + expect(updated!.folderId == null).toBe(true); + }); + + it("does not change folderId when folderId is not in the input", async () => { + const asset = await createAsset(adapter, { + ...assetInput, + folderId: "folder-keep", + }); + + const updated = await updateAsset(adapter, asset.id, { + alt: "new alt text", + }); + expect(updated).not.toBeNull(); + expect(updated!.folderId).toBe("folder-keep"); + expect(updated!.alt).toBe("new alt text"); + }); + + it("returns null for nonexistent asset", async () => { + const result = await updateAsset(adapter, "nonexistent-id", { + alt: "test", + }); + expect(result).toBeNull(); + }); + }); + + // ── deleteAsset ─────────────────────────────────────────────────────────── + + describe("deleteAsset", () => { + it("removes the asset from the database", async () => { + const asset = await createAsset(adapter, assetInput); + await deleteAsset(adapter, asset.id); + + const found = await getAssetById(adapter, asset.id); + expect(found).toBeNull(); + }); + + it("does not throw when deleting a nonexistent asset", async () => { + await expect( + deleteAsset(adapter, "nonexistent-id"), + ).resolves.not.toThrow(); + }); + + it("only deletes the targeted asset", async () => { + const a = await createAsset(adapter, { + ...assetInput, + filename: "a.jpg", + url: "https://example.com/a.jpg", + }); + await createAsset(adapter, { + ...assetInput, + filename: "b.jpg", + url: "https://example.com/b.jpg", + }); + + await deleteAsset(adapter, a.id); + + const remaining = await adapter.findMany({ model: "mediaAsset" }); + expect(remaining).toHaveLength(1); + expect(remaining[0]!.filename).toBe("b.jpg"); + }); + }); + + // ── createFolder ────────────────────────────────────────────────────────── + + describe("createFolder", () => { + it("creates a root folder", async () => { + const folder = await createFolder(adapter, { name: "Uploads" }); + + expect(folder.id).toBeDefined(); + expect(folder.name).toBe("Uploads"); + expect(folder.parentId).toBeUndefined(); + expect(folder.createdAt).toBeInstanceOf(Date); + }); + + it("creates a nested folder with parentId", async () => { + const parent = await createFolder(adapter, { name: "Root" }); + const child = await createFolder(adapter, { + name: "Photos", + parentId: parent.id, + }); + + expect(child.parentId).toBe(parent.id); + }); + }); + + // ── deleteFolder ────────────────────────────────────────────────────────── + + describe("deleteFolder", () => { + it("deletes an empty folder", async () => { + const folder = await createFolder(adapter, { name: "Empty" }); + await deleteFolder(adapter, folder.id); + + const found = await getFolderById(adapter, folder.id); + expect(found).toBeNull(); + }); + + it("throws an error when the folder contains assets", async () => { + const folder = await createFolder(adapter, { name: "Full Folder" }); + await createAsset(adapter, { ...assetInput, folderId: folder.id }); + + await expect(deleteFolder(adapter, folder.id)).rejects.toThrow( + "Cannot delete folder", + ); + + const stillExists = await getFolderById(adapter, folder.id); + expect(stillExists).not.toBeNull(); + }); + + it("allows deletion after assets are removed", async () => { + const folder = await createFolder(adapter, { name: "Soon Empty" }); + const asset = await createAsset(adapter, { + ...assetInput, + folderId: folder.id, + }); + + await deleteAsset(adapter, asset.id); + await expect(deleteFolder(adapter, folder.id)).resolves.not.toThrow(); + + const found = await getFolderById(adapter, folder.id); + expect(found).toBeNull(); + }); + + it("does not throw when deleting a nonexistent folder", async () => { + await expect( + deleteFolder(adapter, "nonexistent-id"), + ).resolves.not.toThrow(); + }); + + it("only deletes the targeted folder and not siblings", async () => { + const a = await createFolder(adapter, { name: "Folder A" }); + await createFolder(adapter, { name: "Folder B" }); + + await deleteFolder(adapter, a.id); + + const remaining = await adapter.findMany({ + model: "mediaFolder", + }); + expect(remaining).toHaveLength(1); + expect(remaining[0]!.name).toBe("Folder B"); + }); + + it("cascade-deletes child folders when the subtree has no assets", async () => { + const parent = await createFolder(adapter, { name: "Parent" }); + const child = await createFolder(adapter, { + name: "Child", + parentId: parent.id, + }); + const grandchild = await createFolder(adapter, { + name: "Grandchild", + parentId: child.id, + }); + + await deleteFolder(adapter, parent.id); + + expect(await getFolderById(adapter, parent.id)).toBeNull(); + expect(await getFolderById(adapter, child.id)).toBeNull(); + expect(await getFolderById(adapter, grandchild.id)).toBeNull(); + }); + + it("throws when a child folder contains assets and leaves the whole subtree intact", async () => { + const parent = await createFolder(adapter, { name: "Parent" }); + const child = await createFolder(adapter, { + name: "Child", + parentId: parent.id, + }); + await createAsset(adapter, { ...assetInput, folderId: child.id }); + + await expect(deleteFolder(adapter, parent.id)).rejects.toThrow( + "Cannot delete folder", + ); + + // Both folders still exist — no partial deletion. + expect(await getFolderById(adapter, parent.id)).not.toBeNull(); + expect(await getFolderById(adapter, child.id)).not.toBeNull(); + }); + }); +}); diff --git a/packages/stack/src/plugins/media/__tests__/plugin.test.ts b/packages/stack/src/plugins/media/__tests__/plugin.test.ts new file mode 100644 index 0000000..10b5902 --- /dev/null +++ b/packages/stack/src/plugins/media/__tests__/plugin.test.ts @@ -0,0 +1,735 @@ +import { afterEach, describe, it, expect, vi } from "vitest"; +import { createMemoryAdapter } from "@btst/adapter-memory"; +import type { DBAdapter as Adapter, DatabaseDefinition } from "@btst/db"; +import { stack } from "../../../api"; +import { mediaBackendPlugin, type MediaBackendConfig } from "../api/plugin"; +import { localAdapter } from "../api/adapters/local"; +import type { + DirectStorageAdapter, + S3StorageAdapter, + VercelBlobStorageAdapter, +} from "../api/storage-adapter"; + +type AdapterFactory = (db: DatabaseDefinition) => Adapter; + +const testAdapter: AdapterFactory = (db: DatabaseDefinition): Adapter => + createMemoryAdapter(db)({}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +function createBackend( + config: Partial & { + adapterFactory?: AdapterFactory; + } = {}, +) { + const { adapterFactory = testAdapter, storageAdapter, ...overrides } = config; + + return stack({ + basePath: "/api", + plugins: { + media: mediaBackendPlugin({ + storageAdapter: storageAdapter ?? localAdapter(), + ...overrides, + }), + }, + adapter: adapterFactory, + }); +} + +function createLocalStorageAdapter( + overrides: Partial = {}, +): DirectStorageAdapter { + return { + type: "local", + upload: vi.fn(async (_buffer, options) => ({ + url: `/uploads/${options.filename}`, + })), + delete: vi.fn(async () => undefined), + ...overrides, + }; +} + +function createS3StorageAdapter( + urlPrefix = "https://assets.example.com", +): S3StorageAdapter { + return { + type: "s3", + urlPrefix, + generateUploadToken: vi.fn(async (options) => { + const normalizedPrefix = urlPrefix.replace(/\/$/, ""); + const key = options.folderId + ? `${options.folderId}/${options.filename}` + : options.filename; + return { + type: "presigned-url" as const, + payload: { + uploadUrl: "https://s3.example.com/upload", + publicUrl: `${normalizedPrefix}/${key}`, + key, + method: "PUT" as const, + headers: { "Content-Type": options.mimeType }, + }, + }; + }), + delete: vi.fn(async () => undefined), + } satisfies S3StorageAdapter; +} + +function createVercelBlobStorageAdapter( + overrides: Partial = {}, +): VercelBlobStorageAdapter { + return { + type: "vercel-blob", + urlHostnameSuffix: ".public.blob.vercel-storage.com", + handleRequest: vi.fn(async (request, callbacks) => { + const body = (await request.json()) as { + pathname?: string; + clientPayload?: string | null; + }; + const tokenOptions = await callbacks.onBeforeGenerateToken?.( + body.pathname ?? "photo.jpg", + body.clientPayload ?? null, + ); + return { ok: true, tokenOptions }; + }), + delete: vi.fn(async () => undefined), + ...overrides, + }; +} + +function createS3Backend( + config: Omit, "storageAdapter"> & { + storageAdapter?: S3StorageAdapter; + adapterFactory?: AdapterFactory; + } = {}, +) { + return createBackend({ + storageAdapter: config.storageAdapter ?? createS3StorageAdapter(), + allowedUrlPrefixes: config.allowedUrlPrefixes, + hooks: config.hooks, + maxFileSizeBytes: config.maxFileSizeBytes, + allowedMimeTypes: config.allowedMimeTypes, + adapterFactory: config.adapterFactory, + }); +} + +function createVercelBlobBackend( + config: Omit, "storageAdapter"> & { + storageAdapter?: VercelBlobStorageAdapter; + adapterFactory?: AdapterFactory; + } = {}, +) { + return createBackend({ + storageAdapter: config.storageAdapter ?? createVercelBlobStorageAdapter(), + allowedUrlPrefixes: config.allowedUrlPrefixes, + hooks: config.hooks, + maxFileSizeBytes: config.maxFileSizeBytes, + allowedMimeTypes: config.allowedMimeTypes, + adapterFactory: config.adapterFactory, + }); +} + +const createAssetRequestBody = (url: string) => ({ + filename: "photo.jpg", + originalName: "Photo.jpg", + mimeType: "image/jpeg", + size: 1024, + url, +}); + +function createJsonRequest( + path: string, + method: string, + body?: unknown, +): Request { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); +} + +function createUploadRequest(options?: { + fileName?: string; + mimeType?: string; + content?: string; + folderId?: string; +}): Request { + const formData = new FormData(); + formData.set( + "file", + new File( + [options?.content ?? "hello world"], + options?.fileName ?? "photo.jpg", + { + type: options?.mimeType ?? "image/jpeg", + }, + ), + ); + + if (options?.folderId) { + formData.set("folderId", options.folderId); + } + + return new Request("http://localhost/api/media/upload", { + method: "POST", + body: formData, + }); +} + +async function createFolderViaApi( + backend: ReturnType, + input: { name: string; parentId?: string }, +) { + const response = await backend.handler( + createJsonRequest("/api/media/folders", "POST", input), + ); + + expect(response.ok).toBe(true); + return (await response.json()) as { + id: string; + name: string; + parentId?: string; + }; +} + +async function createAssetViaApi( + backend: ReturnType, + input: ReturnType & { + folderId?: string; + alt?: string; + }, +) { + const response = await backend.handler( + createJsonRequest("/api/media/assets", "POST", input), + ); + + expect(response.ok).toBe(true); + return (await response.json()) as { + id: string; + folderId?: string; + alt?: string; + }; +} + +function invokeEndpoint( + backend: ReturnType, + endpointKey: string, + request: Request, +) { + return (backend.router as any).endpoints[endpointKey]({ + request, + headers: request.headers, + method: request.method, + params: {}, + query: {}, + asResponse: true, + }); +} + +describe("mediaBackendPlugin create-asset URL validation", () => { + it("rejects client-supplied URLs when using localAdapter by default", async () => { + const backend = createBackend(); + + const response = await backend.handler( + new Request("http://localhost/api/media/assets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + createAssetRequestBody("https://evil.example/tracker.jpg"), + ), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain( + "Client-supplied asset URLs are not allowed with localAdapter", + ); + + const assets = await backend.api.media.listAssets(); + expect(assets.items).toHaveLength(0); + }); + + it("allows localAdapter asset creation when trusted URL prefixes are explicitly configured", async () => { + const backend = createBackend({ + allowedUrlPrefixes: ["https://cdn.example.com/uploads/"], + }); + + const response = await backend.handler( + new Request("http://localhost/api/media/assets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + createAssetRequestBody("https://cdn.example.com/uploads/photo.jpg"), + ), + }), + ); + + expect(response.status).toBe(200); + + const assets = await backend.api.media.listAssets(); + expect(assets.items).toHaveLength(1); + expect(assets.items[0]?.url).toBe( + "https://cdn.example.com/uploads/photo.jpg", + ); + }); +}); + +describe("mediaBackendPlugin S3 URL validation", () => { + it("rejects spoofed domains for explicit allowedUrlPrefixes", async () => { + const backend = createS3Backend({ + allowedUrlPrefixes: ["https://assets.example.com"], + }); + + const response = await backend.handler( + new Request("http://localhost/api/media/assets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + createAssetRequestBody( + "https://assets.example.com.evil.com/payload.jpg", + ), + ), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain( + "URL must start with one of: https://assets.example.com", + ); + }); + + it("rejects spoofed domains for the S3 adapter public URL prefix", async () => { + const backend = createS3Backend({ + storageAdapter: createS3StorageAdapter("https://assets.example.com"), + }); + + const response = await backend.handler( + new Request("http://localhost/api/media/assets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + createAssetRequestBody( + "https://assets.example.com.evil.com/payload.jpg", + ), + ), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain( + "URL must start with the configured S3 publicBaseUrl: https://assets.example.com", + ); + }); + + it("accepts valid asset URLs on the configured prefix boundary", async () => { + const backend = createS3Backend({ + allowedUrlPrefixes: ["https://assets.example.com/"], + }); + + const response = await backend.handler( + new Request("http://localhost/api/media/assets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + createAssetRequestBody("https://assets.example.com/folder/photo.jpg"), + ), + }), + ); + + expect(response.ok).toBe(true); + }); +}); + +describe("mediaBackendPlugin direct upload", () => { + it("uploads a file, creates an asset record, and associates it with a folder", async () => { + const storageAdapter = createLocalStorageAdapter({ + upload: vi.fn(async () => ({ url: "/uploads/photo-123.jpg" })), + }); + const backend = createBackend({ storageAdapter }); + const folder = await createFolderViaApi(backend, { name: "Photos" }); + + const response = await invokeEndpoint( + backend, + "media_uploadDirect", + createUploadRequest({ fileName: "photo.jpg", folderId: folder.id }), + ); + + expect(response.ok).toBe(true); + expect(storageAdapter.upload).toHaveBeenCalledWith( + expect.any(Buffer), + expect.objectContaining({ + filename: "photo.jpg", + mimeType: "image/jpeg", + folderId: folder.id, + }), + ); + + const asset = (await response.json()) as { + filename: string; + originalName: string; + url: string; + folderId?: string; + }; + expect(asset.filename).toBe("photo-123.jpg"); + expect(asset.originalName).toBe("photo.jpg"); + expect(asset.url).toBe("/uploads/photo-123.jpg"); + expect(asset.folderId).toBe(folder.id); + + const assets = await backend.api.media.listAssets({ folderId: folder.id }); + expect(assets.items).toHaveLength(1); + expect(assets.items[0]?.url).toBe("/uploads/photo-123.jpg"); + }); + + it("cleans up the uploaded file if creating the DB record fails", async () => { + const storageAdapter = createLocalStorageAdapter({ + upload: vi.fn(async () => ({ url: "/uploads/will-be-rolled-back.jpg" })), + }); + const failingAdapterFactory: AdapterFactory = (db) => { + const adapter = testAdapter(db); + return { + ...adapter, + create: async (args) => { + if (args.model === "mediaAsset") { + throw new Error("DB write failed"); + } + return adapter.create(args); + }, + } as Adapter; + }; + const backend = createBackend({ + storageAdapter, + adapterFactory: failingAdapterFactory, + }); + + await expect( + invokeEndpoint(backend, "media_uploadDirect", createUploadRequest()), + ).rejects.toThrow("DB write failed"); + expect(storageAdapter.delete).toHaveBeenCalledWith( + "/uploads/will-be-rolled-back.jpg", + ); + + const assets = await backend.api.media.listAssets(); + expect(assets.items).toHaveLength(0); + }); +}); + +describe("mediaBackendPlugin asset deletion", () => { + it("returns 500 and keeps the DB record when storage deletion fails", async () => { + const storageAdapter = createLocalStorageAdapter({ + delete: vi.fn(async () => { + throw new Error("storage unavailable"); + }), + }); + const backend = createBackend({ + storageAdapter, + allowedUrlPrefixes: ["https://cdn.example.com/uploads/"], + }); + const asset = await createAssetViaApi(backend, { + ...createAssetRequestBody("https://cdn.example.com/uploads/photo.jpg"), + }); + + const response = await backend.handler( + createJsonRequest(`/api/media/assets/${asset.id}`, "DELETE"), + ); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toContain( + "Failed to delete file from storage", + ); + + const assets = await backend.api.media.listAssets(); + expect(assets.items).toHaveLength(1); + expect(assets.items[0]?.id).toBe(asset.id); + }); +}); + +describe("mediaBackendPlugin asset update route", () => { + it("updates an asset and calls onBeforeUpdateAsset", async () => { + const onBeforeUpdateAsset = vi.fn(); + const backend = createBackend({ + allowedUrlPrefixes: ["https://cdn.example.com/uploads/"], + hooks: { onBeforeUpdateAsset }, + }); + const asset = await createAssetViaApi(backend, { + ...createAssetRequestBody("https://cdn.example.com/uploads/photo.jpg"), + alt: "Before", + }); + const folder = await createFolderViaApi(backend, { name: "Edited" }); + + const response = await backend.handler( + createJsonRequest(`/api/media/assets/${asset.id}`, "PATCH", { + alt: "After", + folderId: folder.id, + }), + ); + + expect(response.ok).toBe(true); + expect(onBeforeUpdateAsset).toHaveBeenCalledWith( + expect.objectContaining({ id: asset.id }), + { alt: "After", folderId: folder.id }, + expect.objectContaining({ + body: { alt: "After", folderId: folder.id }, + params: { id: asset.id }, + }), + ); + + const updated = (await response.json()) as { + alt?: string; + folderId?: string; + }; + expect(updated.alt).toBe("After"); + expect(updated.folderId).toBe(folder.id); + }); + + it("returns 404 when updating a missing asset", async () => { + const backend = createBackend(); + + const response = await backend.handler( + createJsonRequest("/api/media/assets/missing-id", "PATCH", { + alt: "Nope", + }), + ); + + expect(response.status).toBe(404); + await expect(response.text()).resolves.toContain("Asset not found"); + }); +}); + +describe("mediaBackendPlugin S3 upload token route", () => { + it("sanitizes the S3 key segment and validates the folder", async () => { + const storageAdapter = createS3StorageAdapter(); + const backend = createS3Backend({ storageAdapter }); + const folder = await createFolderViaApi(backend, { name: "Uploads" }); + + const response = await backend.handler( + createJsonRequest("/api/media/upload/token", "POST", { + filename: "../photo.png", + mimeType: "image/png", + size: 2048, + folderId: folder.id, + }), + ); + + expect(response.ok).toBe(true); + expect(storageAdapter.generateUploadToken).toHaveBeenCalledWith({ + filename: "_-photo.png", + mimeType: "image/png", + size: 2048, + folderId: folder.id, + }); + + const token = (await response.json()) as { + payload: { key: string; publicUrl: string }; + }; + expect(token.payload.key).toBe(`${folder.id}/_-photo.png`); + expect(token.payload.publicUrl).toContain(`${folder.id}/_-photo.png`); + }); + + it("returns 404 when the requested folder does not exist", async () => { + const storageAdapter = createS3StorageAdapter(); + const backend = createS3Backend({ storageAdapter }); + + const response = await backend.handler( + createJsonRequest("/api/media/upload/token", "POST", { + filename: "photo.png", + mimeType: "image/png", + size: 2048, + folderId: "missing-folder", + }), + ); + + expect(response.status).toBe(404); + await expect(response.text()).resolves.toContain("Folder not found"); + expect(storageAdapter.generateUploadToken).not.toHaveBeenCalled(); + }); +}); + +describe("mediaBackendPlugin Vercel Blob route", () => { + it("passes token constraints through to the adapter callback", async () => { + const storageAdapter = createVercelBlobStorageAdapter(); + const backend = createVercelBlobBackend({ + storageAdapter, + allowedMimeTypes: ["image/png"], + maxFileSizeBytes: 4096, + }); + + const response = await invokeEndpoint( + backend, + "media_uploadVercelBlob", + createJsonRequest("/api/media/upload/vercel-blob", "POST", { + pathname: "folder/photo.png", + clientPayload: JSON.stringify({ + mimeType: "image/png", + size: 512, + }), + }), + ); + + expect(response.ok).toBe(true); + expect(storageAdapter.handleRequest).toHaveBeenCalledTimes(1); + + const body = (await response.json()) as { + tokenOptions: { + addRandomSuffix: boolean; + allowedContentTypes?: string[]; + maximumSizeInBytes?: number; + }; + }; + expect(body.tokenOptions).toEqual({ + addRandomSuffix: true, + allowedContentTypes: ["image/png"], + maximumSizeInBytes: 4096, + }); + }); + + it("falls back safely when clientPayload is invalid JSON", async () => { + const onBeforeUpload = vi.fn(); + const storageAdapter = createVercelBlobStorageAdapter(); + const backend = createVercelBlobBackend({ + storageAdapter, + hooks: { onBeforeUpload }, + maxFileSizeBytes: 1024, + }); + + const response = await invokeEndpoint( + backend, + "media_uploadVercelBlob", + createJsonRequest("/api/media/upload/vercel-blob", "POST", { + pathname: "folder/photo.png", + clientPayload: "{not json", + }), + ); + + expect(response.ok).toBe(true); + expect(onBeforeUpload).toHaveBeenCalledWith( + { + filename: "photo.png", + mimeType: "application/octet-stream", + size: undefined, + }, + expect.objectContaining({ + headers: expect.any(Headers), + }), + ); + }); +}); + +describe("mediaBackendPlugin hook denial behavior", () => { + it("maps thrown hook errors to a 403 response", async () => { + const backend = createBackend({ + hooks: { + onBeforeCreateFolder: () => { + throw new Error("No folders for you"); + }, + }, + }); + + const response = await backend.handler( + createJsonRequest("/api/media/folders", "POST", { + name: "Denied", + }), + ); + + expect(response.status).toBe(403); + await expect(response.text()).resolves.toContain("No folders for you"); + }); + + it("still denies access for old-style hooks that return false", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const backend = createBackend({ + hooks: { + onBeforeListAssets: (() => false) as unknown as NonNullable< + NonNullable["onBeforeListAssets"] + >, + }, + }); + + const response = await backend.handler( + createJsonRequest("/api/media/assets", "GET"), + ); + + expect(response.status).toBe(403); + await expect(response.text()).resolves.toContain( + "Unauthorized: Cannot list assets", + ); + expect(warnSpy).toHaveBeenCalled(); + }); +}); + +describe("mediaBackendPlugin folder deletion route", () => { + it("returns 409 when a descendant folder contains assets", async () => { + const backend = createBackend({ + allowedUrlPrefixes: ["https://cdn.example.com/uploads/"], + }); + const parent = await createFolderViaApi(backend, { name: "Parent" }); + const child = await createFolderViaApi(backend, { + name: "Child", + parentId: parent.id, + }); + + await createAssetViaApi(backend, { + ...createAssetRequestBody("https://cdn.example.com/uploads/photo.jpg"), + folderId: child.id, + }); + + const response = await backend.handler( + createJsonRequest(`/api/media/folders/${parent.id}`, "DELETE"), + ); + + expect(response.status).toBe(409); + await expect(response.text()).resolves.toContain("Cannot delete folder"); + + const folders = await backend.api.media.listFolders(); + expect(folders.some((folder) => folder.id === parent.id)).toBe(true); + expect(folders.some((folder) => folder.id === child.id)).toBe(true); + }); +}); + +describe("mediaBackendPlugin adapter-specific endpoint gating", () => { + it("rejects direct uploads when using the S3 adapter", async () => { + const backend = createS3Backend(); + + const response = await backend.handler(createUploadRequest()); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain( + "Direct upload is only supported with the local storage adapter", + ); + }); + + it("rejects upload token requests when using the local adapter", async () => { + const backend = createBackend(); + + const response = await backend.handler( + createJsonRequest("/api/media/upload/token", "POST", { + filename: "photo.jpg", + mimeType: "image/jpeg", + size: 1024, + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain( + "Upload token endpoint is only supported with the S3 storage adapter", + ); + }); + + it("rejects Vercel Blob requests when using the local adapter", async () => { + const backend = createBackend(); + + const response = await backend.handler( + createJsonRequest("/api/media/upload/vercel-blob", "POST", { + pathname: "photo.jpg", + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain( + "Vercel Blob endpoint is only supported with the vercelBlobAdapter", + ); + }); +}); diff --git a/packages/stack/src/plugins/media/__tests__/query-key-defs.test.ts b/packages/stack/src/plugins/media/__tests__/query-key-defs.test.ts new file mode 100644 index 0000000..5d46a33 --- /dev/null +++ b/packages/stack/src/plugins/media/__tests__/query-key-defs.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createMemoryAdapter } from "@btst/adapter-memory"; +import { defineDb } from "@btst/db"; +import type { DBAdapter as Adapter } from "@btst/db"; +import { mediaSchema } from "../db"; +import { listAssets } from "../api/getters"; +import { + MEDIA_QUERY_KEYS, + assetListDiscriminator, +} from "../api/query-key-defs"; + +const createTestAdapter = (): Adapter => { + const db = defineDb({}).use(mediaSchema); + return createMemoryAdapter(db)({}); +}; + +const makeAsset = (index: number) => ({ + filename: `asset-${index}.jpg`, + originalName: `Asset ${index}.jpg`, + mimeType: "image/jpeg", + size: 1024 + index, + url: `https://example.com/${index}.jpg`, + createdAt: new Date(Date.now() + index), +}); + +describe("media asset list query keys", () => { + let adapter: Adapter; + + beforeEach(() => { + adapter = createTestAdapter(); + }); + + it("distinguishes unbounded and explicit first-page pagination", async () => { + for (let i = 0; i < 25; i++) { + await adapter.create({ + model: "mediaAsset", + data: makeAsset(i), + }); + } + + const unbounded = await listAssets(adapter); + const paginated = await listAssets(adapter, { limit: 20, offset: 0 }); + + expect(unbounded.items).toHaveLength(25); + expect(paginated.items).toHaveLength(20); + + expect(assetListDiscriminator()).not.toEqual( + assetListDiscriminator({ limit: 20, offset: 0 }), + ); + expect(MEDIA_QUERY_KEYS.assetsList()).not.toEqual( + MEDIA_QUERY_KEYS.assetsList({ limit: 20, offset: 0 }), + ); + }); +}); diff --git a/packages/stack/src/plugins/media/__tests__/storage-adapters.test.ts b/packages/stack/src/plugins/media/__tests__/storage-adapters.test.ts new file mode 100644 index 0000000..55bf34f --- /dev/null +++ b/packages/stack/src/plugins/media/__tests__/storage-adapters.test.ts @@ -0,0 +1,351 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; + +// Top-level vi.mock calls are hoisted by Vitest before any imports. +// Factories are used so the packages do not need to be installed as devDependencies. + +const mockSend = vi.fn().mockResolvedValue({}); +const mockS3Client = vi.fn(() => ({ send: mockSend })); +const mockPutObjectCommand = vi.fn((input: unknown) => ({ + input, + __type: "PutObjectCommand", +})); +const mockDeleteObjectCommand = vi.fn((input: unknown) => ({ + input, + __type: "DeleteObjectCommand", +})); +const mockGetSignedUrl = vi + .fn() + .mockResolvedValue("https://s3.example.com/signed-url"); + +const mockHandleUpload = vi.fn().mockResolvedValue({ + type: "blob.generate-client-token", + clientToken: "tok123", +}); +const mockDel = vi.fn().mockResolvedValue(undefined); + +vi.mock("@aws-sdk/client-s3", () => ({ + S3Client: mockS3Client, + PutObjectCommand: mockPutObjectCommand, + DeleteObjectCommand: mockDeleteObjectCommand, +})); + +vi.mock("@aws-sdk/s3-request-presigner", () => ({ + getSignedUrl: mockGetSignedUrl, +})); + +vi.mock("@vercel/blob/server", () => ({ + handleUpload: mockHandleUpload, +})); + +vi.mock("@vercel/blob", () => ({ + del: mockDel, +})); + +import { localAdapter } from "../api/adapters/local"; + +// ── Local adapter ───────────────────────────────────────────────────────────── + +describe("localAdapter", () => { + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + vi.clearAllMocks(); + }); + + async function makeTmpDir() { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "btst-media-test-")); + return tmpDir; + } + + it("uploads a file and returns a public URL", async () => { + const uploadDir = await makeTmpDir(); + const adapter = localAdapter({ uploadDir, publicPath: "/uploads" }); + + const buffer = Buffer.from("hello world"); + const { url } = await adapter.upload(buffer, { + filename: "test.txt", + mimeType: "text/plain", + size: buffer.byteLength, + }); + + expect(url).toMatch(/^\/uploads\/test-[a-f0-9]{16}\.txt$/); + + const storedFilename = url.split("/").pop()!; + const storedPath = path.join(uploadDir, storedFilename); + const storedContent = await fs.readFile(storedPath); + expect(storedContent.toString()).toBe("hello world"); + }); + + it("creates the upload directory if it does not exist", async () => { + const uploadDir = path.join(await makeTmpDir(), "nested", "uploads"); + const adapter = localAdapter({ uploadDir, publicPath: "/uploads" }); + + const buffer = Buffer.from("data"); + await adapter.upload(buffer, { + filename: "file.txt", + mimeType: "text/plain", + size: buffer.byteLength, + }); + + const stat = await fs.stat(uploadDir); + expect(stat.isDirectory()).toBe(true); + }); + + it("generates a unique filename for each upload", async () => { + const uploadDir = await makeTmpDir(); + const adapter = localAdapter({ uploadDir, publicPath: "/uploads" }); + + const buf = Buffer.from("data"); + const options = { + filename: "same.jpg", + mimeType: "image/jpeg", + size: buf.byteLength, + }; + const { url: url1 } = await adapter.upload(buf, options); + const { url: url2 } = await adapter.upload(buf, options); + + expect(url1).not.toBe(url2); + }); + + it("deletes a previously uploaded file", async () => { + const uploadDir = await makeTmpDir(); + const adapter = localAdapter({ uploadDir, publicPath: "/uploads" }); + + const buffer = Buffer.from("delete me"); + const { url } = await adapter.upload(buffer, { + filename: "todelete.txt", + mimeType: "text/plain", + size: buffer.byteLength, + }); + + const storedFilename = url.split("/").pop()!; + const storedPath = path.join(uploadDir, storedFilename); + + await adapter.delete(url); + await expect(fs.stat(storedPath)).rejects.toThrow(); + }); + + it("does not throw when deleting a file that does not exist", async () => { + const uploadDir = await makeTmpDir(); + const adapter = localAdapter({ uploadDir, publicPath: "/uploads" }); + + await expect( + adapter.delete("/uploads/nonexistent-file.jpg"), + ).resolves.not.toThrow(); + }); + + it("uses default uploadDir and publicPath", async () => { + const adapter = localAdapter(); + expect(adapter.type).toBe("local"); + }); +}); + +// ── S3 adapter ──────────────────────────────────────────────────────────────── + +describe("s3Adapter", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("generates a presigned PUT URL token", async () => { + const { s3Adapter } = await import("../api/adapters/s3"); + const adapter = s3Adapter({ + bucket: "my-bucket", + region: "us-east-1", + accessKeyId: "ACCESS_KEY", + secretAccessKey: "SECRET_KEY", + publicBaseUrl: "https://assets.example.com", + }); + + const token = await adapter.generateUploadToken({ + filename: "photo.jpg", + mimeType: "image/jpeg", + size: 4096, + }); + + expect(token.type).toBe("presigned-url"); + expect(token.payload).toMatchObject({ + uploadUrl: "https://s3.example.com/signed-url", + publicUrl: "https://assets.example.com/photo.jpg", + key: "photo.jpg", + method: "PUT", + headers: { "Content-Type": "image/jpeg" }, + }); + expect(mockPutObjectCommand).toHaveBeenCalledWith( + expect.objectContaining({ + Bucket: "my-bucket", + Key: "photo.jpg", + ContentType: "image/jpeg", + }), + ); + expect(mockGetSignedUrl).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + { expiresIn: 300 }, + ); + }); + + it("includes folderId in the S3 key", async () => { + const { s3Adapter } = await import("../api/adapters/s3"); + const adapter = s3Adapter({ + bucket: "my-bucket", + region: "us-east-1", + accessKeyId: "ACCESS_KEY", + secretAccessKey: "SECRET_KEY", + publicBaseUrl: "https://assets.example.com", + }); + + const token = await adapter.generateUploadToken({ + filename: "image.png", + mimeType: "image/png", + size: 1000, + folderId: "folder-abc", + }); + + expect((token.payload as Record).key).toBe( + "folder-abc/image.png", + ); + expect((token.payload as Record).publicUrl).toBe( + "https://assets.example.com/folder-abc/image.png", + ); + }); + + it("calls DeleteObjectCommand with the correct key when deleting by public URL", async () => { + const { s3Adapter } = await import("../api/adapters/s3"); + const adapter = s3Adapter({ + bucket: "my-bucket", + region: "us-east-1", + accessKeyId: "ACCESS_KEY", + secretAccessKey: "SECRET_KEY", + publicBaseUrl: "https://assets.example.com", + }); + + await adapter.delete("https://assets.example.com/photos/cat.jpg"); + + expect(mockDeleteObjectCommand).toHaveBeenCalledWith({ + Bucket: "my-bucket", + Key: "photos/cat.jpg", + }); + expect(mockSend).toHaveBeenCalled(); + }); + + it("respects the custom expiresIn option", async () => { + const { s3Adapter } = await import("../api/adapters/s3"); + const adapter = s3Adapter({ + bucket: "my-bucket", + region: "us-east-1", + accessKeyId: "ACCESS_KEY", + secretAccessKey: "SECRET_KEY", + publicBaseUrl: "https://assets.example.com", + expiresIn: 600, + }); + + await adapter.generateUploadToken({ + filename: "file.jpg", + mimeType: "image/jpeg", + size: 100, + }); + + expect(mockGetSignedUrl).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + { expiresIn: 600 }, + ); + }); +}); + +// ── Vercel Blob adapter ─────────────────────────────────────────────────────── + +describe("vercelBlobAdapter", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("calls handleUpload with the request and body", async () => { + const { vercelBlobAdapter } = await import("../api/adapters/vercel-blob"); + const adapter = vercelBlobAdapter(); + + const body = { + type: "blob.generate-client-token", + payload: { pathname: "photo.jpg" }, + }; + const request = new Request("https://example.com/api/upload", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); + + const result = await adapter.handleRequest(request, {}); + + expect(mockHandleUpload).toHaveBeenCalledWith( + expect.objectContaining({ + body, + request, + }), + ); + expect(result).toEqual({ + type: "blob.generate-client-token", + clientToken: "tok123", + }); + }); + + it("passes the onBeforeGenerateToken callback to handleUpload", async () => { + const { vercelBlobAdapter } = await import("../api/adapters/vercel-blob"); + const adapter = vercelBlobAdapter(); + + const onBeforeGenerateToken = vi.fn().mockResolvedValue(undefined); + const body = { + type: "blob.generate-client-token", + payload: { pathname: "test.jpg" }, + }; + const request = new Request("https://example.com/api/upload", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); + + await adapter.handleRequest(request, { onBeforeGenerateToken }); + + // Verify that handleUpload received an onBeforeGenerateToken callback + const callArgs = mockHandleUpload.mock.calls[0]![0] as Record< + string, + unknown + >; + expect(callArgs.onBeforeGenerateToken).toBeTypeOf("function"); + + // Invoke the callback directly to verify it proxies to our hook + const cb = callArgs.onBeforeGenerateToken as Function; + await cb("test.jpg", null); + expect(onBeforeGenerateToken).toHaveBeenCalledWith("test.jpg", null); + }); + + it("calls del when deleting a blob by URL", async () => { + const { vercelBlobAdapter } = await import("../api/adapters/vercel-blob"); + const adapter = vercelBlobAdapter(); + + await adapter.delete("https://public.blob.vercel-storage.com/photo.jpg"); + + expect(mockDel).toHaveBeenCalledWith( + "https://public.blob.vercel-storage.com/photo.jpg", + undefined, + ); + }); + + it("passes the token option to del when provided", async () => { + const { vercelBlobAdapter } = await import("../api/adapters/vercel-blob"); + const adapter = vercelBlobAdapter({ token: "my-custom-token" }); + + await adapter.delete("https://public.blob.vercel-storage.com/file.jpg"); + + expect(mockDel).toHaveBeenCalledWith( + "https://public.blob.vercel-storage.com/file.jpg", + { token: "my-custom-token" }, + ); + }); +}); diff --git a/packages/stack/src/plugins/media/api/adapters/local.ts b/packages/stack/src/plugins/media/api/adapters/local.ts new file mode 100644 index 0000000..2371415 --- /dev/null +++ b/packages/stack/src/plugins/media/api/adapters/local.ts @@ -0,0 +1,72 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as crypto from "node:crypto"; +import type { DirectStorageAdapter, UploadOptions } from "../storage-adapter"; + +export interface LocalStorageAdapterOptions { + /** + * Absolute path to the directory where uploaded files are stored. + * @default "./public/uploads" + */ + uploadDir?: string; + /** + * URL prefix used to build the public URL for uploaded files. + * @default "/uploads" + */ + publicPath?: string; +} + +/** + * Create a local filesystem storage adapter. + * Files are written to `uploadDir` and served at `publicPath`. + * Suitable for development and self-hosted deployments. + * + * @example + * ```ts + * mediaBackendPlugin({ + * storageAdapter: localAdapter({ uploadDir: "./public/uploads", publicPath: "/uploads" }) + * }) + * ``` + */ +export function localAdapter( + options: LocalStorageAdapterOptions = {}, +): DirectStorageAdapter { + const uploadDir = options.uploadDir ?? "./public/uploads"; + const publicPath = options.publicPath ?? "/uploads"; + + return { + type: "local" as const, + + async upload( + buffer: Buffer, + { filename }: UploadOptions, + ): Promise<{ url: string }> { + await fs.mkdir(uploadDir, { recursive: true }); + + const ext = path.extname(filename); + const base = path.basename(filename, ext); + const unique = crypto.randomBytes(8).toString("hex"); + const storedFilename = `${base}-${unique}${ext}`; + const filePath = path.join(uploadDir, storedFilename); + + await fs.writeFile(filePath, buffer); + + const url = `${publicPath.replace(/\/$/, "")}/${storedFilename}`; + return { url }; + }, + + async delete(url: string): Promise { + const filename = url.split("/").pop(); + if (!filename) return; + + const filePath = path.join(uploadDir, filename); + try { + await fs.unlink(filePath); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + } + }, + }; +} diff --git a/packages/stack/src/plugins/media/api/adapters/s3.ts b/packages/stack/src/plugins/media/api/adapters/s3.ts new file mode 100644 index 0000000..4b3b225 --- /dev/null +++ b/packages/stack/src/plugins/media/api/adapters/s3.ts @@ -0,0 +1,190 @@ +import type { + S3StorageAdapter, + S3UploadToken, + UploadOptions, +} from "../storage-adapter"; + +export interface S3StorageAdapterOptions { + /** + * The S3 bucket name. + */ + bucket: string; + /** + * AWS region (e.g. `"us-east-1"`). + */ + region: string; + /** + * AWS access key ID. + */ + accessKeyId: string; + /** + * AWS secret access key. + */ + secretAccessKey: string; + /** + * Custom endpoint URL for S3-compatible providers (Cloudflare R2, MinIO, etc.). + * @example "https://.r2.cloudflarestorage.com" + */ + endpoint?: string; + /** + * Base URL used to construct the final public asset URL after upload. + * @example "https://assets.example.com" or "https://pub-.r2.dev" + */ + publicBaseUrl: string; + /** + * Duration in seconds for which the presigned URL is valid. + * @default 300 (5 minutes) + */ + expiresIn?: number; +} + +/** + * Create an S3-compatible presigned-URL storage adapter. + * The server generates a short-lived presigned PUT URL; the browser uploads + * the file directly to S3 (or R2 / MinIO). The server never receives file bytes. + * + * @remarks Requires `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner` + * as optional peer dependencies. + * + * @example + * ```ts + * mediaBackendPlugin({ + * storageAdapter: s3Adapter({ + * bucket: "my-bucket", + * region: "us-east-1", + * accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + * publicBaseUrl: "https://assets.example.com", + * }) + * }) + * ``` + */ +export function s3Adapter(options: S3StorageAdapterOptions): S3StorageAdapter { + const { + bucket, + region, + accessKeyId, + secretAccessKey, + endpoint, + publicBaseUrl, + expiresIn = 300, + } = options; + + let s3ModulePromise: Promise | null = + null; + let clientPromise: Promise< + InstanceType + > | null = null; + + function getS3Module() { + if (!s3ModulePromise) { + s3ModulePromise = import("@aws-sdk/client-s3").catch(() => { + s3ModulePromise = null; + throw new Error( + "[@btst/stack] S3 adapter requires '@aws-sdk/client-s3'. " + + "Run: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner", + ); + }); + } + return s3ModulePromise; + } + + function getClient() { + if (!clientPromise) { + clientPromise = getS3Module().then(({ S3Client }) => { + return new S3Client({ + region, + endpoint, + credentials: { accessKeyId, secretAccessKey }, + forcePathStyle: !!endpoint, + }); + }); + } + return clientPromise.catch((error) => { + if (clientPromise) { + clientPromise = null; + } + if (s3ModulePromise && error instanceof Error) { + throw error; + } + throw new Error( + "[@btst/stack] S3 adapter requires '@aws-sdk/client-s3'. " + + "Run: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner", + ); + }); + } + + async function buildSignedUrl( + client: unknown, + command: unknown, + opts: { expiresIn: number }, + ): Promise { + let getSignedUrl: typeof import("@aws-sdk/s3-request-presigner")["getSignedUrl"]; + try { + ({ getSignedUrl } = await import("@aws-sdk/s3-request-presigner")); + } catch { + throw new Error( + "[@btst/stack] S3 adapter requires '@aws-sdk/s3-request-presigner'. " + + "Run: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner", + ); + } + return getSignedUrl( + client as Parameters[0], + command as Parameters[1], + opts, + ); + } + + return { + type: "s3" as const, + urlPrefix: publicBaseUrl.replace(/\/$/, ""), + + async generateUploadToken( + uploadOptions: UploadOptions, + ): Promise { + const [client, { PutObjectCommand }] = await Promise.all([ + getClient(), + getS3Module(), + ]); + + const key = uploadOptions.folderId + ? `${uploadOptions.folderId}/${uploadOptions.filename}` + : uploadOptions.filename; + + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: uploadOptions.mimeType, + ContentLength: uploadOptions.size, + }); + + const uploadUrl = await buildSignedUrl(client, command, { expiresIn }); + const publicUrl = `${publicBaseUrl.replace(/\/$/, "")}/${key}`; + + return { + type: "presigned-url", + payload: { + uploadUrl, + publicUrl, + key, + method: "PUT" as const, + headers: { "Content-Type": uploadOptions.mimeType }, + }, + }; + }, + + async delete(url: string): Promise { + const [client, { DeleteObjectCommand }] = await Promise.all([ + getClient(), + getS3Module(), + ]); + + const base = publicBaseUrl.replace(/\/$/, ""); + const key = url.startsWith(base) + ? url.slice(base.length + 1) + : (url.split("/").pop() ?? url); + + await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); + }, + }; +} diff --git a/packages/stack/src/plugins/media/api/adapters/vercel-blob.ts b/packages/stack/src/plugins/media/api/adapters/vercel-blob.ts new file mode 100644 index 0000000..488ca62 --- /dev/null +++ b/packages/stack/src/plugins/media/api/adapters/vercel-blob.ts @@ -0,0 +1,132 @@ +import type { + VercelBlobStorageAdapter, + VercelBlobHandlerCallbacks, +} from "../storage-adapter"; + +export interface VercelBlobStorageAdapterOptions { + /** + * The `BLOB_READ_WRITE_TOKEN` environment variable is read automatically + * by `@vercel/blob`. You only need to provide this option if you store + * the token under a different name. + */ + token?: string; +} + +/** + * Minimal subset of the `@vercel/blob/server` `handleUpload` options. + * Defined inline so we do not hard-depend on a specific `@vercel/blob` release. + */ +interface HandleUploadOptions { + body: unknown; + request: Request; + token?: string; + onBeforeGenerateToken: ( + pathname: string, + clientPayload?: string | null, + ) => Promise<{ + addRandomSuffix?: boolean; + allowedContentTypes?: string[]; + maximumSizeInBytes?: number; + }>; + onUploadCompleted: (args: { + blob: { url: string; pathname: string }; + tokenPayload?: string | null; + }) => Promise; +} + +type HandleUploadFn = (options: HandleUploadOptions) => Promise; +type DelFn = (url: string, options?: { token?: string }) => Promise; + +/** + * Create a Vercel Blob storage adapter using the signed direct-upload protocol. + * The server never receives file bytes — it only issues short-lived client tokens + * via `@vercel/blob`'s `handleUpload` helper (available via `@vercel/blob/server` + * in compatible versions). + * + * @remarks Requires `@vercel/blob` as an optional peer dependency (version + * with `handleUpload` exported from `@vercel/blob/server`). + * + * Upload flow: + * 1. Client calls `POST /media/upload/vercel-blob` to obtain a client token. + * 2. Client uses `@vercel/blob/client`'s `upload()` to upload directly to Vercel. + * 3. After upload, client calls `POST /media/assets` to save metadata to the DB. + * + * @example + * ```ts + * mediaBackendPlugin({ + * storageAdapter: vercelBlobAdapter(), + * hooks: { + * onBeforeUpload: async (_meta, ctx) => { + * const session = await getSession(ctx.headers); + * if (!session) throw new Error("Unauthorized"); + * }, + * }, + * }) + * ``` + */ +export function vercelBlobAdapter( + options: VercelBlobStorageAdapterOptions = {}, +): VercelBlobStorageAdapter { + return { + type: "vercel-blob" as const, + urlHostnameSuffix: ".public.blob.vercel-storage.com", + + async handleRequest( + request: Request, + callbacks: VercelBlobHandlerCallbacks, + ): Promise { + let handleUpload: HandleUploadFn; + try { + const vercelBlobServer = + /* @vite-ignore */ + // @ts-expect-error — @vercel/blob/server may not be exported in all installed versions + (await import("@vercel/blob/server")) as { + handleUpload: HandleUploadFn; + }; + ({ handleUpload } = vercelBlobServer); + } catch { + throw new Error( + "[@btst/stack] Vercel Blob adapter requires '@vercel/blob' with " + + "'handleUpload' exported from '@vercel/blob/server'. " + + "Run: npm install @vercel/blob", + ); + } + + const body = await request.json(); + + return handleUpload({ + body, + request, + token: options.token, + onBeforeGenerateToken: async (pathname, clientPayload) => { + const tokenOptions = + (await callbacks.onBeforeGenerateToken?.( + pathname, + clientPayload ?? null, + )) ?? {}; + return { + addRandomSuffix: true, + ...tokenOptions, + }; + }, + onUploadCompleted: async () => { + // DB record is created by the client calling POST /media/assets + // after the upload completes. Nothing to do server-side here. + }, + }); + }, + + async delete(url: string): Promise { + let del: DelFn; + try { + ({ del } = (await import("@vercel/blob")) as { del: DelFn }); + } catch { + throw new Error( + "[@btst/stack] Vercel Blob adapter requires '@vercel/blob'. " + + "Run: npm install @vercel/blob", + ); + } + await del(url, options.token ? { token: options.token } : undefined); + }, + }; +} diff --git a/packages/stack/src/plugins/media/api/getters.ts b/packages/stack/src/plugins/media/api/getters.ts new file mode 100644 index 0000000..430d0f7 --- /dev/null +++ b/packages/stack/src/plugins/media/api/getters.ts @@ -0,0 +1,174 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import type { Asset, Folder } from "../types"; + +/** + * Parameters for filtering and paginating the asset list. + */ +export interface AssetListParams { + folderId?: string; + mimeType?: string; + query?: string; + offset?: number; + limit?: number; +} + +/** + * Paginated result returned by {@link listAssets}. + */ +export interface AssetListResult { + items: Asset[]; + total: number; + limit?: number; + offset?: number; +} + +/** + * Parameters for filtering the folder list. + */ +export interface FolderListParams { + parentId?: string | null; +} + +/** + * Retrieve all assets matching optional filter criteria. + * Pure DB function — no hooks, no HTTP context. Safe for server-side use. + * + * @remarks **Security:** Authorization hooks are NOT called. The caller is + * responsible for any access-control checks before invoking this function. + */ +export async function listAssets( + adapter: Adapter, + params?: AssetListParams, +): Promise { + const query = params ?? {}; + + const whereConditions: Array<{ + field: string; + value: string | number | boolean | string[] | number[] | Date | null; + operator: "eq" | "in"; + }> = []; + + if (query.folderId !== undefined) { + whereConditions.push({ + field: "folderId", + value: query.folderId, + operator: "eq" as const, + }); + } + + if (query.mimeType) { + whereConditions.push({ + field: "mimeType", + value: query.mimeType, + operator: "eq" as const, + }); + } + + const needsInMemoryFilter = !!query.query; + const dbWhere = whereConditions.length > 0 ? whereConditions : undefined; + + const dbTotal: number | undefined = !needsInMemoryFilter + ? await adapter.count({ model: "mediaAsset", where: dbWhere }) + : undefined; + + let assets = await adapter.findMany({ + model: "mediaAsset", + limit: !needsInMemoryFilter ? query.limit : undefined, + offset: !needsInMemoryFilter ? query.offset : undefined, + where: dbWhere, + sortBy: { field: "createdAt", direction: "desc" }, + }); + + if (query.query) { + const searchLower = query.query.toLowerCase(); + assets = assets.filter( + (asset) => + asset.filename.toLowerCase().includes(searchLower) || + asset.originalName.toLowerCase().includes(searchLower) || + asset.alt?.toLowerCase().includes(searchLower), + ); + } + + if (needsInMemoryFilter) { + const total = assets.length; + const offset = query.offset ?? 0; + const limit = query.limit; + assets = assets.slice( + offset, + limit !== undefined ? offset + limit : undefined, + ); + return { items: assets, total, limit: query.limit, offset: query.offset }; + } + + return { + items: assets, + total: dbTotal ?? assets.length, + limit: query.limit, + offset: query.offset, + }; +} + +/** + * Retrieve a single asset by its ID. + * Returns `null` if no asset is found. + * Pure DB function — no hooks, no HTTP context. + * + * @remarks **Security:** Authorization hooks are NOT called. + */ +export async function getAssetById( + adapter: Adapter, + id: string, +): Promise { + return adapter.findOne({ + model: "mediaAsset", + where: [{ field: "id", value: id, operator: "eq" as const }], + }); +} + +/** + * Retrieve all folders, optionally filtered by `parentId`. + * Pass `null` to list root-level folders (those without a parent). + * Pure DB function — no hooks, no HTTP context. + * + * @remarks **Security:** Authorization hooks are NOT called. + */ +export async function listFolders( + adapter: Adapter, + params?: FolderListParams, +): Promise { + // 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; + + return adapter.findMany({ + model: "mediaFolder", + where, + sortBy: { field: "name", direction: "asc" }, + }); +} + +/** + * Retrieve a single folder by its ID. + * Returns `null` if no folder is found. + * Pure DB function — no hooks, no HTTP context. + * + * @remarks **Security:** Authorization hooks are NOT called. + */ +export async function getFolderById( + adapter: Adapter, + id: string, +): Promise { + return adapter.findOne({ + model: "mediaFolder", + where: [{ field: "id", value: id, operator: "eq" as const }], + }); +} diff --git a/packages/stack/src/plugins/media/api/index.ts b/packages/stack/src/plugins/media/api/index.ts new file mode 100644 index 0000000..603edc1 --- /dev/null +++ b/packages/stack/src/plugins/media/api/index.ts @@ -0,0 +1,51 @@ +export * from "./plugin"; + +export { + listAssets, + getAssetById, + listFolders, + getFolderById, + type AssetListParams, + type AssetListResult, + type FolderListParams, +} from "./getters"; + +export { + createAsset, + updateAsset, + deleteAsset, + createFolder, + deleteFolder, + type CreateAssetInput, + type UpdateAssetInput, + type CreateFolderInput, +} from "./mutations"; + +export { serializeAsset, serializeFolder } from "./serializers"; + +export { MEDIA_QUERY_KEYS, assetListDiscriminator } from "./query-key-defs"; + +export { + localAdapter, + type LocalStorageAdapterOptions, +} from "./adapters/local"; + +export { + s3Adapter, + type S3StorageAdapterOptions, +} from "./adapters/s3"; + +export { + vercelBlobAdapter, + type VercelBlobStorageAdapterOptions, +} from "./adapters/vercel-blob"; + +export type { + StorageAdapter, + DirectStorageAdapter, + S3StorageAdapter, + S3UploadToken, + VercelBlobStorageAdapter, + VercelBlobHandlerCallbacks, + UploadOptions, +} from "./storage-adapter"; diff --git a/packages/stack/src/plugins/media/api/mutations.ts b/packages/stack/src/plugins/media/api/mutations.ts new file mode 100644 index 0000000..c8a1a2a --- /dev/null +++ b/packages/stack/src/plugins/media/api/mutations.ts @@ -0,0 +1,179 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import type { Asset, Folder } from "../types"; + +/** + * Input for creating a new asset record. + */ +export interface CreateAssetInput { + filename: string; + originalName: string; + mimeType: string; + size: number; + url: string; + folderId?: string; + alt?: string; +} + +/** + * Input for updating an existing asset record. + */ +export interface UpdateAssetInput { + alt?: string; + folderId?: string | null; +} + +/** + * Input for creating a new folder. + */ +export interface CreateFolderInput { + name: string; + parentId?: string; +} + +/** + * Create an asset record in the database. + * Pure DB function — no authorization hooks, no HTTP context. + * + * @remarks **Security:** No authorization hooks (e.g. `onBeforeUpload`) are called. + * The caller is responsible for any access-control checks before invoking this function. + */ +export async function createAsset( + adapter: Adapter, + input: CreateAssetInput, +): Promise { + return adapter.create({ + model: "mediaAsset", + data: { + filename: input.filename, + originalName: input.originalName, + mimeType: input.mimeType, + size: input.size, + url: input.url, + folderId: input.folderId, + alt: input.alt, + createdAt: new Date(), + }, + }); +} + +/** + * Update an asset's `alt` text or `folderId`. + * Pure DB function — no authorization hooks, no HTTP context. + * + * @remarks **Security:** No authorization hooks are called. + */ +export async function updateAsset( + adapter: Adapter, + id: string, + input: UpdateAssetInput, +): Promise { + const update: Record = {}; + + if (input.alt !== undefined) { + update.alt = input.alt; + } + + if ("folderId" in input) { + // null explicitly clears the folder association; undefined means "not provided" + update.folderId = input.folderId; + } + + return adapter.update({ + model: "mediaAsset", + where: [{ field: "id", value: id, operator: "eq" as const }], + update, + }); +} + +/** + * Delete an asset record from the database by its ID. + * Does NOT delete the underlying file — the caller must do that via the storage adapter. + * Pure DB function — no authorization hooks, no HTTP context. + * + * @remarks **Security:** No authorization hooks are called. + */ +export async function deleteAsset(adapter: Adapter, id: string): Promise { + await adapter.delete({ + model: "mediaAsset", + where: [{ field: "id", value: id, operator: "eq" as const }], + }); +} + +/** + * Create a folder record in the database. + * Pure DB function — no authorization hooks, no HTTP context. + * + * @remarks **Security:** No authorization hooks are called. + */ +export async function createFolder( + adapter: Adapter, + input: CreateFolderInput, +): Promise { + return adapter.create({ + model: "mediaFolder", + data: { + name: input.name, + parentId: input.parentId, + createdAt: new Date(), + }, + }); +} + +/** + * Delete a folder record from the database by its ID. + * Child folders are cascade-deleted automatically. Throws if the folder or + * any of its descendants contain assets (which have associated storage files + * that must be deleted via the storage adapter first). + * Pure DB function — no authorization hooks, no HTTP context. + * + * @remarks **Security:** No authorization hooks are called. + */ +export async function deleteFolder( + adapter: Adapter, + id: string, +): Promise { + // BFS to collect the target folder and all of its descendants. + const allFolderIds: string[] = [id]; + const queue: string[] = [id]; + + while (queue.length > 0) { + const parentId = queue.shift()!; + const children = await adapter.findMany({ + model: "mediaFolder", + where: [{ field: "parentId", value: parentId, operator: "eq" as const }], + }); + for (const child of children) { + allFolderIds.push(child.id); + queue.push(child.id); + } + } + + // Reject the deletion if any folder in the subtree contains assets. + // Assets map to real storage files — the caller must delete them via the + // storage adapter before removing the DB records. + let totalAssets = 0; + for (const folderId of allFolderIds) { + totalAssets += await adapter.count({ + model: "mediaAsset", + where: [{ field: "folderId", value: folderId, operator: "eq" as const }], + }); + } + + if (totalAssets > 0) { + throw new Error( + `Cannot delete folder: it or one of its subfolders contains ${totalAssets} asset(s). Move or delete them first.`, + ); + } + + // Wrap all deletions in a transaction so the subtree is removed atomically. + // If any individual deletion fails the entire subtree is left intact. + await adapter.transaction(async (tx) => { + // Delete deepest folders first, then work back up to the root. + for (const folderId of [...allFolderIds].reverse()) { + await tx.delete({ + model: "mediaFolder", + where: [{ field: "id", value: folderId, operator: "eq" as const }], + }); + } + }); +} diff --git a/packages/stack/src/plugins/media/api/plugin.ts b/packages/stack/src/plugins/media/api/plugin.ts new file mode 100644 index 0000000..220e3f5 --- /dev/null +++ b/packages/stack/src/plugins/media/api/plugin.ts @@ -0,0 +1,809 @@ +import type { DBAdapter as Adapter } from "@btst/db"; +import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api"; +import { z } from "zod"; +import { mediaSchema as dbSchema } from "../db"; +import type { Asset, Folder } from "../types"; +import { + AssetListQuerySchema, + createAssetSchema, + updateAssetSchema, + createFolderSchema, + uploadTokenRequestSchema, +} from "../schemas"; +import { + listAssets, + getAssetById, + listFolders, + getFolderById, +} from "./getters"; +import { + createAsset, + updateAsset, + deleteAsset, + createFolder, + deleteFolder, +} from "./mutations"; +import { + isDirectAdapter, + isS3Adapter, + isVercelBlobAdapter, + type StorageAdapter, +} from "./storage-adapter"; +import { runHookWithShim } from "../../utils"; + +/** + * Sanitize a string for use in an S3 object key. + * Strips path separators and parent-directory segments to prevent path traversal. + */ +function sanitizeS3KeySegment(s: string): string { + return s.replace(/[/\\]/g, "-").replace(/\.\./g, "_").trim() || "unknown"; +} + +function matchesUrlPrefix(url: string, prefix: string): boolean { + const normalizedPrefix = `${prefix.replace(/\/+$/, "")}/`; + return url.startsWith(normalizedPrefix); +} + +/** + * Context passed to media API hooks. + */ +export interface MediaApiContext< + TBody = unknown, + TParams = unknown, + TQuery = unknown, +> { + body?: TBody; + params?: TParams; + query?: TQuery; + request?: Request; + headers?: Headers; + [key: string]: unknown; +} + +/** + * Configuration hooks for the media backend plugin. + * All hooks are optional and allow consumers to customise behaviour. + */ +export interface MediaBackendHooks { + /** + * Called before a file upload is allowed (both direct and signed adapters). + * Throw an Error to reject the upload (e.g. if the user is not authenticated). + */ + onBeforeUpload?: ( + meta: { filename: string; mimeType: string; size?: number }, + context: MediaApiContext, + ) => Promise | void; + + /** + * Called after an asset record is created in the database. + */ + onAfterUpload?: ( + asset: Asset, + context: MediaApiContext, + ) => Promise | void; + + /** + * Called before an asset is deleted. Throw to prevent deletion. + */ + onBeforeDelete?: ( + asset: Asset, + context: MediaApiContext, + ) => Promise | void; + + /** + * Called after an asset has been deleted from the DB and storage. + */ + onAfterDelete?: ( + assetId: string, + context: MediaApiContext, + ) => Promise | void; + + /** + * Called before listing assets. Throw to deny access. + */ + onBeforeListAssets?: ( + filter: z.infer, + context: MediaApiContext, + ) => Promise | void; + + /** + * Called before updating an asset (PATCH). Throw to deny access. + */ + onBeforeUpdateAsset?: ( + asset: Asset, + updates: z.infer, + context: MediaApiContext, + ) => Promise | void; + + /** + * Called before listing folders. Throw to deny access. + */ + onBeforeListFolders?: ( + filter: { parentId?: string }, + context: MediaApiContext, + ) => Promise | void; + + /** + * Called before creating a folder. Throw to deny access. + */ + onBeforeCreateFolder?: ( + input: z.infer, + context: MediaApiContext, + ) => Promise | void; + + /** + * Called before deleting a folder. Throw to deny access. + */ + onBeforeDeleteFolder?: ( + folder: Folder, + context: MediaApiContext, + ) => Promise | void; +} + +/** + * Configuration for the media backend plugin. + */ +export interface MediaBackendConfig { + /** + * The storage adapter to use for file uploads. + * - `localAdapter()` — writes to the local filesystem (dev / self-hosted) + * - `s3Adapter()` — presigned PUT URL (AWS S3, Cloudflare R2, MinIO) + * - `vercelBlobAdapter()` — signed direct upload via Vercel Blob + */ + storageAdapter: StorageAdapter; + + /** + * Maximum file size in bytes. + * Enforced server-side for `localAdapter`. + * Passed into the Vercel Blob token for edge enforcement. + * Validated against the client-reported size for `s3Adapter`. + * @default 10485760 (10 MB) + */ + maxFileSizeBytes?: number; + + /** + * MIME type allowlist (e.g. `["image/jpeg", "image/png"]`). + * If omitted, all MIME types are accepted. + * Enforced server-side for `localAdapter`. + * Passed to Vercel Blob token for edge enforcement. + * Validated against the client-reported MIME type for `s3Adapter`. + */ + allowedMimeTypes?: string[]; + + /** + * URL prefixes that are allowed when creating asset records via `POST /media/assets`. + * When omitted the plugin automatically derives a safe default from the storage adapter: + * - `s3Adapter` → the configured `publicBaseUrl` + * - `vercelBlobAdapter` → any URL whose hostname ends with `.public.blob.vercel-storage.com` + * - `localAdapter` → rejects client-supplied URLs; use `POST /media/upload` instead + * + * Provide this option only when you need to override the automatic default (e.g. to allow + * assets from a CDN in front of your storage that uses a different domain). When using + * `localAdapter`, setting `allowedUrlPrefixes` explicitly opts `POST /media/assets` back in. + */ + allowedUrlPrefixes?: string[]; + + /** + * Optional lifecycle hooks for the media backend plugin. + */ + hooks?: MediaBackendHooks; +} + +/** + * Media backend plugin. + * Provides API endpoints for managing media assets and folders, and supports + * local, S3-compatible, and Vercel Blob storage backends. + * + * @example + * ```ts + * import { mediaBackendPlugin, localAdapter } from "@btst/stack/plugins/media/api"; + * + * mediaBackendPlugin({ + * storageAdapter: localAdapter(), + * hooks: { + * onBeforeUpload: async (_meta, ctx) => { + * const session = await getSession(ctx.headers as Headers); + * if (!session) throw new Error("Unauthorized"); + * }, + * }, + * }) + * ``` + */ +export const mediaBackendPlugin = (config: MediaBackendConfig) => + defineBackendPlugin({ + name: "media", + + dbPlugin: dbSchema, + + api: (adapter: Adapter) => ({ + listAssets: (params?: Parameters[1]) => + listAssets(adapter, params), + getAssetById: (id: string) => getAssetById(adapter, id), + listFolders: (params?: Parameters[1]) => + listFolders(adapter, params), + getFolderById: (id: string) => getFolderById(adapter, id), + }), + + routes: (adapter: Adapter) => { + const { + storageAdapter, + maxFileSizeBytes = 10 * 1024 * 1024, + allowedMimeTypes, + allowedUrlPrefixes, + hooks, + } = config; + + function validateMimeType(mimeType: string, ctx: { error: Function }) { + if (allowedMimeTypes && allowedMimeTypes.length > 0) { + const allowed = allowedMimeTypes.some((pattern) => { + if (pattern.endsWith("/*")) { + return mimeType.startsWith(pattern.slice(0, -1)); + } + return mimeType === pattern; + }); + if (!allowed) { + throw ctx.error(415, { + message: `MIME type '${mimeType}' is not allowed. Allowed: ${allowedMimeTypes.join(", ")}`, + }); + } + } + } + + // ── Asset endpoints ──────────────────────────────────────────────────── + + const listAssetsEndpoint = createEndpoint( + "/media/assets", + { + method: "GET", + query: AssetListQuerySchema, + }, + async (ctx) => { + const { query, headers } = ctx; + const context: MediaApiContext = { query, headers }; + + if (hooks?.onBeforeListAssets) { + await runHookWithShim( + () => hooks.onBeforeListAssets!(query, context), + ctx.error, + "Unauthorized: Cannot list assets", + ); + } + + return listAssets(adapter, query); + }, + ); + + const createAssetEndpoint = createEndpoint( + "/media/assets", + { + method: "POST", + body: createAssetSchema, + }, + async (ctx) => { + const context: MediaApiContext = { + body: ctx.body, + headers: ctx.headers, + }; + + if (hooks?.onBeforeUpload) { + await runHookWithShim( + () => + hooks.onBeforeUpload!( + { + filename: ctx.body.filename, + mimeType: ctx.body.mimeType, + size: ctx.body.size, + }, + context, + ), + ctx.error, + "Unauthorized: Cannot upload asset", + ); + } + + validateMimeType(ctx.body.mimeType, ctx); + + if (ctx.body.size > maxFileSizeBytes) { + throw ctx.error(413, { + message: `File size ${ctx.body.size} bytes exceeds the limit of ${maxFileSizeBytes} bytes`, + }); + } + + { + const url = ctx.body.url; + let urlAllowed = true; + let denialReason = ""; + + if (allowedUrlPrefixes && allowedUrlPrefixes.length > 0) { + // Consumer-supplied override — validate against explicit list. + urlAllowed = allowedUrlPrefixes.some((p) => + matchesUrlPrefix(url, p), + ); + denialReason = `URL must start with one of: ${allowedUrlPrefixes.join(", ")}`; + } else if (isDirectAdapter(storageAdapter)) { + // localAdapter writes files server-side via POST /media/upload and returns + // relative URLs. Reject client-supplied asset URLs unless the consumer + // explicitly opts into trusted prefixes via allowedUrlPrefixes. + urlAllowed = false; + denialReason = + "Client-supplied asset URLs are not allowed with localAdapter. Use POST /media/upload instead, or configure allowedUrlPrefixes to explicitly allow trusted URL prefixes."; + } else if (isS3Adapter(storageAdapter)) { + // Auto-derived from s3Adapter's publicBaseUrl. + urlAllowed = matchesUrlPrefix(url, storageAdapter.urlPrefix); + denialReason = `URL must start with the configured S3 publicBaseUrl: ${storageAdapter.urlPrefix}`; + } else if (isVercelBlobAdapter(storageAdapter)) { + // Vercel Blob public URLs always belong to a known CDN hostname suffix. + try { + const hostname = new URL(url).hostname; + urlAllowed = hostname.endsWith( + storageAdapter.urlHostnameSuffix, + ); + } catch { + urlAllowed = false; + } + denialReason = `URL hostname must end with ${storageAdapter.urlHostnameSuffix}`; + } + + if (!urlAllowed) { + throw ctx.error(400, { message: denialReason }); + } + } + + if (ctx.body.folderId) { + const folder = await getFolderById(adapter, ctx.body.folderId); + if (!folder) { + throw ctx.error(404, { message: "Folder not found" }); + } + } + + const asset = await createAsset(adapter, ctx.body); + + if (hooks?.onAfterUpload) { + await hooks.onAfterUpload(asset, context); + } + + return asset; + }, + ); + + const updateAssetEndpoint = createEndpoint( + "/media/assets/:id", + { + method: "PATCH", + body: updateAssetSchema, + }, + async (ctx) => { + const existing = await getAssetById(adapter, ctx.params.id); + if (!existing) { + throw ctx.error(404, { message: "Asset not found" }); + } + + const context: MediaApiContext = { + body: ctx.body, + params: ctx.params, + headers: ctx.headers, + }; + + if (hooks?.onBeforeUpdateAsset) { + await runHookWithShim( + () => hooks.onBeforeUpdateAsset!(existing, ctx.body, context), + ctx.error, + "Unauthorized: Cannot update asset", + ); + } + + if (ctx.body.folderId != null) { + const folder = await getFolderById(adapter, ctx.body.folderId); + if (!folder) { + throw ctx.error(404, { message: "Folder not found" }); + } + } + + const updated = await updateAsset(adapter, ctx.params.id, ctx.body); + if (!updated) { + throw ctx.error(404, { message: "Asset not found" }); + } + + return updated; + }, + ); + + const deleteAssetEndpoint = createEndpoint( + "/media/assets/:id", + { + method: "DELETE", + }, + async (ctx) => { + const context: MediaApiContext = { + params: ctx.params, + headers: ctx.headers, + }; + + const asset = await getAssetById(adapter, ctx.params.id); + if (!asset) { + throw ctx.error(404, { message: "Asset not found" }); + } + + if (hooks?.onBeforeDelete) { + await runHookWithShim( + () => hooks.onBeforeDelete!(asset, context), + ctx.error, + "Unauthorized: Cannot delete asset", + ); + } + + // Delete the storage file FIRST — if this fails the DB record is + // still intact and the deletion can be retried. Removing the DB + // record first would silently orphan the file in storage with no + // way to track or clean it up. + try { + await storageAdapter.delete(asset.url); + } catch (err) { + console.error( + `[btst/media] Failed to delete file from storage: ${asset.url}`, + err, + ); + throw ctx.error(500, { + message: "Failed to delete file from storage", + }); + } + + await deleteAsset(adapter, ctx.params.id); + + if (hooks?.onAfterDelete) { + await hooks.onAfterDelete(ctx.params.id, context); + } + + return { success: true }; + }, + ); + + // ── Folder endpoints ──────────────────────────────────────────────────── + + const listFoldersEndpoint = createEndpoint( + "/media/folders", + { + method: "GET", + query: z.object({ + parentId: z.string().optional(), + }), + }, + async (ctx) => { + const filter = { parentId: ctx.query.parentId }; + const context: MediaApiContext = { + query: ctx.query, + headers: ctx.headers, + }; + + if (hooks?.onBeforeListFolders) { + await runHookWithShim( + () => hooks.onBeforeListFolders!(filter, context), + ctx.error, + "Unauthorized: Cannot list folders", + ); + } + + return listFolders(adapter, filter); + }, + ); + + const createFolderEndpoint = createEndpoint( + "/media/folders", + { + method: "POST", + body: createFolderSchema, + }, + async (ctx) => { + const context: MediaApiContext = { + body: ctx.body, + headers: ctx.headers, + }; + + if (hooks?.onBeforeCreateFolder) { + await runHookWithShim( + () => hooks.onBeforeCreateFolder!(ctx.body, context), + ctx.error, + "Unauthorized: Cannot create folder", + ); + } + + return createFolder(adapter, ctx.body); + }, + ); + + const deleteFolderEndpoint = createEndpoint( + "/media/folders/:id", + { + method: "DELETE", + }, + async (ctx) => { + 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 (hooks?.onBeforeDeleteFolder) { + await runHookWithShim( + () => hooks.onBeforeDeleteFolder!(folder, context), + ctx.error, + "Unauthorized: Cannot delete folder", + ); + } + + try { + await deleteFolder(adapter, ctx.params.id); + } catch (err) { + throw ctx.error(409, { + message: + err instanceof Error ? err.message : "Cannot delete folder", + }); + } + + return { success: true }; + }, + ); + + // ── Upload endpoints (adapter-specific) ──────────────────────────────── + + // Direct upload — local adapter only + const uploadDirectEndpoint = createEndpoint( + "/media/upload", + { + method: "POST", + }, + async (ctx) => { + if (!isDirectAdapter(storageAdapter)) { + throw ctx.error(400, { + message: + "Direct upload is only supported with the local storage adapter", + }); + } + + if (!ctx.request) { + throw ctx.error(400, { + message: "Request object is not available", + }); + } + + const formData = await ctx.request.formData(); + const file = formData.get("file"); + + if (!file || !(file instanceof File)) { + throw ctx.error(400, { + message: "Missing 'file' field in form data", + }); + } + + const context: MediaApiContext = { headers: ctx.headers }; + + if (hooks?.onBeforeUpload) { + await runHookWithShim( + () => + hooks.onBeforeUpload!( + { + filename: file.name, + mimeType: file.type, + size: file.size, + }, + context, + ), + ctx.error, + "Unauthorized: Cannot upload asset", + ); + } + + validateMimeType(file.type, ctx); + + if (file.size > maxFileSizeBytes) { + throw ctx.error(413, { + message: `File size ${file.size} bytes exceeds the limit of ${maxFileSizeBytes} bytes`, + }); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const folderId = + (formData.get("folderId") as string | undefined) ?? undefined; + + if (folderId) { + const folder = await getFolderById(adapter, folderId); + if (!folder) { + throw ctx.error(404, { message: "Folder not found" }); + } + } + + const { url } = await storageAdapter.upload(buffer, { + filename: file.name, + mimeType: file.type, + size: file.size, + folderId, + }); + + // Create the DB record. If this fails, clean up the already-uploaded + // storage file so it does not become a silently orphaned file. + let asset: Asset; + try { + asset = await createAsset(adapter, { + filename: url.split("/").pop() ?? file.name, + originalName: file.name, + mimeType: file.type, + size: file.size, + url, + folderId, + }); + } catch (err) { + try { + await storageAdapter.delete(url); + } catch (cleanupErr) { + console.error( + `[btst/media] Failed to clean up orphaned storage file after DB error: ${url}`, + cleanupErr, + ); + } + throw err; + } + + if (hooks?.onAfterUpload) { + await hooks.onAfterUpload(asset, context); + } + + return asset; + }, + ); + + // Token generation — S3 adapter + const uploadTokenEndpoint = createEndpoint( + "/media/upload/token", + { + method: "POST", + body: uploadTokenRequestSchema, + }, + async (ctx) => { + if (!isS3Adapter(storageAdapter)) { + throw ctx.error(400, { + message: + "Upload token endpoint is only supported with the S3 storage adapter", + }); + } + + const context: MediaApiContext = { + body: ctx.body, + headers: ctx.headers, + }; + + if (hooks?.onBeforeUpload) { + await runHookWithShim( + () => + hooks.onBeforeUpload!( + { + filename: ctx.body.filename, + mimeType: ctx.body.mimeType, + size: ctx.body.size, + }, + context, + ), + ctx.error, + "Unauthorized: Cannot upload asset", + ); + } + + validateMimeType(ctx.body.mimeType, ctx); + + if (ctx.body.size > maxFileSizeBytes) { + throw ctx.error(413, { + message: `File size ${ctx.body.size} bytes exceeds the limit of ${maxFileSizeBytes} bytes`, + }); + } + + let folderId: string | undefined = ctx.body.folderId; + if (folderId) { + const folder = await getFolderById(adapter, folderId); + if (!folder) { + throw ctx.error(404, { + message: "Folder not found", + }); + } + folderId = folder.id; + } + const filename = sanitizeS3KeySegment(ctx.body.filename); + + return storageAdapter.generateUploadToken({ + filename, + mimeType: ctx.body.mimeType, + size: ctx.body.size, + folderId, + }); + }, + ); + + // Vercel Blob token exchange — vercel-blob adapter + const uploadVercelBlobEndpoint = createEndpoint( + "/media/upload/vercel-blob", + { + method: "POST", + }, + async (ctx) => { + if (!isVercelBlobAdapter(storageAdapter)) { + throw ctx.error(400, { + message: + "Vercel Blob endpoint is only supported with the vercelBlobAdapter", + }); + } + + const context: MediaApiContext = { headers: ctx.headers }; + + if (!ctx.request) { + throw ctx.error(400, { + message: "Request object is not available", + }); + } + + return storageAdapter.handleRequest(ctx.request, { + onBeforeGenerateToken: async (pathname, clientPayload) => { + const filename = pathname.split("/").pop() ?? pathname; + let parsed: Record = {}; + try { + parsed = clientPayload ? JSON.parse(clientPayload) : {}; + } catch { + /* ignore invalid JSON — fall back to defaults */ + } + const mimeType = + (parsed.mimeType as string | undefined) ?? + "application/octet-stream"; + const size = parsed.size as number | undefined; + + if (hooks?.onBeforeUpload) { + await runHookWithShim( + () => + hooks.onBeforeUpload!( + { filename, mimeType, size }, + context, + ), + ctx.error, + "Unauthorized: Cannot upload asset", + ); + } + + validateMimeType(mimeType, ctx); + + if (size != null && size > maxFileSizeBytes) { + throw ctx.error(413, { + message: `File size ${size} bytes exceeds the limit of ${maxFileSizeBytes} bytes`, + }); + } + + return { + addRandomSuffix: true, + allowedContentTypes: + allowedMimeTypes && allowedMimeTypes.length > 0 + ? allowedMimeTypes + : undefined, + maximumSizeInBytes: maxFileSizeBytes, + }; + }, + }); + }, + ); + + return { + listAssets: listAssetsEndpoint, + createAsset: createAssetEndpoint, + updateAsset: updateAssetEndpoint, + deleteAsset: deleteAssetEndpoint, + listFolders: listFoldersEndpoint, + createFolder: createFolderEndpoint, + deleteFolder: deleteFolderEndpoint, + uploadDirect: uploadDirectEndpoint, + uploadToken: uploadTokenEndpoint, + uploadVercelBlob: uploadVercelBlobEndpoint, + } as const; + }, + }); + +export type MediaApiRouter = ReturnType< + ReturnType["routes"] +>; diff --git a/packages/stack/src/plugins/media/api/query-key-defs.ts b/packages/stack/src/plugins/media/api/query-key-defs.ts new file mode 100644 index 0000000..ba1ae09 --- /dev/null +++ b/packages/stack/src/plugins/media/api/query-key-defs.ts @@ -0,0 +1,41 @@ +/** + * Internal query key constants for the media plugin. + * Shared between query-keys.ts (HTTP path) and any SSR/SSG prefetching + * to prevent key drift between client and server. + */ + +import type { AssetListParams } from "./getters"; + +/** + * Discriminator for the asset list cache key. + */ +export interface AssetListDiscriminator { + folderId: string | undefined; + mimeType: string | undefined; + query: string | undefined; + limit: number | undefined; + offset: number | undefined; +} + +export function assetListDiscriminator( + params?: AssetListParams, +): AssetListDiscriminator { + return { + folderId: params?.folderId, + mimeType: params?.mimeType, + query: params?.query, + limit: params?.limit, + offset: params?.offset, + }; +} + +/** Full query key builders — use these with `queryClient.setQueryData()`. */ +export const MEDIA_QUERY_KEYS = { + assetsList: (params?: AssetListParams) => + ["media", "assets", "list", assetListDiscriminator(params)] as const, + + assetDetail: (id: string) => ["media", "assets", "detail", id] as const, + + foldersList: (parentId?: string | null) => + ["media", "folders", "list", parentId ?? "root"] as const, +}; diff --git a/packages/stack/src/plugins/media/api/serializers.ts b/packages/stack/src/plugins/media/api/serializers.ts new file mode 100644 index 0000000..2a142dc --- /dev/null +++ b/packages/stack/src/plugins/media/api/serializers.ts @@ -0,0 +1,28 @@ +import type { + Asset, + Folder, + SerializedAsset, + SerializedFolder, +} from "../types"; + +/** + * Serialize an Asset for SSR/SSG use (convert dates to strings). + * Pure function — no DB access, no hooks. + */ +export function serializeAsset(asset: Asset): SerializedAsset { + return { + ...asset, + createdAt: asset.createdAt.toISOString(), + }; +} + +/** + * Serialize a Folder for SSR/SSG use (convert dates to strings). + * Pure function — no DB access, no hooks. + */ +export function serializeFolder(folder: Folder): SerializedFolder { + return { + ...folder, + createdAt: folder.createdAt.toISOString(), + }; +} diff --git a/packages/stack/src/plugins/media/api/storage-adapter.ts b/packages/stack/src/plugins/media/api/storage-adapter.ts new file mode 100644 index 0000000..b34895e --- /dev/null +++ b/packages/stack/src/plugins/media/api/storage-adapter.ts @@ -0,0 +1,139 @@ +/** + * Options provided to storage adapters when initiating an upload. + */ +export interface UploadOptions { + filename: string; + mimeType: string; + size: number; + folderId?: string; +} + +/** + * Local storage adapter — backend receives and stores file bytes directly. + * Suitable for development and self-hosted deployments. + */ +export interface DirectStorageAdapter { + readonly type: "local"; + /** + * Store the file buffer and return the public URL. + */ + upload(buffer: Buffer, options: UploadOptions): Promise<{ url: string }>; + /** + * Remove the stored file given its public URL. + */ + delete(url: string): Promise; +} + +/** + * Token returned by the S3 adapter. + * The client performs a `PUT` to `payload.uploadUrl` with the file body, + * then saves `payload.publicUrl` as the asset URL. + */ +export interface S3UploadToken { + type: "presigned-url"; + payload: { + uploadUrl: string; + publicUrl: string; + key: string; + method: "PUT"; + headers: Record; + }; +} + +/** + * S3 storage adapter — server issues a short-lived presigned PUT URL; + * the browser uploads directly to S3 (or R2 / MinIO). + */ +export interface S3StorageAdapter { + readonly type: "s3"; + /** + * The public base URL prefix for all assets in this bucket + * (e.g. `"https://assets.example.com"`). Used by the plugin to + * automatically validate client-supplied URLs when no explicit + * `allowedUrlPrefixes` are configured. + */ + readonly urlPrefix: string; + /** + * Generate a presigned PUT URL for direct client upload. + */ + generateUploadToken(options: UploadOptions): Promise; + /** + * Remove the stored object given its public URL. + */ + delete(url: string): Promise; +} + +/** + * Options returned from onBeforeGenerateToken and passed to Vercel Blob's handleUpload. + */ +export interface VercelBlobTokenOptions { + addRandomSuffix?: boolean; + allowedContentTypes?: string[]; + maximumSizeInBytes?: number; +} + +/** + * Callbacks provided to the Vercel Blob adapter when handling a request. + */ +export interface VercelBlobHandlerCallbacks { + /** + * Called before a client token is generated. + * Throw to reject the upload (auth gate). + * Return options to enforce allowedContentTypes and maximumSizeInBytes at the edge. + */ + onBeforeGenerateToken?: ( + pathname: string, + clientPayload: string | null, + ) => Promise | VercelBlobTokenOptions | void; +} + +/** + * Vercel Blob storage adapter — uses the `@vercel/blob/server` `handleUpload` + * protocol. The same endpoint handles both token generation and upload + * completion notifications from Vercel's servers. + */ +export interface VercelBlobStorageAdapter { + readonly type: "vercel-blob"; + /** + * Hostname suffix that all Vercel Blob public URLs end with. + * Used by the plugin to automatically validate client-supplied URLs. + * Always `".public.blob.vercel-storage.com"`. + */ + readonly urlHostnameSuffix: string; + /** + * Process a raw request from `@vercel/blob/client`'s `upload()` or from + * Vercel Blob's upload-completion webhook. Returns a JSON-serialisable object + * that should be sent back as the response body. + */ + handleRequest( + request: Request, + callbacks: VercelBlobHandlerCallbacks, + ): Promise; + /** + * Remove the stored blob given its public URL. + */ + delete(url: string): Promise; +} + +export type StorageAdapter = + | DirectStorageAdapter + | S3StorageAdapter + | VercelBlobStorageAdapter; + +export function isDirectAdapter( + adapter: StorageAdapter, +): adapter is DirectStorageAdapter { + return adapter.type === "local"; +} + +export function isS3Adapter( + adapter: StorageAdapter, +): adapter is S3StorageAdapter { + return adapter.type === "s3"; +} + +export function isVercelBlobAdapter( + adapter: StorageAdapter, +): adapter is VercelBlobStorageAdapter { + return adapter.type === "vercel-blob"; +} diff --git a/packages/stack/src/plugins/media/db.ts b/packages/stack/src/plugins/media/db.ts new file mode 100644 index 0000000..eb41e29 --- /dev/null +++ b/packages/stack/src/plugins/media/db.ts @@ -0,0 +1,62 @@ +import { createDbPlugin } from "@btst/db"; + +/** + * Media plugin schema + * Defines the database tables for media assets and folders + */ +export const mediaSchema = createDbPlugin("media", { + asset: { + modelName: "mediaAsset", + fields: { + filename: { + type: "string", + required: true, + }, + originalName: { + type: "string", + required: true, + }, + mimeType: { + type: "string", + required: true, + }, + size: { + type: "number", + required: true, + }, + url: { + type: "string", + required: true, + }, + folderId: { + type: "string", + required: false, + }, + alt: { + type: "string", + required: false, + }, + createdAt: { + type: "date", + defaultValue: () => new Date(), + }, + }, + }, + folder: { + modelName: "mediaFolder", + fields: { + name: { + type: "string", + required: true, + }, + parentId: { + type: "string", + required: false, + }, + createdAt: { + type: "date", + defaultValue: () => new Date(), + }, + }, + }, +}); diff --git a/packages/stack/src/plugins/media/schemas.ts b/packages/stack/src/plugins/media/schemas.ts new file mode 100644 index 0000000..052b9b1 --- /dev/null +++ b/packages/stack/src/plugins/media/schemas.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +export const AssetListQuerySchema = z.object({ + folderId: z.string().optional(), + mimeType: z.string().optional(), + query: z.string().optional(), + offset: z.coerce.number().int().min(0).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), +}); + +export const createAssetSchema = z.object({ + filename: z.string().min(1), + originalName: z.string().min(1), + mimeType: z.string().min(1), + size: z.number().int().positive(), + url: z.string().url(), + folderId: z.string().optional(), + alt: z.string().optional(), +}); + +export const updateAssetSchema = z.object({ + alt: z.string().optional(), + folderId: z.string().nullable().optional(), +}); + +export const createFolderSchema = z.object({ + name: z.string().min(1), + parentId: z.string().optional(), +}); + +export const uploadTokenRequestSchema = z.object({ + filename: z.string().min(1), + mimeType: z.string().min(1), + size: z.number().int().positive(), + folderId: z.string().optional(), +}); diff --git a/packages/stack/src/plugins/media/types.ts b/packages/stack/src/plugins/media/types.ts new file mode 100644 index 0000000..710e88c --- /dev/null +++ b/packages/stack/src/plugins/media/types.ts @@ -0,0 +1,26 @@ +export type Asset = { + id: string; + filename: string; + originalName: string; + mimeType: string; + size: number; + url: string; + folderId?: string; + alt?: string; + createdAt: Date; +}; + +export type Folder = { + id: string; + name: string; + parentId?: string; + createdAt: Date; +}; + +export interface SerializedAsset extends Omit { + createdAt: string; +} + +export interface SerializedFolder extends Omit { + createdAt: string; +} diff --git a/packages/stack/vitest.config.mts b/packages/stack/vitest.config.mts index f956eef..6dc23b7 100644 --- a/packages/stack/vitest.config.mts +++ b/packages/stack/vitest.config.mts @@ -17,6 +17,12 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + // Stub for @vercel/blob/server — this subpath doesn't exist in all + // installed versions of @vercel/blob. Tests mock this module via vi.mock. + "@vercel/blob/server": path.resolve( + __dirname, + "./src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts", + ), }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b9ee59..9f1df8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -514,7 +514,7 @@ importers: version: 7.4.4 nitro: specifier: 3.0.1-alpha.0 - version: 3.0.1-alpha.0(@electric-sql/pglite@0.3.15)(chokidar@4.0.3)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)))(lru-cache@11.2.2)(mongodb@6.21.0(socks@2.8.7))(mysql2@3.15.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.0.1-alpha.0(@electric-sql/pglite@0.3.15)(@vercel/blob@2.3.1)(chokidar@4.0.3)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)))(lru-cache@11.2.2)(mongodb@6.21.0(socks@2.8.7))(mysql2@3.15.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -624,6 +624,12 @@ importers: '@ai-sdk/react': specifier: ^2.0.94 version: 2.0.94(react@19.2.0)(zod@4.2.1) + '@aws-sdk/client-s3': + specifier: ^3.1011.0 + version: 3.1011.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1011.0 + version: 3.1011.0 '@btst/adapter-memory': specifier: 2.1.1 version: 2.1.1(a5510a3f22769efb216707712fe268fb) @@ -636,6 +642,9 @@ importers: '@types/slug': specifier: ^5.0.9 version: 5.0.9 + '@vercel/blob': + specifier: ^0.27.3 + version: 0.27.3 '@workspace/ui': specifier: workspace:* version: link:../ui @@ -982,6 +991,173 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.1011.0': + resolution: {integrity: sha512-jY7CGX+vfM/DSi4K8UwaZKoXnhqchmAbKFB1kIuHMfPPqW7l3jC/fUVDb95/njMsB2ymYOTusZEzoCTeUB/4qA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.973.20': + resolution: {integrity: sha512-i3GuX+lowD892F3IuJf8o6AbyDupMTdyTxQrCJGcn71ni5hTZ82L4nQhcdumxZ7XPJRJJVHS/CR3uYOIIs0PVA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/crc64-nvme@3.972.5': + resolution: {integrity: sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.18': + resolution: {integrity: sha512-X0B8AlQY507i5DwjLByeU2Af4ARsl9Vr84koDcXCbAkplmU+1xBFWxEPrWRAoh56waBne/yJqEloSwvRf4x6XA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.20': + resolution: {integrity: sha512-ey9Lelj001+oOfrbKmS6R2CJAiXX7QKY4Vj9VJv6L2eE6/VjD8DocHIoYqztTm70xDLR4E1jYPTKfIui+eRNDA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.20': + resolution: {integrity: sha512-5flXSnKHMloObNF+9N0cupKegnH1Z37cdVlpETVgx8/rAhCe+VNlkcZH3HDg2SDn9bI765S+rhNPXGDJJPfbtA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.20': + resolution: {integrity: sha512-gEWo54nfqp2jABMu6HNsjVC4hDLpg9HC8IKSJnp0kqWtxIJYHTmiLSsIfI4ScQjxEwpB+jOOH8dOLax1+hy/Hw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.21': + resolution: {integrity: sha512-hah8if3/B/Q+LBYN5FukyQ1Mym6PLPDsBOBsIgNEYD6wLyZg0UmUF/OKIVC3nX9XH8TfTPuITK+7N/jenVACWA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.18': + resolution: {integrity: sha512-Tpl7SRaPoOLT32jbTWchPsn52hYYgJ0kpiFgnwk8pxTANQdUymVSZkzFvv1+oOgZm1CrbQUP9MBeoMZ9IzLZjA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.20': + resolution: {integrity: sha512-p+R+PYR5Z7Gjqf/6pvbCnzEHcqPCpLzR7Yf127HjJ6EAb4hUcD+qsNRnuww1sB/RmSeCLxyay8FMyqREw4p1RA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.20': + resolution: {integrity: sha512-rWCmh8o7QY4CsUj63qopzMzkDq/yPpkrpb+CnjBEFSOg/02T/we7sSTVg4QsDiVS9uwZ8VyONhq98qt+pIh3KA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.972.8': + resolution: {integrity: sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-expect-continue@3.972.8': + resolution: {integrity: sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.974.0': + resolution: {integrity: sha512-BmdDjqvnuYaC4SY7ypHLXfCSsGYGUZkjCLSZyUAAYn1YT28vbNMJNDwhlfkvvE+hQHG5RJDlEmYuvBxcB9jX1g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.8': + resolution: {integrity: sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-location-constraint@3.972.8': + resolution: {integrity: sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.8': + resolution: {integrity: sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.8': + resolution: {integrity: sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.20': + resolution: {integrity: sha512-yhva/xL5H4tWQgsBjwV+RRD0ByCzg0TcByDCLp3GXdn/wlyRNfy8zsswDtCvr1WSKQkSQYlyEzPuWkJG0f5HvQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-ssec@3.972.8': + resolution: {integrity: sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.21': + resolution: {integrity: sha512-62XRl1GDYPpkt7cx1AX1SPy9wgNE9Iw/NPuurJu4lmhCWS7sGKO+kS53TQ8eRmIxy3skmvNInnk0ZbWrU5Dpyg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.996.10': + resolution: {integrity: sha512-SlDol5Z+C7Ivnc2rKGqiqfSUmUZzY1qHfVs9myt/nxVwswgfpjdKahyTzLTx802Zfq0NFRs7AejwKzzzl5Co2w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.8': + resolution: {integrity: sha512-1eD4uhTDeambO/PNIDVG19A6+v4NdD7xzwLHDutHsUqz0B+i661MwQB2eYO4/crcCvCiQG4SRm1k81k54FEIvw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1011.0': + resolution: {integrity: sha512-Jbh8hIxgfiskZNC9Sb3aDmFCuYkNyVxlYHXx4zZEzSwEx+duWz/BSb1aJv9FiZIzFgCMK/Vh7HqGnJ9DEYipEw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.8': + resolution: {integrity: sha512-n1qYFD+tbqZuyskVaxUE+t10AUz9g3qzDw3Tp6QZDKmqsjfDmZBd4GIk2EKJJNtcCBtE5YiUjDYA+3djFAFBBg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1009.0': + resolution: {integrity: sha512-KCPLuTqN9u0Rr38Arln78fRG9KXpzsPWmof+PZzfAHMMQq2QED6YjQrkrfiH7PDefLWEposY1o4/eGwrmKA4JA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.6': + resolution: {integrity: sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-arn-parser@3.972.3': + resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.5': + resolution: {integrity: sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-format-url@3.972.8': + resolution: {integrity: sha512-J6DS9oocrgxM8xlUTTmQOuwRF6rnAGEujAN9SAzllcrQmwn5iJ58ogxy3SEhD0Q7JZvlA5jvIXBkpQRqEqlE9A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.8': + resolution: {integrity: sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==} + + '@aws-sdk/util-user-agent-node@3.973.7': + resolution: {integrity: sha512-Hz6EZMUAEzqUd7e+vZ9LE7mn+5gMbxltXy18v+YSFY+9LBJz15wkNZvw5JqfX3z0FS9n3bgUtz3L5rAsfh4YlA==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.11': + resolution: {integrity: sha512-iitV/gZKQMvY9d7ovmyFnFuTHbBAtrmLnvaSb/3X8vOKyevwtpmEtyc8AdhVWZe0pI/1GsHxlEvQeOePFzy7KQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -1826,6 +2002,10 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -3758,6 +3938,222 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@smithy/abort-controller@4.2.12': + resolution: {integrity: sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader-native@4.2.3': + resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader@5.2.2': + resolution: {integrity: sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.11': + resolution: {integrity: sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.12': + resolution: {integrity: sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.12': + resolution: {integrity: sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.12': + resolution: {integrity: sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.12': + resolution: {integrity: sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.12': + resolution: {integrity: sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.12': + resolution: {integrity: sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.12': + resolution: {integrity: sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.15': + resolution: {integrity: sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-blob-browser@4.2.13': + resolution: {integrity: sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.12': + resolution: {integrity: sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-stream-node@4.2.12': + resolution: {integrity: sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.12': + resolution: {integrity: sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@4.2.12': + resolution: {integrity: sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.12': + resolution: {integrity: sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.26': + resolution: {integrity: sha512-8Qfikvd2GVKSm8S6IbjfwFlRY9VlMrj0Dp4vTwAuhqbX7NhJKE5DQc2bnfJIcY0B+2YKMDBWfvexbSZeejDgeg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.43': + resolution: {integrity: sha512-ZwsifBdyuNHrFGmbc7bAfP2b54+kt9J2rhFd18ilQGAB+GDiP4SrawqyExbB7v455QVR7Psyhb2kjULvBPIhvA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.15': + resolution: {integrity: sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.12': + resolution: {integrity: sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.12': + resolution: {integrity: sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.5.0': + resolution: {integrity: sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.12': + resolution: {integrity: sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.12': + resolution: {integrity: sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.12': + resolution: {integrity: sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.12': + resolution: {integrity: sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.12': + resolution: {integrity: sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.7': + resolution: {integrity: sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.12': + resolution: {integrity: sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.12.6': + resolution: {integrity: sha512-aib3f0jiMsJ6+cvDnXipBsGDL7ztknYSVqJs1FdN9P+u9tr/VzOR7iygSh6EUOdaBeMCMSh3N0VdyYsG4o91DQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.13.1': + resolution: {integrity: sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.12': + resolution: {integrity: sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.42': + resolution: {integrity: sha512-0vjwmcvkWAUtikXnWIUOyV6IFHTEeQUYh3JUZcDgcszF+hD/StAsQ3rCZNZEPHgI9kVNcbnyc8P2CBHnwgmcwg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.45': + resolution: {integrity: sha512-q5dOqqfTgUcLe38TAGiFn9srToKj2YCHJ34QGOLzM+xYLLA+qRZv7N+33kl1MERVusue36ZHnlNaNEvY/PzSrw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.3.3': + resolution: {integrity: sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.12': + resolution: {integrity: sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.12': + resolution: {integrity: sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.20': + resolution: {integrity: sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.2.13': + resolution: {integrity: sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -4580,6 +4976,14 @@ packages: vue-router: optional: true + '@vercel/blob@0.27.3': + resolution: {integrity: sha512-WizeAxzOTmv0JL7wOaxvLIU/KdBcrclM1ZUOdSlIZAxsTTTe1jsyBthStLby0Ueh7FnmKYAjLz26qRJTk5SDkQ==} + engines: {node: '>=16.14'} + + '@vercel/blob@2.3.1': + resolution: {integrity: sha512-6f9oWC+DbWxIgBLOdqjjn2/REpFrPDB7y5B5HA1ptYkzZaBgL6E34kWrptJvJ7teApJdbAs3I1a5A7z1y8SDHw==} + engines: {node: '>=20.0.0'} + '@vercel/oidc@3.0.3': resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==} engines: {node: '>= 20'} @@ -4800,6 +5204,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + autoprefixer@10.4.22: resolution: {integrity: sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==} engines: {node: ^10 || ^12 || >=14} @@ -4943,6 +5350,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -5989,6 +6399,13 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + + fast-xml-parser@5.4.1: + resolution: {integrity: sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==} + hasBin: true + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -6600,6 +7017,10 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + is-bun-module@2.0.0: resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} @@ -7769,6 +8190,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.1.3: + resolution: {integrity: sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -8523,6 +8948,10 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + rettime@0.10.1: resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} @@ -8901,6 +9330,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.2.0: + resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==} + style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} @@ -9232,6 +9664,14 @@ packages: undici-types@7.8.0: resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + + undici@6.24.1: + resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} + engines: {node: '>=18.17'} + undici@7.16.0: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} @@ -9706,72 +10146,534 @@ packages: zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.2.1: + resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + + zundo@2.3.0: + resolution: {integrity: sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==} + peerDependencies: + zustand: ^4.3.0 || ^5.0.0 + + zustand@4.5.5: + resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@ai-sdk/gateway@2.0.10(zod@4.2.1)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.17(zod@4.2.1) + '@vercel/oidc': 3.0.3 + zod: 4.2.1 + + '@ai-sdk/openai@2.0.68(zod@4.2.1)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.17(zod@4.2.1) + zod: 4.2.1 + + '@ai-sdk/provider-utils@3.0.17(zod@4.2.1)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.6 + zod: 4.2.1 + + '@ai-sdk/provider@2.0.0': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/react@2.0.94(react@19.2.0)(zod@4.2.1)': + dependencies: + '@ai-sdk/provider-utils': 3.0.17(zod@4.2.1) + ai: 5.0.94(zod@4.2.1) + react: 19.2.0 + swr: 2.3.6(react@19.2.0) + throttleit: 2.1.0 + optionalDependencies: + zod: 4.2.1 + + '@alloc/quick-lru@5.2.0': {} + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.6 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.6 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.6 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1011.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.20 + '@aws-sdk/credential-provider-node': 3.972.21 + '@aws-sdk/middleware-bucket-endpoint': 3.972.8 + '@aws-sdk/middleware-expect-continue': 3.972.8 + '@aws-sdk/middleware-flexible-checksums': 3.974.0 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-location-constraint': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-sdk-s3': 3.972.20 + '@aws-sdk/middleware-ssec': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.21 + '@aws-sdk/region-config-resolver': 3.972.8 + '@aws-sdk/signature-v4-multi-region': 3.996.8 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.7 + '@smithy/config-resolver': 4.4.11 + '@smithy/core': 3.23.12 + '@smithy/eventstream-serde-browser': 4.2.12 + '@smithy/eventstream-serde-config-resolver': 4.3.12 + '@smithy/eventstream-serde-node': 4.2.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-blob-browser': 4.2.13 + '@smithy/hash-node': 4.2.12 + '@smithy/hash-stream-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/md5-js': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.26 + '@smithy/middleware-retry': 4.4.43 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.42 + '@smithy/util-defaults-mode-node': 4.2.45 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.13 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.973.20': + dependencies: + '@aws-sdk/types': 3.973.6 + '@aws-sdk/xml-builder': 3.972.11 + '@smithy/core': 3.23.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/crc64-nvme@3.972.5': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.18': + dependencies: + '@aws-sdk/core': 3.973.20 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.20': + dependencies: + '@aws-sdk/core': 3.973.20 + '@aws-sdk/types': 3.973.6 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/node-http-handler': 4.5.0 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/util-stream': 4.5.20 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.20': + dependencies: + '@aws-sdk/core': 3.973.20 + '@aws-sdk/credential-provider-env': 3.972.18 + '@aws-sdk/credential-provider-http': 3.972.20 + '@aws-sdk/credential-provider-login': 3.972.20 + '@aws-sdk/credential-provider-process': 3.972.18 + '@aws-sdk/credential-provider-sso': 3.972.20 + '@aws-sdk/credential-provider-web-identity': 3.972.20 + '@aws-sdk/nested-clients': 3.996.10 + '@aws-sdk/types': 3.973.6 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.20': + dependencies: + '@aws-sdk/core': 3.973.20 + '@aws-sdk/nested-clients': 3.996.10 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.21': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.18 + '@aws-sdk/credential-provider-http': 3.972.20 + '@aws-sdk/credential-provider-ini': 3.972.20 + '@aws-sdk/credential-provider-process': 3.972.18 + '@aws-sdk/credential-provider-sso': 3.972.20 + '@aws-sdk/credential-provider-web-identity': 3.972.20 + '@aws-sdk/types': 3.973.6 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.18': + dependencies: + '@aws-sdk/core': 3.973.20 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.20': + dependencies: + '@aws-sdk/core': 3.973.20 + '@aws-sdk/nested-clients': 3.996.10 + '@aws-sdk/token-providers': 3.1009.0 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.20': + dependencies: + '@aws-sdk/core': 3.973.20 + '@aws-sdk/nested-clients': 3.996.10 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.974.0': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.973.20 + '@aws-sdk/crc64-nvme': 3.972.5 + '@aws-sdk/types': 3.973.6 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.6 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.20': + dependencies: + '@aws-sdk/core': 3.973.20 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/core': 3.23.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.21': + dependencies: + '@aws-sdk/core': 3.973.20 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@smithy/core': 3.23.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-retry': 4.2.12 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.996.10': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.20 + '@aws-sdk/middleware-host-header': 3.972.8 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 + '@aws-sdk/middleware-user-agent': 3.972.21 + '@aws-sdk/region-config-resolver': 3.972.8 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.7 + '@smithy/config-resolver': 4.4.11 + '@smithy/core': 3.23.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.26 + '@smithy/middleware-retry': 4.4.43 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.42 + '@smithy/util-defaults-mode-node': 4.2.45 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.8': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/config-resolver': 4.4.11 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1011.0': + dependencies: + '@aws-sdk/signature-v4-multi-region': 3.996.8 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-format-url': 3.972.8 + '@smithy/middleware-endpoint': 4.4.26 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + tslib: 2.8.1 - zod@4.2.1: - resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + '@aws-sdk/signature-v4-multi-region@3.996.8': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.972.20 + '@aws-sdk/types': 3.973.6 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 - zundo@2.3.0: - resolution: {integrity: sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==} - peerDependencies: - zustand: ^4.3.0 || ^5.0.0 + '@aws-sdk/token-providers@3.1009.0': + dependencies: + '@aws-sdk/core': 3.973.20 + '@aws-sdk/nested-clients': 3.996.10 + '@aws-sdk/types': 3.973.6 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt - zustand@4.5.5: - resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==} - engines: {node: '>=12.7.0'} - peerDependencies: - '@types/react': '>=16.8' - immer: '>=9.0.6' - react: ^19.2.0 - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true + '@aws-sdk/types@3.973.6': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + '@aws-sdk/util-arn-parser@3.972.3': + dependencies: + tslib: 2.8.1 -snapshots: + '@aws-sdk/util-endpoints@3.996.5': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-endpoints': 3.3.3 + tslib: 2.8.1 - '@ai-sdk/gateway@2.0.10(zod@4.2.1)': + '@aws-sdk/util-format-url@3.972.8': dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.17(zod@4.2.1) - '@vercel/oidc': 3.0.3 - zod: 4.2.1 + '@aws-sdk/types': 3.973.6 + '@smithy/querystring-builder': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 - '@ai-sdk/openai@2.0.68(zod@4.2.1)': + '@aws-sdk/util-locate-window@3.965.5': dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.17(zod@4.2.1) - zod: 4.2.1 + tslib: 2.8.1 - '@ai-sdk/provider-utils@3.0.17(zod@4.2.1)': + '@aws-sdk/util-user-agent-browser@3.972.8': dependencies: - '@ai-sdk/provider': 2.0.0 - '@standard-schema/spec': 1.0.0 - eventsource-parser: 3.0.6 - zod: 4.2.1 + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 + bowser: 2.14.1 + tslib: 2.8.1 - '@ai-sdk/provider@2.0.0': + '@aws-sdk/util-user-agent-node@3.973.7': dependencies: - json-schema: 0.4.0 + '@aws-sdk/middleware-user-agent': 3.972.21 + '@aws-sdk/types': 3.973.6 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 - '@ai-sdk/react@2.0.94(react@19.2.0)(zod@4.2.1)': + '@aws-sdk/xml-builder@3.972.11': dependencies: - '@ai-sdk/provider-utils': 3.0.17(zod@4.2.1) - ai: 5.0.94(zod@4.2.1) - react: 19.2.0 - swr: 2.3.6(react@19.2.0) - throttleit: 2.1.0 - optionalDependencies: - zod: 4.2.1 + '@smithy/types': 4.13.1 + fast-xml-parser: 5.4.1 + tslib: 2.8.1 - '@alloc/quick-lru@5.2.0': {} + '@aws/lambda-invoke-store@0.2.4': {} '@babel/code-frame@7.26.2': dependencies: @@ -11128,6 +12030,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastify/busboy@2.1.1': {} + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -13242,6 +14146,345 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@smithy/abort-controller@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader-native@4.2.3': + dependencies: + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.11': + dependencies: + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + tslib: 2.8.1 + + '@smithy/core@3.23.12': + dependencies: + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.12': + dependencies: + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.12': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.13.1 + '@smithy/util-hex-encoding': 4.2.2 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.12': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.12': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.12': + dependencies: + '@smithy/eventstream-codec': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.15': + dependencies: + '@smithy/protocol-http': 5.3.12 + '@smithy/querystring-builder': 4.2.12 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@4.2.13': + dependencies: + '@smithy/chunked-blob-reader': 5.2.2 + '@smithy/chunked-blob-reader-native': 4.2.3 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/hash-stream-node@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.12': + dependencies: + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.26': + dependencies: + '@smithy/core': 3.23.12 + '@smithy/middleware-serde': 4.2.15 + '@smithy/node-config-provider': 4.3.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-middleware': 4.2.12 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.43': + dependencies: + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/service-error-classification': 4.2.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.15': + dependencies: + '@smithy/core': 3.23.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.12': + dependencies: + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.5.0': + dependencies: + '@smithy/abort-controller': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/querystring-builder': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + + '@smithy/shared-ini-file-loader@4.4.7': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.12': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/smithy-client@4.12.6': + dependencies: + '@smithy/core': 3.23.12 + '@smithy/middleware-endpoint': 4.4.26 + '@smithy/middleware-stack': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-stream': 4.5.20 + tslib: 2.8.1 + + '@smithy/types@4.13.1': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.12': + dependencies: + '@smithy/querystring-parser': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.42': + dependencies: + '@smithy/property-provider': 4.2.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.45': + dependencies: + '@smithy/config-resolver': 4.4.11 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.3.3': + dependencies: + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.12': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.12': + dependencies: + '@smithy/service-error-classification': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.20': + dependencies: + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/node-http-handler': 4.5.0 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-waiter@4.2.13': + dependencies: + '@smithy/abort-controller': 4.2.12 + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.1.0': {} @@ -14443,6 +15686,23 @@ snapshots: react: 19.2.0 vue: 3.5.24(typescript@5.9.3) + '@vercel/blob@0.27.3': + dependencies: + async-retry: 1.3.3 + is-buffer: 2.0.5 + is-node-process: 1.2.0 + throttleit: 2.1.0 + undici: 5.29.0 + + '@vercel/blob@2.3.1': + dependencies: + async-retry: 1.3.3 + is-buffer: 2.0.5 + is-node-process: 1.2.0 + throttleit: 2.1.0 + undici: 6.24.1 + optional: true + '@vercel/oidc@3.0.3': {} '@vitejs/plugin-react@5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': @@ -14741,6 +16001,10 @@ snapshots: async-function@1.0.0: {} + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + autoprefixer@10.4.22(postcss@8.5.6): dependencies: browserslist: 4.28.0 @@ -14969,6 +16233,8 @@ snapshots: boolbase@1.0.0: {} + bowser@2.14.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -16371,6 +17637,15 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-xml-builder@1.1.4: + dependencies: + path-expression-matcher: 1.1.3 + + fast-xml-parser@5.4.1: + dependencies: + fast-xml-builder: 1.1.4 + strnum: 2.2.0 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -17154,6 +18429,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-buffer@2.0.5: {} + is-bun-module@2.0.0: dependencies: semver: 7.7.3 @@ -17213,8 +18490,7 @@ snapshots: is-negative-zero@2.0.3: {} - is-node-process@1.2.0: - optional: true + is-node-process@1.2.0: {} is-number-object@1.1.1: dependencies: @@ -18326,7 +19602,7 @@ snapshots: nf3@0.1.12: {} - nitro@3.0.1-alpha.0(@electric-sql/pglite@0.3.15)(chokidar@4.0.3)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)))(lru-cache@11.2.2)(mongodb@6.21.0(socks@2.8.7))(mysql2@3.15.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)): + nitro@3.0.1-alpha.0(@electric-sql/pglite@0.3.15)(@vercel/blob@2.3.1)(chokidar@4.0.3)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)))(lru-cache@11.2.2)(mongodb@6.21.0(socks@2.8.7))(mysql2@3.15.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)): dependencies: consola: 3.4.2 cookie-es: 2.0.0 @@ -18344,7 +19620,7 @@ snapshots: srvx: 0.8.16 undici: 7.16.0 unenv: 2.0.0-rc.21 - unstorage: 2.0.0-alpha.3(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.3.15)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)))(mysql2@3.15.3))(lru-cache@11.2.2)(mongodb@6.21.0(socks@2.8.7))(ofetch@1.5.1) + unstorage: 2.0.0-alpha.3(@vercel/blob@2.3.1)(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.3.15)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)))(mysql2@3.15.3))(lru-cache@11.2.2)(mongodb@6.21.0(socks@2.8.7))(ofetch@1.5.1) optionalDependencies: vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: @@ -18701,6 +19977,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.1.3: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -19612,6 +20890,8 @@ snapshots: retry@0.12.0: {} + retry@0.13.1: {} + rettime@0.10.1: optional: true @@ -20074,6 +21354,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.2.0: {} + style-mod@4.1.3: {} style-to-js@1.1.21: @@ -20409,6 +21691,13 @@ snapshots: undici-types@7.8.0: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + + undici@6.24.1: + optional: true + undici@7.16.0: {} unenv@2.0.0-rc.21: @@ -20501,8 +21790,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - unstorage@2.0.0-alpha.3(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.3.15)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)))(mysql2@3.15.3))(lru-cache@11.2.2)(mongodb@6.21.0(socks@2.8.7))(ofetch@1.5.1): + unstorage@2.0.0-alpha.3(@vercel/blob@2.3.1)(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.3.15)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)))(mysql2@3.15.3))(lru-cache@11.2.2)(mongodb@6.21.0(socks@2.8.7))(ofetch@1.5.1): optionalDependencies: + '@vercel/blob': 2.3.1 chokidar: 4.0.3 db0: 0.3.4(@electric-sql/pglite@0.3.15)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.0(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(bun-types@1.3.2(@types/react@19.2.6))(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)))(mysql2@3.15.3) lru-cache: 11.2.2