Files
Arcrun/cypher-executor/src/routes/portal.ts
T
uncle6me-web b223a69884 fix(portal): 藏書地圖看得到自己的知識——租戶字串改從「寫入端」來,不再拿環境變數預設值(Arcrun#108)
leo 2026-08-12 實撞:藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組,
`arcrun_whoami` 顯示 admin/全部知識庫、`kbdb_search` 也查得到——只有地圖那格是空的。

病根(不是資料掉了,是讀寫兩端各拿一個來源):
  寫入端 owner_id = `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,
    leo = `bfezv28v`)
  讀取端過濾 = `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
    ——repo toml 帶的**官方 prod 值**,而 `acr` 從來不注入 CONSOLE_TENANT
  ⇒ 那個 `"leo"` 不是理論邊角,是每台 self-hosted 實例的實際行為,1854 條全被濾掉。

與 #105(`env.MCP_OWNER_NAMESPACE || "leo"`)同一句話,換一個檔案。

租戶字串該從哪裡來(本票的核心判斷):
  **從「寫入這批知識的那一方」來,不是從一份手抄的環境變數預設值來。**
  不是「掛到每個帳號上」——portal 帳號共用同一台實例的知識庫(design D-2),
  帳號之間的差別是 libraries 權限不是 owner_id;複製一份到帳號上只是多一個會過期的副本。
  #105 真正的教訓是:過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、且要能機械驗證。

修法:
1. 唯一產地 `cypher-executor/src/lib/tenant.ts`
   - `knowledgeOwner(env)` → branded `TenantId`:`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
     丟 `TenantUnresolvedError`。**沒有字面預設值**——`|| 'leo'` 正是把「這台機器沒設定」
     偽裝成「你沒有資料」的元凶。
   - `accountTenant(env)` → 普通 `string`(帳號子 namespace `{tenant}::portal` 與 cypher
     自己寫的設定用它)。**回 string 是刻意的**:型別上就不可能流進知識資料面。
   - 資料面過濾一律經 `ownerQuery()` / `ownerField()`,只吃 `TenantId`。
2. 值的正解由 CLI 從真相源導出:`acr update` 把 config 的 `api_key` 注入成 `ARCRUN_NAMESPACE`,
   但**先驗再寫**(`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫;查不到/問不到就一個字
   都不動)。無條件覆蓋會把「知識本來就在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
   ——那是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。
   未注入時回退 CONSOLE_TENANT ⇒ 對官方 prod 與未更新的實例,這次改動是惰性的。
3. 空地圖分四態(沿 #100「讀不到就說讀不到」):no_library_grant/filtered_out/
   scope_mismatch/confirmed_empty。scope_mismatch 以前不存在,所以設定錯誤被畫成
   「你沒有資料」。回應仍不含租戶字串(design §3.3 紅線)。
4. 同族一起修(同一道閘一次抓到):console-dashboard 4 處、console-auth 1 處
   ——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。

留下的閘(規則存在但沒機制驗證=會再犯第三次):
  · 型別閘:TenantId 只能由 tenant.ts 產出 → 拿隨手一個 string 去過濾,tsc 當場不給過。
  · 出貨閘:scripts/build-worker-artifacts.mjs 編 tier2 成品前先掃,違規 → 編不出成品。
  · 閘自己可測:規則是純函式(tenant-source-rules.mjs),tests/tenant-gate.test.ts
    逐條驗「5 種壞例子會擋」+「11 種合法寫法零誤攔」;掃描範圍只有 src/,擋不到自己。
  規範寫入 .claude/rules/02-forbidden.md 第六類、system-dev/wiki/mistakes.md #26。

沒動:庫權限過濾(一字未改,回歸測試釘住)、帳號資料落點、任何金鑰、租戶字串仍不下發前端。

驗證:
  cypher   vitest 441 綠 / 14 紅,14 紅與 base commit e05518a 逐字相同(既有)
           tsc 5 個既有錯誤,零新增
  cli      node:test 60/60 綠(含本次新增 12 條);tsc 零錯誤
  閘       壞例子實跑 exit 1;build 實跑「建置中止」;乾淨時實跑通過
  端到端   ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR ③)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:15:58 +08:00

