# OpenAPI 草案（完整）

已補上完整端點與資料結構，並提供 `docs/openapi.yaml` 作為可直接擴充的版本。
其中 `/webhooks/*` 為 Member Center 對外發送時遵循的整合契約（實際由 Send Engine 提供端點）。

現有 OAuth scope catalog、resource、client usage mapping 與 endpoint 對照請見 `docs/SCOPES.md`。

## 版本
- OpenAPI: 3.1.0
- 檔案：`docs/openapi.yaml`

## 核心資源
- OAuth2/OIDC：授權、token、discovery、JWKS
- Auth：註冊、Authorization Code + PKCE 登入、刷新、登出、忘記/重設密碼、Email 驗證
- User：個人資料
- Newsletter：訂閱/確認/退訂/偏好
- Admin：Tenants/Lists/OAuth Clients（MVP CRUD）

## Security Schemes
- OAuth2 (Authorization Code + PKCE、Client Credentials)
- Bearer JWT（API 使用）

## 補充說明
- `/oauth/token` 使用 `application/x-www-form-urlencoded`，僅接受 authorization code、refresh token、client credentials
- `/oauth/token` 預設每來源 IP 每分鐘 30 次，透過 `RateLimits:Api:OAuthToken` 調整；超過回傳 `429`，Production 仍需 AWS WAF
- Password grant 與 anonymous OAuth client 已停用；使用者登入必須走已註冊 `web_login` client 的 Authorization Code + PKCE
- Authorization Code 與 Client Credentials token 的實際 granted scopes 都由 application `usage` 對應的 DB mapping 固定核發，不採用呼叫端自行指定的 scope
- Usage scope seed 會同步啟用與停用狀態；從 catalog 移除的 scope 不會繼續留在既有 Stage/Production mapping
- Token endpoint 會套用後台安全設定：access token `5–1440` 分鐘、refresh token `1–365` 天；設定儲存後下一次簽發立即生效
- Access token 以 JWT（JWS）簽發，建議驗證 `iss` 與 `aud`
- `/auth/email/verify` 需要 `token` + `email`
- Password reset 與 Email verify 預設每來源 IP 每 10 分鐘 10 次，透過 Web/API `AuthTokenConsumption` rate-limit options 調整
- `/newsletter/subscribe` 需 tenant S2S token，成功後會回傳 `confirm_token`
- `/newsletter/unsubscribe-token` 需 tenant S2S token 與 `list_id + email` 才能申請 `unsubscribe_token`
- `/newsletter/one-click-unsubscribe-token` 提供 Send Engine 發信前取得 one-click 退訂 token（`tenant_id + list_id + subscriber_id`）
- `/newsletter/one-click-unsubscribe-tokens` 提供 Send Engine 批次取得 one-click 退訂 token（`tenant_id + list_id + subscriber_ids[]`）
- One-click token 單筆／批次端點預設每 client/IP 每分鐘 60 次，透過 `RateLimits:Api:NewsletterOneClickToken` 調整
- `/newsletter/preferences`（GET/POST）需要 tenant S2S token 與 `list_id + email`，避免跨租戶資料讀取/更新

## 通用欄位
- `occurred_at`：RFC3339（例：`2026-02-10T09:30:00Z`）
- `event_id`、`request_id`：UUID

## 通用錯誤格式
```json
{
  "error": "string_code",
  "message": "human readable message",
  "request_id": "uuid"
}
```

## 多租戶資料隔離原則
- 與訂閱者資料（preferences、unsubscribe token）相關的查詢與寫入，一律必須帶 `list_id + email` 做租戶邊界約束。
- 不提供僅靠 `email` 或單純 `subscription_id` 的公開查詢/操作端點。

## Webhook Auth（Member Center -> Send Engine）
- Header（對齊 Send Engine 規格）：
  - `X-Signature`
  - `X-Timestamp`
  - `X-Nonce`
  - `X-Client-Id`
- `X-Client-Id` 來源：
  - 由 Send Engine 的 `auth_clients.id`（UUID）提供
  - Member Center 以 DB 設定（tenant 設定欄位）保存每個租戶的對應值
  - 可由管理 UI（Tenant 編輯）或整合 API `POST /integrations/send-engine/webhook-clients/upsert` 更新
- 簽章建議：
  - `HMAC-SHA256(secret, "{raw_body}")`（對齊 Send Engine 驗證器）
- 驗證規則：
  - timestamp 在允許時間窗（例如 ±5 分鐘）
  - nonce 不可重複（防重放）
  - `X-Client-Id` 必須存在且 active，且 `auth_clients.tenant_id` 與 payload `tenant_id` 一致
  - 不使用 `X-Client-Id` fallback；缺少 tenant 對應 client 時應略過發送
  - 預設拒絕 `auth_clients.tenant_id = NULL` 的通用 client（除非 Send Engine 明確開啟）
  - signature 必須匹配

