diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 5fa1148..d839da5 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -746,6 +746,64 @@ portalRouter.post('/portal/admin/libraries', (c) => }), ); +// 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); + const recordId = await findUserRecordId(c.env, email); + const rec = recordId ? await getRecordById(c.env, recordId) : null; + if (!rec || (rec.values.status ?? '') !== 'active' + || !(await verifyPassword(password, rec.values.password_hash ?? ''))) { + 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 () => {