# Gateway 統一註冊 — 設計規格

> **狀態**：已實作（PR 1–8 ✅；PR 9 文件修訂 ✅）  
> **修訂**：取代 [identity-member-design.md §3.4](./identity-member-design.md)「Gateway 不暴露 `/auth/register`」  
> **最後更新**：2026-05-21

## 1. 目標

使用者 **只與 Portal Gateway 互動** 完成註冊／首次登入；ZITADEL 作為 identity 後端（帳密、OIDC、鎖定），不作第二個註冊入口。

| 路徑 | 說明 |
|------|------|
| **A** | Email + Password + Email OTP 確認 |
| **B** | 上述流程 **必填 invite code**（Logic 驗證） |
| **C** | Social（Google 為 P0，可擴 Apple）— 同一註冊 UX，OAuth 前綁定 invite |

Logic 層負責商務驗證與跨 atomic usecase 編排；usecase 維持 atomic（見 [model.md §6.1](./model.md)）。

---

## 2. 為何比 ZITADEL Hosted 註冊好

- 單一 App / API，無「跳去 IdP 再回來」的割裂感
- 註冊表單可帶 **商務欄位**（條款版本、語系、marketing、invite、referral）
- 失敗補償與 audit 在 Gateway 可控
- 與現有 member OTP、notification 模板、`CreateUnverified` / `Activate` 直接對接

---

## 3. 三條註冊路徑（統一入口 UX）

```
                    ┌─────────────────────────────────────┐
                    │     前端：同一「註冊」頁 / Wizard      │
                    │  tenant + invite_code + 條款 (+ 語系)  │
                    └──────────────┬──────────────────────┘
                                   │
              ┌────────────────────┼────────────────────┐
              ▼                    ▼                    ▼
     Email + Password      Social (Google)        （未來 Apple）
              │                    │
              ▼                    ▼
   POST /auth/register    POST /auth/register/social/start
              │                    │ → oauth_url
              ▼                    ▼
   POST /auth/register/confirm   GET /auth/register/social/callback
              │                    │
              └────────┬───────────┘
                       ▼
              IssueTokenPair (CloudEP JWT)
```

### 3.1 路徑 A+B：Email 註冊

1. Logic 驗證：`tenant_slug`、`invite_code`、條款、密碼強度、email 格式
2. Logic 驗證並 **消耗** invite（見 §6）
3. `zitadel.CreateHumanUser`（email + password，org = tenant 對應 ZITADEL org）
4. `member.Lifecycle.CreateUnverified`（`origin=platform_native`，存 `zitadel_sub` 若 API 回傳）
5. Logic 寫入 **registration metadata**（§7）
6. `member.OTP.Generate`（`purpose=registration_email`）+ `notifier.Send`（`verify_registration_email`）
7. 回 `{ challenge_id, expires_in }`，**不發 JWT**
8. `POST /auth/register/confirm`：OTP verify → `Activate` → `IssueTokenPair`

### 3.2 路徑 C：Social 註冊（Google）

Invite **必填**；在 OAuth redirect **之前** 綁定 session，避免 callback 時無 invite。

1. `POST /auth/register/social/start`  
   Body：`tenant_slug`, `invite_code`, `provider=google`, `accept_terms_version`, 可選 `language`  
   Logic：驗 tenant + invite + 條款 → 建立 **registration session**（Redis）→ 回 `{ oauth_url, session_id }`
2. 使用者完成 Google OAuth → callback `GET /auth/register/social/callback?code=...&state=...`
3. Logic：換 token、驗 state、讀 session 取 invite → **消耗 invite**
4. `zitadel` 查/建 user（或 link）→ `member.Provisioning.EnsureFromOIDC`
5. 若 email 已在 ZITADEL verified → 直接 `Activate`；否則可走 OTP 或信任 IdP `email_verified`（**已決策：信任 Google email_verified=true 則 skip OTP**）
6. 寫 registration metadata → `IssueTokenPair`

> 已存在 member（同 tenant + zitadel_sub）→ 視為 **登入**，仍驗 invite 是否允許「新註冊 only」；若 invite 僅限新 user 且已存在 → `409` + 引導 login。

### 3.3 登入（非註冊）

| API | 說明 |
|-----|------|
| `POST /auth/login` | email + password → ZITADEL ROPG / token → member lookup → JWT |
| `POST /auth/token/exchange` | 保留：已登入 ZITADEL 的 id_token 換 JWT（企業 SSO、舊 client） |
| `POST /auth/login/social` | 可選：OAuth 僅登入、不經 register session（P1，與 register 分 state） |

---

## 4. API 規格（`generate/api/auth.api`）

### 4.1 公開 — 註冊

**POST `/api/v1/auth/register`**

Request:

```json
{
  "tenant_slug": "acme",
  "invite_code": "BETA-2026-XXXX",
  "email": "user@example.com",
  "password": "string",
  "display_name": "Daniel",
  "language": "zh-tw",
  "accept_terms_version": "2026-05-01",
  "marketing_opt_in": false
}
```

