コンテンツにスキップ

オペレーター登録・Passkey登録

Document ID: SRS-TEN-002 Parent: SRS-ROOT-001 v0.5 Version: 0.3 Status: Draft Last Updated: 2026-05-05 Depends on: SRS-TEN-001 v0.4 依存される: SRS-TEN-003, SRS-MST-001, SRS-RES-001/002/003/004/005, SRS-CUS-001/002/004, SRS-REG-001/002, SRS-WRK-001

本書は SRS-ROOT-001 v0.5 に従う。


Phase 1 で x-dev-* ヘッダ前提の apps/api/src/middleware/dev-auth.ts を廃止し、オペレーター認証を better-auth + Passkey 必須 + TOTP 任意 + recovery code + サーバーサイド session に置き換える。

本SRSの責務は「認証主体の生成・本人確認・認証要素登録・セッション管理・招待トークン受領」までである。「店舗所属リンクとロール変更」は SRS-TEN-003 が、「operator → staff の業務リンク」は SRS-CUS-004 が所有する。


  • As a 招待された新規オペレーター, I want to メール招待からそのまま Passkey 登録して初回ログインしたい, so that パスワード無しで即運用開始できる
  • As a 既存オペレーター, I want to 新しい端末に Passkey を追加登録したい, so that 端末紛失時も継続運用できる
  • As a 既存オペレーター, I want to TOTP を任意で有効化したい, so that 高リスク操作時の保険を持てる
  • As a 既存オペレーター, I want to Passkey を全部失った時に recovery code とメール確認で復旧したい, so that オーナー依存の手動復旧を避けられる
  • As a 管理者, I want to オペレーター一覧と招待状態を見たい, so that 誰がまだ未受諾か把握できる

3.1 主シナリオ(新規オペレーターの招待受諾と Passkey 登録)

