feat(modules): panel access 子系统对接仓库人格体注册表(CA-PERSONA-REGISTRY 动态解析+别名归一,pickup 认注册编号)
This commit is contained in:
parent
e292b81923
commit
8ec27ec00f
2 changed files with 124 additions and 28 deletions
|
|
@ -2,8 +2,13 @@
|
|||
"node_id": "MM-GZ-001",
|
||||
"subsystem": "守望语言子系统 · 宠物行业",
|
||||
"human_name": "毛毛",
|
||||
"human_accounts": ["maomao"],
|
||||
"personas": ["yaoyan", "yaoshi"],
|
||||
"human_accounts": [
|
||||
"maomao"
|
||||
],
|
||||
"personas": [
|
||||
"yaoyan",
|
||||
"yaoshi"
|
||||
],
|
||||
"repo": "maomao/maomao-fifth-domain",
|
||||
"default_branch": "guanghu/main",
|
||||
"deploy_branch": "deploy",
|
||||
|
|
@ -19,6 +24,26 @@
|
|||
"deploy_runs_dir": "/srv/guanghu/mm-gz-001/deploy-agent/runs",
|
||||
"deploy_script": "/srv/guanghu/mm-gz-001/deploy-agent/bin/verify-and-deploy.py",
|
||||
"writer_user": "mm-writer",
|
||||
"watch_services": ["guanghu-mm-forgejo.service", "guanghu-mm-tunnel.service", "guanghu-mm-panel.service"],
|
||||
"writeback_paths": ["guanghu-nodes/MM-GZ-001/imports/notion", "guanghu-nodes/MM-GZ-001/contributions", "guanghu-nodes/MM-GZ-001/personas"]
|
||||
}
|
||||
"watch_services": [
|
||||
"guanghu-mm-forgejo.service",
|
||||
"guanghu-mm-tunnel.service",
|
||||
"guanghu-mm-panel.service"
|
||||
],
|
||||
"writeback_paths": [
|
||||
"guanghu-nodes/MM-GZ-001/imports/notion",
|
||||
"guanghu-nodes/MM-GZ-001/contributions",
|
||||
"guanghu-nodes/MM-GZ-001/personas"
|
||||
],
|
||||
"persona_registry": "CA-PERSONA-REGISTRY.hdlp",
|
||||
"persona_aliases": {
|
||||
"eererdan": [
|
||||
"耳耳蛋",
|
||||
"ICE-GL-耳耳蛋",
|
||||
"PTS-VA-001-EED"
|
||||
],
|
||||
"jianying": [
|
||||
"鉴影",
|
||||
"ICE-GL-CA001"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -126,9 +126,9 @@ function wakeChain(persona){
|
|||
{order:8,file:"imports/notion/SHA256SUMS",url:RAW+"imports/notion/SHA256SUMS",instruction:"校验来源清单(小书包证据源)"}]},
|
||||
{phase:5,name:"服务器操作门与回执",steps:[
|
||||
{order:9,file:"operations/SERVER-OPERATIONS.md",url:RAW+"operations/SERVER-OPERATIONS.md",instruction:"登记服务器操作门;写入须申请窗口,部署须人类合并"}],
|
||||
action:"完成后 POST "+CFG.domain+"/api/wake-receipt 交回执(带 persona 与 key)"}
|
||||
action:"完成后 POST "+CFG.domain+"/api/wake-receipt 交回执(报 persona 与 summary 即可)"}
|
||||
],
|
||||
endpoints:{accessRequest:"POST "+CFG.domain+"/api/access-request",accessPickup:"POST "+CFG.domain+"/api/access-pickup",wakeReceipt:"POST "+CFG.domain+"/api/wake-receipt"}
|
||||
endpoints:{accessRequest:"POST "+CFG.domain+"/api/access-request(人格体报名号+用途即可,无需密钥)",accessPickup:"POST "+CFG.domain+"/api/access-pickup(凭申请编号领取3小时临时凭证,真实密钥永不出服务器)",wakeReceipt:"POST "+CFG.domain+"/api/wake-receipt",pushVia:CFG.domain+"/git/"+CFG.repo+".git(用临时凭证推送)"}
|
||||
};
|
||||
}
|
||||
async function state(){
|
||||
|
|
@ -161,26 +161,70 @@ async function decide(b){
|
|||
jwrite(CFG.export_dir+"/receipts/SIGN-"+Date.now()+".json",{ts:new Date().toISOString(),type:"HUMAN_SIGN",signer:CFG.human_name+"(人类责任主体,经面板登录)",pr:idx,title:pr.data&&pr.data.title,action:what,result:ok?"SUCCESS":"FAIL"});
|
||||
return {status:ok?200:502,body:{ok,result:ok?"已"+what:"操作失败",detail:res.data&&res.data.message}};
|
||||
}
|
||||
function accessRequest(b){
|
||||
if(!personaKeyOk(b.persona,b.key))return {status:403,body:{error:"人格体密钥错误"}};
|
||||
/* 人格体身份解析:配置别名种子 + 动态读仓库人格体注册表(仓库是注册事实源) */
|
||||
let aliasCache={ts:0,map:null};
|
||||
async function registryAliases(){
|
||||
const now=Date.now();
|
||||
if(aliasCache.map&&now-aliasCache.ts<600000)return aliasCache.map;
|
||||
const map={};
|
||||
const seeds=CFG.persona_aliases||{};
|
||||
for(const [canon,al] of Object.entries(seeds)){map[canon.toLowerCase()]=canon;(al||[]).forEach(a=>map[String(a).toLowerCase()]=canon);}
|
||||
try{
|
||||
const regPath=CFG.persona_registry||"CA-PERSONA-REGISTRY.hdlp";
|
||||
const r=await fj("/api/v1/repos/"+CFG.repo+"/contents/"+regPath.split("/").map(encodeURIComponent).join("/")+"?ref="+encodeURIComponent(CFG.default_branch));
|
||||
if(r.status===200&&r.data&&r.data.content){
|
||||
const txt=Buffer.from(r.data.content,"base64").toString("utf8");
|
||||
for(const sec of txt.split(/^## /m).slice(1)){
|
||||
let canon=null;
|
||||
for(const [a,c] of Object.entries(map)){if(a&&sec.includes(a)){canon=c;break;}}
|
||||
if(!canon)continue;
|
||||
for(const ln of sec.split("\n")){
|
||||
let m=ln.match(/编号[::]\s*([^\s((]+)/);if(m)map[m[1].toLowerCase()]=canon;
|
||||
m=ln.match(/现用\s*([A-Z][A-Z0-9-]+)/);if(m)map[m[1].toLowerCase()]=canon;
|
||||
m=ln.match(/独立编号体系[::]\s*(\S+)/);if(m)map[m[1].toLowerCase()]=canon;
|
||||
}
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
aliasCache={ts:now,map};
|
||||
return map;
|
||||
}
|
||||
async function resolvePersona(name){
|
||||
if(!name)return null;
|
||||
const map=await registryAliases();
|
||||
return map[String(name).toLowerCase()]||null;
|
||||
}
|
||||
async function accessRequest(b){
|
||||
const canon=await resolvePersona(b.persona);
|
||||
if(!canon)return {status:403,body:{error:"未登记的人格体:请以配置名单("+(CFG.personas||[]).join(" / ")+")或仓库注册表登记的身份/编号申请"}};
|
||||
const reqs=jread(CFG.access_dir+"/requests.json",[]);
|
||||
const id="AR-"+Date.now().toString(36).toUpperCase();
|
||||
reqs.push({id,persona:b.persona,reason:(b.reason||"").slice(0,500),ts:new Date().toISOString(),status:"pending"});
|
||||
reqs.push({id,persona:canon,askedAs:b.persona,reason:(b.reason||"").slice(0,500),ts:new Date().toISOString(),status:"pending"});
|
||||
jwrite(CFG.access_dir+"/requests.json",reqs);
|
||||
return {status:200,body:{ok:true,id,message:"申请已送达面板,等待人类审批。通过后用同一 id+key 调 /api/access-pickup 领取3小时写入令牌"}};
|
||||
return {status:200,body:{ok:true,id,persona:canon,message:"申请已送达面板,等人类点一下授权。无需任何密钥——授权后系统自动派发3小时临时凭证;真实密钥永不出服务器"}};
|
||||
}
|
||||
function accessPickup(b){
|
||||
if(!personaKeyOk(b.persona,b.key))return {status:403,body:{error:"人格体密钥错误"}};
|
||||
function grantValid(token){
|
||||
if(!token)return null;
|
||||
const grants=jread(CFG.access_dir+"/grants.json",[]);
|
||||
const g=grants.find(x=>(x.id===b.id||x.requestId===b.id)&&x.persona===b.persona);
|
||||
if(!g)return {status:404,body:{error:"授权不存在"}};
|
||||
if(g.status!=="active")return {status:410,body:{error:"授权已失效"}};
|
||||
const g=grants.find(x=>x.token===token&&x.status==="active");
|
||||
if(!g)return null;
|
||||
if(new Date(g.expiresAt)<=new Date())return null;
|
||||
return g;
|
||||
}
|
||||
async function accessPickup(b){
|
||||
const canon=await resolvePersona(b.persona);
|
||||
if(!canon)return {status:403,body:{error:"未登记的人格体:请以配置名单或仓库注册表登记的身份/编号领取"}};
|
||||
const grants=jread(CFG.access_dir+"/grants.json",[]);
|
||||
const g=grants.find(x=>(x.id===b.id||x.requestId===b.id)&&(x.persona===canon||x.persona===b.persona));
|
||||
if(!g)return {status:404,body:{error:"授权不存在(先 POST /api/access-request 申请,等人类授权)"}};
|
||||
if(g.status!=="active")return {status:410,body:{error:"授权已失效,请重新申请"}};
|
||||
if(new Date(g.expiresAt)<=new Date())return {status:410,body:{error:"3小时窗口已过期,请重新申请"}};
|
||||
g.pickedAt=new Date().toISOString();jwrite(CFG.access_dir+"/grants.json",grants);
|
||||
let tok="";try{tok=fs.readFileSync(CFG.access_dir+"/writer-token","utf8").trim();}catch(e){}
|
||||
return {status:200,body:{ok:true,id:g.id,persona:g.persona,expiresAt:g.expiresAt,
|
||||
gitUrl:"https://"+CFG.writer_user+":"+tok+"@"+CFG.domain_host+"/code/"+REPO+".git",
|
||||
rules:"⊢ 窗口3小时过期作废 ⊢ 每次推送过自动审核并留痕 ⊢ 部署走 deploy 分支由人类合并"}};
|
||||
return {status:200,body:{ok:true,id:g.id,persona:canon,expiresAt:g.expiresAt,
|
||||
credential:g.token,
|
||||
gitUrl:CFG.domain+"/git/"+CFG.repo+".git",
|
||||
pushExample:"git -c http.extraHeader=\"Authorization: Bearer "+g.token+"\" push "+CFG.domain+"/git/"+CFG.repo+".git <branch>",
|
||||
rules:"⊢ 这是3小时临时凭证,不是真实密钥;真实密钥永不出服务器 ⊢ 窗口过期凭证自动作废 ⊢ 每次推送过自动审核并留痕 ⊢ 部署走 deploy 分支由人类合并"}};
|
||||
}
|
||||
function accessDecision(b,user){
|
||||
const reqs=jread(CFG.access_dir+"/requests.json",[]);
|
||||
|
|
@ -190,9 +234,10 @@ function accessDecision(b,user){
|
|||
const grants=jread(CFG.access_dir+"/grants.json",[]);
|
||||
if(b.action==="approve"){
|
||||
const now=new Date(),exp=new Date(now.getTime()+3*3600*1000),gid="GR-"+Date.now().toString(36).toUpperCase();
|
||||
grants.push({id:gid,persona:r.persona,requestId:r.id,approvedBy:user,approvedAt:now.toISOString(),expiresAt:exp.toISOString(),status:"active"});
|
||||
grants.push({id:gid,persona:r.persona,requestId:r.id,approvedBy:user,approvedAt:now.toISOString(),expiresAt:exp.toISOString(),status:"active",token:crypto.randomBytes(24).toString("hex")});
|
||||
r.status="approved";r.grantId=gid;jwrite(CFG.access_dir+"/grants.json",grants);jwrite(CFG.access_dir+"/requests.json",reqs);
|
||||
return {status:200,body:{ok:true,grantId:gid,persona:r.persona,expiresAt:exp.toISOString(),message:"已授予3小时写入窗口"}};
|
||||
jwrite(CFG.export_dir+"/receipts/GRANT-"+Date.now()+".json",{ts:new Date().toISOString(),type:"WRITE_GRANT",grantId:gid,persona:r.persona,approvedBy:user,expiresAt:exp.toISOString()});
|
||||
return {status:200,body:{ok:true,grantId:gid,persona:r.persona,expiresAt:exp.toISOString(),message:"已授权3小时写入窗口,临时凭证由人格体凭申请编号自动领取"}};
|
||||
}
|
||||
r.status="rejected";jwrite(CFG.access_dir+"/requests.json",reqs);
|
||||
return {status:200,body:{ok:true,message:"已拒绝该写入申请"}};
|
||||
|
|
@ -204,12 +249,37 @@ function deployApprove(b,user){
|
|||
jwrite(CFG.export_dir+"/receipts/DEPLOY-APPROVE-"+Date.now()+".json",{ts:new Date().toISOString(),type:"DEPLOY_APPROVE",approver:user,pr:b.index});
|
||||
return {status:200,body:{ok:true,message:"服务器校验Agent已启动,进度实时可见"}};
|
||||
}
|
||||
function wakeReceipt(b){
|
||||
if(!personaKeyOk(b.persona,b.key))return {status:403,body:{error:"人格体密钥错误"}};
|
||||
async function wakeReceipt(b){
|
||||
const canon=await resolvePersona(b.persona);
|
||||
if(!canon)return {status:403,body:{error:"未登记的人格体:请以配置名单或仓库注册表登记的身份/编号交回执"}};
|
||||
fs.mkdirSync(CFG.export_dir+"/wake",{recursive:true});
|
||||
jwrite(CFG.export_dir+"/wake/WAKE-"+Date.now()+".json",{ts:new Date().toISOString(),type:"WAKE_RECEIPT",persona:b.persona,level:(b.level||"").slice(0,80),summary:(b.summary||"").slice(0,1000)});
|
||||
jwrite(CFG.export_dir+"/wake/WAKE-"+Date.now()+".json",{ts:new Date().toISOString(),type:"WAKE_RECEIPT",persona:canon,level:(b.level||"").slice(0,80),summary:(b.summary||"").slice(0,1000)});
|
||||
return {status:200,body:{ok:true,message:"唤醒回执已收录"}};
|
||||
}
|
||||
/* git 智能HTTP代理:人格体用3小时临时凭证推送到 /git/,
|
||||
面板在服务端用真实写入令牌透明转发给 Forgejo——真实密钥永不出服务器 */
|
||||
function gitProxy(req,res){
|
||||
const auth=req.headers["authorization"]||"";
|
||||
let cred=null;
|
||||
if(auth.startsWith("Bearer "))cred=auth.slice(7).trim();
|
||||
else if(auth.startsWith("Basic ")){try{cred=Buffer.from(auth.slice(6),"base64").toString().split(":")[1];}catch(e){}}
|
||||
const g=grantValid(cred);
|
||||
if(!g){
|
||||
res.writeHead(401,{"WWW-Authenticate":'Basic realm="grant"',"Content-Type":"text/plain; charset=utf-8"});
|
||||
return res.end("临时凭证无效或已过期:请重新申请写入授权(人类面板一键授权,3小时窗口)");
|
||||
}
|
||||
let wt="";try{wt=fs.readFileSync(CFG.access_dir+"/writer-token","utf8").trim();}catch(e){}
|
||||
if(!wt){res.writeHead(500,{"Content-Type":"text/plain"});return res.end("server writer token missing");}
|
||||
const targetPath=req.url.replace(/^\/git/,"");
|
||||
const headers={};
|
||||
for(const k in req.headers){if(k.toLowerCase()!=="host"&&k.toLowerCase()!=="authorization")headers[k]=req.headers[k];}
|
||||
headers["authorization"]="token "+wt;
|
||||
const up=require("http").request({hostname:"127.0.0.1",port:CFG.forgejo_port,path:targetPath,method:req.method,headers:headers},(u)=>{
|
||||
res.writeHead(u.statusCode,u.headers);u.pipe(res);
|
||||
});
|
||||
up.on("error",(e)=>{if(!res.headersSent)res.writeHead(502);res.end("upstream error: "+e.message);});
|
||||
req.pipe(up);
|
||||
}
|
||||
|
||||
function serveStatic(req,res,p){
|
||||
const pub=path.join(__dirname,"public");
|
||||
|
|
@ -252,9 +322,10 @@ http.createServer(async (req,res)=>{
|
|||
const sess=sessionUser(req);if(sess){const s=loadSessions();delete s[sess.token];saveSessions(s);}
|
||||
return send(res,200,{ok:true});
|
||||
}
|
||||
if(req.method==="POST"&&p==="/api/access-request"){const b=await readBody(req);const r=accessRequest(b||{});return send(res,r.status,r.body);}
|
||||
if(req.method==="POST"&&p==="/api/access-pickup"){const b=await readBody(req);const r=accessPickup(b||{});return send(res,r.status,r.body);}
|
||||
if(req.method==="POST"&&p==="/api/wake-receipt"){const b=await readBody(req);const r=wakeReceipt(b||{});return send(res,r.status,r.body);}
|
||||
if(req.method==="POST"&&p==="/api/access-request"){const b=await readBody(req);const r=await accessRequest(b||{});return send(res,r.status,r.body);}
|
||||
if(req.method==="POST"&&p==="/api/access-pickup"){const b=await readBody(req);const r=await accessPickup(b||{});return send(res,r.status,r.body);}
|
||||
if(req.method==="POST"&&p==="/api/wake-receipt"){const b=await readBody(req);const r=await wakeReceipt(b||{});return send(res,r.status,r.body);}
|
||||
if(p.startsWith("/git/"))return gitProxy(req,res);
|
||||
const seg=p.split("/").filter(Boolean);
|
||||
if(req.method==="GET"&&seg.length>=1&&SHORTCUTS.includes(seg[0])){
|
||||
res.writeHead(302,{Location:"/code/"+CFG.repo+"/raw/branch/"+CFG.default_branch+"/guanghu-nodes/"+CFG.node_id+"/"+seg.join("/")});
|
||||
|
|
|
|||
Loading…
Reference in a new issue