60 lines
6.2 KiB
TypeScript
60 lines
6.2 KiB
TypeScript
/** Explicit operator process only. No HTTP, runtime import, role provisioning or READY. */
|
|
import assert from 'node:assert/strict'
|
|
import path from 'node:path'
|
|
import {pathToFileURL} from 'node:url'
|
|
import {createPublicKey} from 'node:crypto'
|
|
import {readProtected,protectedDirectory,sha} from './writer-profile/operator-files.mjs'
|
|
import {approval,reconciliationApproval,poolOptions,roleKeys,assertJsDriverEnvironment} from './writer-profile/operator-contract.mjs'
|
|
import {deploymentRequest} from './database-writer-bootstrap-operator.mjs'
|
|
const hex=(v:unknown)=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v)
|
|
const parse=(b:Buffer)=>JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(b))
|
|
export async function main(args:string[]){
|
|
const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal']
|
|
assert(['publish','reconcile','preflight'].includes(args[0])&&args.length===1+2*flags.length)
|
|
assertJsDriverEnvironment(process.env)
|
|
const values=new Map<string,string>()
|
|
for(let i=1;i<args.length;i+=2){assert(args[i].startsWith('--')&&flags.includes(args[i].slice(2))&&!values.has(args[i].slice(2))&&args[i+1]);values.set(args[i].slice(2),args[i+1])}
|
|
const get=(name:string)=>values.get(name)!
|
|
for(const flag of flags.filter(f=>f.endsWith('sha256')))assert(hex(get(flag)))
|
|
const bootstrap=parse(await readProtected(get('bootstrap'),32768,get('bootstrap-sha256')))
|
|
assert(bootstrap.format==='application-writer-bootstrap-v1'&&bootstrap.publicationAuthorized===false&&bootstrap.readyAuthorized===false)
|
|
const request=deploymentRequest(bootstrap.deploymentRequest),subject=bootstrap.verificationSubject
|
|
assert(subject.installationId===request.installationId&&hex(subject.publicKeySpkiSha256)&&sha(createPublicKey(subject.publicKeyPem).export({format:'der',type:'spki'}))===subject.publicKeySpkiSha256)
|
|
const pinned=(args[0]==='reconcile'?reconciliationApproval:approval)(parse(await readProtected(get('approval'),16384,get('approval-sha256'))))
|
|
const definition=parse(await readProtected(get('definition'),4194304,get('definition-sha256')))
|
|
const transport=parse(await readProtected(get('transport'),16384,get('transport-sha256')))
|
|
const ca=await readProtected(get('ca'),65536,request.tls.caSha256)
|
|
const options=poolOptions(transport,request,ca)
|
|
await protectedDirectory(get('journal'));await protectedDirectory(get('source-registry'))
|
|
const privateKeyFile=path.join(path.dirname(get('bootstrap')),'signing-private.pem')
|
|
if(args[0]==='publish'){const key=await readProtected(privateKeyFile,8192);key.fill(0)}
|
|
// All explicit trust files are checked before app helper/PG imports. No .env URL is used.
|
|
// Existing source helper loads config/dotenv. Complete it BEFORE pg's first
|
|
// import so a newly loaded NODE_PG_FORCE_NATIVE cannot select another driver.
|
|
const {createDatabaseSourceBindingVerifier}=await import('../src/db/database-source-binding.js')
|
|
assertJsDriverEnvironment(process.env)
|
|
const [{default:pg},{createConnectedPublisher},{collectSource44Catalogue,approvedDefinition}]=await Promise.all([
|
|
import('pg'),import('./writer-profile/publisher.mjs'),import('./writer-profile/catalogue.mjs')])
|
|
assertJsDriverEnvironment(process.env)
|
|
assert.equal(approvedDefinition(definition),pinned.definitionSha256)
|
|
const installation=Object.freeze({...subject,roles:Object.fromEntries(roleKeys.map(k=>[k,request.roles[k].signedOid])),roleBindings:request.roles,expectedSource:request.source})
|
|
const verifier=createDatabaseSourceBindingVerifier({registryDirectory:get('source-registry'),installationId:subject.installationId,publicKeySpkiSha256:get('source-spki-sha256'),currentHeadSha256:request.sourceHeadSha256})
|
|
const publisher=createConnectedPublisher(installation,(s:Parameters<typeof collectSource44Catalogue>[0])=>collectSource44Catalogue(s,installation),verifier,pinned)
|
|
const pool=new pg.Pool(options)
|
|
// A pool-level async error never becomes raw stdout or a retry signal.
|
|
let asyncFailure=false;pool.on('error',()=>{asyncFailure=true})
|
|
let result,primary:unknown,failed=false
|
|
try{result=args[0]==='preflight'?await publisher.preflightCurrentProfile(pool,{deadlineAt:Date.now()+120000}):args[0]==='reconcile'?await publisher.reconcilePendingPublication(pool,{journalDirectory:get('journal'),deadlineAt:Date.now()+120000}):await publisher.publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256:pinned.expectedHeadSha256,journalDirectory:get('journal'),validityMs:pinned.validityMs,deadlineAt:Date.now()+120000})}
|
|
catch(error){primary=error;failed=true}
|
|
try{await pool.end()}catch(error){primary??=error;failed=true}
|
|
if(args[0]==='preflight'&&result?.state==='matched'&&!failed&&!asyncFailure)return {event:'application-writer-profile-preflight',outcome:'matched',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,sourceMatched:true,rolesMatched:true,catalogueMatched:true,writerAuthorized:false,cleanupUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
|
|
if(result?.outcome==='committed')return {event:'application-writer-profile-publication',outcome:'committed',epoch:result.epoch,headSha256:result.headSha256,notAfter:result.notAfter,
|
|
cleanupUnconfirmed:result.cleanupUnconfirmed||asyncFailure||failed,journalUnconfirmed:!!result.journalUnconfirmed,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
|
|
if(args[0]==='reconcile'&&result&&!failed&&!asyncFailure&&['idle','confirmed','not-committed'].includes(result.state))return {event:'application-writer-profile-reconciliation',outcome:result.state,headSha256:result.headSha256??null,cleanupUnconfirmed:false,journalUnconfirmed:false,automaticReplay:false,generationAuthenticated:false,readyAuthorized:false}
|
|
throw primary??Error('Publisher terminal unconfirmed')
|
|
}
|
|
if(process.argv[1]&&import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href){
|
|
try{const result=await main(process.argv.slice(2));console.log(JSON.stringify(result));if(result.cleanupUnconfirmed||result.journalUnconfirmed)process.exitCode=2}
|
|
catch{console.error(JSON.stringify({event:'application-writer-profile-publication-refused',automaticReplay:false,inspectPendingBeforeRetry:true,readyAuthorized:false}));process.exitCode=1}
|
|
}
|