npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@feelflow/ffid-sdk

v9.0.0

Published

FeelFlow ID Platform SDK for React/Next.js applications

Downloads

1,808

Readme

@feelflow/ffid-sdk

npm version License: MIT

FeelFlow ID Platform SDK — React/Next.js 向け + サーバーサイドモジュールはフレームワーク非依存。

5行のコードでFFID認証を導入!

インストール

npm install @feelflow/ffid-sdk
# or
yarn add @feelflow/ffid-sdk
# or
pnpm add @feelflow/ffid-sdk

統合ガイド

サービスを FFID Platform に統合する詳細なガイドは Integration Guide を参照してください。OAuth フロー、フロントエンド/バックエンド実装パターン、セキュリティチェックリスト、アンチパターン集を網羅しています。

Cookie 同意管理基盤 (v5.0.0+)

GDPR / ePrivacy / 改正電気通信事業法 / APPI 準拠の Cookie 同意 UI を 15 分以内 に導入できます (opt-in、v4.x 完全互換)。

// app/layout.tsx (App Router、最短 3 ステップ)
import {
  FFIDProvider,
  FFIDAnalyticsProvider,
  FFIDCookieBanner,
  DEFAULT_OAUTH_SCOPES,
} from '@feelflow/ffid-sdk'

export default function RootLayout({ children }) {
  return (
    <FFIDProvider serviceCode="your-service" scope={DEFAULT_OAUTH_SCOPES}>
      <FFIDAnalyticsProvider
        baseUrl={process.env.NEXT_PUBLIC_FFID_BASE_URL!}
        serviceApiKey={process.env.FFID_SERVICE_API_KEY!}
        gaMeasurementId={process.env.NEXT_PUBLIC_GA_ID ?? null}
        // Production-only measurement (v5.16.0+): suppress GA on
        // preview/staging/local so non-prod traffic never pollutes the
        // production GA4 property. Defaults to true if omitted.
        gaEnabled={process.env.VERCEL_ENV === 'production'}
      >
        {children}
        <FFIDCookieBanner />
      </FFIDAnalyticsProvider>
    </FFIDProvider>
  )
}

クイックスタート

1. プロバイダーを設定(5行で完了!)

// app/layout.tsx
import { FFIDProvider, DEFAULT_OAUTH_SCOPES } from '@feelflow/ffid-sdk'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        <FFIDProvider serviceCode="chatbot" scope={DEFAULT_OAUTH_SCOPES}>{children}</FFIDProvider>
      </body>
    </html>
  )
}

2. 認証情報を使用

import { useFFID, useSubscription } from '@feelflow/ffid-sdk'

function Dashboard() {
  const { user, isAuthenticated, login, logout } = useFFID()
  const { isActive, planCode } = useSubscription()

  if (!isAuthenticated) {
    return <button onClick={login}>ログイン</button>
  }

  return (
    <div>
      <p>Welcome, {user.displayName ?? user.email}!</p>
      <p>プラン: {planCode}</p>
      <button onClick={logout}>ログアウト</button>
    </div>
  )
}

UIコンポーネント

import {
  FFIDLoginButton,
  FFIDUserMenu,
  FFIDOrganizationSwitcher,
  FFIDSubscriptionBadge,
} from '@feelflow/ffid-sdk/components'

function Header() {
  return (
    <header>
      <FFIDLoginButton>ログイン</FFIDLoginButton>
      <FFIDOrganizationSwitcher />
      <FFIDSubscriptionBadge />
      <FFIDUserMenu />
    </header>
  )
}

コンポーネントのスタイリング(classNames パターン)

各UIコンポーネントは Radix UI スタイルパターン に従った classNames プロップをサポートしています。これにより、コンポーネントの各パーツに個別のクラスを適用できます。

FFIDUserMenu

<FFIDUserMenu
  classNames={{
    container: 'relative',          // ラッパー要素
    button: 'focus:ring-2',         // アバターボタン(トリガー)
    avatar: 'rounded-full',         // アバター画像/フォールバック
    menu: 'shadow-lg',              // ドロップダウンメニュー
    userInfo: 'border-b',           // ユーザー情報セクション
    menuItem: 'hover:bg-gray-100',  // カスタムメニュー項目
    logout: 'text-red-600',         // ログアウトボタン
  }}
/>

FFIDOrganizationSwitcher

<FFIDOrganizationSwitcher
  classNames={{
    container: 'relative',           // ラッパー要素
    button: 'border rounded',        // トリガーボタン
    dropdown: 'shadow-md',           // ドロップダウンメニュー
    option: 'px-4 py-2',             // 各組織オプション
    optionSelected: 'bg-blue-50',    // 選択中の組織(optionに加えて適用)
  }}
/>

