# Server9300 Web Auth API

Base URL: `https://my.keylockr.app`

這份文件是 `/web/*` 外部 JSON 契約的權威來源。SSO、AppData、KPS 與綁定生命週期的
權威契約另見 [`api.5.sso.md`](../server9305_v3apikl/api.5.sso.md)。WWW 發布副本位於
`https://keylockr.app/assets/developers/web-auth-api.md`。

## 1. Transport 與 closed-schema 規則

- API 呼叫使用 `POST`、`Content-Type: application/json`、`Accept: application/json`。
- JSON root 必須是 object。Request schema 是 closed：只能傳各 endpoint 列出的欄位；禁止 duplicate
  key、unknown field、錯誤型別及 trailing JSON value。Server 的相容性容忍不是外部契約，caller
  不得依賴。
- 可正常處理的成功與業務錯誤都使用 HTTP `200`。先檢查 `_res`，再按 endpoint 與 discriminator
  解碼其餘欄位。HTTP `404`／`405`、無法解析的 body 或非 JSON response 是 transport failure，
  不得當成業務 response。
- 每個 success variant 都是 closed schema。收到 unknown／duplicate field、缺少 required field、
  錯誤型別或未列出的 discriminator 組合時，consumer 必須 fail closed。
- Request 中的 `string` 均為 UTF-8 JSON string；標記 required 時不得省略。Server 會在文件明示處
  trim／canonicalize，但 consumer 應先傳送乾淨值。

所有 JSON success 都含 `_res: "ok"`。符合 request schema 後進入業務驗證的 JSON error 只有以下形狀：

```ts
type WebError = {
  _res: "err",
  code: string,
  msg?: string,
  color?: "danger" | "warning" | "info",
  reportable?: boolean,
  ref_id?: string,
}
```

Error object 的 optional metadata 只供顯示／回報；不得改變 `code` 的控制流程。Alias 格式錯誤可回
`Wrong Email`、`Email not allowed +`、`No number`、`Invalid US phone number`、
`Invalid Australia phone number`、`Invalid Taiwan phone number`、`Invalid Japan phone number` 或
`alias_forbid_24hours`。頻率限制使用 KPS 通用格式 `try_later N seconds/milliseconds` 或
`try_later_auto N seconds/milliseconds`。缺少 required 欄位、錯誤型別、unknown／duplicate field、
trailing value、無效 JSON 與相依服務故障不提供穩定 business-error shape；它們一律是 protocol／transport
failure。Consumer 應 fail closed，不得從目前 provider 的容忍、panic 或 framework response 猜測契約。

## 2. Web SSO context 與 trust metadata

以下三個 optional request 欄位只適用於 `alias_verify` 與 `reg_choose_num`：

| 欄位 | 型別 | 規則 |
|---|---|---|
| `app_tag` | string | 已註冊服務的 effective App Tag；trim 後區分大小寫。未設定自訂 tag 時傳十進位 Service ID。 |
| `sign_pk` | string | 本次登入的 32-byte Ed25519 public credential，standard Base64。 |
| `enc_pk` | string | Client-owned 的持久 32-byte X25519 identity key，standard Base64；backend-owned 忽略 request 值並使用服務登記公鑰。 |

`svc_id` 不是外部欄位。只有 canonical `app_tag` 非空時 response 才進入 Web SSO context；此時
`safe_ready` 必定出現在既有使用者／完成註冊的 success response。有效 SSO 整合必須傳
`app_tag + sign_pk`；client ownership 另須傳 `enc_pk`，backend ownership 則以 MyDeveloper
登記的 `encrypt_pk_base64` 為唯一 effective `enc_pk`。服務無法解析或權威 key 非 canonical
32 bytes 時 fail closed：主登入仍可成功，但不回 `app_id`。

Trust metadata 的權威來源：

| 欄位 | 權威語意 |
|---|---|
| `safe_id` | KeyLockr `users.id`；第三方穩定 user ID。 |
| `nickname` | KeyLockr 使用者目前顯示名稱；不是穩定 identity。 |
| `token` | 9300 新建並持久化的 session token；只與同一 `safe_id` 配對使用。 |
| `safe_ready` | `users.encrypt_pk` 是否存在的提示；不是授權、能力或 binding 證明。 |
| `app_id` | 9300 已解析服務及驗證 public keys 後回傳的 KeyLockr binding ID；只有 field 存在時才可使用。 |

`app_id` **可能是既有 binding，也可能是本次 alias 流程新建的 binding**：