## OAuth Client 用途分離（強制）
- `usage=tenant_api`：
  - 供租戶站台拿 token 呼叫 Member Center / Send Engine API
  - scope 僅給業務所需（如 `newsletter:subscriptions.read`、`newsletter:subscriptions.write`、`newsletter:events.write`）
- `usage=send_api`：
  - 供租戶站台呼叫 Send Engine 發信流程
  - 內建 scope：`newsletter:send.write`、`newsletter:send.read`
- `usage=file_api`：
  - 供檔案上傳 / 下載服務使用
  - 使用 `client_credentials`
  - 內建 scope：`files:upload.write`、`files:download.read`、`files:download.delegate`、`files:metadata.read`、`files:delete`
- `usage=web_login`：
  - 供外部網站使用 Member Center 統一登入 UI
  - 使用 Authorization Code + PKCE
  - 需設定 `redirect_uris`
  - 可使用 `client_type=public`
  - 允許 scope：`openid`、`email`、`profile`、全部 current-user `profile:*`
- `usage=webhook_outbound`：
  - 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途
  - 不可用於租戶 API 呼叫
  - `X-Client-Id` 仍以 Send Engine `auth_clients.id` 為準
  - 需設定 `redirect_uris`（Authorization Code 流程）
- `usage=platform_service`：
  - 供平台級 S2S（例如 SES 聚合事件回寫）
  - 可不綁定 `tenant_id`，scope 使用 `newsletter:events.write.global`
- `tenant_api` / `send_api` / `platform_service` / `file_api` 建議（且實作要求）`client_type=confidential`
- `redirect_uris` 僅 `web_login` / `webhook_outbound` 需要；其他 usage 可為空
- 管理規則：
  - 每個 tenant 至少 2 組憑證（`tenant_api` / `webhook_outbound`）
  - 平台級流程另建 `platform_service` 憑證
  - secret 分開輪替，禁止共用

## 整合 API / Auth（狀態）
### API
- `GET /newsletter/subscriptions?list_id=...`：已實作（供發送引擎同步）
- `POST /webhooks/subscriptions`：已實作（Member Center 發送；Send Engine 接收）
- `POST /webhooks/lists/full-sync`：規格已定義（Member Center 發送；Send Engine 接收）
- `POST /subscriptions/disable`：已實作（Send Engine 回寫黑名單）
- `POST /integrations/send-engine/webhook-clients/upsert`：已實作（Send Engine 回填 tenant webhook client id）
- `POST /newsletter/one-click-unsubscribe-token`：已實作（Send Engine 發信前申請 one-click token）
- `POST /newsletter/one-click-unsubscribe-tokens`：已實作（Send Engine 批次申請 one-click token）

### Auth / Scope
- `tenant_api` / `send_api` / `file_api` / `webhook_outbound` 類型需綁定 `tenant_id`
- `platform_service` 可不綁定 `tenant_id`
- 新增 scope：
  - `newsletter:list.read`
  - `newsletter:list.read.global`
  - `newsletter:subscriptions.read`
  - `newsletter:subscriptions.write`
  - `newsletter:send.write`
  - `newsletter:send.read`
  - `newsletter:events.read`
  - `newsletter:events.write`
  - `newsletter:events.write.global`
- 規劃新增 profile scopes：
  - `profile:basic.read`
  - `profile:basic.write`
  - `profile:addresses.read`
  - `profile:addresses.write`
  - `profile:subscriptions.read`
  - `profile:subscriptions.write`
- profile 相關 API 以 service API 為主要整合模式，授權完全由 scope 控制
- 現有 `/user/profile`、`/user/addresses`、`/user/subscriptions` 為 current-user API，資料主體固定為 access token `sub`
- `profile:*` scope 不授權查詢或修改其他會員；by-email API 已停用
- 未來公開會員資料與有限個資整合必須使用獨立 endpoint、DTO 與 scope
- 公開會員 API 不在目前範圍；待外部網站有明確欄位與用途後，依最小揭露原則逐功能新增
- 規劃新增 file access scopes：
  - `files:upload.write`
  - `files:download.read`
  - `files:download.delegate`
  - `files:delete`
  - `files:metadata.read`
- 規劃新增 audience：
  - `file_access_api`
- 發送引擎僅能用上述 scope，禁止 admin 權限
- `POST /subscriptions/disable` 需 Bearer token 且包含下列其一：
  - `newsletter:events.write`（tenant-scoped）
  - `newsletter:events.write.global`（platform-scoped，SES 回寫用）
- `GET /newsletter/subscriptions` 需包含下列其一：
  - `newsletter:list.read`：token 必須含有效 `tenant_id`，且只能讀取該 tenant 的清單
  - `newsletter:list.read.global`：允許平台服務跨 tenant 讀取