Response 200:

```json
{
  "code": 0,
  "data": {
    "challenge_id": "uuid",
    "expires_in": 300,
    "uid": "ACME-10000042"
  }
}
```

**POST `/api/v1/auth/register/confirm`**

```json
{
  "tenant_slug": "acme",
  "challenge_id": "uuid",
  "code": "482913"
}
```

Response 200: `{ access_token, refresh_token, expires_in, uid, token_type: "Bearer" }`

**POST `/api/v1/auth/register/resend`**

```json
{
  "tenant_slug": "acme",
  "challenge_id": "uuid"
}
```

**POST `/api/v1/auth/register/social/start`**

```json
{
  "tenant_slug": "acme",
  "invite_code": "BETA-2026-XXXX",
  "provider": "google",
  "accept_terms_version": "2026-05-01",
  "language": "zh-tw",
  "redirect_uri": "https://app.example.com/auth/callback"
}
```

Response: `{ oauth_url, session_id, expires_in }`

**GET `/api/v1/auth/register/social/callback`**

Query: `code`, `state`（含 session_id）  
Response: 302 到前端帶 token，或 JSON（依 `Accept` / 設定）

### 4.2 公開 — 登入 / Token

| Method | Path | 說明 |
|--------|------|------|
| POST | `/api/v1/auth/login` | email + password + tenant_slug |
| POST | `/api/v1/auth/token/refresh` | refresh_token |
| POST | `/api/v1/auth/token/exchange` | id_token + tenant_slug（SSO 備用） |
| POST | `/api/v1/auth/logout` | JWT，jti 黑名單 |

### 4.3 驗證 tag（`.api`）

- `register`: `required,email`, `password` min length, `invite_code` required
- 公開路由 **不走** JWT middleware；**走** rate limit（§12）

---

## 5. 分層與新增模組

```
internal/
  library/zitadel/           # HTTP client：CreateUser, VerifyPassword, OIDC token
  model/auth/
    config/
    domain/
      entity/registration_meta.go
      repository/invite.go, registration_session.go
      usecase/token.go, invite.go
    usecase/                 # atomic：IssueTokenPair, ValidateInvite, ConsumeInvite
  logic/auth/
    register_logic.go
    register_confirm_logic.go
    register_resend_logic.go
    register_social_start_logic.go
    register_social_callback_logic.go
    login_logic.go
  middleware/
    jwt_cloud_ep.go          # P1：取代 dev X-Tenant-ID header
```

**Logic 專屬商務（不下沉 usecase）**

- invite 是否必填、是否過期、是否限新 user
- 條款版本是否接受
- tenant 是否允許 B2C 註冊
- 密碼政策（client + server 雙重）
- 註冊 rate limit key 組合
- Social：OAuth state 與 registration session 綁定

**auth usecase（atomic）**

- `TokenUseCase.IssuePair / Refresh / Logout`
- `InviteUseCase.Validate / Consume`（或 tenant 模組，見 §6）

**禁止**：auth usecase 呼叫 member usecase；一律在 logic 編排。

---

## 6. Invite Code（B 必填）

### 6.1 資料模型（Mongo `invite_codes`）

| 欄位 | 說明 |
|------|------|
| tenant_id | 租戶 |
| code_hash | bcrypt/sha256，不明文存 |
| max_uses | 总次數 |
| used_count | 已用 |
| expires_at | 可選 |
| new_users_only | default true |
| created_at | |

索引：`(tenant_id, code_hash)` unique

### 6.2 流程

1. `ValidateInvite(tenant_id, plain_code)` → ok / `ErrInviteInvalid|Expired|Exhausted`
2. **Consume** 在 ZITADEL/member 建立 **之前** 用 Redis lock + Mongo `$inc used_count`（防并发超卖）
3. 若後續 ZITADEL 或 member 失敗 → **不回滚 invite**（已決策：防刷；可 admin 补发）。Logic 记录 audit + 人工处理阈值

可选 P1：`ReleaseInvite` 仅当失败发生在 Validate 后 30s 内且 ZITADEL 未创建。

---

## 7. Registration Metadata（Logic 要留的資料）

Collection：`registration_metadata` 或 member 子文档 `registration`：

| 欄位 | 來源 |
|------|------|
| tenant_id, uid | member |
| invite_code_id | 消耗后的 invite 记录 id |
| accept_terms_version | request |
| marketing_opt_in | request |
| registration_channel | `email` \| `google` |
| client_ip / user_agent | handler 注入 context |
| occurred_at | server |

写入时机：email 路径在 `CreateUnverified` 后；social 在 `EnsureFromOIDC` 后。

---

## 8. 狀態機

### Email 註冊

```
register → unverified (member) + zitadel user active
confirm OK → active + JWT
OTP max attempts → challenge locked
abort: cron 清理 7 天未 confirm 的 unverified + zitadel deactivate
```

### Social 註冊