- 非 AppData 服務按 `(safe_id, app_tag, effective_enc_pk)` 復用既有 SSO binding，並在 OTP
  成功的同一 transaction 內把 `sign_pk` 更新為目前 credential；沒有時可建立純 SSO binding。
  同一 `sign_pk` 不得 claim 不同 `enc_pk`，alias 路徑也不得輪換 AP `enc_pk`。selector 會在同一
  namespace lock 內跨 SSO／AP 檢查 effective `enc_pk`；enc identity 已被另一 binding type 占用與
  sign credential alias 都 fail closed、response 不帶 optional `app_id`，但內部診斷分類彼此獨立。
- credential 更新、server-side generation 遞增與共用持久 invalidation outbox 在同一 PostgreSQL
  transaction 提交。9305 在 outbox 排空前不會信任殘留 identity/result cache；這不新增 Web Auth
  request／response 欄位，也不把 optional supplement 失敗提升為 OTP 主登入失敗。
- AppData 服務只復用 Safe 先前授權的同一 encryption identity，且 `(safe_id, app_tag)` 必須精確對應唯一有效
  AppData file；alias 驗證永不建立 AppData binding 或授予 filekey。
- Response 沒有「本次新建」的、綁定 transaction 的 ownership proof。Consumer 因此不得在 callback、
  finish、session persistence 或後續 AppData 失敗時自動呼叫 `app_logout` 或 `app_del`。普通登出只在
  仍持有目前 credential 時明確呼叫 `app_logout`；`app_del` 只可在完整驗證成功後，由使用者明確選擇
  永久斷開該 binding，最後一個 AppData reference 才會同時刪除共用 file。

## 3. `POST /web/alias_ping`

檢查使用者狀態，可選擇傳送驗證碼。此 endpoint 不回傳 nickname。

```ts
type AliasPingRequest = {
  id: string,       // email、mobile 或 Safe ID
  send_code?: bool, // 省略等同 false
}
```

Success 是下列 closed union：

```ts
type AliasPingSuccess =
  | { _res: "ok", status: "safe_id", safe_id: string }
  | { _res: "ok", status: "user_404" }
  | { _res: "ok", status: "existed", safe_id: string, code_sent?: true }
  | { _res: "ok", status: "user_404", code_sent: true }
```

規則：

- `status="safe_id"` 只表示該 Safe ID 存在，且必須有 `safe_id`；不傳驗證碼。
- Safe ID 不存在時是沒有 `safe_id`／`code_sent` 的 `status="user_404"`。
- Email/mobile 已存在時是 `status="existed"` 並回傳 `safe_id`；不存在時是 `user_404`。
- Email/mobile 且 request `send_code=true` 時必須有 literal `code_sent=true`；省略或 false 時該欄位
  必須不存在。`code_sent=false` 不是合法 response。

## 4. `POST /web/alias_verify`

驗證 email/mobile 的六位驗證碼。成功會消費驗證碼。

```ts
type AliasVerifyRequest = {
  id: string,
  code: string,
  app_tag?: string,
  sign_pk?: string,
  enc_pk?: string,
}
```

既有使用者成功會設定 `uid` Cookie 並新建 session token：

```ts
type AliasVerifyExisting =
  | {
      _res: "ok", status: "existed",
      safe_id: string, nickname: string, token: string,
    }
  | {
      _res: "ok", status: "existed",
      safe_id: string, nickname: string, token: string,
      safe_ready: boolean, app_id?: string,
    }
```

第二個 variant 只在 Web SSO context 使用；`safe_ready` 必填，`app_id` 只有第 2 節全部綁定條件
成立時才存在。Consumer 必須把缺少 `app_id` 視為「沒有可用 binding」，不得從 `safe_ready` 推導。

新使用者 success 固定是以下形狀，不帶 Cookie、session token 或任何 SSO 欄位：

```ts
{ _res: "ok", status: "new", verify_token: string }
```

`verify_token` 有效 3600 秒，只可與同一 canonical alias 搭配 `reg_req`／`reg_choose_num`。

Endpoint-specific errors：

| `code` | 語意 |
|---|---|
| `safe_id 不能用於驗證碼登入` | `id` 被分類為 Safe ID。 |
| `too_many_attempts` | 同一 alias 的驗證失敗次數超限。 |
| `verification_code_error` | 驗證碼不相符。 |
| `user_404` | Alias row 存在但權威 user row 已不存在；fail closed。 |

## 5. `POST /web/reg_req`

為通過 `alias_verify` 的新使用者取得候選 Safe ID。

