2026-07-24 10:39:10 +08:00
|
|
|
"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,
|
2026-08-03 00:00:04 +08:00
|
|
|
approvalChannel: "",
|
2026-07-24 10:39:10 +08:00
|
|
|
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) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 17:56:47 +08:00
|
|
|
bindApprover(handoffToken, authorizerId) {
|
|
|
|
|
const order = this.findByHandoff(handoffToken);
|
|
|
|
|
if (!order || order.state !== "pending") return false;
|
|
|
|
|
order.authorizerId = String(authorizerId || "");
|
|
|
|
|
this.persist();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 10:39:10 +08:00
|
|
|
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;
|
2026-08-03 00:00:04 +08:00
|
|
|
order.approvalChannel = "email";
|
2026-07-24 10:39:10 +08:00
|
|
|
order.approvalHash = "";
|
|
|
|
|
order.handoffHash = "";
|
|
|
|
|
this.persist();
|
|
|
|
|
return { ok: true, order: publicOrder(order) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 23:37:26 +08:00
|
|
|
pendingForApprover(authorizerId, now = Date.now() / 1000) {
|
|
|
|
|
const id = String(authorizerId || "");
|
|
|
|
|
return [...this.workorders.values()]
|
|
|
|
|
.filter(order => (
|
|
|
|
|
order.state === "pending"
|
|
|
|
|
&& order.authorizerId === id
|
|
|
|
|
&& now <= order.expiresAt
|
|
|
|
|
))
|
|
|
|
|
.sort((left, right) => left.createdAt - right.createdAt)
|
|
|
|
|
.map(publicOrder);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inspectPending(workorderId, now = Date.now() / 1000) {
|
|
|
|
|
const order = this.workorders.get(String(workorderId || ""));
|
|
|
|
|
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) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
approveById(workorderId, authorizerId, now = Date.now() / 1000) {
|
|
|
|
|
const order = this.workorders.get(String(workorderId || ""));
|
|
|
|
|
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" };
|
|
|
|
|
if (!order.authorizerId || order.authorizerId !== String(authorizerId || "")) {
|
|
|
|
|
return { ok: false, reason: "authorizer_mismatch" };
|
|
|
|
|
}
|
|
|
|
|
order.state = "approved";
|
|
|
|
|
order.approvedAt = now;
|
2026-08-03 00:00:04 +08:00
|
|
|
order.approvalChannel = "guanghu_router";
|
2026-07-29 23:37:26 +08:00
|
|
|
order.approvalHash = "";
|
|
|
|
|
order.handoffHash = "";
|
|
|
|
|
this.persist();
|
|
|
|
|
return { ok: true, order: publicOrder(order) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 10:39:10 +08:00
|
|
|
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), {
|
2026-07-26 15:26:21 +08:00
|
|
|
workorderId: id,
|
2026-07-24 10:39:10 +08:00
|
|
|
persona: order.persona,
|
|
|
|
|
target: order.target,
|
|
|
|
|
scope: order.scope,
|
|
|
|
|
action: order.action,
|
|
|
|
|
actions: order.allowedActions || [order.action],
|
|
|
|
|
resource: order.resource || "",
|
2026-07-26 17:56:47 +08:00
|
|
|
authorizerId: order.authorizerId || "",
|
2026-08-03 00:00:04 +08:00
|
|
|
approvalChannel: order.approvalChannel || "legacy_unspecified",
|
2026-07-24 10:39:10 +08:00
|
|
|
createdAt: now,
|
|
|
|
|
expiresAt: now + this.sessionTtl,
|
|
|
|
|
maxExpiresAt: now + this.maxSessionLifetime,
|
|
|
|
|
renewals: 0,
|
2026-07-26 15:26:21 +08:00
|
|
|
lastReceipt: {
|
|
|
|
|
schema: "guanghu.operation-receipt/v1",
|
|
|
|
|
state: "session_issued",
|
|
|
|
|
diagnostic_code: "session_issued",
|
|
|
|
|
occurred_at: now,
|
|
|
|
|
next_step: "读取并确认该目标节点的实时导航图,然后才可执行已登记动作。",
|
|
|
|
|
},
|
2026-07-24 10:39:10 +08:00
|
|
|
});
|
|
|
|
|
order.claimed = true;
|
|
|
|
|
order.state = "claimed";
|
|
|
|
|
order.claimHash = "";
|
|
|
|
|
this.persist();
|
2026-07-26 15:26:21 +08:00
|
|
|
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 } };
|
2026-07-24 10:39:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 } };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 15:26:21 +08:00
|
|
|
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 } };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 10:39:10 +08:00
|
|
|
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 };
|