mcpField
R
recur-entitlements

recur-entitlements

github.com/recur-tw開発039 回インストール3 週間前に更新
リポジトリ

recur-entitlements について

recur-entitlementsは、Recurのサブスクリプションや購入に基づくアクセス判定を実装する開発スキルです。クライアントの読み込み状態の扱いを説明し、重要な認可判断はサーバー側で行うよう示します。

主な機能

  1. 01商品識別子によるアクセス判定
  2. 02クライアントの読み込み完了管理
  3. 03サーバー側チェックの例
  4. 04サブスクリプション状態と拒否理由
  5. 05購入後の権限情報の再取得

活用シーン

  1. 01契約プラン別の機能制御
  2. 02購入後の画面表示の更新
  3. 03サーバー側でのダウンロードや有料処理の保護

インストール

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

$npx skills add https://github.com/recur-tw/skills --skill recur-entitlements

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

詳細

Recur のエンタイトルメントとアクセス制御

Recur のエンタイトルメントシステムを使ったアクセス制御の実装をサポートします。エンタイトルメントを使うと、顧客が製品(サブスクリプションまたは買い切り商品)にアクセスできるかどうかを確認できます。

クイックスタート:クライアント側での確認

import { RecurProvider, useCustomer } from 'recur-tw'
// Your own hook, defined in "Knowing when the answer is real" below.
import { useEntitlementsReady } from './hooks/use-entitlements-ready'

// The identifier you hand the provider. Pass the same value to the readiness hook.
const customerEmail = '[email protected]'

// 1. Wrap app with provider and identify customer
function App() {
  return (
    <RecurProvider
      config={{ publishableKey: process.env.NEXT_PUBLIC_RECUR_PUBLISHABLE_KEY }}
      customer={{ email: customerEmail }}
    >
      <MyApp />
    </RecurProvider>
  )
}

// 2. Check access anywhere in your app
function PremiumFeature() {
  const { check } = useCustomer()
  const ready = useEntitlementsReady(customerEmail)

  if (!ready) return <div>Loading...</div>

  const { allowed } = check('pro-plan')

  if (!allowed) {
    return <UpgradePrompt />
  }

  return <PremiumContent />
}
判定結果が確定したタイミングを把握する

useCustomer() には「初期化済み」フラグがありません。リクエストはエフェクト内でのみ開始されるため、isLoading は最初 false です。また、リクエスト前も顧客が実際に存在しない場合も customer は null になるため、どちらを見ても「まだ取得していない」のか「その顧客が存在しない」のかは判断できません。isLoading だけを条件にすると初回描画でアップグレードを促すメッセージが一瞬表示され、!customer を条件にすると新しい顧客がいつまでもスピナーを表示し続けます。

初回の読み込み完了を自分で追跡します。

// hooks/use-entitlements-ready.ts
import { useEffect, useRef, useState } from 'react'
import { useCustomer } from 'recur-tw'

// `customerKey` is the identifier you pass to RecurProvider (email, externalId or id).
export function useEntitlementsReady(customerKey: string | null | undefined) {
  const { isLoading, error, customer } = useCustomer()
  const [ready, setReady] = useState(false)
  const [seenKey, setSeenKey] = useState(customerKey)
  const pending = useRef(false)
  const switched = useRef(false)

  // Adjusted during render, not in an effect: RecurProvider kicks off the new request
  // from an effect and leaves the old cache with `isLoading === false` until it does,
  // so an effect-only guard would still pass for one render after a switch.
  if (customerKey !== seenKey) {
    setSeenKey(customerKey)
    setReady(false)
    switched.current = true
  }

  useEffect(() => {
    if (isLoading) {
      pending.current = true
      setReady(false)          // a new fetch: the cache still holds the previous customer
    } else if (pending.current) {
      pending.current = false
      setReady(!error)         // ready only when that request actually succeeded
    }
  }, [isLoading, error])

  // A customer already in the cache proves a request finished. Without this, a gate that
  // first mounts AFTER the provider settled never sees isLoading go true, so it would wait
  // forever. Only before a switch: afterwards the cache still holds the previous customer.
  return ready || (!switched.current && !isLoading && customer !== null)
}

