import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import {fileURLToPath} from 'node:url'; const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); const sha=x=>crypto.createHash('sha256').update(x).digest('hex'); const source=s=>s.replaceAll('\r\n','\n').replace(/^import .*;\n/gm,'').replace(/^export /gm,''); const baseline={ ksfStartWorkshopSession:['yKpUay6RDG','08fb43393d88351e50e7b73d296d4675bbc22d266bc73ed22db57446fd702c68'], ksfSaveWorkshopTask:['f0faHuk5Br','5ff81e847857dd869522a065423978fc7744b3a80dad900bd18d58850d3fa531'], ksfGetPublicBoard:['inPfC51rvn','e5067736cd5c5f6d8b2244e2a7757ed23719dc0a85b9b2fba2c0e0b83cc437fb'], ksfGetIngestContract:['dWSrHp4FfQ','84aa7bfd086308f6d59711c4c4cd6e7a068688f7b98c7b4773ba0ef1580a3586'] }; export function patchAccountBinding(code){ if(code.includes('// KSF_ACCOUNT_BINDING_V1'))return code; let next=code.replaceAll('\r\n','\n'); const replace=(a,b)=>{if(next.split(a).length!==2)throw Error('ACCOUNT_PATCH_BASELINE_MISMATCH');next=next.replace(a,b);}; replace('attribution:{participantName:p.participant.name,participantKey:',"attribution:{...(participantAccount?.platform==='fmode'?{accountProfileKey:p.participant.key}:{}),participantName:p.participant.name,participantKey:"); const bootstrap=fs.readFileSync(path.join(root,'server/src/bootstrap.mjs'),'utf8').replaceAll('\r\n','\n'); const page=bootstrap.slice(bootstrap.indexOf(" if(name==='ksfWorkshopAccount'"),bootstrap.indexOf(' attachBoardActionHandlers(app')); replace(" if(name==='ksfConfirmationPage'){"," attachAccountBindingHandlers(app,{store,settings});\n"+page+" if(name==='ksfConfirmationPage'){"); replace(' const actor=await app.authenticate(request.headers);\n return response.json'," const actor=name==='ksfWorkshopAccount'&¶ms.action==='confirm'\n ?await app.authenticateWorkshopPlatform(request.headers):await app.authenticate(request.headers);\n return response.json"); 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; new Function(next);return next; } export async function deployAccountBinding({request,apply=false,outDir=path.join(root,'work/account-binding-private')}){ const manifest=JSON.parse(fs.readFileSync(path.join(root,'deploy/account-binding-20261112/functions.json'),'utf8')); const plans=[]; for(const[name,[id,hash]]of Object.entries(baseline)){ const row=await request('GET','/classes/Function/'+id); if(row.name!==name||row.path!=='/'+name)throw Error('FUNCTION_IDENTITY_MISMATCH'); const expected=manifest.functions.find(p=>p.name===name); if(!expected||expected.objectId!==id||expected.beforeSha256!==hash||![hash,expected.afterSha256].includes(sha(row.code)))throw Error('UNKNOWN_ACCOUNT_FUNCTION_VERSION'); const code=patchAccountBinding(row.code);if(sha(code)!==expected.afterSha256)throw Error('ACCOUNT_PAYLOAD_HASH_MISMATCH'); plans.push({row,code}); } const name='ksfWorkshopAccount',existing=(await request('GET','/classes/Function?'+new URLSearchParams({where:JSON.stringify({name})}))).results; if(existing.length>1)throw Error('DUPLICATE_ACCOUNT_FUNCTION'); const code=plans[0].code.replace('runDatabaseFunction("ksfStartWorkshopSession",request','runDatabaseFunction("ksfWorkshopAccount",request'); if(!code.includes('runDatabaseFunction("ksfWorkshopAccount",request'))throw Error('NEW_FUNCTION_HANDLER_MISMATCH');new Function(code); if(sha(code)!==manifest.functions.find(p=>p.name===name)?.afterSha256)throw Error('ACCOUNT_PAYLOAD_HASH_MISMATCH'); if(existing[0]&&existing[0].code!==code)throw Error('ACCOUNT_ENDPOINT_ALREADY_EXISTS'); plans.push({row:existing[0]||{name,path:'/'+name,ACL:{}},code}); 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'}))}; if(!apply)return receipt; fs.mkdirSync(outDir,{recursive:true,mode:0o700}); 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; const done=[]; try{ // Publish the binding endpoint before enabling the new-session guard. for(const p of [...plans].reverse()){ if(p.row.code===p.code)continue; 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');} const route='/classes/Function'+(p.row.objectId?'/'+p.row.objectId:''); const result=await request(p.row.objectId?'PUT':'POST',route,p.row.objectId?{code:p.code}:{...p.row,code:p.code}); p.appliedId=p.row.objectId||result.objectId;done.push(p); const read=await request('GET','/classes/Function/'+p.appliedId); 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'); } receipt.readbackVerified=true;receipt.functions.forEach(p=>{p.objectId||=plans.find(x=>x.row.name===p.name).appliedId;});return receipt; }catch(e){ let rollbackComplete=true; 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;} throw Object.assign(Error('ACCOUNT_DEPLOYMENT_STOPPED'),{receipt:{...receipt,ok:false,rollbackComplete}}); } } if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){ 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));} catch(e){console.error(JSON.stringify(e.receipt||{ok:false,error:/^[A-Z_]+$/.test(e.message)?e.message:'ACCOUNT_DEPLOYMENT_STOPPED'}));process.exitCode=1;} }