b223a69884
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>
2015 lines
104 KiB
TypeScript
2015 lines
104 KiB
TypeScript
/**
|
||
* RAG Portal 多人授權 — P2:用戶模型+認證 API(portal-auth design §2/§4/§5,Gitea #24/#25)
|
||
*
|
||
* 架構(design D-1):Portal=cypher-executor 的 /portal 路由(非獨立 worker)。
|
||
* 本檔只做 P2 的 auth/admin API;P3(/portal HTML 殼+ /portal/data/* enforce)另一波。
|
||
*
|
||
* 鐵律對照:
|
||
* - rule 2.1/2.2:這是 UI session 登入(console-auth 同類先例),非 workflow credential
|
||
* 原語。KDF 在 lib/portal-auth.ts(WebCrypto PBKDF2),本檔無解密/簽章/template 展開。
|
||
* - 零新表(design §2):portal_user / portal_library 都是 KBDB 萬用表 template;
|
||
* 資料經 KBDB base HTTP API 寫入(kbdbBase 慣例,不直連 D1、不寫 SQL)。
|
||
* - 子 namespace(design D-2):一切帳號資料 owner_id=`{CONSOLE_TENANT}::portal`。
|
||
* 既有租戶查詢面(/kbdb/*、MCP)都以 CONSOLE_TENANT 過濾 → 物理上搜不到帳號資料
|
||
* (email/password_hash 不會出現在知識搜尋結果)。
|
||
* - session(design §4.3):KV `portal_sess:{token}` 只存 record_id(TTL 暫存=合規),
|
||
* **每個請求回讀 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),
|
||
// 知識資料面用 knowledgeOwner(TenantId)——型別分家,拿錯編不過。
|
||
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 Secret,D1 只留 ref)。
|
||
import { storeCredential, hasCredential } from './credentials';
|
||
// D61(Leo/arcrun-rag#55,ADR D61):**帳號不再住知識資料庫**。
|
||
// 讀寫一律先走 lib/portal-auth-store(CF 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.3:5 次失敗
|
||
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_id,Arcrun#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);
|
||
}
|
||
|
||
/** 帳號子 namespace(design 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_id(design §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 names(t135 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 順手刪 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> {
|
||
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_id(design §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.3:portal_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_allowed(graph 模式要不要顯示)、
|
||
// 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/#25(2026-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-7:owner secret 是
|
||
// 安裝期人閘,不引入新 secret、不開放無閘註冊)。建第一個 role=admin 的 portal_user;
|
||
// 已有 admin → 409 拒絕重複 bootstrap。順手冪等確保 templates(seed 是 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: '已有 admin,bootstrap 只能執行一次;後續帳號請用 /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 session,design 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/libraries(design §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?}]}。
|
||
// t52(leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵」):
|
||
// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
|
||
// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
|
||
// POST /portal/daemon/extract — 小幫手把「已轉成純文字的原稿」送上來,雲端用 Workers AI 萃成知識卡。
|
||
// body {email, password, page_name, text}。認證同 /portal/daemon/config(帳密)。
|
||
//
|
||
// 🔴 t181(leo 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 送卡片上雲時本來就帶這個 header(collector/direct.go:355),沿用同一把最自然。
|
||
portalRouter.post('/portal/daemon/extract', (c) =>
|
||
run(c, async () => {
|
||
// 🔴 t189(leo 08-04 實撞:geek6688 萃取回 401「X-Arcrun-API-Key 不正確」):
|
||
//
|
||
// t181 那一輪我改成 `apiKey !== portalTenant(c.env)` 就 401,
|
||
// **但那假設了「daemon 的 api_key = 實例的 CONSOLE_TENANT」——這個假設是錯的**:
|
||
// geek6688:實例 tenant = ckxt8yr9,daemon 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 AI(wrangler.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} 實體B(3-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 /records(portal_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) });
|
||
}),
|
||
);
|
||
|
||
// t176:t122 的 extractor_config(雲端指定地端萃取引擎)整組移除——
|
||
// interface/KV key/讀取函式都不再需要,因為雲端已不下發、也不再有設定入口。
|
||
// ⚠️ 舊實例的 KV 殘值無害:daemon 端 t176 起也不吃這個欄位了。
|
||
|
||
// POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」
|
||
// 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)——
|
||
// 用戶不必再下載 config.json 丟隱藏資料夾,托盤第一次開啟輸入網址+帳密就上工。
|
||
// 認證=與 /portal/login 同一把(同樣吃節流與停用檢查);回傳只含連線設定,不含任何知識內容。
|
||
// t122:extractor 改讀雲端設定(未設→預設 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);
|
||
// t176(leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
|
||
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
|
||
//
|
||
// 為什麼拔掉:extractor_config 這把 KV 的 key 是 `${portalTenant(env)}:portal:extractor_config`,
|
||
// 而 portalTenant 是 **worker 層級**環境變數(見本檔 43 行)=**全租戶共用一把**。
|
||
// 任一處設了 claude,所有人的 daemon 都會收到 claude;沒裝 Claude Code 的機器
|
||
// 會萃取全滅,而 portal 的 Claude 勾選框又恆為 disabled(daemon 從未回報 has_claude)
|
||
// ⇒ 用戶自己解不開(08-03 封測實證:雲端同步成功、金鑰有效,卻零張卡)。
|
||
// leo:「地端要用什麼模型就在 daemon 上輸入 API Key 設置,而不是雲端設置後控制地端」。
|
||
//
|
||
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_url/namespace/library)與本 route 本身照舊,
|
||
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
|
||
// #108:這裡下發給小幫手的 namespace 決定了它把知識**寫**到哪一格。
|
||
// 以前給的是帳號層字串(CONSOLE_TENANT),與 CLI/MCP 用的實例 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 });
|
||
}),
|
||
);
|
||
|
||
// t176:t131 這一版 `/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 — 庫目錄列表。
|
||
// t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」):
|
||
// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)——
|
||
// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。
|
||
// t135:讀 daemon 最近回報的 active libs(KV 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 });
|
||
}),
|
||
);
|
||
|
||
// t160(leo 07-31:「要直通 daemon,同步,**沒有登記這回事**」):
|
||
// 人工建庫端點 POST /portal/admin/libraries 已刪——庫只從 daemon 同步自動出現
|
||
// (/portal/daemon/libraries,t159)。現行 UI(e28e190 起)本就零呼叫此端點(死端點);
|
||
// 人工登記只會製造對不上的空庫(07-27 leo 拿掉表單時已定調)。
|
||
// GET 列表與 PATCH(管理已存在的庫:改名/停用/graph_source)照舊。
|
||
|
||
// POST /portal/daemon/libraries — 小幫手(daemon)連線精靈時把看守資料夾的庫報上來自動登記。
|
||
// t159(2026-07-31 leo prod 實走揪出):daemon registerLibraries(arcrun-tray main.go:505,t52)
|
||
// 一直在打這個端點,但 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 /records(portal_library,daemon 登記)→ ${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 同一個值 ⇒ 兩者落在同一租戶分區,查得到彼此。
|
||
// - t176:Claude 偏好(use_claude_for_extract/claude_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_available/use_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 — 設定保留天數(P7,role=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 key(role=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 ?? ''}」。資料仍在,重新同步會再出現。`,
|
||
});
|
||
}),
|
||
);
|
||
|
||
// ── 檢修孔核心邏輯(t213,InkStoneCo 總管交辦,2026-08-08)──────────────────────
|
||
//
|
||
// buildDiagnostics 是 GET /portal/data/diagnostics(P3 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 存在的前提)、
|
||
// 已嵌入/待嵌入卡片數、以及 embedSelfTest(KBDB #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[];
|
||
}
|
||
|
||
/**
|
||
* tenant=owner_id(session 版傳 `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-stats:per-library 即時聚合 SQL(t142,COUNT,非快取)。
|
||
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_count/triplet_count 兩者都是 0 時,才另外花一次查詢,
|
||
// 用完全不同的路徑(不分庫、不分模板,只問「這個 owner_id 底下到底有沒有任何 entries」)
|
||
// 做交叉驗證——如果探測到有資料,代表問題出在查詢方式或 owner_id 對不上(2026-08-01
|
||
// t161 前科:手動補的 record owner_id 存成 None,kbdb_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-app(daemon 桌面殼)能免帳密拿到雲端這半的診斷數字,
|
||
// 跟本機那半(manifest 總量/失敗分類統計/daemon 版本與自我更新狀態,見 products/arcrun-rag
|
||
// repo t213 phase 2 的設計)合併成一份完整診斷檔——本端點純粹只負責雲端能看到的那半。
|
||
//
|
||
// 認證比照既有 /portal/daemon/extract(X-Arcrun-API-Key,不是 session):daemon 是背景
|
||
// 常駐行程,使用者的密碼只在連線精靈當下用過就丟、不落地(見 collector/cmd/arcrun-app/
|
||
// connect.go 註解),背景查詢沒有密碼可用。
|
||
//
|
||
// apiKey 當 tenant/owner_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/diagnostics(portal-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,
|
||
});
|
||
}),
|
||
);
|