deploy-account-binding.mjs 6.6 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import {fileURLToPath} from 'node:url';
  5. const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
  6. const sha=x=>crypto.createHash('sha256').update(x).digest('hex');
  7. const source=s=>s.replaceAll('\r\n','\n').replace(/^import .*;\n/gm,'').replace(/^export /gm,'');
  8. const baseline={
  9. ksfStartWorkshopSession:['yKpUay6RDG','08fb43393d88351e50e7b73d296d4675bbc22d266bc73ed22db57446fd702c68'],
  10. ksfSaveWorkshopTask:['f0faHuk5Br','5ff81e847857dd869522a065423978fc7744b3a80dad900bd18d58850d3fa531'],
  11. ksfGetPublicBoard:['inPfC51rvn','e5067736cd5c5f6d8b2244e2a7757ed23719dc0a85b9b2fba2c0e0b83cc437fb'],
  12. ksfGetIngestContract:['dWSrHp4FfQ','84aa7bfd086308f6d59711c4c4cd6e7a068688f7b98c7b4773ba0ef1580a3586']
  13. };
  14. export function patchAccountBinding(code){
  15. if(code.includes('// KSF_ACCOUNT_BINDING_V1'))return code;
  16. let next=code.replaceAll('\r\n','\n');
  17. const replace=(a,b)=>{if(next.split(a).length!==2)throw Error('ACCOUNT_PATCH_BASELINE_MISMATCH');next=next.replace(a,b);};
  18. replace('attribution:{participantName:p.participant.name,participantKey:',"attribution:{...(participantAccount?.platform==='fmode'?{accountProfileKey:p.participant.key}:{}),participantName:p.participant.name,participantKey:");
  19. const bootstrap=fs.readFileSync(path.join(root,'server/src/bootstrap.mjs'),'utf8').replaceAll('\r\n','\n');
  20. const page=bootstrap.slice(bootstrap.indexOf(" if(name==='ksfWorkshopAccount'"),bootstrap.indexOf(' attachBoardActionHandlers(app'));
  21. replace(" if(name==='ksfConfirmationPage'){"," attachAccountBindingHandlers(app,{store,settings});\n"+page+" if(name==='ksfConfirmationPage'){");
  22. replace(' const actor=await app.authenticate(request.headers);\n return response.json'," const actor=name==='ksfWorkshopAccount'&&params.action==='confirm'\n ?await app.authenticateWorkshopPlatform(request.headers):await app.authenticate(request.headers);\n return response.json");
  23. next='// KSF_ACCOUNT_BINDING_V1\n'+source(fs.readFileSync(path.join(root,'src/core/workshop-roster.js'),'utf8'))+'\nconst ACCOUNT_LINK_HTML='+JSON.stringify(fs.readFileSync(path.join(root,'server/web/account-link.html'),'utf8').replaceAll('\r\n','\n'))+';\n'+source(fs.readFileSync(path.join(root,'server/src/account-binding.mjs'),'utf8'))+'\n'+next;
  24. new Function(next);return next;
  25. }
  26. export async function deployAccountBinding({request,apply=false,outDir=path.join(root,'work/account-binding-private')}){
  27. const manifest=JSON.parse(fs.readFileSync(path.join(root,'deploy/account-binding-20261112/functions.json'),'utf8'));
  28. const plans=[];
  29. for(const[name,[id,hash]]of Object.entries(baseline)){
  30. const row=await request('GET','/classes/Function/'+id);
  31. if(row.name!==name||row.path!=='/'+name)throw Error('FUNCTION_IDENTITY_MISMATCH');
  32. const expected=manifest.functions.find(p=>p.name===name);
  33. if(!expected||expected.objectId!==id||expected.beforeSha256!==hash||![hash,expected.afterSha256].includes(sha(row.code)))throw Error('UNKNOWN_ACCOUNT_FUNCTION_VERSION');
  34. const code=patchAccountBinding(row.code);if(sha(code)!==expected.afterSha256)throw Error('ACCOUNT_PAYLOAD_HASH_MISMATCH');
  35. plans.push({row,code});
  36. }
  37. const name='ksfWorkshopAccount',existing=(await request('GET','/classes/Function?'+new URLSearchParams({where:JSON.stringify({name})}))).results;
  38. if(existing.length>1)throw Error('DUPLICATE_ACCOUNT_FUNCTION');
  39. const code=plans[0].code.replace('runDatabaseFunction("ksfStartWorkshopSession",request','runDatabaseFunction("ksfWorkshopAccount",request');
  40. if(!code.includes('runDatabaseFunction("ksfWorkshopAccount",request'))throw Error('NEW_FUNCTION_HANDLER_MISMATCH');new Function(code);
  41. if(sha(code)!==manifest.functions.find(p=>p.name===name)?.afterSha256)throw Error('ACCOUNT_PAYLOAD_HASH_MISMATCH');
  42. if(existing[0]&&existing[0].code!==code)throw Error('ACCOUNT_ENDPOINT_ALREADY_EXISTS');
  43. plans.push({row:existing[0]||{name,path:'/'+name,ACL:{}},code});
  44. const receipt={ok:true,readOnly:!apply,schemaChanges:0,permissionsChanged:false,staticBoardDeployed:false,functions:plans.map(p=>({name:p.row.name,objectId:p.row.objectId||null,beforeSha256:p.row.code?sha(p.row.code):null,afterSha256:sha(p.code),action:p.row.code===p.code?'unchanged':p.row.objectId?'update':'create'}))};
  45. if(!apply)return receipt;
  46. fs.mkdirSync(outDir,{recursive:true,mode:0o700});
  47. const backup=path.join(outDir,'deployment-before-'+crypto.randomUUID()+'.private.json');fs.writeFileSync(backup,JSON.stringify(plans.map(p=>p.row)),{mode:0o600,flag:'wx'});receipt.backup=backup;
  48. const done=[];
  49. try{
  50. // Publish the binding endpoint before enabling the new-session guard.
  51. for(const p of [...plans].reverse()){
  52. if(p.row.code===p.code)continue;
  53. if(p.row.objectId){const current=await request('GET','/classes/Function/'+p.row.objectId);if(current.code!==p.row.code)throw Error('CONCURRENT_FUNCTION_CHANGE');}
  54. const route='/classes/Function'+(p.row.objectId?'/'+p.row.objectId:'');
  55. const result=await request(p.row.objectId?'PUT':'POST',route,p.row.objectId?{code:p.code}:{...p.row,code:p.code});
  56. p.appliedId=p.row.objectId||result.objectId;done.push(p);
  57. const read=await request('GET','/classes/Function/'+p.appliedId);
  58. if(read.code!==p.code||read.name!==p.row.name||read.path!==p.row.path||JSON.stringify(read.ACL)!==JSON.stringify(p.row.ACL))throw Error('FUNCTION_READBACK_MISMATCH');
  59. }
  60. receipt.readbackVerified=true;receipt.functions.forEach(p=>{p.objectId||=plans.find(x=>x.row.name===p.name).appliedId;});return receipt;
  61. }catch(e){
  62. let rollbackComplete=true;
  63. for(const p of done.reverse())try{const route='/classes/Function/'+p.appliedId,r=await request('GET',route);if(r.code!==p.code){rollbackComplete=false;continue;}if(p.row.objectId)await request('PUT',route,{code:p.row.code});else await request('DELETE',route);}catch{rollbackComplete=false;}
  64. throw Object.assign(Error('ACCOUNT_DEPLOYMENT_STOPPED'),{receipt:{...receipt,ok:false,rollbackComplete}});
  65. }
  66. }
  67. if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
  68. try{if(!['plan','apply'].includes(process.argv[2])||process.argv.length!==3)throw Error('USAGE_PLAN_OR_APPLY');const{request}=await import('../server/scripts/platform-client.mjs');const r=await deployAccountBinding({request,apply:process.argv[2]==='apply'});fs.mkdirSync(path.join(root,'work/account-binding-private'),{recursive:true,mode:0o700});fs.writeFileSync(path.join(root,'work/account-binding-private/deploy-'+process.argv[2]+'.json'),JSON.stringify(r,null,2),{mode:0o600});console.log(JSON.stringify(r,null,2));}
  69. catch(e){console.error(JSON.stringify(e.receipt||{ok:false,error:/^[A-Z_]+$/.test(e.message)?e.message:'ACCOUNT_DEPLOYMENT_STOPPED'}));process.exitCode=1;}
  70. }