```ts
type RegRequest = {
  id: string,
  verify_token: string,
}

type RegRequestSuccess = {
  _res: "ok",
  avail_safe_ids: string[],
}
```

`avail_safe_ids` 只包含目前為此 canonical alias 預留的候選值；consumer 不得假設固定數量。

| `code` | 語意 |
|---|---|
| `safe_id 不能用於註冊` | `id` 被分類為 Safe ID。 |
| `token_expired` | Token 不存在或已過期。 |
| `token_alias_mismatch` | Token 的 canonical alias 與 `id` 不符。 |
| `registered_already` | Alias 已由其他流程完成註冊。 |

## 6. `POST /web/reg_choose_num`

選擇已預留的 Safe ID 並完成註冊。SSO caller 必須原樣重送 `alias_verify` 的三個 context 欄位。

```ts
type RegChooseRequest = {
  id: string,
  verify_token: string,
  safe_id: string,
  nickname?: string, // 省略或 exact 空字串時使用 safe_id；非空值原樣儲存且最多 50 Unicode code points
  app_tag?: string,
  sign_pk?: string,
  enc_pk?: string,
}
```

Success 會設定 `uid` Cookie、新建 session token；沒有 `status` 欄位：

```ts
type RegChooseSuccess =
  | { _res: "ok", safe_id: string, nickname: string, token: string }
  | {
      _res: "ok", safe_id: string, nickname: string, token: string,
      safe_ready: false, app_id?: string,
    }
```

第二個 variant 只在 Web SSO context 使用。新註冊 user 沒有 Safe encryption key，所以
`safe_ready` 固定為 `false`。有效的非 AppData 服務可以建立純 SSO binding 並回傳 `app_id`；AppData
服務不會在此建立 binding，因此不回傳 `app_id`。無論是否回傳 `app_id`，都適用第 2 節的禁止自動
cleanup 規則。

| `code` | 語意 |
|---|---|
| `safe_id 不能用於註冊` | `id` 被分類為 Safe ID。 |
| `token_expired` | Token 不存在或已過期。 |
| `token_alias_mismatch` | Token 的 canonical alias 與 `id` 不符。 |
| `number_gone` | 候選號碼已不存在。 |
| `wrong_reservation` | 候選號碼不是為此 alias 預留。 |
| `registered_already` | Alias 已由其他流程完成註冊。 |

## 7. `POST /web/user_update`

使用 `uid` Cookie，或同時提供 `safe_id + token` 更新 nickname。

```ts
type UserUpdateRequest = {
  nickname: string, // trim 後必須為 1...50 UTF-8 bytes
  safe_id?: string,
  token?: string,
}

type UserUpdateSuccess = { _res: "ok", nickname: string }
```

`safe_id` 與 `token` 必須成對提供；否則只嘗試 Cookie。Endpoint errors 是
`not_logged_in`、`nickname_required`、`nickname_too_long`。

## 8. `/web/logout`

BE-to-BE 登出使用 JSON：

```ts
type LogoutRequest = { safe_id: string, token: string }
type LogoutSuccess = { _res: "ok" }
```

`POST /web/logout` 同時提供兩欄時刪除該配對 token，無論是否原本存在都回傳上述 success。
Browser 使用 `GET /web/logout`：清除 `uid` Cookie 並跳轉 `/`；它不是 JSON API。`POST` 未同時提供
兩欄也會走 Browser redirect 分支，BE-to-BE caller 不得依賴。

本 endpoint 只清 9300 的 HTTP session/token，不會代替 9305 KPS `app_logout`。普通 KL SSO
登出必須在仍持有目前 `sign_sk` 時先呼叫 `app_logout`，成功後刪除 signing credential 並保留
`enc_sk/enc_pk`；下次登入才可用同一 encryption identity 與新 signing credential 認回原
`app_id`。永久斷開及刪除最後 AppData reference 才使用 `app_del`。

## 9. 建議流程

```text
alias_ping {id, send_code:true}
  -> alias_verify {id, code, optional SSO context}
     -> status=existed: 建立第三方 session
     -> status=new:
        reg_req {id, verify_token}
        -> reg_choose_num {id, verify_token, safe_id, nickname?, optional SSO context}
```

每一步都重新檢查 `_res` 與 exact variant。`app_id` 缺失時停止 AppData 自動復原；callback、finish、
本機 session 儲存或 AppData 操作失敗時只清理 consumer 自己的 pending/session，不撤銷 KeyLockr binding。