FFIDSubscriptionBadge

<FFIDSubscriptionBadge
  classNames={{
    badge: 'font-semibold',  // バッジspan要素
  }}
/>

Note: FFIDLoginButton はシンプルな単一要素コンポーネントのため、標準の className プロップのみをサポートしています。

API リファレンス

FFIDProvider

アプリケーション全体をラップするプロバイダーコンポーネント。

<FFIDProvider
  serviceCode="chatbot"               // 必須: サービスコード
  scope={DEFAULT_OAUTH_SCOPES}        // 必須: OAuth scope (v3.0.0+)
  apiBaseUrl="..."                    // オプション: カスタムAPIエンドポイント
  debug={true}                        // オプション: デバッグログ有効化(非推奨、loggerを使用)
  logger={customLogger}               // オプション: カスタムロガー(下記参照)
  refreshInterval={300000}            // オプション: セッション更新間隔(ms)
  onAuthStateChange={(user) => {}}    // オプション: 認証状態変更時コールバック
  onError={(error) => {}}             // オプション: エラー時コールバック
  cleanCallbackUrl={true}             // オプション: token モードの callback URL cleanup(false で consumer に委譲、v5.22.0+)
>
  {children}
</FFIDProvider>

カスタムロガー

SDKのデバッグ出力をカスタマイズできます。デフォルトではログは出力されません(サイレント)。

import type { FFIDLogger } from '@feelflow/ffid-sdk'
import pino from 'pino' // または winston, bunyan 等

// アプリケーションのロガーインスタンス
const appLogger = pino({ level: 'debug' })

// FFID SDK用にラップ
const ffidLogger: FFIDLogger = {
  debug: (...args) => appLogger.debug({ sdk: 'ffid' }, ...args),
  info: (...args) => appLogger.info({ sdk: 'ffid' }, ...args),
  warn: (...args) => appLogger.warn({ sdk: 'ffid' }, ...args),
  error: (...args) => appLogger.error({ sdk: 'ffid' }, ...args),
}

// 使用例
<FFIDProvider serviceCode="chatbot" scope={DEFAULT_OAUTH_SCOPES} logger={ffidLogger}>
  {children}
</FFIDProvider>

ロガー優先順位:

  1. logger が指定されている場合 → カスタムロガーを使用
  2. debug: true で logger なし → console を使用(後方互換性)
  3. 両方なし → サイレント(no-op)

useFFID()

ユーザー・組織情報を取得するフック。

const {
  user,               // FFIDUser | null - 現在のユーザー
  organizations,      // FFIDOrganization[] - 所属組織一覧
  currentOrganization, // FFIDOrganization | null - 現在の組織
  isLoading,          // boolean - ロード中
  isAuthenticated,    // boolean - 認証済み
  login,              // () => void - ログインページへリダイレクト
  logout,             // () => Promise<void> - ログアウト
  switchOrganization, // (id: string) => void - 組織切り替え
  refresh,            // () => Promise<void> - セッション更新
} = useFFID()

useSubscription()

契約情報を取得するフック。

const {
  subscription,     // FFIDSubscription | null - 現在のサブスクリプション
  planCode,         // string | null - プランコード
  isActive,         // boolean - DB ステータスが 'active'
  isTrialing,       // boolean - トライアル中
  isCanceled,       // boolean - 解約済み
  isTrialExpired,   // boolean - トライアル期間超過
  effectiveStatus,  // EffectiveSubscriptionStatus | null - 意味論的アクセス制御値
  isBlocked,        // boolean - blocked / expired / canceled / trial_expired
  isGrace,          // boolean - past_due_grace (支払い失敗の猶予期間中)
  hasPlan,          // (plans: string | string[]) => boolean - プラン確認
  hasAccess,        // () => boolean - アクセス権確認 (active || past_due_grace)
  hasAccessLegacy,  // () => boolean - 旧セマンティクス (active || trialing, pre-2.19)
} = useSubscription()

effectiveStatus は /api/v1/subscriptions/ext/check が返す意味論的ステータスと同じ値を取る。詳細は 契約期限切れハンドリング を参照。

useRequireActiveSubscription()

契約が期限切れ/遮断状態のときに自動でリダイレクトするフック。

'use client'
import { useRouter } from 'next/navigation'
import { useRequireActiveSubscription } from '@feelflow/ffid-sdk'

function ProtectedShell({ children }: { children: React.ReactNode }) {
  const router = useRouter()
  const { loading } = useRequireActiveSubscription({
    redirectTo: (status) =>
      status === 'canceled' ? '/contract-ended' : '/contract-required',
    onRedirect: (url) => router.replace(url),
  })

  if (loading) return <FullPageSpinner />
  return <>{children}</>
}