!error によるリセットが重要です。SDK はリクエスト失敗時に最後に成功したキャッシュを復元し、isLoading もクリアするため、これがないと失敗した取得によって以前のキャッシュに対してガードが開いてしまいます。「読み込み中」なのか「取得に失敗した」のかを判断するには、useCustomer() から error を読み取ってください。

RecurProvider に customer を渡さなかった場合、リクエストが実行されないため、これは false のままです。開発時に検出すべきケースです。

プロバイダーとともにゲートをマウントする

すべての末端コンポーネントに配置するのではなく、RecurProvider の直下にある1つのコンポーネントにガードを配置します。

function EntitlementsGate({ children }: { children: React.ReactNode }) {
  const ready = useEntitlementsReady(customerEmail)
  if (!ready) return <Spinner />
  return <>{children}</>
}

<RecurProvider config={{ publishableKey }} customer={{ email: customerEmail }}>
  <EntitlementsGate>
    <App />          {/* everything below can call check() freely */}
  </EntitlementsGate>
</RecurProvider>

プロバイダーとともにマウントされるゲートはリクエスト全体を監視します。フックが読み込みを検知するには、これが唯一の方法です。後からマウントされるゲートは、フックの最後の行にあるキャッシュのフォールバックに依存しますが、顧客が Recur に存在しない場合には役立ちません。エンタイトルメントのエンドポイントは、「その顧客が存在しない」場合も「まだ取得していない」場合も customer: null を含む 200 を返すため、不明な顧客に対する遅れてマウントされたゲートは待機し続けます。

このフックでできないこと

顧客の切り替えを安全にすることはできません。RecurProvider にはリクエストの順序制御がありません。識別子が変わると、最初のリクエストがまだ進行中でも2つ目のリクエストが始まり、最後に完了したリクエストの結果が採用されます。前の顧客のレスポンスが新しい顧客のキャッシュを上書きし、共有の isLoading フラグをクリアする可能性があります。その時点で、このフックは誤ったデータに対して準備完了を報告します。レンダー時のリセットで影響を受ける時間を短くできますが、SDK の外部からこの問題を完全になくすことはできません。

そのため、1つのセッション内で顧客を切り替えられる場合、クライアントのキャッシュを認可の判断に使わないでください。秘密鍵を保持し、対象の顧客を読み取れるサーバー上で確認してください。切り替え時にアプリを再マウントする方法でも、リクエストの重複を避けられます。

顧客の識別

次のいずれかの方法で顧客を識別します。

// By email (most common)
<RecurProvider customer={{ email: '[email protected]' }}>

// By your system's user ID
<RecurProvider customer={{ externalId: 'user_123' }}>

// By Recur customer ID
<RecurProvider customer={{ id: 'cus_xxx' }}>

アクセスの確認

同期確認(キャッシュ済み)

高速で、キャッシュ済みデータを使用します。UI のレンダリングに適しています。

const { check } = useCustomer()

// Check by product slug
const { allowed, entitlement } = check('pro-plan')

// Check by product ID (a CUID from list_products, e.g. 'cmfxq8n2a0001l8yz3k5p9t7d'; 'prod_xxx' is a placeholder)
const { allowed } = check('prod_xxx')

if (allowed) {
  // User has access
  // entitlement contains details like status, expiresAt
}
非同期確認(最新データ)

回答する前に再取得します。有用ですが、確定的な判断には使えません。詳しくは以下の警告を参照してください。

const { check } = useCustomer()

// Refetches, then answers
const { allowed, entitlement } = await check('pro-plan', { live: true })

// Good for:
// - Refreshing the UI after checkout
// - When cached data is probably stale

{ live: true } は前回のスナップショットに基づいて回答することがあります。 現在の SDK では、再取得を待った後、呼び出し元のレンダーで取得された同期チェックを呼び出すため、新しいデータはキャッシュに保存されますが、そのデータに基づいて回答が計算されるわけではありません。購入直後でも false が返ることがあります。

