Compare commits

..

10 Commits

Author SHA1 Message Date
Claude c76e10d314 test(portal): 補 #66 與 D62 的回歸測試(8 條,35/35 綠)
#66(三條,斷言的是「session 有沒有被刪掉」而不只是狀態碼)
- 傳播空窗(加速器 key 還在)+讀不到 record → 503 auth_store_propagating,**KV 那筆還在**
- 非空窗+讀不到 record → 401 擋下,**KV 那筆仍然還在**(刪是清潔工,清掉的卻是唯一憑據)
- session 內容本身壞掉 → 401 且**該刪**(那是確定的事實,不是暫時讀不到)
  ⇒ 前兩條在舊碼上必紅:舊碼在這兩個情況都會 SESSIONS_KV.delete()

D62(五條)
- 帶 reset_token 呼叫 /portal/password/change:**不必登入、不必現有密碼**;
  且「先刪再回」=同一條連結第二次必定 400 reset_token_invalid,KV 裡也真的沒了
- 先「看一眼」票(GET /portal/password/reset)不會消耗它
- 亂猜 / 格式不對的 token → 400
- 沒 reset_token 又沒登入 → 401(修改密碼那一格仍要身分)
- 沒設代寄服務 → /portal/password/forgot 誠實 503 mail_relay_not_configured(不假裝寄出去了)

擺放順序有註解說明:#66 那組會故意把 per-isolate overlay 灌成空的(模擬空窗),
而 overlay 是模組級全域變數不隨 test 重置,故必須排在檔案最後。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss
2026-08-10 13:55:53 +00:00
Claude 9a29eb5af6 fix(portal): 瀏覽器實測抓到的兩個前端缺陷——連結被路由吃掉、.hide 從來沒有在藏東西
兩個都是「HTTP 200 看不出來、真的開瀏覽器才會現形」的(規則五點五③)。

① `#/reset?token=…` 被路由正規化吃掉
   route() 的第一行是「raw 不在 VIEWS 就換成 HOME」,而 reset 不是站內的 view
   ⇒ 使用者點信裡的連結,網址被改寫成 `#/search`、修改密碼畫面根本沒出現,
     **而且 token 一起被丟掉**——連結一次有效,等於這條連結就這樣廢了。
   改:route() 開頭先看有沒有 reset token,有就走 showReset 並 return。
   (boot() 那條只顧得到「整頁重新載入」;hash 變動這條路以前沒人守。)

② `.hide` 一直只有三條有 scope 的規則(#tabbar .tab / #sidenav .nav / .modebtn)
   沒有通用的 `.hide { display:none }` ⇒ 任何其他元素掛上 class="hide" **完全沒被藏起來**,
   看起來有藏、其實沒藏。實測畫面:登入頁還沒按「忘記密碼」就已經露出 email 欄與「寄出連結」;
   連結模式下「現有密碼」那一格也照樣顯示(D62 的「差別只有一格」當場破功)。
   補通用規則,帶 !important 以蓋過 inline display(#forgot-box 有)。
   既有三種用法意圖一致(都是要藏),行為不變。

stage 瀏覽器實測(youlin,playwright):忘記密碼 → 取連結 → 點進去 →
不輸入現有密碼設新密碼 → 自動回登入頁 → 用新密碼登入成功 → 進站 →
設定頁同一份表單(現有密碼那一格回來)改密碼 → **沒有被踢出去、可以繼續操作**。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss
2026-08-10 13:51:41 +00:00
Claude 8d49d883c0 feat(portal): 改密碼與忘記密碼合成同一個機制,用連結不用一次性密碼(D62)
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
2026-08-10 13:23:44 +00:00
Claude 417d69ceb3 fix(portal): 傳播空窗期不再銷毀 session——讀不到 ≠ 這個人不存在(arcrun-rag#66)
leo 2026-08-10 在 youlin stage 改完密碼被登出,之後怎麼登都回不去。
病灶是「這一瞬間讀不到」被當成「這個人不存在」,而且做的是**不可逆**的動作。

認證的家是 CF Workers Secret,改它會產生 worker 新版本,既有 isolate 讀到的
還是舊 env(#55 實測 ≥15 秒)。舊的 requirePortalUser 在那個空窗裡直接把
session 從 KV 刪掉——不是擋下讓你重試,是當場銷毀,等 secret 鋪開也回不來。
#55 補的「讀不到就再問一次加速器」只加在登入路徑(findAndVerifyUser),這道門沒有。

三處修法(缺一不可):

1. requirePortalUser 先問一次加速器再判定(與登入路徑同一招、同一支函式)
2. **永不因「讀不到」刪 session**;仍讀不到且正在傳播空窗 → 回 503
   `auth_store_propagating` 而不是 401(讀得到 record 的「已停用」照舊刪,那是確定的事實)
3. 前端 boot() 從「任何非 2xx 都 dropSession」收斂成**只有 401 才算被登出**
   ——後端不刪、前端卻自己丟掉 localStorage 的 token,症狀一模一樣

順帶修掉同一族的一個資料遺失路徑:mutateAuthStore 舊版拿「可能是舊版 env」當底稿做
read-modify-write,而 writeAuthStore 會重切分片並刪掉多出來的舊分片 ⇒ 底稿若是
「某帳號被建立之前」的版本,那個帳號會在這次寫入中被抹掉且無法還原(secret 是唯一真相源)。
改成先問加速器、再把 env 版與 overlay 版取聯集當底稿;刪除仍有效(fn() 在聯集之後才跑)。

驗證:tsc 與 baseline 同為 23 個既有錯誤(零新增);tests/portal-auth.test.ts 27/27 綠。
stage 實測見交付回報。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss
2026-08-10 13:22:34 +00:00
Leo 035e8b255b chore(mcp): stage build 標記對齊實際部署的 commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 09:51:36 +00:00
Leo 1c630ecfd4 docs(mcp): /health 註解誠實界定——404 只代表比本版舊,不等於舊世代
實測:leo21c 與 geek6688 的 /health 都回 404,但 geek6688 是新世代(email+password)。
原註解會讓下一個人拿 404 去判世代而誤判。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 09:51:36 +00:00
Leo a99d3e5a3e chore(mcp): 補 stage 部署設定(youlin)——升級 arcrun-mcp 終於有測試場
arcrun-mcp 不在安裝器出貨的那批裡,所以「升級某台的 arcrun-mcp」一直沒有地方先驗,
要驗只能拿 leo21c(唯一一份 47.9 萬筆知識的真身)冒險。本檔把 stage 補上。

順帶把「手動直推 arcrun-mcp 到自架帳號」的兩個坑從人的記性搬進檔案:
① 拿掉 mcp.arcrun.dev route(zone 在 uncle6,自架帳號沒有)
② OAUTH_KV 填真 id(出貨 toml 是佔位符,直推不填 → /authorize 503)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 09:51:36 +00:00
Leo 894408181f feat(mcp): GET /health — 一條 curl 看出這台是新舊世代 MCP
判斷一台實例的 arcrun-mcp 是哪一代認證,原本只能打 /authorize 剖 HTML 有幾個欄位
(ops-facts 2026-08-10 的土法)。改成誠實版本面:200+auth=portal-login =新世代;
404 =舊世代(同意頁還要那把沒人拿得到的 MCP_OWNER_SECRET ⇒ 等於接不上)。
順帶 build 標記(MCP_BUILD var)讓「這台跑的是哪一版」看得到。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 09:51:36 +00:00
uncle6me-web d022ca067b merge: kbdb DELETE 誠實回報向量刪除結果(arcrun-rag#46)
總管複核:改法與同檔 /entries/deprecate-by-library 一致(同步 await 再回應),
不動 schema、不加表(守 D38),附 84 行測試。
舊版 fire-and-forget 會把向量刪除失敗靜默吞掉——呼叫端看到「刪除成功」
但語意搜尋還留著殘影,這正是 #46 回報的症狀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:50:36 +08:00
uncle6me-web 6715402bcc fix(kbdb): DELETE /entries/:id 向量刪除改同步+誠實回報(arcrun-rag#46)
舊版把向量刪除包成 fire-and-forget(waitUntil(...).catch(()=>{}))——失敗被靜默
吞掉,呼叫端(rag_takedown_direct workflow/未來的 portal 刪除 UI)永遠不知道
向量沒清乾淨。與同檔 /entries/deprecate-by-library(同步 await + 回報
vectors_deleted)的做法不一致,補齊同款誠實回報:回應加 vector_deleted
(true/false/null),D1 本體刪除不因向量失敗而被擋下。

補上這支端點原本零覆蓋的測試(entries-delete.test.ts,3 案:模組未開/
向量刪除成功/向量刪除失敗)。

隨附 live e2e 驗證(youlin stage,4 筆測試知識,含批次 3 筆):刪除後
keyword/semantic 混合檢索與 rag_chat AI 問答皆不再讀到已刪內容,控制組
(未刪的既有知識)不受影響——確認端到端「刪除即在所有查詢路徑消失」成立,
細節見 Leo/arcrun-rag#46 留言。
2026-08-10 15:41:01 +08:00
11 changed files with 885 additions and 44 deletions
+200 -17
View File
@@ -122,6 +122,12 @@
.modebtn { flex: none; padding: 9px 18px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); }
.modebtn.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }
.modebtn.hide { display: none; }
/* 🔴 2026-08-10 實測撞到:`.hide` 一直**只有上面那三條有 scope 的規則**#tabbar .tab / #sidenav .nav / .modebtn),
所以任何其他元素掛上 class="hide" 都**完全沒有被藏起來**——看起來有藏、其實沒藏。
D62 的「忘記密碼」欄位與「現有密碼」欄位就是這樣在瀏覽器上原形畢露的。
補一條通用規則;帶 !important 是因為要蓋過 inline 的 display#forgot-box 就有)。
既有那三種用法的意圖與本規則一致(都是「藏起來」),加這條不改變它們的行為。 */
.hide { display: none !important; }
.kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; }
@media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
.kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; }
@@ -219,12 +225,42 @@ if (!window.ARCRUN_API_BASE) {
<button class="btn" id="login-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em">登入</button>
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
</div>
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">帳號由管理員發放。忘記密碼請聯絡管理員重設。</div>
<div style="font-size:13px;color:rgba(var(--ink-rgb),.4);line-height:1.7">你自己就是管理員?<a href="/console/" style="color:var(--amber)">用管理主控台密碼救援自己</a></div>
<!-- 🔴 D50/D62:這裡原本寫「忘記密碼請聯絡管理員重設」+一條「用管理主控台密碼救援自己」
的 console 連結。**兩條都是已否決的做法**,不准寫回來:
① leo:「我要是記得密碼,還來找忘記密碼幹嘛?」——console 登入頁一樣要密碼
② console 與 portal 是安裝時**同一組帳密**寫進兩個地方(D50 補刀)
⇒ console 不是第二道閘,是同一把鎖的第二個鑰匙孔,往它補洞不會多出任何一條路
③ 唯一管理員忘記密碼時「聯絡管理員」=叫他聯絡他自己
leo 2026-08-10:「**是對 portal 不是對 console**,這樣 youlin 雖然忘記,
我還是可以去 portal 忘記密碼。」⇒ 出口就在這裡。 -->
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">
忘記密碼了?<a href="#" id="login-forgot" style="color:var(--amber)">寄一條「修改密碼」連結給我</a>
</div>
<div id="forgot-box" class="hide" style="display:flex;flex-direction:column;gap:10px;margin-top:4px">
<input type="email" id="forgot-email" class="txt" placeholder="你註冊時用的 Email" autocomplete="username">
<button class="btn" id="forgot-submit">寄出連結</button>
<div id="forgot-status" style="font-size:13.5px;min-height:1.2em;line-height:1.6"></div>
</div>
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
</div>
</div>
<!-- 修改密碼(D62):忘記密碼點信裡的連結進來的落點。
🔴 這個殼**刻意沒有自己的密碼欄位**——真正的表單是設定頁那一份 #pw-form,
進到這個模式時被 JS 原封不動搬進 #reset-slot。D62:「同一個畫面、同一條寫入路徑,
差別只有一格:現有密碼是要填、還是被連結豁免。」複製一份長得像的就會漂移。 -->
<div class="authwrap view" id="v-reset">
<div class="authbox">
<div>
<div style="font-size:22px;font-weight:600;margin-bottom:6px">設定新密碼</div>
<div id="reset-who" style="font-size:14px;color:rgba(var(--ink-rgb),.55);line-height:1.7;margin-bottom:14px"></div>
<div id="reset-slot"></div>
<div id="reset-error" class="err" style="font-size:14px;line-height:1.7"></div>
<div style="margin-top:16px;font-size:13.5px"><a href="#/" id="reset-back" style="color:var(--amber)">回登入頁</a></div>
</div>
</div>
</div>
<!-- 首次設定(t49leo 07-25:一鍵安裝的用戶打開專屬網址,第一件事就是在這裡建帳號,
不再需要回安裝器那頁。auth-status 說 configured:false 才會出現。)-->
<div class="authwrap view" id="v-firstsetup">
@@ -375,10 +411,14 @@ if (!window.ARCRUN_API_BASE) {
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
</div>
</div>
<div class="panel">
<div style="font-size:17px;font-weight:600">更改密碼</div>
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
<!-- D62leo 2026-08-10):「改密碼與忘記密碼**這兩個機制其實是一個機制,可以簡化**。」
⇒ 這個表單是**整個站唯一一份**改密碼的表單。忘記密碼點連結進來時,
它會被原封不動搬到 #v-reset 那個殼裡(不是複製一份長得像的)——
差別只有「現有密碼」那一格顯不顯示。同一個畫面、同一條寫入路徑。 -->
<div class="panel" id="st-pw-panel">
<div style="font-size:17px;font-weight:600" id="pw-title">更改密碼</div>
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)" id="pw-sub">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
<div id="pw-form" style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼" autocomplete="current-password">
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)" autocomplete="new-password">
<input type="password" id="st-pw-new2" class="txt" placeholder="再輸入一次新密碼" autocomplete="new-password">
@@ -746,6 +786,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
}
var LOADERS = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
function route() {
// 🔴 D62`#/reset?token=…` **不是站內的一個 view**,是「還沒登入的人拿著連結進來」。
// 少了這一段,下面那行正規化會把不認得的 `reset` 換成 HOME ⇒ **連 token 一起丟掉**
// 使用者點信裡的連結會落在搜尋頁、而且再也回不去(連結一次有效,已經耗掉了)。
// 實測(2026-08-10 stage,瀏覽器):網址被改寫成 `#/search`、修改密碼畫面根本沒出現。
var rt = resetTokenFromUrl();
if (rt) { showReset(rt); return; }
var r = currentRoute();
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);
@@ -817,11 +863,28 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
} catch (e) { /* 登入殼還沒渲染就算了 */ }
}
function boot() {
// D62:信裡的「修改密碼」連結最優先——這個人**就是進不去才收到這封信的**,
// 不可以因為瀏覽器裡剛好還留著一個舊 token 就把他丟去別的畫面。
var rt = (typeof resetTokenFromUrl === 'function') ? resetTokenFromUrl() : '';
if (rt) { showReset(rt); return; }
if (!S.token) { showAuth(); return; }
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (!x.ok) { dropSession(); return; }
// 🔴 arcrun-rag#66**只有 401 才算「你被登出了」**。
// 舊版是 `if (!x.ok) dropSession()` 任何非 2xx 都清掉 token——
// 包含改完密碼後那幾十秒的 503(認證 secret 正在鋪開)與 502(KBDB 暫時不可達)。
// 那正是 leo 08-10「改完密碼、重新整理就回不去」的最後一哩:
// 後端就算不刪 KV,前端自己把鑰匙丟了,結果一樣。
if (x.status === 401) { dropSession(); return; }
if (!x.ok) {
// 暫時性故障:留著 session,告訴他這是暫時的、下一步做什麼。
showAuth();
$('login-status').textContent = (x.d && x.d.code === 'auth_store_propagating')
? '認證資料正在更新中(通常幾十秒),請稍候重新整理——你並沒有被登出。'
: '服務暫時不可用,請稍後重新整理(你的登入沒有失效)。';
return;
}
S.profile = x.d;
showApp();
})
@@ -1185,6 +1248,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
}
// 任何 data 請求收到 401 → session 失效 → 回登入殼
//
// 🔴 arcrun-rag#66 的前端那一半:後端在「認證資料正在鋪開」時改回 503
// `auth_store_propagating`(不再回 401、也不再刪 KV 那筆 session)。
// 這裡**必須跟著只認 401**——若前端把任何錯誤都當登出,後端不刪也沒用:
// 使用者手上的 token 會被自己的瀏覽器丟掉,症狀跟被踢出去一模一樣。
// (503 由各呼叫點自己顯示錯誤訊息,session 原封不動。)
function guard401(status) {
if (status === 401) { dropSession(); return true; }
return false;
@@ -2022,33 +2091,147 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
'<div class="kvline"><span class="muted">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' +
'<div class="kvline" style="align-items:flex-start"><span class="muted" style="flex:none">可查庫</span><span style="text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + libHtml + '</span></div>';
}
$('st-pw-save').addEventListener('click', function () {
// ══════════ D62:改密碼與忘記密碼=同一個機制 ══════════
//
// leo 2026-08-10:「這兩個機制其實是一個機制,可以簡化。」
// 修改密碼:輸入**現有的** → 輸入新的 → 覆蓋
// 忘記密碼:收到**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
// ⇒ 底下只有**一個表單元素**(#pw-form)與**一支送出函式**,差別只有「現有密碼」那一格
// 顯不顯示、以及帶 current 還是帶 reset_token。兩份長得像的東西必然漂移
// arcrun-rag#40「同一個事實兩份」)。
// 🔴 不做一次性密碼(leo:「不要發一次性密碼太麻煩」)。
var S_RESET_TOKEN = ''; // 非空=現在是「忘記密碼」模式
/** 把那一份唯一的表單搬到指定容器,並決定「現有密碼」那一格出不出現。 */
function mountPasswordForm(host, needCurrent) {
var form = $('pw-form');
if (form && form.parentNode !== host) host.appendChild(form);
$('st-pw-old').classList.toggle('hide', !needCurrent);
$('st-pw-save').textContent = needCurrent ? '更新密碼' : '設定新密碼';
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
$('st-pw-status').textContent = '';
}
/** 送出:兩種模式共用。帶 reset_token 就走連結那一格,否則走現有密碼那一格。 */
function submitPasswordChange() {
var needCurrent = !S_RESET_TOKEN;
var oldPw = $('st-pw-old').value;
var newPw = $('st-pw-new').value;
var newPw2 = $('st-pw-new2').value;
var st = $('st-pw-status');
st.className = 'err';
if (!oldPw || !newPw) { st.textContent = '請填舊密碼與新密碼'; return; }
if (needCurrent && !oldPw) { st.textContent = '請填現在的密碼'; return; }
if (!newPw) { st.textContent = '請填新密碼'; return; }
if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; }
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
var body = { 'new': newPw };
if (S_RESET_TOKEN) body.reset_token = S_RESET_TOKEN; else body.current = oldPw;
$('st-pw-save').disabled = true;
st.textContent = '';
fetch(API_BASE + '/portal/me/password', {
st.textContent = '處理中…';
fetch(API_BASE + '/portal/password/change', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ current: oldPw, 'new': newPw })
headers: Object.assign({ 'Content-Type': 'application/json' }, needCurrent ? authHeaders() : {}),
body: JSON.stringify(body)
})
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('st-pw-save').disabled = false;
if (guard401(x.status)) return;
if (!x.ok) { st.textContent = x.d.error || '更新失敗'; return; }
// 忘記密碼模式沒有 session,401 不代表「你被登出」——別誤踢
if (needCurrent && guard401(x.status)) return;
if (!x.ok) { st.textContent = (x.d && x.d.error) || '更新失敗'; return; }
st.className = 'ok';
st.textContent = '密碼已更新';
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
toast('密碼已更新');
if (S_RESET_TOKEN) {
st.textContent = '密碼已設定好了,正在帶你回登入頁…';
var who = (x.d && x.d.email) || '';
S_RESET_TOKEN = '';
setTimeout(function () {
history.replaceState(null, '', location.pathname);
location.hash = '';
$('v-reset').classList.remove('on');
mountPasswordForm($('st-pw-panel'), true);
showAuth();
if (who) $('login-email').value = who;
$('login-status').className = 'ok';
$('login-status').textContent = '新密碼已生效,請用它登入。';
}, 1200);
} else {
st.textContent = '密碼已更新(你不會被登出,可以繼續操作)';
toast('密碼已更新');
}
})
.catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); });
}
$('st-pw-save').addEventListener('click', submitPasswordChange);
// ── 登入頁的「忘記密碼」入口(**在 portal,不是 console**leo 2026-08-10)──
$('login-forgot').addEventListener('click', function (ev) {
ev.preventDefault();
$('forgot-box').classList.remove('hide');
$('forgot-email').value = $('login-email').value.trim();
$('forgot-email').focus();
});
$('forgot-submit').addEventListener('click', function () {
var email = $('forgot-email').value.trim();
var st = $('forgot-status');
st.className = 'err';
if (!email) { st.textContent = '請填 Email'; return; }
$('forgot-submit').disabled = true;
st.textContent = '寄送中…';
fetch(API_BASE + '/portal/password/forgot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
})
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('forgot-submit').disabled = false;
if (!x.ok) { st.textContent = (x.d && x.d.error) || '寄送失敗'; return; }
st.className = 'ok';
st.textContent = (x.d && x.d.message) || '如果這個 email 有帳號,連結已經寄出了。';
})
.catch(function (e) { $('forgot-submit').disabled = false; st.textContent = friendlyErr(e); });
});
/** 點信裡的連結進來:`…/portal/#/reset?token=…`cypher 的 /portal/password/reset-link 導過來的)。 */
function resetTokenFromUrl() {
var m = String(location.hash || '').match(/[?&]token=([^&]+)/);
if (m) return decodeURIComponent(m[1]);
m = String(location.search || '').match(/[?&]token=([^&]+)/);
return m ? decodeURIComponent(m[1]) : '';
}
function showReset(token) {
$('v-login').classList.remove('on');
$('v-firstsetup').classList.remove('on');
$('shell').classList.remove('on');
$('tabbar').classList.remove('on');
$('v-reset').classList.add('on');
$('reset-error').textContent = '';
// 先問一次「這張票還有效嗎」——連結是一次有效、30 分鐘過期的,
// 過期就要當場說清楚,不要讓人填完密碼才被打回票(#49:別把系統狀態講成用戶的錯)。
fetch(API_BASE + '/portal/password/reset?token=' + encodeURIComponent(token))
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok || !x.d || x.d.valid !== true) {
$('reset-who').textContent = '';
$('reset-error').textContent = (x.d && x.d.error) || '這條連結已經失效了。請回登入頁重新按一次「忘記密碼」。';
return;
}
S_RESET_TOKEN = token;
$('reset-who').innerHTML = '你正在為 <strong>' + esc(x.d.email) + '</strong> 設定新密碼。<br>這條連結只能用一次,<strong>不需要輸入現在的密碼</strong>。';
mountPasswordForm($('reset-slot'), false); // ← 差別只有這一格
})
.catch(function (e) { $('reset-error').textContent = friendlyErr(e); });
}
$('reset-back').addEventListener('click', function (ev) {
ev.preventDefault();
S_RESET_TOKEN = '';
history.replaceState(null, '', location.pathname);
location.hash = '';
$('v-reset').classList.remove('on');
mountPasswordForm($('st-pw-panel'), true);
showAuth();
});
boot();
+51 -3
View File
@@ -143,7 +143,18 @@ export function authStoreWritable(env: Bindings): boolean {
*/
export function readAuthStore(env: Bindings): AuthStoreData {
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
return readAuthStoreFromEnv(env);
}
/**
* 只讀 `env` 那一版(**跳過 overlay**)。
*
* 為什麼要分出這一支(#66 修補的一半):read-modify-write 時,overlay 與 env 兩份都可能
* 各自「有對方沒有的帳號」——overlay 可能來自加速器(別台 isolate 剛寫的),
* env 可能是**比加速器更新**的一版(加速器過期、或這顆 isolate 已經吃到新版本)。
* 只採信其中一份就會把另一份獨有的帳號寫掉,而 secret 是唯一真相源 ⇒ **永久消失**。
*/
function readAuthStoreFromEnv(env: Bindings): AuthStoreData {
const bag = env as unknown as Record<string, unknown>;
const out = emptyStore();
for (const name of shardNames(env)) {
@@ -268,13 +279,50 @@ export async function hydrateFromAccelerator(env: Bindings): Promise<boolean> {
}
}
/** 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。 */
/**
* 這台實例「剛剛才寫過認證儲存」嗎——亦即現在是不是**傳播空窗期**。
*
* 🔴 #66 用它分辨兩件長得一樣、後果完全相反的事:
* - 「查不到這個帳號」= 帳號真的被刪了 → 該擋(401)
* - 「查不到這個帳號」= secret 新版本還沒鋪到這顆 isolate → **不該擋,更不該刪 session**
* 加速器的 key 只在寫入後存活 `ACCEL_TTL_SECONDS`,它存在就代表「最近有人動過認證儲存」。
* 讀不到(KV 掛了/沒設)⇒ 回 false,退回舊行為,不會比現在更糟。
*/
export async function authStoreRecentlyWritten(env: Bindings): Promise<boolean> {
try {
return Boolean(await env.SESSIONS_KV.get(ACCEL_KEY));
} catch {
return false;
}
}
/** 兩份 store 取聯集:同一個 id 以 `updated_at` 新者為準;只在一邊出現的一律保留。 */
function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
const byId = new Map<string, AuthUserRecord>();
for (const u of [...a.users, ...b.users]) {
const prev = byId.get(u.id);
if (!prev || (u.updated_at ?? '') >= (prev.updated_at ?? '')) byId.set(u.id, u);
}
return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
}
/**
* 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。
*
* 🔴 #66:**改之前先把手上這份補齊**。舊版直接 `readAuthStore(env)` 當底稿,而 `writeAuthStore`
* 會把整份重切分片並刪掉多出來的舊分片 ⇒ 若底稿是「某個帳號被建立之前」的版本,
* 那個帳號會在這次寫入中**被抹掉,且再也回不來**(secret 是唯一真相源,沒有第二份可還原)。
* 這正是「改一次密碼=有人被鎖在門外」的另一半病因。
*
* 補法:先問一次加速器,再把 env 版與 overlay 版**取聯集**當底稿——
* 兩邊獨有的帳號都留下來;刪除仍然有效,因為 `fn()` 是在聯集**之後**才跑。
*/
export async function mutateAuthStore(
env: Bindings,
fn: (data: AuthStoreData) => void | Promise<void>,
): Promise<AuthStoreData> {
const data = readAuthStore(env);
const next: AuthStoreData = { version: 1, console: data.console, users: [...data.users] };
await hydrateFromAccelerator(env);
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
await fn(next);
await writeAuthStore(env, next);
return next;
+14
View File
@@ -98,6 +98,20 @@ export function randomHex(bytes: number): string {
.join('');
}
/**
* SHA-256 → hex。**用途只有一個**:把「修改密碼連結」的 token 換成查詢用的 KV keyD62)。
*
* 為什麼不直接拿 token 當 key:連結裡的 token 是**能改密碼的憑據**,直接當 key 等於
* 把它明碼存在 KV 裡;改存雜湊後,看得到 KV 的人也拿不到可用的連結。
* (這裡只做 digest——不是 rule 2.2 禁的 `crypto.subtle.decrypt` / RSASSA 簽章。)
*/
export async function sha256Hex(input: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。
* 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。
+327 -20
View File
@@ -23,7 +23,7 @@ import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbBase } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth';
import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
// arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
// 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。
@@ -33,6 +33,7 @@ import { storeCredential, hasCredential } from './credentials';
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
import {
AuthStoreWriteError,
authStoreRecentlyWritten,
authStoreStatus,
findAuthUserByEmail,
findAuthUserById,
@@ -413,6 +414,23 @@ export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Resp
/**
* portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。
* 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KVbest-effort,正確性不依賴它)。
*
* 🔴 `Leo/arcrun-rag#66`2026-08-10leo 本人被鎖在 stage 外面的那條):
* **「這一瞬間讀不到」不可以觸發不可逆的動作。**
* 認證的家是 CF Workers Secret,改它(改密碼/建帳號/停用)會產生 worker 新版本,
* **既有 isolate 讀到的還是舊 env**`#55` 實測 ≥15 秒)。舊版在那個空窗裡:
* ① `getRecordById` 讀不到 → ② **直接把 session 從 KV 刪掉** → ③ 回 401
* ⇒ 帶著一個**完全有效的 token**,登入狀態被當場銷毀,等 secret 鋪開也回不來。
* `#55` 補的「讀不到就再問一次加速器」只加在登入路徑(`findAndVerifyUser`),這道門沒有。
*
* 三段修法(缺一不可):
* 1. **先問一次加速器再判定**——與登入路徑同一招,同一支 `hydrateFromAccelerator`。
* 2. **永不因「讀不到」刪 session**。刪是 best-effort 清潔工,而它清掉的是使用者唯一的
* 憑據;KV 的 TTL 本來就會回收,這件事沒有非做不可的理由。
* 3. 仍然讀不到且**正在傳播空窗**(加速器 key 還在)→ 回 **503 `auth_store_propagating`**
* 不是 401。理由在前端:portal 的 `guard401()` 一看到 401 就清 localStorage 踢回登入頁
* ⇒ 就算 KV 那筆還在,使用者手上的 token 也被自己的瀏覽器丟掉了。
* **後端不刪、前端不丟,這件事才算真的修好。**
*/
export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
const token = bearerToken(c);
@@ -426,15 +444,33 @@ export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Pro
/* fallthrough */
}
if (!recordId) {
// 這一筆 session 的內容本身壞掉=確定的事實(不是讀不到),刪它是對的。
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
}
const rec = await getRecordById(c.env, recordId);
let rec = await getRecordById(c.env, recordId);
if (!rec && (await hydrateFromAccelerator(c.env))) {
rec = await getRecordById(c.env, recordId); // ①:與登入路徑同一招,再問一次加速器
}
if (!rec) {
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
// ③:分辨「傳播空窗」與「帳號真的沒了」——前者不可以把人踢出去。
if (await authStoreRecentlyWritten(c.env)) {
return {
ok: false,
res: c.json(
{
error: '認證資料正在更新中(Cloudflare 正在鋪開新版本),請稍候幾秒再試——你並沒有被登出。',
code: 'auth_store_propagating',
},
503,
),
};
}
// ②:查無此帳號(可能真的被刪了)→ 擋下即可,**不刪 session**KV TTL 自己會回收)。
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
}
if ((rec.values.status ?? '') !== 'active') {
// 讀得到 record = 確定的事實,停用要即時生效,刪 session 是對的。
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
return { ok: false, res: c.json({ error: '帳號已停用' }, 403) };
}
@@ -679,29 +715,300 @@ portalRouter.get('/portal/session', (c) =>
}),
);
// POST /portal/me/password — body {current, new}。驗舊密改新密。
portalRouter.post('/portal/me/password', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const body = await c.req.json().catch(() => null);
const current = String(body?.current ?? '');
const next = String(body?.new ?? '');
if (!current || !next) return c.json({ error: 'current 與 new 必填' }, 400);
if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
// ═════════════════ D62:改密碼與忘記密碼是**同一個機制** ═══════════════════════
//
// leo 2026-08-10 拍板(頂層 decisions-summary D62):
// 「這兩個機制其實是一個機制,可以簡化。」
// - 修改密碼:到「修改密碼」→ 輸入**現有的** → 輸入新的 → 覆蓋現有的
// - 忘記密碼:寄給你**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
// ⇒ **同一個畫面、同一條寫入路徑,差別只有一格**:「現有密碼」是要填、還是被連結豁免。
//
// 🔴 **不做一次性密碼**(leo:「不要發一次性密碼太麻煩」)——這修正了 D50 的「一次性驗證碼」,
// 形態改成連結;D50 其餘部分(console 退場、不准沿用註冊辨識碼)不變。
// 🔴 連結的安全性(承 D50 的理由,不可退讓):
// ① **一次有效**——用掉就從 KV 刪除(本檔 consumeResetToken
// ② **會過期**——KV expirationTtl 30 分鐘,過了就是不存在
// ③ **與註冊辨識碼不同源**——現場 crypto 亂數產生、只活在這台實例的 SESSIONS_KV
// 跟 landing `SIGNUPS` 那組安裝辨識碼沒有任何關係。
// D50 否決固定辨識碼的理由正是「綁定不會變 ⇒ 等於不會過期的鑰匙」。
// 🔴 已否決、不准寫回來的三條(D50):console 密碼救援/重裝重設密碼/直接用固定辨識碼。
// 🔴 **入口在 portal,不是 console**leo 2026-08-10:「是對 portal 不是對 console
// 這樣 youlin 雖然忘記,我還是可以去 portal 忘記密碼。」)——console 與 portal 是
// 安裝時同一組帳密寫進兩個地方(D50 補刀),往 console 補洞不會多出任何一條路。
const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
if (!ok) return c.json({ error: '舊密碼不正確' }, 401);
/** 「修改密碼」連結 token 的 KV key 前綴(存的是 token 的 sha256,不是 token 本身)。 */
const PWRESET_PREFIX = 'portal_pwreset:';
/** 連結有效期:30 分鐘(安全要求②「會過期」)。 */
const PWRESET_TTL_SECONDS = 30 * 60;
/** 同一個 email 的請求節流 key 前綴+冷卻秒數(避免被拿來灌信)。 */
const PWRESET_THROTTLE_PREFIX = 'portal_pwreset_req:';
const PWRESET_THROTTLE_SECONDS = 120;
/** 代寄回呼票(讓郵差可以回頭問「這封真的是你要我寄的嗎」)的 key 前綴與存活秒數。 */
const RELAY_TICKET_PREFIX = 'portal_relay_ticket:';
const RELAY_TICKET_TTL_SECONDS = 120;
const newHash = await hashPassword(next);
await patchRecordValues(c.env, auth.user.recordId, {
password_hash: newHash,
updated_at: new Date().toISOString(),
interface ResetTokenPayload {
record_id: string;
email: string;
created_at: string;
}
/**
* portal 前端(使用者會看到的那個網址)的 origin。
* 與 index.ts 的 CORS 白名單**同一套推導**UI_ORIGINS 優先,否則用 workers.dev 兄弟位址。
* 2026-08-08 事故的教訓:能推導出來的東西就不要再多一個「必須被注入、漏了看不出來」的變數。)
*/
function portalUiOrigin(env: Bindings): string | null {
const declared = String(env.UI_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
if (declared.length > 0) return declared[0];
const sub = String(env.WORKER_SUBDOMAIN ?? '').trim();
return sub ? `https://arcrun-rag-ui.${sub}.workers.dev` : null;
}
/** 發一張「修改密碼」連結票,回傳要放進連結的 token(明碼只在這一刻存在)。 */
async function issueResetToken(env: Bindings, recordId: string, email: string): Promise<string> {
const token = randomHex(32);
const payload: ResetTokenPayload = { record_id: recordId, email, created_at: new Date().toISOString() };
await env.SESSIONS_KV.put(`${PWRESET_PREFIX}${await sha256Hex(token)}`, JSON.stringify(payload), {
expirationTtl: PWRESET_TTL_SECONDS,
});
return token;
}
/** 看一眼票是否有效(**不消耗**)——給「點進連結先渲染畫面」用。 */
async function peekResetToken(env: Bindings, token: string): Promise<ResetTokenPayload | null> {
if (!token || !/^[0-9a-f]{16,128}$/i.test(token)) return null;
const raw = await env.SESSIONS_KV.get(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
if (!raw) return null;
try {
return JSON.parse(raw) as ResetTokenPayload;
} catch {
return null;
}
}
/**
* 用掉一張票(安全要求①「一次有效」):**先刪再回傳**。
* 順序是刻意的——先刪掉才回,兩個人同時點同一條連結時最多只有一個拿得到。
*/
async function consumeResetToken(env: Bindings, token: string): Promise<ResetTokenPayload | null> {
const payload = await peekResetToken(env, token);
if (!payload) return null;
await env.SESSIONS_KV.delete(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
return payload;
}
/**
* **唯一的密碼寫入路徑**(D62「同一條寫入路徑」的落地點)。
* 修改密碼與忘記密碼都只能從這裡覆蓋密碼——不再有第二支自己 hash 自己 patch 的路。
*/
async function writeNewPassword(env: Bindings, recordId: string, newPassword: string): Promise<void> {
const newHash = await hashPassword(newPassword);
await patchRecordValues(env, recordId, {
password_hash: newHash,
updated_at: new Date().toISOString(),
});
}
/**
* 請中央 landing 代寄「修改密碼」連結。
*
* 🔴 **為什麼要代寄**leo 2026-08-10 給的職責切法):
* 「其實這應該是 youlin 的實例告訴 arcrun.dev 說『我實例的重設密碼網址是 abc.recover
* 你幫我寄信給用戶讓他來修改密碼』,由 arcrun.dev 幫它寄出這封信。」
* ⇒ **實例**產生連結、管一次有效/過期/作廢;**arcrun.dev 只是郵差**,不碰任何認證邏輯。
* 必須這樣切的硬理由:用戶自己的實例**根本沒有寄信能力**——安裝器部署 cypher 的 binding
* 只有 ai/d1/kv_namespace/plain_text/secret_text/service/vectorize**沒有 send_email**。
* ⚠️ 「由我們中央代寄」是依 leo「寄給你」推導的**假設**,他尚未正式表態(D62 明列為未裁前置)。
*
* 🔴 **絕不把整條 URL 交給郵差**(總管 2026-08-10 紅線):寄件網域 `arcrun.dev` 掛在 uncle6、
* 帶 DKIM。郵差若肯收「任意 URL + 任意 email」就寄,任何人裝一台實例就能用 `arcrun.dev`
* 的名義、**通過 DKIM 驗證**把任意連結寄給任意人 ⇒ 一台開放的釣魚中繼,
* 燒的是整個網域的信譽、波及所有用戶、**不可逆**。
*
* 做法:我們只交出**這台實例自己的 origin + 一張回呼票**。連結由郵差自己組,而且郵差會
* **回頭打這個 origin** 問「這張票是你發的嗎」(見 /portal/password/relay-verify)。
* 冒用別人的網域會被那台實例自己否認 ⇒ **主機屬於呼叫方這件事由郵差親自確認,
* 不是相信呼叫方的宣稱**。
*
* `apiOrigin` 取自**這次請求真正抵達的位址**`new URL(c.req.url).origin`),不新增任何
* 需要被注入的變數——2026-08-08 兩次事故的教訓:能推導的就不要再多一個會被漏掉的設定。
*
* **不假綠**(mindset §7):寄不出去就回 'failed' / 'not_configured'。
*/
async function relayResetLink(
env: Bindings,
apiOrigin: string,
email: string,
ticket: string,
): Promise<'sent' | 'not_configured' | 'failed'> {
const base = String(env.PORTAL_MAIL_RELAY_BASE ?? '').trim().replace(/\/$/, '');
if (!base) return 'not_configured';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.PORTAL_MAIL_RELAY_KEY) headers['X-Arcrun-Relay-Key'] = env.PORTAL_MAIL_RELAY_KEY;
try {
const res = await fetch(`${base}/api/send-password-reset`, {
method: 'POST',
headers,
body: JSON.stringify({ email, api_origin: apiOrigin, ticket }),
});
return c.json({ success: true });
return res.ok ? 'sent' : 'failed';
} catch {
return 'failed';
}
}
/**
* POST /portal/password/relay-verify — **郵差回頭確認用**body `{ticket}`)。
*
* 這支存在的唯一理由是上面那條紅線:郵差不可以相信「呼叫方說這台是我的」。
* 它會回頭打**連結裡的那個主機**問這張票在不在——
* - 真的是這台發的 → 這裡答得出來 → 寄
* - 有人冒用別人的網域 → 被冒用的那台**根本沒有這張票** → 答不出來 → 郵差拒寄
* 回應**不含 email 明碼、不含 reset token**,只回收件人 email 的 sha256 讓郵差核對它手上那個,
* 以及要放進信裡的完整連結(由本台自己組,主機必然是自己)。
* 票 TTL 兩分鐘、看過即焚——它不是憑據,只是一次握手。
*/
portalRouter.post('/portal/password/relay-verify', (c) =>
run(c, async () => {
const body = await c.req.json().catch(() => null);
const ticket = String(body?.ticket ?? '').trim();
if (!ticket || !/^[0-9a-f]{8,64}$/i.test(ticket)) return c.json({ ok: false }, 400);
const key = `${RELAY_TICKET_PREFIX}${ticket}`;
const raw = await c.env.SESSIONS_KV.get(key);
if (!raw) return c.json({ ok: false }, 404);
await c.env.SESSIONS_KV.delete(key); // 一次握手,看過即焚
let parsed: { email: string; link: string };
try {
parsed = JSON.parse(raw) as { email: string; link: string };
} catch {
return c.json({ ok: false }, 404);
}
return c.json({ ok: true, email_sha256: await sha256Hex(parsed.email), link: parsed.link });
}),
);
// GET /portal/password/reset-link?token=… — 信裡連結的落點。
// **連結的主機刻意是 cypher 自己**(=郵差回呼確認的那個主機,兩者必須是同一個,
// 否則「郵差確認過的主機」與「信裡的主機」就不是同一件事,紅線等於沒守)。
// 這裡只做一件事:把人帶去 portal 前端的修改密碼畫面。
portalRouter.get('/portal/password/reset-link', (c) => {
const token = c.req.query('token') ?? '';
const ui = portalUiOrigin(c.env);
if (!ui) return c.text('這台實例沒有設定 portal 前端網址,無法導向修改密碼畫面。', 500);
return c.redirect(`${ui}/portal/#/reset?token=${encodeURIComponent(token)}`, 302);
});
// POST /portal/password/forgot — body {email}。**公開端點**(忘記密碼的人當然沒登入)。
//
// 不洩漏帳號存在性:帳號在不在,回的都是同一句話、同一個 200。
// 唯一會回錯的是「這台實例根本沒設代寄服務」——那與「有沒有這個帳號」無關,講出來不洩漏任何事,
// 而不講就會讓人對著一封永遠不會到的信等下去(#49「把故障講成用戶的問題」的反面)。
portalRouter.post('/portal/password/forgot', (c) =>
run(c, async () => {
const body = await c.req.json().catch(() => null);
const email = String(body?.email ?? '').trim().toLowerCase();
if (!email || !isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
if (!String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()) {
return c.json(
{
error:
'這台實例還沒有設定寄信服務,「忘記密碼」的連結寄不出去。' +
'請重新執行安裝/更新讓它就緒,或請管理員直接幫你改密碼。',
code: 'mail_relay_not_configured',
},
503,
);
}
const generic = {
success: true,
message: '如果這個 email 在這台實例上有帳號,我們已經把「修改密碼」的連結寄過去了(連結 30 分鐘內有效、只能用一次)。',
};
// 節流:同一個 email 兩分鐘內只寄一次(擋灌信,也擋拿這支當帳號存在性探針的節奏)
const throttleKey = `${PWRESET_THROTTLE_PREFIX}${email}`;
if (await c.env.SESSIONS_KV.get(throttleKey)) return c.json(generic);
await c.env.SESSIONS_KV.put(throttleKey, '1', { expirationTtl: PWRESET_THROTTLE_SECONDS });
const recordId = await findUserRecordId(c.env, email).catch(() => null);
if (!recordId) return c.json(generic); // 沒有這個帳號 → 一樣的回應,什麼都不寄
const token = await issueResetToken(c.env, recordId, email);
// 連結的主機=這次請求真正抵達的位址(郵差待會兒就是回頭打這裡確認的)
const apiOrigin = new URL(c.req.url).origin;
const link = `${apiOrigin}/portal/password/reset-link?token=${encodeURIComponent(token)}`;
const ticket = randomHex(16);
await c.env.SESSIONS_KV.put(`${RELAY_TICKET_PREFIX}${ticket}`, JSON.stringify({ email, link }), {
expirationTtl: RELAY_TICKET_TTL_SECONDS,
});
await relayResetLink(c.env, apiOrigin, email, ticket); // 寄不出去也回同一句(存在性不可由回應推得)
return c.json(generic);
}),
);
// GET /portal/password/reset?token=… — 點進連結時先問「這張票還有效嗎」(**不消耗**)。
// 回 email 讓畫面顯示「你正在為 xxx@yyy 設定新密碼」——票本身就證明持有人控制那個信箱。
portalRouter.get('/portal/password/reset', (c) =>
run(c, async () => {
const payload = await peekResetToken(c.env, c.req.query('token') ?? '');
if (!payload) {
return c.json(
{ valid: false, error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。' },
400,
);
}
return c.json({ valid: true, email: payload.email });
}),
);
/**
* POST /portal/password/change — **D62 的那一支**:修改密碼與忘記密碼共用。
*
* body`{ new, current? , reset_token? }`
* - 帶 `reset_token`(從信裡的連結來)→ **忽略 current**,票就是憑據
* - 沒帶 → 必須是登入狀態 + 提供正確的 `current`
* 兩條路在這一行之後**完全相同**writeNewPassword)——這就是「差別只有一格」的實體。
*/
async function handlePasswordChange(c: Context<{ Bindings: Bindings }>): Promise<Response> {
const body = await c.req.json().catch(() => null);
const next = String(body?.new ?? '');
const resetToken = String(body?.reset_token ?? '').trim();
if (!next) return c.json({ error: 'new(新密碼)必填' }, 400);
if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
// ── 忘記密碼那一格:憑連結,不問現有密碼 ──
if (resetToken) {
const payload = await consumeResetToken(c.env, resetToken);
if (!payload) {
return c.json(
{ error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。', code: 'reset_token_invalid' },
400,
);
}
await writeNewPassword(c.env, payload.record_id, next);
return c.json({ success: true, email: payload.email, via: 'reset_link' });
}
// ── 修改密碼那一格:要登入、要現有密碼 ──
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const current = String(body?.current ?? '');
if (!current) return c.json({ error: 'current(現有密碼)必填' }, 400);
const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
if (!ok) return c.json({ error: '舊密碼不正確' }, 401);
await writeNewPassword(c.env, auth.user.recordId, next);
return c.json({ success: true, via: 'current_password' });
}
portalRouter.post('/portal/password/change', (c) => run(c, () => handlePasswordChange(c)));
// POST /portal/me/password — 舊名,**轉呼同一支**(現有前端與 CLI 還在用這個路徑)。
// 保留別名而不是留第二份實作:兩份必然漂移(D39/arcrun-rag#40「同一個事實兩份」)。
portalRouter.post('/portal/me/password', (c) => run(c, () => handlePasswordChange(c)));
// ═══════════════════════════════ admin 端點 ══════════════════════════════════
// POST /portal/admin/bootstrap — 需 **console owner session**design D-7owner secret 是
+13
View File
@@ -115,6 +115,19 @@ export type Bindings = {
// expirationTtl。未設 → 6048007 天,design §4.3——issue 要求短效,比 console 30 天緊)。
// 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。
PORTAL_SESSION_TTL?: string;
// Portal / console 前端站的 origin 白名單(逗號分隔,非機密)。index.ts 的 CORS 讀它;
// D62 的「修改密碼」連結也用它當「使用者會看到的那個網址」(未設 → 用 workers.dev 兄弟位址推導)。
UI_ORIGINS?: string;
// ── D62「忘記密碼」=寄一條「修改密碼」連結(非機密)───────────────────────────
// 中央代寄服務的 base URLlanding worker)。**用戶自己的實例沒有寄信能力**——安裝器
// 部署 cypher 的 binding 只有 ai/d1/kv/plain_text/secret_text/service/vectorize
// **沒有 send_email**;能寄信的是我們 landing 的 CF Email Service(寄件網域 arcrun.dev)。
// 未設 → /portal/password/forgot 誠實回 503 `mail_relay_not_configured`,不假裝寄出去了。
// ⚠️ 「由中央代寄」是依 leo「寄給你」推導的**假設**,尚待他正式表態(D62 未裁前置)。
PORTAL_MAIL_RELAY_BASE?: string;
// 代寄服務的共享秘密(可選)。設了就在代寄請求帶 X-Arcrun-Relay-Key,讓 landing 端
// 分辨「這是我們自己的實例」。未設=不帶(landing 端仍有速率限制與固定樣板)。
PORTAL_MAIL_RELAY_KEY?: string;
// Portal 工作流頁可見性(portal-auth P3design D-8 定案,非機密):admin(預設)/ all / off。
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
+100
View File
@@ -582,3 +582,103 @@ describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
});
});
// ═══════════════ D62 arcrun-rag#662026-08-10)═══════════════
//
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
const D62_EMAIL = 'd62-reset@example.com';
it('/portal/password/change 帶 reset_token**不需要登入、不需要現有密碼**,且票用完即失效', async () => {
// 直接把一張票種進 KV(等同 /portal/password/forgot 發出來的那張),
// 存的是 token 的 sha256——KV 裡看不到可用的連結。
const { sha256Hex } = await import('../src/lib/portal-auth');
const token = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90';
const recordId = `${AUTH_ID_PREFIX}d62test000000000000000`;
await env.SESSIONS_KV.put(
`portal_pwreset:${await sha256Hex(token)}`,
JSON.stringify({ record_id: recordId, email: D62_EMAIL, created_at: new Date().toISOString() }),
);
// 票有效時,先「看一眼」不會消耗它
const peek = await json('GET', `/portal/password/reset?token=${token}`);
expect(peek.status).toBe(200);
expect((await peek.json() as { valid: boolean; email: string }).email).toBe(D62_EMAIL);
// 認證儲存裡沒有這個 record_id → 覆蓋密碼會失敗,但**票必須已經被消耗**(先刪再回)
const used = await json('POST', '/portal/password/change', { reset_token: token, new: 'brand-new-pw-1' });
expect(used.status).not.toBe(200); // 這個 record 不存在,寫入失敗是預期的
// 關鍵斷言:同一條連結**不能再用第二次**
const again = await json('POST', '/portal/password/change', { reset_token: token, new: 'second-try-pw-1' });
expect(again.status).toBe(400);
expect((await again.json() as { code: string }).code).toBe('reset_token_invalid');
// 而且票在 KV 裡真的沒了
expect(await env.SESSIONS_KV.get(`portal_pwreset:${await sha256Hex(token)}`)).toBeNull();
});
it('亂猜的 token / 格式不對的 token → 400,不洩漏任何東西', async () => {
for (const t of ['deadbeef'.repeat(8), 'not-hex-at-all', '']) {
const res = await json('GET', `/portal/password/reset?token=${t}`);
expect(res.status).toBe(400);
expect((await res.json() as { valid: boolean }).valid).toBe(false);
}
});
it('沒帶 reset_token 又沒登入 → 401(修改密碼那一格仍然要身分)', async () => {
const res = await json('POST', '/portal/password/change', { current: 'x', new: 'brand-new-pw-1' });
expect(res.status).toBe(401);
});
it('新密碼太短 → 400(兩條路共用同一組驗證)', async () => {
const res = await json('POST', '/portal/password/change', { reset_token: 'a'.repeat(64), new: 'short' });
expect(res.status).toBe(400);
});
it('/portal/password/forgot:沒設代寄服務 → 誠實回 503,不假裝信寄出去了', async () => {
const res = await json('POST', '/portal/password/forgot', { email: D62_EMAIL });
expect(res.status).toBe(503);
expect((await res.json() as { code: string }).code).toBe('mail_relay_not_configured');
});
});
describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
const TOKEN_A = 'sess-token-66-propagating';
const TOKEN_B = 'sess-token-66-really-gone';
const MISSING = `${AUTH_ID_PREFIX}notinstore0000000000000`;
it('正在傳播(加速器 key 還在)+讀不到 record → 503 auth_store_propagating,且 **session 沒被刪**', async () => {
await seedPortalSession(TOKEN_A, MISSING);
// 加速器 key 存在=「剛剛有人動過認證儲存」=現在是傳播空窗
await env.SESSIONS_KV.put(
'auth_store_recent',
JSON.stringify({ written_at: Date.now() + 10_000_000, data: { version: 1, console: null, users: [] } }),
);
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_A}` });
expect(res.status).toBe(503);
expect((await res.json() as { code: string }).code).toBe('auth_store_propagating');
// 🔴 這是整張票的重點:舊碼會在這裡把 KV 那筆刪掉,等 secret 鋪開也回不來
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_A}`)).not.toBeNull();
});
it('不在傳播空窗(加速器 key 不存在)+讀不到 record → 401 擋下,但**仍然不刪 session**', async () => {
await seedPortalSession(TOKEN_B, MISSING);
await env.SESSIONS_KV.delete('auth_store_recent');
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_B}` });
expect(res.status).toBe(401);
// 刪 session 是 best-effort 清潔工,而它清掉的是使用者唯一的憑據;KV 的 TTL 本來就會回收
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_B}`)).not.toBeNull();
});
it('session 內容本身壞掉(不是讀不到)→ 401 且**該刪**(確定的事實,不是暫時性)', async () => {
await env.SESSIONS_KV.put('portal_sess:broken-66', 'not-json-at-all');
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer broken-66' });
expect(res.status).toBe(401);
expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
});
});
+19 -4
View File
@@ -380,11 +380,26 @@ entryRoutes.patch('/:id', async (c) => {
});
// DELETE /entries/:id
//
// 🔴 2026-08-10arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來):
// 舊版把向量刪除包成 fire-and-forget`waitUntil(...).catch(()=>{})`)——失敗被靜默吞掉,
// 呼叫端(rag_takedown_direct workflow/未來的 portal 刪除 UI)永遠不知道向量沒清乾淨,
// 使用者看到「刪除成功」,但語意搜尋可能還留著殘影,直到下次搜尋命中它才被自癒清掉
// search 路徑的 orphan 清理是事後補救,不是保證)。
// 改法:與同檔 `/entries/deprecate-by-library`(見上)同款——**同步 await 再回應**,
// 誠實回報 `vector_deleted`true=清了/false=清失敗,D1 仍照刪/null=模組未開,不適用)。
// D1 刪除永遠執行到底(entry 本體一定會消失),差別只在向量那一步呼叫端看不看得見失敗。
entryRoutes.delete('/:id', async (c) => {
// 模組開 → 連帶刪向量(避免孤兒向量)。失敗不致命。
const id = c.req.param('id');
let vector_deleted: boolean | null = null; // 模組未開=不適用,維持 null 誠實表達「這件事沒發生過」
if (embedEnabled(c.env)) {
c.executionCtx.waitUntil(c.env.VECTORIZE!.deleteByIds([c.req.param('id')]).then(() => {}).catch(() => {}));
try {
await c.env.VECTORIZE!.deleteByIds([id]);
vector_deleted = true;
} catch {
vector_deleted = false; // 誠實回 false,不假裝清乾淨了;D1 本體仍照刪,不因向量失敗而擋下
}
}
await deleteEntry(c.env.DB, c.req.param('id'));
return c.json({ success: true });
await deleteEntry(c.env.DB, id);
return c.json({ success: true, vector_deleted });
});
+84
View File
@@ -0,0 +1,84 @@
// arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來。
//
// DELETE /entries/:id 舊版把向量刪除包成 fire-and-forgetwaitUntil(...).catch(()=>{}))——
// 呼叫端完全看不到向量清除是否成功。本測試鎖住新行為:同步 await+回應帶 vector_deleted
// 且無論向量刪除成功或失敗,D1 本體都要真的被刪掉(不因向量失敗而擋下本體刪除)。
//
// 測試手法同 search-deprecated-filter.test.tsfake D1 捕捉 SQL 形狀;mock VECTORIZE 可控
// deleteByIds 成功/失敗,驗證 route 層如何把結果誠實透傳給呼叫端。
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import type { Bindings } from '../src/types';
interface Captured { sql: string; params: unknown[] }
function makeCaptureDB(captured: Captured[]) {
const prepare = (sql: string) => {
const rec: Captured = { sql, params: [] };
captured.push(rec);
const stmt = {
bind(...args: unknown[]) { rec.params = args; return stmt; },
async all<T>() { return { results: [] as T[] }; },
async first<T>() { return null as unknown as T; },
async run() { return { success: true }; },
};
return stmt;
};
return { prepare } as unknown as D1Database;
}
function makeApp(captured: Captured[], extraEnv: Record<string, unknown> = {}) {
const app = new Hono<{ Bindings: Bindings }>();
app.route('/entries', entryRoutes);
const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test', ...extraEnv } as unknown as Bindings;
return { app, env };
}
describe('arcrun-rag#46 — DELETE /entries/:id 失敗可見性', () => {
it('embed 模組未開 → vector_deleted:null(不適用,非「清成功了」的謊)+ D1 仍照刪', async () => {
const captured: Captured[] = [];
const { app, env } = makeApp(captured); // 無 VECTORIZE/AI
const res = await app.request('/entries/e123', { method: 'DELETE' }, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null };
expect(body.success).toBe(true);
expect(body.vector_deleted).toBe(null);
expect(captured.some((c) => c.sql.includes('DELETE FROM entries WHERE id = ?'))).toBe(true);
});
it('模組開+向量刪除成功 → vector_deleted:true(同步等到結果才回應,不是猜的)', async () => {
const captured: Captured[] = [];
const deletedIds: string[][] = [];
const { app, env } = makeApp(captured, {
VECTORIZE: {
async deleteByIds(ids: string[]) { deletedIds.push(ids); return { count: ids.length }; },
},
AI: { async run() { return { data: [[0.1]] }; } },
});
const res = await app.request('/entries/e123', { method: 'DELETE' }, env);
const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null };
expect(body.success).toBe(true);
expect(body.vector_deleted).toBe(true);
expect(deletedIds).toEqual([['e123']]); // 真的呼叫了、帶對 id,不是没做就回真
});
it('🔴 模組開+向量刪除失敗 → vector_deleted:false 誠實回報,且 D1 本體仍真的刪掉', async () => {
const captured: Captured[] = [];
const { app, env } = makeApp(captured, {
VECTORIZE: {
async deleteByIds() { throw new Error('Vectorize 503(模擬故障)'); },
},
AI: { async run() { return { data: [[0.1]] }; } },
});
const res = await app.request('/entries/e123', { method: 'DELETE' }, env);
// 舊版這裡的失敗會被 waitUntil(...).catch(()=>{}) 吞掉、caller 永遠看不到;
// 新版:HTTP 仍是 200(D1 本體真的刪了,這件事沒有失敗),但誠實標出向量那一步失敗了。
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; vector_deleted: boolean | null };
expect(body.success).toBe(true);
expect(body.vector_deleted).toBe(false);
// D1 本體不因向量失敗而被擋下——刪除的「本體一定會消失」承諾不打折扣。
expect(captured.some((c) => c.sql.includes('DELETE FROM entries WHERE id = ?'))).toBe(true);
});
});
+18
View File
@@ -14,6 +14,24 @@ const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partn
// 安全模型見 mcp/OAUTH.md。註冊在 basePath 之前,落在同一份共享 router。
registerOAuthRoutes(_app);
// ── GET /health — 讓「這台跑的是哪一版 MCP」用一條 curl 看得到 ─────────────────────
// 為什麼要有:cypher-executor 早就有 /healthbundle_version + auth_store 探針),
// arcrun-mcp 沒有 ⇒ 要判斷某台實例的 MCP 是哪一代認證,只能打 /authorize 剖 HTML 數欄位
// ops-facts 2026-08-10 的土法)。那個判準脆弱又難教。
//
// ⚠️ 誠實界定(別把它當世代判準用過頭):本端點是**這個 commit 之後才有的**,所以
// 「/health 回 404」只代表「比本版舊」,**不代表就是 owner_secret 世代**——
// 現階段判世代仍要看 /authorize 的欄位(一個 owner_secret =舊;email+password =新)。
// 等這版推到各實例之後,`auth` 欄位才會變成一眼可讀的世代判準。
// 不需認證、不吐任何機密;MCP_BUILD 是部署標記,由各實例 toml [vars] 帶入。
_app.get("/health", (c) => c.json({
ok: true,
service: "arcrun-mcp",
auth: "portal-login",
build: c.env.MCP_BUILD ?? "unknown",
oauth_kv: c.env.OAUTH_KV ? "present" : "missing",
}));
const app = _app.basePath('/mcp');
app.use("*", cors({
+3
View File
@@ -38,6 +38,9 @@ export interface Env {
// 允許的 redirect_uri host 白名單(逗號分隔)。DCR 無狀態故靠此擋 open-redirect/釣魚。
// 未設 → 預設只允許 claude.ai / claude.com / anthropic.com(含子網域)+ localhost。
MCP_ALLOWED_REDIRECT_HOSTS?: string;
// 部署標記(非機密):GET /health 原樣回報,讓「這台跑的是哪一版」用一條 curl 看得到。
// 由各實例的 wrangler toml [vars] 帶入;沒帶 → /health 回 build:"unknown"(誠實,不假裝)。
MCP_BUILD?: string;
}
export interface ToolContext {
+56
View File
@@ -0,0 +1,56 @@
# ── arcrun-mcp STAGEyoulin 帳號)────────────────────────────────────────────────
#
# 這支存在的理由(2026-08-10Leo/mira#4):
# `arcrun-mcp` 不在安裝器出貨的那批裡,所以「升級某台的 arcrun-mcp」一直沒有測試場,
# 要驗只能拿 leo21c(唯一一份 47.9 萬筆知識的真身)去冒險。這支把 stage 補上:
# youlin stageagent-memory「兩台分工」:AI 測 youlin、leo 核實 prod)。
#
# 用法(在 mcp/ 目錄下跑,-c 必須與 main="src/index.ts" 同目錄,否則找不到入口):
# cd mcp
# CLOUDFLARE_ACCOUNT_ID=1129efd7df2e8899d537e9c8fbabb6cb \
# CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN_YOULIN_CC_USE" \
# npx wrangler deploy -c wrangler.stage.toml
#
# 與出貨用的 wrangler.toml 的差別,就是「手動直推 arcrun-mcp 到自架帳號」的兩個坑
# agent-memory 已記,這裡是把它變成檔案而不是靠人記得):
# ① 拿掉 [[routes]] —— `mcp.arcrun.dev` 那個 zone 在 uncle6,自架帳號沒有,帶著會部署失敗
# ② OAUTH_KV 填真 id —— 出貨 toml 是佔位符 REPLACE_WITH_REAL_KV_ID,只有走 acr init/update
# 才會被自動注入;直推必須自己填(沒填 → /authorize 回 503OAuth 整條死)
#
# 🔴 [vars] 必須列**全**wrangler deploy 會用本檔的 vars 整組取代線上的,漏一個就是靜默清掉。
# 下方七個 var 是 2026-08-10 從線上 arcrun-mcp 抓下來的原樣值。
# SecretsKBDB_INTERNAL_TOKEN / MCP_OWNER_SECRET)不進本檔也不會被 deploy 洗掉。
name = "arcrun-mcp"
main = "src/index.ts"
compatibility_date = "2024-11-27"
compatibility_flags = [ "nodejs_compat" ]
workers_dev = true
[vars]
MULTI_TENANT = "false"
CF_ACCOUNT_ID = "1129efd7df2e8899d537e9c8fbabb6cb"
WORKER_SUBDOMAIN = "youlin-hsieh-dev"
CONSOLE_TENANT = "yuga3bse"
MCP_OWNER_NAMESPACE = "yuga3bse"
KBDB_BASE_URL = "https://arcrun-kbdb.youlin-hsieh-dev.workers.dev"
UI_ORIGINS = "https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev"
# 部署標記,GET /health 原樣回報。每次重推 stage 就換一個,別讓它跟線上脫節。
MCP_BUILD = "stage-2026-08-10+45f1c09"
[[services]]
binding = "COMPONENT_REGISTRY"
service = "arcrun-registry"
[[services]]
binding = "CYPHER_EXECUTOR"
service = "arcrun-cypher-executor"
[[services]]
binding = "KBDB"
service = "arcrun-kbdb"
# youlin 的 OAuth KV(安裝器建的 arcrun-rag-yuga3bse-kv-oauth_kv)。
[[kv_namespaces]]
binding = "OAUTH_KV"
id = "6a8d4dd621994607b3998ade4c7e9944"