- tenant-scoped API 採 fail-closed；缺少、格式錯誤或不相符的 `tenant_id` 一律拒絕
- `POST /newsletter/subscribe` 與 `POST /newsletter/unsubscribe-token` 需 `newsletter:subscriptions.write`；token tenant 必須擁有指定 `list_id`
- `GET /newsletter/preferences` 需 `newsletter:subscriptions.read`，`POST /newsletter/preferences` 需 `newsletter:subscriptions.write`；兩者都驗證清單 tenant
- `/newsletter/confirm` 與 `/newsletter/unsubscribe` 是收件者的一次性 token 完成端點，維持匿名存取
- 建議 Send Engine 使用 client credentials 取 token，不建議使用長效固定 token
- Send Engine 建議以 JWKS 驗簽 JWT（JWS），並驗證 `scope/tenant_id/exp`
  - `iss` 由 `Auth:Issuer` 設定（例：`http://localhost:7850/`）
  - `aud` 由 Auth resource registry 決定，而非每個服務各自新增一組獨立設定
  - 標準資源包含：
    - Send Engine 流程：`send_engine_api`
    - Member Center API 流程：`member_center_api`
    - File access 流程：`file_access_api`
  - `TokenController` 依 scope / usage 對照 resource registry 計算 `aud`

### File Access Auth（A service / client / access agent）

用途：
- `A service` 上傳檔案到 file space
- `A service` 對 client 發放短效下載 URL
- access agent 依 token 決定是否放行上傳或下載

規則：
- Upload 採 S2S：
  - `A service` 使用 `client_credentials`
  - token 至少需帶 `files:upload.write`
  - token 必須帶有效 `tenant_id`；缺少、格式錯誤或與 request 不符時拒絕
  - access agent 驗 `iss/aud/exp/scope/tenant_id`
- Download 採 delegated short-lived token：
  - 不直接將一般 S2S token 暴露給 client
  - `A service` 先以自身商業邏輯驗證 Member Center user 身份與檔案權限
  - `A service` 以 `files:download.delegate` 向 Member Center 申請短效 download token
  - access agent 以 `files:download.read` 回打 Member Center 驗證 token 是否有效
  - 下載 token 至少需帶：
    - `tenant_id`
    - `user_id`
    - `file_id` 或 `object_key`
    - `method=GET`
    - 短效 `exp`
    - 建議 `jti`
- access agent 至少應送回 Member Center 驗：
  - opaque download token
  - `tenant_id`
  - token 內檔案識別與實際 request 是否一致
  - token 內 method 與實際 request 是否一致

補充：
- File Access 與 Send Engine 同屬「外部資源服務」，驗證模型一致
- 差異在於 File Access download token 為 Member Center 簽發的 delegated short-lived opaque token，而非一般 client credentials token
- Member Center 負責 delegated download token issuing 與 validation；File Access agent 負責把實際 GET request 的 tenant/file/method 邊界帶回 Member Center 驗證

### 回寫原因碼（Send Engine -> Member Center）
- `hard_bounce`
- `soft_bounce_threshold`
- `complaint`
- `suppression`

處理規則：
- `hard_bounce` / `soft_bounce_threshold` / `suppression`：
  - 取消該 email 的所有訂閱（跨租戶）
  - 加入全域黑名單
- `complaint`：
  - 僅取消該筆訂閱（依 `subscriber_id + list_id`）
  - 不加入黑名單

### `/subscriptions/disable` 請求欄位（Send Engine -> Member Center）
- `tenant_id`（UUID）
- `subscriber_id`（UUID）
- `list_id`（UUID）
- `reason`（`hard_bounce | soft_bounce_threshold | complaint | suppression`）
- `disabled_by`（建議固定 `send_engine`）
- `occurred_at`（RFC3339）

Member Center 會用 `subscriber_id + list_id` 查詢訂閱，再驗證 `tenant_id` 邊界；驗證通過後才寫入全域 email 黑名單。

### One-Click 退訂 Token 介接方式（Send Engine）
用途：Send Engine 在寄信前批次向 Member Center 取得每位收件者的 one-click 退訂 token。

步驟：
1. 以 client credentials 取得 access token（scope：`newsletter:events.write` 或 `newsletter:events.write.global`）
2. 呼叫 `POST /newsletter/one-click-unsubscribe-tokens`
3. 將回傳 `status=issued` 的 `unsubscribe_token` 寫入每封信的 `List-Unsubscribe` URL

Request（批次）：
```json
{
  "tenant_id": "c9034414-43d6-404e-8d41-e80922420bf1",
  "list_id": "a92fdeda-29bb-42ca-9c05-e7df3983288a",
  "subscriber_ids": [
    "33333333-3333-3333-3333-333333333333",
    "44444444-4444-4444-4444-444444444444"
  ]
}
```

Response（批次）：
```json
{
  "items": [
    {
      "subscriber_id": "33333333-3333-3333-3333-333333333333",
      "unsubscribe_token": "token-xxx",
      "status": "issued"
    },
    {
      "subscriber_id": "44444444-4444-4444-4444-444444444444",
      "unsubscribe_token": null,
      "status": "blacklisted"
    }
  ]
}
```

狀態說明：
- `issued`：可直接用於 one-click 退訂連結
- `not_found`：找不到對應訂閱者（tenant/list/subscriber 邊界不匹配）
- `blacklisted`：已在黑名單，不提供 token
