diff --git a/cypher-executor/src/index.ts b/cypher-executor/src/index.ts index 6cd92e4..1118afe 100644 --- a/cypher-executor/src/index.ts +++ b/cypher-executor/src/index.ts @@ -22,6 +22,7 @@ import { executionsRouter } from './routes/executions'; import { initSeedRouter } from './routes/init-seed'; import { kbdbProxyRouter } from './routes/kbdb-proxy'; import { consoleRouter } from './routes/console'; +import { consoleAuthRouter } from './routes/console-auth'; const app = new Hono<{ Bindings: Bindings }>(); @@ -52,6 +53,7 @@ app.route('/', executionsRouter); // LI SDD M2.1: /executions/* + /workflows/:n app.route('/', initSeedRouter); // 薄殼原則:seed recipe 是 API 行為(rule 07,壓測 §4.1) app.route('/', kbdbProxyRouter); // kbdb-base 9.5:KBDB 資料層 proxy(讓 CLI 透過 cypher 達 KBDB,純轉發) app.route('/', consoleRouter); // Arcrun#3:搜尋/控制台頁 v0(單檔 HTML+原生 JS,薄殼) +app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單 email+password 登入(單一管理員帳密,非多租戶) // Worker 導出(fetch + scheduled) // scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick; diff --git a/cypher-executor/src/routes/console-auth.ts b/cypher-executor/src/routes/console-auth.ts new file mode 100644 index 0000000..690e6d7 --- /dev/null +++ b/cypher-executor/src/routes/console-auth.ts @@ -0,0 +1,154 @@ +/** + * arcrun console 登入(Arcrun#3 發現②,2026-07-03) + * + * 背景:console v0 那格叫「API Key」但 self-hosted 單租戶下其實只是 namespace 明碼字串, + * 不是註冊制 key(leo 原話:理論上根本沒有 API Key 這件事)。leo 拍板: + * 換成簡單 email+password 登入頁(自己設一組帳密即可,不用第三方 OAuth 或高級機制), + * 登入成功後端發 session token 存 localStorage;**後端 API 呼叫仍是用固定租戶字串打 + * KBDB**(登入系統只是擋外人看到頁面,不是要做多租戶)。 + * + * 與 routes/auth.ts 的差異:auth.ts 是官方 SaaS 的 Google/GitHub OAuth 多租戶註冊(每個 + * 使用者各自一把 ak_... api_key,各自一個租戶)。這裡是 self-hosted console 的「單一管理員 + * 帳密」— 全站只有一組帳密,只為擋外人看頁面,不產生新租戶、不核發 API key。 + * + * 帳密怎麼設(不是雲端工人幫 leo 決定密碼):首次造訪 /console 時若尚未設定過,前端會走 + * 「首次設定」流程(POST /console/setup)——leo 自己在瀏覽器輸入 email/password,一次性寫入 + * SESSIONS_KV `console:credentials`(已存在就 409,不能覆蓋,換帳密走 /console/setup/reset + * 需帶舊密碼)。之後才是一般登入(POST /console/login)。 + * + * 固定租戶字串:CONSOLE_TENANT([vars],非機密——self-hosted 架構本就是明碼 namespace)。 + * 預設 "leo"(Arcrun#3 發現①已核實:owner_id='leo' 是 D1 中 458,357 筆資料實際使用的租戶字串, + * ak_... 只有 2 筆孤兒資料,故統一收斂到 'leo',不製造第三個租戶)。 + */ +import { Hono } from 'hono'; +import type { Bindings } from '../types'; + +export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>(); + +const CREDS_KEY = 'console:credentials'; +const SESSION_PREFIX = 'console_sess:'; +const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 天 + +interface StoredCredentials { + email: string; + salt: string; // hex + hash: string; // hex,sha256(salt + password) 迭代 3 次 + created_at: string; +} + +function randomHex(bytes: number): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + return Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input); + const digest = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** 簡易迭代雜湊(非 bcrypt/argon2,但比單輪 SHA-256 好一點;self-hosted 單管理員帳密,威脅模型輕)。 */ +async function hashPassword(password: string, salt: string): Promise { + let h = `${salt}:${password}`; + for (let i = 0; i < 3; i++) h = await sha256Hex(h); + return h; +} + +function tenantOf(c: { env: Bindings }): string { + return c.env.CONSOLE_TENANT || 'leo'; +} + +// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。 +consoleAuthRouter.get('/console/auth-status', async (c) => { + const existing = await c.env.SESSIONS_KV.get(CREDS_KEY); + return c.json({ configured: !!existing }); +}); + +// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。 +consoleAuthRouter.post('/console/setup', async (c) => { + const existing = await c.env.SESSIONS_KV.get(CREDS_KEY); + if (existing) return c.json({ error: '已設定過帳密,請改用登入;要換帳密請用 /console/setup/reset(需舊密碼)' }, 409); + + const body = await c.req.json().catch(() => null); + const email = (body?.email ?? '').trim(); + const password = body?.password ?? ''; + if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); + if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400); + + const salt = randomHex(16); + const hash = await hashPassword(password, salt); + const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() }; + await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record)); + + const token = randomHex(32); + await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), { + expirationTtl: SESSION_TTL_SECONDS, + }); + return c.json({ success: true, session_token: token, tenant: tenantOf(c) }); +}); + +// POST /console/setup/reset — 換帳密(body: {current_password, email, password})。需驗舊密碼,防外人重設。 +consoleAuthRouter.post('/console/setup/reset', async (c) => { + const raw = await c.env.SESSIONS_KV.get(CREDS_KEY); + if (!raw) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400); + const existing = JSON.parse(raw) as StoredCredentials; + + const body = await c.req.json().catch(() => null); + const currentPassword = body?.current_password ?? ''; + const email = (body?.email ?? '').trim(); + const password = body?.password ?? ''; + if (!currentPassword || !email || !password) return c.json({ error: 'current_password、email、password 必填' }, 400); + if (password.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400); + + const currentHash = await hashPassword(currentPassword, existing.salt); + if (currentHash !== existing.hash) return c.json({ error: '舊密碼不正確' }, 401); + + const salt = randomHex(16); + const hash = await hashPassword(password, salt); + const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: existing.created_at }; + await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record)); + return c.json({ success: true }); +}); + +// POST /console/login — body: {email, password}。成功 → session token(localStorage 存這個,不存密碼)。 +consoleAuthRouter.post('/console/login', async (c) => { + const raw = await c.env.SESSIONS_KV.get(CREDS_KEY); + if (!raw) return c.json({ error: '尚未設定帳密,請先完成首次設定' }, 400); + const existing = JSON.parse(raw) as StoredCredentials; + + const body = await c.req.json().catch(() => null); + const email = (body?.email ?? '').trim().toLowerCase(); + const password = body?.password ?? ''; + if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); + + const hash = await hashPassword(password, existing.salt); + if (email !== existing.email || hash !== existing.hash) { + return c.json({ error: 'email 或密碼錯誤' }, 401); + } + + const token = randomHex(32); + await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), { + expirationTtl: SESSION_TTL_SECONDS, + }); + return c.json({ success: true, session_token: token, tenant: tenantOf(c) }); +}); + +// GET /console/session — Authorization: Bearer 。前端載入頁面時用來確認 session 還有效 +// + 拿回固定租戶字串(不必再手貼 API Key)。 +consoleAuthRouter.get('/console/session', async (c) => { + const auth = c.req.header('authorization') ?? ''; + const token = auth.match(/^Bearer\s+(\S+)/i)?.[1]; + if (!token) return c.json({ valid: false }, 401); + const sess = await c.env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`); + if (!sess) return c.json({ valid: false }, 401); + return c.json({ valid: true, tenant: tenantOf(c) }); +}); + +// POST /console/logout — Authorization: Bearer 。 +consoleAuthRouter.post('/console/logout', async (c) => { + const auth = c.req.header('authorization') ?? ''; + const token = auth.match(/^Bearer\s+(\S+)/i)?.[1]; + if (token) await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); + return c.json({ success: true }); +}); diff --git a/cypher-executor/src/routes/console.ts b/cypher-executor/src/routes/console.ts index 45dddb9..9214459 100644 --- a/cypher-executor/src/routes/console.ts +++ b/cypher-executor/src/routes/console.ts @@ -14,8 +14,12 @@ * 不新增 binding;CORS 該 worker已開;不需 auth。 * recipes 打同源 GET /public-recipes(recipes.ts,公庫,不需 auth) * - * 認證:頁面請用戶貼一次 X-Arcrun-API-Key(self-hosted=namespace 明碼),存 localStorage。 - * knowledge/workflows 兩區需要;components/recipes 是公開資料,不需要。 + * 認證(v1,Arcrun#3 發現②改版):不再讓使用者貼 API Key(self-hosted 單租戶下那格本來就只是 + * namespace 明碼字串,不是註冊制 key——leo 原話:理論上根本沒有 API Key 這件事)。改用簡單 + * email+password 登入頁(routes/console-auth.ts,自己設一組帳密,無第三方 OAuth)。登入成功後端 + * 發 session token 存 localStorage;**實際打 /kbdb/*、/workflows/search 仍用固定租戶字串** + * (後端回應帶的 tenant,來自 CONSOLE_TENANT,登入系統只擋外人看頁面,不做多租戶)。 + * knowledge/workflows 兩區需要登入;components/recipes 是公開資料,不需要。 * * config 區(v0 唯讀):vectorize 狀態不是新端點——直接讀「知識庫」查詢回應本身的 mode/ * capability_hint(kbdb entries.ts 既有機制:要求 semantic、缺 Vectorize 就誠實降級並帶 hint)。 @@ -68,12 +72,35 @@ function renderConsoleHtml(registryBase: string): string {
-

