diff --git a/server-tools/lake-lamp-authz/README.md b/server-tools/lake-lamp-authz/README.md index d40812f..7113e78 100644 --- a/server-tools/lake-lamp-authz/README.md +++ b/server-tools/lake-lamp-authz/README.md @@ -27,6 +27,22 @@ - 所有改变服务器状态的登记动作必须先生成备份引用和回滚方案;失败自动回滚,成功写验收回执; - 广州公开代理只应暴露 `/approve/`、`/api/workorders` 与 claim 路由,服务本体监听 JD 回环地址。 +## HoloLake 手机身份、知识湖与模型代理 + +HoloLake 手机端使用独立的邮箱验证码会话,不复用工单批准链接: + +- `/api/hololake/session/email/request` 对已登记和未登记邮箱返回相同形态,避免枚举账号; +- 六位验证码只在邮件正文出现,服务器状态仅保存带私密 pepper 的摘要,十分钟失效且最多尝试五次; +- 会话绑定 HoloLake 设备编号,手机仅保存短期会话令牌;服务器磁盘仍只保存令牌摘要; +- `/api/hololake/knowledge/manifest` 与 `/archive` 只暴露固定登记仓库 + `bingshuo/hololake-knowledge-base` 的当前 `refs/heads/main` 快照,不向手机下发 Forgejo 凭据; +- `/api/hololake/ai/catalog` 只返回可用模型编号;`/execute` 只调用服务器登记的提供商与模型, + 不接受任意 URL、请求头或密钥,响应和回执均不包含服务器密钥。 + +私密模型登记文件使用 `hololake-ai-providers.example.json` 的结构,真实文件只放在 +`/etc/guanghu/secrets/hololake-ai-providers.json`,不得提交到仓库。知识仓库路径、模型 +登记文件、session pepper 和会话状态路径由 `authorization.env` 固定;手机不能切换这些路径。 + `request-workorder.js` 从临时环境变量读取 QQ 数字,在内存中补全邮箱并只发送 SHA-256 指纹;数字本身不会写入请求正文、状态文件或代码仓库。未提供私密 request credential 时,脚本自动切换到跨设备公开建单模式。 diff --git a/server-tools/lake-lamp-authz/authorization.env.example b/server-tools/lake-lamp-authz/authorization.env.example index 2d359ec..6a55b1b 100644 --- a/server-tools/lake-lamp-authz/authorization.env.example +++ b/server-tools/lake-lamp-authz/authorization.env.example @@ -20,6 +20,14 @@ SMTP_HOST=smtp.qq.com SMTP_PORT=465 SMTP_USER=SET_IN_PRIVATE_SERVER_FILE QQ_SMTP_AUTH_CODE=SET_IN_PRIVATE_SERVER_FILE +HOLOLAKE_SESSION_PEPPER=SET_RANDOM_32_BYTE_VALUE_IN_PRIVATE_SERVER_FILE +HOLOLAKE_SESSION_STATE_FILE=/var/lib/guanghu/lake-lamp-authz/hololake-sessions.json +HOLOLAKE_OTP_TTL=600 +HOLOLAKE_ACCOUNT_SESSION_TTL=86400 +HOLOLAKE_OTP_REQUEST_LIMIT=6 +HOLOLAKE_KNOWLEDGE_REPOSITORY_PATH=/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/hololake-knowledge-base.git +HOLOLAKE_KNOWLEDGE_MAX_ARCHIVE_BYTES=134217728 +HOLOLAKE_AI_PROVIDERS_FILE=/etc/guanghu/secrets/hololake-ai-providers.json LAKE_LAMP_ARCHITECTURE_PROVISION_SOCKET=/run/guanghu-architecture-provision/provision.sock ARCHITECTURE_PROVISION_REPO_URL=https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git ARCHITECTURE_PROVISION_REPO_DIR=/var/lib/guanghu/architecture-provision/repo diff --git a/server-tools/lake-lamp-authz/hololake-ai-providers.example.json b/server-tools/lake-lamp-authz/hololake-ai-providers.example.json new file mode 100644 index 0000000..3583f4f --- /dev/null +++ b/server-tools/lake-lamp-authz/hololake-ai-providers.example.json @@ -0,0 +1,14 @@ +{ + "schema": "guanghu.hololake-ai-providers/v1", + "providers": { + "default": { + "name": "HoloLake Server Model", + "base_url": "https://api.example.invalid/v1", + "api_key": "SET_IN_PRIVATE_SERVER_FILE", + "models": [ + "SET_REGISTERED_MODEL_ID" + ], + "timeout_ms": 120000 + } + } +} diff --git a/server-tools/lake-lamp-authz/hololake-api.test.js b/server-tools/lake-lamp-authz/hololake-api.test.js new file mode 100644 index 0000000..c64ddf7 --- /dev/null +++ b/server-tools/lake-lamp-authz/hololake-api.test.js @@ -0,0 +1,236 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const { + HoloLakeSessionManager, +} = require("./hololake-session"); +const { createApp } = require("./server"); + +async function withServer(run) { + const mail = []; + const sessionManager = new HoloLakeSessionManager({ + registeredEmails: ["owner@example.invalid"], + pepper: "test-only-pepper-with-enough-entropy", + stateFile: "", + sendEmail: async message => { + mail.push(message); + return true; + }, + }); + const knowledgeProvider = { + manifest: () => ({ + schema: "guanghu.hololake-knowledge-manifest/v1", + repository: "bingshuo/hololake-knowledge-base", + ref: "refs/heads/main", + commit: "a".repeat(40), + committed_at: 1_800_000_000, + archive_url: `/api/hololake/knowledge/archive?commit=${"a".repeat(40)}`, + }), + archive: commit => ({ + schema: "guanghu.hololake-knowledge-archive/v1", + repository: "bingshuo/hololake-knowledge-base", + commit, + sha256: "b".repeat(64), + content_type: "application/zip", + body: Buffer.from("PK-test-archive"), + }), + }; + const aiGateway = { + catalog: () => ({ + schema: "guanghu.hololake-ai-catalog/v1", + providers: [{ id: "default", name: "HoloLake", models: ["gpt-test"] }], + }), + execute: async body => ({ + ok: true, + response: { + choices: [{ + message: { + role: "assistant", + content: body.messages[0].content, + }, + }], + }, + receipt: { + schema: "guanghu.hololake-ai-receipt/v1", + state: "executed", + provider: body.provider, + model: body.model, + }, + }), + }; + const app = createApp({ + requestToken: "request-only-secret", + ownerEmail: "owner@example.invalid", + publicBaseUrl: "https://example.invalid/authz", + stateFile: "", + sendEmail: async () => true, + hololakeSessionManager: sessionManager, + hololakeKnowledgeProvider: knowledgeProvider, + hololakeAiGateway: aiGateway, + }); + await new Promise(resolve => app.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${app.address().port}`; + try { + await run({ base, mail }); + } finally { + app.closeAllConnections?.(); + await new Promise(resolve => app.close(resolve)); + } +} + +async function login(base, mail, deviceId = "ios-device-001") { + const request = await fetch(`${base}/api/hololake/session/email/request`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-hololake-device-id": deviceId, + }, + body: JSON.stringify({ + email: "owner@example.invalid", + device_id: deviceId, + }), + }); + assert.equal(request.status, 202); + const requested = await request.json(); + const code = mail[0].text.match(/\b\d{6}\b/)[0]; + const verifiedResponse = await fetch( + `${base}/api/hololake/session/email/verify`, + { + method: "POST", + headers: { + "content-type": "application/json", + "x-hololake-device-id": deviceId, + }, + body: JSON.stringify({ + request_id: requested.request_id, + code, + device_id: deviceId, + }), + }, + ); + assert.equal(verifiedResponse.status, 200); + return (await verifiedResponse.json()).session_token; +} + +test("HoloLake session API is non-enumerating and rejects invalid verification", async () => { + await withServer(async ({ base, mail }) => { + const unknown = await fetch( + `${base}/api/hololake/session/email/request`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "nobody@example.invalid", + device_id: "ios-device-001", + }), + }, + ); + assert.equal(unknown.status, 202); + assert.equal(mail.length, 0); + assert.equal((await unknown.json()).accepted, true); + + const invalid = await fetch( + `${base}/api/hololake/session/email/verify`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + request_id: crypto.randomUUID(), + code: "000000", + device_id: "ios-device-001", + }), + }, + ); + assert.equal(invalid.status, 403); + assert.equal((await invalid.json()).error, "invalid_or_expired_code"); + }); +}); + +test("authenticated device can read fixed knowledge, use AI proxy, inspect and revoke session", async () => { + await withServer(async ({ base, mail }) => { + const deviceId = "ios-device-001"; + const token = await login(base, mail, deviceId); + const headers = { + authorization: `Bearer ${token}`, + "x-hololake-device-id": deviceId, + }; + + const session = await fetch(`${base}/api/hololake/session`, { headers }); + assert.equal(session.status, 200); + assert.equal((await session.json()).session.device_id, deviceId); + + const manifest = await fetch( + `${base}/api/hololake/knowledge/manifest`, + { headers }, + ); + assert.equal(manifest.status, 200); + assert.equal((await manifest.json()).commit, "a".repeat(40)); + + const archive = await fetch( + `${base}/api/hololake/knowledge/archive?commit=${"a".repeat(40)}`, + { headers }, + ); + assert.equal(archive.status, 200); + assert.equal(archive.headers.get("x-hololake-commit"), "a".repeat(40)); + assert.equal(archive.headers.get("x-content-sha256"), "b".repeat(64)); + assert.equal(Buffer.from(await archive.arrayBuffer()).toString(), "PK-test-archive"); + + const ai = await fetch(`${base}/api/hololake/ai/execute`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + provider: "default", + model: "gpt-test", + messages: [{ role: "user", content: "hello" }], + }), + }); + assert.equal(ai.status, 200); + assert.equal( + (await ai.json()).response.choices[0].message.content, + "hello", + ); + const catalog = await fetch(`${base}/api/hololake/ai/catalog`, { headers }); + assert.equal(catalog.status, 200); + assert.deepEqual((await catalog.json()).providers[0].models, ["gpt-test"]); + + const logout = await fetch(`${base}/api/hololake/session`, { + method: "DELETE", + headers, + }); + assert.equal(logout.status, 200); + assert.equal( + (await fetch(`${base}/api/hololake/session`, { headers })).status, + 401, + ); + }); +}); + +test("knowledge and AI endpoints require the session and matching device", async () => { + await withServer(async ({ base, mail }) => { + assert.equal( + (await fetch(`${base}/api/hololake/knowledge/manifest`)).status, + 401, + ); + assert.equal( + (await fetch(`${base}/api/hololake/ai/execute`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + })).status, + 401, + ); + + const token = await login(base, mail); + assert.equal( + (await fetch(`${base}/api/hololake/knowledge/manifest`, { + headers: { + authorization: `Bearer ${token}`, + "x-hololake-device-id": "ios-device-002", + }, + })).status, + 403, + ); + }); +}); diff --git a/server-tools/lake-lamp-authz/hololake-capabilities.js b/server-tools/lake-lamp-authz/hololake-capabilities.js new file mode 100644 index 0000000..fcea77d --- /dev/null +++ b/server-tools/lake-lamp-authz/hololake-capabilities.js @@ -0,0 +1,242 @@ +"use strict"; + +const childProcess = require("node:child_process"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); + +class HoloLakeKnowledgeProvider { + constructor(options = {}) { + this.repositoryId = String(options.repositoryId || ""); + this.repositoryPath = String(options.repositoryPath || ""); + this.ref = "refs/heads/main"; + this.maxArchiveBytes = Math.max( + 1024 * 1024, + Number(options.maxArchiveBytes || 128 * 1024 * 1024), + ); + if (!/^[a-z0-9._-]+\/[a-z0-9._-]+$/.test(this.repositoryId)) { + throw new Error("knowledge_repository_id_invalid"); + } + if (!this.repositoryPath || !fs.existsSync(this.repositoryPath)) { + throw new Error("knowledge_repository_unavailable"); + } + } + + manifest() { + const commit = this.git(["rev-parse", "--verify", `${this.ref}^{commit}`]) + .toString("utf8") + .trim(); + if (!/^[0-9a-f]{40}$/.test(commit)) { + throw new Error("knowledge_commit_invalid"); + } + const committedAt = Number( + this.git(["show", "-s", "--format=%ct", commit]).toString("utf8").trim(), + ); + return { + schema: "guanghu.hololake-knowledge-manifest/v1", + repository: this.repositoryId, + ref: this.ref, + commit, + committed_at: committedAt, + archive_url: `/api/hololake/knowledge/archive?commit=${commit}`, + }; + } + + archive(commit) { + const manifest = this.manifest(); + if (!safeEqual(String(commit || ""), manifest.commit)) { + throw new Error("knowledge_commit_not_current"); + } + const body = this.git( + ["archive", "--format=zip", manifest.commit], + this.maxArchiveBytes + 1, + ); + if (body.length > this.maxArchiveBytes) { + throw new Error("knowledge_archive_too_large"); + } + return { + schema: "guanghu.hololake-knowledge-archive/v1", + repository: this.repositoryId, + commit: manifest.commit, + sha256: crypto.createHash("sha256").update(body).digest("hex"), + content_type: "application/zip", + body, + }; + } + + git(args, maxBuffer = 1024 * 1024) { + return childProcess.execFileSync( + "git", + [`--git-dir=${this.repositoryPath}`, ...args], + { + encoding: "buffer", + maxBuffer, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } +} + +class HoloLakeAiGateway { + constructor(options = {}) { + this.fetchImpl = options.fetchImpl || fetch; + this.maxMessages = Math.max(1, Number(options.maxMessages || 64)); + this.maxInputCharacters = Math.max( + 1, + Number(options.maxInputCharacters || 120_000), + ); + this.maxOutputTokens = Math.max( + 16, + Number(options.maxOutputTokens || 16_384), + ); + this.maxResponseBytes = Math.max( + 64 * 1024, + Number(options.maxResponseBytes || 2 * 1024 * 1024), + ); + this.providers = normalizeProviders(options.providers || {}); + } + + catalog() { + return { + schema: "guanghu.hololake-ai-catalog/v1", + providers: Object.entries(this.providers).map(([id, provider]) => ({ + id, + name: provider.name || id, + models: provider.models, + })), + }; + } + + async execute(request = {}) { + const providerId = String(request.provider || ""); + const provider = this.providers[providerId]; + if (!provider) throw new Error("ai_provider_not_registered"); + const model = String(request.model || ""); + if (!provider.models.includes(model)) { + throw new Error("ai_model_not_registered"); + } + const messages = validateMessages( + request.messages, + this.maxMessages, + this.maxInputCharacters, + ); + const maxTokens = Number(request.max_tokens || 1024); + if ( + !Number.isInteger(maxTokens) + || maxTokens < 1 + || maxTokens > this.maxOutputTokens + ) { + throw new Error("ai_output_limit_exceeded"); + } + + const response = await this.fetchImpl(`${provider.baseUrl}/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${provider.apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model, + messages, + max_tokens: maxTokens, + stream: false, + }), + signal: AbortSignal.timeout(provider.timeoutMs), + }); + const text = await response.text(); + if (Buffer.byteLength(text) > this.maxResponseBytes) { + throw new Error("ai_response_too_large"); + } + if (!response.ok) { + const error = new Error("ai_upstream_failed"); + error.status = response.status; + throw error; + } + let parsed; + try { + parsed = JSON.parse(text); + } catch { + throw new Error("ai_upstream_invalid_json"); + } + return { + ok: true, + response: parsed, + receipt: { + schema: "guanghu.hololake-ai-receipt/v1", + state: "executed", + provider: providerId, + model, + upstream_status: response.status, + occurred_at: Date.now() / 1000, + }, + }; + } +} + +function loadAiProviders(file) { + if (!file || !fs.existsSync(file)) return {}; + const parsed = JSON.parse(fs.readFileSync(file, "utf8")); + if (!parsed || parsed.schema !== "guanghu.hololake-ai-providers/v1") { + throw new Error("ai_provider_registry_invalid"); + } + return parsed.providers || {}; +} + +function normalizeProviders(providers) { + const normalized = {}; + for (const [id, value] of Object.entries(providers)) { + if (!/^[a-z0-9._-]{1,64}$/.test(id) || !value) continue; + const baseUrl = String(value.baseUrl || value.base_url || "").replace(/\/$/, ""); + const apiKey = String(value.apiKey || value.api_key || ""); + const models = Array.isArray(value.models) + ? value.models.map(String).filter(model => /^[A-Za-z0-9._:/-]{1,160}$/.test(model)) + : []; + let parsed; + try { + parsed = new URL(baseUrl); + } catch { + continue; + } + if (parsed.protocol !== "https:" || !apiKey || models.length === 0) continue; + normalized[id] = { + baseUrl, + apiKey, + name: String(value.name || id).slice(0, 120), + models, + timeoutMs: Math.max(1_000, Number(value.timeoutMs || value.timeout_ms || 120_000)), + }; + } + return normalized; +} + +function validateMessages(messages, maxMessages, maxInputCharacters) { + if (!Array.isArray(messages) || messages.length < 1) { + throw new Error("ai_messages_required"); + } + if (messages.length > maxMessages) throw new Error("ai_input_too_large"); + let characters = 0; + const normalized = messages.map(message => { + if ( + !message + || !["system", "user", "assistant"].includes(message.role) + || typeof message.content !== "string" + ) { + throw new Error("ai_message_invalid"); + } + characters += message.content.length; + return { role: message.role, content: message.content }; + }); + if (characters > maxInputCharacters) throw new Error("ai_input_too_large"); + return normalized; +} + +function safeEqual(left, right) { + const a = Buffer.from(String(left)); + const b = Buffer.from(String(right)); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +module.exports = { + HoloLakeAiGateway, + HoloLakeKnowledgeProvider, + loadAiProviders, +}; diff --git a/server-tools/lake-lamp-authz/hololake-capabilities.test.js b/server-tools/lake-lamp-authz/hololake-capabilities.test.js new file mode 100644 index 0000000..bad0ab8 --- /dev/null +++ b/server-tools/lake-lamp-authz/hololake-capabilities.test.js @@ -0,0 +1,158 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const childProcess = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { + HoloLakeAiGateway, + HoloLakeKnowledgeProvider, +} = require("./hololake-capabilities"); + +function createBareKnowledgeRepository() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "hololake-knowledge-")); + const work = path.join(root, "work"); + const bare = path.join(root, "knowledge.git"); + fs.mkdirSync(work); + childProcess.execFileSync("git", ["init", "-b", "main"], { cwd: work }); + childProcess.execFileSync("git", ["config", "user.email", "test@example.invalid"], { cwd: work }); + childProcess.execFileSync("git", ["config", "user.name", "HoloLake Test"], { cwd: work }); + fs.writeFileSync(path.join(work, "INDEX.md"), "# HoloLake\n"); + childProcess.execFileSync("git", ["add", "INDEX.md"], { cwd: work }); + childProcess.execFileSync("git", ["commit", "-m", "knowledge baseline"], { cwd: work }); + childProcess.execFileSync("git", ["clone", "--bare", work, bare]); + return { root, bare }; +} + +test("knowledge provider exposes only the fixed main commit and archive", () => { + const repository = createBareKnowledgeRepository(); + const provider = new HoloLakeKnowledgeProvider({ + repositoryId: "bingshuo/hololake-knowledge-base", + repositoryPath: repository.bare, + maxArchiveBytes: 2 * 1024 * 1024, + }); + + const manifest = provider.manifest(); + assert.equal( + manifest.repository, + "bingshuo/hololake-knowledge-base", + ); + assert.match(manifest.commit, /^[0-9a-f]{40}$/); + assert.equal(manifest.ref, "refs/heads/main"); + + const archive = provider.archive(manifest.commit); + assert.equal(archive.commit, manifest.commit); + assert.equal(archive.content_type, "application/zip"); + assert.equal(archive.body.subarray(0, 2).toString("ascii"), "PK"); + assert.equal(archive.sha256.length, 64); + + assert.throws( + () => provider.archive("0000000000000000000000000000000000000000"), + /knowledge_commit_not_current/, + ); +}); + +test("AI gateway accepts only registered provider models and never leaks the key", async () => { + const requests = []; + const gateway = new HoloLakeAiGateway({ + providers: { + default: { + baseUrl: "https://models.example.invalid/v1", + apiKey: "server-secret-key", + models: ["gpt-test"], + }, + }, + fetchImpl: async (url, options) => { + requests.push({ url, options }); + return new Response(JSON.stringify({ + id: "response-1", + choices: [{ message: { role: "assistant", content: "ok" } }], + usage: { total_tokens: 3 }, + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const result = await gateway.execute({ + provider: "default", + model: "gpt-test", + messages: [{ role: "user", content: "hello" }], + max_tokens: 32, + }); + assert.equal(result.ok, true); + assert.equal(result.response.choices[0].message.content, "ok"); + assert.equal(result.receipt.provider, "default"); + assert.equal(requests.length, 1); + assert.equal( + requests[0].options.headers.authorization, + "Bearer server-secret-key", + ); + assert.doesNotMatch(JSON.stringify(result), /server-secret-key/); + const catalog = gateway.catalog(); + assert.deepEqual(catalog.providers[0].models, ["gpt-test"]); + assert.doesNotMatch(JSON.stringify(catalog), /server-secret-key|models\\.example/); + + await assert.rejects( + () => gateway.execute({ + provider: "default", + model: "not-registered", + messages: [{ role: "user", content: "hello" }], + }), + /ai_model_not_registered/, + ); + await assert.rejects( + () => gateway.execute({ + provider: "https://attacker.invalid/v1", + model: "gpt-test", + messages: [{ role: "user", content: "hello" }], + }), + /ai_provider_not_registered/, + ); + assert.equal(requests.length, 1); +}); + +test("AI gateway bounds message shape, count, text, and requested output", async () => { + const gateway = new HoloLakeAiGateway({ + providers: { + default: { + baseUrl: "https://models.example.invalid/v1", + apiKey: "server-secret-key", + models: ["gpt-test"], + }, + }, + fetchImpl: async () => new Response("{}", { status: 200 }), + maxMessages: 2, + maxInputCharacters: 10, + maxOutputTokens: 64, + }); + + await assert.rejects( + () => gateway.execute({ + provider: "default", + model: "gpt-test", + messages: [{ role: "user", content: "12345678901" }], + }), + /ai_input_too_large/, + ); + await assert.rejects( + () => gateway.execute({ + provider: "default", + model: "gpt-test", + messages: [{ role: "tool", content: "hello" }], + }), + /ai_message_invalid/, + ); + await assert.rejects( + () => gateway.execute({ + provider: "default", + model: "gpt-test", + messages: [{ role: "user", content: "hello" }], + max_tokens: 65, + }), + /ai_output_limit_exceeded/, + ); +}); diff --git a/server-tools/lake-lamp-authz/hololake-session.js b/server-tools/lake-lamp-authz/hololake-session.js new file mode 100644 index 0000000..8dac32d --- /dev/null +++ b/server-tools/lake-lamp-authz/hololake-session.js @@ -0,0 +1,260 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +class HoloLakeSessionManager { + constructor(options = {}) { + this.now = options.now || Date.now; + this.sendEmail = options.sendEmail || (async () => false); + this.pepper = String(options.pepper || ""); + if (this.pepper.length < 24) { + throw new Error("HOLOLAKE_SESSION_PEPPER must contain at least 24 characters"); + } + this.stateFile = String(options.stateFile || ""); + this.otpTtlMs = Number(options.otpTtlSeconds || 10 * 60) * 1000; + this.sessionTtlMs = Number(options.sessionTtlSeconds || 24 * 60 * 60) * 1000; + this.maxOtpAttempts = Math.max(1, Number(options.maxOtpAttempts || 5)); + this.requestLimit = Math.max(1, Number(options.requestLimit || 6)); + this.requestWindowMs = Number(options.requestWindowSeconds || 60 * 60) * 1000; + this.registeredEmails = new Map( + (options.registeredEmails || []) + .map(normalizeEmail) + .filter(validEmail) + .map(email => [email, this.digest(`account:${email}`)]), + ); + this.requestEvents = new Map(); + this.state = this.loadState(); + } + + async requestOtp({ email, deviceId, networkKey = "unknown" }) { + const now = this.now(); + this.prune(now); + const normalizedDevice = normalizeDeviceId(deviceId); + const requestId = crypto.randomUUID(); + if (!normalizedDevice) return { accepted: false, error: "invalid_device" }; + if (!this.takeRequest(String(networkKey || "unknown"), now)) { + return { accepted: false, error: "rate_limited" }; + } + + const normalizedEmail = normalizeEmail(email); + const accountId = this.registeredEmails.get(normalizedEmail); + if (!accountId) return { accepted: true, request_id: requestId }; + + const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0"); + this.state.challenges[requestId] = { + account_id: accountId, + device_id: normalizedDevice, + code_digest: this.digest( + `otp:${requestId}:${normalizedDevice}:${code}`, + ), + attempts: 0, + expires_at: now + this.otpTtlMs, + }; + this.persist(); + const sent = await this.sendEmail({ + to: normalizedEmail, + subject: "HoloLake 登录验证码", + text: [ + `你的 HoloLake 登录验证码是:${code}`, + `验证码将在 ${Math.ceil(this.otpTtlMs / 60_000)} 分钟后失效。`, + "如果不是你本人操作,请忽略这封邮件。", + ].join("\n"), + }); + if (!sent) { + delete this.state.challenges[requestId]; + this.persist(); + } + return { accepted: true, request_id: requestId }; + } + + verifyOtp({ requestId, code, deviceId }) { + const now = this.now(); + this.prune(now); + const id = String(requestId || ""); + const challenge = this.state.challenges[id]; + const normalizedDevice = normalizeDeviceId(deviceId); + if ( + !challenge + || !normalizedDevice + || challenge.expires_at <= now + || challenge.attempts >= this.maxOtpAttempts + ) { + if (challenge) { + delete this.state.challenges[id]; + this.persist(); + } + return invalidCode(); + } + + challenge.attempts += 1; + const candidate = this.digest( + `otp:${id}:${normalizedDevice}:${String(code || "")}`, + ); + const matches = safeEqual(candidate, challenge.code_digest) + && safeEqual(normalizedDevice, challenge.device_id); + if (!matches) { + if (challenge.attempts >= this.maxOtpAttempts) { + delete this.state.challenges[id]; + } + this.persist(); + return invalidCode(); + } + + delete this.state.challenges[id]; + const token = crypto.randomBytes(48).toString("base64url"); + const tokenDigest = this.digest(`session:${token}`); + this.state.sessions[tokenDigest] = { + account_id: challenge.account_id, + device_id: normalizedDevice, + created_at: now, + expires_at: now + this.sessionTtlMs, + }; + this.persist(); + return { + ok: true, + session_token: token, + expires_at: (now + this.sessionTtlMs) / 1000, + expires_in: this.sessionTtlMs / 1000, + }; + } + + authenticate(token, deviceId) { + const now = this.now(); + const normalizedDevice = normalizeDeviceId(deviceId); + const tokenDigest = this.digest(`session:${String(token || "")}`); + const session = this.state.sessions[tokenDigest]; + if (!session) return { ok: false, error: "session_invalid" }; + if (session.expires_at <= now) { + delete this.state.sessions[tokenDigest]; + this.persist(); + return { ok: false, error: "session_expired" }; + } + if (!normalizedDevice || !safeEqual(normalizedDevice, session.device_id)) { + return { ok: false, error: "session_device_mismatch" }; + } + return { + ok: true, + session: { + account_id: session.account_id, + device_id: session.device_id, + created_at: session.created_at / 1000, + expires_at: session.expires_at / 1000, + }, + }; + } + + revoke(token, deviceId) { + const authenticated = this.authenticate(token, deviceId); + if (!authenticated.ok) return authenticated; + const tokenDigest = this.digest(`session:${String(token || "")}`); + delete this.state.sessions[tokenDigest]; + this.persist(); + return { ok: true }; + } + + inspectState() { + return JSON.parse(JSON.stringify(this.state)); + } + + digest(value) { + return crypto.createHmac("sha256", this.pepper).update(value).digest("hex"); + } + + takeRequest(key, now) { + const previous = (this.requestEvents.get(key) || []) + .filter(timestamp => now - timestamp < this.requestWindowMs); + if (previous.length >= this.requestLimit) { + this.requestEvents.set(key, previous); + return false; + } + previous.push(now); + this.requestEvents.set(key, previous); + return true; + } + + prune(now) { + let changed = false; + for (const [id, challenge] of Object.entries(this.state.challenges)) { + if (challenge.expires_at <= now) { + delete this.state.challenges[id]; + changed = true; + } + } + for (const [digest, session] of Object.entries(this.state.sessions)) { + if (session.expires_at <= now) { + delete this.state.sessions[digest]; + changed = true; + } + } + if (changed) this.persist(); + } + + loadState() { + if (!this.stateFile || !fs.existsSync(this.stateFile)) { + return emptyState(); + } + const parsed = JSON.parse(fs.readFileSync(this.stateFile, "utf8")); + if ( + !parsed + || parsed.schema !== "guanghu.hololake-session-state/v1" + || !isRecord(parsed.challenges) + || !isRecord(parsed.sessions) + ) { + throw new Error("invalid HoloLake session state"); + } + return parsed; + } + + persist() { + if (!this.stateFile) return; + fs.mkdirSync(path.dirname(this.stateFile), { recursive: true, mode: 0o700 }); + const temporary = `${this.stateFile}.${process.pid}.tmp`; + fs.writeFileSync(temporary, `${JSON.stringify(this.state, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + fs.renameSync(temporary, this.stateFile); + fs.chmodSync(this.stateFile, 0o600); + } +} + +function emptyState() { + return { + schema: "guanghu.hololake-session-state/v1", + challenges: {}, + sessions: {}, + }; +} + +function invalidCode() { + return { ok: false, error: "invalid_or_expired_code" }; +} + +function normalizeEmail(value) { + return String(value || "").trim().normalize("NFKC").toLowerCase(); +} + +function normalizeDeviceId(value) { + const normalized = String(value || "").trim(); + return /^[A-Za-z0-9._:-]{8,120}$/.test(normalized) ? normalized : ""; +} + +function validEmail(value) { + return value.length <= 254 && /^[^@\s]+@[^@\s]+$/.test(value); +} + +function safeEqual(left, right) { + const a = Buffer.from(String(left)); + const b = Buffer.from(String(right)); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +function isRecord(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +module.exports = { + HoloLakeSessionManager, +}; diff --git a/server-tools/lake-lamp-authz/hololake-session.test.js b/server-tools/lake-lamp-authz/hololake-session.test.js new file mode 100644 index 0000000..4d81422 --- /dev/null +++ b/server-tools/lake-lamp-authz/hololake-session.test.js @@ -0,0 +1,211 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + HoloLakeSessionManager, +} = require("./hololake-session"); + +function fixture(overrides = {}) { + let now = 1_800_000_000_000; + const mail = []; + const manager = new HoloLakeSessionManager({ + registeredEmails: ["owner@example.invalid"], + pepper: "test-only-pepper-with-enough-entropy", + stateFile: "", + now: () => now, + sendEmail: async message => { + mail.push(message); + return true; + }, + ...overrides, + }); + return { + manager, + mail, + advance(milliseconds) { + now += milliseconds; + }, + }; +} + +test("OTP request is non-enumerating and stores no plaintext code", async () => { + const known = fixture(); + const requested = await known.manager.requestOtp({ + email: "Owner@Example.Invalid", + deviceId: "ios-device-001", + }); + assert.equal(requested.accepted, true); + assert.match(requested.request_id, /^[0-9a-f-]{36}$/); + assert.equal(known.mail.length, 1); + assert.match(known.mail[0].text, /\b\d{6}\b/); + const otp = known.mail[0].text.match(/\b\d{6}\b/)[0]; + assert.doesNotMatch(JSON.stringify(known.manager.inspectState()), new RegExp(otp)); + + const unknown = fixture(); + const decoy = await unknown.manager.requestOtp({ + email: "nobody@example.invalid", + deviceId: "ios-device-001", + }); + assert.equal(decoy.accepted, requested.accepted); + assert.match(decoy.request_id, /^[0-9a-f-]{36}$/); + assert.equal(unknown.mail.length, 0); +}); + +test("OTP verification is device-bound, attempt-limited, and returns a one-time session token", async () => { + const state = fixture(); + const requested = await state.manager.requestOtp({ + email: "owner@example.invalid", + deviceId: "ios-device-001", + }); + const otp = state.mail[0].text.match(/\b\d{6}\b/)[0]; + + const wrongDevice = state.manager.verifyOtp({ + requestId: requested.request_id, + code: otp, + deviceId: "ios-device-002", + }); + assert.equal(wrongDevice.ok, false); + assert.equal(wrongDevice.error, "invalid_or_expired_code"); + + const verified = state.manager.verifyOtp({ + requestId: requested.request_id, + code: otp, + deviceId: "ios-device-001", + }); + assert.equal(verified.ok, true); + assert.match(verified.session_token, /^[A-Za-z0-9_-]{40,}$/); + assert.doesNotMatch( + JSON.stringify(state.manager.inspectState()), + new RegExp(verified.session_token), + ); + + const reused = state.manager.verifyOtp({ + requestId: requested.request_id, + code: otp, + deviceId: "ios-device-001", + }); + assert.equal(reused.ok, false); + assert.equal(reused.error, "invalid_or_expired_code"); +}); + +test("session authentication, expiry, and revocation never return the stored token", async () => { + const state = fixture({ sessionTtlSeconds: 60 }); + const requested = await state.manager.requestOtp({ + email: "owner@example.invalid", + deviceId: "ios-device-001", + }); + const otp = state.mail[0].text.match(/\b\d{6}\b/)[0]; + const verified = state.manager.verifyOtp({ + requestId: requested.request_id, + code: otp, + deviceId: "ios-device-001", + }); + + const active = state.manager.authenticate( + verified.session_token, + "ios-device-001", + ); + assert.equal(active.ok, true); + assert.equal(active.session.device_id, "ios-device-001"); + assert.equal(Object.hasOwn(active.session, "token"), false); + + assert.equal( + state.manager.authenticate(verified.session_token, "ios-device-002").error, + "session_device_mismatch", + ); + assert.equal( + state.manager.revoke(verified.session_token, "ios-device-002").error, + "session_device_mismatch", + ); + assert.equal( + state.manager.revoke(verified.session_token, "ios-device-001").ok, + true, + ); + assert.equal( + state.manager.authenticate(verified.session_token, "ios-device-001").error, + "session_invalid", + ); + + const second = await state.manager.requestOtp({ + email: "owner@example.invalid", + deviceId: "ios-device-001", + }); + const secondOtp = state.mail[1].text.match(/\b\d{6}\b/)[0]; + const secondSession = state.manager.verifyOtp({ + requestId: second.request_id, + code: secondOtp, + deviceId: "ios-device-001", + }); + state.advance(61_000); + assert.equal( + state.manager.authenticate( + secondSession.session_token, + "ios-device-001", + ).error, + "session_expired", + ); +}); + +test("OTP expires, locks after five failed attempts, and request rate is bounded", async () => { + const expired = fixture({ otpTtlSeconds: 30 }); + const requested = await expired.manager.requestOtp({ + email: "owner@example.invalid", + deviceId: "ios-device-001", + }); + const otp = expired.mail[0].text.match(/\b\d{6}\b/)[0]; + expired.advance(31_000); + assert.equal( + expired.manager.verifyOtp({ + requestId: requested.request_id, + code: otp, + deviceId: "ios-device-001", + }).error, + "invalid_or_expired_code", + ); + + const locked = fixture({ maxOtpAttempts: 5 }); + const lockRequest = await locked.manager.requestOtp({ + email: "owner@example.invalid", + deviceId: "ios-device-001", + }); + const realOtp = locked.mail[0].text.match(/\b\d{6}\b/)[0]; + for (let attempt = 0; attempt < 5; attempt += 1) { + assert.equal( + locked.manager.verifyOtp({ + requestId: lockRequest.request_id, + code: "000000", + deviceId: "ios-device-001", + }).ok, + false, + ); + } + assert.equal( + locked.manager.verifyOtp({ + requestId: lockRequest.request_id, + code: realOtp, + deviceId: "ios-device-001", + }).error, + "invalid_or_expired_code", + ); + + const limited = fixture({ requestLimit: 2 }); + await limited.manager.requestOtp({ + email: "owner@example.invalid", + deviceId: "ios-device-001", + networkKey: "198.51.100.7", + }); + await limited.manager.requestOtp({ + email: "owner@example.invalid", + deviceId: "ios-device-001", + networkKey: "198.51.100.7", + }); + const denied = await limited.manager.requestOtp({ + email: "owner@example.invalid", + deviceId: "ios-device-001", + networkKey: "198.51.100.7", + }); + assert.equal(denied.accepted, false); + assert.equal(denied.error, "rate_limited"); + assert.equal(limited.mail.length, 2); +}); diff --git a/server-tools/lake-lamp-authz/install-architecture-provisioner.sh b/server-tools/lake-lamp-authz/install-architecture-provisioner.sh index 4493057..1c6e593 100755 --- a/server-tools/lake-lamp-authz/install-architecture-provisioner.sh +++ b/server-tools/lake-lamp-authz/install-architecture-provisioner.sh @@ -10,7 +10,7 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) install_root=/opt/guanghu/lake-lamp-authz install -d -m 0755 "$install_root" -for file in server.js workorder-manager.js map-gate.js smtp-mailer.js action-client.js architecture-provision-broker.js deployment-event.js deployment-event-worker.js deployment-source-policy.js; do +for file in server.js workorder-manager.js map-gate.js smtp-mailer.js action-client.js architecture-provision-broker.js deployment-event.js deployment-event-worker.js deployment-source-policy.js hololake-session.js hololake-capabilities.js; do install -m 0644 "$script_dir/$file" "$install_root/$file" done install -m 0644 "$script_dir/lake-lamp-architecture-provision.service" /etc/systemd/system/lake-lamp-architecture-provision.service diff --git a/server-tools/lake-lamp-authz/lake-lamp-authz.service b/server-tools/lake-lamp-authz/lake-lamp-authz.service index 90af20b..b14271f 100644 --- a/server-tools/lake-lamp-authz/lake-lamp-authz.service +++ b/server-tools/lake-lamp-authz/lake-lamp-authz.service @@ -17,6 +17,7 @@ PrivateTmp=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/var/lib/guanghu/lake-lamp-authz /var/lib/guanghu/repo-authorizations /var/lib/guanghu/repo-push-uploads /var/lib/guanghu/forgejo/repositories/bingshuo/hololake-platform.git /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/guanghu-ice-heart.git +ReadOnlyPaths=-/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/hololake-knowledge-base.git -/etc/guanghu/secrets/hololake-ai-providers.json RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 LockPersonality=true diff --git a/server-tools/lake-lamp-authz/server.js b/server-tools/lake-lamp-authz/server.js index d17df28..a26c394 100644 --- a/server-tools/lake-lamp-authz/server.js +++ b/server-tools/lake-lamp-authz/server.js @@ -10,6 +10,12 @@ const { sendSmtpMail } = require("./smtp-mailer"); const { executeRegisteredAction } = require("./action-client"); const { enqueueDeploymentEvent } = require("./deployment-event"); const { GuanghuRouter, loadDevices } = require("./guanghu-router"); +const { HoloLakeSessionManager } = require("./hololake-session"); +const { + HoloLakeAiGateway, + HoloLakeKnowledgeProvider, + loadAiProviders, +} = require("./hololake-capabilities"); const { loadRegistry: loadRepoPushRegistry, receiveBundle, @@ -71,6 +77,72 @@ function createApp(options = {}) { smtpUser: process.env.SMTP_USER || ownerEmail, smtpPass: process.env.QQ_SMTP_AUTH_CODE || "", })); + const hololakePepper = String( + options.hololakeSessionPepper + || process.env.HOLOLAKE_SESSION_PEPPER + || "", + ); + const hololakeSessionManager = options.hololakeSessionManager || ( + hololakePepper + ? new HoloLakeSessionManager({ + registeredEmails: approvers.map(item => item.email), + pepper: hololakePepper, + stateFile: Object.prototype.hasOwnProperty.call(options, "hololakeSessionStateFile") + ? options.hololakeSessionStateFile + : ( + process.env.HOLOLAKE_SESSION_STATE_FILE + || "/var/lib/guanghu/lake-lamp-authz/hololake-sessions.json" + ), + otpTtlSeconds: Number( + options.hololakeOtpTtlSeconds + || process.env.HOLOLAKE_OTP_TTL + || 10 * 60, + ), + sessionTtlSeconds: Number( + options.hololakeSessionTtlSeconds + || process.env.HOLOLAKE_ACCOUNT_SESSION_TTL + || 24 * 60 * 60, + ), + requestLimit: Number( + options.hololakeOtpRequestLimit + || process.env.HOLOLAKE_OTP_REQUEST_LIMIT + || 6, + ), + sendEmail, + }) + : null + ); + const hololakeKnowledgePath = String( + options.hololakeKnowledgeRepositoryPath + || process.env.HOLOLAKE_KNOWLEDGE_REPOSITORY_PATH + || "", + ); + const hololakeKnowledgeProvider = options.hololakeKnowledgeProvider || ( + hololakeKnowledgePath + ? new HoloLakeKnowledgeProvider({ + repositoryId: "bingshuo/hololake-knowledge-base", + repositoryPath: hololakeKnowledgePath, + maxArchiveBytes: Number( + options.hololakeKnowledgeMaxArchiveBytes + || process.env.HOLOLAKE_KNOWLEDGE_MAX_ARCHIVE_BYTES + || 128 * 1024 * 1024, + ), + }) + : null + ); + const hololakeAiProvidersFile = String( + options.hololakeAiProvidersFile + || process.env.HOLOLAKE_AI_PROVIDERS_FILE + || "", + ); + const hololakeAiProviders = options.hololakeAiProviders || ( + hololakeAiProvidersFile ? loadAiProviders(hololakeAiProvidersFile) : {} + ); + const hololakeAiGateway = options.hololakeAiGateway || ( + Object.keys(hololakeAiProviders).length > 0 + ? new HoloLakeAiGateway({ providers: hololakeAiProviders }) + : null + ); const mapGate = options.mapGate || new MapGate({ mapsDir: options.mapsDir || process.env.LAKE_LAMP_MAPS_DIR || "/etc/guanghu/navigation-maps", stateFile: Object.prototype.hasOwnProperty.call(options, "mapStateFile") ? options.mapStateFile : (process.env.LAKE_LAMP_MAP_STATE_FILE || "/var/lib/guanghu/lake-lamp-authz/map-acks.json"), @@ -139,6 +211,23 @@ function createApp(options = {}) { return { ok: true, order: issued.order }; } + function authenticateHoloLake(req) { + if (!hololakeSessionManager) { + return { ok: false, status: 503, error: "hololake_session_unavailable" }; + } + const token = bearer(req); + if (!token) return { ok: false, status: 401, error: "session_required" }; + const deviceId = String(req.headers["x-hololake-device-id"] || ""); + const authenticated = hololakeSessionManager.authenticate(token, deviceId); + if (!authenticated.ok) { + return { + ...authenticated, + status: authenticated.error === "session_device_mismatch" ? 403 : 401, + }; + } + return { ...authenticated, token, deviceId }; + } + function bindAndDeliver(created) { const inspected = manager.inspectHandoff(created.handoffToken); if (!inspected.ok) return { delivered: 0, approver: null, order: null }; @@ -190,6 +279,206 @@ function createApp(options = {}) { }, }); + if ( + req.method === "POST" + && url.pathname === "/api/hololake/session/email/request" + ) { + if (!hololakeSessionManager) { + return json(res, 503, failure("hololake_session_unavailable")); + } + const body = await readJson(req); + if (!body) return json(res, 400, failure("invalid_json")); + const headerDeviceId = String( + req.headers["x-hololake-device-id"] || "", + ); + if ( + headerDeviceId + && body.device_id + && !safeEqual(headerDeviceId, String(body.device_id)) + ) { + return json(res, 400, failure("invalid_device")); + } + const requested = await hololakeSessionManager.requestOtp({ + email: body.email, + deviceId: String(body.device_id || headerDeviceId), + networkKey: clientAddress(req), + }); + if (!requested.accepted) { + return json( + res, + requested.error === "rate_limited" ? 429 : 400, + failure(requested.error), + ); + } + return json(res, 202, { + accepted: true, + request_id: requested.request_id, + expires_in: Number( + options.hololakeOtpTtlSeconds + || process.env.HOLOLAKE_OTP_TTL + || 10 * 60, + ), + next_step: "如果邮箱已登记,输入邮件中的六位验证码。", + }); + } + + if ( + req.method === "POST" + && url.pathname === "/api/hololake/session/email/verify" + ) { + if (!hololakeSessionManager) { + return json(res, 503, failure("hololake_session_unavailable")); + } + const body = await readJson(req); + if (!body) return json(res, 400, failure("invalid_json")); + const headerDeviceId = String( + req.headers["x-hololake-device-id"] || "", + ); + if ( + headerDeviceId + && body.device_id + && !safeEqual(headerDeviceId, String(body.device_id)) + ) { + return json(res, 400, failure("invalid_device")); + } + const verified = hololakeSessionManager.verifyOtp({ + requestId: body.request_id, + code: body.code, + deviceId: String(body.device_id || headerDeviceId), + }); + if (!verified.ok) return json(res, 403, failure(verified.error)); + return json(res, 200, verified); + } + + if ( + (req.method === "GET" || req.method === "DELETE") + && url.pathname === "/api/hololake/session" + ) { + const authenticated = authenticateHoloLake(req); + if (!authenticated.ok) { + return json( + res, + authenticated.status, + failure(authenticated.error), + ); + } + if (req.method === "DELETE") { + const revoked = hololakeSessionManager.revoke( + authenticated.token, + authenticated.deviceId, + ); + return json(res, revoked.ok ? 200 : 401, revoked); + } + return json(res, 200, { + ok: true, + session: authenticated.session, + }); + } + + if ( + req.method === "GET" + && url.pathname === "/api/hololake/knowledge/manifest" + ) { + const authenticated = authenticateHoloLake(req); + if (!authenticated.ok) { + return json( + res, + authenticated.status, + failure(authenticated.error), + ); + } + if (!hololakeKnowledgeProvider) { + return json(res, 503, failure("knowledge_repository_unavailable")); + } + try { + return json(res, 200, hololakeKnowledgeProvider.manifest()); + } catch { + return json(res, 503, failure("knowledge_repository_unavailable")); + } + } + + if ( + req.method === "GET" + && url.pathname === "/api/hololake/knowledge/archive" + ) { + const authenticated = authenticateHoloLake(req); + if (!authenticated.ok) { + return json( + res, + authenticated.status, + failure(authenticated.error), + ); + } + if (!hololakeKnowledgeProvider) { + return json(res, 503, failure("knowledge_repository_unavailable")); + } + try { + const archive = hololakeKnowledgeProvider.archive( + url.searchParams.get("commit"), + ); + res.writeHead(200, { + "content-type": archive.content_type, + "content-length": archive.body.length, + "content-disposition": `attachment; filename="hololake-knowledge-${archive.commit}.zip"`, + "cache-control": "private, no-store", + "x-content-type-options": "nosniff", + "x-hololake-commit": archive.commit, + "x-content-sha256": archive.sha256, + }); + res.end(archive.body); + return; + } catch (error) { + const code = error && error.message === "knowledge_commit_not_current" + ? "knowledge_commit_not_current" + : "knowledge_archive_unavailable"; + return json(res, code === "knowledge_commit_not_current" ? 409 : 503, failure(code)); + } + } + + if ( + req.method === "GET" + && url.pathname === "/api/hololake/ai/catalog" + ) { + const authenticated = authenticateHoloLake(req); + if (!authenticated.ok) { + return json( + res, + authenticated.status, + failure(authenticated.error), + ); + } + if (!hololakeAiGateway) { + return json(res, 503, failure("ai_gateway_unavailable")); + } + return json(res, 200, hololakeAiGateway.catalog()); + } + + if ( + req.method === "POST" + && url.pathname === "/api/hololake/ai/execute" + ) { + const authenticated = authenticateHoloLake(req); + if (!authenticated.ok) { + return json( + res, + authenticated.status, + failure(authenticated.error), + ); + } + if (!hololakeAiGateway) { + return json(res, 503, failure("ai_gateway_unavailable")); + } + const body = await readJson(req); + if (!body) return json(res, 400, failure("invalid_json")); + try { + return json(res, 200, await hololakeAiGateway.execute(body)); + } catch (error) { + const code = String(error && error.message || "ai_gateway_failed"); + const status = code.startsWith("ai_upstream_") ? 502 : 400; + return json(res, status, failure(code)); + } + } + if (req.method === "POST" && url.pathname === "/api/repositories/resolve") { const body = await readJson(req); if (!body) return json(res, 400, failure("invalid_json"));