チームと組織
注目すべき点
チーム機能は v4.0.0 で追加されました。認可(Better Auth organization プラグイン)と課金(課金主体としての組織)の両方にまたがるため、本ページで両面をまとめて説明します。
中核となる考え方:組織が課金主体
一言でいえば、組織は単なるメンバーのグループではなく、サブスクリプションを購入しクレジットを保有できる課金主体です。
| 個人ワークスペース | チームワークスペース | |
|---|---|---|
| 課金主体 | ユーザー | 組織 |
| 残高テーブル | credit_balances | organization_credit_balances |
| サブスクリプション行 | organization_id が NULL | organization_id が組織を指す |
| クレジットパックの購入 | 可 | 不可(クレジットパックは個人の資産) |
| シート上限 | 該当なし | チームプランの seats。サブスクリプションがなければ 1 |
両者はバケット構造、冪等メカニズム、台帳テーブルを共有し、異なるのは残高テーブルだけです。業務コードにチーム用の分岐が必要になることはほとんどありません。
データモデル
3 つのテーブルは Better Auth の organization プラグイン由来です。
| テーブル | 説明 |
|---|---|
organization | id / name / slug(ユニーク)/ logo / metadata(プラグインがシリアライズした JSON テキスト。キー検索はしない) |
member | メンバーシップ。(organization_id, user_id) が複合ユニークで、1 ユーザーは 1 組織につき 1 行のみ |
invitation | 招待。status は pending / accepted / rejected / canceled。expired という永続ステータスはなく、期限切れは読み取り時に expires_at から判定します |
課金テーブルとの関係:
organization
├─ organization_credit_balances (cascade) 組織を消せばプールも消える
├─ subscriptions.organization_id (RESTRICT) 契約履歴があれば物理削除不可
├─ credit_transactions.organization_id (RESTRICT) 台帳履歴があれば物理削除不可
└─ orders.organization_id (set null) 注文は残り、帰属だけが外れるRESTRICT はデータベース層の最終防衛線で、アプリケーション層にも削除ガードがあります(後述)。
ワークスペース:このリクエストは誰として動くか
session.activeOrganizationId が、現在のリクエストを誰に帰属させるかを決めます。サーバー側の唯一の出口が getWorkspaceContext() です。
import { getWorkspaceContext } from '@/lib/auth/server'
const workspace = await getWorkspaceContext()
// { userId: string, organizationId: string | null } | null
// organizationId が null = 個人ワークスペース呼び出しのたびに member テーブルでメンバーシップを再確認します。 この手順は省けません。ユーザーが組織から外された、あるいは組織が削除されたあと、セッション内の activeOrganizationId は古いポインタになります。再確認によって静かに個人ワークスペースへ降格し、他人のプールのクレジットを使ってしまう事態を防ぎます。
フロントエンドのワークスペース切り替えは components/shared/WorkspaceSwitcher.tsx が担い、organization.setActive でセッションに書き込みます。サーバー側のすべての消費側(課金プール、残高表示)がそれに追随します。


