"use strict"; const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); class WorkOrderManager { constructor({ approvalTtl = 3 * 60 * 60, sessionTtl = 3 * 60 * 60, maxSessionLifetime = 24 * 60 * 60, stateFile = "" } = {}) { this.approvalTtl = approvalTtl; this.sessionTtl = sessionTtl; this.maxSessionLifetime = maxSessionLifetime; this.stateFile = stateFile; this.workorders = new Map(); this.sessions = new Map(); this.load(); } request({ persona, provenance = {}, target, scope, action, allowedActions = [action], description = "", resource = "" }, now = Date.now() / 1000) { const id = crypto.randomUUID(); const handoffToken = randomToken(); const claimToken = randomToken(); this.workorders.set(id, { id, persona: { pid: persona.pid, name: persona.name || persona.pid }, provenance: { system_entry: String(provenance.system_entry || "").slice(0, 120), software: String(provenance.software || "").slice(0, 120), model: String(provenance.model || "").slice(0, 120), instance: String(provenance.instance || "").slice(0, 120), }, target, scope, action, allowedActions: [...new Set(allowedActions)], description: String(description).slice(0, 500), resource: String(resource).slice(0, 180), createdAt: now, expiresAt: now + this.approvalTtl, handoffHash: hash(handoffToken), approvalHash: "", claimHash: hash(claimToken), emailSentAt: 0, state: "pending", claimed: false, }); this.persist(); return { id, handoffToken, claimToken, expiresIn: this.approvalTtl }; } inspectHandoff(handoffToken, now = Date.now() / 1000) { const order = this.findByHandoff(handoffToken); if (!order) return { ok: false, reason: "request_not_found" }; if (now > order.expiresAt) return { ok: false, reason: "request_expired" }; if (order.state !== "pending") return { ok: false, reason: "request_already_closed" }; return { ok: true, order: publicOrder(order) }; } issueApproval(handoffToken, now = Date.now() / 1000) { const inspected = this.inspectHandoff(handoffToken, now); if (!inspected.ok) return inspected; const order = this.workorders.get(inspected.order.id); if (order.emailSentAt) return { ok: false, reason: "approval_email_already_sent", order: publicOrder(order) }; const approvalToken = randomToken(); order.approvalHash = hash(approvalToken); order.emailSentAt = now; this.persist(); return { ok: true, approvalToken, order: publicOrder(order) }; } bindApprover(handoffToken, authorizerId) { const order = this.findByHandoff(handoffToken); if (!order || order.state !== "pending") return false; order.authorizerId = String(authorizerId || ""); this.persist(); return true; } failApprovalEmail(handoffToken) { const order = this.findByHandoff(handoffToken); if (!order || order.state !== "pending") return false; order.approvalHash = ""; order.emailSentAt = 0; this.persist(); return true; } inspectApproval(approvalToken, now = Date.now() / 1000) { const order = this.findByApproval(approvalToken); if (!order) return { ok: false, reason: "approval_not_found" }; if (now > order.expiresAt) return { ok: false, reason: "approval_expired" }; if (order.state !== "pending") return { ok: false, reason: "approval_already_used" }; return { ok: true, order: publicOrder(order) }; } approve(approvalToken, now = Date.now() / 1000) { const inspected = this.inspectApproval(approvalToken, now); if (!inspected.ok) return inspected; const order = this.workorders.get(inspected.order.id); order.state = "approved"; order.approvedAt = now; order.approvalHash = ""; order.handoffHash = ""; this.persist(); return { ok: true, order: publicOrder(order) }; } claim(id, claimToken, now = Date.now() / 1000) { const order = this.workorders.get(id); if (!order || !safeEqual(order.claimHash, hash(claimToken || ""))) return { ok: false, reason: "claim_not_found" }; if (now > order.expiresAt) return { ok: false, reason: "approval_expired" }; if (order.state !== "approved") return { ok: false, reason: order.state === "pending" ? "approval_pending" : "claim_unavailable" }; if (order.claimed) return { ok: false, reason: "claim_already_used" }; const sessionToken = randomToken(); this.sessions.set(hash(sessionToken), { workorderId: id, persona: order.persona, target: order.target, scope: order.scope, action: order.action, actions: order.allowedActions || [order.action], resource: order.resource || "", authorizerId: order.authorizerId || "", createdAt: now, expiresAt: now + this.sessionTtl, maxExpiresAt: now + this.maxSessionLifetime, renewals: 0, lastReceipt: { schema: "guanghu.operation-receipt/v1", state: "session_issued", diagnostic_code: "session_issued", occurred_at: now, next_step: "读取并确认该目标节点的实时导航图,然后才可执行已登记动作。", }, }); order.claimed = true; order.state = "claimed"; order.claimHash = ""; this.persist(); return { ok: true, sessionToken, expiresIn: this.sessionTtl, target: order.target, scope: order.scope, action: order.action, resource: order.resource || "", workorderId: id, receipt: { ...this.sessions.get(hash(sessionToken)).lastReceipt } }; } verifySession(sessionToken, persona, target, scope, action, now = Date.now() / 1000, resource = undefined) { const key = hash(sessionToken || ""); const session = this.sessions.get(key); if (!session) return { ok: false, reason: "session_not_found" }; if (now > session.expiresAt) { this.sessions.delete(key); this.persist(); return { ok: false, reason: "session_expired" }; } if (session.persona.pid !== persona.pid) return { ok: false, reason: "persona_mismatch" }; if (session.target !== target) return { ok: false, reason: "target_mismatch" }; if (session.scope !== scope) return { ok: false, reason: "scope_mismatch" }; if (!(session.actions || [session.action]).includes(action)) return { ok: false, reason: "action_mismatch" }; if (resource !== undefined && String(session.resource || "") !== String(resource || "")) return { ok: false, reason: "resource_mismatch" }; const maxExpiresAt = session.maxExpiresAt || session.createdAt + this.maxSessionLifetime; const activityExpiresAt = Math.min(now + this.sessionTtl, maxExpiresAt); const renewalWindow = Math.max(60, Math.floor(this.sessionTtl * 2 / 3)); let autoRenewed = false; if (now + renewalWindow >= session.expiresAt && activityExpiresAt > session.expiresAt) { session.expiresAt = activityExpiresAt; session.lastActivityAt = now; session.renewals = Number(session.renewals || 0) + 1; autoRenewed = true; this.persist(); } return { ok: true, autoRenewed, session: { ...session } }; } renewSession(sessionToken, persona, target, scope, now = Date.now() / 1000) { const key = hash(sessionToken || ""); const session = this.sessions.get(key); if (!session) return { ok: false, reason: "session_not_found" }; if (now > session.expiresAt) return { ok: false, reason: "session_expired" }; if (session.persona.pid !== persona.pid) return { ok: false, reason: "persona_mismatch" }; if (session.target !== target) return { ok: false, reason: "target_mismatch" }; if (session.scope !== scope) return { ok: false, reason: "scope_mismatch" }; const maxExpiresAt = session.maxExpiresAt || session.createdAt + this.maxSessionLifetime; const expiresAt = Math.min(now + this.sessionTtl, maxExpiresAt); if (expiresAt <= session.expiresAt) return { ok: false, reason: "session_lifetime_limit" }; session.expiresAt = expiresAt; session.renewals = Number(session.renewals || 0) + 1; this.persist(); return { ok: true, expiresAt, renewals: session.renewals, session: { ...session } }; } recordReceipt(sessionToken, receipt) { const session = this.sessions.get(hash(sessionToken || "")); if (!session) return { ok: false, reason: "session_not_found" }; session.lastReceipt = { ...receipt }; this.persist(); return { ok: true, receipt: { ...session.lastReceipt } }; } findByApproval(token) { const needle = hash(token || ""); for (const order of this.workorders.values()) { if (order.approvalHash && safeEqual(order.approvalHash, needle)) return order; } return null; } findByHandoff(token) { const needle = hash(token || ""); for (const order of this.workorders.values()) { if (order.handoffHash && safeEqual(order.handoffHash, needle)) return order; } return null; } load(now = Date.now() / 1000) { if (!this.stateFile || !fs.existsSync(this.stateFile)) return; try { const state = JSON.parse(fs.readFileSync(this.stateFile, "utf8")); for (const order of state.workorders || []) { if (order.expiresAt >= now) this.workorders.set(order.id, order); } for (const [tokenHash, session] of Object.entries(state.sessions || {})) { if (session.expiresAt >= now) this.sessions.set(tokenHash, session); } } catch { this.workorders.clear(); this.sessions.clear(); } } persist() { if (!this.stateFile) return; const stateDir = path.dirname(this.stateFile); if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); const temp = `${this.stateFile}.${process.pid}.tmp`; fs.writeFileSync(temp, JSON.stringify({ version: 1, workorders: [...this.workorders.values()], sessions: Object.fromEntries(this.sessions) }), { mode: 0o600 }); fs.renameSync(temp, this.stateFile); } } function randomToken() { return crypto.randomBytes(32).toString("base64url"); } function hash(value) { return crypto.createHash("sha256").update(String(value)).digest("hex"); } 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 publicOrder(order) { return { id: order.id, persona: { ...order.persona }, provenance: { ...(order.provenance || {}) }, target: order.target, scope: order.scope, action: order.action, allowed_actions: [...(order.allowedActions || [order.action])], description: order.description, resource: order.resource || "", createdAt: order.createdAt, expiresAt: order.expiresAt, state: order.state, approval_email_sent: Boolean(order.emailSentAt), }; } module.exports = { WorkOrderManager };