2015 lines
104 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* RAG Portal 多人授權 — P2:用戶模型+認證 APIportal-auth design §2/§4/§5Gitea #24/#25
*
* 架構(design D-1):Portalcypher-executor 的 /portal 路由(非獨立 worker)。
* 本檔只做 P2 的 auth/admin APIP3/portal HTML 殼+ /portal/data/* enforce)另一波。
*
* 鐵律對照:
* - rule 2.1/2.2:這是 UI session 登入(console-auth 同類先例),非 workflow credential
* 原語。KDF 在 lib/portal-auth.tsWebCrypto PBKDF2),本檔無解密/簽章/template 展開。
* - 零新表(design §2):portal_user / portal_library 都是 KBDB 萬用表 template
* 資料經 KBDB base HTTP API 寫入(kbdbBase 慣例,不直連 D1、不寫 SQL)。
* - 子 namespacedesign D-2):一切帳號資料 owner_id`{CONSOLE_TENANT}::portal`。
* 既有租戶查詢面(/kbdb/*、MCP)都以 CONSOLE_TENANT 過濾 → 物理上搜不到帳號資料
* email/password_hash 不會出現在知識搜尋結果)。
* - sessiondesign §4.3):KV `portal_sess:{token}` 只存 record_idTTL 暫存=合規),
* **每個請求回讀 user record 當唯一真相源** → 停用/改權限即時生效,不靠 session 反向索引。
* - 絕不下發租戶字串(design §3.3 關鍵差異 vs console):/portal/session 只回
* display_name/role/libraries。
* - 密碼永不明碼儲存、永不進 log(本檔不 log 任何 body)。
*/
import { Hono } from 'hono';
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, sha256Hex } from '../lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
// Arcrun#108:租戶字串只有一個產地(lib/tenant.ts)。帳號面用 accountTenant(普通 string),
// 知識資料面用 knowledgeOwnerTenantId)——型別分家,拿錯編不過。
import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey, TenantUnresolvedError, type TenantId } from '../lib/tenant';
// arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
// 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。
import { storeCredential, hasCredential } from './credentials';
// D61Leo/arcrun-rag#55ADR D61):**帳號不再住知識資料庫**。
// 讀寫一律先走 lib/portal-auth-storeCF Workers Secrets,不靠任何 binding),
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
import {
AuthStoreWriteError,
authStoreRecentlyWritten,
authStoreStatus,
findAuthUserByEmail,
findAuthUserById,
hydrateFromAccelerator,
isAuthStoreId,
mutateAuthStore,
newAuthUserId,
readAuthStore,
type AuthUserRecord,
} from '../lib/portal-auth-store';
export const portalRouter = new Hono<{ Bindings: Bindings }>();
const SESSION_PREFIX = 'portal_sess:';
const LOCKFAIL_PREFIX = 'portal_lockfail:';
const LOCK_LIMIT = 5; // design §4.35 次失敗
const LOCK_TTL_SECONDS = 15 * 60; // 鎖 15 分鐘(KV TTL 自然過期)
const DEFAULT_SESSION_TTL = 604800; // 7 天(design §4.3,比 console 30 天緊)
const USER_TEMPLATE = 'portal_user';
export const LIBRARY_TEMPLATE = 'portal_library';
// ── 基礎 helpers ────────────────────────────────────────────────────────────
/**
* 帳號層的租戶字串(**不是**知識資料的 owner_idArcrun#108 拆開)。
*
* 只用來組帳號子 namespace`{tenant}::portal`design D-2)與 cypher 自己寫的設定
* extractor_config / credentials 目錄)——那些都是 cypher 用同一個值寫進去的,所以自洽。
*
* 🔴 **不可以拿它過濾知識資料面**(三元組 / entries / records / 藏書地圖 / 工作流):
* 那批是 CLI/小幫手用實例 namespace 寫的,兩者對不上就是 #108
* leo 的 1854 條被 `CONSOLE_TENANT="leo"` 過濾成 0)。資料面請用
* `lib/tenant.ts` 的 `knowledgeOwner(env)`——它回 `TenantId`,本函式回 `string`
* 型別上就分得開,不必靠人記得。
*
* **只在 server 側使用,永不下發前端**。
*/
export function portalTenant(env: Bindings): string {
return accountTenant(env);
}
/** 帳號子 namespacedesign D-2)。 */
function portalNamespace(env: Bindings): string {
return `${accountTenant(env)}::portal`;
}
function sessionTtl(env: Bindings): number {
const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? '', 10);
// KV expirationTtl 下限 60 秒;壞值誠實退回預設而非炸掉
return Number.isFinite(n) && n >= 60 ? n : DEFAULT_SESSION_TTL;
}
function bearerToken(c: Context<{ Bindings: Bindings }>): string | null {
const auth = c.req.header('authorization') ?? '';
return auth.match(/^Bearer\s+(\S+)/i)?.[1] ?? null;
}
/** KBDB 不可達/回錯時拋這個 → 各 route 統一 502 誠實回報(不假綠、不偽裝成 401)。 */
export class KbdbError extends Error {}
export async function kbdbFetch(env: Bindings, path: string, init?: RequestInit): Promise<Response> {
const { base, headers } = kbdbBase(env);
let res: Response;
try {
res = await fetch(`${base}${path}`, { ...init, headers: { ...headers, ...(init?.headers as Record<string, string> | undefined) } });
} catch (e) {
throw new KbdbError(`fetch ${path} 失敗:${e instanceof Error ? e.message : String(e)}`);
}
return res;
}
/** route handler 包一層:KbdbError → 502(誠實),其餘照拋。 */
export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<Response>): Promise<Response> {
try {
return await fn();
} catch (e) {
// D61:認證儲存寫不進去要**看得出來是這件事**(不是 KBDB 的錯,也不是密碼的錯)
if (e instanceof AuthStoreWriteError) {
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
}
// Arcrun#108:連「這台實例的知識放在哪一格」都解析不出來 → 誠實講「讀不到」,
// 不拿 repo 預設值當答案然後回一頁空的(那正是本票的病:設定缺失被畫成「你沒有資料」)。
if (e instanceof TenantUnresolvedError) {
return c.json({ error: e.message, code: 'tenant_unresolved' }, 500);
}
if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯:${e.message}` }, 502);
throw e;
}
}
// ── KBDB 資料層 helpers(全走 base HTTP API,零 SQL)────────────────────────────
export interface PortalRecord {
record_id: string;
template_id: string;
values: Record<string, string>;
}
/** 冪等確保 portal templates 存在(seed 是 API 行為,rule 07/init/seed 與 bootstrap 共用)。 */
export async function ensurePortalTemplates(
env: Bindings,
): Promise<{ created: string[]; existing: string[]; errors: string[] }> {
const created: string[] = [];
const existing: string[] = [];
const errors: string[] = [];
for (const seed of PORTAL_TEMPLATE_SEEDS) {
try {
const got = await kbdbFetch(env, `/templates/${encodeURIComponent(seed.name)}`);
if (got.ok) {
// 已存在 → 檢查 slots 是否落後 seed(如 P3 新增 portal_library.graph_source)。
// updateRecord 對「不在 template slots_json 的 slot」會 reject——不補 slot
// 舊實例就永遠寫不進新標記。PATCH 補聯集(冪等,既有 record 不動)。
const body = (await got.json().catch(() => null)) as {
template?: { id: string; slots_json?: string };
} | null;
const tpl = body?.template;
if (tpl?.id && tpl.slots_json) {
let currentSlots: string[] = [];
try {
const parsed = JSON.parse(tpl.slots_json);
if (Array.isArray(parsed)) currentSlots = parsed.filter((s): s is string => typeof s === 'string');
} catch {
/* slots_json 壞掉 → 視同空,補成 seed 全集 */
}
const missing = seed.slots.filter((s) => !currentSlots.includes(s));
if (missing.length > 0) {
const patched = await kbdbFetch(env, `/templates/${encodeURIComponent(tpl.id)}`, {
method: 'PATCH',
body: JSON.stringify({ slots: [...currentSlots, ...missing] }),
});
if (!patched.ok) throw new KbdbError(`PATCH /templates/${seed.name} 補 slots → ${patched.status}`);
}
}
existing.push(seed.name);
continue;
}
if (got.status !== 404) throw new KbdbError(`GET /templates/${seed.name}${got.status}`);
const res = await kbdbFetch(env, '/templates', {
method: 'POST',
body: JSON.stringify({
name: seed.name,
slots: seed.slots,
description: seed.description,
created_by: seed.created_by,
}),
});
if (!res.ok) throw new KbdbError(`POST /templates ${seed.name}${res.status}`);
created.push(seed.name);
} catch (e) {
errors.push(`${seed.name}: ${e instanceof Error ? e.message : String(e)}`);
}
}
return { created, existing, errors };
}
// ── D61 認證儲存 ⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)─────
function authUserToRecord(u: AuthUserRecord): PortalRecord {
return {
record_id: u.id,
template_id: USER_TEMPLATE,
values: {
email: u.email,
display_name: u.display_name,
status: u.status,
role: u.role,
password_hash: u.password_hash,
libraries: JSON.stringify(u.libraries ?? []),
created_at: u.created_at,
updated_at: u.updated_at,
},
};
}
function recordValuesToAuthUser(id: string, v: Record<string, string>): AuthUserRecord {
return {
id,
email: (v.email ?? '').toLowerCase(),
display_name: v.display_name ?? '',
status: v.status ?? 'active',
role: v.role ?? 'user',
libraries: parseLibraries(v.libraries),
password_hash: v.password_hash ?? '',
created_at: v.created_at ?? new Date().toISOString(),
updated_at: v.updated_at ?? new Date().toISOString(),
};
}
/**
* 舊實例自癒:在 KBDB 找到的既有帳號,原樣搬進認證儲存。
* best-effort——搬不動(寫入路徑未就緒)不影響這次登入,只是下次還會再走一次舊路。
* 這就是 #55「第一版不做跨版本遷移機制」的落地方式:**用一次成功的登入把自己搬過去**。
*/
async function promoteLegacyUser(env: Bindings, rec: PortalRecord): Promise<void> {
try {
const email = (rec.values.email ?? '').toLowerCase();
if (!email) return;
if (findAuthUserByEmail(env, email)) return;
await mutateAuthStore(env, (data) => {
if (data.users.some((u) => u.email === email)) return;
data.users.push(recordValuesToAuthUser(newAuthUserId(), rec.values));
});
} catch {
/* 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來) */
}
}
/** email → user record_id。**新家優先**;找不到才回退舊家(KBDB),並順手搬過去。 */
async function findUserRecordId(env: Bindings, email: string): Promise<string | null> {
const inStore = findAuthUserByEmail(env, email);
if (inStore) return inStore.id;
return findLegacyUserRecordId(env, email);
}
/** 舊家(KBDB)的 email → record_iddesign §2.3 head entry O(1) 查找)。 */
async function findLegacyUserRecordId(env: Bindings, email: string): Promise<string | null> {
const ns = portalNamespace(env);
const params = new URLSearchParams({
page_name: email,
entry_type: USER_TEMPLATE,
owner_id: ns,
limit: '1',
});
const res = await kbdbFetch(env, `/entries?${params.toString()}`);
if (!res.ok) throw new KbdbError(`head entry 查找 → ${res.status}`);
const body = (await res.json()) as { entries?: { content: string | null }[] };
const content = body.entries?.[0]?.content;
return content ?? null;
}
async function getRecordById(env: Bindings, recordId: string): Promise<PortalRecord | null> {
// D61:住新家的帳號零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到)
if (isAuthStoreId(recordId)) {
const u = findAuthUserById(env, recordId);
return u ? authUserToRecord(u) : null;
}
const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`);
if (res.status === 404) return null;
if (!res.ok) throw new KbdbError(`GET /records/${recordId}${res.status}`);
const body = (await res.json()) as { record?: PortalRecord };
return body.record ?? null;
}
async function patchRecordValues(env: Bindings, recordId: string, values: Record<string, string>): Promise<PortalRecord> {
// D61:住新家的帳號改寫進 Workers Secrets(改密碼/停用/改權限都在這條路上)
if (isAuthStoreId(recordId)) {
let updated: AuthUserRecord | null = null;
await mutateAuthStore(env, (data) => {
const idx = data.users.findIndex((u) => u.id === recordId);
if (idx < 0) throw new KbdbError(`認證儲存找不到帳號 ${recordId}`);
const merged = { ...authUserToRecord(data.users[idx]).values, ...values };
updated = recordValuesToAuthUser(recordId, merged);
data.users[idx] = updated;
});
if (!updated) throw new KbdbError(`認證儲存更新失敗 ${recordId}`);
return authUserToRecord(updated);
}
const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, {
method: 'PATCH',
body: JSON.stringify({ values }),
});
if (!res.ok) throw new KbdbError(`PATCH /records/${recordId}${res.status}`);
const body = (await res.json()) as { record?: PortalRecord };
if (!body.record) throw new KbdbError(`PATCH /records/${recordId} 回應缺 record`);
return body.record;
}
async function deleteKbdbRecord(env: Bindings, recordId: string): Promise<boolean> {
if (isAuthStoreId(recordId)) {
let found = false;
await mutateAuthStore(env, (data) => {
const idx = data.users.findIndex((u) => u.id === recordId);
if (idx >= 0) {
data.users.splice(idx, 1);
found = true;
}
});
return found;
}
const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: 'DELETE' });
if (res.status === 404) return false;
if (!res.ok) throw new KbdbError(`DELETE /records/${recordId}${res.status}`);
return true;
}
/** KV key for daemon's most-recently-reported active library namest135 daemon hint)。 */
function daemonActiveKey(env: Bindings): string {
return `${portalTenant(env)}:portal:daemon_active_libs`;
}
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
// D61:帳號清單=新家為主,舊家(KBDB)尚未搬走的補在後面(同 email 以新家為準)。
// 舊家讀不到不算失敗——認證已經不靠它了,這裡只是把還沒搬完的人也列出來。
if (template === USER_TEMPLATE) {
const fromStore = readAuthStore(env).users.map(authUserToRecord);
const seen = new Set(fromStore.map((r) => (r.values.email ?? '').toLowerCase()));
let legacy: PortalRecord[] = [];
try {
legacy = await listLegacyRecordsByTemplate(env, template);
} catch {
legacy = [];
}
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()))];
}
return listLegacyRecordsByTemplate(env, template);
}
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」仍走這條,那些本來就該住知識庫)。 */
async function listLegacyRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template}${res.status}`);
const body = (await res.json()) as { records?: PortalRecord[] };
return body.records ?? [];
}
interface CreateUserInput {
email: string;
display_name: string;
role: 'user' | 'admin';
libraries: string[];
password_hash: string;
}
/**
* 建帳號。**D61 起一律建在認證儲存(Workers Secrets),不再寫進 KBDB。**
* 寫入路徑未就緒就誠實拋錯(AuthStoreWriteError → 502),不偷偷退回舊家——
* 退回去等於這個帳號下次搬資料時又會不見,那正是本案要根治的病。
*/
async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<string> {
const now = new Date().toISOString();
const id = newAuthUserId();
await mutateAuthStore(env, (data) => {
data.users.push({
id,
email: input.email.toLowerCase(),
display_name: input.display_name,
status: 'active',
role: input.role,
libraries: input.libraries,
password_hash: input.password_hash,
created_at: now,
updated_at: now,
});
});
return id;
}
// ── user 值域 helpers ──────────────────────────────────────────────────────
export function parseLibraries(raw: string | undefined): string[] {
if (!raw) return [];
try {
const arr = JSON.parse(raw);
if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr;
} catch {
/* fallthrough */
}
return [];
}
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254;
}
/** 庫名進 metadata/query(逗號分隔參數),故禁逗號/空白/怪字元。 */
function isValidLibraryName(name: string): boolean {
return /^(\*|[A-Za-z0-9_-]{1,64})$/.test(name);
}
function validLibrariesInput(libs: unknown): libs is string[] {
return Array.isArray(libs) && libs.length > 0 && libs.every((x) => typeof x === 'string' && isValidLibraryName(x));
}
/** admin 面向的公開 user 形狀:**絕不含 password_hash**。 */
function toPublicUser(rec: PortalRecord) {
const v = rec.values;
return {
record_id: rec.record_id,
email: v.email ?? '',
display_name: v.display_name ?? '',
status: v.status ?? '',
role: v.role ?? '',
libraries: parseLibraries(v.libraries),
created_at: v.created_at ?? '',
updated_at: v.updated_at ?? '',
};
}
// ── session 閘 ────────────────────────────────────────────────────────────
export type AuthedUser = { token: string; recordId: string; values: Record<string, string> };
export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Response };
/**
* 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);
if (!token) return { ok: false, res: c.json({ error: '未登入' }, 401) };
const sess = await c.env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`);
if (!sess) return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
let recordId: string | undefined;
try {
recordId = (JSON.parse(sess) as { record_id?: string }).record_id;
} catch {
/* fallthrough */
}
if (!recordId) {
// 這一筆 session 的內容本身壞掉=確定的事實(不是讀不到),刪它是對的。
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
}
let rec = await getRecordById(c.env, recordId);
if (!rec && (await hydrateFromAccelerator(c.env))) {
rec = await getRecordById(c.env, recordId); // ①:與登入路徑同一招,再問一次加速器
}
if (!rec) {
// ③:分辨「傳播空窗」與「帳號真的沒了」——前者不可以把人踢出去。
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) };
}
return { ok: true, user: { token, recordId, values: rec.values } };
}
async function requirePortalAdmin(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth;
if ((auth.user.values.role ?? '') !== 'admin') {
return { ok: false, res: c.json({ error: '需要 admin 權限' }, 403) };
}
return auth;
}
// ── D-4 graph 粗閘 / D-8 工作流頁能力(P3;server 是唯一裁決點,前端只照 session 渲染)────
/**
* 知識圖譜的「來源庫」集合(design D-4):portal_library 中標 graph_source='true'
* 且未停用的庫。**沒有任何庫標記時預設 ['general']**D-4 定案)。
*/
export async function graphSourceLibraries(env: Bindings): Promise<string[]> {
const libs = await listRecordsByTemplate(env, LIBRARY_TEMPLATE);
const marked = libs
.filter((l) => (l.values.graph_source ?? '') === 'true' && (l.values.status ?? 'active') !== 'disabled')
.map((l) => l.values.name ?? '')
.filter(Boolean);
return marked.length > 0 ? marked : ['general'];
}
/** graph 粗閘判定:擁有任一 graph 來源庫的權限(或 ["*"] 全庫)才放行。 */
export async function hasGraphAccess(env: Bindings, userLibraries: string[]): Promise<boolean> {
if (userLibraries.includes('*')) return true; // 全庫 → 必含來源庫,省一次 KBDB 呼叫
if (userLibraries.length === 0) return false;
const sources = await graphSourceLibraries(env);
return sources.some((s) => userLibraries.includes(s));
}
/**
* 工作流頁可見性(design D-8 定案:admin):PORTAL_SHOW_WORKFLOWS = admin(預設)/ all / off。
* 壞值誠實退回預設 admin(不因 typo 意外全開)。
*/
export function workflowsVisible(env: Bindings, role: string): boolean {
const setting = (env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase();
if (setting === 'off') return false;
if (setting === 'all') return true;
return role === 'admin';
}
/**
* 上傳能力(portal-demo-suite):PORTAL_UPLOAD_REPO / PORTAL_UPLOAD_GITEA / PORTAL_UPLOAD_TOKEN
* 三個 bindings **齊全**才啟用。Mira 零影響:未設=功能不存在(/portal/data/upload 404、
* 前端 nav 隱藏)。這裡只是顯示提示——真閘在 /portal/data/upload 路由層,前端藏不藏都繞不過。
*/
export function uploadEnabled(env: Bindings): boolean {
return Boolean(env.PORTAL_UPLOAD_REPO && env.PORTAL_UPLOAD_GITEA && env.PORTAL_UPLOAD_TOKEN);
}
/**
* admin 操作目標 record 的成員資格驗證:record 的 email head entry(子 namespace 內)
* 必須指回同一 record_id——同時證明「是 portal_user」且「在本實例的 {tenant}::portal 下」,
* 防 admin 拿任意 record_id 改到不相干的 KBDB record。
*/
async function assertPortalUserRecord(env: Bindings, recordId: string): Promise<PortalRecord | null> {
const rec = await getRecordById(env, recordId);
if (!rec) return null;
const email = rec.values.email;
if (!email) return null;
const headRecordId = await findUserRecordId(env, email);
if (headRecordId !== recordId) return null;
return rec;
}
// ── 登入節流(design §4.3:KV 計數,TTL 自然過期)──────────────────────────────
async function isLocked(env: Bindings, email: string): Promise<boolean> {
const raw = await env.SESSIONS_KV.get(`${LOCKFAIL_PREFIX}${email}`);
if (!raw) return false;
try {
return ((JSON.parse(raw) as { count?: number }).count ?? 0) >= LOCK_LIMIT;
} catch {
return false;
}
}
async function recordLoginFail(env: Bindings, email: string): Promise<void> {
const key = `${LOCKFAIL_PREFIX}${email}`;
const raw = await env.SESSIONS_KV.get(key);
let count = 0;
if (raw) {
try {
count = (JSON.parse(raw) as { count?: number }).count ?? 0;
} catch {
count = 0;
}
}
await env.SESSIONS_KV.put(key, JSON.stringify({ count: count + 1 }), { expirationTtl: LOCK_TTL_SECONDS });
}
async function clearLoginFail(env: Bindings, email: string): Promise<void> {
await env.SESSIONS_KV.delete(`${LOCKFAIL_PREFIX}${email}`);
}
/**
* D61:這台實例是不是「一個帳號都沒有」(新家空、舊家也空/讀不到)。
* 只在「查無此帳號」時才呼叫,不進正常登入熱路徑。
*/
async function instanceHasNoAuthData(env: Bindings): Promise<boolean> {
if (readAuthStore(env).users.length > 0) return false;
try {
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
} catch {
return true; // 舊家讀不到 + 新家空 = 這台實例確實沒有可用的登入資料
}
}
/**
* D61:查帳號+驗密碼的**唯一**入口(portal 登入與兩支 daemon 端點共用,避免三份走樣)。
*
* 🔴 為什麼要「失敗後再問一次加速器」(2026-08-10 stage 演練實測撞到的坑):
* 認證的家是 CF Workers Secret,改它會產生 worker 新版本,**既有 isolate 讀到的還是舊 env**。
* 實測「建好帳號 → 立刻登入」有 15 秒以上是 401,而且那幾次還被算進 5 次鎖定
* ⇒ 安裝精靈「建立帳號 → 馬上登入」會把人鎖在門外 15 分鐘。
* ⇒ 所以查不到/密碼對不上時,**先問一次加速器再判定失敗**(見 lib 的 ACCEL_KEY 註解)。
* ⇒ 加速器讀不到也沒關係,只是回到「等傳播」;它不是真相源,換 KV 不影響 D61 的不變量。
*/
async function findAndVerifyUser(
env: Bindings,
email: string,
password: string,
): Promise<{ recordId: string | null; rec: PortalRecord | null; ok: boolean }> {
const attempt = async () => {
const recordId = await findUserRecordId(env, email);
const rec = recordId ? await getRecordById(env, recordId) : null;
const ok = rec ? await verifyPassword(password, rec.values.password_hash ?? '') : false;
return { recordId, rec, ok };
};
const first = await attempt();
if (first.ok) return first;
if (await hydrateFromAccelerator(env)) {
const second = await attempt();
if (second.ok || second.rec) return second;
}
return first;
}
// ═══════════════════════════════ 認證端點 ═══════════════════════════════════
// POST /portal/login — body {email, password}。成功發 portal session token。
// 錯誤訊息刻意不分「帳號不存在 vs 密碼錯」(不洩帳號存在性);停用帳號誠實回 403。
portalRouter.post('/portal/login', (c) =>
run(c, async () => {
const body = await c.req.json().catch(() => null);
const email = String(body?.email ?? '').trim().toLowerCase();
const password = String(body?.password ?? '');
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
if (await isLocked(c.env, email)) {
return c.json({ error: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 429);
}
const { recordId, rec, ok } = await findAndVerifyUser(c.env, email, password);
if (!recordId || !rec) {
// D61 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
// 「這台實例一個帳號都沒有」跟「你密碼打錯」是兩件事,不准混成同一句話——
// 2026-08-09 leo 就是被這個誤判鎖了 15 分鐘,而他的密碼從頭到尾都是對的。
// ⇒ 回一個**分得出來**的錯,而且**不計入鎖定**。
if (await instanceHasNoAuthData(c.env)) {
return c.json(
{
error:
'這台實例讀不到任何登入資料——不是密碼錯。認證儲存是空的,' +
'請重新執行安裝/更新以重新建立管理員帳號。',
code: 'auth_store_empty',
auth_store: authStoreStatus(c.env),
},
503,
);
}
await recordLoginFail(c.env, email);
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
if ((rec.values.status ?? '') !== 'active') {
return c.json({ error: '帳號已停用' }, 403);
}
if (!ok) {
await recordLoginFail(c.env, email);
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
// D61 自癒:這次是拿舊家(KBDB)的帳號登進來的 → 順手搬進認證儲存,
// 下次換庫/換租戶代號就不會再把他鎖在門外。
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
await clearLoginFail(c.env, email);
const token = randomHex(32);
// session 值只存 record_iddesign §4.3)——權限/狀態每請求回讀 record,不快取進 session
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: recordId }), {
expirationTtl: sessionTtl(c.env),
});
return c.json({
success: true,
session_token: token,
display_name: rec.values.display_name ?? '',
role: rec.values.role ?? 'user',
libraries: parseLibraries(rec.values.libraries),
// session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據),
// 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
// access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、
// 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
session_expires_in: sessionTtl(c.env),
// 絕不回租戶字串(design §3.3portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*
});
}),
);
// POST /portal/logout
portalRouter.post('/portal/logout', async (c) => {
const token = bearerToken(c);
if (token) await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
return c.json({ success: true });
});
// GET /portal/session — 每請求回讀 user record(真相源);回 display_name/role/libraries
// **絕不回租戶字串**design §5)。
// P3 補能力欄位(前端據此渲染,design §6/D-4/D-8):graph_allowedgraph 模式要不要顯示)、
// workflows_visible(工作流頁要不要顯示)。**這兩個只是顯示提示——真正的擋在
// /portal/data/* 路由層**(無權 403/404),前端藏不藏都繞不過。
portalRouter.get('/portal/session', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const v = auth.user.values;
const role = v.role ?? 'user';
const libraries = parseLibraries(v.libraries);
return c.json({
valid: true,
display_name: v.display_name ?? '',
email: v.email ?? '', // t53:完成安裝清單在站內生 daemon config.json 要用(身分顯示欄)
role,
libraries,
graph_allowed: await hasGraphAccess(c.env, libraries),
workflows_visible: workflowsVisible(c.env, role),
// portal-demo-suite:上傳頁能力(bindings 齊全才 true;同上,只是顯示提示,真閘在路由層)
upload_enabled: uploadEnabled(c.env),
});
}),
);
// ═════════════════ 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 補洞不會多出任何一條路。
/** 「修改密碼」連結 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;
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 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「把故障講成用戶的問題」的反面)。
//
// 🔴 arcrun-rag#38/#69/#252026-08-11 leo 親口點出兩個問題,改字前先讀):
// ① 「實例」是行話——目標用戶是「只會叫 AI 幫忙的人」,不懂什麼叫一台實例。
// ② 「請管理員直接幫你改密碼」對單人使用者是死路——他自己就是管理員,等於叫他聯絡自己
// (`#25` 同一種病:「我忘記 portal 密碼,畫面叫我去找管理員,但管理員就是我」)。
// ⇒ 訊息改成白話(不提「實例」),而且給一條他自己走得完的路:**重新跑一次安裝/更新**
// 現在**真的有用**(不是安慰話)——arcrun-rag#38/#69/#25 同批修正讓安裝器學會「版本號一樣
// 不代表這個功能已經接上,沒接上就當作舊的重推」,所以照原本收到的安裝網址走一次,
// 選同一個 Cloudflare 帳號,這個功能就會自動接上,不需要自己改任何設定。
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:
'寄信功能還沒接上,所以「忘記密碼」的信寄不出去。' +
'請照當初收到的安裝網址,重新執行一次安裝(選同一個 Cloudflare 帳號)——' +
'完成後這個功能就會自動接上,不需要自己設定任何東西,也不用找任何人幫忙。',
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 是
// 安裝期人閘,不引入新 secret、不開放無閘註冊)。建第一個 role=admin 的 portal_user
// 已有 admin → 409 拒絕重複 bootstrap。順手冪等確保 templatesseed 是 API 行為)。
portalRouter.post('/portal/admin/bootstrap', (c) =>
run(c, async () => {
const consoleOk = await validateConsoleSession(c.env, c.req.header('authorization'));
if (!consoleOk) return c.json({ error: '需要 console owner session(先登入 /console' }, 401);
const seeded = await ensurePortalTemplates(c.env);
if (seeded.errors.length > 0) {
return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502);
}
const users = await listRecordsByTemplate(c.env, USER_TEMPLATE);
if (users.some((u) => (u.values.role ?? '') === 'admin')) {
return c.json({ error: '已有 adminbootstrap 只能執行一次;後續帳號請用 /portal/admin/users' }, 409);
}
const body = await c.req.json().catch(() => null);
const email = String(body?.email ?? '').trim().toLowerCase();
const password = String(body?.password ?? '');
const displayName = String(body?.display_name ?? '').trim() || email;
if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409);
const recordId = await createPortalUser(c.env, {
email,
display_name: displayName,
role: 'admin',
libraries: ['*'], // bootstrap admin 預設全庫(design §3.3["*"]=不注 library filter
password_hash: await hashPassword(password),
});
return c.json({ success: true, record_id: recordId, email, role: 'admin' });
}),
);
// POST /portal/admin/recover-password — 管理員自救援出口(arcrun-rag#25:唯一 admin 忘記
// portal 密碼就進不去,登入頁只會叫他「聯絡管理員」=叫他聯絡自己,沒有下一步)。
//
// 根因:`/portal/admin/users/:id/reset-password`(上面)與其他 admin 端點全部要求
// `requirePortalAdmin`**要先有一個有效的 portal admin session**——雞生蛋問題:admin
// 密碼忘了就進不去 portal,進不去 portal 就沒有 session 去重設密碼。`bootstrap` 能繞過這關
// 是因為它吃的是**另一道獨立的閘**console owner sessiondesign D-7);但 bootstrap
// 只能跑一次(已有 admin 就 409),事後沒有對應的「用同一道閘做救援」端點。
//
// 修法:開一個**只認 console owner session、不認 portal session**的救援端點,直接複用
// bootstrap 已經在用的 `validateConsoleSession`。console 帳密(`/console/setup` 首次設定時
// 建立)與 portal 帳密是完全分開存放的兩組(見 console-auth.ts),只要 console 密碼沒有一起忘記,
// 這條路就走得通——不必問人、不必讀原始碼,畫面上(/console → 設定 → Portal 帳號密碼救援)
// 就有完整入口。找不到該 email 的帳號 → 404(誠實,不誤導成別的錯誤)。
portalRouter.post('/portal/admin/recover-password', (c) =>
run(c, async () => {
const consoleOk = await validateConsoleSession(c.env, c.req.header('authorization'));
if (!consoleOk) return c.json({ error: '需要 console owner session(先登入 /console' }, 401);
const body = await c.req.json().catch(() => null);
const email = String(body?.email ?? '').trim().toLowerCase();
if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
const recordId = await findUserRecordId(c.env, email);
const rec = recordId ? await getRecordById(c.env, recordId) : null;
if (!recordId || !rec) return c.json({ error: `找不到 email${email} 的 portal 帳號` }, 404);
const password = generatePassword();
await patchRecordValues(c.env, recordId, {
password_hash: await hashPassword(password),
updated_at: new Date().toISOString(),
});
return c.json({ success: true, email, password }); // 一次性回傳,server 不留明碼
}),
);
// GET /portal/admin/users — 同仁列表(role=admin 閘)。**回應剝除 password_hash**。
portalRouter.get('/portal/admin/users', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const users = await listRecordsByTemplate(c.env, USER_TEMPLATE);
return c.json({ success: true, users: users.map(toPublicUser), count: users.length });
}),
);
// POST /portal/admin/users — 新增同仁。body {email, display_name?, role?, libraries?, password?}。
// 未帶 password → server 產一次性密碼隨回應回傳一次(不落地明碼,design §4.3 簡化版)。
portalRouter.post('/portal/admin/users', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const body = await c.req.json().catch(() => null);
const email = String(body?.email ?? '').trim().toLowerCase();
const displayName = String(body?.display_name ?? '').trim() || email;
const role = body?.role === 'admin' ? 'admin' : 'user';
const libraries: string[] = validLibrariesInput(body?.libraries) ? (body.libraries as string[]) : ['general'];
if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
if (body?.libraries !== undefined && !validLibrariesInput(body?.libraries)) {
return c.json({ error: 'libraries 須為非空字串陣列(庫名限 A-Za-z0-9_- 或 "*"' }, 400);
}
if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409);
let password = body?.password !== undefined ? String(body.password) : '';
let generated: string | undefined;
if (password) {
if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
} else {
generated = generatePassword();
password = generated;
}
const recordId = await createPortalUser(c.env, {
email,
display_name: displayName,
role,
libraries,
password_hash: await hashPassword(password),
});
const rec = await getRecordById(c.env, recordId);
return c.json({
success: true,
user: rec ? toPublicUser(rec) : { record_id: recordId, email },
// 一次性回傳(不儲存明碼);admin 口頭轉交同仁後即失效於 server 側
...(generated ? { generated_password: generated } : {}),
});
}),
);
// PATCH /portal/admin/users/:id — 改 status/role/librariesdesign §5)。
// 停用即時生效機制=每請求回讀 record(§4.3),不依賴刪 session。
portalRouter.patch('/portal/admin/users/:id', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const recordId = c.req.param('id');
const rec = await assertPortalUserRecord(c.env, recordId);
if (!rec) return c.json({ error: '用戶不存在' }, 404);
const body = await c.req.json().catch(() => null);
if (!body) return c.json({ error: 'body 必須是 JSON' }, 400);
const patch: Record<string, string> = {};
if (body.status !== undefined) {
if (body.status !== 'active' && body.status !== 'disabled') {
return c.json({ error: 'status 只能是 active / disabled' }, 400);
}
patch.status = body.status;
}
if (body.role !== undefined) {
if (body.role !== 'user' && body.role !== 'admin') return c.json({ error: 'role 只能是 user / admin' }, 400);
patch.role = body.role;
}
if (body.libraries !== undefined) {
if (!validLibrariesInput(body.libraries)) {
return c.json({ error: 'libraries 須為非空字串陣列(庫名限 A-Za-z0-9_- 或 "*"' }, 400);
}
patch.libraries = JSON.stringify(body.libraries);
}
if (Object.keys(patch).length === 0) return c.json({ error: '沒有可更新的欄位(status/role/libraries' }, 400);
// 鎖死保護(P4,總管派工明定):**不可停用/降級最後一個 active admin**——
// 否則系統再無人能管帳號(bootstrap 只能跑一次,409),變成鎖死狀態。
// 只在「目標現在是 active admin 且 patch 會使它不再是」時才多打一次 list(平時零成本)。
const isActiveAdmin = (rec.values.role ?? '') === 'admin' && (rec.values.status ?? '') === 'active';
const wouldLoseAdmin = patch.status === 'disabled' || patch.role === 'user';
if (isActiveAdmin && wouldLoseAdmin) {
const all = await listRecordsByTemplate(c.env, USER_TEMPLATE);
const otherActiveAdmins = all.filter(
(u) => u.record_id !== recordId && (u.values.role ?? '') === 'admin' && (u.values.status ?? '') === 'active',
);
if (otherActiveAdmins.length === 0) {
return c.json({ error: '不可停用或降級最後一個管理員——系統至少要保留一個 active admin' }, 409);
}
}
patch.updated_at = new Date().toISOString();
const updated = await patchRecordValues(c.env, recordId, patch);
return c.json({ success: true, user: toPublicUser(updated) });
}),
);
// POST /portal/admin/users/:id/reset-password — 產一次性新密碼回傳(design §4.3 簡化版:
// admin 口頭轉交;must_change 首登改密列第二波)。
portalRouter.post('/portal/admin/users/:id/reset-password', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const recordId = c.req.param('id');
const rec = await assertPortalUserRecord(c.env, recordId);
if (!rec) return c.json({ error: '用戶不存在' }, 404);
const password = generatePassword();
await patchRecordValues(c.env, recordId, {
password_hash: await hashPassword(password),
updated_at: new Date().toISOString(),
});
return c.json({ success: true, password }); // 一次性回傳,server 不留明碼
}),
);
// ── 庫目錄(design §3.2 portal_library:登記簿;庫本體=條目上的 metadata 標記)────
function toPublicLibrary(rec: PortalRecord) {
const v = rec.values;
return {
record_id: rec.record_id,
name: v.name ?? '',
display_name: v.display_name ?? '',
description: v.description ?? '',
status: v.status ?? '',
// D-4:此庫是否為知識圖譜萃取來源(graph 粗閘按這個判定;全都沒標 → 預設 general)
graph_source: (v.graph_source ?? '') === 'true',
};
}
// POST /portal/daemon/libraries — body {email, password, libraries:[{name, display_name?}]}。
// t52leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵」):
// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
// POST /portal/daemon/extract — 小幫手把「已轉成純文字的原稿」送上來,雲端用 Workers AI 萃成知識卡。
// body {email, password, page_name, text}。認證同 /portal/daemon/config(帳密)。
//
// 🔴 t181leo 08-04:「daemon 的 AI 改用 workers AI」,列為**最優先**——
// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」):
// 舊路徑要用戶自己去 Google 申請 Gemini API Key,實測撞到三種災難:
// ① 完全不知道要去哪裡設定(台大資工碩士都卡住 ⇒ leo:「一般人就完蛋了」)
// ② 拿到的金鑰所屬 Google 帳號被 flag ⇒ 403 PERMISSION_DENIED、換專案也無效
// ③ 52 檔全滅還要把金鑰傳給別人實打才查得出真因
// ⇒ 走 Workers AI`env.AI` binding**完全不需要任何金鑰**,
// 用的是用戶自己 CF 帳號內建的 AI;他的 Google 帳號被封也不受影響。
//
// 為什麼萃取放雲端而不是 daemon 直接打:daemon 端**沒有 AI binding**
//binding 是 Worker 專屬),且模型選型集中在雲端才能統一換。
// ⚠️ 隱私邊界不變:daemon 送的是**已在本機轉成文字的原稿**,回傳的是知識卡;
// 原始檔案(docx/pdf)仍然不出用戶的電腦。
// 🔑 認證用 `X-Arcrun-API-Key`(=namespace),**不是帳密**
// daemon 的密碼**只在連線精靈當下用過就丟、不落地**(config.json 沒有密碼欄,刻意的安全設計),
// 但背景萃取是每輪自動跑的 ⇒ 根本拿不到密碼。
// 而 daemon 送卡片上雲時本來就帶這個 headercollector/direct.go:355),沿用同一把最自然。
portalRouter.post('/portal/daemon/extract', (c) =>
run(c, async () => {
// 🔴 t189leo 08-04 實撞:geek6688 萃取回 401「X-Arcrun-API-Key 不正確」):
//
// t181 那一輪我改成 `apiKey !== portalTenant(c.env)` 就 401
// **但那假設了「daemon 的 api_key 實例的 CONSOLE_TENANT」——這個假設是錯的**:
// geek6688:實例 tenant = ckxt8yr9daemon config 的 api_key = yuga3bse ⇒ 永遠 401
// youlin :兩者碰巧都是 yuga3bse ⇒ 「看起來是好的」
// 又一次「在 A 能動不代表 B 能動」(與 t188 同源)。
// ⚠️ 當時我還寫了「key 錯就 401」的測試,**把錯誤假設固化成綠燈**——
// 測試只證明「符合我的假設」,不證明「假設是對的」。
//
// 正解=照本 repo 既有慣例:這把 key 是**租戶識別**,不是要比對的共用密語
// (見 `webhooks-named.ts` 的 `owner_id: apiKey` 用法)。
// 本端點只用實例自己的 `env.AI` 生成、**不寫任何資料**、不回傳庫內內容
// ⇒ 有帶 key 即可,不做等值比對。
const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
const body = (await c.req.json().catch(() => null)) as
| { page_name?: string; text?: string }
| null;
const pageName = String(body?.page_name ?? '').trim();
const srcText = String(body?.text ?? '');
if (!pageName || !srcText.trim()) return c.json({ error: 'page_name 與 text 必填' }, 400);
if (!c.env.AI) {
// 誠實失敗:不假裝成功,並指名這個部署缺什麼(禁假綠)
return c.json({ error: '這個部署沒有綁定 Workers AIwrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501);
}
// 提示詞與 daemon 端 gemmaPrompt 同一份契約(第一行必須是「# <頁名>」),
// 兩邊要一起改;daemon 端在 collector/extract_gemma.go。
// 註:關聯段用的是「知識卡三元組」格式(主詞/謂詞/受詞),與 Arcrun 工作流的邊無關。
const REL = '>'.repeat(2);
const prompt =
`把以下原稿重寫成定稿知識卡(正體中文)。直接輸出卡片本身:第一行必須是「# ${pageName}」,` +
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
`## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B3-8 行,用上面實體名)\n\n原稿:\n${srcText}`;
try {
// 模型與 workers_ai_chat recipe 同一支(選型實測見 api-recipe-seeds.ts:140
// llama-4-scout 2373ms/答案最完整;對照 Gemini gemma-4-31b-it 16.87 秒且吐英文草稿)。
const out = (await c.env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', {
messages: [{ role: 'user', content: prompt }],
max_tokens: 2048,
temperature: 0.2,
} as never)) as { response?: string } | undefined;
const card = String(out?.response ?? '').trim();
if (!card) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
// 淨化:模型偶爾在卡片前多帶一段前言 ⇒ 取最後一個「# <頁名>」起(同 daemon cleanGemmaCard
const marker = `# ${pageName}`;
const idx = card.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + '\n' });
} catch (e) {
return c.json({ error: `Workers AI 執行失敗:${e instanceof Error ? e.message : String(e)}` }, 502);
}
}),
);
portalRouter.post('/portal/daemon/libraries', (c) =>
run(c, async () => {
const body = (await c.req.json().catch(() => null)) as
| { email?: string; password?: string; libraries?: { name?: string; display_name?: string }[] }
| null;
const email = String(body?.email ?? '').trim().toLowerCase();
const password = String(body?.password ?? '');
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429);
// D61:與 /portal/login 共用同一支查找+驗證(含「剛建好還沒傳播」的加速器重試)
const { rec, ok } = await findAndVerifyUser(c.env, email, password);
if (!rec || (rec.values.status ?? '') !== 'active' || !ok) {
await recordLoginFail(c.env, email);
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
await clearLoginFail(c.env, email);
const wanted = Array.isArray(body?.libraries) ? body!.libraries! : [];
const seeded = await ensurePortalTemplates(c.env);
if (seeded.errors.length > 0) {
return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502);
}
const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
const have = new Set(existing.map((l) => String(l.values.name ?? '')));
const ns = portalNamespace(c.env);
const created: string[] = [];
for (const item of wanted) {
const name = String(item?.name ?? '').trim();
if (!isValidLibraryName(name) || name === '*' || have.has(name)) continue;
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
body: JSON.stringify({
template: LIBRARY_TEMPLATE,
owner_id: ns,
values: {
name,
display_name: String(item?.display_name ?? '').trim() || name,
description: '同步小幫手看守的資料夾',
status: 'active',
},
}),
});
if (!res.ok) throw new KbdbError(`POST /recordsportal_library)→ ${res.status}`);
have.add(name);
created.push(name);
}
const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
// t135:記下本次 daemon 回報的所有庫名(48h TTL)供 GET /portal/admin/libraries 顯示「未同步」提示。
const activeNames = wanted.map((item) => String(item?.name ?? '').trim()).filter(Boolean);
if (activeNames.length > 0) {
await c.env.WEBHOOKS.put(daemonActiveKey(c.env), JSON.stringify(activeNames), { expirationTtl: 172800 });
}
return c.json({ success: true, created, libraries: after.map(toPublicLibrary) });
}),
);
// t176t122 的 extractor_config(雲端指定地端萃取引擎)整組移除——
// interfaceKV key/讀取函式都不再需要,因為雲端已不下發、也不再有設定入口。
// ⚠️ 舊實例的 KV 殘值無害:daemon 端 t176 起也不吃這個欄位了。
// POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」
// 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)——
// 用戶不必再下載 config.json 丟隱藏資料夾,托盤第一次開啟輸入網址+帳密就上工。
// 認證=與 /portal/login 同一把(同樣吃節流與停用檢查);回傳只含連線設定,不含任何知識內容。
// t122extractor 改讀雲端設定(未設→預設 gemma;gemma+金鑰→一併下發金鑰)。
portalRouter.post('/portal/daemon/config', (c) =>
run(c, async () => {
const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string } | null;
const email = String(body?.email ?? '').trim().toLowerCase();
const password = String(body?.password ?? '');
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
if (await isLocked(c.env, email)) {
return c.json({ error: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 429);
}
// D61:同上,共用 findAndVerifyUser
const { rec, ok } = await findAndVerifyUser(c.env, email, password);
if (!rec) {
await recordLoginFail(c.env, email);
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403);
if (!ok) {
await recordLoginFail(c.env, email);
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
await clearLoginFail(c.env, email);
// t176leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
//
// 為什麼拔掉:extractor_config 這把 KV 的 key 是 `${portalTenant(env)}:portal:extractor_config`
// 而 portalTenant 是 **worker 層級**環境變數(見本檔 43 行)=**全租戶共用一把**。
// 任一處設了 claude,所有人的 daemon 都會收到 claude;沒裝 Claude Code 的機器
// 會萃取全滅,而 portal 的 Claude 勾選框又恆為 disableddaemon 從未回報 has_claude
// ⇒ 用戶自己解不開(08-03 封測實證:雲端同步成功、金鑰有效,卻零張卡)。
// leo:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」。
//
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_urlnamespacelibrary)與本 route 本身照舊,
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
// #108:這裡下發給小幫手的 namespace 決定了它把知識**寫**到哪一格。
// 以前給的是帳號層字串(CONSOLE_TENANT),與 CLIMCP 用的實例 namespace 是兩個來源
// ⇒ 寫進去的地方和讀出來的地方可以各自漂。改成同一個 knowledgeOwner,一台實例一個值。
const daemonCfg: Record<string, string> = {
cypher_url: new URL(c.req.url).origin,
namespace: knowledgeOwner(c.env),
library: 'kb',
email,
instance_name: String(rec.values.display_name ?? ''),
};
return c.json({ success: true, config: daemonCfg });
}),
);
// t176t131 這一版 `/portal/admin/ai`POST/GET**整組移除**。兩個原因:
// ① 它是**重複註冊**——本檔後段(arcrun-rag#10 那版)另有一組同路徑 route。
// Hono 先到先比 ⇒ 舊的這組一直贏,後段那組修好的「金鑰真的寫進 credentials」形同死碼。
// 這正是「key 從來沒存進去」的 bug 在 merge 後仍可能復發的原因。
// ② 它把 use_claude_for_extract 同步進 extractor_config 下發給 daemon
// syncExtractorFromAiConfig),而那把 KV 是**全租戶共用**——正是 08-03 事故根因。
// 保留的是後段那組(只管 Gemini 金鑰,走 storeCredential 唯一寫入路徑,不碰 extractor)。
// t176`POST /portal/daemon/report-capabilities` 已移除。
// 它的用途是收 daemon 回報的 has_claude 去解鎖 portal 的 Claude 勾選框;
// 但 daemon 端從未實作這個呼叫(arcrun-rag 全庫 grep = 0 命中),
// 導致 daemon_caps KV 永遠空、勾選框恆 disabled。t176 起地端模型由小幫手自己設,
// 這條回報鏈整條不需要了。
// POST /portal/admin/chat-key — body {key}。保留舊端點相容(新 UI 走 /portal/admin/ai)。
// 舊版 setup checklist / 舊 UI 仍走這裡;只更新 rag_chat workflow,不同步 ai_config。
portalRouter.post('/portal/admin/chat-key', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const body = (await c.req.json().catch(() => null)) as { key?: string } | null;
const key = String(body?.key ?? '').trim();
if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400);
const tenant = knowledgeOwner(c.env);
const kvKey = `${tenant}:wf:rag_chat`;
const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404);
let record: Record<string, unknown>;
try {
record = JSON.parse(raw) as Record<string, unknown>;
} catch {
return c.json({ error: 'AI 問答工作流記錄損壞,請重新安裝' }, 500);
}
// 結構不動、只換金鑰值:走遍 graph/config,凡 x-goog-api-key 欄一律設為新值
//(現值可能是 {{credential.gemini_api_key}} 佔位、空字串或舊 key,都直接覆蓋)。
let replaced = 0;
const visit = (o: unknown): void => {
if (Array.isArray(o)) { o.forEach(visit); return; }
if (o && typeof o === 'object') {
const rec = o as Record<string, unknown>;
for (const k of Object.keys(rec)) {
if (k.toLowerCase() === 'x-goog-api-key') { rec[k] = key; replaced += 1; }
else visit(rec[k]);
}
}
};
visit(record['graph']);
visit(record['config']);
if (replaced === 0) return c.json({ error: '工作流裡找不到金鑰欄位,請重新安裝後再試' }, 500);
await c.env.WEBHOOKS.put(kvKey, JSON.stringify(record));
return c.json({ success: true, replaced });
}),
);
// t176`POST|GET /portal/admin/extractor`t122)已移除。
// 這是「雲端指定地端萃取引擎」的舊入口,且**沒有任何伺服器端驗證**——
// 只要打這條就能把 extractor_config 設成 claude,而那把 KV 全租戶共用
// ⇒ 所有沒裝 Claude Code 的機器萃取全滅(08-03 事故)。
// 地端模型現由同步小幫手托盤「AI 設定…」自己設,雲端不再有這個概念。
// GET /portal/admin/libraries — 庫目錄列表。
// t52leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)——
// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。
// t135:讀 daemon 最近回報的 active libsKV TTL 48h),已登記的庫若不在其中標 daemon_watching:false。
portalRouter.get('/portal/admin/libraries', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
// 讀 daemon 最近回報的 active lib names(若 KV 不存在 = daemon 從未回報,不標 hint
let daemonActive: Set<string> | null = null;
try {
const raw = await c.env.WEBHOOKS.get(daemonActiveKey(c.env), 'text');
if (raw) daemonActive = new Set((JSON.parse(raw) as string[]).map((n) => String(n).trim()));
} catch { /* KV 不可達不擋主流程 */ }
const out = libs.map((rec) => {
const lib = toPublicLibrary(rec);
const watching = daemonActive === null ? undefined : daemonActive.has(lib.name);
return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) };
});
const known = new Set(out.map((l) => l.name));
// t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。
// 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。
try {
const tenant = knowledgeOwner(c.env);
const ownerParam = ownerQuery(tenant);
const [autoRes, cardRes, tripletRes] = await Promise.all([
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
kbdbFetch(c.env, `/records/triplet-stats?${ownerParam}`).catch(() => null),
]);
// 解析統計,建成 Map 供 O(1) 查找
const cardMap = new Map<string, number>();
if (cardRes?.ok) {
const body = (await cardRes.json()) as { stats?: { library: string; card_count: number }[] };
for (const s of body.stats ?? []) cardMap.set(s.library, s.card_count);
}
const tripletMap = new Map<string, number>();
if (tripletRes?.ok) {
const body = (await tripletRes.json()) as { stats?: { library: string; triplet_count: number }[] };
for (const s of body.stats ?? []) tripletMap.set(s.library, s.triplet_count);
}
// 已登記庫補入統計
for (const lib of out) {
(lib as Record<string, unknown>).card_count = cardMap.get(lib.name) ?? 0;
(lib as Record<string, unknown>).triplet_count = tripletMap.get(lib.name) ?? 0;
}
// 資料面自動出現的庫(蓋章即現身)
if (autoRes?.ok) {
const body = (await autoRes.json()) as { libraries?: string[] };
for (const name of body.libraries ?? []) {
const n = String(name ?? '').trim();
// general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉
if (!n || n === 'general' || known.has(n)) continue;
known.add(n);
const watching = daemonActive === null ? undefined : daemonActive.has(n);
out.push({
record_id: '', name: n, display_name: n,
description: '資料同步時自動出現(可在此補顯示名)',
status: 'active', graph_source: false, auto: true,
card_count: cardMap.get(n) ?? 0,
triplet_count: tripletMap.get(n) ?? 0,
...(watching !== undefined ? { daemon_watching: watching } : {}),
});
}
}
} catch {
// 資料面查不到不擋登記簿(誠實降級:至少顯示已登記的庫)
}
return c.json({ success: true, libraries: out, count: out.length });
}),
);
// t160leo 07-31:「要直通 daemon,同步,**沒有登記這回事**」):
// 人工建庫端點 POST /portal/admin/libraries 已刪——庫只從 daemon 同步自動出現
// /portal/daemon/librariest159)。現行 UIe28e190 起)本就零呼叫此端點(死端點);
// 人工登記只會製造對不上的空庫(07-27 leo 拿掉表單時已定調)。
// GET 列表與 PATCH(管理已存在的庫:改名/停用/graph_source)照舊。
// POST /portal/daemon/libraries — 小幫手(daemon)連線精靈時把看守資料夾的庫報上來自動登記。
// t1592026-07-31 leo prod 實走揪出):daemon registerLibrariesarcrun-tray main.go:505t52
// 一直在打這個端點,但 cypher 從來沒有它 ⇒ 404 被 daemon「失敗不擋連線」靜默吞掉
// ⇒ portal_library 登記簿永遠空 ⇒ portal「庫目錄管理」空(資料同步倒是全正常——
// triplet records 的 library slot 都在,病只在登記簿沒人寫)。
// 契約照 daemon 既有呼叫:body {email, password, libraries:[{name, display_name}]}。
// 帳密驗證=與 /portal/session 同一套(daemon 只在精靈那一刻拿到帳密,不存)。
// 冪等:已登記(同 name)跳過——重跑精靈不堆重複。
portalRouter.post('/portal/daemon/libraries', (c) =>
run(c, async () => {
const body = await c.req.json().catch(() => null);
const email = String(body?.email ?? '').trim().toLowerCase();
const password = String(body?.password ?? '');
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
const items = Array.isArray(body?.libraries) ? body.libraries : [];
if (items.length === 0) return c.json({ success: true, registered: [], skipped: [] });
// 帳密驗證(沿用 /portal/session 的鎖定與驗證機制)
if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429);
// D61:與 /portal/login 共用同一支查找+驗證(含「剛建好還沒傳播」的加速器重試)
const { rec, ok } = await findAndVerifyUser(c.env, email, password);
if (!rec || (rec.values.status ?? '') !== 'active' || !ok) {
await recordLoginFail(c.env, email);
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
await clearLoginFail(c.env, email);
const seeded = await ensurePortalTemplates(c.env);
if (seeded.errors.length > 0) {
return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502);
}
const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
const have = new Set(existing.map((l) => l.values.name ?? ''));
const ns = portalNamespace(c.env);
const registered: string[] = [];
const skipped: string[] = [];
for (const it of items) {
const name = String(it?.name ?? '').trim();
const displayName = String(it?.display_name ?? '').trim() || name;
if (!isValidLibraryName(name) || name === '*') { skipped.push(name || '(空)'); continue; }
if (have.has(name)) { skipped.push(name); continue; }
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
body: JSON.stringify({
template: LIBRARY_TEMPLATE,
owner_id: ns,
values: { name, display_name: displayName, description: '', status: 'active' },
}),
});
if (!res.ok) throw new KbdbError(`POST /recordsportal_librarydaemon 登記)→ ${res.status}`);
have.add(name);
registered.push(name);
}
return c.json({ success: true, registered, skipped });
}),
);
// PATCH /portal/admin/libraries/:id — 改 display_name/description/status(停用庫=翻 status slot)。
portalRouter.patch('/portal/admin/libraries/:id', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const recordId = c.req.param('id');
// 成員資格:record 必須在本實例的庫目錄列表內(庫數小,list 比對即可)
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
if (!libs.some((l) => l.record_id === recordId)) return c.json({ error: '庫不存在' }, 404);
const body = await c.req.json().catch(() => null);
if (!body) return c.json({ error: 'body 必須是 JSON' }, 400);
const patch: Record<string, string> = {};
if (body.display_name !== undefined) patch.display_name = String(body.display_name).trim();
if (body.description !== undefined) patch.description = String(body.description).trim();
if (body.status !== undefined) {
if (body.status !== 'active' && body.status !== 'disabled') {
return c.json({ error: 'status 只能是 active / disabled' }, 400);
}
patch.status = body.status;
}
// D-4(P3):標記/取消「知識圖譜來源庫」。boolean 進、slot 存 'true'/'false' 字串。
if (body.graph_source !== undefined) {
if (typeof body.graph_source !== 'boolean') {
return c.json({ error: 'graph_source 只能是 true / false' }, 400);
}
patch.graph_source = body.graph_source ? 'true' : 'false';
}
if (Object.keys(patch).length === 0) {
return c.json({ error: '沒有可更新的欄位(display_name/description/status/graph_source' }, 400);
}
const updated = await patchRecordValues(c.env, recordId, patch);
return c.json({ success: true, library: toPublicLibrary(updated) });
}),
);
// ── AI 設定(arcrun-rag#10)────────────────────────────────────────────────────
//
// 🔴 為什麼這段存在(2026-08-01 真因,別再讓它消失):
// 前端設定頁**一直**在打 `GET|POST /portal/admin/ai`,但**後端從來沒有這條 route**
// ⇒ 用戶填 Gemini key → 404 → **key 從來沒被存進任何地方**,畫面卻像存好了(藍字=假綠)。
// leo 實撞成「重裝後 key 不見」,但真相是「從來沒存進去,所以重填也沒用」。
// 產物層鐵證:bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=0。
//
// 設計約束:
// - **不另造第二套儲存**POST 內部轉呼 credentials.ts 既有的 `storeCredential()`
// (唯一寫入路徑=Workers Secret 明文 + D1 目錄列 ref)。
// - **永不回傳 key 本身**D36):GET 只回 `has_key` 布林。
// - credential 的 `api_key` 欄=租戶 slug`portalTenant`),與安裝器 seedCredential
// 寫 `kbdb_internal_token` 用的 ns 同一個值 ⇒ 兩者落在同一租戶分區,查得到彼此。
// - t176Claude 偏好(use_claude_for_extractclaude_available)已整組移除——
// 地端用哪個模型由同步小幫手自己設,雲端不再有這個概念。這裡只管 Gemini 金鑰。
// GET /portal/admin/ai — 回 AI 設定現況(role=admin 閘)。**只回 has_key 布林,永不回 key**。
portalRouter.get('/portal/admin/ai', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const tenantSlug = portalTenant(c.env);
let hasKey = false;
try {
// D38 圍牆修復(2026-08-07):改走 credentials.ts 的 KBDB 目錄查詢,不直連 D1。
hasKey = await hasCredential(c.env, tenantSlug, 'gemini_api_key');
} catch {
// KBDB 不可達 ⇒ 當作沒設定(不擋頁面),但也不假裝有
hasKey = false;
}
// t176:不再有 claude_availableuse_claude_for_extract——地端用哪個模型
// 由同步小幫手自己設,雲端不介入(leo 08-03)。
return c.json({ success: true, has_key: hasKey });
}),
);
// GET /portal/admin/execution-log-retention — 讀本實例的執行紀錄保留期設定(P7,role=admin 閘)。
// retention_days: number=自訂天數;null=已設「不刪除」(企業稽核);未設定過的租戶也回一個值
// (KBDB 端會退回預設 90 天,見 kbdb/src/actions/execution-log.ts DEFAULT_RETENTION_DAYS)。
portalRouter.get('/portal/admin/execution-log-retention', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
if (!res.ok) throw new KbdbError(`GET /execution-log/retention → ${res.status}`);
const data = (await res.json()) as { retention_days?: number | null; default_days?: number };
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
}),
);
// PUT /portal/admin/execution-log-retention — 設定保留天數(P7role=admin 閘)。
// body: { retention_days: number|null }。null=不刪除(leo 08-07:「我願意花很多錢保存,
// 不要刪除」,這是稽核用途的付費理由,不是成本負擔);正整數=自訂天數,覆蓋預設 90 天。
portalRouter.put('/portal/admin/execution-log-retention', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const body = (await c.req.json().catch(() => null)) as { retention_days?: number | null } | null;
const days = body?.retention_days;
if (days !== null && days !== undefined && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) {
return c.json({ error: 'retention_days 必須是正整數,或 null(代表不刪除)' }, 400);
}
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, '/execution-log/retention', {
method: 'PUT',
body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === undefined ? null : days }),
});
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention → ${res.status}`);
const data = (await res.json()) as { retention_days?: number | null };
return c.json({ success: true, retention_days: data.retention_days ?? null });
}),
);
// DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫(只有資料章記、無登記簿 record)。
// 語意:把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。
// ⚠️ 影響資料可搜性,要求 body.confirm 等於庫名才執行(二次確認)。
// ⚠️ 此路由必須在 DELETE /:id 之前宣告(Hono 先到先比;by-name 否則被當成 :id)。
portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const name = decodeURIComponent(c.req.param('name'));
const body = await c.req.json().catch(() => null);
const confirm = String(body?.confirm ?? '').trim();
if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400);
if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400);
const ownerId = knowledgeOwner(c.env);
const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', {
method: 'PATCH',
body: JSON.stringify({ owner_id: ownerField(ownerId), library: name }),
});
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`);
const data = (await res.json()) as { deprecated_count?: number };
return c.json({
success: true,
deprecated_count: data.deprecated_count ?? 0,
message: `已從自動清單移除「${name}」(共標記 ${data.deprecated_count ?? 0} 筆資料不可搜)。資料保留可還原——重新同步時會再出現。`,
});
}),
);
// POST /portal/admin/ai — 存 Gemini keyrole=admin 閘)。body: { gemini_api_key: string }
// t176:Claude 偏好欄位已移除(地端模型由同步小幫手自己設,雲端不下發)。
portalRouter.post('/portal/admin/ai', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const body = await c.req.json().catch(() => null) as { gemini_api_key?: unknown } | null;
const rawKey = typeof body?.gemini_api_key === 'string' ? body.gemini_api_key.trim() : '';
if (!rawKey) {
return c.json({ error: '沒有要變更的項目(金鑰留空)' }, 400);
}
const tenantSlug = portalTenant(c.env);
try {
// 唯一寫入路徑(credentials.ts):Workers Secret 存值 + D1 存 ref。
await storeCredential(c.env, tenantSlug, 'gemini_api_key', rawKey, 'gemini');
} catch (e) {
// 誠實回報寫入失敗——這正是本 bug 的教訓:不能讓前端以為存好了。
return c.json(
{ error: `金鑰儲存失敗:${e instanceof Error ? e.message : String(e)}` },
502,
);
}
return c.json({ success: true, has_key: true });
}),
);
// DELETE /portal/admin/libraries/:id — 移除已登記庫(有 record_id 的登記簿 record)。
// 只刪登記簿那筆 record;知識資料(entries with library=name)完全不動。
// 資料若有的話,重新同步後會以 auto 庫重新出現。
portalRouter.delete('/portal/admin/libraries/:id', (c) =>
run(c, async () => {
const auth = await requirePortalAdmin(c);
if (!auth.ok) return auth.res;
const recordId = c.req.param('id');
// 成員資格驗(防憑空 id 打到不相干 record)
const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
const target = libs.find((l) => l.record_id === recordId);
if (!target) return c.json({ error: '庫不存在' }, 404);
const found = await deleteKbdbRecord(c.env, recordId);
if (!found) return c.json({ error: '庫不存在' }, 404);
return c.json({
success: true,
name: target.values.name ?? '',
message: `已從目錄移除「${target.values.display_name ?? target.values.name ?? ''}」。資料仍在,重新同步會再出現。`,
});
}),
);
// ── 檢修孔核心邏輯(t213InkStoneCo 總管交辦,2026-08-08)──────────────────────
//
// buildDiagnostics 是 GET /portal/data/diagnosticsP3 session 版,portal 網頁「疑難排解」
// 按鈕)與 GET /portal/daemon/diagnostics(下方新增,daemon 免帳密版)共用的**唯一**實作
// (薄殼原則 rule 07:能力只放一處)。原本整段邏輯躺在 portal-data.ts 的 handler 裡;
// daemon 是背景常駐行程、沒有 portal session(密碼只在連線精靈當下用過就丟,見
// connect.go 註解),構不到 session 版端點,需要一支 X-Arcrun-API-Key 版本——抽出來讓
// 兩條路由共用同一份查詢邏輯,不是各寫一份、日後各自漂移。
//
// 涵蓋「這次一定要涵蓋」的向量/embedding 健康狀態:embed 模組是否開(index 存在的前提)、
// 已嵌入/待嵌入卡片數、以及 embedSelfTestKBDB #12)——這是唯一能分辨「從沒嵌過」與
// 「嵌了但 index 查不到自己」兩種故障模式的方法(Arcrun#11 的真實案例正是後者,光看
// 計數看不出來)。
//
// 🔴 兩條紅線(規格原文,2026-08-07 leo 直接指令):
// ① 不准把內部概念暴露給用戶——本函式只回統計/狀態,呼叫端文案不提 KBDB/Vectorize/
// owner_id 這類詞。
// ② 不准洩漏知識卡內容本體——以下每一個欄位都只挑「數字」或「布林」,即使背後的 KBDB
// 端點回應含 content(如 triplet 的 subject/object 名稱),本函式一律只讀出用得到
// 的數字後就丟掉那個回應,不把原始內容往呼叫端送。
export interface DiagnosticsCore {
library_count: number;
triplet_count: number;
library_scope_check: Record<string, unknown>;
embedding: Record<string, unknown>;
notes: string[];
}
/**
* tenantowner_idsession 版傳 `knowledgeOwner(env)`daemon 版傳 `tenantFromApiKey(header)`)。
* #108:型別收成 `TenantId`——診斷檔要是報了另一個命名空間的統計,等於用假數字排查真問題。
*/
export async function buildDiagnostics(env: Bindings, tenant: TenantId): Promise<DiagnosticsCore> {
const notes: string[] = [];
// ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。
let embedding: Record<string, unknown> = { checked: false };
try {
const [statusRes, selftestRes] = await Promise.all([
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant)}`),
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant)}`),
]);
const statusBody = (await statusRes.json().catch(() => null)) as
| { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
| null;
const selftestBody = (await selftestRes.json().catch(() => null)) as
| { success?: boolean; enabled?: boolean; tested?: boolean; passed?: boolean | null; note?: string }
| null;
embedding = {
checked: true,
module_enabled: statusBody?.enabled ?? false, // Vectorize+AI binding 都在,才有「index」這回事
cards_embedded: statusBody?.embedded ?? 0,
cards_pending: statusBody?.pending ?? 0,
self_test: {
ran: selftestBody?.tested ?? false,
// 三態:true=能搜到自己 false=搜不到自己(index 收錄有缺)/ null=還沒東西可測或模組未開
found_itself: selftestBody?.tested ? (selftestBody?.passed ?? null) : null,
note: selftestBody?.note ?? '',
},
};
} catch (e) {
notes.push(`embed 健康狀態查詢失敗:${e instanceof Error ? e.message : String(e)}`);
}
// ② 卡片與知識圖譜規模(只取數字,不取內容欄位)。
//
// 改走與 /portal/admin/libraries 相同、驗證過在用的**即時查詢**組合(不依賴 library_map
// 快取——2026-08-08 曾實測快取恆回 0,根因與修正過程見 commit 7dbd4f5,此處不重貼一次
// 避免兩處各改各的漂移):
// - listRecordsByTemplate(portal_library):已登記的庫(t159
// - GET /entries/libraries:資料裡實際蓋章出現過的庫,登記與否都算(t52,
// 「蓋章即現身」);'general' 是未標庫的系統 fallback 桶,不算使用者眼中的一個庫,
// 與 admin/libraries 同慣例排除。
// - GET /records/triplet-statsper-library 即時聚合 SQLt142COUNT,非快取)。
let library_count = 0;
let triplet_count = 0;
const ownerParam = ownerQuery(tenant);
try {
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
kbdbFetch(env, `/entries/libraries?${ownerParam}`),
kbdbFetch(env, `/records/triplet-stats?${ownerParam}`),
]);
const knownLibs = new Set(
registeredLibs.map((r) => (r.values.name ?? '').trim()).filter((n): n is string => !!n),
);
const autoBody = (await autoRes.json().catch(() => null)) as { success?: boolean; libraries?: string[] } | null;
for (const name of autoBody?.libraries ?? []) {
const n = String(name ?? '').trim();
if (n && n !== 'general') knownLibs.add(n);
}
library_count = knownLibs.size;
const tripletBody = (await tripletRes.json().catch(() => null)) as
| { success?: boolean; stats?: { library: string; triplet_count?: number }[] }
| null;
triplet_count = (tripletBody?.stats ?? []).reduce((sum, s) => sum + (Number(s.triplet_count) || 0), 0);
} catch (e) {
notes.push(`知識庫規模查詢失敗:${e instanceof Error ? e.message : String(e)}`);
}
// ②.5 統計自我檢查(呼應上面 embedding.self_test 的精神——leo 直接指令:「不要讓『查不到』
// 和『沒有』長得一樣」)。library_counttriplet_count 兩者都是 0 時,才另外花一次查詢,
// 用完全不同的路徑(不分庫、不分模板,只問「這個 owner_id 底下到底有沒有任何 entries」)
// 做交叉驗證——如果探測到有資料,代表問題出在查詢方式或 owner_id 對不上(2026-08-01
// t161 前科:手動補的 record owner_id 存成 Nonekbdb_query 全量查得到、按 owner_id 過濾
// 的畫面永遠空,比真的沒資料更難查);如果探測也是空,才比較像真的是空庫。
let library_scope_check: Record<string, unknown> = { ran: false };
if (library_count === 0 && triplet_count === 0) {
try {
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant), limit: '1' }).toString()}`);
const probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null;
const total = probeBody?.total ?? 0;
library_scope_check = {
ran: true,
any_entries_found: total > 0,
note:
total > 0
? `這個租戶底下查得到其他資料(entries 共 ${total} 筆),但庫/三元組統計仍回 0——像是查詢方式或租戶對不上,不像真的沒資料,需要人再查一次`
: '這個租戶底下完全查不到任何資料——比較像是真的還沒有資料,不是查詢方式錯了',
};
} catch (e) {
library_scope_check = {
ran: true,
any_entries_found: null,
note: `自我探測查詢本身失敗:${e instanceof Error ? e.message : String(e)}`,
};
}
}
// ③ 最近一次萃取(daemon → /portal/daemon/extract)成功與否:目前沒有雲端側的失敗歷史
// 記錄可讀(該端點是同步請求/回應,失敗只回給呼叫當下的 daemon,雲端不落地保存)——
// 這個缺口不在這裡假裝補上一句話,本機那半(manifest 的 LastError 分類統計,t213 phase 2
// arcrun-app 那半)才是真正能答這題的地方;本函式維持誠實:知道多少答多少,不摻水。
return { library_count, triplet_count, library_scope_check, embedding, notes };
}
// GET /portal/daemon/diagnostics — 檢修孔的 daemon 版(t213 matrix/arcrun 半部,InkStoneCo
// 總管交辦,2026-08-08)。讓 arcrun-appdaemon 桌面殼)能免帳密拿到雲端這半的診斷數字,
// 跟本機那半(manifest 總量/失敗分類統計/daemon 版本與自我更新狀態,見 products/arcrun-rag
// repo t213 phase 2 的設計)合併成一份完整診斷檔——本端點純粹只負責雲端能看到的那半。
//
// 認證比照既有 /portal/daemon/extractX-Arcrun-API-Key,不是 session):daemon 是背景
// 常駐行程,使用者的密碼只在連線精靈當下用過就丟、不落地(見 collector/cmd/arcrun-app/
// connect.go 註解),背景查詢沒有密碼可用。
//
// apiKey 當 tenantowner_id 用,**不與 portalTenant(env) 比對**——t189 教訓:daemon 的
// api_key 不保證等於這個 worker 的 CONSOLE_TENANT(多帳號情境下曾對不上,見上方
// /portal/daemon/extract 的 t189 註解),「有帶 key 即可」是本 repo 對 X-Arcrun-API-Key
// 的既有慣例(見 webhooks-named.ts 的 owner_id: apiKey 用法)。
//
// 邏輯與 /portal/data/diagnosticsportal-data.ts)共用同一個上面的 buildDiagnostics()——
// 薄殼原則:能力只實作一次;兩條路由只是認證層不同,回應形狀完全一致,不改既有行為。
portalRouter.get('/portal/daemon/diagnostics', (c) =>
run(c, async () => {
const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
// 這條路的租戶來自**請求本身**(小幫手帶的 namespace),不是環境變數 → 沒有 #108 的漂移問題。
const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
return c.json({
generated_at: new Date().toISOString(),
instance_url: new URL(c.req.url).origin,
bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null,
...core,
});
}),
);