ロール
プラグインのロールは素の文字列です(複数の場合はカンマ区切り)。ボイラープレートでは 3 つを使います。
| ロール | 付与元 | 権限 |
|---|---|---|
owner | 組織を作成した人に自動付与(creatorRole: 'owner') | 契約、招待、ロール変更、メンバー削除、組織削除 |
admin | owner が指名 | 招待、メンバー管理 |
member | 招待参加時のデフォルト | 共有クレジットプールの利用 |
混同しないでください。これは組織内のロールであり、サイト全体の管理権限を司る user.role(user / admin / superadmin)とはまったく別系統です。
チームサブスクリプションとシート
チームプランを定義する
config/pricing.ts で audience: 'team' を指定し、seats でシート数を宣言します。
{
id: 'team-monthly',
kind: 'subscription',
interval: 'month',
audience: 'team', // 課金主体は組織
seats: 5, // シート上限
monthlyCredits: 10000, // 共有プールへ付与
provider: 'stripe',
stripePriceId: { test: '...', live: '...' },
price: 149.5,
currency: 'USD',
active: true,
copy: { /* ... */ },
}詳細は料金設定を参照してください。
シート上限の計算方法
seats = getPlanSeats(有効なチームサブスクリプションの planId)- 有効なサブスクリプションがある → そのプランの
seats - ない → 1(owner のみ)
「サブスクリプションがなければシートは 1」という設計により、多数の特殊分岐が消えます。各呼び出し箇所で「契約がなければ招待を拒否」と書く必要はなく、上限が 1 で owner が既に埋めているため、招待は自然に拒否されます。
「有効」の判定には ENTITLED の集合(active / trialing / past_due / unpaid)を使います。督促中でもシートは減りません —— メンバーは外されず、上限も縮みません。クレジットやストレージ保持と同じ物差しを使うことで、UI の表示とサーバー側の強制が常に一致します。
チームサブスクリプションは誰の customer に紐づくか
チームサブスクリプションは購入者個人の Stripe customer に紐づきます。組織レベルの customer は存在しません。クレジットを組織プールへ振り向ける唯一の根拠は、チェックアウト時に metadata へ書かれた organizationId です。
ここからサーバー側で強制される制限が導かれます。セルフサービスポータルを開けるのは購入者本人だけです。 そうでなければ、セッションを持つメンバーなら誰でも登録カードを見たり、解約したりできてしまいます。この検証は UI でボタンを隠すのではなく、サーバー側で行われます。
招待フロー
owner/admin が /dashboard/team でメールアドレスを入力
│
▼
beforeCreateInvitation フック ← シートの事前チェック(保留中の招待も計上)
│
▼
invitation 行の書き込み + 招待メール送信 emails/organization-invitation.tsx
│
▼
受信者が /accept-invitation/{id} を開く
│
├─ 未ログイン → /login?next=... を経由して戻る
├─ メール不一致 → 通知と「ログアウトして切り替え」の導線
└─ 無効/処理済み → 読み取り専用表示
│
▼
承諾
│
▼
beforeAcceptInvitation フック ← シートの事前チェック(保留中は計上しない。自分の分を消費中のため)
│
▼
member 行の書き込み
│
▼
afterAcceptInvitation フック ← 並行性の補償:上限超過なら回収
│
▼
アクティブなワークスペースを切り替え → /dashboard/team設定項目(lib/auth/organization.ts):
| 設定 | 値 | 意味 |
|---|---|---|
invitationExpiresIn | 48 時間 | 招待の有効期間 |
cancelPendingInvitationsOnReInvite | true | 同じメールアドレスへ再招待すると、前回の招待を自動的に無効化 |
organizationLimit | 5 | 1 ユーザーが作成できる組織は最大 5 個(作成は無料で権利はサブスクリプション由来のため、これは濫用対策の保険) |
membershipLimit | 100 | プラグイン層の緩い上限。実際のシート上限はフック内にあり、サブスクリプションに従います |
招待メールは lib/mail 経由で送信され、送信者には DEFAULT_ADMIN_EMAIL を使います(未設定の場合はその場で例外になります)。
シートの強制:2 段の防御
シートの超過販売はこのモジュールで最も難しい部分で、ボイラープレートは 2 層構えにしています。
第 1 段:4 つの事前フック
なぜ 2 つではなく4 つなのか。better-auth 1.4.7 の accept-invitation ルートは adapter.createMember を直接呼び、before/afterAcceptInvitation しか発火せず、before/afterAddMember は発火しないからです。後者は createOrganization とサーバー側の addMember エンドポイントしかカバーしません。
したがって両方のペアを掛ける必要があります。
| フック | カバーする経路 | 保留中の招待を計上するか |
|---|---|---|
beforeCreateInvitation | 招待の送信 | する —— 未承諾の招待がシートを予約する |
beforeAcceptInvitation | 招待による参加(主経路) | しない —— 承諾者は自分の招待が予約した分を消費する |
beforeAddMember | 組織作成 / サーバー側の addMember | しない |
第 2 段:アドバイザリロックと超過分の回収
事前フックはロックなしの「数えてから動く」方式です。2 人が同時に承諾すると、双方が事前チェックを通過し、メンバー数が有料シートを超えることがあります。
しかもプラグインのメンバー挿入は自分たちのトランザクション内にないため、「チェック + 挿入」を 1 つのロックで包めません。そこで事後に収束させます。
afterAcceptInvitation / afterAddMember
│
▼
pg_advisory_xact_lock(org) ← 直列化
│
▼
メンバー数を数え直す
│
├─ 超過なし → 何もしない
└─ 超過あり → (createdAt, id) 順で「最新の N 件の超過行」を取り、
今回の呼び出しが挿入した行が含まれる場合のみ、その行を削除決定的な順序づけにより、同時に二重超過が起きても、各侵入者はきっかり自分自身だけを退去させ、既存メンバーを誤って削除することはありません。
実装上の重要な注意点が 1 つあります。削除と例外送出は分離しなければなりません。 同一トランザクション内で delete してから throw すると、ロールバックで delete まで取り消され、メンバーは残ったままユーザーだけがエラーを見ることになります。トランザクションは「行を削除してシート数を算出する」ことだけを担い、例外はコミット後に投げます。
共有クレジットプール
チームプランのクレジットは organization_credit_balances に入ります。バケット、台帳、冪等性はすべて個人と同一で、違いは残高テーブルだけです。
消費に分岐は不要です。
import { consumeCredits } from '@/actions/credits'
// 内部で現在のワークスペースを読むため、チームワークスペースなら共有プールから引かれる
await consumeCredits({ amount: 10, note: 'AI image generation' })台帳は同じ credit_transactions テーブルのままで、organization_id の有無で主体を区別します。チームの行では user_id が操作者を記録するため、チームページで「誰が使ったか」を表示できます。
クレジットパックはチームワークスペースでは購入できません。購入者個人の購入バケットにしか入らないため、通してしまうと「支払ったのに現在のワークスペースの残高が動かない」ことになります。チェックアウトは TEAM_WORKSPACE_CREDIT_PACK を返し、クライアントが切り替えを促します。
詳細はクレジットシステムを参照してください。
組織削除のガード
beforeDeleteOrganization フックは 2 つのケースを拒否します。
- 有効なチームサブスクリプションが存在する(督促中も含む —— プロバイダーがまだ課金をリトライ中)。共有プールは有料の権利であり、主体を削除すると遅れて届くサブスクリプション Webhook の行き先がなくなります
- 資金の履歴が存在する(サブスクリプション行または台帳行)。財務記録が主体と共に消えてはいけません
一度もお金に触れていない「クリーンな」組織だけが物理削除できます。スキーマ層の外部キー RESTRICT は同じ規則のデータベース側の裏付けです。
履歴のある組織をどうしてもアーカイブする必要がある場合は手作業の運用になります —— ボイラープレートは意図的に迂回路を用意していません。
チーム管理ページ
/dashboard/team の 4 カード構成です。
| カード | 内容 |
|---|---|
| サブスクリプション | 現在のチームプラン、ステータス、周期。/dashboard/billing へリンク |
| 共有クレジットプール | 2 バケットの残高 |
| メンバーとシート | メンバー一覧(ロール変更、削除、自主退出)+ 3 状態の招待エリア + 保留中の招待一覧 |
| アクティビティ | ページング対応の組織クレジット台帳 |
「3 状態の招待エリア」とは、サブスクリプションなし → 購入の案内、シート満杯 → インライン通知、それ以外 → 招待フォーム、という分岐です。この 3 状態は描画時に決まり、ユーザーがフックへ送信して 403 を食らうことはありません。
チーム未作成の場合は、作成/選択のオンボーディングに進みます(?create=1 で作成ビューを強制)。
データ層は actions/team/index.ts で、すべての読み取りに「呼び出し元がこの組織のメンバーであること」の検証が入ります。
getTeamOverview(organizationId) // 共有プール + サブスクリプション + メンバー数 + 保留中の招待 + シート上限
getTeamCreditHistory({ organizationId, pageIndex, pageSize }) // ページング対応の組織台帳自分の機能でチームを扱う
ほとんどの場合は何もする必要がありません —— consumeCredits が既にワークスペースからプールを選んでいます。明示的な分岐が必要な場合は次のようにします。
import { getWorkspaceContext } from '@/lib/auth/server'
const workspace = await getWorkspaceContext()
if (workspace?.organizationId) {
// チームワークスペース:データは組織に帰属
} else {
// 個人ワークスペース
}自前のテーブルもチーム対応させる場合は、課金テーブルに倣って organization_id 列を追加し、次の 3 点を決めてください。
- 外部キーの削除挙動(
cascade/RESTRICT/set null) - 読み取り時の主体述語(
organizationId ? eq(org) : and(eq(user), isNull(org))) - メンバーシップの認可
よくある質問
Q: 1 人のユーザーが複数のチームに所属できますか?
できます。ユニーク制約は「1 ユーザーにつき 1 組織で 1 行」であり、参加できる組織数を制限しません。ある時点で有効なのは activeOrganizationId が指す 1 つだけです。
Q: チームの作成に費用はかかりますか?
かかりません。作成は無料(最大 5 個)ですが、チームサブスクリプションのない組織はシート上限が 1 で、誰も招待できず共有クレジットもありません。
Q: サブスクリプションが切れるとメンバーは外されますか?
外されません。督促中(past_due / unpaid)はシートもメンバーもそのままです。サブスクリプションが本当に終了すると上限は 1 に戻りますが、既存のメンバー行が自動削除されることはありません —— 新たに招待できなくなり、共有プールへの付与が止まるだけです。
Q: チームのクレジットが尽きたらクレジットパックを買えますか?
買えません。クレジットパックは個人の購入バケットにしか入りません。チームプールを補充する唯一の方法はサブスクリプションによる付与で、もっと必要ならプランを上げることになります。
Q: owner は自分の組織から退出できますか?
「owner のいない組織」を残す形ではできません。先に owner を他者へ譲渡するか、組織ごと削除してください(資金履歴がない場合に限ります)。