コンテンツにスキップ

0011. 公開オンボーディング (メール招待 + 6 桁コード)

コンテキストと課題

新規顧客 (組織) を受け入れたいが、公開ページから誰でも勝手に組織を作れるのは避けたい。一方で、 運営が毎回手で組織と初期管理者を作るのも持続しない。「運営が宛先メールに招待を出し、受信者自身が 組織 + 初回 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 = 6generateSixDigitCode = 暗号乱数 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 対策のアトミックゲート)

/signuprequireAuth を通さない公開エンドポイントだが、service_role で以下を行う:

  1. email で未使用・最新の招待行を取得し、存在/未使用/期限内/attempts < 上限 を事前フィルタ。
  2. アトミックゲート RPC register_signup_invite_attempt(p_id, p_max) を呼ぶ。これは used_at is null かつ attempts < p_max のときだけ attempts を +1 する条件付き UPDATE で、 READ COMMITTED 下の EvalPlanQual により並行実行が直列化される (上限超過で true にならない)。
  3. コード照合 + テナント種別整合を確認し、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 — 台帳の初期定義 (平文 code PK)、service_role のみ許可・anon/authenticated ポリシー不在のセキュリティ方針。
  • supabase/migrations/00000000000049_signup_invite_codes_email_otp.sql:30-61id/email/code_hash/attempts 追加、主キーを id へ移行、email 部分インデックス。
  • supabase/migrations/00000000000050_signup_invite_codes_atomic_attempt_gate.sql:36-66register_signup_invite_attempt(security definer、条件付き UPDATE、EvalPlanQual による直列化)。
  • scripts/invite-org.mjs:19-26,103-123,263-267,314-321RESEND_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.mdbootstrap-org.mjs はワンオフの初回種火 (継続オンボーディングとは別)。