diff --git a/package.json b/package.json index 7851063..5382268 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@libsql/client": "^0.17.4", "@next/third-parties": "^16.2.10", + "@openrouter/ai-sdk-provider": "^2.10.0", "@payloadcms/db-sqlite": "3.85.1", "@payloadcms/next": "3.85.1", "@payloadcms/plugin-seo": "3.85.1", @@ -29,7 +30,7 @@ "@payloadcms/ui": "3.85.1", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.11", - "chaipro": "^0.2.6", + "chaipro": "^0.3.0", "cnfast": "^0.0.8", "cross-env": "^7.0.3", "dotenv": "16.4.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e881eb..ce747c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@next/third-parties': specifier: ^16.2.10 version: 16.2.10(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6) + '@openrouter/ai-sdk-provider': + specifier: ^2.10.0 + version: 2.10.0(ai@6.0.219(zod@4.4.3))(zod@4.4.3) '@payloadcms/db-sqlite': specifier: 3.85.1 version: 3.85.1(@opentelemetry/api@1.9.1)(payload@3.85.1(graphql@16.14.2)(typescript@5.7.3))(supports-color@8.1.1) @@ -47,8 +50,8 @@ importers: specifier: ^0.5.11 version: 0.5.11(tailwindcss@4.3.3) chaipro: - specifier: ^0.2.6 - version: 0.2.6(1b462b3377e3eb2a68b9fbf2b0a240be) + specifier: ^0.3.0 + version: 0.3.0(0a9275dfd3343f1dea1ef8265e506048) cnfast: specifier: ^0.0.8 version: 0.0.8 @@ -1851,6 +1854,13 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@openrouter/ai-sdk-provider@2.10.0': + resolution: {integrity: sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw==} + engines: {node: '>=18'} + peerDependencies: + ai: ^6.0.0 + zod: ^3.25.0 || ^4.0.0 + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -3835,8 +3845,8 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chaipro@0.2.6: - resolution: {integrity: sha512-0PBy/E93nceScQGgwvdsK3/E5pECtlCHeqn4ufM2PSoKdTKsFGeY+a66RTbvIGcTRF8WlUczt78SVRPshQEQKQ==} + chaipro@0.3.0: + resolution: {integrity: sha512-TfMshz6w3MN44JPxjOnvurj3ogVA07acmuVP5Sx5HDKq8gBl5SQDGskB12+B2WrgvONZze3MC6mXyM9VLwmiFQ==} hasBin: true peerDependencies: '@ai-sdk/openai-compatible': '>=2.0.0' @@ -7352,6 +7362,13 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.3.5 + '@ai-sdk/gateway@3.0.143(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.13 + '@ai-sdk/provider-utils': 4.0.35(zod@4.4.3) + '@vercel/oidc': 3.2.0 + zod: 4.4.3 + '@ai-sdk/provider-utils@4.0.35(zod@4.3.5)': dependencies: '@ai-sdk/provider': 3.0.13 @@ -7359,6 +7376,13 @@ snapshots: eventsource-parser: 3.1.0 zod: 4.3.5 + '@ai-sdk/provider-utils@4.0.35(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.13 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + zod: 4.4.3 + '@ai-sdk/provider@3.0.13': dependencies: json-schema: 0.4.0 @@ -8845,6 +8869,11 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@openrouter/ai-sdk-provider@2.10.0(ai@6.0.219(zod@4.4.3))(zod@4.4.3)': + dependencies: + ai: 6.0.219(zod@4.4.3) + zod: 4.4.3 + '@opentelemetry/api@1.9.1': {} '@payloadcms/db-sqlite@3.85.1(@opentelemetry/api@1.9.1)(payload@3.85.1(graphql@16.14.2)(typescript@5.7.3))(supports-color@8.1.1)': @@ -10777,6 +10806,14 @@ snapshots: '@opentelemetry/api': 1.9.1 zod: 4.3.5 + ai@6.0.219(zod@4.4.3): + dependencies: + '@ai-sdk/gateway': 3.0.143(zod@4.4.3) + '@ai-sdk/provider': 3.0.13 + '@ai-sdk/provider-utils': 4.0.35(zod@4.4.3) + '@opentelemetry/api': 1.9.1 + zod: 4.4.3 + ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -11003,7 +11040,7 @@ snapshots: chai@6.2.2: {} - chaipro@0.2.6(1b462b3377e3eb2a68b9fbf2b0a240be): + chaipro@0.3.0(0a9275dfd3343f1dea1ef8265e506048): dependencies: '@ai-sdk/react': 3.0.221(react@19.2.6)(zod@4.3.5) '@floating-ui/dom': 1.7.4 @@ -11091,10 +11128,11 @@ snapshots: optionalDependencies: '@libsql/client': 0.17.4 '@next/third-parties': 16.2.10(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6) + '@openrouter/ai-sdk-provider': 2.10.0(ai@6.0.219(zod@4.4.3))(zod@4.4.3) '@payloadcms/richtext-lexical': 3.85.1(@faceless-ui/modal@3.0.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@payloadcms/next@3.85.1(@types/react@19.2.14)(graphql@16.14.2)(monaco-editor@0.55.1)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.85.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@8.1.1)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.85.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3)(yjs@13.6.31) '@payloadcms/ui': 3.85.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.85.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) '@tailwindcss/postcss': 4.3.3 - ai: 6.0.219(zod@4.3.5) + ai: 6.0.219(zod@4.4.3) drizzle-orm: 0.45.2(@libsql/client@0.17.4)(@opentelemetry/api@1.9.1) next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4) payload: 3.85.1(graphql@16.14.2)(typescript@5.7.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c5f9dda..bc44a68 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,7 +9,7 @@ allowBuilds: unrs-resolver: true minimumReleaseAgeExclude: - - chaipro@0.1.0 || 0.1.13 || 0.1.14 || 0.2.1 || 0.2.3 || 0.2.4 || 0.2.6 + - chaipro@0.1.0 || 0.1.13 || 0.1.14 || 0.2.1 || 0.2.3 || 0.2.4 || 0.2.6 || 0.3.0 overrides: "framer-motion@12.23.20>motion-dom": "12.23.20" diff --git a/src/chaibuilder.config.ts b/src/chaibuilder.config.ts index 9dcbdab..5931546 100644 --- a/src/chaibuilder.config.ts +++ b/src/chaibuilder.config.ts @@ -7,18 +7,105 @@ import { Blog } from '@/collections/Blog' import config from '@payload-config' import { createLibsqlDB } from 'chaipro/db/libsql' import { - asChaiBuilderGlobalProvider, - buildChaiBuilderConfig, - payloadMediaPlugin, + asChaiBuilderGlobalProvider, + buildChaiBuilderConfig, + payloadMediaPlugin, } from 'chaipro/payload' import { aiPlugin } from 'chaipro/plugins/ai-pro/server' import { animationPlugin } from 'chaipro/plugins/animation/server' -import { mediaSearchPlugin } from 'chaipro/plugins/media-search/server' import { redirectsPlugin } from 'chaipro/plugins/redirects/server' import { revisionsPlugin } from 'chaipro/plugins/revisions/server' import { trashPlugin } from 'chaipro/plugins/trash/server' import type { ResolvedChaiBuilderServerConfig } from 'chaipro/types' +/** + * The eight models the editor offers, picked for web design and front-end work. + * Most take images too, so a screenshot or mockup can be attached to the prompt; + * the text-only ones say so via `allowedFileTypes: []`. Each model is described + * once and mapped to the provider's own slug — Vercel AI Gateway and OpenRouter + * disagree on some vendor prefixes (`xai` vs `x-ai`, `zai` vs `z-ai`), so the id + * has to follow whichever provider is wired up. + */ +const AI_MODELS = [ + { + gateway: 'anthropic/claude-opus-5', + openrouter: 'anthropic/claude-opus-5', + name: 'Claude Opus 5', + provider: 'anthropic', + multiplier: 5, + description: '5x Credits', + }, + { + gateway: 'anthropic/claude-sonnet-5', + openrouter: 'anthropic/claude-sonnet-5', + name: 'Claude Sonnet 5', + provider: 'anthropic', + multiplier: 3, + description: '3x Credits', + }, + { + gateway: 'openai/gpt-5.5', + openrouter: 'openai/gpt-5.5', + name: 'GPT-5.5', + provider: 'openai', + multiplier: 5, + description: '5x Credits', + }, + { + gateway: 'google/gemini-3.6-flash', + openrouter: 'google/gemini-3.6-flash', + name: 'Gemini 3.6 Flash', + provider: 'google', + multiplier: 2, + description: '2x Credits', + }, + { + gateway: 'xai/grok-4.5', + openrouter: 'x-ai/grok-4.5', + name: 'Grok 4.5', + provider: 'xai', + multiplier: 3, + description: '3x Credits', + }, + { + gateway: 'moonshotai/kimi-k3', + openrouter: 'moonshotai/kimi-k3', + name: 'Kimi K3', + provider: 'moonshotai', + multiplier: 3, + description: '3x Credits', + }, + { + gateway: 'zai/glm-5.2', + openrouter: 'z-ai/glm-5.2', + name: 'GLM 5.2', + provider: 'zai', + multiplier: 1, + description: '1x Credits', + // text-only model — it cannot read images/files + allowedFileTypes: [], + }, + { + gateway: 'deepseek/deepseek-v4-pro', + openrouter: 'deepseek/deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + provider: 'deepseek', + multiplier: 1, + description: '1x Credits', + // text-only model — it cannot read images/files + allowedFileTypes: [], + }, +] + +/** + * Mirrors chaipro's own provider resolution: an `OPENROUTER_API_KEY` wins, + * otherwise requests go through the Vercel AI Gateway (`AI_GATEWAY_API_KEY`). + */ +const aiModels = AI_MODELS.map(({ gateway, openrouter, ...model }) => ({ + ...model, + id: process.env.OPENROUTER_API_KEY ? openrouter : gateway, +})) + const chaiConfig: Readonly = buildChaiBuilderConfig({ payloadConfig: config, db: createLibsqlDB({ @@ -27,97 +114,18 @@ const chaiConfig: Readonly = buildChaiBuilderCo url: process.env.DATABASE_URL || 'file:/tmp/chai-placeholder.db', authToken: process.env.DATABASE_AUTH_TOKEN || undefined, }), - // Registering a plugin enables its feature; options carry the feature's config. - // Plugin tables are NOT gated here — `chaiBuilderSchemaHookSqlite` injects the - // full ChaiBuilder schema, so every plugin's tables exist (empty when - // unregistered) and enabling a plugin later needs no migration. - // Not registered: aiCreditsPlugin (credit billing), multilingualPlugin, - // tenancyPlugin (core pins a single app), plansPlugin, licensingPlugin, - // rolesPlugin (DB-backed roles), layoutPlugin, formSubmissionsPlugin (this - // starter writes submissions to its own Payload collection). plugins: [ // Payload-backed DAM (asset actions + media trash entity). Non-Payload // hosts would register mediaPlugin({ storage }) instead. payloadMediaPlugin(), redirectsPlugin(), trashPlugin(), - mediaSearchPlugin({ - providers: [ - { - id: 'pexels', - filters: { - orientation: ['default', 'landscape', 'portrait', 'square'], - size: ['default', 'large', 'medium', 'small'], - color: [ - 'default', - 'red', - 'orange', - 'yellow', - 'green', - 'turquoise', - 'blue', - 'violet', - 'pink', - 'brown', - 'black', - 'gray', - 'white', - ], - }, - }, - { - id: 'unsplash', - filters: { - orientation: ['default', 'landscape', 'portrait', 'squarish'], - color: [ - 'default', - 'black_and_white', - 'black', - 'white', - 'yellow', - 'orange', - 'red', - 'purple', - 'magenta', - 'green', - 'teal', - 'blue', - ], - order_by: ['default', 'relevant', 'latest'], - }, - }, - ], - }), aiPlugin(), revisionsPlugin({ drafts: true, maxRevisions: 10 }), animationPlugin(), ], ai: { - models: [ - { - id: 'zai/glm-5.2', - name: 'GLM 5.2', - provider: 'zai', - multiplier: 3, - description: '3x Credits', - // text-only model — it cannot read images/files - allowedFileTypes: [], - }, - { - id: 'google/gemini-3.5-flash', - name: 'Gemini 3.5 Flash', - provider: 'google', - multiplier: 3, - description: '3x Credits', - }, - { - id: 'google/gemini-3-flash', - name: 'Gemini 3 Flash', - provider: 'google', - multiplier: 1, - description: '1x Credits', - }, - ], + models: aiModels, }, globalDataProvider: asChaiBuilderGlobalProvider({ slug: 'site-config' }), pageTypes: [ diff --git a/src/collections/Media.ts b/src/collections/Media.ts index b147526..49c3dbd 100644 --- a/src/collections/Media.ts +++ b/src/collections/Media.ts @@ -1,5 +1,6 @@ import type { CollectionConfig } from 'payload' import { mediaAccess } from '@/access/authenticated' +import { getMediaStoragePrefix } from '@/utilities/getAppStoragePrefix' export const Media: CollectionConfig = { slug: 'media', @@ -28,6 +29,27 @@ export const Media: CollectionConfig = { hidden: true, }, }, + /** + * Declared here rather than left to `s3Storage`, which only injects a + * `prefix` field when the bucket credentials happen to be set. That made the + * database schema depend on environment variables: a deployment whose + * migration was generated without storage configured, then run with it + * configured, queries a `media.prefix` column that was never created. + * + * `s3Storage` reuses this field when it is already present, so the schema is + * now identical either way. The default is a function on purpose — a literal + * would be emitted as a column default, putting the app-specific prefix back + * into the schema and reintroducing the same drift. + */ + { + name: 'prefix', + type: 'text', + admin: { + hidden: true, + readOnly: true, + }, + defaultValue: () => getMediaStoragePrefix(), + }, ], upload: true, } diff --git a/src/migrations/20260724_153838_initial.json b/src/migrations/20260802_094413_initial.json similarity index 99% rename from src/migrations/20260724_153838_initial.json rename to src/migrations/20260802_094413_initial.json index d46ddf6..46eb247 100644 --- a/src/migrations/20260724_153838_initial.json +++ b/src/migrations/20260802_094413_initial.json @@ -4410,6 +4410,13 @@ "notNull": false, "autoincrement": false }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, "updated_at": { "name": "updated_at", "type": "text", @@ -7014,6 +7021,6 @@ "internal": { "indexes": {} }, - "id": "17015411-10be-4111-9c84-5b83a421acb9", + "id": "af3746aa-04f8-4378-aeee-4d1efb570876", "prevId": "00000000-0000-0000-0000-000000000000" } \ No newline at end of file diff --git a/src/migrations/20260724_153838_initial.ts b/src/migrations/20260802_094413_initial.ts similarity index 99% rename from src/migrations/20260724_153838_initial.ts rename to src/migrations/20260802_094413_initial.ts index 1b4a986..2dcaee5 100644 --- a/src/migrations/20260724_153838_initial.ts +++ b/src/migrations/20260802_094413_initial.ts @@ -624,6 +624,7 @@ export async function up({ db, payload, req }: MigrateUpArgs): Promise { \`app\` text NOT NULL, \`deleted_at\` text, \`deleted_by\` text, + \`prefix\` text, \`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, \`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, \`url\` text, diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 01206e3..53f006e 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -1,9 +1,9 @@ -import * as migration_20260724_153838_initial from './20260724_153838_initial'; +import * as migration_20260802_094413_initial from './20260802_094413_initial'; export const migrations = [ { - up: migration_20260724_153838_initial.up, - down: migration_20260724_153838_initial.down, - name: '20260724_153838_initial' + up: migration_20260802_094413_initial.up, + down: migration_20260802_094413_initial.down, + name: '20260802_094413_initial' }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 8e9f3a9..2a69cf1 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -213,6 +213,7 @@ export interface Media { alt: string; deletedAt?: string | null; deletedBy?: string | null; + prefix?: string | null; updatedAt: string; createdAt: string; url?: string | null; @@ -531,6 +532,7 @@ export interface MediaSelect { alt?: T; deletedAt?: T; deletedBy?: T; + prefix?: T; updatedAt?: T; createdAt?: T; url?: T; diff --git a/src/payload.config.ts b/src/payload.config.ts index 9c198c7..6cad1e2 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -15,7 +15,7 @@ import { SiteConfig } from './collections/SiteConfig' import { Users } from './collections/Users' import { getAdminRoute } from '@/utilities/adminRoute' -import { getAppStoragePrefix } from '@/utilities/getAppStoragePrefix' +import { getMediaStoragePrefix } from '@/utilities/getAppStoragePrefix' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -72,6 +72,7 @@ export function resolveDatabase( export function buildPayloadConfig(overrides: PayloadConfigOverrides = {}) { const { url: databaseUrl, authToken: databaseAuthToken } = resolveDatabase(overrides.database) const secret = overrides.secret || process.env.PAYLOAD_SECRET || PLACEHOLDER_SECRET + const mediaStoragePrefix = getMediaStoragePrefix() return buildConfig({ routes: { @@ -167,12 +168,13 @@ export function buildPayloadConfig(overrides: PayloadConfigOverrides = {}) { }), // Local disk uploads do not survive a redeploy on serverless hosts, so S3 // is registered whenever the bucket credentials are present. The app key - // is required too, since the storage prefix is derived from it. - ...(mediaStorageActive && process.env.CHAIBUILDER_APP_KEY + // is required too, since the storage prefix is derived from it — an empty + // prefix means one of the two is missing. + ...(mediaStoragePrefix ? [ s3Storage({ collections: { - media: { prefix: getAppStoragePrefix() }, + media: { prefix: mediaStoragePrefix }, }, bucket: process.env.BUCKET_NAME!, config: { diff --git a/src/utilities/getAppStoragePrefix.ts b/src/utilities/getAppStoragePrefix.ts index 76f4481..67f148f 100644 --- a/src/utilities/getAppStoragePrefix.ts +++ b/src/utilities/getAppStoragePrefix.ts @@ -1,43 +1,52 @@ -import { createHash } from 'node:crypto' - -/** - * Project-specific UUID v5 namespace. Fixed constant — do not change after - * deploy (would change every app's R2 prefix). Not secret; obscures app UUID - * in bucket paths when combined with v5(appKey). - */ -const MEDIA_STORAGE_NAMESPACE = 'a3f2c891-4e7b-5d2a-9c18-6f0e1b3d5a72' - -function parseUuidToBytes(uuid: string): Buffer { - const hex = uuid.replace(/-/g, '') - if (hex.length !== 32) { - throw new Error('Invalid namespace UUID') - } - return Buffer.from(hex, 'hex') -} - -function formatUuid(bytes: Buffer): string { - const hex = bytes.toString('hex') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` -} - -/** RFC 4122 UUID v5 — deterministic from namespace + name. */ -function uuidV5(name: string, namespaceUuid: string): string { - const namespace = parseUuidToBytes(namespaceUuid) - const hash = createHash('sha1').update(namespace).update(name, 'utf8').digest() - const bytes = Buffer.from(hash.subarray(0, 16)) - bytes[6] = (bytes[6]! & 0x0f) | 0x50 // version 5 - bytes[8] = (bytes[8]! & 0x3f) | 0x80 // variant RFC 4122 - return formatUuid(bytes) -} +/** Canonical UUID: five hex groups of 8-4-4-4-12 characters. */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ /** * Opaque, deterministic R2 folder id for the current app. - * UUID v5(CHAIBUILDER_APP_KEY) under a fixed project namespace. + * + * The app key's final group is dropped and the remaining four are joined in + * reverse order, giving 20 hex characters: + * + * 926e3219-b756-4b17-856b-ad17c4fe139c -> 856b4b17b756926e3219 + * + * Deliberately a pure function of the app key and nothing else: an app's folder + * is fixed for the life of the app, and there is no constant here that anyone + * can change to send new uploads to a second folder while old files stay behind + * in the first. + * + * The dropped group keeps the whole app key out of object paths — those 48 bits + * cannot be recovered from the prefix — and the reversal stops the remainder + * from reading as the head of a UUID. */ export function getAppStoragePrefix(appId?: string): string { const appKey = appId ?? process.env.CHAIBUILDER_APP_KEY if (!appKey) { throw new Error('CHAIBUILDER_APP_KEY is required to compute storage prefix') } - return uuidV5(appKey, MEDIA_STORAGE_NAMESPACE) + const normalized = appKey.toLowerCase() + // `/setup` always writes a `randomUUID()`. Anything else is a hand-edited key, + // and dropping a group off a value that has none would put files at the bucket + // root or under a prefix short enough to collide — fail loudly instead. + if (!UUID_RE.test(normalized)) { + throw new Error('CHAIBUILDER_APP_KEY must be a UUID to compute storage prefix') + } + return normalized.split('-').slice(0, -1).reverse().join('') +} + +/** + * The prefix media files are actually stored under, or `''` when uploads go to + * local disk. Bucket credentials and the app key are both required: without + * either one, `s3Storage` is not registered and nothing is prefixed. + * + * Resolved per call rather than at module load so it stays a runtime value — + * baking it into the database schema as a column default would make the schema + * differ between deployments and between build and run. + */ +export function getMediaStoragePrefix(): string { + const configured = + process.env.BUCKET_NAME && + process.env.AWS_ACCESS_KEY_ID && + process.env.AWS_SECRET_ACCESS_KEY && + process.env.CHAIBUILDER_APP_KEY + return configured ? getAppStoragePrefix() : '' } diff --git a/tests/int/app-storage-prefix.int.spec.ts b/tests/int/app-storage-prefix.int.spec.ts index 31b510c..b25334e 100644 --- a/tests/int/app-storage-prefix.int.spec.ts +++ b/tests/int/app-storage-prefix.int.spec.ts @@ -1,25 +1,42 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { getAppStoragePrefix } from '../../src/utilities/getAppStoragePrefix' +import { getAppStoragePrefix, getMediaStoragePrefix } from '../../src/utilities/getAppStoragePrefix' -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const PREFIX_RE = /^[0-9a-f]{20}$/ + +const APP_KEY = '926e3219-b756-4b17-856b-ad17c4fe139c' +const OTHER_APP_KEY = '3f5a1c88-2d94-4e07-b1aa-6c3e9f2d7b41' describe('getAppStoragePrefix', () => { const env = process.env beforeEach(() => { process.env = { ...env } - process.env.CHAIBUILDER_APP_KEY = '00000000-0000-4000-8000-000000000001' + process.env.CHAIBUILDER_APP_KEY = APP_KEY }) afterEach(() => { process.env = env }) - it('returns a UUID-shaped string different from the app key', () => { + // The prefix shows up in object paths, so it must not read as a UUID: no + // hyphens, and short enough that no complete UUID is published. + it('returns 20 hex characters with no hyphens', () => { + const prefix = getAppStoragePrefix() + expect(prefix).toMatch(PREFIX_RE) + expect(prefix).not.toContain('-') + }) + + it('does not publish the app key in full', () => { const prefix = getAppStoragePrefix() - expect(prefix).toMatch(UUID_RE) - expect(prefix).not.toBe(process.env.CHAIBUILDER_APP_KEY) + expect(prefix).not.toBe(APP_KEY) + expect(prefix).not.toContain(APP_KEY.replace(/-/g, '')) + // The final group is dropped, so those 48 bits are absent from the path. + expect(prefix).not.toContain(APP_KEY.split('-').at(-1)) + }) + + it('joins the surviving groups in reverse order', () => { + const [first, second, third, fourth] = APP_KEY.split('-') + expect(getAppStoragePrefix()).toBe(`${fourth}${third}${second}${first}`) }) it('is stable for the same app key', () => { @@ -28,11 +45,20 @@ describe('getAppStoragePrefix', () => { it('changes when app key changes', () => { const a = getAppStoragePrefix() - process.env.CHAIBUILDER_APP_KEY = '00000000-0000-4000-8000-000000000002' + process.env.CHAIBUILDER_APP_KEY = OTHER_APP_KEY const b = getAppStoragePrefix() expect(a).not.toBe(b) }) + // The final group is dropped, so it cannot affect the folder. Harmless for + // the `randomUUID()` keys `/setup` writes, but hand-picked sequential keys + // that differ only in that group will share a folder. + it("ignores the app key's final group", () => { + expect(getAppStoragePrefix('00000000-0000-4000-8000-000000000001')).toBe( + getAppStoragePrefix('00000000-0000-4000-8000-000000000002'), + ) + }) + it('is unaffected by PAYLOAD_SECRET', () => { const before = getAppStoragePrefix() process.env.PAYLOAD_SECRET = 'totally-different-secret' @@ -41,7 +67,7 @@ describe('getAppStoragePrefix', () => { it('accepts explicit appId override', () => { const fromEnv = getAppStoragePrefix() - const fromArg = getAppStoragePrefix('00000000-0000-4000-8000-000000000001') + const fromArg = getAppStoragePrefix(APP_KEY) expect(fromArg).toBe(fromEnv) }) @@ -50,10 +76,57 @@ describe('getAppStoragePrefix', () => { expect(() => getAppStoragePrefix()).toThrow(/CHAIBUILDER_APP_KEY/) }) - // Pin expected output so accidental namespace constant changes are caught - it('matches pinned v5 output for a fixture app key', () => { - expect(getAppStoragePrefix('00000000-0000-4000-8000-000000000001')).toBe( - '56e6552e-e274-526d-85a9-c4107865b7ca', - ) + // Dropping a group off a key that has none would leave an empty prefix, so + // anything not UUID-shaped has to be rejected — including a hyphen-less key + // that carries all 32 hex characters. + it.each([ + 'my-site', + 'zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz', + '926e3219b756', + '926e3219b7564b17856bad17c4fe139c', + '926e3219-b756-4b17-856b', + ])('throws for a non-UUID app key (%j)', (key) => { + expect(() => getAppStoragePrefix(key)).toThrow(/CHAIBUILDER_APP_KEY/) + }) + + // Pin expected output so an accidental change to the derivation is caught — + // it would send new uploads to a different folder than every existing file. + it('matches pinned output for a fixture app key', () => { + expect(getAppStoragePrefix(APP_KEY)).toBe('856b4b17b756926e3219') + }) + + it('accepts an uppercase app key', () => { + expect(getAppStoragePrefix(APP_KEY.toUpperCase())).toBe(getAppStoragePrefix(APP_KEY)) + }) +}) + +describe('getMediaStoragePrefix', () => { + const env = process.env + + beforeEach(() => { + process.env = { ...env } + process.env.CHAIBUILDER_APP_KEY = '00000000-0000-4000-8000-000000000001' + process.env.BUCKET_NAME = 'media' + process.env.AWS_ACCESS_KEY_ID = 'key' + process.env.AWS_SECRET_ACCESS_KEY = 'secret' + }) + + afterEach(() => { + process.env = env + }) + + it('returns the app storage prefix when the bucket is fully configured', () => { + expect(getMediaStoragePrefix()).toBe(getAppStoragePrefix()) }) + + // Each of these leaves `s3Storage` unregistered, so uploads go to local disk + // and carry no prefix. Throwing instead would break booting an unconfigured + // deployment far enough to serve `/setup`. + it.each(['BUCKET_NAME', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'CHAIBUILDER_APP_KEY'])( + 'returns an empty prefix when %s is missing', + (missing) => { + delete process.env[missing] + expect(getMediaStoragePrefix()).toBe('') + }, + ) })