Section titled “3.1 主シナリオ(新規オペレーターの招待受諾と Passkey 登録)”
  1. 管理者が POST /api/admin/operators/invitations でメール招待を作成する(SRS-TEN-003 の責務だが、実装は本SRSの招待トークン仕様に従う)
  2. システムが operator_invitation に 1 行作成し、32 byte ランダムトークンをメール送信する
  3. 招待先ユーザーがリンクを開く
  4. システムがトークンハッシュ一致・未失効・未取消・未受諾を検証する
  5. operator.email が未存在なら新規 operator を作成する
  6. ブラウザが Passkey registration challenge を取得する
  7. ユーザーが OS / ブラウザの WebAuthn UI で Passkey を登録する
  8. システムが passkey_credential を保存する
  9. ユーザーが希望すれば TOTP を有効化する。不要なら skip できる
  10. システムが recovery code 10 個を生成し、この場で一度だけ表示する
  11. システムが operator_session を作成し、active_store_id = invitation.store_id をセットする
  12. SRS-TEN-003 の受諾処理と同一トランザクションで operator_store_link を作成する
  13. ユーザーは /api/admin/* を利用可能になる
  • AF-1 既存オペレーターが別店舗招待を受ける: 新規 operator は作らず、既存ログイン済みセッションで本人確認済みの同一メールオペレーターに紐付ける
  • AF-2 既存オペレーターが新端末で Passkey を追加: 既存ログイン済みセッションから追加登録し、最後の 1 本を削除する前に代替 Passkey の存在を要求する
  • AF-3 TOTP を使わない: Passkey のみで通常ログイン可能。TOTP 有効ユーザーのみ 2 段階目を要求する
  • AF-4 recovery code 再生成: 旧 10 個は全失効し、新 10 個をその場で再表示する
  • AF-5 全端末ログアウト: 現在セッション以外を一括失効する API を提供する。UI は Phase 2
  • EF-1 招待トークン不正: AUTH.INVITATION_INVALID 404
  • EF-2 招待トークン失効: AUTH.INVITATION_EXPIRED 410
  • EF-3 招待トークン取消済み: AUTH.INVITATION_REVOKED 410
  • EF-4 受諾済みトークン再使用: 既に同一 operator/store link が成立していれば 200 no-op、そうでなければ 409
  • EF-5 既存オペレーター受諾でログイン中ユーザーのメールが招待メールと不一致: AUTH.INVITATION_EMAIL_MISMATCH 409
  • EF-6 Passkey 登録に成功したが operator_store_link 作成に失敗: トランザクション全体 rollback
  • EF-7 recovery code は正しいが email confirmation が無い: AUTH.RECOVERY_EMAIL_CONFIRMATION_REQUIRED 403
  • EF-8 POST /api/auth/sign-in/email: 常に 404
  • EF-9 レート制限超過: 429 AUTH.RATE_LIMITED

  • 招待受諾画面
  • Passkey 登録画面
  • TOTP 任意設定画面
  • recovery code 表示画面
  • アカウントセキュリティ画面
  • 自分のセッション管理画面(最小)
  • 招待受諾画面は token 検証後に Passkey 登録を開始する
  • Passkey 登録は OS ネイティブ UI を呼ぶだけで、パスワード入力欄を持たない
  • TOTP 設定画面は QR と確認コード入力を持つ
  • recovery code 画面は「後で再表示不可」を明示する
  • セキュリティ画面は登録済み Passkey 一覧、TOTP 有効/無効、recovery code 再生成、全端末ログアウトを持つ
  • email は lower-case normalize 後に citext 一意
  • 招待 token は base64url 43 文字
  • TOTP code は 6 桁
  • recovery code は英大文字・数字 8〜12 桁表示、DB には hash のみ保存
  • Passkey nickname は任意 1〜50 文字

MethodPath用途認証permission
GET/api/admin/operators店舗所属オペレーター一覧 + 招待状態operator sessionadmin:operator:read
POST/api/admin/operators/invitations招待作成operator sessionadmin:operator:create
POST/api/admin/operators/invitations/:id/revoke招待取消operator sessionadmin:operator:create
POST/api/auth/invitations/accept招待受諾開始tokenなし
POST/api/auth/passkeys/register/optionsPasskey 登録 challenge 発行token or sessionなし
POST/api/auth/passkeys/register/verifyPasskey 登録完了token or sessionなし
POST/api/auth/passkeys/authenticate/optionsPasskey 認証 challenge 発行publicなし
POST/api/auth/passkeys/authenticate/verifyPasskey 認証完了publicなし
POST/api/auth/totp/setupTOTP 初期化sessionなし
POST/api/auth/totp/verifyTOTP 有効化 / 認証pending sessionなし
POST/api/auth/recovery-codes/regeneraterecovery code 再発行sessionなし
POST/api/auth/recovery/sign-inrecovery code + email confirmation 復旧publicなし
POST/api/auth/sessions/logout現在セッション logoutsessionなし
POST/api/auth/sessions/logout-all他端末 session 全失効sessionなし
POST/api/auth/stores/switchactive store 切替sessionなし
GET/api/admin/auth/me自分の session / factor 状態sessionなし

5.2 リクエスト / レスポンス要約

Section titled “5.2 リクエスト / レスポンス要約”

POST /api/admin/operators/invitations

  • request: { email, role_id }
  • response: { data: { invitation_id, expires_at, email, role_id } }

POST /api/auth/invitations/accept

  • request: { token }
  • response: { data: { invitation_id, email, store_id, existing_operator: boolean } }

POST /api/auth/recovery/sign-in

  • request: { email, recovery_code, email_confirmation_token, new_passkey_attestation }
  • response: { data: { session_id, active_store_id, recovered: true } }

POST /api/auth/stores/switch

  • request: { store_id }
  • response: { data: { active_store_id } }
code意味
AUTH.UNAUTHENTICATED未ログイン
AUTH.FORBIDDEN権限不足
AUTH.INVITATION_INVALIDtoken 不正
AUTH.INVITATION_EXPIREDtoken 失効
AUTH.INVITATION_REVOKEDtoken 取消済み
AUTH.INVITATION_EMAIL_MISMATCH既存アカウントの email 不一致
AUTH.PASSKEY_REQUIREDPasskey 未登録
AUTH.TOTP_REQUIREDTOTP 必須だが未通過
AUTH.RECOVERY_CODE_INVALIDrecovery code 不正
AUTH.RECOVERY_CODE_USEDrecovery code 使用済み
AUTH.RECOVERY_EMAIL_CONFIRMATION_REQUIREDemail confirmation 未完了
AUTH.STORE_NOT_LINKEDactive store 切替先に所属なし
AUTH.RATE_LIMITEDレート制限

operator(共通 auth 主体、UUIDv7、store_id なし、親 §7.1.1 例外)

Section titled “operator(共通 auth 主体、UUIDv7、store_id なし、親 §7.1.1 例外)”
カラム制約
iduuidPK
emailcitextUNIQUE, NOT NULL
namevarchar(100)NOT NULL
statusvarchar(20)NOT NULL DEFAULT active
last_login_attimestamptzNULL
created_attimestamptzNOT NULL
updated_attimestamptzNOT NULL
カラム制約
iduuidPK
operator_iduuidFK → operator(id)
credential_idbyteaUNIQUE, NOT NULL
public_keybyteaNOT NULL
sign_countbigintNOT NULL DEFAULT 0
transportsjsonbNOT NULL DEFAULT []
aaguiduuidNULL
nicknamevarchar(50)NULL
last_used_attimestamptzNULL
revoked_attimestamptzNULL
created_attimestamptzNOT NULL
カラム制約
operator_iduuidPK, FK
secret_encryptedbyteaNOT NULL
enabled_attimestamptzNULL
created_attimestamptzNOT NULL
updated_attimestamptzNOT NULL
カラム制約
operator_iduuidFK
slotsmallint1..10
code_hashbyteaNOT NULL
used_attimestamptzNULL
batch_iduuidNOT NULL
created_attimestamptzNOT NULL
PK(operator_id, slot)

operator_session(store_id なし、server-side session)

Section titled “operator_session(store_id なし、server-side session)”
カラム制約
iduuidPK
operator_iduuidFK → operator(id)
token_hashbyteaUNIQUE, NOT NULL
active_store_iduuidNULL
ipinetNULL
user_agenttextNULL
expires_attimestamptzNOT NULL
last_seen_attimestamptzNOT NULL
revoked_attimestamptzNULL
created_attimestamptzNOT NULL
カラム制約
iduuidPK
store_iduuidFK → store(id), NOT NULL
emailcitextNOT NULL
role_iduuidFK → role(store_id, id), NOT NULL
token_hashbyteaUNIQUE, NOT NULL
invited_by_operator_iduuidFK → operator(id), NOT NULL
expires_attimestamptzNOT NULL
accepted_attimestamptzNULL
accepted_operator_iduuidFK → operator(id)
revoked_attimestamptzNULL
created_attimestamptzNOT NULL

actor_kind enum('operator','system') 列を追加し、operator_id に条件付き CHECK 制約を入れる。既存行は operator_id IS NULL なら 'system'、それ以外は 'operator' で埋める。

FK 制約は 0012 では張らない。理由は dev-auth 移行 8 PR §7.9 の順序で、PR-3 (本 migration) 〜 PR-6 までは dev-auth.ts が動作しており、x-dev-operator-id ヘッダ由来の UUID(operator テーブルに不在)が operator_action_log に書き込まれ続ける。FK を 0012 で張ると、既存 dev 行と以降の dev 期間の audit INSERT が同時に壊れる。よって FK は dev-auth 物理削除 (PR-7) 直後の後続 migration(番号は実装時に確保)で追加する。

CREATE TYPE operator_action_actor_kind AS ENUM ('operator','system');
ALTER TABLE operator_action_log
ADD COLUMN actor_kind operator_action_actor_kind NOT NULL DEFAULT 'system';
UPDATE operator_action_log
SET actor_kind = CASE
WHEN operator_id IS NULL THEN 'system'::operator_action_actor_kind
ELSE 'operator'::operator_action_actor_kind
END;
ALTER TABLE operator_action_log
ADD CONSTRAINT operator_action_log_actor_kind_chk
CHECK (
(actor_kind = 'operator' AND operator_id IS NOT NULL)
OR
(actor_kind = 'system' AND operator_id IS NULL)
);
-- FK は dev-auth 物理削除後の後続 migration で追加する。0012 では張らない。
-- ALTER TABLE operator_action_log
-- ADD CONSTRAINT operator_action_log_operator_fk
-- FOREIGN KEY (operator_id) REFERENCES operator(id) ON DELETE RESTRICT;

ローカル開発 DB に既存の dev データがあるケースでは、本 migration 適用前に docker compose down -v && docker compose up -d db && bun run db:bootstrap && bun run db:migrate && bun run db:seed で再構築するか、actor_kind backfill だけ行う本 migration はそのまま適用可能。

0012_operator_auth_and_sessions.sql で以下を 1 ファイルに統合する。

  1. operator, passkey_credential, totp_secret, recovery_code, operator_session, operator_invitation の作成
  2. operator_action_log.actor_kind 列追加と operator_id 条件付き制約・FK 整備
  3. operator_invitation に対する RLS / GRANT
  4. auth 系 store-less テーブルの GRANT(RLS なし、app ロールに SELECT/INSERT/UPDATE のみ)

permission backfill (admin:operator:*) は 0013_operator_membership_and_staff_link.sql で SRS-TEN-003 と統合実施する。

  • operator_invitationstore_id を持つため通常の tenant RLS を貼る
  • operator, passkey_credential, totp_secret, recovery_code, operator_session共通 auth エンティティ例外(親 §7.1.1)。store_id を持たない
  • 上記 store-less auth テーブルは tenant RLS の安全網を持ちにくいため、アクセス主体を packages/auth に閉じ込める
  • /api/admin/* の permission 判定は app 層、/api/auth/* の本人確認は better-auth に集約する
  • email/password route は middleware で 404 に落とす(二重ガード)

  • admin 利用には少なくとも 1 本の active Passkey が必要
  • 招待受諾で新規オペレーターを作る場合、Passkey 登録完了前に session を発行しない
  • 既存ログイン中セッションから追加登録は許可する
  • 最後の active Passkey を削除する操作は、同一トランザクションで代替 Passkey が追加されない限り拒否する
  • 端末買替時は「追加登録 → 旧 credential revoke」の順のみ許可する
  • revoked_at を持つだけで物理削除しない
  • 監査ログ auth.passkey.add / auth.passkey.revoke を記録する

7.3 紛失リカバリ(親 §6.3 準拠)

Section titled “7.3 紛失リカバリ(親 §6.3 準拠)”
  • 復旧条件は unused recovery code 1 個 + email confirmation 完了
  • email-only recovery は禁止
  • recovery 成功時は、旧 session を全失効し、新しい Passkey を同一フローで必須登録する
  • recovery code 使用時は used_at を即時更新する
  • recovery 完了後は新 10 個へ再生成する
  • TOTP は opt-in
  • 有効化済みユーザーは Passkey 認証後に TOTP を追加で要求する
  • TOTP secret は暗号化保存し、平文再表示しない
  • TOTP 無効化はログイン済み + recovery code または TOTP 現値のどちらかを要求する
  • 常に 10 個
  • DB には hash のみ保存
  • 表示は生成直後 1 回のみ
  • 再生成時に旧 batch を全失効する
  • 10 個全消費時は recovery フロー不可。本人ログイン済み時の再生成が必要
  • raw token は 32 byte cryptographic random
  • 表示・URL 埋込は base64url
  • DB には SHA-256 hash のみ保存
  • 有効期限は 72h 固定
  • revoke 後の再送は旧 row 再利用でなく新 row 発行を推奨する。履歴が明確だから
  • 既存 operator の別店舗招待受諾は「同一 email かつ本人ログイン済み」を必須にする
  • session は active_store_id を持つ
  • 認可は毎 request で active_store_id + operator_store_link + role_permission から解決する
  • role 変更・link revoke は次 request から即反映する
  • store switch は switch 先 link 存在をサーバー側で再検証する
  • emailAndPassword.enabled = false(パスワード認証を完全 OFF)
  • passkey plugin、two-factor (TOTP) plugin を採用
  • organization plugin は 不採用。マルチテナントは自前 operator_store_link で行う
  • session の additionalFieldsactive_store_id を生やす
  • generateId: () => uuidv7() を全 better-auth ID で統一
  • Hono integration は公式採用、/api/auth/* にマウント
  • cookie は HttpOnly + Secure + SameSite=Lax、状態変更系は X-Requested-With 必須(CSRF 二重防衛)
  1. PR-1: 仕様凍結(parent v0.5、TEN-001 v0.4、TEN-002 v0.2、TEN-003 v0.2、CUS-004 v0.1 同時承認)
  2. PR-2: auth 抽象化先行 — apps/api から DevAuthContext 直参照を剥がし、auth.bypassForTest() と共通 AuthContextpackages/auth に作る。挙動は dev-auth のまま
  3. PR-3: DB 基盤 migration — 0012, 0013 で auth 系・link 系テーブルを追加。permission backfill も 0013 で実施
  4. PR-4: better-auth 実装 — packages/auth に better-auth 設定、Passkey/TOTP/recovery code/session 管理、/api/auth/* mount を実装
  5. PR-5: admin auth 切替 — /api/admin/*devAuth から better-auth session middleware へ差し替える
  6. PR-6: テスト移行 — apps/api/test/*x-dev-* ヘッダ依存を auth.bypassForTest() へ置き換え
  7. PR-7: 物理削除と CI ガード — apps/api/src/middleware/dev-auth.ts を削除し、本番 build / grep CI で x-dev- 混入を落とす
  8. PR-8: seed / setup 更新 — setup guide, seed, test fixtures を better-auth 前提へ更新
  • packages/auth
    • PERMISSIONS 定数
    • PRESET_ROLE_PERMISSIONS
    • resolveReservationTransitionPermission(input) 等の純関数 resolver
    • auth.bypassForTest() ヘルパ
  • apps/api/src/middleware
    • requirePermission(key, opts?)
    • requireReservationTransitionPermission (body の to + reopen から resolver を呼ぶ)

packages/auth に Hono を import しない。


  • 認証開始 API p95 300ms 以下
  • Passkey verify p95 500ms 以下
  • 招待メール送信は 30 秒以内に job 開始
  • Rate limit は passkey auth 10 回/分、TOTP 5 回/15 分、recovery 5 回/15 分
  • 監査ログは 1 年保持
  • 本番 build に dev-auth.ts を含めない(CI で grep 検証)

key用途初期付与
admin:operator:readオペレーター一覧・招待状態の閲覧owner / manager
admin:operator:create招待作成・取消owner / manager
admin:operator:updateオペレーター更新owner / manager
admin:operator:retireオペレーター無効化・復帰owner / manager
  • emailAndPassword.enabled = false
  • /api/auth/sign-in/email は 404
  • Cookie は HttpOnly + Secure + SameSite=Lax
  • 状態変更系は X-Requested-With 必須
  • auth.bypassForTest() を提供し、x-dev-* ヘッダは削除する
  • 招待 token, session token, recovery code は raw 値を DB 保存しない

10. 受け入れ基準(Given-When-Then)

Section titled “10. 受け入れ基準(Given-When-Then)”
  • GWT-1 新規招待受諾成功: Given 未登録 email の有効招待 / When Passkey 登録を完了 / Then operator, passkey_credential, operator_session, operator_store_link が作成される
  • GWT-2 既存オペレーター別店舗招待: Given 既存 operator が別店舗招待を受領 / When 同一 email の既存 session で受諾 / Then 新規 operator は作られず link のみ増える
  • GWT-3 招待 token 不正: Given 無効 token / When 受諾開始 / Then 404 AUTH.INVITATION_INVALID
  • GWT-4 招待 token 失効: Given expires_at < now() / When 受諾開始 / Then 410 AUTH.INVITATION_EXPIRED
  • GWT-5 招待 token 取消済み: Given revoked_at IS NOT NULL / When 受諾開始 / Then 410 AUTH.INVITATION_REVOKED
  • GWT-6 Passkey 必須: Given 新規招待受諾中 / When Passkey verify 前に admin session 要求 / Then 401
  • GWT-7 TOTP 任意: Given TOTP 未設定 operator / When Passkey sign-in / Then 追加 factor 無しで session 作成
  • GWT-8 TOTP 必須化: Given TOTP 設定済み operator / When Passkey sign-in 成功 / Then pending session となり TOTP verify 前は admin API 利用不可
  • GWT-9 recovery code 10 個発行: Given 初回登録完了 / When recovery code 表示 / Then 10 個のみ返り、再取得 API は存在しない
  • GWT-10 recovery code 使用: Given 未使用 code + email confirmation 成功 / When recovery sign-in / Then code が消費され旧 session 全失効、新 Passkey 必須登録
  • GWT-11 recovery code 再利用拒否: Given used_at IS NOT NULL / When 同じ code を送信 / Then 403 AUTH.RECOVERY_CODE_USED
  • GWT-12 recovery code 再生成: Given ログイン済み operator / When regenerate / Then 旧 batch 全失効、新 batch 10 件作成
  • GWT-13 email/password 404: Given 任意 / When POST /api/auth/sign-in/email / Then 404
  • GWT-14 active store switch: Given 2 店舗 link を持つ operator / When store B に switch / Then session.active_store_id = B
  • GWT-15 未所属 store switch 拒否: Given store B link 無し / When switch / Then 403 AUTH.STORE_NOT_LINKED
  • GWT-16 全端末 logout: Given 複数 session / When logout-all / Then current 以外が失効
  • GWT-17 招待一覧閲覧: Given owner session / When /api/admin/operators / Then store 内 operator と pending invitation を見られる
  • GWT-18 招待権限不足: Given staff session / When POST /api/admin/operators/invitations / Then 403
  • GWT-19 既存 email 不一致拒否: Given 招待 email と別 email の login session / When accept / Then 409 AUTH.INVITATION_EMAIL_MISMATCH
  • GWT-20 actor_kind CHECK: Given actor_kind='operator'operator_id IS NULL を INSERT / Then DB CHECK 違反
  • GWT-21 dev-auth 排除: Given 本番 build / When dev-auth.ts / x-dev-* の存在 grep / Then 0 件

  • Unit: token hash, recovery code batch rotation, active store switch validation, TOTP required state machine
  • Integration: invite accept, passkey register, recovery, session revoke, rate limit, 404 email route
  • Contract: /api/auth/*/api/admin/operators* の schema 固定
  • Security: raw token 非保存、revoked session 再利用不能、cookie 属性、CSRF header
  • Cross-SRS: operator_store_link 作成は SRS-TEN-003 と同時検証

  • send-operator-invitation-email
  • cleanup-expired-operator-sessions
  • cleanup-expired-operator-invitations

ジョブ命名・enqueue 規約は SRS-WRK-001 に従う。


#内容扱い
OQ-TEN-002-01共有端末 (受付 iPad) モードPhase 2 持越し(親 OQ-15)
OQ-TEN-002-02better-auth v1.x の継続採用判断(Bun 互換)Phase 1 後半に再評価
OQ-TEN-002-03recovery code 全消費後の管理者再招待フローPhase 2 検討

VersionDateAuthorChange
0.12026-05-05Codex / yudai初版ドラフト
0.22026-05-05yudai (with Codex co-design)Round 2 反映: migration 番号 0012 に確定、actor_kind enum 採用、layer 分離 (packages/auth = resolver, apps/api = middleware)、permission 47 表整合 (admin:operator:* 4 keys)、Smart Pay は完全 Phase 2 へ。dev-auth 移行 8 PR 手順を §7.9 に明記
0.32026-05-05yudai (with Codex co-design)§6.1 operator_action_log 変更: 0012 では FK を張らない方針を明文化(dev-auth 期間中の audit INSERT を壊さないため)。FK 追加は PR-7 dev-auth 物理削除直後の後続 migration へ遅延