8d49d883c0
leo 2026-08-10 拍板:「這兩個機制其實是一個機制,可以簡化。」 修改密碼:輸入**現有的** → 輸入新的 → 覆蓋 忘記密碼:收到**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋 ⇒ 同一個畫面、同一條寫入路徑,差別只有「現有密碼」那一格。 後端(cypher-executor/src/routes/portal.ts) - POST /portal/password/change =**那一支**。帶 reset_token 就走連結那一格(忽略 current), 沒帶就要登入 + 正確的 current。兩條路在 writeNewPassword 之後完全相同。 /portal/me/password 保留成**別名轉呼同一支**(不留第二份實作,兩份必然漂移)。 - POST /portal/password/forgot(公開)/GET /portal/password/reset(看票,不消耗) - 連結的安全性(承 D50,不可退讓):**一次有效**(用掉即刪,先刪再回)、 **會過期**(KV TTL 30 分鐘)、**與註冊辨識碼不同源**(現場 crypto 亂數, 只活在本實例 SESSIONS_KV,與 landing SIGNUPS 那組安裝辨識碼毫無關係)。 KV 存的是 token 的 sha256,不是 token 本身。 - 🔴 不做一次性密碼(leo:「不要發一次性密碼太麻煩」)。 - 🔴 已否決不准寫回來的三條(D50):console 密碼救援/重裝重設密碼/直接用固定辨識碼。 中央代寄(arcrun-rag landing 那半在該 repo) leo 給的職責切法:實例產生連結、管一次性/過期;arcrun.dev 只是郵差。 必須這樣切的硬理由:用戶自己的實例**沒有 send_email binding**,根本寄不了信。 🔴 總管紅線:**絕不把整條 URL 交給郵差**——寄件網域帶 DKIM,肯收「任意 URL+任意 email」 就是一台開放的釣魚中繼,燒的是整個網域信譽、不可逆。 故只交出本實例 origin + 一張回呼票,並新增 POST /portal/password/relay-verify: 郵差**回頭打這個 origin** 問「這張票是你發的嗎」,冒用別人網域會被那台實例自己否認 ⇒ 主機屬於呼叫方這件事由郵差親自確認,不是相信宣稱。 信裡的連結落點 GET /portal/password/reset-link(主機刻意=被確認過的那個 origin)。 前端(console-ui/public/portal/index.html) - 登入頁「忘記密碼」入口,**在 portal 不在 console**(leo:「是對 portal 不是對 console, 這樣 youlin 雖然忘記,我還是可以去 portal 忘記密碼。」) - 拆掉登入頁原有的「用管理主控台密碼救援自己」連結與「忘記密碼請聯絡管理員」 ——兩條都是 D50 已否決的做法(console 與 portal 是同一組帳密的兩個鑰匙孔)。 - #v-reset 殼**沒有自己的密碼欄位**:真正的表單是設定頁那唯一一份 #pw-form, 進入連結模式時被原封不動搬過去,只切換「現有密碼」那一格顯不顯示。 同一個表單元素、同一支送出函式 —— D62「同一個畫面」的字面落地。 驗證:tsc 與 baseline 同為 23 個既有錯誤(零新增);27/27 既有測試綠;前端 JS node --check 過。 stage 實測見交付回報。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss