Compare commits

...

6 Commits

Author SHA1 Message Date
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
6 changed files with 186 additions and 6 deletions
+20 -1
View File
@@ -821,7 +821,20 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); 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 +1198,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;
+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;
+38 -2
View File
@@ -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) };
}
+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"