@feelflow/ffid-sdk 10.0.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,1010 +1,1062 @@
1
- # @feelflow/ffid-sdk
2
-
3
- [![npm version](https://img.shields.io/npm/v/@feelflow/ffid-sdk.svg)](https://www.npmjs.com/package/@feelflow/ffid-sdk)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
-
6
- FeelFlow ID Platform SDK — React/Next.js 向け + サーバーサイドモジュールはフレームワーク非依存。
7
-
8
- **5行のコードでFFID認証を導入!**
9
-
10
- ## インストール
11
-
12
- ```bash
13
- npm install @feelflow/ffid-sdk
14
- # or
15
- yarn add @feelflow/ffid-sdk
16
- # or
17
- pnpm add @feelflow/ffid-sdk
18
- ```
19
-
20
- ## 統合ガイド
21
-
22
- サービスを FFID Platform に統合する詳細なガイドは **[Integration Guide](./docs/INTEGRATION_GUIDE.md)** を参照してください。OAuth フロー、フロントエンド/バックエンド実装パターン、セキュリティチェックリスト、アンチパターン集を網羅しています。
23
-
24
- ### Cookie 同意管理基盤 (v5.0.0+)
25
-
26
- GDPR / ePrivacy / 改正電気通信事業法 / APPI 準拠の Cookie 同意 UI を **15 分以内** に導入できます (opt-in、v4.x 完全互換)。
27
-
28
- - **[Cookie Consent Integration Guide](./docs/cookie-consent-guide.md)** — 17 章の詳細統合手順 (App Router / Pages Router / Vite / Consent Mode v2 / Sentry replay / RSC / i18n / A11y / エラーハンドリング)
29
- - **[v4 → v5 Migration Guide](./docs/MIGRATION_v5.md)** — minimal / full アップグレード手順、自前 gtag からの移譲、Rollback
30
- - **[Troubleshooting Q&A](./docs/cookie-consent-troubleshooting.md)** — Banner / GA / Cookie / TypeScript / API / Sentry のよくある詰まりポイント
31
- - **動作サンプル**: [App Router](./examples/cookie-consent/nextjs-app-router/) / [Pages Router](./examples/cookie-consent/nextjs-pages-router/) / [Vite + React](./examples/cookie-consent/vite-react/)
32
-
33
- ```tsx
34
- // app/layout.tsx (App Router、最短 3 ステップ)
35
- import {
36
- FFIDProvider,
37
- FFIDAnalyticsProvider,
38
- FFIDCookieBanner,
39
- DEFAULT_OAUTH_SCOPES,
40
- } from '@feelflow/ffid-sdk'
41
-
42
- export default function RootLayout({ children }) {
43
- return (
44
- <FFIDProvider serviceCode="your-service" scope={DEFAULT_OAUTH_SCOPES}>
45
- <FFIDAnalyticsProvider
46
- baseUrl={process.env.NEXT_PUBLIC_FFID_BASE_URL!}
47
- serviceApiKey={process.env.FFID_SERVICE_API_KEY!}
48
- gaMeasurementId={process.env.NEXT_PUBLIC_GA_ID ?? null}
49
- // Production-only measurement (v5.16.0+): suppress GA on
50
- // preview/staging/local so non-prod traffic never pollutes the
51
- // production GA4 property. Defaults to true if omitted.
52
- gaEnabled={process.env.VERCEL_ENV === 'production'}
53
- >
54
- {children}
55
- <FFIDCookieBanner />
56
- </FFIDAnalyticsProvider>
57
- </FFIDProvider>
58
- )
59
- }
60
- ```
61
-
62
- ## クイックスタート
63
-
64
- ### 1. プロバイダーを設定(5行で完了!)
65
-
66
- ```tsx
67
- // app/layout.tsx
68
- import { FFIDProvider, DEFAULT_OAUTH_SCOPES } from '@feelflow/ffid-sdk'
69
-
70
- export default function RootLayout({ children }: { children: React.ReactNode }) {
71
- return (
72
- <html lang="ja">
73
- <body>
74
- <FFIDProvider serviceCode="chatbot" scope={DEFAULT_OAUTH_SCOPES}>{children}</FFIDProvider>
75
- </body>
76
- </html>
77
- )
78
- }
79
- ```
80
-
81
- ### 2. 認証情報を使用
82
-
83
- ```tsx
84
- import { useFFID, useSubscription } from '@feelflow/ffid-sdk'
85
-
86
- function Dashboard() {
87
- const { user, isAuthenticated, login, logout } = useFFID()
88
- const { isActive, planCode } = useSubscription()
89
-
90
- if (!isAuthenticated) {
91
- return <button onClick={login}>ログイン</button>
92
- }
93
-
94
- return (
95
- <div>
96
- <p>Welcome, {user.displayName ?? user.email}!</p>
97
- <p>プラン: {planCode}</p>
98
- <button onClick={logout}>ログアウト</button>
99
- </div>
100
- )
101
- }
102
- ```
103
-
104
- ## UIコンポーネント
105
-
106
- ```tsx
107
- import {
108
- FFIDLoginButton,
109
- FFIDUserMenu,
110
- FFIDOrganizationSwitcher,
111
- FFIDSubscriptionBadge,
112
- } from '@feelflow/ffid-sdk/components'
113
-
114
- function Header() {
115
- return (
116
- <header>
117
- <FFIDLoginButton>ログイン</FFIDLoginButton>
118
- <FFIDOrganizationSwitcher />
119
- <FFIDSubscriptionBadge />
120
- <FFIDUserMenu />
121
- </header>
122
- )
123
- }
124
- ```
125
-
126
- ### コンポーネントのスタイリング(classNames パターン)
127
-
128
- 各UIコンポーネントは [Radix UI スタイルパターン](https://www.radix-ui.com/primitives/docs/guides/styling) に従った `classNames` プロップをサポートしています。これにより、コンポーネントの各パーツに個別のクラスを適用できます。
129
-
130
- #### FFIDUserMenu
131
-
132
- ```tsx
133
- <FFIDUserMenu
134
- classNames={{
135
- container: 'relative', // ラッパー要素
136
- button: 'focus:ring-2', // アバターボタン(トリガー)
137
- avatar: 'rounded-full', // アバター画像/フォールバック
138
- menu: 'shadow-lg', // ドロップダウンメニュー
139
- userInfo: 'border-b', // ユーザー情報セクション
140
- menuItem: 'hover:bg-gray-100', // カスタムメニュー項目
141
- logout: 'text-red-600', // ログアウトボタン
142
- }}
143
- />
144
- ```
145
-
146
- #### FFIDOrganizationSwitcher
147
-
148
- ```tsx
149
- <FFIDOrganizationSwitcher
150
- classNames={{
151
- container: 'relative', // ラッパー要素
152
- button: 'border rounded', // トリガーボタン
153
- dropdown: 'shadow-md', // ドロップダウンメニュー
154
- option: 'px-4 py-2', // 各組織オプション
155
- optionSelected: 'bg-blue-50', // 選択中の組織(optionに加えて適用)
156
- }}
157
- />
158
- ```
159
-
160
- #### FFIDSubscriptionBadge
161
-
162
- ```tsx
163
- <FFIDSubscriptionBadge
164
- classNames={{
165
- badge: 'font-semibold', // バッジspan要素
166
- }}
167
- />
168
- ```
169
-
170
- > **Note**: `FFIDLoginButton` はシンプルな単一要素コンポーネントのため、標準の `className` プロップのみをサポートしています。
171
-
172
- ## API リファレンス
173
-
174
- ### FFIDProvider
175
-
176
- アプリケーション全体をラップするプロバイダーコンポーネント。
177
-
178
- ```tsx
179
- <FFIDProvider
180
- serviceCode="chatbot" // 必須: サービスコード
181
- scope={DEFAULT_OAUTH_SCOPES} // 必須: OAuth scope (v3.0.0+)
182
- apiBaseUrl="..." // オプション: カスタムAPIエンドポイント
183
- debug={true} // オプション: デバッグログ有効化(非推奨、loggerを使用)
184
- logger={customLogger} // オプション: カスタムロガー(下記参照)
185
- refreshInterval={300000} // オプション: セッション更新間隔(ms)
186
- onAuthStateChange={(user) => {}} // オプション: 認証状態変更時コールバック
187
- onError={(error) => {}} // オプション: エラー時コールバック
188
- cleanCallbackUrl={true} // オプション: token モードの callback URL cleanup(false で consumer に委譲、v5.22.0+)
189
- >
190
- {children}
191
- </FFIDProvider>
192
- ```
193
-
194
- ### カスタムロガー
195
-
196
- SDKのデバッグ出力をカスタマイズできます。デフォルトではログは出力されません(サイレント)。
197
-
198
- ```tsx
199
- import type { FFIDLogger } from '@feelflow/ffid-sdk'
200
- import pino from 'pino' // または winston, bunyan 等
201
-
202
- // アプリケーションのロガーインスタンス
203
- const appLogger = pino({ level: 'debug' })
204
-
205
- // FFID SDK用にラップ
206
- const ffidLogger: FFIDLogger = {
207
- debug: (...args) => appLogger.debug({ sdk: 'ffid' }, ...args),
208
- info: (...args) => appLogger.info({ sdk: 'ffid' }, ...args),
209
- warn: (...args) => appLogger.warn({ sdk: 'ffid' }, ...args),
210
- error: (...args) => appLogger.error({ sdk: 'ffid' }, ...args),
211
- }
212
-
213
- // 使用例
214
- <FFIDProvider serviceCode="chatbot" scope={DEFAULT_OAUTH_SCOPES} logger={ffidLogger}>
215
- {children}
216
- </FFIDProvider>
217
- ```
218
-
219
- **ロガー優先順位:**
220
-
221
- 1. `logger` が指定されている場合 → カスタムロガーを使用
222
- 2. `debug: true` で `logger` なし → `console` を使用(後方互換性)
223
- 3. 両方なし → サイレント(no-op)
224
-
225
- ### useFFID()
226
-
227
- ユーザー・組織情報を取得するフック。
228
-
229
- ```tsx
230
- const {
231
- user, // FFIDUser | null - 現在のユーザー
232
- organizations, // FFIDOrganization[] - 所属組織一覧
233
- currentOrganization, // FFIDOrganization | null - 現在の組織
234
- isLoading, // boolean - ロード中
235
- isAuthenticated, // boolean - 認証済み
236
- login, // () => void - ログインページへリダイレクト
237
- logout, // () => Promise<void> - ログアウト
238
- switchOrganization, // (id: string) => void - 組織切り替え
239
- refresh, // () => Promise<void> - セッション更新
240
- } = useFFID()
241
- ```
242
-
243
- ### useSubscription()
244
-
245
- 契約情報を取得するフック。
246
-
247
- ```tsx
248
- const {
249
- subscription, // FFIDSubscription | null - 現在のサブスクリプション
250
- planCode, // string | null - プランコード
251
- isActive, // boolean - DB ステータスが 'active'
252
- isTrialing, // boolean - トライアル中
253
- isCanceled, // boolean - 解約済み
254
- isTrialExpired, // boolean - トライアル期間超過
255
- effectiveStatus, // EffectiveSubscriptionStatus | null - 意味論的アクセス制御値
256
- isBlocked, // boolean - blocked / expired / canceled / trial_expired
257
- isGrace, // boolean - past_due_grace (支払い失敗の猶予期間中)
258
- hasPlan, // (plans: string | string[]) => boolean - プラン確認
259
- hasAccess, // () => boolean - アクセス権確認 (active || past_due_grace)
260
- hasAccessLegacy, // () => boolean - 旧セマンティクス (active || trialing, pre-2.19)
261
- } = useSubscription()
262
- ```
263
-
264
- `effectiveStatus` は `/api/v1/subscriptions/ext/check` が返す意味論的ステータスと同じ値を取る。詳細は [契約期限切れハンドリング](#契約期限切れハンドリング) を参照。
265
-
266
- ### useRequireActiveSubscription()
267
-
268
- 契約が期限切れ/遮断状態のときに自動でリダイレクトするフック。
269
-
270
- ```tsx
271
- 'use client'
272
- import { useRouter } from 'next/navigation'
273
- import { useRequireActiveSubscription } from '@feelflow/ffid-sdk'
274
-
275
- function ProtectedShell({ children }: { children: React.ReactNode }) {
276
- const router = useRouter()
277
- const { loading } = useRequireActiveSubscription({
278
- redirectTo: (status) =>
279
- status === 'canceled' ? '/contract-ended' : '/contract-required',
280
- onRedirect: (url) => router.replace(url),
281
- })
282
-
283
- if (loading) return <FullPageSpinner />
284
- return <>{children}</>
285
- }
286
- ```
287
-
288
- オプション:
289
-
290
- - `redirectTo`: `string` または `(status: EffectiveSubscriptionStatus) => string`
291
- - `allowGrace` (default: `true`): `past_due_grace` を通過させるか
292
- - `onRedirect` (optional): 独自のリダイレクト関数(未指定時は `window.location.href`)
293
-
294
- ### withSubscription()
295
-
296
- サブスクリプション確認HOC。
297
-
298
- ```tsx
299
- const PremiumFeature = withSubscription(MyComponent, {
300
- plans: ['pro', 'enterprise'],
301
- fallback: <UpgradePrompt />,
302
- loading: <Spinner />,
303
- })
304
- ```
305
-
306
- ## 契約期限切れハンドリング
307
-
308
- 契約が失効したり解約されたとき、外部サービス側で適切にアクセスを遮断し、ユーザーを再契約動線に案内する必要があります。SDK は 3 層構成(トークン検証 / 契約チェック / Webhook 受信)をサポートしています。
309
-
310
- ### 最小構成(Next.js App Router)
311
-
312
- ```tsx
313
- 'use client'
314
- import { useRouter } from 'next/navigation'
315
- import { useRequireActiveSubscription } from '@feelflow/ffid-sdk'
316
-
317
- export default function Layout({ children }: { children: React.ReactNode }) {
318
- const router = useRouter()
319
- const { loading, effectiveStatus } = useRequireActiveSubscription({
320
- redirectTo: '/contract-required',
321
- onRedirect: (url) => router.replace(url),
322
- })
323
-
324
- if (loading) return <Spinner />
325
- if (effectiveStatus !== 'active' && effectiveStatus !== 'past_due_grace') {
326
- return null // リダイレクト発火中
327
- }
328
- return <>{children}</>
329
- }
330
- ```
331
-
332
- **詳細な実装レシピ**(middleware、Express、UI 分岐、Webhook 受信、fixture API を使ったテスト、`EffectiveSubscriptionStatus` 別の UI 推奨動作まで) → [docs/03-implementation/EXPIRED_CONTRACT_HANDLING.md](https://github.com/feel-flow/feelflow-id-platform/blob/develop/docs/03-implementation/EXPIRED_CONTRACT_HANDLING.md)
333
-
334
- ### Server-side service access decision
335
-
336
- 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 を再実装しない。
337
-
338
- ```ts
339
- import { createFFIDClient } from '@feelflow/ffid-sdk'
340
-
341
- const ffid = createFFIDClient({
342
- serviceCode: 'flow-board-ai',
343
- scope: '',
344
- authMode: 'service-key',
345
- serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
346
- })
347
-
348
- const { data: access, error } = await ffid.checkServiceAccess({
349
- userId,
350
- organizationId,
351
- allowGrace: true,
352
- })
353
-
354
- if (error || !access?.hasAccess) {
355
- return Response.redirect('/contract-required')
356
- }
357
- ```
358
-
359
- - `checkServiceAccess()` は **`data.hasAccess` が唯一の gate**。`result.error` は入力 validation など SDK が decision を作れない場合にだけ返る。
360
- - `hasAccess`: FFID が決めた canonical access decision。`allowGrace=false` のときだけ SDK 側で `past_due_grace` を deny に変換する。
361
- - `effectiveStatus`: `active` / `past_due_grace` / `blocked` / `canceled` / `trial_expired` / `expired`。
362
- - `gracePeriodEndsAt`: `past_due_grace` が `blocked` に変わる時刻。表示・警告用であり、アクセス判定の source of truth にしない。
363
- - `failPolicy`: 現在は `failClosed` 固定。FFID に到達できない、または canonical decision を取得できない場合は `result.error` ではなく `data.hasAccess=false` / `denialReason='ffid_unreachable'` / `data.error` を返す。呼び出し側は `result.error` だけで通過判定しない。
364
- - `denialReason` には `member_limit_reached`(10.0.0)も含まれる。flat プランの人数枠が満員で席を付けられない利用者は、ログイン時に OAuth の `denial_reason: 'member_limit_reached'` で止まる。`checkServiceAccess()` 自体はこの値を返さず、人数枠・クレジット残高は `hasAccess` に影響しない。`memberLimitReached` / `credits` / `pricingModel` は情報として decision に載る(古い FFID は返さない)。
365
-
366
- ### Credits(flat プラン、v10.0.0)
367
-
368
- `pricingModel: 'flat'`(固定料金 + 人数枠 + 月次 AI クレジット)の契約では、AI を実行するたびに FFID のクレジット台帳へ消費を報告する。契約の正本は FFID の [CREDITS_API.md](https://github.com/feel-flow/feelflow-id-platform/blob/develop/docs/04-api/CREDITS_API.md)、手順は [docs/INTEGRATION_GUIDE.md の Credits 節](./docs/INTEGRATION_GUIDE.md#credits-flat-plans)。
369
-
370
- ```ts
371
- import { createFFIDClient, FFID_CREDITS_ERROR_CODES, type FFIDCreditsInsufficientDetails } from '@feelflow/ffid-sdk'
372
-
373
- // サービスのバックエンド(consume は service-key モード専用)
374
- const ffid = createFFIDClient({
375
- serviceCode: 'chatbot-v2',
376
- scope: '',
377
- authMode: 'service-key',
378
- serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
379
- })
380
-
381
- const { data, error } = await ffid.credits.consume({
382
- subscriptionId,
383
- amount: 50,
384
- idempotencyKey: `chat.reply:${requestId}`, // AI 実行 1 回につき 1 つ。再試行でも同じキー
385
- feature: 'chat.reply',
386
- metadata: { model: 'example', tokens: 1200 }, // 個人情報は入れない
387
- })
388
-
389
- if (error?.code === FFID_CREDITS_ERROR_CODES.CREDITS_INSUFFICIENT) {
390
- const details = error.details as unknown as FFIDCreditsInsufficientDetails
391
- // 自動で再試行しない。購入導線(owner / admin)か管理者への依頼を出す
392
- return { blocked: true, available: details.available }
393
- }
394
- if (error) {
395
- // 5xx / NETWORK_ERROR は同じキー・同じ amount で再試行してよい(duplicate が返る)
396
- throw new Error(error.message)
397
- }
398
- // data.status は 'consumed' | 'duplicate'
399
- ```
400
-
401
- | メソッド | 認証モード | 用途 |
402
- |---|---|---|
403
- | `credits.consume(params)` | service-key のみ | AI 実行 1 回ぶんの消費を報告 |
404
- | `credits.getBalance(subscriptionId)` | token / service-key | 残高・当期・内訳・`isLow` / `isExhausted` |
405
- | `credits.listHistory(subscriptionId, { cursor?, limit?, type? })` | token / service-key | 履歴(新しい順) |
406
- | `credits.listPacks(serviceCode?)` | token / service-key | 購入できるパック |
407
- | `credits.createPackCheckout(params)` | token のみ(owner / admin) | パック購入の Stripe Checkout URL |
408
- | `credits.getBuyCreditsUrl({ subscriptionId, orgId? })` / `redirectToBuyCredits` | どれでも | FFID の契約詳細で購入ダイアログを開く URL(UUID か `ffid:<uuid>`。`sub_...` は不可) |
409
- | `getSeats(subscriptionId)` | token / service-key | 人数枠(`maxMembers` / `assignedMembers` / `availableMembers`、`null` = 無制限) |
410
-
411
- - AI 機能のゲートは **残高**(`consume` の結果・`getBalance()`・`checkServiceAccess()` の `credits`)で行い、`hasAccess` では行わない。クレジットが 0 でも `hasAccess` は `true` のまま。
412
- - `consume` を service-key 以外で、`createPackCheckout` を token 以外で呼ぶと、ネットワークに出る前に `FORBIDDEN`(`details.reason: 'api_key_required'` / `'bearer_required'`)が返る。
413
- - 残高の変化は webhook `credits.granted` / `credits.low` / `credits.exhausted` / `credits.pack_purchased` でも届く(`@feelflow/ffid-sdk/webhooks`)。
414
-
415
- ### Organization member management
416
-
417
- `organization:read` / `organization:write` scope を持つ service-key または token で、組織メンバーの参照・追加・ロール変更・削除ができます。
418
-
419
- ```ts
420
- import { createFFIDClient } from '@feelflow/ffid-sdk/server'
421
-
422
- const ffid = createFFIDClient({
423
- serviceCode: 'flow-board-ai',
424
- scope: '',
425
- authMode: 'service-key',
426
- serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
427
- })
428
-
429
- const addResult = await ffid.addMember({
430
- organizationId,
431
- email: 'new-member@example.com',
432
- role: 'member',
433
- })
434
-
435
- if (addResult.error) {
436
- throw new Error(addResult.error.message)
437
- }
438
-
439
- await ffid.updateMemberRole({
440
- organizationId,
441
- userId: addResult.data.member.userId,
442
- role: 'admin',
443
- })
444
- ```
445
-
446
- - `addMember` は既存 FFID ユーザーを active member として追加します。招待メール送信や token 発行は行いません。
447
- - `role` は `admin` / `member` / `viewer` のみ指定可能です。`owner` は追加・昇格 API では扱いません。
448
- - agency client と同じ呼び味が必要な場合は `ffid.addMember(organizationId, { email, role })` も使えます。
449
-
450
- ### Organization profile / public logo wall
451
-
452
- hub 等の外部サービスが企業プロフィールを登録・照会し、FeelFlow 全サービス共通のロゴウォールを表示するためのメソッド(#5485 / #5547)。
453
-
454
- - **`getOrganizationProfile` / `upsertOrganizationProfile` / `uploadOrganizationLogo`** は **`authMode: 'service-key'` 専用**(scope: `organization:read` / `organization:write`)。`token` / `cookie` モードで呼ぶとサーバーへ往復せず `VALIDATION_ERROR`。
455
- - **`getPublicLogoWall`** は認証不要。service-key を送らない。エンドポイントは `GET /api/v1/organizations/logo-wall`(公開 API なので SDK を使わず直接 GET してもよいが、型付きエラーが欲しければ SDK 経由を推奨)。
456
- - `upsert` の `listingConsent.consented: true` では `consentVersion` と `consentedByUserId` が必須。同意操作者が対象 org の active メンバーでないとサーバーが `CONSENTED_BY_NOT_MEMBER` を返す。
457
-
458
- ```ts
459
- import {
460
- createFFIDClient,
461
- FFID_ORGANIZATION_PROFILE_ERROR_CODES,
462
- } from '@feelflow/ffid-sdk/server'
463
-
464
- const ffid = createFFIDClient({
465
- serviceCode: 'feel-agent',
466
- scope: '',
467
- authMode: 'service-key',
468
- serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
469
- })
470
-
471
- const current = await ffid.getOrganizationProfile(organizationId)
472
- if (current.error) throw new Error(current.error.message)
473
-
474
- const upsert = await ffid.upsertOrganizationProfile(organizationId, {
475
- displayName: '株式会社サンプル',
476
- industry: 'it-software',
477
- companySize: '11-50',
478
- websiteUrl: 'https://example.co.jp',
479
- listingConsent: {
480
- consented: true,
481
- consentVersion: 'v1.0',
482
- consentedByUserId: userId,
483
- },
484
- })
485
- if (upsert.error?.code === FFID_ORGANIZATION_PROFILE_ERROR_CODES.CONSENTED_BY_NOT_MEMBER) {
486
- throw new Error(upsert.error.message)
487
- }
488
-
489
- await ffid.uploadOrganizationLogo(organizationId, logoFile)
490
-
491
- const wall = await ffid.getPublicLogoWall()
492
- // wall.data.entries: { displayName, logoUrl, websiteUrl }[]
493
- ```
494
-
495
- 位置引数と params オブジェクトの両方を受け付ける(`addMember` と同じ)。
496
-
497
- ### Provisioning(users / organizations)
498
-
499
- 外部サービスからの一括移行向けに、**service-key 認証**でユーザー・組織を冪等にプロビジョニングするメソッド。REST を直叩きせず `createFFIDClient` 経由で呼べる(SDK-first、#3790 / #4127)。
500
-
501
- - **`authMode: 'service-key'`(`X-Service-Api-Key`)専用**。`token`(Bearer)/ `cookie` モードで呼ぶとサーバーへ往復せず即座に `VALIDATION_ERROR` を返す(サーバー側エンドポイントが service-key 認証のみを受け付けるため)。
502
- - 必要 scope: `provisionUser` → `user:provision`、`provisionOrganization` → `organization:write`。
503
-
504
- ```ts
505
- import { createFFIDClient } from '@feelflow/ffid-sdk/server'
506
-
507
- const ffid = createFFIDClient({
508
- serviceCode: 'praxis',
509
- scope: '',
510
- authMode: 'service-key',
511
- serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
512
- })
513
-
514
- // 1. ユーザーを冪等に作成(新規なら created:true / 既存なら created:false)
515
- const userRes = await ffid.provisionUser({
516
- email: 'owner@example.com',
517
- profile: { displayName: '山田 太郎' },
518
- })
519
- if (userRes.error) throw new Error(userRes.error.message)
520
- if (!userRes.data.dryRun) {
521
- console.log(userRes.data.created ? '新規作成' : '既存ユーザー', userRes.data.user.id)
522
- }
523
-
524
- // 2. その owner の組織を作成し、メンバーを冪等に追加
525
- const orgRes = await ffid.provisionOrganization({
526
- name: 'Example 組織',
527
- ownerEmail: 'owner@example.com',
528
- members: [
529
- { email: 'member1@example.com', role: 'member' },
530
- { email: 'member2@example.com', role: 'viewer' },
531
- ],
532
- })
533
- if (orgRes.error) {
534
- // owner が未登録なら error.code === 'OWNER_NOT_FOUND'(先に provisionUser が必要)
535
- throw new Error(orgRes.error.message)
536
- }
537
- if (!orgRes.data.dryRun) {
538
- for (const m of orgRes.data.members) {
539
- console.log(m.email, m.status) // 'added' | 'already_member' | 'user_not_found'
540
- }
541
- }
542
- ```
543
-
544
- #### 冪等セマンティクス
545
-
546
- - `provisionUser`: **既存メールは `created: false`**(HTTP 200、no-op)、新規メールは `created: true`(HTTP 201、パスワードレス・メール確認済み)。メールはサーバー側で normalize(trim + lowercase)されるため、大文字小文字違いの再送でも同一ユーザーに解決する。新規作成で `profile` を渡した場合のみ `profileWritten` が付き、`false` は「ユーザーは作成されたがプロフィール書き込みに失敗」→ 再送を推奨。
547
- - `provisionOrganization`: owner が同名の組織を既に持つ場合は `created: false`(HTTP 200)。`members` は冪等に追加され、各要素の `status` が `added` / `already_member` / `user_not_found` を返す。
548
-
549
- #### dryRun
550
-
551
- `dryRun: true` を渡すと **一切書き込まず**、実行時に何が起きるかだけを返す。レスポンスは `dryRun` 判別のタグ付きユニオン。
552
-
553
- ```ts
554
- const preview = await ffid.provisionUser({ email: 'owner@example.com', dryRun: true })
555
- if (!preview.error && preview.data.dryRun) {
556
- console.log(preview.data.wouldCreate) // true = 実行すれば新規作成される
557
- }
558
-
559
- const orgPreview = await ffid.provisionOrganization({
560
- name: 'Example 組織',
561
- ownerEmail: 'owner@example.com',
562
- members: [{ email: 'member1@example.com', role: 'member' }],
563
- dryRun: true,
564
- })
565
- if (!orgPreview.error && orgPreview.data.dryRun) {
566
- console.log(orgPreview.data.wouldCreate) // 組織を新規作成するか
567
- // per-member plan status: 'would_add' | 'already_member' | 'user_not_found'
568
- orgPreview.data.members.forEach((m) => console.log(m.email, m.status))
569
- }
570
- ```
571
-
572
- `if (res.data.dryRun)` で TypeScript が型を絞り込むため、dry-run 分岐では `wouldCreate`、実行分岐では `created` がそれぞれ型安全に参照できる。
573
-
574
- ### getProfile() / updateProfile()
575
-
576
- ログイン中ユーザー自身のプロフィールを取得・更新するメソッド(`createFFIDClient` から呼び出し)。
577
-
578
- - エンドポイント: `GET /api/v1/users/ext/me` / `PUT /api/v1/users/ext/me`
579
- - 対応 authMode: `token`(Bearer)のみが SDK 経由で成立する
580
- - **cookie モードは非対応** — ext エンドポイントはクロスオリジン用途のため、Bearer token または `X-Service-Api-Key` のどちらかが必須。FFID 自身の UI(同一オリジン)は従来通り `/api/v1/users/me` を使う
581
- - **service-key モードも SDK 経由では非対応** — API Key 認証時はバックエンドが `?userId=<uuid>` クエリを要求するが、`getProfile()` / `updateProfile()` は自分自身(ambient user)を前提とするため、`userId` を受け取らない。サーバー間で任意ユーザーのプロフィールを操作したい場合は `fetchWithAuth` で直接エンドポイントを叩いてください
582
- - 外部サービス(hub 等)のフロントエンドから token モードで呼ぶのが想定される主要パターン
583
-
584
- ```tsx
585
- import { createFFIDClient } from '@feelflow/ffid-sdk'
586
-
587
- const client = createFFIDClient({
588
- serviceCode: 'hub',
589
- authMode: 'token',
590
- apiBaseUrl: 'https://id.feelflow.net',
591
- })
592
-
593
- // 取得
594
- const { data: profile, error } = await client.getProfile()
595
- if (error) {
596
- console.error('プロフィール取得失敗:', error.message)
597
- } else {
598
- console.log(profile.email, profile.displayName, profile.timezone)
599
- }
600
-
601
- // 更新(部分更新 — 渡したフィールドだけ差し替え)
602
- const { data: updated, error: updateError } = await client.updateProfile({
603
- displayName: '山田 太郎',
604
- timezone: 'Asia/Tokyo',
605
- locale: 'ja',
606
- preferences: { theme: 'dark' },
607
- })
608
- ```
609
-
610
- `updateProfile` に空オブジェクト `{}` を渡すと `VALIDATION_ERROR` が返ります(無意味なラウンドトリップを防止)。
611
-
612
- #### フィールドのクリア(null を渡す)
613
-
614
- 対応する optional フィールドに `null` を渡すと、FFID backend 側で該当カラムを
615
- SQL NULL にクリアします(v2.16.0〜 / #2354)。個人プロフィールの
616
- `companyName` は廃止予定で、旧クライアントからの値は受理しても無視します。
617
-
618
- ```tsx
619
- // 部署をクリア
620
- await client.updateProfile({
621
- department: null,
622
- })
623
- ```
624
-
625
- 値のセマンティクス:
626
-
627
- | 渡す値 | FFID backend の挙動 |
628
- | --- | --- |
629
- | `undefined` / キー未指定 | 未変更(partial update) |
630
- | `null` | クリア(SQL NULL を書き込む) |
631
- | `""`(空文字列) | 空文字リテラルをそのまま保存(`null` 扱いには**ならない**) |
632
-
633
- 対応フィールド: `displayName` / `phone` / `department` / `jobTitle` / `preferences`。`timezone` / `locale` は application-level invariant(サーバー側 normalization が string 前提)のため null 非許容。クリアは不可 — キー未指定で現状維持、もしくは新しい有効な値を渡す。
634
-
635
- ### getAnalyticsConfig() (#2347)
636
-
637
- 外部サービスが自身に割り当てられた GA4 Measurement ID を取得するメソッド。
638
-
639
- - エンドポイント: `GET /api/v1/ext/analytics/config?service=<code>`
640
- - 必要 scope: `analytics:read`(Bearer / API Key 両方で enforce)
641
- - レスポンス: `FFIDAnalyticsConfig` (`{ code, measurementId, displayName, isActive }`)
642
- - **archived service** (`isActive: false`) でも 200 を返す — caller が次回 deploy で tracking 停止判断する間、in-flight events が 5xx を起こさないように measurementId は引き続き返す
643
-
644
- ```ts
645
- import { createFFIDClient } from '@feelflow/ffid-sdk/server'
646
-
647
- const client = createFFIDClient({
648
- serviceCode: 'flow-board-ai',
649
- authMode: 'service-key',
650
- serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
651
- })
652
-
653
- const result = await client.getAnalyticsConfig('feel-agent-ai')
654
- if (result.error) {
655
- if (result.error.code === 'SERVICE_NOT_FOUND') {
656
- // 該当 GA4 stream がまだ sync されていない / typo
657
- } else {
658
- console.error(`[FFID] analytics config fetch failed: ${result.error.code}`)
659
- }
660
- } else if (result.data.isActive) {
661
- // GA4 タグを描画して events を送信
662
- loadGA4Script(result.data.measurementId)
663
- }
664
- ```
665
-
666
- エラーコード:
667
- - `VALIDATION_ERROR` — `serviceCode` が空 / kebab-case 形式違反(SDK 側 pre-validate)
668
- - `INSUFFICIENT_SCOPE` (403) — `analytics:read` scope なし
669
- - `SERVICE_NOT_FOUND` (404) — DB に未登録の service code
670
- - `INVALID_PARAM` (400) — server 側で形式違反を検出(pre-validate 通過後の boundary)
671
-
672
- ## 代理店の招待リンク(`?invite=`)
673
-
674
- 代理店の招待リンクから来た顧客が FFID で登録すると、作られた組織がその代理店に紐付きます。SDK の役割は**トークンを signup URL まで運ぶこと**だけで、紐付けの実務は FFID 側が行います。
675
-
676
- > 代理店ポータルが発行するリンクは FFID の origin(`https://id.feelflow.net/signup?invite=…`)を指します。サービスサイトを着地点にする形(`https://<あなたのサービス>/?invite=…`)は、代理店がトークンを自分のキャンペーン URL に貼って成立させる導線です。この節が効くのはその形です。
677
-
678
- ### React 利用者(`FFIDProvider` を mount している場合)
679
-
680
- **配線は不要です。** provider が mount 時に着地 URL の `invite` を捕捉し、first-party cookie へ保存します。`getSignupUrl()` が自動でトークンを載せます。
681
-
682
- ただし provider は捕捉結果を返さないので、**cookie の保持に失敗したことを利用者側で観測する経路はありません**(debug ログには落ちます)。検知したい場合は provider に加えて自分で `captureAgencyInviteToken()` を呼び、戻り値の `persisted` を見てください。
683
-
684
- ### React 以外 / provider を使わない場合
685
-
686
- `captureAgencyInviteToken()` を**ページ遷移ごとに**呼びます。
687
-
688
- ```typescript
689
- import { captureAgencyInviteToken } from '@feelflow/ffid-sdk'
690
-
691
- // ページ読み込みごと(SPA ならルート遷移ごと)に呼ぶ
692
- const { token, source, persisted } = captureAgencyInviteToken()
693
-
694
- if (persisted === false) {
695
- // cookie がブラウザに落とされた。今回の URL 経由では紐付くが、
696
- // パラメータの無い別ページへ回遊すると失われる。
697
- } else if (persisted === undefined && source === 'url') {
698
- // 書ける環境が無かった(SSR)。ブラウザ側で捕捉し直す必要がある。
699
- }
700
- ```
701
-
702
- 呼ばなかった場合は「着地ページで即 signup した人だけ紐付く」まで**劣化します**(壊れるのではなく、下記の回遊経路を救えなくなります)。
703
-
704
- ### なぜページ遷移ごとに呼ぶ必要があるのか
705
-
706
- 実際の導線は次の形です。
707
-
708
- ```
709
- /?invite=abc に着地 → 料金ページを見る → /pricing で「登録」を押す
710
- ```
711
-
712
- `getSignupUrl()` が呼ばれる時点の URL には `invite` がありません。着地時点で捕捉して保持する以外にこの経路を救う方法がなく、**SDK 自身はページ読み込みを hook できない**ため、起動点は利用者側にあります。
713
-
714
- ### ⚠️ 効かない経路: SSR(Server Component)で signup URL を組む場合
715
-
716
- サーバーには `window.location.search` も `document.cookie` もありません。`getSignupUrl()` をそのまま呼ぶとトークンは載らないので、呼び出し側が読んだ値を渡してください(渡さない場合はプロセスあたり 1 度 warn します)。
717
-
718
- ```typescript
719
- // Server Component
720
- import { cookies } from 'next/headers'
721
- import { AGENCY_INVITE_COOKIE_NAME } from '@feelflow/ffid-sdk'
722
-
723
- const inviteToken = (await cookies()).get(AGENCY_INVITE_COOKIE_NAME)?.value
724
- const href = ffid.getSignupUrl(undefined, { inviteToken })
725
- ```
726
-
727
- ### `authMode: 'token'`(OAuth モード)は v7.4.0 で対応済み
728
-
729
- この設定では signup 意図が `/api/v1/oauth/authorize` を経由するため `getSignupUrl()` を通りません。**v7.4.0 以降は `redirectToAuthorize` が `invite` を authorize URL へ転送し、FFID 側が `/signup` へ引き継ぎます**(`redirectToLogin({ screenHint: 'signup' })` は token モードでは `redirectToAuthorize` へ委譲されるので、こちらも同様に載ります)。配線は不要です。
730
-
731
- v7.3.x までは token モードでトークンが FFID に 1 度も届かず、紐付けが無言で落ちていました。**v7.4.0 未満を使っている場合は、SDK と FFID 本体の両方を更新してください** — 転送は SDK が `invite` を送り、FFID が `/signup` へ stamp する 2 段で成立します。
732
-
733
- ⚠️ 転送の gate は `screenHint === 'signup'` です。`prompt` に `'create'` を指定して signup 画面へ向かう経路は SDK の `FFIDPrompt`(`'select_account' | 'login'`)が受け付けないため到達しません。
734
-
735
- ### cookie の仕様
736
-
737
- | 項目 | 値 |
738
- |---|---|
739
- | 名前 | `ffid_invite`(first-party・consumer 自身のホスト) |
740
- | 有効期限 | 30 日 |
741
- | `Path` | `/` |
742
- | `SameSite` | `Lax` |
743
- | `Secure` | `NODE_ENV === 'production'` のときだけ(`options.secure` で上書き可) |
744
- | `Domain` | 既定は現在のホストのみ(`options.domain` で指定可) |
745
-
746
- `Secure` を本番限定にしているのは、**https でない独自ドメイン**(LAN IP や plain http の検証機)でブラウザが `Secure` cookie を黙って捨てるためです。`http://localhost` は secure context として扱われるので `Secure` でも保持されます(ローカル開発では上書き不要)。
747
-
748
- URL と cookie の両方にトークンがある場合は **URL が勝ち、cookie も更新されます**(最後に踏んだリンクが勝つ)。
749
-
750
- **削除には書き込み時と同じ `path` / `domain` / `secure` が必要です。** ブラウザが一致を要求するため、既定以外で書いた cookie は既定の `clearAgencyInviteToken()` では消えません。
751
-
752
- ### ⚠️ 保持期間 30 日の副作用
753
-
754
- 招待リンクは既定で使用回数が無制限です。したがって保持中の cookie は、**同じブラウザで後から作られた組織も同じ代理店に紐付けます**。共有ブラウザではこれが誤紐付けになります。
755
-
756
- SDK は登録完了時に cookie を消しません(登録完了は FFID のドメインで起こるため consumer から検知しにくい)。単回にしたい場合は、登録完了を検知できる地点で明示的に消してください。
757
-
758
- ```typescript
759
- import { clearAgencyInviteToken } from '@feelflow/ffid-sdk'
760
-
761
- clearAgencyInviteToken() // 削除が read-back で確認できたら true / SSR では undefined
762
- ```
763
-
764
- ### ⚠️ トークンをログに出さないこと
765
-
766
- このトークンは URL に載る**平文の資格情報**で、**単回使用ではありません**(招待リンクの使用回数上限は既定で無制限)。漏れたトークンは、新規組織が作られる限り何度でも紐付けに使えます。
767
-
768
- SDK 内部ではログ・エラーレポート・アナリティクスに値を出しません。`getSignupUrl()` の出力を自前でログや解析イベントに送っている場合は、`redactAgencyInviteToken()` を通してください。
769
-
770
- ```typescript
771
- import { redactAgencyInviteToken } from '@feelflow/ffid-sdk'
772
-
773
- logger.info('signup url', redactAgencyInviteToken(ffid.getSignupUrl()))
774
- // → https://id.feelflow.net/signup?redirect=...&service=svc&invite=[REDACTED]
775
- ```
776
-
777
- 素の `invite=` だけでなく、`redirect` パラメータに着地 URL が percent-encode されて入る**入れ子**(`redirect=…%3Finvite%3D…`)も潰します。覆う encode の深さは **2 段まで**です。GA4 の `page_location` のように URL を丸ごと送るイベントでも同じ経路を通してください。
778
-
779
- **login URL にも `redirect` 経由で入れ子で載ります。** 専用の `&invite=` は付きませんが(紐付けは新規登録時の組織作成に対して起こるため)、着地 URL が `?invite=…` なら login URL の `redirect` の中に入ります。login URL をログに出す場合も redact してください。
780
-
781
- ## 型定義
782
-
783
- ```typescript
784
- interface FFIDUser {
785
- id: string
786
- email: string
787
- displayName: string | null
788
- avatarUrl: string | null
789
- locale: string | null
790
- timezone: string | null
791
- createdAt: string
792
- }
793
-
794
- interface FFIDOrganization {
795
- id: string
796
- name: string
797
- slug: string
798
- role: 'owner' | 'admin' | 'member'
799
- status: 'active' | 'invited' | 'suspended'
800
- }
801
-
802
- interface FFIDSubscription {
803
- id: string
804
- serviceCode: string
805
- serviceName: string
806
- planCode: string
807
- planName: string
808
- status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'paused'
809
- currentPeriodEnd: string | null
810
- }
811
- ```
812
-
813
- ### OAuth userinfo の契約要約
814
-
815
- token mode では SDK は `/api/v1/oauth/userinfo` を呼び出し、基本プロフィールに加えてサービス契約の要約を受け取ります。
816
- この要約により、追加 API を呼ばずにプラン判定や UI 分岐を行えます。
817
-
818
- ```ts
819
- interface FFIDOAuthUserInfoSubscription {
820
- subscriptionId: string | null
821
- status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'paused' | null
822
- planCode: string | null
823
- seatModel: 'organization' | null
824
- memberRole: 'owner' | 'admin' | 'member' | 'viewer' | null
825
- organizationId: string | null
826
- }
827
- ```
828
-
829
- `seatModel` はシートモデル識別用であり、organization と role は userinfo の解決済み組織文脈として扱います。
830
-
831
- ## React 以外の環境で使う
832
-
833
- 本 SDK は React/Next.js 向けに設計されていますが、一部のモジュールはフレームワーク非依存で利用できます。
834
-
835
- ### サーバーサイドモジュール(React 依存なし)
836
-
837
- 以下の subpath exports は React に一切依存しません。Node.js、Deno、Bun 等で即座に利用できます。
838
-
839
- ```typescript
840
- // 利用規約・法的文書
841
- import { createFFIDLegalClient } from '@feelflow/ffid-sdk/legal'
842
-
843
- // Agency(代理店)管理
844
- import { createFFIDAgencyClient } from '@feelflow/ffid-sdk/agency'
845
-
846
- // お知らせ取得
847
- import { createFFIDAnnouncementsClient } from '@feelflow/ffid-sdk/announcements'
848
-
849
- // Webhook 署名検証・ハンドラー(※ Node.js crypto が必要)
850
- import { createFFIDWebhookHandler, verifyWebhookSignature } from '@feelflow/ffid-sdk/webhooks'
851
- ```
852
-
853
- > **Note**: `webhooks` モジュールは Node.js の `crypto` モジュールと `Buffer` を使用します。Cloudflare Workers で利用する場合は [`nodejs_compat` 互換フラグ](https://developers.cloudflare.com/workers/runtime-apis/nodejs/)を有効にしてください。
854
-
855
- これらのモジュールは独立した subpath export として公開されているため、メインエントリ (`@feelflow/ffid-sdk`) を経由せず、React の依存が伝播しません。
856
-
857
- ### `createFFIDClient` を非 React 環境で使う
858
-
859
- 非 React 環境では、可能な限り上記の subpath exports(`/legal`、`/webhooks` 等)の個別クライアントを使用してください。メインエントリの `createFFIDClient` を使う必要がある場合、`createFFIDClient` 自体は React を使用しませんが、メインエントリに含まれるため **bundler 環境では React を external に指定する**必要があります。
860
-
861
- #### Cloudflare Workers
862
-
863
- ビルドコマンドで `--external` を指定するか、カスタム esbuild 設定で `external` を設定してください。
864
-
865
- ```bash
866
- # wrangler のビルドコマンド例
867
- esbuild src/index.ts --bundle --format=esm --external:react --external:react-dom
868
- ```
869
-
870
- #### Vue / Nuxt(Vite)
871
-
872
- ```typescript
873
- // vite.config.ts
874
- export default defineConfig({
875
- build: {
876
- rollupOptions: {
877
- external: ['react', 'react-dom'],
878
- },
879
- },
880
- })
881
- ```
882
-
883
- #### esbuild
884
-
885
- ```bash
886
- esbuild src/index.ts --bundle --external:react --external:react-dom
887
- ```
888
-
889
- #### webpack
890
-
891
- ```javascript
892
- // webpack.config.js
893
- module.exports = {
894
- externals: {
895
- react: 'react',
896
- 'react-dom': 'react-dom',
897
- },
898
- }
899
- ```
900
-
901
- > **Note**: サーバーサイドで bundler を使わずに実行する場合、**subpath exports(`@feelflow/ffid-sdk/legal` 等)を使用すれば** external 設定なしで React がインストールされていなくても動作します。メインエントリ(`@feelflow/ffid-sdk`)を ESM で import する場合は、モジュールグラフが静的に解決されるため React が必要です。
902
-
903
- ### `peerDependencies` は optional です
904
-
905
- SDK の `package.json` で `react` / `react-dom` は `optional: true` に設定済みです(利用者側での設定は不要)。React をインストールしなくても `npm install` 時に warning は発生しません。
906
-
907
- ```json
908
- // SDK の package.json に設定済み(参考)
909
- {
910
- "peerDependenciesMeta": {
911
- "react": { "optional": true },
912
- "react-dom": { "optional": true }
913
- }
914
- }
915
- ```
916
-
917
- ## E2E テストモード(`@feelflow/ffid-sdk/server/test`)
918
-
919
- > **⚠️ SECURITY NOTICE** — テストモードは Bearer トークン検証を **意図的にバイパス** する仕組みです。本番環境で誤って有効化すると、登録されたバイパストークンを持つ任意のリクエストが認証通過します。
920
-
921
- 各サービスで E2E テストを書く際、`verifyAccessToken` の introspect 呼び出しをモックする実装を独自に持つ必要がなくなります。SDK 側で **多重 production guard 付き** の bypass クライアントを提供します。
922
-
923
- ```ts
924
- import { createFFIDClient } from '@feelflow/ffid-sdk/server'
925
- import { createTestFFIDClient } from '@feelflow/ffid-sdk/server/test'
926
-
927
- const isE2E =
928
- process.env.NODE_ENV !== 'production' &&
929
- process.env.FFID_TEST_MODE === 'true'
930
-
931
- const client = isE2E
932
- ? createTestFFIDClient({
933
- users: [
934
- {
935
- bypassToken: process.env.E2E_TEST_BYPASS_SECRET!,
936
- userInfo: {
937
- sub: 'e2e-test-sub',
938
- email: 'e2e@example.com',
939
- name: 'E2E Test User',
940
- picture: null,
941
- },
942
- },
943
- ],
944
- })
945
- : createFFIDClient({ /* normal production options */ })
946
-
947
- const result = await client.verifyAccessToken(bearerToken)
948
- ```
949
-
950
- ### Built-in production guards (defense-in-depth)
951
-
952
- - `NODE_ENV` を **trim + lowercase** 後に比較。`"production "`(改行混入)や `"Production"` も production として扱う(Vercel 環境変数の copy/paste 事故対策)
953
- - `process.env` を露出しない runtime(Edge / Cloudflare Workers / browser)では **fail-close** で構築拒否
954
- - `bypassToken` の重複検知 / 空チェック / `userInfo.sub` 必須チェック(構築時 throw)
955
- - 未登録 token は **fail-close**(実 introspect への暗黙 fallthrough は行わない)
956
- - 構築時点で `users` のスナップショットを取り、入力配列の post-construction mutation は無視
957
- - 各 `verifyAccessToken` 呼び出しは新しいオブジェクトを返却(caller mutation が後続呼び出しを汚染しない)
958
-
959
- ### `allowInProduction` escape hatch
960
-
961
- staging が `NODE_ENV=production` をミラーするケース等のみ、明示的な ack 文字列で有効化できます:
962
-
963
- ```ts
964
- import {
965
- createTestFFIDClient,
966
- TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK,
967
- } from '@feelflow/ffid-sdk/server/test'
968
-
969
- createTestFFIDClient({
970
- users: [...],
971
- allowInProduction: TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK,
972
- })
973
- // → process.emitWarning(..., 'FFIDTestModeInProduction') を毎構築時に発火
974
- ```
975
-
976
- `boolean` ではなく **literal string ack** を要求する型なので、`allowInProduction: someBooleanFlag` のような誤代入はコンパイルエラーになります。ack 文字列は grep 可能で監査も容易です。
977
-
978
- ### サブパス分離
979
-
980
- `createTestFFIDClient` は **`@feelflow/ffid-sdk/server/test` からのみ** import 可能です(`@feelflow/ffid-sdk/server` にも main entry にも含まれない)。本番コードからの誤 import は ESLint の `no-restricted-imports` 等で検知することを推奨します:
981
-
982
- ```js
983
- // eslint.config.mjs (flat config)
984
- import { defineConfig } from 'eslint/config'
985
-
986
- export default defineConfig([
987
- {
988
- files: ['src/**/*.{ts,tsx}'], // production code only
989
- ignores: ['**/__tests__/**', 'tests/e2e/**'],
990
- rules: {
991
- 'no-restricted-imports': ['error', {
992
- patterns: ['@feelflow/ffid-sdk/server/test'],
993
- }],
994
- },
995
- },
996
- ])
997
- ```
998
-
999
- ## 環境変数
1000
-
1001
- オプションで環境変数を使用してデフォルト設定を上書きできます:
1002
-
1003
- ```bash
1004
- NEXT_PUBLIC_FFID_API_URL=https://id.feelflow.net
1005
- NEXT_PUBLIC_FFID_SERVICE_CODE=chatbot
1006
- ```
1007
-
1008
- ## ライセンス
1009
-
1010
- MIT
1
+ # @feelflow/ffid-sdk
2
+
3
+ <!-- markdownlint の既定からの差分(理由): MD013 = 日本語本文・表・コードを含む公開文書で、折り返せる語境界がなく、表と URL は折り返せない -->
4
+ <!-- markdownlint-configure-file {"MD013": false} -->
5
+
6
+ [![npm version](https://img.shields.io/npm/v/@feelflow/ffid-sdk.svg)](https://www.npmjs.com/package/@feelflow/ffid-sdk)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
9
+ FeelFlow ID Platform SDK — React/Next.js 向け + サーバーサイドモジュールはフレームワーク非依存。
10
+
11
+ **5行のコードでFFID認証を導入!**
12
+
13
+ ## インストール
14
+
15
+ ```bash
16
+ npm install @feelflow/ffid-sdk
17
+ # or
18
+ yarn add @feelflow/ffid-sdk
19
+ # or
20
+ pnpm add @feelflow/ffid-sdk
21
+ ```
22
+
23
+ ## 統合ガイド
24
+
25
+ サービスを FFID Platform に統合する詳細なガイドは **[Integration Guide](./docs/INTEGRATION_GUIDE.md)** を参照してください。OAuth フロー、フロントエンド/バックエンド実装パターン、セキュリティチェックリスト、アンチパターン集を網羅しています。
26
+
27
+ ### Cookie 同意管理基盤 (v5.0.0+)
28
+
29
+ GDPR / ePrivacy / 改正電気通信事業法 / APPI 準拠の Cookie 同意 UI を **15 分以内** に導入できます (opt-in、v4.x 完全互換)。
30
+
31
+ - **[Cookie Consent Integration Guide](./docs/cookie-consent-guide.md)** — 17 章の詳細統合手順 (App Router / Pages Router / Vite / Consent Mode v2 / Sentry replay / RSC / i18n / A11y / エラーハンドリング)
32
+ - **[v4 → v5 Migration Guide](./docs/MIGRATION_v5.md)** — minimal / full アップグレード手順、自前 gtag からの移譲、Rollback
33
+ - **[Troubleshooting Q&A](./docs/cookie-consent-troubleshooting.md)** — Banner / GA / Cookie / TypeScript / API / Sentry のよくある詰まりポイント
34
+ - **動作サンプル**: [App Router](./examples/cookie-consent/nextjs-app-router/) / [Pages Router](./examples/cookie-consent/nextjs-pages-router/) / [Vite + React](./examples/cookie-consent/vite-react/)
35
+
36
+ ```tsx
37
+ // app/layout.tsx (App Router、最短 3 ステップ)
38
+ import {
39
+ FFIDProvider,
40
+ FFIDAnalyticsProvider,
41
+ FFIDCookieBanner,
42
+ DEFAULT_OAUTH_SCOPES,
43
+ } from '@feelflow/ffid-sdk'
44
+
45
+ export default function RootLayout({ children }) {
46
+ return (
47
+ <FFIDProvider serviceCode="your-service" scope={DEFAULT_OAUTH_SCOPES}>
48
+ <FFIDAnalyticsProvider
49
+ baseUrl={process.env.NEXT_PUBLIC_FFID_BASE_URL!}
50
+ serviceApiKey={process.env.FFID_SERVICE_API_KEY!}
51
+ gaMeasurementId={process.env.NEXT_PUBLIC_GA_ID ?? null}
52
+ // Production-only measurement (v5.16.0+): suppress GA on
53
+ // preview/staging/local so non-prod traffic never pollutes the
54
+ // production GA4 property. Defaults to true if omitted.
55
+ gaEnabled={process.env.VERCEL_ENV === 'production'}
56
+ >
57
+ {children}
58
+ <FFIDCookieBanner />
59
+ </FFIDAnalyticsProvider>
60
+ </FFIDProvider>
61
+ )
62
+ }
63
+ ```
64
+
65
+ ## クイックスタート
66
+
67
+ ### 1. プロバイダーを設定(5行で完了!)
68
+
69
+ ```tsx
70
+ // app/layout.tsx
71
+ import { FFIDProvider, DEFAULT_OAUTH_SCOPES } from '@feelflow/ffid-sdk'
72
+
73
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
74
+ return (
75
+ <html lang="ja">
76
+ <body>
77
+ <FFIDProvider serviceCode="chatbot" scope={DEFAULT_OAUTH_SCOPES}>{children}</FFIDProvider>
78
+ </body>
79
+ </html>
80
+ )
81
+ }
82
+ ```
83
+
84
+ ### 2. 認証情報を使用
85
+
86
+ ```tsx
87
+ import { useFFID, useSubscription } from '@feelflow/ffid-sdk'
88
+
89
+ function Dashboard() {
90
+ const { user, isAuthenticated, login, logout } = useFFID()
91
+ const { isActive, planCode } = useSubscription()
92
+
93
+ if (!isAuthenticated) {
94
+ return <button onClick={login}>ログイン</button>
95
+ }
96
+
97
+ return (
98
+ <div>
99
+ <p>Welcome, {user.displayName ?? user.email}!</p>
100
+ <p>プラン: {planCode}</p>
101
+ <button onClick={logout}>ログアウト</button>
102
+ </div>
103
+ )
104
+ }
105
+ ```
106
+
107
+ ## UIコンポーネント
108
+
109
+ ```tsx
110
+ import {
111
+ FFIDLoginButton,
112
+ FFIDUserMenu,
113
+ FFIDOrganizationSwitcher,
114
+ FFIDSubscriptionBadge,
115
+ } from '@feelflow/ffid-sdk/components'
116
+
117
+ function Header() {
118
+ return (
119
+ <header>
120
+ <FFIDLoginButton>ログイン</FFIDLoginButton>
121
+ <FFIDOrganizationSwitcher />
122
+ <FFIDSubscriptionBadge />
123
+ <FFIDUserMenu />
124
+ </header>
125
+ )
126
+ }
127
+ ```
128
+
129
+ ### コンポーネントのスタイリング(classNames パターン)
130
+
131
+ 各UIコンポーネントは [Radix UI スタイルパターン](https://www.radix-ui.com/primitives/docs/guides/styling) に従った `classNames` プロップをサポートしています。これにより、コンポーネントの各パーツに個別のクラスを適用できます。
132
+
133
+ #### FFIDUserMenu
134
+
135
+ ```tsx
136
+ <FFIDUserMenu
137
+ classNames={{
138
+ container: 'relative', // ラッパー要素
139
+ button: 'focus:ring-2', // アバターボタン(トリガー)
140
+ avatar: 'rounded-full', // アバター画像/フォールバック
141
+ menu: 'shadow-lg', // ドロップダウンメニュー
142
+ userInfo: 'border-b', // ユーザー情報セクション
143
+ menuItem: 'hover:bg-gray-100', // カスタムメニュー項目
144
+ logout: 'text-red-600', // ログアウトボタン
145
+ }}
146
+ />
147
+ ```
148
+
149
+ #### FFIDOrganizationSwitcher
150
+
151
+ ```tsx
152
+ <FFIDOrganizationSwitcher
153
+ classNames={{
154
+ container: 'relative', // ラッパー要素
155
+ button: 'border rounded', // トリガーボタン
156
+ dropdown: 'shadow-md', // ドロップダウンメニュー
157
+ option: 'px-4 py-2', // 各組織オプション
158
+ optionSelected: 'bg-blue-50', // 選択中の組織(optionに加えて適用)
159
+ }}
160
+ />
161
+ ```
162
+
163
+ #### FFIDSubscriptionBadge
164
+
165
+ ```tsx
166
+ <FFIDSubscriptionBadge
167
+ classNames={{
168
+ badge: 'font-semibold', // バッジspan要素
169
+ }}
170
+ />
171
+ ```
172
+
173
+ > **Note**: `FFIDLoginButton` はシンプルな単一要素コンポーネントのため、標準の `className` プロップのみをサポートしています。
174
+
175
+ ## API リファレンス
176
+
177
+ ### FFIDProvider
178
+
179
+ アプリケーション全体をラップするプロバイダーコンポーネント。
180
+
181
+ ```tsx
182
+ <FFIDProvider
183
+ serviceCode="chatbot" // 必須: サービスコード
184
+ scope={DEFAULT_OAUTH_SCOPES} // 必須: OAuth scope (v3.0.0+)
185
+ apiBaseUrl="..." // オプション: カスタムAPIエンドポイント
186
+ debug={true} // オプション: デバッグログ有効化(非推奨、loggerを使用)
187
+ logger={customLogger} // オプション: カスタムロガー(下記参照)
188
+ refreshInterval={300000} // オプション: セッション更新間隔(ms)
189
+ onAuthStateChange={(user) => {}} // オプション: 認証状態変更時コールバック
190
+ onError={(error) => {}} // オプション: エラー時コールバック
191
+ cleanCallbackUrl={true} // オプション: token モードの callback URL cleanup(false で consumer に委譲、v5.22.0+)
192
+ >
193
+ {children}
194
+ </FFIDProvider>
195
+ ```
196
+
197
+ ### カスタムロガー
198
+
199
+ SDKのデバッグ出力をカスタマイズできます。デフォルトではログは出力されません(サイレント)。
200
+
201
+ ```tsx
202
+ import type { FFIDLogger } from '@feelflow/ffid-sdk'
203
+ import pino from 'pino' // または winston, bunyan 等
204
+
205
+ // アプリケーションのロガーインスタンス
206
+ const appLogger = pino({ level: 'debug' })
207
+
208
+ // FFID SDK用にラップ
209
+ const ffidLogger: FFIDLogger = {
210
+ debug: (...args) => appLogger.debug({ sdk: 'ffid' }, ...args),
211
+ info: (...args) => appLogger.info({ sdk: 'ffid' }, ...args),
212
+ warn: (...args) => appLogger.warn({ sdk: 'ffid' }, ...args),
213
+ error: (...args) => appLogger.error({ sdk: 'ffid' }, ...args),
214
+ }
215
+
216
+ // 使用例
217
+ <FFIDProvider serviceCode="chatbot" scope={DEFAULT_OAUTH_SCOPES} logger={ffidLogger}>
218
+ {children}
219
+ </FFIDProvider>
220
+ ```
221
+
222
+ **ロガー優先順位:**
223
+
224
+ 1. `logger` が指定されている場合 → カスタムロガーを使用
225
+ 2. `debug: true` で `logger` なし → `console` を使用(後方互換性)
226
+ 3. 両方なし → サイレント(no-op)
227
+
228
+ ### useFFID()
229
+
230
+ ユーザー・組織情報を取得するフック。
231
+
232
+ ```tsx
233
+ const {
234
+ user, // FFIDUser | null - 現在のユーザー
235
+ organizations, // FFIDOrganization[] - 所属組織一覧
236
+ currentOrganization, // FFIDOrganization | null - 現在の組織
237
+ isLoading, // boolean - ロード中
238
+ isAuthenticated, // boolean - 認証済み
239
+ login, // () => void - ログインページへリダイレクト
240
+ logout, // () => Promise<void> - ログアウト
241
+ switchOrganization, // (id: string) => void - 組織切り替え
242
+ refresh, // () => Promise<void> - セッション更新
243
+ } = useFFID()
244
+ ```
245
+
246
+ ### useSubscription()
247
+
248
+ 契約情報を取得するフック。
249
+
250
+ ```tsx
251
+ const {
252
+ subscription, // FFIDSubscription | null - 現在のサブスクリプション
253
+ planCode, // string | null - プランコード
254
+ isActive, // boolean - DB ステータスが 'active'
255
+ isTrialing, // boolean - トライアル中
256
+ isCanceled, // boolean - 解約済み
257
+ isTrialExpired, // boolean - トライアル期間超過
258
+ effectiveStatus, // EffectiveSubscriptionStatus | null - 意味論的アクセス制御値
259
+ isBlocked, // boolean - blocked / expired / canceled / trial_expired
260
+ isGrace, // boolean - past_due_grace (支払い失敗の猶予期間中)
261
+ hasPlan, // (plans: string | string[]) => boolean - プラン確認
262
+ hasAccess, // () => boolean - アクセス権確認 (active || past_due_grace)
263
+ hasAccessLegacy, // () => boolean - 旧セマンティクス (active || trialing, pre-2.19)
264
+ } = useSubscription()
265
+ ```
266
+
267
+ `effectiveStatus` は `/api/v1/subscriptions/ext/check` が返す意味論的ステータスと同じ値を取る。詳細は [契約期限切れハンドリング](#契約期限切れハンドリング) を参照。
268
+
269
+ ### useRequireActiveSubscription()
270
+
271
+ 契約が期限切れ/遮断状態のときに自動でリダイレクトするフック。
272
+
273
+ ```tsx
274
+ 'use client'
275
+ import { useRouter } from 'next/navigation'
276
+ import { useRequireActiveSubscription } from '@feelflow/ffid-sdk'
277
+
278
+ function ProtectedShell({ children }: { children: React.ReactNode }) {
279
+ const router = useRouter()
280
+ const { loading } = useRequireActiveSubscription({
281
+ redirectTo: (status) =>
282
+ status === 'canceled' ? '/contract-ended' : '/contract-required',
283
+ onRedirect: (url) => router.replace(url),
284
+ })
285
+
286
+ if (loading) return <FullPageSpinner />
287
+ return <>{children}</>
288
+ }
289
+ ```
290
+
291
+ オプション:
292
+
293
+ - `redirectTo`: `string` または `(status: EffectiveSubscriptionStatus) => string`
294
+ - `allowGrace` (default: `true`): `past_due_grace` を通過させるか
295
+ - `onRedirect` (optional): 独自のリダイレクト関数(未指定時は `window.location.href`)
296
+
297
+ ### withSubscription()
298
+
299
+ サブスクリプション確認HOC。
300
+
301
+ ```tsx
302
+ const PremiumFeature = withSubscription(MyComponent, {
303
+ plans: ['pro', 'enterprise'],
304
+ fallback: <UpgradePrompt />,
305
+ loading: <Spinner />,
306
+ })
307
+ ```
308
+
309
+ ## 契約期限切れハンドリング
310
+
311
+ 契約が失効したり解約されたとき、外部サービス側で適切にアクセスを遮断し、ユーザーを再契約動線に案内する必要があります。SDK は 3 層構成(トークン検証 / 契約チェック / Webhook 受信)をサポートしています。
312
+
313
+ ### 最小構成(Next.js App Router)
314
+
315
+ ```tsx
316
+ 'use client'
317
+ import { useRouter } from 'next/navigation'
318
+ import { useRequireActiveSubscription } from '@feelflow/ffid-sdk'
319
+
320
+ export default function Layout({ children }: { children: React.ReactNode }) {
321
+ const router = useRouter()
322
+ const { loading, effectiveStatus } = useRequireActiveSubscription({
323
+ redirectTo: '/contract-required',
324
+ onRedirect: (url) => router.replace(url),
325
+ })
326
+
327
+ if (loading) return <Spinner />
328
+ if (effectiveStatus !== 'active' && effectiveStatus !== 'past_due_grace') {
329
+ return null // リダイレクト発火中
330
+ }
331
+ return <>{children}</>
332
+ }
333
+ ```
334
+
335
+ **詳細な実装レシピ**(middleware、Express、UI 分岐、Webhook 受信、fixture API を使ったテスト、`EffectiveSubscriptionStatus` 別の UI 推奨動作まで) → [docs/03-implementation/EXPIRED_CONTRACT_HANDLING.md](https://github.com/feel-flow/feelflow-id-platform/blob/develop/docs/03-implementation/EXPIRED_CONTRACT_HANDLING.md)
336
+
337
+ ### ログインを拒否されたとき(`?error=access_denied` と `denial_reason`、v10.1.0)
338
+
339
+ 組織に席が無い・人数枠が満員などで FFID がログインを拒否すると、通常はまず FFID の案内ページ(席が無いときは `/subscriptions/seat-required`)が表示されます。そこから「サービスへ戻る」を押したとき、または `prompt=none` で authorize したときに、サービスの `redirect_uri` へ `code` の代わりに `?error=access_denied&denial_reason=<理由>&state=...` が返ります(FFID [oauth.md「契約・席が無いとき」](https://github.com/feel-flow/feelflow-id-platform/blob/develop/docs/04-api/oauth.md))。
340
+
341
+ - token モードの `FFIDProvider` は、これを `useFFID().error` と `onError` に出します(`state` を照合した後。一致しなければ `STATE_MISMATCH_ERROR`)
342
+ - 理由は `getFFIDAuthorizationError(error)` で読みます。`denialReason` は `no_membership` / `no_active_subscription` / `no_seat_assignment` / `subscription_expired` / `member_limit_reached`(`FFIDOAuthDenialReason`)か、理由が無い・未知のときは `null`
343
+ - SDK が URL から外すのは使用済みの `state` だけです。`error` / `denial_reason` は残るので、URL を自分で読んでいる実装もそのまま動きます
344
+ - **このエラーで `login()` を自動で呼び直さないでください。** FFID は同じ理由で拒否します。案内を出し、利用者の操作で再試行させます
345
+
346
+ ```tsx
347
+ 'use client'
348
+ import { useFFID, getFFIDAuthorizationError } from '@feelflow/ffid-sdk'
349
+
350
+ export function LoginDeniedNotice() {
351
+ const { error } = useFFID()
352
+ const denial = getFFIDAuthorizationError(error)
353
+ if (!denial) return null
354
+ switch (denial.denialReason) {
355
+ case 'no_seat_assignment':
356
+ case 'member_limit_reached':
357
+ return <p>このサービスの利用者に追加されていません。組織のオーナー / 管理者に追加を依頼してください。</p>
358
+ case 'subscription_expired':
359
+ case 'no_active_subscription':
360
+ return <p>組織の契約が有効ではありません。</p>
361
+ default:
362
+ return <p>ログインできませんでした。</p>
363
+ }
364
+ }
365
+ ```
366
+
367
+ サーバー(BFF)の `createServerAuthClient().handleCallback()` も、拒否のときは `error.code` に OAuth の `error`(`access_denied` など)を入れ、同じ `getFFIDAuthorizationError(result.error)` で理由を読めます(`@feelflow/ffid-sdk/server` から import)。
368
+
369
+ ### Server-side service access decision
370
+
371
+ 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 を再実装しない。
372
+
373
+ ```ts
374
+ import { createFFIDClient } from '@feelflow/ffid-sdk'
375
+
376
+ const ffid = createFFIDClient({
377
+ serviceCode: 'flow-board-ai',
378
+ scope: '',
379
+ authMode: 'service-key',
380
+ serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
381
+ })
382
+
383
+ const { data: access, error } = await ffid.checkServiceAccess({
384
+ userId,
385
+ organizationId,
386
+ allowGrace: true,
387
+ })
388
+
389
+ if (error || !access?.hasAccess) {
390
+ return Response.redirect('/contract-required')
391
+ }
392
+ ```
393
+
394
+ - `checkServiceAccess()` は **`data.hasAccess` が唯一の gate**。`result.error` は入力 validation など SDK が decision を作れない場合にだけ返る。
395
+ - `hasAccess`: FFID が決めた canonical access decision。`allowGrace=false` のときだけ SDK 側で `past_due_grace` を deny に変換する。
396
+ - `effectiveStatus`: `active` / `past_due_grace` / `blocked` / `canceled` / `trial_expired` / `expired`。
397
+ - `gracePeriodEndsAt`: `past_due_grace` が `blocked` に変わる時刻。表示・警告用であり、アクセス判定の source of truth にしない。
398
+ - `failPolicy`: 現在は `failClosed` 固定。FFID に到達できない、または canonical decision を取得できない場合は `result.error` ではなく `data.hasAccess=false` / `denialReason='ffid_unreachable'` / `data.error` を返す。呼び出し側は `result.error` だけで通過判定しない。
399
+ - `denialReason` には `member_limit_reached`(10.0.0)も含まれる。flat プランの人数枠が満員で席を付けられない利用者は、ログイン時に OAuth の `denial_reason: 'member_limit_reached'` で止まる。`checkServiceAccess()` 自体はこの値を返さず、人数枠・クレジット残高は `hasAccess` に影響しない。`memberLimitReached` / `credits` / `pricingModel` は情報として decision に載る(古い FFID は返さない)。
400
+
401
+ ### Credits(flat プラン、v10.0.0)
402
+
403
+ `pricingModel: 'flat'`(固定料金 + 人数枠 + 月次 AI クレジット)の契約では、AI を実行するたびに FFID のクレジット台帳へ消費を報告する。契約の正本は FFID の [CREDITS_API.md](https://github.com/feel-flow/feelflow-id-platform/blob/develop/docs/04-api/CREDITS_API.md)、手順は [docs/INTEGRATION_GUIDE.md の Credits 節](./docs/INTEGRATION_GUIDE.md#credits-flat-plans)。
404
+
405
+ ```ts
406
+ import { createFFIDClient, FFID_CREDITS_ERROR_CODES, type FFIDCreditsInsufficientDetails } from '@feelflow/ffid-sdk'
407
+
408
+ // サービスのバックエンド(consume は service-key モード専用)
409
+ const ffid = createFFIDClient({
410
+ serviceCode: 'chatbot-v2',
411
+ scope: '',
412
+ authMode: 'service-key',
413
+ serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
414
+ })
415
+
416
+ const { data, error } = await ffid.credits.consume({
417
+ subscriptionId,
418
+ amount: 50,
419
+ idempotencyKey: `chat.reply:${requestId}`, // AI 実行 1 回につき 1 つ。再試行でも同じキー
420
+ feature: 'chat.reply',
421
+ metadata: { model: 'example', tokens: 1200 }, // 個人情報は入れない
422
+ })
423
+
424
+ if (error?.code === FFID_CREDITS_ERROR_CODES.CREDITS_INSUFFICIENT) {
425
+ const details = error.details as unknown as FFIDCreditsInsufficientDetails
426
+ // 自動で再試行しない。購入導線(owner / admin)か管理者への依頼を出す
427
+ return { blocked: true, available: details.available }
428
+ }
429
+ if (error) {
430
+ // 5xx / NETWORK_ERROR は同じキー・同じ amount で再試行してよい(duplicate が返る)
431
+ throw new Error(error.message)
432
+ }
433
+ // data.status は 'consumed' | 'duplicate'
434
+ ```
435
+
436
+ | メソッド | 認証モード | 用途 |
437
+ | --- | --- | --- |
438
+ | `credits.consume(params)` | service-key のみ | AI 実行 1 回ぶんの消費を報告 |
439
+ | `credits.getBalance(subscriptionId)` | token / service-key | 残高・当期・内訳・`isLow` / `isExhausted` |
440
+ | `credits.listHistory(subscriptionId, { cursor?, limit?, type? })` | token / service-key | 履歴(新しい順) |
441
+ | `credits.listPacks(serviceCode?)` | token / service-key | 購入できるパック |
442
+ | `credits.createPackCheckout(params)` | token のみ(owner / admin) | パック購入の Stripe Checkout URL |
443
+ | `credits.getBuyCreditsUrl({ subscriptionId, orgId? })` / `redirectToBuyCredits` | どれでも | FFID の契約詳細で購入ダイアログを開く URL(UUID か `ffid:<uuid>`。`sub_...` は不可) |
444
+ | `getSeats(subscriptionId)` | token / service-key | 人数枠(`maxMembers` / `assignedMembers` / `availableMembers`、`null` = 無制限) |
445
+
446
+ - AI 機能のゲートは **残高**(`consume` の結果・`getBalance()`・`checkServiceAccess()` の `credits`)で行い、`hasAccess` では行わない。クレジットが 0 でも `hasAccess` は `true` のまま。
447
+ - `consume` を service-key 以外で、`createPackCheckout` を token 以外で呼ぶと、ネットワークに出る前に `FORBIDDEN`(`details.reason: 'api_key_required'` / `'bearer_required'`)が返る。
448
+ - 残高の変化は webhook `credits.granted` / `credits.low` / `credits.exhausted` / `credits.pack_purchased` でも届く(`@feelflow/ffid-sdk/webhooks`)。
449
+
450
+ ### Organization member management
451
+
452
+ `organization:read` / `organization:write` scope を持つ service-key または token で、組織メンバーの参照・追加・ロール変更・削除ができます。
453
+
454
+ ```ts
455
+ import { createFFIDClient } from '@feelflow/ffid-sdk/server'
456
+
457
+ const ffid = createFFIDClient({
458
+ serviceCode: 'flow-board-ai',
459
+ scope: '',
460
+ authMode: 'service-key',
461
+ serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
462
+ })
463
+
464
+ const addResult = await ffid.addMember({
465
+ organizationId,
466
+ email: 'new-member@example.com',
467
+ role: 'member',
468
+ })
469
+
470
+ if (addResult.error) {
471
+ throw new Error(addResult.error.message)
472
+ }
473
+
474
+ await ffid.updateMemberRole({
475
+ organizationId,
476
+ userId: addResult.data.member.userId,
477
+ role: 'admin',
478
+ })
479
+ ```
480
+
481
+ - `addMember` は既存 FFID ユーザーを active member として追加します。招待メール送信や token 発行は行いません。
482
+ - `role` は `admin` / `member` / `viewer` のみ指定可能です。`owner` は追加・昇格 API では扱いません。
483
+ - agency client と同じ呼び味が必要な場合は `ffid.addMember(organizationId, { email, role })` も使えます。
484
+ - 未登録の人を招待する(招待メールと受諾リンクを FFID が送る)ときは `inviteMember({ organizationId, email, role, inviterUserId })` を使います。`inviterUserId` は、サービスで招待を操作した人の FFID ユーザー ID です。FFID の「保留中の招待」と招待メールに、その人が招待者として出ます。
485
+ - service-key モードでは `inviterUserId` を渡してください。その人が対象の組織の owner / admin でなければ 403 `FORBIDDEN`(`details.reason: 'inviter_not_organization_admin'`)で、招待は作られません。省略すると従来どおり組織の owner が招待者になり、メールの招待者名は `service:<serviceCode>` です。
486
+ - token モードでは省略します(ログイン中の利用者が招待者になります)。別の人の ID を渡すと 400 `VALIDATION_ERROR` です。
487
+ - `inviterUserId` は「誰を招待者として記録するか」の指定で、招待できる範囲は増えません。FFID が確かめるのは「招待できる立場か」までなので、実際に操作した本人の ID だけを渡してください。
488
+
489
+ ### Organization profile / public logo wall
490
+
491
+ hub 等の外部サービスが企業プロフィールを登録・照会し、FeelFlow 全サービス共通のロゴウォールを表示するためのメソッド(#5485 / #5547)。
492
+
493
+ - **`getOrganizationProfile` / `upsertOrganizationProfile` / `uploadOrganizationLogo`** は **`authMode: 'service-key'` 専用**(scope: `organization:read` / `organization:write`)。`token` / `cookie` モードで呼ぶとサーバーへ往復せず `VALIDATION_ERROR`。
494
+ - **`getPublicLogoWall`** は認証不要。service-key を送らない。エンドポイントは `GET /api/v1/organizations/logo-wall`(公開 API なので SDK を使わず直接 GET してもよいが、型付きエラーが欲しければ SDK 経由を推奨)。
495
+ - `upsert` の `listingConsent.consented: true` では `consentVersion` と `consentedByUserId` が必須。同意操作者が対象 org の active メンバーでないとサーバーが `CONSENTED_BY_NOT_MEMBER` を返す。
496
+
497
+ ```ts
498
+ import {
499
+ createFFIDClient,
500
+ FFID_ORGANIZATION_PROFILE_ERROR_CODES,
501
+ } from '@feelflow/ffid-sdk/server'
502
+
503
+ const ffid = createFFIDClient({
504
+ serviceCode: 'feel-agent',
505
+ scope: '',
506
+ authMode: 'service-key',
507
+ serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
508
+ })
509
+
510
+ const current = await ffid.getOrganizationProfile(organizationId)
511
+ if (current.error) throw new Error(current.error.message)
512
+
513
+ const upsert = await ffid.upsertOrganizationProfile(organizationId, {
514
+ displayName: '株式会社サンプル',
515
+ industry: 'it-software',
516
+ companySize: '11-50',
517
+ websiteUrl: 'https://example.co.jp',
518
+ listingConsent: {
519
+ consented: true,
520
+ consentVersion: 'v1.0',
521
+ consentedByUserId: userId,
522
+ },
523
+ })
524
+ if (upsert.error?.code === FFID_ORGANIZATION_PROFILE_ERROR_CODES.CONSENTED_BY_NOT_MEMBER) {
525
+ throw new Error(upsert.error.message)
526
+ }
527
+
528
+ await ffid.uploadOrganizationLogo(organizationId, logoFile)
529
+
530
+ const wall = await ffid.getPublicLogoWall()
531
+ // wall.data.entries: { displayName, logoUrl, websiteUrl }[]
532
+ ```
533
+
534
+ 位置引数と params オブジェクトの両方を受け付ける(`addMember` と同じ)。
535
+
536
+ ### Provisioning(users / organizations)
537
+
538
+ 外部サービスからの一括移行向けに、**service-key 認証**でユーザー・組織を冪等にプロビジョニングするメソッド。REST を直叩きせず `createFFIDClient` 経由で呼べる(SDK-first、#3790 / #4127)。
539
+
540
+ - **`authMode: 'service-key'`(`X-Service-Api-Key`)専用**。`token`(Bearer)/ `cookie` モードで呼ぶとサーバーへ往復せず即座に `VALIDATION_ERROR` を返す(サーバー側エンドポイントが service-key 認証のみを受け付けるため)。
541
+ - 必要 scope: `provisionUser` → `user:provision`、`provisionOrganization` → `organization:write`。
542
+
543
+ ```ts
544
+ import { createFFIDClient } from '@feelflow/ffid-sdk/server'
545
+
546
+ const ffid = createFFIDClient({
547
+ serviceCode: 'praxis',
548
+ scope: '',
549
+ authMode: 'service-key',
550
+ serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
551
+ })
552
+
553
+ // 1. ユーザーを冪等に作成(新規なら created:true / 既存なら created:false)
554
+ const userRes = await ffid.provisionUser({
555
+ email: 'owner@example.com',
556
+ profile: { displayName: '山田 太郎' },
557
+ })
558
+ if (userRes.error) throw new Error(userRes.error.message)
559
+ if (!userRes.data.dryRun) {
560
+ console.log(userRes.data.created ? '新規作成' : '既存ユーザー', userRes.data.user.id)
561
+ }
562
+
563
+ // 2. その owner の組織を作成し、メンバーを冪等に追加
564
+ const orgRes = await ffid.provisionOrganization({
565
+ name: 'Example 組織',
566
+ ownerEmail: 'owner@example.com',
567
+ members: [
568
+ { email: 'member1@example.com', role: 'member' },
569
+ { email: 'member2@example.com', role: 'viewer' },
570
+ ],
571
+ })
572
+ if (orgRes.error) {
573
+ // owner が未登録なら error.code === 'OWNER_NOT_FOUND'(先に provisionUser が必要)
574
+ throw new Error(orgRes.error.message)
575
+ }
576
+ if (!orgRes.data.dryRun) {
577
+ for (const m of orgRes.data.members) {
578
+ console.log(m.email, m.status) // 'added' | 'already_member' | 'user_not_found'
579
+ }
580
+ }
581
+ ```
582
+
583
+ #### 冪等セマンティクス
584
+
585
+ - `provisionUser`: **既存メールは `created: false`**(HTTP 200、no-op)、新規メールは `created: true`(HTTP 201、パスワードレス・メール確認済み)。メールはサーバー側で normalize(trim + lowercase)されるため、大文字小文字違いの再送でも同一ユーザーに解決する。新規作成で `profile` を渡した場合のみ `profileWritten` が付き、`false` は「ユーザーは作成されたがプロフィール書き込みに失敗」→ 再送を推奨。
586
+ - `provisionOrganization`: owner が同名の組織を既に持つ場合は `created: false`(HTTP 200)。`members` は冪等に追加され、各要素の `status` が `added` / `already_member` / `user_not_found` を返す。
587
+
588
+ #### dryRun
589
+
590
+ `dryRun: true` を渡すと **一切書き込まず**、実行時に何が起きるかだけを返す。レスポンスは `dryRun` 判別のタグ付きユニオン。
591
+
592
+ ```ts
593
+ const preview = await ffid.provisionUser({ email: 'owner@example.com', dryRun: true })
594
+ if (!preview.error && preview.data.dryRun) {
595
+ console.log(preview.data.wouldCreate) // true = 実行すれば新規作成される
596
+ }
597
+
598
+ const orgPreview = await ffid.provisionOrganization({
599
+ name: 'Example 組織',
600
+ ownerEmail: 'owner@example.com',
601
+ members: [{ email: 'member1@example.com', role: 'member' }],
602
+ dryRun: true,
603
+ })
604
+ if (!orgPreview.error && orgPreview.data.dryRun) {
605
+ console.log(orgPreview.data.wouldCreate) // 組織を新規作成するか
606
+ // per-member plan status: 'would_add' | 'already_member' | 'user_not_found'
607
+ orgPreview.data.members.forEach((m) => console.log(m.email, m.status))
608
+ }
609
+ ```
610
+
611
+ `if (res.data.dryRun)` で TypeScript が型を絞り込むため、dry-run 分岐では `wouldCreate`、実行分岐では `created` がそれぞれ型安全に参照できる。
612
+
613
+ ### getProfile() / updateProfile()
614
+
615
+ ログイン中ユーザー自身のプロフィールを取得・更新するメソッド(`createFFIDClient` から呼び出し)。
616
+
617
+ - エンドポイント: `GET /api/v1/users/ext/me` / `PUT /api/v1/users/ext/me`
618
+ - 対応 authMode: `token`(Bearer)のみが SDK 経由で成立する
619
+ - **cookie モードは非対応** — ext エンドポイントはクロスオリジン用途のため、Bearer token または `X-Service-Api-Key` のどちらかが必須。FFID 自身の UI(同一オリジン)は従来通り `/api/v1/users/me` を使う
620
+ - **service-key モードも SDK 経由では非対応** — API Key 認証時はバックエンドが `?userId=<uuid>` クエリを要求するが、`getProfile()` / `updateProfile()` は自分自身(ambient user)を前提とするため、`userId` を受け取らない。サーバー間で任意ユーザーのプロフィールを操作したい場合は `fetchWithAuth` で直接エンドポイントを叩いてください
621
+ - 外部サービス(hub 等)のフロントエンドから token モードで呼ぶのが想定される主要パターン
622
+
623
+ ```tsx
624
+ import { createFFIDClient } from '@feelflow/ffid-sdk'
625
+
626
+ const client = createFFIDClient({
627
+ serviceCode: 'hub',
628
+ authMode: 'token',
629
+ apiBaseUrl: 'https://id.feelflow.net',
630
+ })
631
+
632
+ // 取得
633
+ const { data: profile, error } = await client.getProfile()
634
+ if (error) {
635
+ console.error('プロフィール取得失敗:', error.message)
636
+ } else {
637
+ console.log(profile.email, profile.displayName, profile.timezone)
638
+ }
639
+
640
+ // 更新(部分更新 — 渡したフィールドだけ差し替え)
641
+ const { data: updated, error: updateError } = await client.updateProfile({
642
+ displayName: '山田 太郎',
643
+ timezone: 'Asia/Tokyo',
644
+ locale: 'ja',
645
+ preferences: { theme: 'dark' },
646
+ })
647
+ ```
648
+
649
+ `updateProfile` に空オブジェクト `{}` を渡すと `VALIDATION_ERROR` が返ります(無意味なラウンドトリップを防止)。
650
+
651
+ #### フィールドのクリア(null を渡す)
652
+
653
+ 対応する optional フィールドに `null` を渡すと、FFID backend 側で該当カラムを
654
+ SQL NULL にクリアします(v2.16.0〜 / #2354)。個人プロフィールの
655
+ `companyName` は廃止予定で、旧クライアントからの値は受理しても無視します。
656
+
657
+ ```tsx
658
+ // 部署をクリア
659
+ await client.updateProfile({
660
+ department: null,
661
+ })
662
+ ```
663
+
664
+ 値のセマンティクス:
665
+
666
+ | 渡す値 | FFID backend の挙動 |
667
+ | --- | --- |
668
+ | `undefined` / キー未指定 | 未変更(partial update) |
669
+ | `null` | クリア(SQL NULL を書き込む) |
670
+ | `""`(空文字列) | 空文字リテラルをそのまま保存(`null` 扱いには**ならない**) |
671
+
672
+ 対応フィールド: `displayName` / `phone` / `department` / `jobTitle` / `preferences`。`timezone` / `locale` は application-level invariant(サーバー側 normalization が string 前提)のため null 非許容。クリアは不可 — キー未指定で現状維持、もしくは新しい有効な値を渡す。
673
+
674
+ ### getAnalyticsConfig() (#2347)
675
+
676
+ 外部サービスが自身に割り当てられた GA4 Measurement ID を取得するメソッド。
677
+
678
+ - エンドポイント: `GET /api/v1/ext/analytics/config?service=<code>`
679
+ - 必要 scope: `analytics:read`(Bearer / API Key 両方で enforce)
680
+ - レスポンス: `FFIDAnalyticsConfig` (`{ code, measurementId, displayName, isActive }`)
681
+ - **archived service** (`isActive: false`) でも 200 を返す — caller が次回 deploy で tracking 停止判断する間、in-flight events が 5xx を起こさないように measurementId は引き続き返す
682
+
683
+ ```ts
684
+ import { createFFIDClient } from '@feelflow/ffid-sdk/server'
685
+
686
+ const client = createFFIDClient({
687
+ serviceCode: 'flow-board-ai',
688
+ authMode: 'service-key',
689
+ serviceApiKey: process.env.FFID_SERVICE_API_KEY!,
690
+ })
691
+
692
+ const result = await client.getAnalyticsConfig('feel-agent-ai')
693
+ if (result.error) {
694
+ if (result.error.code === 'SERVICE_NOT_FOUND') {
695
+ // 該当 GA4 stream がまだ sync されていない / typo
696
+ } else {
697
+ console.error(`[FFID] analytics config fetch failed: ${result.error.code}`)
698
+ }
699
+ } else if (result.data.isActive) {
700
+ // GA4 タグを描画して events を送信
701
+ loadGA4Script(result.data.measurementId)
702
+ }
703
+ ```
704
+
705
+ エラーコード:
706
+
707
+ - `VALIDATION_ERROR` — `serviceCode` が空 / kebab-case 形式違反(SDK 側 pre-validate)
708
+ - `INSUFFICIENT_SCOPE` (403) — `analytics:read` scope なし
709
+ - `SERVICE_NOT_FOUND` (404) — DB に未登録の service code
710
+ - `INVALID_PARAM` (400) — server 側で形式違反を検出(pre-validate 通過後の boundary)
711
+
712
+ ## 代理店の招待リンク(`?invite=`)
713
+
714
+ 代理店の招待リンクから来た顧客が FFID で登録すると、作られた組織がその代理店に紐付きます。SDK の役割は**トークンを signup URL まで運ぶこと**だけで、紐付けの実務は FFID 側が行います。
715
+
716
+ > 代理店ポータルが発行するリンクは FFID の origin(`https://id.feelflow.net/signup?invite=…`)を指します。サービスサイトを着地点にする形(`https://<あなたのサービス>/?invite=…`)は、代理店がトークンを自分のキャンペーン URL に貼って成立させる導線です。この節が効くのはその形です。
717
+
718
+ ### React 利用者(`FFIDProvider` を mount している場合)
719
+
720
+ **配線は不要です。** provider が mount 時に着地 URL の `invite` を捕捉し、first-party cookie へ保存します。`getSignupUrl()` が自動でトークンを載せます。
721
+
722
+ ただし provider は捕捉結果を返さないので、**cookie の保持に失敗したことを利用者側で観測する経路はありません**(debug ログには落ちます)。検知したい場合は provider に加えて自分で `captureAgencyInviteToken()` を呼び、戻り値の `persisted` を見てください。
723
+
724
+ ### React 以外 / provider を使わない場合
725
+
726
+ `captureAgencyInviteToken()` を**ページ遷移ごとに**呼びます。
727
+
728
+ ```typescript
729
+ import { captureAgencyInviteToken } from '@feelflow/ffid-sdk'
730
+
731
+ // ページ読み込みごと(SPA ならルート遷移ごと)に呼ぶ
732
+ const { token, source, persisted } = captureAgencyInviteToken()
733
+
734
+ if (persisted === false) {
735
+ // cookie がブラウザに落とされた。今回の URL 経由では紐付くが、
736
+ // パラメータの無い別ページへ回遊すると失われる。
737
+ } else if (persisted === undefined && source === 'url') {
738
+ // 書ける環境が無かった(SSR)。ブラウザ側で捕捉し直す必要がある。
739
+ }
740
+ ```
741
+
742
+ 呼ばなかった場合は「着地ページで即 signup した人だけ紐付く」まで**劣化します**(壊れるのではなく、下記の回遊経路を救えなくなります)。
743
+
744
+ ### なぜページ遷移ごとに呼ぶ必要があるのか
745
+
746
+ 実際の導線は次の形です。
747
+
748
+ ```text
749
+ /?invite=abc に着地 → 料金ページを見る → /pricing で「登録」を押す
750
+ ```
751
+
752
+ `getSignupUrl()` が呼ばれる時点の URL には `invite` がありません。着地時点で捕捉して保持する以外にこの経路を救う方法がなく、**SDK 自身はページ読み込みを hook できない**ため、起動点は利用者側にあります。
753
+
754
+ ### ⚠️ 効かない経路: SSR(Server Component)で signup URL を組む場合
755
+
756
+ サーバーには `window.location.search` も `document.cookie` もありません。`getSignupUrl()` をそのまま呼ぶとトークンは載らないので、呼び出し側が読んだ値を渡してください(渡さない場合はプロセスあたり 1 度 warn します)。
757
+
758
+ ```typescript
759
+ // Server Component
760
+ import { cookies } from 'next/headers'
761
+ import { AGENCY_INVITE_COOKIE_NAME } from '@feelflow/ffid-sdk'
762
+
763
+ const inviteToken = (await cookies()).get(AGENCY_INVITE_COOKIE_NAME)?.value
764
+ const href = ffid.getSignupUrl(undefined, { inviteToken })
765
+ ```
766
+
767
+ ### `authMode: 'token'`(OAuth モード)は v7.4.0 で対応済み
768
+
769
+ この設定では signup 意図が `/api/v1/oauth/authorize` を経由するため `getSignupUrl()` を通りません。**v7.4.0 以降は `redirectToAuthorize` が `invite` を authorize URL へ転送し、FFID 側が `/signup` へ引き継ぎます**(`redirectToLogin({ screenHint: 'signup' })` は token モードでは `redirectToAuthorize` へ委譲されるので、こちらも同様に載ります)。配線は不要です。
770
+
771
+ v7.3.x までは token モードでトークンが FFID に 1 度も届かず、紐付けが無言で落ちていました。**v7.4.0 未満を使っている場合は、SDK と FFID 本体の両方を更新してください** — 転送は SDK が `invite` を送り、FFID が `/signup` へ stamp する 2 段で成立します。
772
+
773
+ ⚠️ 転送の gate は `screenHint === 'signup'` です。`prompt` に `'create'` を指定して signup 画面へ向かう経路は SDK の `FFIDPrompt`(`'select_account' | 'login'`)が受け付けないため到達しません。
774
+
775
+ ### cookie の仕様
776
+
777
+ | 項目 | 値 |
778
+ | --- | --- |
779
+ | 名前 | `ffid_invite`(first-party・consumer 自身のホスト) |
780
+ | 有効期限 | 30 日 |
781
+ | `Path` | `/` |
782
+ | `SameSite` | `Lax` |
783
+ | `Secure` | `NODE_ENV === 'production'` のときだけ(`options.secure` で上書き可) |
784
+ | `Domain` | 既定は現在のホストのみ(`options.domain` で指定可) |
785
+
786
+ `Secure` を本番限定にしているのは、**https でない独自ドメイン**(LAN IP や plain http の検証機)でブラウザが `Secure` cookie を黙って捨てるためです。`http://localhost` は secure context として扱われるので `Secure` でも保持されます(ローカル開発では上書き不要)。
787
+
788
+ URL と cookie の両方にトークンがある場合は **URL が勝ち、cookie も更新されます**(最後に踏んだリンクが勝つ)。
789
+
790
+ **削除には書き込み時と同じ `path` / `domain` / `secure` が必要です。** ブラウザが一致を要求するため、既定以外で書いた cookie は既定の `clearAgencyInviteToken()` では消えません。
791
+
792
+ ### ⚠️ 保持期間 30 日の副作用
793
+
794
+ 招待リンクは既定で使用回数が無制限です。したがって保持中の cookie は、**同じブラウザで後から作られた組織も同じ代理店に紐付けます**。共有ブラウザではこれが誤紐付けになります。
795
+
796
+ SDK は登録完了時に cookie を消しません(登録完了は FFID のドメインで起こるため consumer から検知しにくい)。単回にしたい場合は、登録完了を検知できる地点で明示的に消してください。
797
+
798
+ ```typescript
799
+ import { clearAgencyInviteToken } from '@feelflow/ffid-sdk'
800
+
801
+ clearAgencyInviteToken() // 削除が read-back で確認できたら true / SSR では undefined
802
+ ```
803
+
804
+ ### ⚠️ トークンをログに出さないこと
805
+
806
+ このトークンは URL に載る**平文の資格情報**で、**単回使用ではありません**(招待リンクの使用回数上限は既定で無制限)。漏れたトークンは、新規組織が作られる限り何度でも紐付けに使えます。
807
+
808
+ SDK 内部ではログ・エラーレポート・アナリティクスに値を出しません。`getSignupUrl()` の出力を自前でログや解析イベントに送っている場合は、`redactAgencyInviteToken()` を通してください。
809
+
810
+ ```typescript
811
+ import { redactAgencyInviteToken } from '@feelflow/ffid-sdk'
812
+
813
+ logger.info('signup url', redactAgencyInviteToken(ffid.getSignupUrl()))
814
+ // → https://id.feelflow.net/signup?redirect=...&service=svc&invite=[REDACTED]
815
+ ```
816
+
817
+ 素の `invite=` だけでなく、`redirect` パラメータに着地 URL が percent-encode されて入る**入れ子**(`redirect=…%3Finvite%3D…`)も潰します。覆う encode の深さは **2 段まで**です。GA4 の `page_location` のように URL を丸ごと送るイベントでも同じ経路を通してください。
818
+
819
+ **login URL にも `redirect` 経由で入れ子で載ります。** 専用の `&invite=` は付きませんが(紐付けは新規登録時の組織作成に対して起こるため)、着地 URL が `?invite=…` なら login URL の `redirect` の中に入ります。login URL をログに出す場合も redact してください。
820
+
821
+ ## 型定義
822
+
823
+ ```typescript
824
+ interface FFIDUser {
825
+ id: string
826
+ email: string
827
+ displayName: string | null
828
+ avatarUrl: string | null
829
+ locale: string | null
830
+ timezone: string | null
831
+ createdAt: string
832
+ }
833
+
834
+ interface FFIDOrganization {
835
+ id: string
836
+ name: string
837
+ slug: string
838
+ role: 'owner' | 'admin' | 'member'
839
+ status: 'active' | 'invited' | 'suspended'
840
+ }
841
+
842
+ interface FFIDSubscription {
843
+ id: string
844
+ serviceCode: string
845
+ serviceName: string
846
+ planCode: string
847
+ planName: string
848
+ status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'paused'
849
+ currentPeriodEnd: string | null
850
+ }
851
+ ```
852
+
853
+ ### OAuth userinfo の契約要約
854
+
855
+ token mode では SDK は `/api/v1/oauth/userinfo` を呼び出し、基本プロフィールに加えてサービス契約の要約を受け取ります。
856
+ この要約により、追加 API を呼ばずにプラン判定や UI 分岐を行えます。
857
+
858
+ ```ts
859
+ interface FFIDOAuthUserInfoSubscription {
860
+ subscriptionId: string | null
861
+ status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'paused' | null
862
+ planCode: string | null
863
+ seatModel: 'organization' | null
864
+ memberRole: 'owner' | 'admin' | 'member' | 'viewer' | null
865
+ organizationId: string | null
866
+ }
867
+ ```
868
+
869
+ `seatModel` はシートモデル識別用であり、organization と role は userinfo の解決済み組織文脈として扱います。
870
+
871
+ ## React 以外の環境で使う
872
+
873
+ 本 SDK は React/Next.js 向けに設計されていますが、一部のモジュールはフレームワーク非依存で利用できます。
874
+
875
+ ### サーバーサイドモジュール(React 依存なし)
876
+
877
+ 以下の subpath exports は React に一切依存しません。Node.js、Deno、Bun 等で即座に利用できます。
878
+
879
+ ```typescript
880
+ // 利用規約・法的文書
881
+ import { createFFIDLegalClient } from '@feelflow/ffid-sdk/legal'
882
+
883
+ // Agency(代理店)管理
884
+ import { createFFIDAgencyClient } from '@feelflow/ffid-sdk/agency'
885
+
886
+ // お知らせ取得
887
+ import { createFFIDAnnouncementsClient } from '@feelflow/ffid-sdk/announcements'
888
+
889
+ // Webhook 署名検証・ハンドラー(※ Node.js crypto が必要)
890
+ import { createFFIDWebhookHandler, verifyWebhookSignature } from '@feelflow/ffid-sdk/webhooks'
891
+ ```
892
+
893
+ > **Note**: `webhooks` モジュールは Node.js の `crypto` モジュールと `Buffer` を使用します。Cloudflare Workers で利用する場合は [`nodejs_compat` 互換フラグ](https://developers.cloudflare.com/workers/runtime-apis/nodejs/)を有効にしてください。
894
+
895
+ これらのモジュールは独立した subpath export として公開されているため、メインエントリ (`@feelflow/ffid-sdk`) を経由せず、React の依存が伝播しません。
896
+
897
+ ### `createFFIDClient` を非 React 環境で使う
898
+
899
+ 非 React 環境では、可能な限り上記の subpath exports(`/legal`、`/webhooks` 等)の個別クライアントを使用してください。メインエントリの `createFFIDClient` を使う必要がある場合、`createFFIDClient` 自体は React を使用しませんが、メインエントリに含まれるため **bundler 環境では React を external に指定する**必要があります。
900
+
901
+ #### Cloudflare Workers
902
+
903
+ ビルドコマンドで `--external` を指定するか、カスタム esbuild 設定で `external` を設定してください。
904
+
905
+ ```bash
906
+ # wrangler のビルドコマンド例
907
+ esbuild src/index.ts --bundle --format=esm --external:react --external:react-dom
908
+ ```
909
+
910
+ #### Vue / Nuxt(Vite)
911
+
912
+ ```typescript
913
+ // vite.config.ts
914
+ export default defineConfig({
915
+ build: {
916
+ rollupOptions: {
917
+ external: ['react', 'react-dom'],
918
+ },
919
+ },
920
+ })
921
+ ```
922
+
923
+ #### esbuild
924
+
925
+ ```bash
926
+ esbuild src/index.ts --bundle --external:react --external:react-dom
927
+ ```
928
+
929
+ #### webpack
930
+
931
+ ```javascript
932
+ // webpack.config.js
933
+ module.exports = {
934
+ externals: {
935
+ react: 'react',
936
+ 'react-dom': 'react-dom',
937
+ },
938
+ }
939
+ ```
940
+
941
+ > **Note**: サーバーサイドで bundler を使わずに実行する場合、**subpath exports(`@feelflow/ffid-sdk/legal` 等)を使用すれば** external 設定なしで React がインストールされていなくても動作します。メインエントリ(`@feelflow/ffid-sdk`)を ESM で import する場合は、モジュールグラフが静的に解決されるため React が必要です。
942
+
943
+ ### `peerDependencies` は optional です
944
+
945
+ SDK の `package.json` で `react` / `react-dom` は `optional: true` に設定済みです(利用者側での設定は不要)。React をインストールしなくても `npm install` 時に warning は発生しません。
946
+
947
+ ```json
948
+ // SDK の package.json に設定済み(参考)
949
+ {
950
+ "peerDependenciesMeta": {
951
+ "react": { "optional": true },
952
+ "react-dom": { "optional": true }
953
+ }
954
+ }
955
+ ```
956
+
957
+ ## サーバー側のアクセストークン検証の照会障害(SDK 11)
958
+
959
+ `verifyAccessToken()` の introspect 照会(JWT検証後の `includeProfile: true` を含む)が
960
+ HTTP429 / 5xx を返す場合は、本文のエラーコードやJSONの可否によらず
961
+ `NETWORK_ERROR` と `details.status` を返します。SDKの診断ログにはHTTPステータスだけを
962
+ 記録し、上流本文・アクセストークンをコピーしません。自動再試行は行いません。
963
+
964
+ 連携先は、この照会障害を本人の認証不正と区別し、利用は拒否したうえで一時的な利用不可
965
+ (例: HTTP503)として案内してください。HTTP200の `active: false` は引き続き
966
+ `TOKEN_VERIFICATION_ERROR`、HTTP400 / 401 / 403の明示エラーコードは従来どおり保持します。
967
+ このメソッドによる本人確認とは別に、契約の利用可否と本人のactiveな席を確認する必要があります。
968
+
969
+ ## E2E テストモード(`@feelflow/ffid-sdk/server/test`)
970
+
971
+ > **⚠️ SECURITY NOTICE** — テストモードは Bearer トークン検証を **意図的にバイパス** する仕組みです。本番環境で誤って有効化すると、登録されたバイパストークンを持つ任意のリクエストが認証通過します。
972
+
973
+ 各サービスで E2E テストを書く際、`verifyAccessToken` の introspect 呼び出しをモックする実装を独自に持つ必要がなくなります。SDK 側で **多重 production guard 付き** の bypass クライアントを提供します。
974
+
975
+ ```ts
976
+ import { createFFIDClient } from '@feelflow/ffid-sdk/server'
977
+ import { createTestFFIDClient } from '@feelflow/ffid-sdk/server/test'
978
+
979
+ const isE2E =
980
+ process.env.NODE_ENV !== 'production' &&
981
+ process.env.FFID_TEST_MODE === 'true'
982
+
983
+ const client = isE2E
984
+ ? createTestFFIDClient({
985
+ users: [
986
+ {
987
+ bypassToken: process.env.E2E_TEST_BYPASS_SECRET!,
988
+ userInfo: {
989
+ sub: 'e2e-test-sub',
990
+ email: 'e2e@example.com',
991
+ name: 'E2E Test User',
992
+ picture: null,
993
+ },
994
+ },
995
+ ],
996
+ })
997
+ : createFFIDClient({ /* normal production options */ })
998
+
999
+ const result = await client.verifyAccessToken(bearerToken)
1000
+ ```
1001
+
1002
+ ### Built-in production guards (defense-in-depth)
1003
+
1004
+ - `NODE_ENV` を **trim + lowercase** 後に比較。`"production "`(改行混入)や `"Production"` も production として扱う(Vercel 環境変数の copy/paste 事故対策)
1005
+ - `process.env` を露出しない runtime(Edge / Cloudflare Workers / browser)では **fail-close** で構築拒否
1006
+ - `bypassToken` の重複検知 / 空チェック / `userInfo.sub` 必須チェック(構築時 throw)
1007
+ - 未登録 token は **fail-close**(実 introspect への暗黙 fallthrough は行わない)
1008
+ - 構築時点で `users` のスナップショットを取り、入力配列の post-construction mutation は無視
1009
+ - 各 `verifyAccessToken` 呼び出しは新しいオブジェクトを返却(caller mutation が後続呼び出しを汚染しない)
1010
+
1011
+ ### `allowInProduction` escape hatch
1012
+
1013
+ staging が `NODE_ENV=production` をミラーするケース等のみ、明示的な ack 文字列で有効化できます:
1014
+
1015
+ ```ts
1016
+ import {
1017
+ createTestFFIDClient,
1018
+ TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK,
1019
+ } from '@feelflow/ffid-sdk/server/test'
1020
+
1021
+ createTestFFIDClient({
1022
+ users: [...],
1023
+ allowInProduction: TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK,
1024
+ })
1025
+ // → process.emitWarning(..., 'FFIDTestModeInProduction') を毎構築時に発火
1026
+ ```
1027
+
1028
+ `boolean` ではなく **literal string ack** を要求する型なので、`allowInProduction: someBooleanFlag` のような誤代入はコンパイルエラーになります。ack 文字列は grep 可能で監査も容易です。
1029
+
1030
+ ### サブパス分離
1031
+
1032
+ `createTestFFIDClient` は **`@feelflow/ffid-sdk/server/test` からのみ** import 可能です(`@feelflow/ffid-sdk/server` にも main entry にも含まれない)。本番コードからの誤 import は ESLint の `no-restricted-imports` 等で検知することを推奨します:
1033
+
1034
+ ```js
1035
+ // eslint.config.mjs (flat config)
1036
+ import { defineConfig } from 'eslint/config'
1037
+
1038
+ export default defineConfig([
1039
+ {
1040
+ files: ['src/**/*.{ts,tsx}'], // production code only
1041
+ ignores: ['**/__tests__/**', 'tests/e2e/**'],
1042
+ rules: {
1043
+ 'no-restricted-imports': ['error', {
1044
+ patterns: ['@feelflow/ffid-sdk/server/test'],
1045
+ }],
1046
+ },
1047
+ },
1048
+ ])
1049
+ ```
1050
+
1051
+ ## 環境変数
1052
+
1053
+ オプションで環境変数を使用してデフォルト設定を上書きできます:
1054
+
1055
+ ```bash
1056
+ NEXT_PUBLIC_FFID_API_URL=https://id.feelflow.net
1057
+ NEXT_PUBLIC_FFID_SERVICE_CODE=chatbot
1058
+ ```
1059
+
1060
+ ## ライセンス
1061
+
1062
+ MIT