mcpField
S
supabase-database-ops

supabase-database-ops

github.com/venture-formationsセキュリティ136 回インストール8 週間前に更新
リポジトリ

supabase-database-ops について

supabase-database-ops は、AIProDaily の開発で Supabase クエリやデータベース API を扱う際のガードレールです。テナント分離、サーバー側アクセス、取得フィールドの限定、エラーチェックを徹底し、テナント間のデータ漏えいリスクを抑えます。

主な機能

  1. 01テナントテーブルで publication_id を必須化
  2. 02supabaseAdmin のサーバー側利用を徹底
  3. 03不要な SELECT * を回避
  4. 04データベース操作のエラー確認
  5. 05挿入・更新・削除のパターン提示

活用シーン

  1. 01テナント範囲を限定した Supabase クエリの作成
  2. 02データベースにアクセスする API ルートの開発
  3. 03記事、キャンペーン、RSS データの変更
  4. 04行レベルセキュリティポリシーエラーの調査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/venture-formations/aiprodaily --skill supabase-database-ops

インストール後、Claude Code で/supabase-database-opsを入力して読み込まれたか確認します。

詳細

Supabase Database Operations - Critical Guardrail

目的

AIProDaily プラットフォームでのマルチテナントデータ漏洩を防ぎ、データベースのベストプラクティスを徹底するための重大なガードレールです。

使用するタイミング

以下の場合、このスキルは検証が完了するまでデータベース操作をブロックします。

  • Supabase クエリを作成するとき(supabaseAdmin.from())
  • テナントごとに分離されたテーブルにアクセスするとき
  • データベースにアクセスする API ルートを作成するとき
  • キャンペーン、記事、RSS データを扱うとき

🚨 重大なルール 🚨

ルール #1:必ず publication_id でフィルタリングする

テナントごとに分離されたテーブルに対するすべてのクエリに、publication_id フィルターを含める必要があります。

// ✅ CORRECT - publication_id filter present
const { data, error } = await supabaseAdmin
  .from('newsletter_campaigns')
  .select('id, status, date')
  .eq('publication_id', newsletterId)  // ✅ REQUIRED
  .eq('id', campaignId)
  .single()

// ❌ WRONG - Missing publication_id filter (DATA LEAKAGE!)
const { data, error } = await supabaseAdmin
  .from('newsletter_campaigns')
  .select('id, status, date')
  .eq('id', campaignId)  // ❌ Can access other tenants' data!
  .single()
テナントごとに分離されたテーブル(必ず publication_id でフィルタリングする):
  • newsletter_campaigns
  • articles
  • secondary_articles
  • rss_posts
  • post_ratings
  • rss_feeds
  • app_settings
  • advertisements
  • campaign_advertisements
  • archived_articles
  • archived_rss_posts
分離されていないテーブル(publication_id は不要):
  • newsletters(最上位のテナントテーブル)
  • システム全体の設定テーブル

ルール #2:サーバーサイドの操作には supabaseAdmin を使用する

サービスロールキーをクライアントサイドに公開してはいけません。

// ✅ CORRECT - Server-side API route or Server Action
import { supabaseAdmin } from '@/lib/supabase'

export async function POST(request: NextRequest) {
  const { data } = await supabaseAdmin
    .from('newsletter_campaigns')
    .select('*')
    .eq('publication_id', newsletterId)

  return NextResponse.json({ data })
}

// ❌ WRONG - Never in client components
'use client'
import { supabaseAdmin } from '@/lib/supabase'  // ❌ Security risk!

export default function ClientComponent() {
  // This exposes service role key to browser
  const { data } = await supabaseAdmin.from('...').select()
}

supabaseAdmin を使用する場所:

  • ✅ API ルート(app/api/**/*.ts)
  • ✅ サーバーアクション('use server' 関数)
  • ✅ サーバーコンポーネント('use client' がないもの)
  • ✅ バックグラウンドジョブ/cron
  • ✅ ワークフローのステップ

使用してはいけない場所:

  • ❌ クライアントコンポーネント('use client')
  • ❌ ブラウザーで実行されるコード
  • ❌ 公開ページ

ルール #3:SELECT * を避ける

必要なフィールドのみを選択してください。

// ✅ CORRECT - Specific fields
const { data } = await supabaseAdmin
  .from('articles')
  .select('id, headline, article_text, is_active')
  .eq('publication_id', newsletterId)
  .eq('campaign_id', campaignId)

// ❌ WRONG - Fetches all columns (performance impact)
const { data } = await supabaseAdmin
  .from('articles')
  .select('*')
  .eq('publication_id', newsletterId)
  .eq('campaign_id', campaignId)

例外:データ操作ですべての列が本当に必要な場合。


ルール #4:必ずエラーを確認する

データベース操作が成功すると思い込んではいけません。

// ✅ CORRECT - Check for errors
const { data, error } = await supabaseAdmin
  .from('newsletter_campaigns')
  .select('id, status')
  .eq('publication_id', newsletterId)
  .eq('id', campaignId)
  .single()