アクセスの許可、ダウンロードの提供、有料作業の開始など、重要な処理のゲートとして決して使用しないでください。代わりに、秘密鍵を使ってサーバー上で確認します。クライアント側での { live: true } は、ユーザーに表示する情報を更新するためのものであり、判断を下すためのものではありません。

手動での再取得
const { refetch } = useCustomer()

// After checkout completion
onPaymentComplete: async () => {
  await refetch() // Refresh entitlements
  router.push('/dashboard')
}

エンタイトルメントのレスポンス構造

interface CheckResult {          // what check() returns
  allowed: boolean
  reason?: 'no_customer' | 'no_entitlement' | 'not_found' | 'expired' | 'insufficient_balance'
  entitlement?: Entitlement
  balance?: number               // CREDITS products
  unlimited?: boolean
  subscription?: { id: string; status: string; product: { id: string; slug: string; name: string }; currentPeriodEnd: string }
}

interface Entitlement {
  product: string        // Product slug
  productId: string      // Product ID (CUID)
  status: EntitlementStatus
  source: 'subscription' | 'order'  // How they got access
  sourceId: string       // Subscription/Order ID
  grantedAt: string      // When access was granted
  expiresAt: string | null  // When access expires (null = permanent)
  subscriptionId?: string
}

type EntitlementStatus =
  | 'active'      // Subscription active
  | 'trialing'    // In trial period
  | 'past_due'    // Payment failed, in grace period
  | 'canceled'    // Cancelled but access until period end
  | 'purchased'   // One-time purchase (permanent)

サーバー側での確認

サーバー SDK の使用
import { Recur } from 'recur-tw/server'

const recur = new Recur(process.env.RECUR_SECRET_KEY!)

// In API route or server action
async function checkAccess(userEmail: string) {
  // Server EntitlementCheckResult is { allowed, subscription? } — there is no
  // `entitlement` field here (that one belongs to the React check()).
  const { allowed, subscription } = await recur.entitlements.check({
    product: 'pro-plan',
    customer: { email: userEmail },
  })

  if (!allowed) {
    throw new Error('Upgrade required')
  }

  return subscription
}
REST API を直接使用
// GET /v1/customers/entitlements
const response = await fetch(
  `https://api.recur.tw/v1/customers/entitlements?email=${encodeURIComponent(email)}`,
  {
    headers: {
      'X-Recur-Secret-Key': process.env.RECUR_SECRET_KEY!,
    },
  }
)

// REST responses are snake_case (the API wrapper converts them), unlike the SDK's
// camelCase types: customer { id, email, name, external_id }, subscription | null,
// entitlements[] with product_id / granted_at / expires_at / source_id.
const { customer, subscription, entitlements } = await response.json()

よくあるパターン

ペイウォールコンポーネント
function Paywall({
  children,
  product,
  customerKey,
  fallback
}: {
  children: React.ReactNode
  product: string
  /** The identifier passed to RecurProvider, so the guard closes on a customer switch. */
  customerKey: string | null | undefined
  fallback?: React.ReactNode
}) {
  const { check } = useCustomer()
  const ready = useEntitlementsReady(customerKey)

  if (!ready) {
    return <div>Loading...</div>
  }

  const { allowed } = check(product)

  if (!allowed) {
    return fallback || <UpgradePrompt product={product} />
  }

  return <>{children}</>
}

// Usage
<Paywall product="pro-plan" customerKey={customerEmail}>
  <PremiumDashboard />
</Paywall>
フラグによる機能制御
function useFeature(featureProduct: string, customerKey: string | null | undefined) {
  const { check, error } = useCustomer()
  const ready = useEntitlementsReady(customerKey)

  // `isLoading` alone is not enough here either: it starts false, so the first paint
  // would read an empty cache and report the feature as off.
  if (!ready) {
    return { enabled: false, loading: !error, error, entitlement: undefined }
  }

  const { allowed, entitlement } = check(featureProduct)

  return {
    enabled: allowed,
    loading: false,
    error: null,
    entitlement,
    isTrial: entitlement?.status === 'trialing',
    isPastDue: entitlement?.status === 'past_due',
  }
}