オプション:

  • redirectTo: string または (status: EffectiveSubscriptionStatus) => string
  • allowGrace (default: true): past_due_grace を通過させるか
  • onRedirect (optional): 独自のリダイレクト関数(未指定時は window.location.href)

withSubscription()

サブスクリプション確認HOC。

const PremiumFeature = withSubscription(MyComponent, {
  plans: ['pro', 'enterprise'],
  fallback: <UpgradePrompt />,
  loading: <Spinner />,
})

契約期限切れハンドリング

契約が失効したり解約されたとき、外部サービス側で適切にアクセスを遮断し、ユーザーを再契約動線に案内する必要があります。SDK は 3 層構成(トークン検証 / 契約チェック / Webhook 受信)をサポートしています。

最小構成(Next.js App Router)

'use client'
import { useRouter } from 'next/navigation'
import { useRequireActiveSubscription } from '@feelflow/ffid-sdk'

export default function Layout({ children }: { children: React.ReactNode }) {
  const router = useRouter()
  const { loading, effectiveStatus } = useRequireActiveSubscription({
    redirectTo: '/contract-required',
    onRedirect: (url) => router.replace(url),
  })

  if (loading) return <Spinner />
  if (effectiveStatus !== 'active' && effectiveStatus !== 'past_due_grace') {
    return null // リダイレクト発火中
  }
  return <>{children}</>
}

詳細な実装レシピ(middleware、Express、UI 分岐、Webhook 受信、fixture API を使ったテスト、EffectiveSubscriptionStatus 別の UI 推奨動作まで) → docs/03-implementation/EXPIRED_CONTRACT_HANDLING.md

Server-side service access decision

API route / middleware では checkServiceAccess() を使う。FFID の /api/v1/subscriptions/ext/check が返す canonical decision をそのまま受け取り、外部サービス側で past_due_since + 7d、current_period_end + 7d、payment_failed_at + 7d のような lifecycle date math を再実装しない。

import { createFFIDClient } from '@feelflow/ffid-sdk'

const ffid = createFFIDClient({
  serviceCode: 'flow-board-ai',
  scope: '',
  authMode: 'service-key',
  serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
})

const { data: access, error } = await ffid.checkServiceAccess({
  userId,
  organizationId,
  allowGrace: true,
})

if (error || !access?.hasAccess) {
  return Response.redirect('/contract-required')
}
  • checkServiceAccess() は data.hasAccess が唯一の gate。result.error は入力 validation など SDK が decision を作れない場合にだけ返る。
  • hasAccess: FFID が決めた canonical access decision。allowGrace=false のときだけ SDK 側で past_due_grace を deny に変換する。
  • effectiveStatus: active / past_due_grace / blocked / canceled / trial_expired / expired。
  • gracePeriodEndsAt: past_due_grace が blocked に変わる時刻。表示・警告用であり、アクセス判定の source of truth にしない。
  • failPolicy: 現在は failClosed 固定。FFID に到達できない、または canonical decision を取得できない場合は result.error ではなく data.hasAccess=false / denialReason='ffid_unreachable' / data.error を返す。呼び出し側は result.error だけで通過判定しない。

Organization member management

organization:read / organization:write scope を持つ service-key または token で、組織メンバーの参照・追加・ロール変更・削除ができます。

import { createFFIDClient } from '@feelflow/ffid-sdk/server'

const ffid = createFFIDClient({
  serviceCode: 'flow-board-ai',
  scope: '',
  authMode: 'service-key',
  serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
})

const addResult = await ffid.addMember({
  organizationId,
  email: '[email protected]',
  role: 'member',
})

if (addResult.error) {
  throw new Error(addResult.error.message)
}

await ffid.updateMemberRole({
  organizationId,
  userId: addResult.data.member.userId,
  role: 'admin',
})
  • addMember は既存 FFID ユーザーを active member として追加します。招待メール送信や token 発行は行いません。
  • role は admin / member / viewer のみ指定可能です。owner は追加・昇格 API では扱いません。
  • agency client と同じ呼び味が必要な場合は ffid.addMember(organizationId, { email, role }) も使えます。

Organization profile / public logo wall