if (error) {
  console.error('[DB] Query failed:', error.message)
  throw new Error('Failed to fetch campaign')
}

if (!data) {
  console.log('[DB] No campaign found')
  return null
}

// Now safe to use data
return data

// ❌ WRONG - No error handling
const { data } = await supabaseAdmin
  .from('newsletter_campaigns')
  .select('id, status')
  .eq('id', campaignId)
  .single()

return data.status  // ❌ Crashes if error or data is null

データベースクエリのパターン

標準的なクエリパターン
const { data, error } = await supabaseAdmin
  .from('table_name')
  .select('field1, field2, field3')
  .eq('publication_id', newsletterId)  // ✅ ALWAYS for tenant tables
  .eq('other_field', value)
  .single()  // or .maybeSingle() if record might not exist

if (error) {
  console.error('[DB] Query error:', error.message)
  throw new Error(`Database query failed: ${error.message}`)
}

if (!data) {
  console.log('[DB] No record found')
  return null
}

return data
挿入パターン
const { data, error } = await supabaseAdmin
  .from('articles')
  .insert({
    publication_id: newsletterId,  // ✅ REQUIRED
    campaign_id: campaignId,
    headline: 'Article headline',
    article_text: 'Content here',
    is_active: false
  })
  .select()
  .single()

if (error) {
  console.error('[DB] Insert failed:', error.message)
  throw new Error('Failed to create article')
}

return data
更新パターン
const { data, error } = await supabaseAdmin
  .from('articles')
  .update({
    is_active: true,
    updated_at: new Date().toISOString()
  })
  .eq('id', articleId)
  .eq('publication_id', newsletterId)  // ✅ REQUIRED - prevents updating other tenants
  .select()
  .single()

if (error) {
  console.error('[DB] Update failed:', error.message)
  throw new Error('Failed to update article')
}

return data
削除パターン
const { error } = await supabaseAdmin
  .from('rss_posts')
  .delete()
  .eq('id', postId)
  .eq('publication_id', newsletterId)  // ✅ REQUIRED - prevents deleting other tenants' data

if (error) {
  console.error('[DB] Delete failed:', error.message)
  throw new Error('Failed to delete post')
}
結合パターン(リレーションシップ)
const { data, error } = await supabaseAdmin
  .from('newsletter_campaigns')
  .select(`
    id,
    status,
    date,
    articles (
      id,
      headline,
      is_active
    ),
    secondary_articles (
      id,
      headline,
      is_active
    )
  `)
  .eq('publication_id', newsletterId)  // ✅ REQUIRED on parent table
  .eq('id', campaignId)
  .single()

よくある間違い

❌ publication_id フィルターを忘れる
// This query can access ANY campaign from ANY tenant!
const { data } = await supabaseAdmin
  .from('newsletter_campaigns')
  .select('*')
  .eq('id', campaignId)  // ❌ Missing publication_id
❌ クライアントサイドで supabaseAdmin を使用する
'use client'

// ❌ Exposes service role key to browser
export default function MyComponent() {
  const { data } = await supabaseAdmin.from('...').select()
}
❌ エラー処理をしない
// ❌ No error check - will crash on failure
const { data } = await supabaseAdmin.from('...').select().single()
const status = data.status  // Crashes if data is null
❌ SELECT * を使用する
// ❌ Fetches unnecessary data, impacts performance
const { data } = await supabaseAdmin
  .from('articles')
  .select('*')

クイックリファレンス

✅ 実行すること:

  • テナントごとに分離されたテーブルでは、必ず publication_id でフィルタリングする
  • supabaseAdmin はサーバーサイドでのみ使用する
  • 特定のフィールドを選択する
  • エラーを確認する
  • 単一のレコードには .single() を使用する
  • レコードが存在しない可能性がある場合は .maybeSingle() を使用する
  • [DB] プレフィックスを付けてエラーをログに記録する

❌ 避けること:

  • publication_id フィルターを省略する
  • クライアントコンポーネントで supabaseAdmin を使用する
  • 理由なく SELECT * を使用する
  • エラーを無視する
  • データが存在すると決めつける
  • サービスキーをクライアントサイドに公開する

エラーからの復旧

「Row level security policy violated」と表示された場合:

  1. publication_id でフィルタリングしているか確認する
  2. クライアントではなく supabaseAdmin を使用しているか確認する
  3. サーバーサイド(API ルート/サーバーアクション)で実行していることを確認する

「column does not exist」と表示された場合:

  1. カラム名のスペルを確認する
  2. データベーススキーマにフィールドが存在するか確認する
  3. 正しいテーブルをクエリしていることを確認する

スキルの状態:アクティブなガードレール ✅ 適用レベル:ブロック(重大) 行数:< 500 ✅ 目的:マルチテナントデータの漏洩を防止 ✅

コメント