- Updated OpenAPI documentation to include new OAuth2 usage types: `send_api`. - Added endpoints for issuing one-click unsubscribe tokens in both single and batch requests. - Modified OAuth client creation and management to enforce new usage types and redirect URI requirements. - Implemented logic in the Newsletter service to handle one-click unsubscribe token issuance. - Updated UI to reflect changes in OAuth client usage options and redirect URI handling. - Enhanced token generation logic to support new scopes and audience settings for Send Engine.
178 lines
7.4 KiB
Markdown
178 lines
7.4 KiB
Markdown
# OpenAPI 草案(完整)
|
||
|
||
已補上完整端點與資料結構,並提供 `docs/openapi.yaml` 作為可直接擴充的版本。
|
||
|
||
## 版本
|
||
- OpenAPI: 3.1.0
|
||
- 檔案:`docs/openapi.yaml`
|
||
|
||
## 核心資源
|
||
- OAuth2/OIDC:授權、token、discovery、JWKS
|
||
- Auth:註冊、登入(password grant)、刷新、登出、忘記/重設密碼、Email 驗證
|
||
- User:個人資料
|
||
- Newsletter:訂閱/確認/退訂/偏好
|
||
- Admin:Tenants/Lists/OAuth Clients(MVP CRUD)
|
||
|
||
## Security Schemes
|
||
- OAuth2 (Authorization Code + PKCE、Client Credentials)
|
||
- Bearer JWT(API 使用)
|
||
|
||
## 補充說明
|
||
- `/oauth/token`、`/auth/login`、`/auth/refresh` 使用 `application/x-www-form-urlencoded`
|
||
- Access token 以 JWT(JWS)簽發,建議驗證 `iss` 與 `aud`
|
||
- `/auth/email/verify` 需要 `token` + `email`
|
||
- `/newsletter/subscribe` 會回傳 `confirm_token`
|
||
- `/newsletter/unsubscribe-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[]`)
|
||
- `/newsletter/preferences`(GET/POST)需要 `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:events.write`)
|
||
- `usage=send_api`:
|
||
- 供租戶站台呼叫 Send Engine 發信流程
|
||
- 內建 scope:`newsletter:send.write`、`newsletter:send.read`
|
||
- `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` 建議(且實作要求)`client_type=confidential`
|
||
- `redirect_uris` 僅 `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` / `webhook_outbound` 類型需綁定 `tenant_id`
|
||
- `platform_service` 可不綁定 `tenant_id`
|
||
- 新增 scope:
|
||
- `newsletter:list.read`
|
||
- `newsletter:send.write`
|
||
- `newsletter:send.read`
|
||
- `newsletter:events.read`
|
||
- `newsletter:events.write`
|
||
- `newsletter:events.write.global`
|
||
- 發送引擎僅能用上述 scope,禁止 admin 權限
|
||
- `POST /subscriptions/disable` 需 Bearer token 且包含下列其一:
|
||
- `newsletter:events.write`(tenant-scoped)
|
||
- `newsletter:events.write.global`(platform-scoped,SES 回寫用)
|
||
- 建議 Send Engine 使用 client credentials 取 token,不建議使用長效固定 token
|
||
- Send Engine 建議以 JWKS 驗簽 JWT(JWS),並驗證 `scope/tenant_id/exp`
|
||
- `iss` 由 `Auth:Issuer` 設定(例:`http://localhost:7850/`)
|
||
- `aud` 預設:
|
||
- Send Engine 流程:`send_engine_api`(可用 `Auth:SendEngineAudience` 覆寫)
|
||
- Member Center API 流程:`member_center_api`(可用 `Auth:MemberCenterAudience` 覆寫)
|
||
|
||
### 回寫原因碼(Send Engine -> Member Center)
|
||
- `hard_bounce`
|
||
- `soft_bounce_threshold`
|
||
- `complaint`
|
||
- `suppression`
|
||
|
||
### `/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
|