// Usage
function MyComponent() {
  const { enabled, isTrial } = useFeature('pro-plan', customerEmail)

  if (!enabled) return <UpgradeButton />

  return (
    <>
      {isTrial && <TrialBanner />}
      <ProFeature />
    </>
  )
}
API ミドルウェア
// middleware/requireSubscription.ts
import { Recur } from 'recur-tw/server'

const recur = new Recur(process.env.RECUR_SECRET_KEY!)

export async function requireSubscription(
  req: Request,
  product: string
) {
  const userEmail = await getUserEmail(req) // Your auth logic

  const { allowed } = await recur.entitlements.check({
    product,
    customer: { email: userEmail },
  })

  if (!allowed) {
    throw new Response(JSON.stringify({ error: 'Subscription required' }), {
      status: 403,
      headers: { 'Content-Type': 'application/json' },
    })
  }
}

// Usage in API route
export async function GET(req: Request) {
  await requireSubscription(req, 'pro-plan')

  // User has access, continue...
  return Response.json({ data: 'premium content' })
}
複数の製品ティア
function PricingGate({ customerKey }: { customerKey: string | null | undefined }) {
  const { check } = useCustomer()
  const ready = useEntitlementsReady(customerKey)

  if (!ready) return <div>Loading...</div>

  const hasPro = check('pro-plan').allowed
  const hasEnterprise = check('enterprise-plan').allowed

  if (hasEnterprise) {
    return <EnterpriseDashboard />
  }

  if (hasPro) {
    return <ProDashboard />
  }

  return <FreeDashboard />
}

エッジケースへの対応

支払い遅延中のサブスクリプション
const { allowed, entitlement } = check('pro-plan')

if (allowed && entitlement?.status === 'past_due') {
  // Show warning but allow access during grace period
  return (
    <>
      <PaymentFailedBanner />
      <PremiumContent />
    </>
  )
}
トライアル中のサブスクリプション
const { entitlement } = check('pro-plan')

if (entitlement?.status === 'trialing') {
  const trialEnds = new Date(entitlement.expiresAt!)
  const daysLeft = Math.ceil((trialEnds.getTime() - Date.now()) / (1000 * 60 * 60 * 24))

  return <TrialBanner daysLeft={daysLeft} />
}
解約済みだが有効な状態
const { entitlement } = check('pro-plan')

if (entitlement?.status === 'canceled') {
  // User cancelled but still has access until period end
  return (
    <>
      <ResubscribeBanner expiresAt={entitlement.expiresAt} />
      <PremiumContent />
    </>
  )
}

拒否理由

allowed が false の場合、CheckResult.reason に理由が示されます('no_customer' | 'no_entitlement' | 'not_found' | 'expired' | 'insufficient_balance')。

const { allowed, reason } = check('pro-plan')

if (!allowed) {
  switch (reason) {
    case 'no_customer':
      // Customer not found
      return <CreateAccountPrompt />

    case 'no_entitlement':
      // No subscription to this product
      return <SubscribePrompt />

    case 'expired':
      // Subscription/access expired
      return <RenewPrompt />

    case 'insufficient_balance':
      // For credit-based products
      return <BuyCreditsPrompt />

    default:
      return <GenericUpgradePrompt />
  }
}

ベストプラクティス

  1. UI ではキャッシュされたチェックを使用する - 高速に描画でき、優れた UX を実現できます
  2. サーバーで判断する - アクセスを許可する処理、ファイルを公開する処理、有料の作業を開始する処理は、すべてシークレットキーを使用してチェックします。{ live: true } は UI を更新しますが、以前のスナップショットに基づいて応答する場合があるため、認可の判断には使用できません
  3. すべてのステータスに対応する - active、trialing、past_due、canceled
  4. チェックアウト後に再取得する - 購入後に UI が更新されるようにします
  5. 適切なフォールバックを実装する - エラーではなく、アップグレードを促すメッセージを表示します

関連スキル

  • /recur-quickstart - SDK の初期設定
  • /recur-checkout - 購入フローの実装
  • /recur-webhooks - Webhook を使用したエンタイトルメントの同期

コメント