diff --git a/console-ui/public/console/dashboard/index.html b/console-ui/public/console/dashboard/index.html
index 9812592..9c82770 100644
--- a/console-ui/public/console/dashboard/index.html
+++ b/console-ui/public/console/dashboard/index.html
@@ -92,7 +92,12 @@
.theme-btn { flex: none; margin-left: 12px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.65); font-size: 16px; cursor: pointer; line-height: 1; align-self: center; }
-
+
diff --git a/console-ui/public/console/index.html b/console-ui/public/console/index.html
index 4b97e4a..9ddb2f3 100644
--- a/console-ui/public/console/index.html
+++ b/console-ui/public/console/index.html
@@ -220,7 +220,12 @@
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
-
+
diff --git a/cypher-executor/tests/portal-admin-ai.test.ts b/cypher-executor/tests/portal-admin-ai.test.ts
new file mode 100644
index 0000000..7759d23
--- /dev/null
+++ b/cypher-executor/tests/portal-admin-ai.test.ts
@@ -0,0 +1,141 @@
+/**
+ * GET|POST /portal/admin/ai —— arcrun-rag#10 迴歸守衛
+ *
+ * 🔴 為什麼有這支測試(別刪):
+ * 這條 route **以前根本不存在**,但前端設定頁一直在打它 ⇒ 用戶填 Gemini key → 404
+ * ⇒ **key 從來沒被存進任何地方**,畫面卻像存好了(藍字=假綠)。
+ * leo 實撞成「重裝後 key 不見」,真相是「從來沒存進去,所以重填也沒用」。
+ * 產物層鐵證(修復前):bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=**0**。
+ * ⇒ 這支測試的存在本身就是防線:**route 消失=測試紅**。
+ *
+ * 覆蓋:
+ * 1. 未登入 → 401;非 admin → 403(不是 404=route 真的在)
+ * 2. GET 回 has_key 布林,**永不回傳 key 本身**(D36)
+ * 3. POST 空 body → 400(不假裝成功)
+ * 4. POST 只改 Claude 偏好(不帶 key)→ 成功,且不碰 credential
+ */
+import { SELF, env, fetchMock } from 'cloudflare:test';
+import { beforeAll, afterEach, describe, it, expect } from 'vitest';
+import { hashPassword } from '../src/lib/portal-auth';
+
+const KBDB = 'https://kbdb.test';
+
+let storedHash: string;
+
+beforeAll(async () => {
+ fetchMock.activate();
+ fetchMock.disableNetConnect();
+ storedHash = await hashPassword('unit-test-pw-1', 10_000);
+});
+afterEach(() => fetchMock.assertNoPendingInterceptors());
+
+function json(method: string, path: string, body?: unknown, headers: Record = {}) {
+ return SELF.fetch(`http://localhost${path}`, {
+ method,
+ headers: { 'Content-Type': 'application/json', ...headers },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+}
+
+function mockGetRecord(recordId: string, values: Record) {
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: `/records/${recordId}`, method: 'GET' })
+ .reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } });
+}
+
+function adminValues(overrides: Record = {}): Record {
+ return {
+ email: 'admin@example.com',
+ display_name: '管理員',
+ status: 'active',
+ role: 'admin',
+ password_hash: storedHash,
+ libraries: '["*"]',
+ created_at: '2026-07-14T00:00:00.000Z',
+ updated_at: '2026-07-14T00:00:00.000Z',
+ ...overrides,
+ };
+}
+
+async function seedSession(token: string, recordId: string) {
+ await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
+}
+
+const authHdr = (t: string) => ({ Authorization: `Bearer ${t}` });
+
+describe('GET /portal/admin/ai — 認證閘(route 存在的證明)', () => {
+ it('未登入 → 401(不是 404 ⇒ route 真的在)', async () => {
+ const res = await json('GET', '/portal/admin/ai');
+ expect(res.status).toBe(401);
+ expect(res.status).not.toBe(404);
+ });
+
+ it('非 admin → 403', async () => {
+ await seedSession('tok-user', 'rec_user');
+ mockGetRecord('rec_user', adminValues({ role: 'user', email: 'u@example.com' }));
+ const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-user'));
+ expect(res.status).toBe(403);
+ });
+});
+
+describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', () => {
+ it('回 has_key 布林+claude 旗標,且回應完全不含金鑰值', async () => {
+ await seedSession('tok-a1', 'rec_admin');
+ mockGetRecord('rec_admin', adminValues());
+ const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1'));
+ expect(res.status).toBe(200);
+ const raw = await res.text();
+ const d = JSON.parse(raw) as Record;
+
+ expect(typeof d.has_key).toBe('boolean');
+ expect(typeof d.claude_available).toBe('boolean');
+ expect(typeof d.use_claude_for_extract).toBe('boolean');
+
+ // D36:回應裡不得出現任何疑似金鑰的欄位
+ expect(raw).not.toContain('gemini_api_key_value');
+ expect(d).not.toHaveProperty('key');
+ expect(d).not.toHaveProperty('value');
+ expect(d).not.toHaveProperty('secret_ref');
+ });
+});
+
+describe('POST /portal/admin/ai — 不假裝成功', () => {
+ it('空 body(沒 key 也沒偏好)→ 400,不回 success', async () => {
+ await seedSession('tok-a2', 'rec_admin');
+ mockGetRecord('rec_admin', adminValues());
+ const res = await json('POST', '/portal/admin/ai', {}, authHdr('tok-a2'));
+ expect(res.status).toBe(400);
+ const d = (await res.json()) as Record;
+ expect(d.success).toBeUndefined();
+ expect(String(d.error)).toContain('沒有要變更');
+ });
+
+ it('只改 Claude 偏好(不帶 key)→ 成功並回存後的值', async () => {
+ await seedSession('tok-a3', 'rec_admin');
+ mockGetRecord('rec_admin', adminValues());
+ const res = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a3'));
+ expect(res.status).toBe(200);
+ const d = (await res.json()) as Record;
+ expect(d.success).toBe(true);
+ expect(d.use_claude_for_extract).toBe(true);
+ // 沒送 key ⇒ 不得回報 has_key(避免誤報「已輸入」)
+ expect(d.has_key).toBeUndefined();
+ });
+
+ // 註:vitest-pool-workers 預設 isolatedStorage=true ⇒ **每個 it 之間 KV 會還原**,
+ // 所以「寫在上一個 it、讀在下一個 it」測不出來(那是測試框架語意,不是程式缺陷)。
+ // 要驗來回一致,必須在**同一個 it** 內完成寫→讀。
+ it('偏好可讀回:同一測試內 POST 寫入 → GET 讀得到同一值', async () => {
+ await seedSession('tok-a4', 'rec_admin');
+ mockGetRecord('rec_admin', adminValues()); // POST 的 requirePortalAdmin 回讀
+ mockGetRecord('rec_admin', adminValues()); // GET 的 requirePortalAdmin 回讀
+
+ const post = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a4'));
+ expect(post.status).toBe(200);
+
+ const get = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a4'));
+ const d = (await get.json()) as Record;
+ expect(d.use_claude_for_extract).toBe(true);
+ });
+});