① API Key

-
- - +

① 登入

+
檢查登入狀態中...
+ + + + + + -
存在瀏覽器 localStorage,不送去別的地方。知識庫/workflows 查詢需要它;components/recipes 是公庫資料不用。
@@ -115,15 +142,97 @@ function renderConsoleHtml(registryBase: string): string { const REGISTRY_BASE = ${JSON.stringify(registryBase)}; const $ = (id) => document.getElementById(id); - function getKey() { return localStorage.getItem('arcrun_console_api_key') || ''; } - function setKey(v) { localStorage.setItem('arcrun_console_api_key', v); } + // ── 登入狀態(Arcrun#3 發現②:session token 只擋頁面,實際查詢用後端回的固定租戶字串)── + let currentTenant = ''; // 登入成功後才有值;查詢函式用它當 X-Arcrun-API-Key + function getSessionToken() { return localStorage.getItem('arcrun_console_session') || ''; } + function setSessionToken(v) { localStorage.setItem('arcrun_console_session', v); } + function clearSessionToken() { localStorage.removeItem('arcrun_console_session'); } + function getKey() { return currentTenant; } // 給 searchKb/searchWorkflows 沿用既有介面 - $('api-key').value = getKey(); - $('save-key').addEventListener('click', () => { - setKey($('api-key').value.trim()); - $('api-key').placeholder = '已存'; + function showAuthPanel(which) { + ['auth-loading', 'auth-setup-form', 'auth-login-form', 'auth-authed'].forEach((id) => { + $(id).style.display = id === which ? '' : 'none'; + }); + } + + async function checkAuthStatus() { + const token = getSessionToken(); + if (token) { + try { + const res = await fetch('/console/session', { headers: { Authorization: 'Bearer ' + token } }); + if (res.ok) { + const data = await res.json(); + currentTenant = data.tenant || ''; + $('authed-tenant').textContent = '(租戶:' + currentTenant + ')'; + showAuthPanel('auth-authed'); + return; + } + } catch (e) { /* fall through to login */ } + clearSessionToken(); + } + try { + const res = await fetch('/console/auth-status'); + const data = await res.json(); + showAuthPanel(data.configured ? 'auth-login-form' : 'auth-setup-form'); + } catch (e) { + showAuthPanel('auth-login-form'); + } + } + + $('setup-submit').addEventListener('click', async () => { + const email = $('setup-email').value.trim(); + const password = $('setup-password').value; + $('setup-status').textContent = '設定中...'; + try { + const res = await fetch('/console/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const data = await res.json(); + if (!res.ok) { $('setup-status').textContent = data.error || '設定失敗'; return; } + setSessionToken(data.session_token); + currentTenant = data.tenant || ''; + $('authed-tenant').textContent = '(租戶:' + currentTenant + ')'; + showAuthPanel('auth-authed'); + } catch (e) { + $('setup-status').textContent = '請求失敗:' + e.message; + } }); + $('login-submit').addEventListener('click', async () => { + const email = $('login-email').value.trim(); + const password = $('login-password').value; + $('login-status').textContent = '登入中...'; + try { + const res = await fetch('/console/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const data = await res.json(); + if (!res.ok) { $('login-status').textContent = data.error || '登入失敗'; return; } + setSessionToken(data.session_token); + currentTenant = data.tenant || ''; + $('authed-tenant').textContent = '(租戶:' + currentTenant + ')'; + showAuthPanel('auth-authed'); + } catch (e) { + $('login-status').textContent = '請求失敗:' + e.message; + } + }); + + $('logout-btn').addEventListener('click', async () => { + const token = getSessionToken(); + if (token) { + try { await fetch('/console/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + token } }); } catch (e) {} + } + clearSessionToken(); + currentTenant = ''; + checkAuthStatus(); + }); + + checkAuthStatus(); + function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } @@ -160,7 +269,7 @@ function renderConsoleHtml(registryBase: string): string { const ul = $('kb-results'); if (!q) { statusEl.textContent = '請輸入查詢字'; return; } const key = getKey(); - if (!key) { statusEl.textContent = '請先貼 API Key(上方 ①)'; return; } + if (!key) { statusEl.textContent = '請先登入(上方 ①)'; return; } statusEl.textContent = '查詢中...'; try { const res = await fetch('/kbdb/search?q=' + encodeURIComponent(q) + '&mode=semantic', { @@ -191,7 +300,7 @@ function renderConsoleHtml(registryBase: string): string { const ul = $('wf-results'); if (!q) { statusEl.textContent = '請輸入查詢字'; return; } const key = getKey(); - if (!key) { statusEl.textContent = '請先貼 API Key(上方 ①)'; return; } + if (!key) { statusEl.textContent = '請先登入(上方 ①)'; return; } statusEl.textContent = '查詢中...'; try { const res = await fetch('/workflows/search?q=' + encodeURIComponent(q), { diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 91b576f..0b5ee7c 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -61,6 +61,10 @@ export type Bindings = { // 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下 // 沒設就 fallback 到當下用戶的 ak_,會寫進該用戶 KBDB namespace(次優但能用) PLATFORM_API_KEY?: string; + // Console 固定租戶字串(Arcrun#3 發現②,非機密——self-hosted 架構本就是明碼 namespace)。 + // console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。 + // 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。 + CONSOLE_TENANT?: string; }; // 重新 export Cloudflare Workers ExecutionContext 以便其他 module 用 diff --git a/cypher-executor/wrangler.toml b/cypher-executor/wrangler.toml index 51e39c2..649b69a 100644 --- a/cypher-executor/wrangler.toml +++ b/cypher-executor/wrangler.toml @@ -118,6 +118,11 @@ WORKER_SUBDOMAIN = "uncle6-me" # Self-hosted fork:改成自己部署的 arcrun-kbdb.<你的subdomain>.workers.dev。 KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev" +# console 固定租戶字串(Arcrun#3 發現②,routes/console-auth.ts)。非機密——self-hosted +# 架構本就是明碼 namespace。console 登入後端一律用這個字串打 /kbdb/*、/workflows/search +# (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。 +CONSOLE_TENANT = "leo" + [[routes]] pattern = "cypher.arcrun.dev/*" zone_name = "arcrun.dev"