```
social/start → registration_session (Redis TTL 10min, 含 invite_id)
callback OK → active + JWT
session 过期 → 重新 start
```

---

## 9. 失敗補償

| 步驟失敗 | 補償 |
|----------|------|
| invite 无效 | 直接 4xx，无 side effect |
| ZITADEL CreateUser 成功，member 失败 | Logic 调用 `zitadel.DeactivateUser(sub)` |
| member 成功，Send OTP 失败 | `OTP.Invalidate(challenge_id)` |
| confirm 时 Activate 失败 | 5xx，保留 challenge 可重试 confirm |

---

## 10. 錯誤碼（Logic / Usecase）

| 情境 | errb |
|------|------|
| invite 无效 | `InputInvalidFormat` 或 `ResNotFound("invite")` |
| invite 用尽 | `ResInsufficientQuota` |
| email 已注册 | `ResAlreadyExist` |
| OTP 错误 | `AuthForbidden` + cause |
| tenant 不允许注册 | `AuthForbidden` |
| 未 accept 条款 | `InputMissingRequired` |
| ZITADEL 下游失败 | `SvcThirdParty` |
| DB 失败 | `DBError` via wrapRepoErr |

---

## 11. 設定（`etc/gateway.yaml`）

```yaml
Auth:
  AccessExpire: 900
  RefreshExpire: 604800
  RegistrationSessionTTLSeconds: 600

Zitadel:
  Issuer: https://zitadel.internal
  MgmtURL: https://zitadel.internal/management/v1
  ServiceUserToken: ${ZITADEL_SERVICE_TOKEN}
  GoogleClientID: ...
  GoogleClientSecret: ...
  DefaultOrgID: ...   # 或 per-tenant 映射表

Member:
  Registration:
    RequireInviteCode: true
    UnverifiedRetentionDays: 7
    TrustSocialEmailVerified: true
```

---

## 12. Rate Limit（Redis）

| Key | 限制 |
|-----|------|
| `auth:register:ip:{ip}` | 10 / hour |
| `auth:register:email:{tenant}:{email}` | 3 / hour |
| `auth:register:invite_fail:{ip}` | 20 / hour |
| OTP resend | 沿用 member `VerifyRateStore` 或独立 cooldown |

---

## 13. 實施順序（PR 切分）

| PR | 内容 | 验收 |
|----|------|------|
| **1** | `library/zitadel` + config + 单元测试（mock server） | CreateUser / VerifyPassword |
| **2** | `model/auth` TokenUseCase（Issue/Refresh）+ JWT middleware 骨架 | 签/验 JWT |
| **3** | invite repository + InviteUseCase + mongo index | validate/consume |
| **4** | `auth.api` + Logic register + confirm + resend | curl 完整 email 注册 |
| **5** | registration_metadata + audit 字段 | Mongo 可查来源 |
| **6** | Social start/callback + registration session | Google 注册拿 JWT |
| **7** | `POST /auth/login` + 取代 dev header | member API Bearer 访问 |
| **8** | `/auth/token/exchange` + login/social 分离 | 企业 SSO 不冲突 |
| **9** | 文档修订 identity-member-design §3.4、§7.1 | 文档一致 ✅ |

---

## 14. 測試策略

- **单元**：invite consume 并发、OTP purpose、errb 映射
- **集成**：testcontainers Mongo/Redis + zitadel mock
- **E2E CLI**：`cmd/auth-register-test` 模拟 register → confirm → login
- **Social**：mock OAuth callback with fixed state

---

## 15. 與現有代码复用

| 已有 | 用途 |
|------|------|
| `Lifecycle.CreateUnverified` / `Activate` | email 注册 |
| `OTP.*` + `OTPPurposeRegistrationEmail` | 确认 |
| `Notifier` + `NotifyVerifyRegistrationEmail` | 寄信 |
| `Provisioning.EnsureFromOIDC` | social JIT |
| `Tenant.ResolveBySlug` | tenant_slug → tenant_id |
| `response.Write` + `errb` | HTTP 输出 |

---

## 16. 修訂 identity-member-design.md（已完成）

以下修訂已寫入 [identity-member-design.md](./identity-member-design.md)（v1.0.0，2026-05-21）：

- **§3.4**：Gateway 統一註冊 BFF（Email / Social / invite 必填）
- **§7.1**：auth.api 路由表（含實作狀態）
- **§8.1**：CloudEP JWT middleware + dev header fallback
- **§9.1**：login / token exchange 流程
- **§5.9 Case A**：標記為已實作
- **決策列 19**：註冊路徑決策更新

---

## 17. 前端契约（简要）

1. 注册页收集：`tenant_slug`, `invite_code`, 条款, 语言
2. Tab：Email | Google（同 invite + 条款）
3. Email：register → 输入 OTP → confirm → 存 token
4. Google：social/start → redirect → callback 页取 token
5. 错误：`409` 已存在 → 引导 login；invite 无效 → 留在注册页
