Compare commits

...

8 Commits

Author SHA1 Message Date
Leo eebb691426 merge: 傳播空窗期不再銷毀 session(arcrun-rag#66)
leo 2026-08-10 本人被這個鎖在 stage 外面,並靠修好後的重設流程走回來——
這是「收的人自己驗到」,不是我們自己測過。

病:認證的家是 Workers Secret,改它會產生 worker 新版本,既有 isolate 讀到舊 env。
舊碼在那個空窗裡讀不到 record 就直接 SESSIONS_KV.delete()
⇒ 帶著完全有效的 token,登入狀態被當場銷毀,secret 鋪開也回不來。
#55 補的加速器重試只加在登入路徑,session 驗證這道門沒有。

實測空窗約 45 秒(不是 #55 記的 ≥15 秒):
改完密碼後舊密碼一路到 t+44.1s 仍登得進去,t+47.1s 才開始 401。

三段修法:
1. 先問加速器再判定(與登入路徑同一支 hydrateFromAccelerator)
2. 永不因「讀不到」刪 session——刪是 best-effort 清潔工,清掉的卻是使用者唯一的憑據
3. 仍讀不到且在空窗 → 503 auth_store_propagating,不是 401
   (前端看到 401 就清 localStorage,後端不刪也沒用)
前端 boot() 收斂成「只有 401 才算被登出」。

驗證:stage 改密碼後同一 token 打 90 秒 → 200x30 / 401x0;瀏覽器實跑仍在站內;
tests/portal-auth.test.ts 27/27 綠。
2026-08-10 14:19:04 +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
8 changed files with 289 additions and 10 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() }) 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 (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) { .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; S.profile = x.d;
showApp(); showApp();
}) })
@@ -1185,6 +1198,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
} }
// 任何 data 請求收到 401 → session 失效 → 回登入殼 // 任何 data 請求收到 401 → session 失效 → 回登入殼
//
// 🔴 arcrun-rag#66 的前端那一半:後端在「認證資料正在鋪開」時改回 503
// `auth_store_propagating`(不再回 401、也不再刪 KV 那筆 session)。
// 這裡**必須跟著只認 401**——若前端把任何錯誤都當登出,後端不刪也沒用:
// 使用者手上的 token 會被自己的瀏覽器丟掉,症狀跟被踢出去一模一樣。
// (503 由各呼叫點自己顯示錯誤訊息,session 原封不動。)
function guard401(status) { function guard401(status) {
if (status === 401) { dropSession(); return true; } if (status === 401) { dropSession(); return true; }
return false; return false;
+51 -3
View File
@@ -143,7 +143,18 @@ export function authStoreWritable(env: Bindings): boolean {
*/ */
export function readAuthStore(env: Bindings): AuthStoreData { export function readAuthStore(env: Bindings): AuthStoreData {
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay; 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 bag = env as unknown as Record<string, unknown>;
const out = emptyStore(); const out = emptyStore();
for (const name of shardNames(env)) { 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( export async function mutateAuthStore(
env: Bindings, env: Bindings,
fn: (data: AuthStoreData) => void | Promise<void>, fn: (data: AuthStoreData) => void | Promise<void>,
): Promise<AuthStoreData> { ): Promise<AuthStoreData> {
const data = readAuthStore(env); await hydrateFromAccelerator(env);
const next: AuthStoreData = { version: 1, console: data.console, users: [...data.users] }; const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
await fn(next); await fn(next);
await writeAuthStore(env, next); await writeAuthStore(env, next);
return next; return next;
+38 -2
View File
@@ -33,6 +33,7 @@ import { storeCredential, hasCredential } from './credentials';
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。 // KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
import { import {
AuthStoreWriteError, AuthStoreWriteError,
authStoreRecentlyWritten,
authStoreStatus, authStoreStatus,
findAuthUserByEmail, findAuthUserByEmail,
findAuthUserById, 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 * portal session token KV record_id ** record** status=active
* design §4.3/ session KVbest-effort * 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`**
* 401portal `guard401()` 401 localStorage
* KV 使 token
* ****
*/ */
export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> { export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
const token = bearerToken(c); const token = bearerToken(c);
@@ -426,15 +444,33 @@ export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Pro
/* fallthrough */ /* fallthrough */
} }
if (!recordId) { if (!recordId) {
// 這一筆 session 的內容本身壞掉=確定的事實(不是讀不到),刪它是對的。
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) }; 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) { 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) }; return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
} }
if ((rec.values.status ?? '') !== 'active') { if ((rec.values.status ?? '') !== 'active') {
// 讀得到 record = 確定的事實,停用要即時生效,刪 session 是對的。
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
return { ok: false, res: c.json({ error: '帳號已停用' }, 403) }; return { ok: false, res: c.json({ error: '帳號已停用' }, 403) };
} }
+19 -4
View File
@@ -380,11 +380,26 @@ entryRoutes.patch('/:id', async (c) => {
}); });
// DELETE /entries/:id // 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) => { entryRoutes.delete('/:id', async (c) => {
// 模組開 → 連帶刪向量(避免孤兒向量)。失敗不致命。 const id = c.req.param('id');
let vector_deleted: boolean | null = null; // 模組未開=不適用,維持 null 誠實表達「這件事沒發生過」
if (embedEnabled(c.env)) { 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')); await deleteEntry(c.env.DB, id);
return c.json({ success: true }); 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。 // 安全模型見 mcp/OAUTH.md。註冊在 basePath 之前,落在同一份共享 router。
registerOAuthRoutes(_app); 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'); const app = _app.basePath('/mcp');
app.use("*", cors({ app.use("*", cors({
+3
View File
@@ -38,6 +38,9 @@ export interface Env {
// 允許的 redirect_uri host 白名單(逗號分隔)。DCR 無狀態故靠此擋 open-redirect/釣魚。 // 允許的 redirect_uri host 白名單(逗號分隔)。DCR 無狀態故靠此擋 open-redirect/釣魚。
// 未設 → 預設只允許 claude.ai / claude.com / anthropic.com(含子網域)+ localhost。 // 未設 → 預設只允許 claude.ai / claude.com / anthropic.com(含子網域)+ localhost。
MCP_ALLOWED_REDIRECT_HOSTS?: string; MCP_ALLOWED_REDIRECT_HOSTS?: string;
// 部署標記(非機密):GET /health 原樣回報,讓「這台跑的是哪一版」用一條 curl 看得到。
// 由各實例的 wrangler toml [vars] 帶入;沒帶 → /health 回 build:"unknown"(誠實,不假裝)。
MCP_BUILD?: string;
} }
export interface ToolContext { 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"