0011. 公開オンボーディング (メール招待 + 6 桁コード)¶
- ステータス: Accepted(実装済み)
- 関連: 0004 login_id 方式の認証 / 0009 API を Cloudflare Workers で構築
コンテキストと課題¶
新規顧客 (組織) を受け入れたいが、公開ページから誰でも勝手に組織を作れるのは避けたい。一方で、 運営が毎回手で組織と初期管理者を作るのも持続しない。「運営が宛先メールに招待を出し、受信者自身が 組織 + 初回 admin を作る」という 半クローズドな公開オンボーディング が欲しい。秘密 (メール送信鍵) はブラウザにも Worker にも置きたくない。
決定¶
運営がメール宛に 6 桁コードを発行 → 受信者が /register → 公開 /signup が検証して
tenant + admin を作成 する。コードはハッシュ保存し、試行回数ゲートで総当たりを抑止する。
構成¶
| 層 | 実体 | 役割 |
|---|---|---|
| DB | public.signup_invite_codes (migration 48 + 49) |
招待台帳。email / code_hash / attempts を持つ。service_role のみ 読み書き可 |
| 運営 | scripts/invite-org.mjs |
6 桁コード生成 → code_hash 保存 → Resend で宛先にメール送信 |
| API | POST /signup (apps/api/src/routes/signup.ts) |
公開エンドポイント。email + code 検証 → tenant / admin / profiles 作成 → 招待消費 |
| Web | /register (RegisterOrgPage.tsx) |
公開ページ。メール + コード + 組織情報を入力 |
コードの生成・保存¶
- 6 桁の数字コード (
SIGNUP_CODE_LENGTH = 6、generateSixDigitCode= 暗号乱数randomInt(0, 1_000_000)を 0 埋め)。 - 保存は平文ではなく SHA-256 ハッシュ:
hashCode(salt, code) = sha256(${salt}:${code})。salt は 招待行のid(uuid)。signup_invite_codesにはcode_hash/email/attemptsを持つ。 - 台帳テーブルは RLS で service_role のみ許可。anon / authenticated 向けポリシーを一切作らないことで、 ブラウザからの招待コード列挙を不可能にする (公開サインアップの主防御は「有効なコードを知っていること」)。
検証 (TOCTOU 対策のアトミックゲート)¶
/signup は requireAuth を通さない公開エンドポイントだが、service_role で以下を行う:
emailで未使用・最新の招待行を取得し、存在/未使用/期限内/attempts < 上限を事前フィルタ。- アトミックゲート RPC
register_signup_invite_attempt(p_id, p_max)を呼ぶ。これはused_at is null かつ attempts < p_maxのときだけattemptsを +1 する条件付き UPDATE で、 READ COMMITTED 下の EvalPlanQual により並行実行が直列化される (上限超過で true にならない)。 - コード照合 + テナント種別整合を確認し、tenant / admin auth ユーザー / profiles を作成して招待を消費。 どの条件で弾いたかをリークしないよう 汎用 403 を返す。
秘密鍵の置き場所¶
RESEND_API_KEYは運営スクリプト (invite-org.mjs) の実行環境にのみ置く。Worker (API) には置かない。 招待発行はこの運営スクリプトだけが行うため。Resend 送信失敗時は保存した招待行をロールバックする。- 初回の「種火」(最初の 1 組織) は別途
scripts/bootstrap-org.mjs(ワンオフ) で作る。これは 継続的オンボーディングとは別物。
影響 (Consequences)¶
良い点¶
- 公開ページから組織を作れるが、有効な招待コードを知っている人だけに限定できる (半クローズド)。
- コードはハッシュ保存 + 試行回数ゲート + 期限 + メール紐付けで、6 桁という小さな空間でも総当たりを 実用上抑止できる。台帳は service_role 限定で列挙不能。
- メール送信鍵 (Resend) を 運営スクリプトに隔離 でき、ブラウザにも Worker にも秘密が出ない。
トレードオフ / 注意点¶
- 招待発行は運営の手作業 (
invite-org.mjs実行)。セルフサーブではないため、受け入れ件数が増えると 運用負荷になる。 - 6 桁数字コードは空間が小さい (10^6)。安全性は 試行回数ゲート + 期限 + メール紐付け + 汎用 403 に 依存する。これらのいずれかが緩むと総当たり耐性が落ちる。
- アトミックゲートは Postgres の EvalPlanQual (READ COMMITTED) 挙動に依存する。DB 側のロック/分離 レベルの理解が前提。
- 台帳は migration 48 (平文
codeを主キーとする運営コード台帳) → 49 (email +code_hash+attemptsを追加し主キーをidへ移行) → 50 (アトミックゲート) と段階的に進化しており、現行フローは email + code_hash 経路。
根拠 (典拠)¶
supabase/migrations/00000000000048_signup_invite_codes.sql:8-16,21-55— 台帳の初期定義 (平文codePK)、service_role のみ許可・anon/authenticated ポリシー不在のセキュリティ方針。supabase/migrations/00000000000049_signup_invite_codes_email_otp.sql:30-61—id/email/code_hash/attempts追加、主キーをidへ移行、email 部分インデックス。supabase/migrations/00000000000050_signup_invite_codes_atomic_attempt_gate.sql:36-66—register_signup_invite_attempt(security definer、条件付き UPDATE、EvalPlanQual による直列化)。scripts/invite-org.mjs:19-26,103-123,263-267,314-321—RESEND_API_KEYは運営スクリプトのみ (Worker に置かない)、6 桁コード生成、sha256(${id}:${code})、Resend 送信。apps/api/src/routes/signup.ts:40-57,203,217-262— login_id→email 整合、getServiceRoleClient、招待取得→事前フィルタ→アトミックゲート→コード照合→作成、汎用 403。docs/signup-onboarding.md— 構成表とエンドツーエンドのフロー。docs/bootstrap-org.md—bootstrap-org.mjsはワンオフの初回種火 (継続オンボーディングとは別)。