hub 等の外部サービスが企業プロフィールを登録・照会し、FeelFlow 全サービス共通のロゴウォールを表示するためのメソッド(#5485 / #5547)。

  • getOrganizationProfile / upsertOrganizationProfile / uploadOrganizationLogo は authMode: 'service-key' 専用(scope: organization:read / organization:write)。token / cookie モードで呼ぶとサーバーへ往復せず VALIDATION_ERROR。
  • getPublicLogoWall は認証不要。service-key を送らない。エンドポイントは GET /api/v1/organizations/logo-wall(公開 API なので SDK を使わず直接 GET してもよいが、型付きエラーが欲しければ SDK 経由を推奨)。
  • upsert の listingConsent.consented: true では consentVersion と consentedByUserId が必須。同意操作者が対象 org の active メンバーでないとサーバーが CONSENTED_BY_NOT_MEMBER を返す。
import {
  createFFIDClient,
  FFID_ORGANIZATION_PROFILE_ERROR_CODES,
} from '@feelflow/ffid-sdk/server'

const ffid = createFFIDClient({
  serviceCode: 'feel-agent',
  scope: '',
  authMode: 'service-key',
  serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
})

const current = await ffid.getOrganizationProfile(organizationId)
if (current.error) throw new Error(current.error.message)

const upsert = await ffid.upsertOrganizationProfile(organizationId, {
  displayName: '株式会社サンプル',
  industry: 'it-software',
  companySize: '11-50',
  websiteUrl: 'https://example.co.jp',
  listingConsent: {
    consented: true,
    consentVersion: 'v1.0',
    consentedByUserId: userId,
  },
})
if (upsert.error?.code === FFID_ORGANIZATION_PROFILE_ERROR_CODES.CONSENTED_BY_NOT_MEMBER) {
  throw new Error(upsert.error.message)
}

await ffid.uploadOrganizationLogo(organizationId, logoFile)

const wall = await ffid.getPublicLogoWall()
// wall.data.entries: { displayName, logoUrl, websiteUrl }[]

位置引数と params オブジェクトの両方を受け付ける(addMember と同じ)。

Provisioning(users / organizations)

外部サービスからの一括移行向けに、service-key 認証でユーザー・組織を冪等にプロビジョニングするメソッド。REST を直叩きせず createFFIDClient 経由で呼べる(SDK-first、#3790 / #4127)。

  • authMode: 'service-key'(X-Service-Api-Key)専用。token(Bearer)/ cookie モードで呼ぶとサーバーへ往復せず即座に VALIDATION_ERROR を返す(サーバー側エンドポイントが service-key 認証のみを受け付けるため)。
  • 必要 scope: provisionUser → user:provision、provisionOrganization → organization:write。
import { createFFIDClient } from '@feelflow/ffid-sdk/server'

const ffid = createFFIDClient({
  serviceCode: 'praxis',
  scope: '',
  authMode: 'service-key',
  serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
})

// 1. ユーザーを冪等に作成(新規なら created:true / 既存なら created:false)
const userRes = await ffid.provisionUser({
  email: '[email protected]',
  profile: { displayName: '山田 太郎' },
})
if (userRes.error) throw new Error(userRes.error.message)
if (!userRes.data.dryRun) {
  console.log(userRes.data.created ? '新規作成' : '既存ユーザー', userRes.data.user.id)
}

// 2. その owner の組織を作成し、メンバーを冪等に追加
const orgRes = await ffid.provisionOrganization({
  name: 'Example 組織',
  ownerEmail: '[email protected]',
  members: [
    { email: '[email protected]', role: 'member' },
    { email: '[email protected]', role: 'viewer' },
  ],
})
if (orgRes.error) {
  // owner が未登録なら error.code === 'OWNER_NOT_FOUND'(先に provisionUser が必要)
  throw new Error(orgRes.error.message)
}
if (!orgRes.data.dryRun) {
  for (const m of orgRes.data.members) {
    console.log(m.email, m.status) // 'added' | 'already_member' | 'user_not_found'
  }
}

冪等セマンティクス

  • provisionUser: 既存メールは created: false(HTTP 200、no-op)、新規メールは created: true(HTTP 201、パスワードレス・メール確認済み)。メールはサーバー側で normalize(trim + lowercase)されるため、大文字小文字違いの再送でも同一ユーザーに解決する。新規作成で profile を渡した場合のみ profileWritten が付き、false は「ユーザーは作成されたがプロフィール書き込みに失敗」→ 再送を推奨。
  • provisionOrganization: owner が同名の組織を既に持つ場合は created: false(HTTP 200)。members は冪等に追加され、各要素の status が added / already_member / user_not_found を返す。

dryRun

dryRun: true を渡すと 一切書き込まず、実行時に何が起きるかだけを返す。レスポンスは dryRun 判別のタグ付きユニオン。

const preview = await ffid.provisionUser({ email: '[email protected]', dryRun: true })
if (!preview.error && preview.data.dryRun) {
  console.log(preview.data.wouldCreate) // true = 実行すれば新規作成される
}

const orgPreview = await ffid.provisionOrganization({
  name: 'Example 組織',
  ownerEmail: '[email protected]',
  members: [{ email: '[email protected]', role: 'member' }],
  dryRun: true,
})
if (!orgPreview.error && orgPreview.data.dryRun) {
  console.log(orgPreview.data.wouldCreate) // 組織を新規作成するか
  // per-member plan status: 'would_add' | 'already_member' | 'user_not_found'
  orgPreview.data.members.forEach((m) => console.log(m.email, m.status))
}

if (res.data.dryRun) で TypeScript が型を絞り込むため、dry-run 分岐では wouldCreate、実行分岐では created がそれぞれ型安全に参照できる。

getProfile() / updateProfile()

ログイン中ユーザー自身のプロフィールを取得・更新するメソッド(createFFIDClient から呼び出し)。

  • エンドポイント: GET /api/v1/users/ext/me / PUT /api/v1/users/ext/me
  • 対応 authMode: token(Bearer)のみが SDK 経由で成立する
    • cookie モードは非対応 — ext エンドポイントはクロスオリジン用途のため、Bearer token または X-Service-Api-Key のどちらかが必須。FFID 自身の UI(同一オリジン)は従来通り /api/v1/users/me を使う
    • service-key モードも SDK 経由では非対応 — API Key 認証時はバックエンドが ?userId=<uuid> クエリを要求するが、getProfile() / updateProfile() は自分自身(ambient user)を前提とするため、userId を受け取らない。サーバー間で任意ユーザーのプロフィールを操作したい場合は fetchWithAuth で直接エンドポイントを叩いてください
  • 外部サービス(hub 等)のフロントエンドから token モードで呼ぶのが想定される主要パターン
import { createFFIDClient } from '@feelflow/ffid-sdk'

const client = createFFIDClient({
  serviceCode: 'hub',
  authMode: 'token',
  apiBaseUrl: 'https://id.feelflow.net',
})

// 取得
const { data: profile, error } = await client.getProfile()
if (error) {
  console.error('プロフィール取得失敗:', error.message)
} else {
  console.log(profile.email, profile.displayName, profile.timezone)
}

// 更新(部分更新 — 渡したフィールドだけ差し替え)
const { data: updated, error: updateError } = await client.updateProfile({
  displayName: '山田 太郎',
  timezone: 'Asia/Tokyo',
  locale: 'ja',
  preferences: { theme: 'dark' },
})

updateProfile に空オブジェクト {} を渡すと VALIDATION_ERROR が返ります(無意味なラウンドトリップを防止)。

フィールドのクリア(null を渡す)

対応する optional フィールドに null を渡すと、FFID backend 側で該当カラムを SQL NULL にクリアします(v2.16.0〜 / #2354)。個人プロフィールの companyName は廃止予定で、旧クライアントからの値は受理しても無視します。

// 部署をクリア
await client.updateProfile({
  department: null,
})

値のセマンティクス:

| 渡す値 | FFID backend の挙動 | | --- | --- | | undefined / キー未指定 | 未変更(partial update) | | null | クリア(SQL NULL を書き込む) | | ""(空文字列) | 空文字リテラルをそのまま保存(null 扱いにはならない) |

対応フィールド: displayName / phone / department / jobTitle / preferences。timezone / locale は application-level invariant(サーバー側 normalization が string 前提)のため null 非許容。クリアは不可 — キー未指定で現状維持、もしくは新しい有効な値を渡す。

getAnalyticsConfig() (#2347)

外部サービスが自身に割り当てられた GA4 Measurement ID を取得するメソッド。

  • エンドポイント: GET /api/v1/ext/analytics/config?service=<code>
  • 必要 scope: analytics:read(Bearer / API Key 両方で enforce)
  • レスポンス: FFIDAnalyticsConfig ({ code, measurementId, displayName, isActive })
  • archived service (isActive: false) でも 200 を返す — caller が次回 deploy で tracking 停止判断する間、in-flight events が 5xx を起こさないように measurementId は引き続き返す
import { createFFIDClient } from '@feelflow/ffid-sdk/server'

const client = createFFIDClient({
  serviceCode: 'flow-board-ai',
  authMode: 'service-key',
  serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
})

const result = await client.getAnalyticsConfig('feel-agent-ai')
if (result.error) {
  if (result.error.code === 'SERVICE_NOT_FOUND') {
    // 該当 GA4 stream がまだ sync されていない / typo
  } else {
    console.error(`[FFID] analytics config fetch failed: ${result.error.code}`)
  }
} else if (result.data.isActive) {
  // GA4 タグを描画して events を送信
  loadGA4Script(result.data.measurementId)
}

エラーコード:

  • VALIDATION_ERROR — serviceCode が空 / kebab-case 形式違反(SDK 側 pre-validate)
  • INSUFFICIENT_SCOPE (403) — analytics:read scope なし
  • SERVICE_NOT_FOUND (404) — DB に未登録の service code
  • INVALID_PARAM (400) — server 側で形式違反を検出(pre-validate 通過後の boundary)

代理店の招待リンク(?invite=)

代理店の招待リンクから来た顧客が FFID で登録すると、作られた組織がその代理店に紐付きます。SDK の役割はトークンを signup URL まで運ぶことだけで、紐付けの実務は FFID 側が行います。

代理店ポータルが発行するリンクは FFID の origin(https://id.feelflow.net/signup?invite=…)を指します。サービスサイトを着地点にする形(https://<あなたのサービス>/?invite=…)は、代理店がトークンを自分のキャンペーン URL に貼って成立させる導線です。この節が効くのはその形です。

React 利用者(FFIDProvider を mount している場合)

配線は不要です。 provider が mount 時に着地 URL の invite を捕捉し、first-party cookie へ保存します。getSignupUrl() が自動でトークンを載せます。

ただし provider は捕捉結果を返さないので、cookie の保持に失敗したことを利用者側で観測する経路はありません(debug ログには落ちます)。検知したい場合は provider に加えて自分で captureAgencyInviteToken() を呼び、戻り値の persisted を見てください。

React 以外 / provider を使わない場合

captureAgencyInviteToken() をページ遷移ごとに呼びます。

import { captureAgencyInviteToken } from '@feelflow/ffid-sdk'

// ページ読み込みごと(SPA ならルート遷移ごと)に呼ぶ
const { token, source, persisted } = captureAgencyInviteToken()

if (persisted === false) {
  // cookie がブラウザに落とされた。今回の URL 経由では紐付くが、
  // パラメータの無い別ページへ回遊すると失われる。
} else if (persisted === undefined && source === 'url') {
  // 書ける環境が無かった(SSR)。ブラウザ側で捕捉し直す必要がある。
}

呼ばなかった場合は「着地ページで即 signup した人だけ紐付く」まで劣化します(壊れるのではなく、下記の回遊経路を救えなくなります)。

なぜページ遷移ごとに呼ぶ必要があるのか

実際の導線は次の形です。

/?invite=abc に着地 → 料金ページを見る → /pricing で「登録」を押す

getSignupUrl() が呼ばれる時点の URL には invite がありません。着地時点で捕捉して保持する以外にこの経路を救う方法がなく、SDK 自身はページ読み込みを hook できないため、起動点は利用者側にあります。

⚠️ 効かない経路: SSR(Server Component)で signup URL を組む場合

サーバーには window.location.search も document.cookie もありません。getSignupUrl() をそのまま呼ぶとトークンは載らないので、呼び出し側が読んだ値を渡してください(渡さない場合はプロセスあたり 1 度 warn します)。

// Server Component
import { cookies } from 'next/headers'
import { AGENCY_INVITE_COOKIE_NAME } from '@feelflow/ffid-sdk'

const inviteToken = (await cookies()).get(AGENCY_INVITE_COOKIE_NAME)?.value
const href = ffid.getSignupUrl(undefined, { inviteToken })

authMode: 'token'(OAuth モード)は v7.4.0 で対応済み

この設定では signup 意図が /api/v1/oauth/authorize を経由するため getSignupUrl() を通りません。v7.4.0 以降は redirectToAuthorize が invite を authorize URL へ転送し、FFID 側が /signup へ引き継ぎます(redirectToLogin({ screenHint: 'signup' }) は token モードでは redirectToAuthorize へ委譲されるので、こちらも同様に載ります)。配線は不要です。

v7.3.x までは token モードでトークンが FFID に 1 度も届かず、紐付けが無言で落ちていました。v7.4.0 未満を使っている場合は、SDK と FFID 本体の両方を更新してください — 転送は SDK が invite を送り、FFID が /signup へ stamp する 2 段で成立します。

⚠️ 転送の gate は screenHint === 'signup' です。prompt に 'create' を指定して signup 画面へ向かう経路は SDK の FFIDPrompt('select_account' | 'login')が受け付けないため到達しません。

cookie の仕様

| 項目 | 値 | |---|---| | 名前 | ffid_invite(first-party・consumer 自身のホスト) | | 有効期限 | 30 日 | | Path | / | | SameSite | Lax | | Secure | NODE_ENV === 'production' のときだけ(options.secure で上書き可) | | Domain | 既定は現在のホストのみ(options.domain で指定可) |

Secure を本番限定にしているのは、https でない独自ドメイン(LAN IP や plain http の検証機)でブラウザが Secure cookie を黙って捨てるためです。http://localhost は secure context として扱われるので Secure でも保持されます(ローカル開発では上書き不要)。

URL と cookie の両方にトークンがある場合は URL が勝ち、cookie も更新されます(最後に踏んだリンクが勝つ)。

削除には書き込み時と同じ path / domain / secure が必要です。 ブラウザが一致を要求するため、既定以外で書いた cookie は既定の clearAgencyInviteToken() では消えません。

⚠️ 保持期間 30 日の副作用

招待リンクは既定で使用回数が無制限です。したがって保持中の cookie は、同じブラウザで後から作られた組織も同じ代理店に紐付けます。共有ブラウザではこれが誤紐付けになります。

SDK は登録完了時に cookie を消しません(登録完了は FFID のドメインで起こるため consumer から検知しにくい)。単回にしたい場合は、登録完了を検知できる地点で明示的に消してください。

import { clearAgencyInviteToken } from '@feelflow/ffid-sdk'

clearAgencyInviteToken() // 削除が read-back で確認できたら true / SSR では undefined

⚠️ トークンをログに出さないこと

このトークンは URL に載る平文の資格情報で、単回使用ではありません(招待リンクの使用回数上限は既定で無制限)。漏れたトークンは、新規組織が作られる限り何度でも紐付けに使えます。

SDK 内部ではログ・エラーレポート・アナリティクスに値を出しません。getSignupUrl() の出力を自前でログや解析イベントに送っている場合は、redactAgencyInviteToken() を通してください。

import { redactAgencyInviteToken } from '@feelflow/ffid-sdk'

logger.info('signup url', redactAgencyInviteToken(ffid.getSignupUrl()))
// → https://id.feelflow.net/signup?redirect=...&service=svc&invite=[REDACTED]

素の invite= だけでなく、redirect パラメータに着地 URL が percent-encode されて入る入れ子(redirect=…%3Finvite%3D…)も潰します。覆う encode の深さは 2 段までです。GA4 の page_location のように URL を丸ごと送るイベントでも同じ経路を通してください。

login URL にも redirect 経由で入れ子で載ります。 専用の &invite= は付きませんが(紐付けは新規登録時の組織作成に対して起こるため)、着地 URL が ?invite=… なら login URL の redirect の中に入ります。login URL をログに出す場合も redact してください。

型定義

interface FFIDUser {
  id: string
  email: string
  displayName: string | null
  avatarUrl: string | null
  locale: string | null
  timezone: string | null
  createdAt: string
}

interface FFIDOrganization {
  id: string
  name: string
  slug: string
  role: 'owner' | 'admin' | 'member'
  status: 'active' | 'invited' | 'suspended'
}

interface FFIDSubscription {
  id: string
  serviceCode: string
  serviceName: string
  planCode: string
  planName: string
  status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'paused'
  currentPeriodEnd: string | null
}

OAuth userinfo の契約要約

token mode では SDK は /api/v1/oauth/userinfo を呼び出し、基本プロフィールに加えてサービス契約の要約を受け取ります。 この要約により、追加 API を呼ばずにプラン判定や UI 分岐を行えます。

interface FFIDOAuthUserInfoSubscription {
  subscriptionId: string | null
  status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'paused' | null
  planCode: string | null
  seatModel: 'organization' | null
  memberRole: 'owner' | 'admin' | 'member' | 'viewer' | null
  organizationId: string | null
}

seatModel はシートモデル識別用であり、organization と role は userinfo の解決済み組織文脈として扱います。

React 以外の環境で使う

本 SDK は React/Next.js 向けに設計されていますが、一部のモジュールはフレームワーク非依存で利用できます。

サーバーサイドモジュール(React 依存なし)

以下の subpath exports は React に一切依存しません。Node.js、Deno、Bun 等で即座に利用できます。

// 利用規約・法的文書
import { createFFIDLegalClient } from '@feelflow/ffid-sdk/legal'

// Agency(代理店)管理
import { createFFIDAgencyClient } from '@feelflow/ffid-sdk/agency'

// お知らせ取得
import { createFFIDAnnouncementsClient } from '@feelflow/ffid-sdk/announcements'

// Webhook 署名検証・ハンドラー(※ Node.js crypto が必要)
import { createFFIDWebhookHandler, verifyWebhookSignature } from '@feelflow/ffid-sdk/webhooks'

Note: webhooks モジュールは Node.js の crypto モジュールと Buffer を使用します。Cloudflare Workers で利用する場合は nodejs_compat 互換フラグを有効にしてください。

これらのモジュールは独立した subpath export として公開されているため、メインエントリ (@feelflow/ffid-sdk) を経由せず、React の依存が伝播しません。

createFFIDClient を非 React 環境で使う

非 React 環境では、可能な限り上記の subpath exports(/legal、/webhooks 等)の個別クライアントを使用してください。メインエントリの createFFIDClient を使う必要がある場合、createFFIDClient 自体は React を使用しませんが、メインエントリに含まれるため bundler 環境では React を external に指定する必要があります。

Cloudflare Workers

ビルドコマンドで --external を指定するか、カスタム esbuild 設定で external を設定してください。

# wrangler のビルドコマンド例
esbuild src/index.ts --bundle --format=esm --external:react --external:react-dom

Vue / Nuxt(Vite)

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      external: ['react', 'react-dom'],
    },
  },
})

esbuild

esbuild src/index.ts --bundle --external:react --external:react-dom

webpack

// webpack.config.js
module.exports = {
  externals: {
    react: 'react',
    'react-dom': 'react-dom',
  },
}

Note: サーバーサイドで bundler を使わずに実行する場合、subpath exports(@feelflow/ffid-sdk/legal 等)を使用すれば external 設定なしで React がインストールされていなくても動作します。メインエントリ(@feelflow/ffid-sdk)を ESM で import する場合は、モジュールグラフが静的に解決されるため React が必要です。

peerDependencies は optional です

SDK の package.json で react / react-dom は optional: true に設定済みです(利用者側での設定は不要)。React をインストールしなくても npm install 時に warning は発生しません。

// SDK の package.json に設定済み(参考)
{
  "peerDependenciesMeta": {
    "react": { "optional": true },
    "react-dom": { "optional": true }
  }
}

E2E テストモード(@feelflow/ffid-sdk/server/test)

⚠️ SECURITY NOTICE — テストモードは Bearer トークン検証を 意図的にバイパス する仕組みです。本番環境で誤って有効化すると、登録されたバイパストークンを持つ任意のリクエストが認証通過します。

各サービスで E2E テストを書く際、verifyAccessToken の introspect 呼び出しをモックする実装を独自に持つ必要がなくなります。SDK 側で 多重 production guard 付き の bypass クライアントを提供します。

import { createFFIDClient } from '@feelflow/ffid-sdk/server'
import { createTestFFIDClient } from '@feelflow/ffid-sdk/server/test'

const isE2E =
  process.env.NODE_ENV !== 'production' &&
  process.env.FFID_TEST_MODE === 'true'

const client = isE2E
  ? createTestFFIDClient({
      users: [
        {
          bypassToken: process.env.E2E_TEST_BYPASS_SECRET!,
          userInfo: {
            sub: 'e2e-test-sub',
            email: '[email protected]',
            name: 'E2E Test User',
            picture: null,
          },
        },
      ],
    })
  : createFFIDClient({ /* normal production options */ })

const result = await client.verifyAccessToken(bearerToken)

Built-in production guards (defense-in-depth)

  • NODE_ENV を trim + lowercase 後に比較。"production "(改行混入)や "Production" も production として扱う(Vercel 環境変数の copy/paste 事故対策)
  • process.env を露出しない runtime(Edge / Cloudflare Workers / browser)では fail-close で構築拒否
  • bypassToken の重複検知 / 空チェック / userInfo.sub 必須チェック(構築時 throw)
  • 未登録 token は fail-close(実 introspect への暗黙 fallthrough は行わない)
  • 構築時点で users のスナップショットを取り、入力配列の post-construction mutation は無視
  • 各 verifyAccessToken 呼び出しは新しいオブジェクトを返却(caller mutation が後続呼び出しを汚染しない)

allowInProduction escape hatch

staging が NODE_ENV=production をミラーするケース等のみ、明示的な ack 文字列で有効化できます:

import {
  createTestFFIDClient,
  TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK,
} from '@feelflow/ffid-sdk/server/test'

createTestFFIDClient({
  users: [...],
  allowInProduction: TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK,
})
// → process.emitWarning(..., 'FFIDTestModeInProduction') を毎構築時に発火

boolean ではなく literal string ack を要求する型なので、allowInProduction: someBooleanFlag のような誤代入はコンパイルエラーになります。ack 文字列は grep 可能で監査も容易です。

サブパス分離

createTestFFIDClient は @feelflow/ffid-sdk/server/test からのみ import 可能です(@feelflow/ffid-sdk/server にも main entry にも含まれない)。本番コードからの誤 import は ESLint の no-restricted-imports 等で検知することを推奨します:

// eslint.config.mjs (flat config)
import { defineConfig } from 'eslint/config'

export default defineConfig([
  {
    files: ['src/**/*.{ts,tsx}'],          // production code only
    ignores: ['**/__tests__/**', 'tests/e2e/**'],
    rules: {
      'no-restricted-imports': ['error', {
        patterns: ['@feelflow/ffid-sdk/server/test'],
      }],
    },
  },
])

環境変数

オプションで環境変数を使用してデフォルト設定を上書きできます:

NEXT_PUBLIC_FFID_API_URL=https://id.feelflow.net
NEXT_PUBLIC_FFID_SERVICE_CODE=chatbot

ライセンス

MIT