Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 417d69ceb3 | |||
| 035e8b255b | |||
| 1c630ecfd4 | |||
| a99d3e5a3e | |||
| 894408181f | |||
| d022ca067b | |||
| 6715402bcc |
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 順手刪 KV(best-effort,正確性不依賴它)。
|
* 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KV(best-effort,正確性不依賴它)。
|
||||||
|
*
|
||||||
|
* 🔴 `Leo/arcrun-rag#66`(2026-08-10,leo 本人被鎖在 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> {
|
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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,11 +380,26 @@ entryRoutes.patch('/:id', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /entries/:id
|
// DELETE /entries/:id
|
||||||
|
//
|
||||||
|
// 🔴 2026-08-10(arcrun-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'));
|
}
|
||||||
return c.json({ success: true });
|
await deleteEntry(c.env.DB, id);
|
||||||
|
return c.json({ success: true, vector_deleted });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// arcrun-rag#46「刪掉的知識搜尋還撈得到」第 4 點:中途失敗要看得出來。
|
||||||
|
//
|
||||||
|
// DELETE /entries/:id 舊版把向量刪除包成 fire-and-forget(waitUntil(...).catch(()=>{}))——
|
||||||
|
// 呼叫端完全看不到向量清除是否成功。本測試鎖住新行為:同步 await+回應帶 vector_deleted,
|
||||||
|
// 且無論向量刪除成功或失敗,D1 本體都要真的被刪掉(不因向量失敗而擋下本體刪除)。
|
||||||
|
//
|
||||||
|
// 測試手法同 search-deprecated-filter.test.ts:fake 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 早就有 /health(bundle_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({
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# ── arcrun-mcp STAGE(youlin 帳號)────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# 這支存在的理由(2026-08-10,Leo/mira#4):
|
||||||
|
# `arcrun-mcp` 不在安裝器出貨的那批裡,所以「升級某台的 arcrun-mcp」一直沒有測試場,
|
||||||
|
# 要驗只能拿 leo21c(唯一一份 47.9 萬筆知識的真身)去冒險。這支把 stage 補上:
|
||||||
|
# youlin = stage(agent-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 回 503,OAuth 整條死)
|
||||||
|
#
|
||||||
|
# 🔴 [vars] 必須列**全**:wrangler deploy 會用本檔的 vars 整組取代線上的,漏一個就是靜默清掉。
|
||||||
|
# 下方七個 var 是 2026-08-10 從線上 arcrun-mcp 抓下來的原樣值。
|
||||||
|
# Secrets(KBDB_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"
|
||||||
Reference in New Issue
Block a user