{"version":3,"sources":["../../../src/server/stores/supabase/index.ts","../../../src/server/stores/supabase/storage.ts"],"sourcesContent":["/**\n * Default Supabase Storage adapter — public entry.\n *\n * Imported via `@mordn/chat-widget/server/supabase` so a BYO-storage consumer\n * never pulls `@supabase/supabase-js` into their bundle.\n *\n *   import { createSupabaseStorage } from '@mordn/chat-widget/server/supabase';\n *   createChatHandler({ storage: createSupabaseStorage(), ... });\n */\nimport 'server-only';\n\nexport { createSupabaseStorage, type SupabaseStorageOptions } from './storage';\n","/**\n * Default StorageAdapter implementation, on Supabase Storage.\n *\n * This ships the *correct* attachment security model as the default — the one\n * the original scaffold got wrong. It is one implementation of the\n * `StorageAdapter` interface; a BYO adapter (S3/R2/GCS) is equally valid.\n *\n * The three interface rules, as implemented here:\n *\n *   1. PRIVATE AT REST — the bucket must be created as a *private* bucket in\n *      Supabase. We never call `getPublicUrl`. (If the bucket is public, that\n *      is a host-app misconfiguration; this adapter never relies on it.)\n *\n *   2. SIGNED, SHORT-LIVED READS — `upload` and `resign` return\n *      `createSignedUrl` results with a bounded TTL.\n *\n *   3. USER-NAMESPACED, UNGUESSABLE PATHS — every path is\n *      `<userId>/<conversationId>/<randomUUID>/<safeFilename>`. The adapter is\n *      bound to one verified user and derives the path itself, so an upload\n *      cannot land in another user's namespace. `resign`/`remove` refuse paths\n *      outside the bound user's prefix.\n */\n\nimport 'server-only';\nimport { createClient, type SupabaseClient } from '@supabase/supabase-js';\n\nimport type {\n  StorageAdapter,\n  UploadInput,\n  UploadResult,\n} from '../../storage-adapter';\n\nconst DEFAULT_BUCKET = 'chat-attachments';\n// Short by design: the upload URL is used within seconds (model fetch +\n// thumbnail), and history reloads re-sign on demand. One hour is a generous\n// ceiling that still bounds the exposure of a leaked URL.\nconst DEFAULT_SIGNED_TTL_SECONDS = 60 * 60;\n\nexport interface SupabaseStorageOptions {\n  /** Supabase project URL. Defaults to `process.env.NEXT_PUBLIC_SUPABASE_URL`. */\n  supabaseUrl?: string;\n  /**\n   * Service-role key. Required — signing + private writes need it. Defaults to\n   * `process.env.SUPABASE_SERVICE_ROLE_KEY`. NEVER expose this to the client;\n   * this adapter only ever runs server-side (guarded by `server-only`).\n   */\n  serviceRoleKey?: string;\n  /** Storage bucket name. Defaults to `chat-attachments`. Must be PRIVATE. */\n  bucket?: string;\n  /** Signed-URL TTL in seconds. Defaults to 3600 (1 hour). */\n  signedUrlTtlSeconds?: number;\n}\n\n/** Strip anything that could break a storage path; clamp length. */\nfunction safeFilename(name: string): string {\n  const cleaned = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80);\n  return cleaned.length ? cleaned : 'file';\n}\n\nclass SupabaseStorageAdapter implements StorageAdapter {\n  private readonly bucket: string;\n  private readonly ttl: number;\n  /** Path prefix this adapter is allowed to touch: `<userId>/`. */\n  private readonly userPrefix: string;\n\n  constructor(\n    public readonly userId: string,\n    private readonly client: SupabaseClient,\n    bucket: string,\n    ttl: number,\n  ) {\n    this.bucket = bucket;\n    this.ttl = ttl;\n    this.userPrefix = `${userId}/`;\n  }\n\n  /** Guard: a path is only operable if it lives under the bound user's prefix. */\n  private ownsPath(storagePath: string): boolean {\n    return storagePath.startsWith(this.userPrefix) && !storagePath.includes('..');\n  }\n\n  async upload(input: UploadInput): Promise<UploadResult> {\n    const token = crypto.randomUUID();\n    const filename = safeFilename(input.filename);\n    const conversationSegment = input.conversationId\n      ? safeFilename(input.conversationId)\n      : 'unfiled';\n    // Path is fully derived from the bound userId — callers can't inject it.\n    const path = `${this.userId}/${conversationSegment}/${token}/${filename}`;\n\n    const body =\n      input.data instanceof Uint8Array ? input.data : new Uint8Array(input.data);\n\n    const { error: uploadError } = await this.client.storage\n      .from(this.bucket)\n      .upload(path, body, {\n        contentType: input.mediaType,\n        cacheControl: '3600',\n        upsert: false,\n      });\n    if (uploadError) {\n      throw new Error(`[chat-widget] storage upload failed: ${uploadError.message}`);\n    }\n\n    const url = await this.signOrThrow(path);\n    return {\n      storagePath: path,\n      url,\n      filename: input.filename,\n      mediaType: input.mediaType,\n      size: input.size,\n    };\n  }\n\n  async resign(storagePath: string): Promise<string | null> {\n    // Refuse to sign anything outside the bound user's namespace.\n    if (!this.ownsPath(storagePath)) return null;\n    const { data, error } = await this.client.storage\n      .from(this.bucket)\n      .createSignedUrl(storagePath, this.ttl);\n    if (error || !data?.signedUrl) return null;\n    return data.signedUrl;\n  }\n\n  async remove(storagePath: string): Promise<void> {\n    if (!this.ownsPath(storagePath)) return; // never delete outside the user's prefix\n    // Supabase remove is idempotent — removing a missing object is not an error.\n    const { error } = await this.client.storage.from(this.bucket).remove([storagePath]);\n    if (error) {\n      throw new Error(`[chat-widget] storage remove failed: ${error.message}`);\n    }\n  }\n\n  private async signOrThrow(path: string): Promise<string> {\n    const { data, error } = await this.client.storage\n      .from(this.bucket)\n      .createSignedUrl(path, this.ttl);\n    if (error || !data?.signedUrl) {\n      throw new Error(\n        `[chat-widget] failed to sign URL${error ? `: ${error.message}` : ''}`,\n      );\n    }\n    return data.signedUrl;\n  }\n}\n\n/**\n * Create a `StorageAdapterFactory` backed by Supabase Storage.\n *\n * Pass to `createChatHandler({ storage: createSupabaseStorage() })`. Requires\n * a PRIVATE `chat-attachments` bucket (or whatever `bucket` you name) and the\n * service-role key. Each adapter instance is bound to the verified `userId`\n * the handler provides per request; the Supabase client is shared.\n */\nexport function createSupabaseStorage(options: SupabaseStorageOptions = {}) {\n  const supabaseUrl = options.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL;\n  const serviceRoleKey = options.serviceRoleKey ?? process.env.SUPABASE_SERVICE_ROLE_KEY;\n  if (!supabaseUrl || !serviceRoleKey) {\n    throw new Error(\n      '[chat-widget] createSupabaseStorage needs NEXT_PUBLIC_SUPABASE_URL and ' +\n        'SUPABASE_SERVICE_ROLE_KEY (or explicit options).',\n    );\n  }\n  const bucket = options.bucket ?? DEFAULT_BUCKET;\n  const ttl = options.signedUrlTtlSeconds ?? DEFAULT_SIGNED_TTL_SECONDS;\n  // One shared client; auth disabled (we use the service-role key directly).\n  const client = createClient(supabaseUrl, serviceRoleKey, {\n    auth: { persistSession: false, autoRefreshToken: false },\n  });\n\n  return (userId: string): StorageAdapter =>\n    new SupabaseStorageAdapter(userId, client, bucket, ttl);\n}\n"],"mappings":"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","names":[]}