feat(mcp): расширить управление маршрутизацией, доступом и обслуживанием базы
This commit is contained in:
1 parent
9240e8be11
commit
cffcfada88
125 files changed
+6021
-602
No files matched your search
@@ -20,7 +20,7 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r
|
||||
const packageJson=JSON.parse(await readFile(path.join(backend,'package.json'),'utf8'))
|
||||
await mkdir(projectBackend,{recursive:true});await writeFile(path.join(projectBackend,'package.json'),JSON.stringify({type:'module',dependencies:packageJson.dependencies}))
|
||||
const first=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output}),second=await buildDatabaseOperator({backendDirectory:backend,outputDirectory:output})
|
||||
assert.deepEqual(first,second);assert.equal(first.modules.length,OPERATOR_SOURCES.length);assert.equal(first.modules.length,19);assert.deepEqual(first.runtimeDependencies,['dotenv','pg','zod']);assert.equal(first.backendDistModules.length,12)
|
||||
assert.deepEqual(first,second);assert.equal(first.modules.length,OPERATOR_SOURCES.length);assert.equal(first.modules.length,20);assert.deepEqual(first.runtimeDependencies,['dotenv','pg','zod']);assert.equal(first.backendDistModules.length,12)
|
||||
const copied=new Set()
|
||||
async function copyDist(relative){
|
||||
if(copied.has(relative))return;copied.add(relative)
|
||||
@@ -36,10 +36,10 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r
|
||||
assert.deepEqual([...copied].sort(),first.backendDistModules.map(item=>item.file).sort())
|
||||
// Exercise the real build entry point with its default backend/output paths.
|
||||
const scripts=path.join(projectBackend,'scripts')
|
||||
assert.equal(await stageDatabaseOperatorScripts(scripts,path.join(backend,'scripts')),21)
|
||||
assert.equal(await stageDatabaseOperatorScripts(scripts,path.join(backend,'scripts')),OPERATOR_SOURCES.length+2)
|
||||
const buildScript=path.join(scripts,'build-database-operator.mjs')
|
||||
const built=spawnSync(process.execPath,[buildScript],{cwd:temporary,env:{...process.env,NODE_OPTIONS:''},encoding:'utf8',timeout:30000})
|
||||
assert.equal(built.status,0,built.stderr);assert.equal(JSON.parse(built.stdout.trim()).modules,19)
|
||||
assert.equal(built.status,0,built.stderr);assert.equal(JSON.parse(built.stdout.trim()).modules,20)
|
||||
assert.deepEqual(JSON.parse(await readFile(path.join(output,'build-manifest.json'),'utf8')),first)
|
||||
const configFile=path.join(projectBackend,'dist','config.js'),originalConfig=await readFile(configFile,'utf8')
|
||||
for(const injected of ["import 'tsx';","import '../src/config.js';","import '../outside.js';"]){
|
||||
@@ -61,9 +61,9 @@ test('fixed artifact is reproducible, resolves the compiled backend graph, and r
|
||||
const modulesToImport=first.modules.map(item=>path.join(output,item.file))
|
||||
const importer=path.join(temporary,'import-all.mjs');await writeFile(importer,`import {pathToFileURL} from 'node:url';for(const file of ${JSON.stringify(modulesToImport)})await import(pathToFileURL(file).href);console.log(JSON.stringify({imported:${modulesToImport.length},readyAuthorized:false}));`)
|
||||
const imported=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,importer],{cwd:temporary,env,encoding:'utf8',timeout:30000})
|
||||
assert.equal(imported.status,0,imported.stderr);assert.equal(JSON.parse(imported.stdout.trim()).imported,19);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0)
|
||||
assert.equal(imported.status,0,imported.stderr);assert.equal(JSON.parse(imported.stdout.trim()).imported,20);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0)
|
||||
const refusals=[]
|
||||
for(const [entry,event,args] of [['database-writer-bootstrap-operator.mjs','application-writer-bootstrap-refused',['invalid']],['database-writer-profile-operator.mjs','application-writer-profile-publication-refused',['invalid']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-inspect']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-resolve']]]){
|
||||
for(const [entry,event,args] of [['database-writer-bootstrap-operator.mjs','application-writer-bootstrap-refused',['invalid']],['database-writer-profile-operator.mjs','application-writer-profile-publication-refused',['invalid']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-inspect']],['database-writer-profile-operator.mjs','database-maintenance-command-refused',['maintenance-resolve']],['database-writer-profile-operator.mjs','database-source-activation-refused',['source-activate']]]){
|
||||
const child=spawnSync(process.execPath,['--import',pathToFileURL(guard).href,path.join(output,entry),...args],{cwd:temporary,env,encoding:'utf8',timeout:30000})
|
||||
assert.equal(child.status,1,child.stderr);assert.equal(child.stdout,'');assert.equal(JSON.parse(child.stderr.trim()).event,event);assert.equal(JSON.parse(await readFile(telemetry,'utf8')).connections,0);refusals.push(event)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
export const OPERATOR_SOURCES=Object.freeze([
|
||||
'database-writer-bootstrap-operator.mjs','database-writer-profile-operator.mts',
|
||||
'writer-profile/operator-files.mjs','writer-profile/operator-contract.mjs','writer-profile/publisher.mjs','writer-profile/context.mjs',
|
||||
'writer-profile/protected-source-registry.mts','writer-profile/maintenance-resolution.mts',
|
||||
'writer-profile/protected-source-registry.mts','writer-profile/maintenance-resolution.mts','writer-profile/source-activation.mts',
|
||||
'writer-profile/catalogue.mts','writer-profile/catalogue-value-bridge.ts','writer-profile/catalogue-projection.ts','writer-profile/catalogue-leaves.ts',
|
||||
'writer-profile/application-writer-profile-catalogue.ts','writer-profile/application-writer-profile-catalogue-values.ts',
|
||||
'writer-profile/application-writer-profile-catalogue-parent-policy.ts','writer-profile/application-writer-profile-canonical.ts',
|
||||
|
||||
@@ -8,10 +8,14 @@ import {approval,reconciliationApproval,poolOptions,roleKeys,assertJsDriverEnvir
|
||||
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))
|
||||
const sourceCommands=['source-activate','source-revoke','source-status']
|
||||
export async function main(args:string[]){
|
||||
if(sourceCommands.includes(args[0]))return (await import('./writer-profile/source-activation.mjs')).sourceActivationMain(args)
|
||||
if(['maintenance-inspect','maintenance-resolve'].includes(args[0]))return (await import('./writer-profile/maintenance-resolution.mjs')).maintenanceMain(args)
|
||||
const activePreflight=args[0]==='source-preflight',preflight=activePreflight||args[0]==='preflight'
|
||||
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)
|
||||
if(activePreflight)flags.push('activation-directory','activation-sha256')
|
||||
assert((preflight||['publish','reconcile'].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])}
|
||||
@@ -32,34 +36,49 @@ export async function main(args:string[]){
|
||||
// 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')
|
||||
const {createProtectedDatabaseSourceBindingVerifier}=await import('./writer-profile/protected-source-registry.mjs')
|
||||
assertJsDriverEnvironment(process.env)
|
||||
const [{default:pg},{createConnectedPublisher},{collectSource44Catalogue,approvedDefinition}]=await Promise.all([
|
||||
const [{default:pg},{createConnectedPublisher,requireCurrentCatalogueDefinition},{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 reviewedDefinition=approvedDefinition(definition)
|
||||
assert.equal(reviewedDefinition.definitionSha256,pinned.definitionSha256)
|
||||
if(args[0]!=='reconcile')requireCurrentCatalogueDefinition(pinned,reviewedDefinition)
|
||||
const installation=Object.freeze({...subject,roles:Object.fromEntries(roleKeys.map(k=>[k,request.roles[k].signedOid])),roleBindings:request.roles,expectedSource:request.source,tls:Object.freeze({...request.tls})})
|
||||
const sourcePin={registryDirectory:get('source-registry'),installationId:subject.installationId,publicKeySpkiSha256:get('source-spki-sha256'),currentHeadSha256:request.sourceHeadSha256}
|
||||
// Preflight is the read-only lifecycle entry for protected registry evidence.
|
||||
// A refused protection check never falls back to the signature-only verifier.
|
||||
const verifier=args[0]==='preflight'
|
||||
?(await import('./writer-profile/protected-source-registry.mjs')).createProtectedDatabaseSourceBindingVerifier(sourcePin)
|
||||
:createDatabaseSourceBindingVerifier(sourcePin)
|
||||
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'&&result.platformProtectionVerified===true&&!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,platformProtectionVerified: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')
|
||||
// Use the same protected registry evidence for inspection and publication.
|
||||
const verifier=createProtectedDatabaseSourceBindingVerifier(sourcePin)
|
||||
async function connected(sourceVerifier:typeof verifier,deadlineAt:number){
|
||||
const publisher=createConnectedPublisher(installation,(s:Parameters<typeof collectSource44Catalogue>[0])=>collectSource44Catalogue(s,installation),sourceVerifier,pinned,reviewedDefinition)
|
||||
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=preflight?await publisher.preflightCurrentProfile(pool,{deadlineAt}):args[0]==='reconcile'?await publisher.reconcilePendingPublication(pool,{journalDirectory:get('journal'),deadlineAt}):await publisher.publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256:pinned.expectedHeadSha256,journalDirectory:get('journal'),validityMs:pinned.validityMs,deadlineAt})}
|
||||
catch(error){primary=error;failed=true}
|
||||
try{await pool.end()}catch(error){primary??=error;failed=true}
|
||||
if(preflight&&result?.state==='matched'&&result.platformProtectionVerified===true&&!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,platformProtectionVerified: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')
|
||||
}
|
||||
const deadlineAt=Date.now()+120000
|
||||
if(!activePreflight)return connected(verifier,deadlineAt)
|
||||
const {withActiveSourcePreflight}=await import('./writer-profile/source-activation.mjs')
|
||||
const publisherRole=request.roles.publisher.name
|
||||
const expectedSource={...request.source,databaseOid:Number(request.source.databaseOid),sessionUser:publisherRole,currentUser:publisherRole}
|
||||
const result=await withActiveSourcePreflight({directory:get('activation-directory'),expectedSha256:get('activation-sha256'),sourcePin,expectedSource,tlsSpkiSha256:request.tls.spkiSha256,deadlineAt},verifier,
|
||||
guardedVerifier=>connected(guardedVerifier,deadlineAt))
|
||||
assert.equal(result.event,'application-writer-profile-preflight')
|
||||
return {...result,event:'application-writer-profile-source-preflight',sourceActivationMatched:true}
|
||||
}
|
||||
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:['maintenance-inspect','maintenance-resolve'].includes(process.argv[2])?'database-maintenance-command-refused':'application-writer-profile-publication-refused',automaticReplay:false,inspectPendingBeforeRetry:true,readyAuthorized:false}));process.exitCode=1}
|
||||
catch(error){
|
||||
const sourceCommand=sourceCommands.includes(process.argv[2])
|
||||
const unknown=sourceCommand&&(error as NodeJS.ErrnoException|null)?.code==='SOURCE_ACTIVATION_TERMINAL_UNKNOWN'
|
||||
console.error(JSON.stringify({event:sourceCommand?(unknown?'database-source-activation-unknown':'database-source-activation-refused'):
|
||||
['maintenance-inspect','maintenance-resolve'].includes(process.argv[2])?'database-maintenance-command-refused':process.argv[2]==='source-preflight'?'application-writer-profile-source-preflight-refused':'application-writer-profile-publication-refused',automaticReplay:false,inspectPendingBeforeRetry:true,readyAuthorized:false}));process.exitCode=unknown?2:1
|
||||
}
|
||||
}
|
||||
@@ -6,22 +6,38 @@ import fs from 'node:fs/promises'
|
||||
import {createRequire} from 'node:module'
|
||||
import {runInNewContext} from 'node:vm'
|
||||
import {EventEmitter} from 'node:events'
|
||||
import {generateKeyPairSync} from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import * as format from '../src/db/application-writer-profile-format.ts'
|
||||
import {MIGRATION_MANIFEST,migrationManifestDigest} from '../src/db/migration-manifest.ts'
|
||||
import {readCurrentApplicationWriterProfile} from '../src/db/application-writer-profile-registry-read.ts'
|
||||
import {syntheticFixture,stagedModelClient} from '../src/db/application-writer-profile-registry-read-model.test.ts'
|
||||
import {approvedDefinition} from './writer-profile/catalogue.mts'
|
||||
import {BUILD44_PROVENANCE,FIXED44_ROLE_TEMPLATE} from './writer-profile/fixed44-build-definition.ts'
|
||||
const require=createRequire(new URL('../package.json',import.meta.url)),ts=require('typescript'),pg=require('pg')
|
||||
const candidate=await fs.readFile(new URL('./writer-profile/publisher.mjs',import.meta.url),'utf8')
|
||||
interface PreflightDiagnostics {state:string;headSha256:string;sourceMatched:boolean;rolesMatched:boolean;catalogueMatched:boolean;platformProtectionVerified:boolean;writerAuthorized:boolean;generationAuthenticated:boolean;readyAuthorized:boolean}
|
||||
interface PublisherModule {createConnectedPublisher(installation:unknown,collect:()=>Promise<unknown>,sourceVerifier:{verify:()=>Promise<{platformProtectionVerified:boolean}>},approval:unknown):{preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise<PreflightDiagnostics>}}
|
||||
interface OperatorModule {main(args:string[]):Promise<{event:string;outcome:string;platformProtectionVerified:boolean;writerAuthorized:boolean;readyAuthorized:boolean}>}
|
||||
interface PublisherModule {
|
||||
requireCurrentCatalogueDefinition(approval:unknown,definition:unknown):string
|
||||
createConnectedPublisher(installation:unknown,collect:()=>Promise<unknown>,sourceVerifier:{verify:()=>Promise<{platformProtectionVerified:boolean}>},approval:unknown,definition:unknown):{
|
||||
preflightCurrentProfile(pool:unknown,options:{deadlineAt:number}):Promise<PreflightDiagnostics>
|
||||
publishCurrentProfile(pool:unknown,options:{privateKeyFile:string;expectedHeadSha256:null;journalDirectory:string;deadlineAt:number}):Promise<{outcome:string}>
|
||||
}
|
||||
}
|
||||
interface OperatorModule {main(args:string[]):Promise<{event:string;outcome:string;platformProtectionVerified:boolean;writerAuthorized:boolean;readyAuthorized:boolean;sourceActivationMatched?:boolean}>}
|
||||
function compile<T>(source:string,modules:Record<string,unknown>):T{
|
||||
const exports:Record<string,unknown>={};const compiled=ts.transpileModule(source.split('\nif(process.argv[1]')[0].replaceAll('import.meta.url',JSON.stringify('file:///candidate-not-cli.mts')),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
|
||||
runInNewContext(compiled,{exports,require:(name:string)=>{assert(name in modules,'Unapproved import '+name);return modules[name]},Buffer,URL,TextDecoder,Date,setTimeout,clearTimeout,Object,Error,AggregateError,Promise,Map,Set,process:{env:{},argv:[]}}, {filename:'candidate.mjs'})
|
||||
// The isolated VM loads the exact source above; tests exercise this narrow export contract.
|
||||
return exports as T
|
||||
}
|
||||
function fixture(fault=''){
|
||||
const f=syntheticFixture('1'),catalogue={catalogSha256:'f'.repeat(64),definitionSha256:f.policy.definitionSha256,leafCount:117},security={sha256:'e'.repeat(64),roles:{...f.policy.roles}}
|
||||
const frozenDefinition=approvedDefinition({format:'application-writer-source44-definition-approval-v1',provenance:BUILD44_PROVENANCE,template:FIXED44_ROLE_TEMPLATE})
|
||||
const frozenMigrations=MIGRATION_MANIFEST.slice(0,44),markerIds=frozenMigrations.map(pin=>pin.file.slice(0,-4))
|
||||
const markerSql='SELECT id FROM public.schema_migrations ORDER BY id COLLATE "C" LIMIT $1'
|
||||
function fixture(fault='',forPublication=false){
|
||||
const f=syntheticFixture('1'),catalogue={...frozenDefinition,catalogSha256:'f'.repeat(64),leafCount:117},security={sha256:'e'.repeat(64),roles:{...f.policy.roles}}
|
||||
const currentBuild=fault==='current-build'||fault==='current-approved-frozen-definition'
|
||||
f.policy.manifestSha256=frozenDefinition.manifestSha256;f.policy.definitionSha256=frozenDefinition.definitionSha256
|
||||
f.policy.catalogSha256=format.sha(format.canonical({base:catalogue.catalogSha256,security:security.sha256}))
|
||||
if(fault==='signed-source')f.policy.source.databaseOid='999'
|
||||
if(fault==='signed-role')f.policy.roles.operator='99'
|
||||
@@ -34,30 +50,49 @@ function fixture(fault=''){
|
||||
f.state.current[0].head_sha256=top.head_sha256;f.state.policy=[{policy_sha256:hash,policy_bytes:raw}]
|
||||
if(fault==='tamper')f.state.policy[0].policy_bytes=Buffer.from(raw.toString().replace('synthetic','tampered'))
|
||||
if(fault==='empty'){f.state.current=[];f.state.latest=[];f.state.policy=[]}
|
||||
const model=stagedModelClient(f.state),queries:string[]=[],events:string[]=[];let clocks=0
|
||||
const model=stagedModelClient(f.state),queries:string[]=[],events:string[]=[],markerLimits:unknown[][]=[]
|
||||
let clocks=0,checkouts=0
|
||||
const client=Object.assign(new EventEmitter(),{query:async(sql:string,values?:unknown[])=>{
|
||||
queries.push(sql)
|
||||
if(sql.includes('clock_timestamp()')&&++clocks===2&&fault==='late-clock')return {rows:[{now:'1'}]}
|
||||
if(sql===markerSql){
|
||||
markerLimits.push(values??[])
|
||||
const ids=fault==='marker-missing'?markerIds.slice(0,-1):fault==='marker-extra'||fault==='marker-drift'&&markerLimits.length===2?[...markerIds,'9999_foreign']:markerIds
|
||||
return {rows:ids.map(id=>({id}))}
|
||||
}
|
||||
if(sql.startsWith('WITH')||sql.includes('clock_timestamp()'))return model.client.query(sql,values)
|
||||
if(sql.startsWith('SELECT pg_catalog.pg_advisory_unlock'))return {rows:[{released:true}]}
|
||||
if(sql==='ROLLBACK')return {rows:[],command:fault==='rollback-ack'?'':'ROLLBACK'}
|
||||
if(sql.startsWith('INSERT INTO mmapp_control.current'))return {rows:[{id:1}]}
|
||||
return {rows:[],command:sql.split(' ')[0]}
|
||||
},release:(bad:boolean)=>{events.push('release:'+bad)}})
|
||||
const pool=new pg.Pool();pool.connect=async()=>client
|
||||
const pool=new pg.Pool();pool.connect=async()=>{checkouts++;return client}
|
||||
const source={systemIdentifier:'123',databaseOid:'456',databaseName:'synthetic',serverVersionNum:180004,inRecovery:false}
|
||||
let contexts=0,verifications=0
|
||||
const installation={...f.subject,expectedSource:source,roleBindings:{publisher:{name:'publisher',signedOid:'14'}}}
|
||||
const approval={expiresAt:Date.now()+60000,manifestSha256:'b'.repeat(64),definitionSha256:catalogue.definitionSha256,catalogSha256:format.sha(format.canonical({base:catalogue.catalogSha256,security:security.sha256}))}
|
||||
const fixtureKey=forPublication?generateKeyPairSync('ed25519'):null
|
||||
const subject=fixtureKey?{...f.subject,publicKeyPem:fixtureKey.publicKey.export({format:'pem',type:'spki'}).toString(),publicKeySpkiSha256:format.sha(fixtureKey.publicKey.export({format:'der',type:'spki'}))}:f.subject
|
||||
const installation={...subject,expectedSource:source,roleBindings:{publisher:{name:'publisher',signedOid:'14'}}}
|
||||
const approval={expiresAt:Date.now()+60000,manifestSha256:['approval-manifest','current-approved-frozen-definition'].includes(fault)?migrationManifestDigest():frozenDefinition.manifestSha256,definitionSha256:catalogue.definitionSha256,catalogSha256:format.sha(format.canonical({base:catalogue.catalogSha256,security:security.sha256}))}
|
||||
const definition=fault==='static-definition'?{...frozenDefinition,definitionSha256:'0'.repeat(64)}:frozenDefinition
|
||||
const privateKeyFile=path.resolve('modeled-fixture-key.pem'),journalDirectory=path.resolve('modeled-journal'),files=new Map<string,Buffer>()
|
||||
if(fixtureKey)files.set(privateKeyFile,Buffer.from(fixtureKey.privateKey.export({format:'pem',type:'pkcs8'})))
|
||||
function file(filename:string){const value=files.get(filename);if(!value)throw Object.assign(Error('Fixture file absent'),{code:'ENOENT'});return value}
|
||||
function stat(filename:string){return {size:file(filename).length,dev:1,ino:1,isFile:()=>true,isSymbolicLink:()=>false}}
|
||||
const modules={
|
||||
pg,'node:crypto':require('node:crypto'),'node:fs/promises':{},'node:path':require('node:path'),
|
||||
'./operator-files.mjs':{withJournalLease:()=>{throw Error('Preflight must not use publication journal')},writeFile:()=>{throw Error('No publication writes')}},
|
||||
pg,'node:crypto':require('node:crypto'),'node:fs/promises':{realpath:async(file:string)=>file,lstat:async(filename:string)=>stat(filename),unlink:async(filename:string)=>files.delete(filename),open:async(filename:string)=>({stat:async()=>stat(filename),readFile:async()=>Buffer.from(file(filename)),close:async()=>{}})},'node:path':path,
|
||||
'./operator-contract.mjs':{retainPublisherTlsPeer:(actual:unknown)=>{
|
||||
assert.equal(actual,client)
|
||||
if(fault==='initial-tls')throw Error('TLS peer refused')
|
||||
return ()=>{if(fault==='lost-tls'&&verifications===2)throw Error('Retained TLS lost')}
|
||||
}},
|
||||
'./operator-files.mjs':{withJournalLease:async(_directory:string,work:()=>Promise<unknown>)=>{assert(forPublication);events.push('journal-lease');return work()},writeFile:async(filename:string,value:Buffer)=>{assert(forPublication);files.set(filename,value)}},
|
||||
'../../src/db/database-source-observation.js':{captureDatabaseSourceObservation:async()=>{events.push('capture-source');return {}},assertDatabaseSourceObservationCurrent:async()=>{events.push('fresh-source');if(fault==='late-source')throw Error('Source changed')}},
|
||||
'../../src/db/migration-manifest.js':{MIGRATION_MANIFEST:[],migrationManifestDigest:()=> 'b'.repeat(64)},
|
||||
'../../src/db/migration-manifest.js':{MIGRATION_MANIFEST:currentBuild?MIGRATION_MANIFEST:frozenMigrations,migrationManifestDigest:()=>currentBuild?migrationManifestDigest():frozenDefinition.manifestSha256},
|
||||
'../../src/db/application-writer-profile-format.js':format,
|
||||
'./context.mjs':{ownedContext:async()=>{contexts++;if(fault==='late-actor'&&contexts===2)throw Error('Actor changed');return {pid:1,source}},sameOwned:(a:unknown,b:unknown)=>assert.deepEqual(a,b),sourceCoordinates:(v:{source:typeof source})=>v.source,protectedStructure:async()=>{events.push('roles');if(fault==='role-structure')throw Error('Unsafe role');return security},readCurrent:readCurrentApplicationWriterProfile,HISTORY:'history',CURRENT:'current'},
|
||||
'./context.mjs':{ownedContext:async()=>{contexts++;if(fault==='late-actor'&&contexts===2)throw Error('Actor changed');return {pid:1,source}},sameOwned:(a:unknown,b:unknown)=>assert.deepEqual(a,b),sourceCoordinates:(v:{source:typeof source})=>v.source,protectedStructure:async()=>{events.push('roles');if(fault==='role-structure')throw Error('Unsafe role');return security},readCurrent:forPublication?async()=>null:readCurrentApplicationWriterProfile,HISTORY:'history',CURRENT:'current'},
|
||||
}
|
||||
const api=compile<PublisherModule>(candidate,modules).createConnectedPublisher(installation,async()=>{events.push('catalogue');if(fault==='catalogue-eof')throw Error('Incomplete EOF');return catalogue},{verify:async()=>{verifications++;events.push('verify-source');if(fault==='source-verifier')throw Error('Unsigned source');if(fault==='late-expiry'&&verifications===2)approval.expiresAt=1;return {platformProtectionVerified:!(fault==='initial-unprotected'&&verifications===1||fault==='terminal-unprotected'&&verifications===2)}}},approval)
|
||||
return {run:()=>api.preflightCurrentProfile(pool,{deadlineAt:Date.now()+30000}),queries,events,f}
|
||||
const api=compile<PublisherModule>(candidate,modules).createConnectedPublisher(installation,async()=>{events.push('catalogue');if(fault==='catalogue-eof')throw Error('Incomplete EOF');return fault==='catalogue-manifest'?{...catalogue,manifestSha256:migrationManifestDigest()}:fault==='catalogue-definition'?{...catalogue,definitionSha256:'0'.repeat(64)}:catalogue},{verify:async()=>{verifications++;events.push('verify-source');if(fault==='source-verifier')throw Error('Unsigned source');if(fault==='late-expiry'&&verifications===2)approval.expiresAt=1;return {platformProtectionVerified:!(fault==='initial-unprotected'&&verifications===1||fault==='terminal-unprotected'&&verifications===2)}}},approval,definition)
|
||||
return {run:()=>api.preflightCurrentProfile(pool,{deadlineAt:Date.now()+30000}),publish:()=>api.publishCurrentProfile(pool,{privateKeyFile,journalDirectory,expectedHeadSha256:null,deadlineAt:Date.now()+30000}),queries,events,markerLimits,files,get checkouts(){return checkouts},f}
|
||||
}
|
||||
test('current44 preflight authenticates signed current and actual component inputs on one read-only lease',async()=>{
|
||||
const f=fixture(),result=await f.run();assert.equal(result.state,'matched');assert.equal(result.headSha256,f.f.state.current[0].head_sha256)
|
||||
@@ -65,9 +100,17 @@ test('current44 preflight authenticates signed current and actual component inpu
|
||||
assert.equal(result.platformProtectionVerified,true)
|
||||
assert.equal(result.writerAuthorized,false);assert.equal(result.generationAuthenticated,false);assert.equal(result.readyAuthorized,false)
|
||||
assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1);assert(f.queries.includes('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'));assert.equal(f.queries.filter(q=>q==='ROLLBACK').length,1)
|
||||
assert.equal(format.canonical(f.markerLimits),'[[45]]')
|
||||
assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.events.at(-1),'release:false')
|
||||
assert.deepEqual(f.events.slice(0,6),['capture-source','verify-source','roles','catalogue','verify-source','fresh-source'])
|
||||
})
|
||||
test('publisher refuses before SQL on a wrong peer and stops SQL when the retained TLS socket is lost',async()=>{
|
||||
const initial=fixture('initial-tls')
|
||||
await assert.rejects(initial.run());assert.deepEqual(initial.queries,[]);assert.deepEqual(initial.events,['release:true'])
|
||||
const lost=fixture('lost-tls')
|
||||
await assert.rejects(lost.run());assert.equal(lost.events.at(-1),'release:true')
|
||||
assert(!lost.queries.some(q=>q==='ROLLBACK'||q.includes('pg_advisory_unlock')))
|
||||
})
|
||||
for(const fault of ['initial-unprotected','terminal-unprotected'])test('preflight cannot promote '+fault+' into protected evidence',async()=>{
|
||||
const f=fixture(fault),result=await f.run();assert.equal(result.platformProtectionVerified,false)
|
||||
assert.equal(result.writerAuthorized,false);assert.equal(result.readyAuthorized,false)
|
||||
@@ -76,23 +119,64 @@ for(const fault of ['initial-unprotected','terminal-unprotected'])test('prefligh
|
||||
for(const fault of ['tamper','empty','expired','signed-source','signed-role','signed-catalogue','signed-definition','signed-manifest','role-structure','catalogue-eof','source-verifier','late-source','late-actor','late-expiry','late-clock','rollback-ack'])test('preflight refuses '+fault+' without any write or replay',async()=>{
|
||||
const f=fixture(fault);await assert.rejects(f.run());assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT|CREATE|ALTER|DROP)\b/.test(q)));assert.equal(f.queries.filter(q=>q.startsWith('BEGIN')).length,1)
|
||||
})
|
||||
test('existing operator main dispatches preflight only after protected input validation, without private key or publication',async()=>{
|
||||
for(const fault of ['current-build','current-approved-frozen-definition','approval-manifest','static-definition'])test('incompatible static manifest/definition refuses '+fault+' before either pool checkout or publication lease',async()=>{
|
||||
const f=fixture(fault,true)
|
||||
await assert.rejects(f.run(),/Approved catalogue definition is not bound/)
|
||||
await assert.rejects(f.publish(),/Approved catalogue definition is not bound/)
|
||||
assert.equal(f.checkouts,0);assert.deepEqual(f.events,[]);assert.deepEqual(f.queries,[])
|
||||
})
|
||||
for(const fault of ['catalogue-manifest','catalogue-definition','marker-missing','marker-extra'])test('retained preflight refuses '+fault+' and rolls back',async()=>{
|
||||
const f=fixture(fault)
|
||||
await assert.rejects(f.run(),error=>error instanceof Error&&/Catalogue evidence differs|Exact current migration marker/.test((error.cause as Error).message))
|
||||
assert.equal(f.checkouts,1);assert.equal(f.queries.filter(q=>q==='ROLLBACK').length,1)
|
||||
assert(!f.queries.some(q=>/^(?:INSERT|UPDATE|DELETE|COMMIT)\b/.test(q)))
|
||||
if(fault.startsWith('marker-'))assert.equal(format.canonical(f.markerLimits),'[[45]]')
|
||||
else assert.deepEqual(f.markerLimits,[])
|
||||
})
|
||||
test('publication checks exact marker set on both retained transactions and refuses late marker drift before registry writes',async()=>{
|
||||
const positive=fixture('',true)
|
||||
assert.equal((await positive.publish()).outcome,'committed')
|
||||
assert.equal(positive.checkouts,1);assert.equal(format.canonical(positive.markerLimits),'[[45],[45]]')
|
||||
assert.equal(positive.queries.filter(q=>q==='COMMIT').length,2)
|
||||
const drift=fixture('marker-drift',true)
|
||||
await assert.rejects(drift.publish(),error=>error instanceof Error&&/Exact current migration marker/.test((error.cause as Error).message))
|
||||
assert.equal(drift.checkouts,1);assert.equal(format.canonical(drift.markerLimits),'[[45],[45]]')
|
||||
assert.equal(drift.queries.filter(q=>q==='COMMIT').length,1);assert.equal(drift.queries.filter(q=>q==='ROLLBACK').length,1)
|
||||
assert(!drift.queries.some(q=>/^(?:INSERT|UPDATE|DELETE)\b/.test(q)))
|
||||
assert([...drift.files.keys()].some(filename=>filename.endsWith('pending-publication.json')))
|
||||
})
|
||||
test('operator requires the protected source verifier for preflight, publication and reconciliation',async()=>{
|
||||
const cli=await fs.readFile(new URL('./database-writer-profile-operator.mts',import.meta.url),'utf8'),f=syntheticFixture('1'),reads:string[]=[],events:string[]=[]
|
||||
const request={installationId:f.subject.installationId,roles:Object.fromEntries(['runtime','operator','capture','publisher','control'].map((k,i)=>[k,{name:k,signedOid:String(i+11)}])),source:f.policy.source,sourceHeadSha256:'a'.repeat(64),tls:{caSha256:'a'.repeat(64)}}
|
||||
const files:Record<string,unknown>={bootstrap:{format:'application-writer-bootstrap-v1',publicationAuthorized:false,readyAuthorized:false,deploymentRequest:request,verificationSubject:f.subject},approval:{expiresAt:Date.now()+60000,definitionSha256:'b'.repeat(64)},definition:{},transport:{},ca:'fixture'}
|
||||
let failEnd=false,asyncError=false,protectedRefused=false
|
||||
const request={installationId:f.subject.installationId,roles:Object.fromEntries(['runtime','operator','capture','publisher','control'].map((k,i)=>[k,{name:k,signedOid:String(i+11)}])),source:f.policy.source,sourceHeadSha256:'a'.repeat(64),tls:{caSha256:'a'.repeat(64),spkiSha256:'d'.repeat(64)}}
|
||||
const files:Record<string,unknown>={bootstrap:{format:'application-writer-bootstrap-v1',publicationAuthorized:false,readyAuthorized:false,deploymentRequest:request,verificationSubject:f.subject},approval:{expiresAt:Date.now()+60000,definitionSha256:'b'.repeat(64)},definition:{},transport:{},ca:'fixture','signing-private.pem':'fixture'}
|
||||
let failEnd=false,asyncError=false,protectedRefused=false,activationRefused=false,incompatibleDefinition=false
|
||||
const retainedPools:Pool[]=[]
|
||||
class Pool extends EventEmitter{constructor(){super();retainedPools.push(this)}async end(){events.push('pool-end');if(failEnd)throw Error('Pool cleanup unconfirmed')}}
|
||||
const result={state:'matched',epoch:'1',headSha256:'c'.repeat(64),notAfter:Date.now()+60000,platformProtectionVerified:true}
|
||||
const protectedVerifier={verify:async()=>({platformProtectionVerified:true})}
|
||||
const guardedVerifier={...protectedVerifier}
|
||||
const modules={
|
||||
'node:assert/strict':assert,'node:path':require('node:path'),'node:url':require('node:url'),'node:crypto':require('node:crypto'),
|
||||
'./writer-profile/operator-files.mjs':{readProtected:async(file:string)=>{reads.push(file);assert(file in files);return Buffer.from(JSON.stringify(files[file]))},protectedDirectory:async()=>{},sha:format.sha},
|
||||
'./writer-profile/operator-contract.mjs':{approval:(value:unknown)=>{events.push('approval');return value},reconciliationApproval:()=>{throw Error('No expired reconciliation bypass')},poolOptions:()=>{events.push('transport');return {}},roleKeys:['runtime','operator','capture','publisher','control'],assertJsDriverEnvironment:()=>{}},
|
||||
'./database-writer-bootstrap-operator.mjs':{deploymentRequest:(value:unknown)=>value},'../src/db/database-source-binding.js':{createDatabaseSourceBindingVerifier:()=>{throw Error('Preflight must never fall back to unprotected source verification')}},
|
||||
'./writer-profile/operator-contract.mjs':{approval:(value:unknown)=>{events.push('approval');return value},reconciliationApproval:(value:unknown)=>{events.push('reconciliation-approval');return value},poolOptions:()=>{events.push('transport');return {}},roleKeys:['runtime','operator','capture','publisher','control'],assertJsDriverEnvironment:()=>{}},
|
||||
'./database-writer-bootstrap-operator.mjs':{deploymentRequest:(value:unknown)=>value},
|
||||
'./writer-profile/protected-source-registry.mjs':{createProtectedDatabaseSourceBindingVerifier:(pin:{registryDirectory:string;currentHeadSha256:string})=>{events.push('protected-source');assert.equal(pin.registryDirectory,'source-registry');assert.equal(pin.currentHeadSha256,request.sourceHeadSha256);if(protectedRefused)throw Error('Protected source refused');return protectedVerifier}},
|
||||
pg:{Pool},'./writer-profile/publisher.mjs':{createConnectedPublisher:(_installation:unknown,_collect:unknown,verifier:unknown)=>{assert.equal(verifier,protectedVerifier);return {preflightCurrentProfile:async()=>{events.push('preflight');if(asyncError){const activePool=retainedPools.at(-1);assert(activePool);activePool.emit('error',Error('Async pool fault'))}return result},publishCurrentProfile:()=>{throw Error('No publish')},reconcilePendingPublication:()=>{throw Error('No reconcile')}}}},
|
||||
'./writer-profile/catalogue.mjs':{collectSource44Catalogue:()=>{},approvedDefinition:()=> 'b'.repeat(64)},
|
||||
'./writer-profile/source-activation.mjs':{withActiveSourcePreflight:async(input:{directory:string;expectedSha256:string;sourcePin:{currentHeadSha256:string};expectedSource:unknown;tlsSpkiSha256:string;deadlineAt:number},verifier:unknown,work:(verifier:unknown)=>Promise<unknown>)=>{
|
||||
assert.equal(verifier,protectedVerifier);assert.equal(input.directory,'active-registry');assert.equal(input.expectedSha256,'e'.repeat(64))
|
||||
assert.equal(input.sourcePin.currentHeadSha256,request.sourceHeadSha256);assert.equal(input.tlsSpkiSha256,request.tls.spkiSha256);assert(input.deadlineAt>Date.now())
|
||||
assert.equal(format.canonical(input.expectedSource),format.canonical({...request.source,databaseOid:Number(request.source.databaseOid),sessionUser:'publisher',currentUser:'publisher'}))
|
||||
events.push('activation-admission');if(activationRefused)throw Error('Activation refused')
|
||||
const value=await work(guardedVerifier);events.push('activation-terminal');return value
|
||||
}},
|
||||
pg:{Pool},'./writer-profile/publisher.mjs':{requireCurrentCatalogueDefinition:()=>{if(incompatibleDefinition)throw Error('Build manifest refused')},createConnectedPublisher:(_installation:unknown,_collect:unknown,verifier:unknown)=>{
|
||||
assert.equal(verifier,events.includes('activation-admission')?guardedVerifier:protectedVerifier)
|
||||
return {
|
||||
preflightCurrentProfile:async()=>{events.push('preflight');if(asyncError){const activePool=retainedPools.at(-1);assert(activePool);activePool.emit('error',Error('Async pool fault'))}return result},
|
||||
publishCurrentProfile:async()=>{events.push('publish');return {...result,outcome:'committed'}},
|
||||
reconcilePendingPublication:async()=>{events.push('reconcile');return {state:'confirmed',headSha256:result.headSha256}},
|
||||
}
|
||||
}},
|
||||
'./writer-profile/catalogue.mjs':{collectSource44Catalogue:()=>{},approvedDefinition:()=>({definitionSha256:'b'.repeat(64),manifestSha256:frozenDefinition.manifestSha256})},
|
||||
}
|
||||
const flags=['bootstrap','bootstrap-sha256','approval','approval-sha256','definition','definition-sha256','transport','transport-sha256','ca','source-registry','source-spki-sha256','journal'],args=['preflight',...flags.flatMap(flag=>['--'+flag,flag.endsWith('sha256')?'a'.repeat(64):flag])]
|
||||
const output=await compile<OperatorModule>(cli,modules).main(args)
|
||||
@@ -103,5 +187,37 @@ test('existing operator main dispatches preflight only after protected input val
|
||||
asyncError=false;result.platformProtectionVerified=false;await assert.rejects(compile<OperatorModule>(cli,modules).main(args),/Publisher terminal unconfirmed/)
|
||||
result.platformProtectionVerified=true;protectedRefused=true;const before=retainedPools.length
|
||||
await assert.rejects(compile<OperatorModule>(cli,modules).main(args),/Protected source refused/);assert.equal(retainedPools.length,before,'Protection failure must not open a Pool or fall back')
|
||||
for(const [command,approvalEvent,outcome] of [['publish','approval','committed'],['reconcile','reconciliation-approval','confirmed']]){
|
||||
const commandArgs=[command,...args.slice(1)]
|
||||
const poolCount=retainedPools.length
|
||||
await assert.rejects(compile<OperatorModule>(cli,modules).main(commandArgs),/Protected source refused/)
|
||||
assert.equal(retainedPools.length,poolCount)
|
||||
protectedRefused=false
|
||||
events.length=0;reads.length=0
|
||||
const terminal=await compile<OperatorModule>(cli,modules).main(commandArgs)
|
||||
assert.equal(terminal.outcome,outcome);assert.equal(terminal.readyAuthorized,false)
|
||||
assert.deepEqual(events,[approvalEvent,'transport','protected-source',command,'pool-end'])
|
||||
assert.equal(reads.includes('signing-private.pem'),command==='publish')
|
||||
protectedRefused=true
|
||||
}
|
||||
const activeArgs=['source-preflight',...args.slice(1),'--activation-directory','active-registry','--activation-sha256','e'.repeat(64)]
|
||||
protectedRefused=false;activationRefused=true;events.length=0;reads.length=0
|
||||
const beforeActivation=retainedPools.length
|
||||
await assert.rejects(compile<OperatorModule>(cli,modules).main(activeArgs),/Activation refused/)
|
||||
assert.equal(retainedPools.length,beforeActivation)
|
||||
assert.deepEqual(events,['approval','transport','protected-source','activation-admission'])
|
||||
activationRefused=false;events.length=0;reads.length=0
|
||||
const active=await compile<OperatorModule>(cli,modules).main(activeArgs)
|
||||
assert.equal(active.event,'application-writer-profile-source-preflight');assert.equal(active.sourceActivationMatched,true)
|
||||
assert.equal(active.readyAuthorized,false);assert.equal(active.writerAuthorized,false)
|
||||
assert.deepEqual(events,['approval','transport','protected-source','activation-admission','preflight','pool-end','activation-terminal'])
|
||||
assert.deepEqual(reads,['bootstrap','approval','definition','transport','ca'])
|
||||
incompatibleDefinition=true;events.length=0;reads.length=0
|
||||
const beforeBuildRefusal=retainedPools.length
|
||||
for(const incompatibleArgs of [args,activeArgs,['publish',...args.slice(1)]])await assert.rejects(compile<OperatorModule>(cli,modules).main(incompatibleArgs),/Build manifest refused/)
|
||||
assert.equal(retainedPools.length,beforeBuildRefusal)
|
||||
assert(!events.some(event=>event==='protected-source'||event==='activation-admission'))
|
||||
const historical=await compile<OperatorModule>(cli,modules).main(['reconcile',...args.slice(1)])
|
||||
assert.equal(historical.outcome,'confirmed');assert.equal(historical.readyAuthorized,false)
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
|
||||
const endpoint = new URL(process.env.OWN_MCP_ENDPOINT), origin = endpoint.origin
|
||||
assert.equal(endpoint.hostname, '127.0.0.1'); assert.equal(process.env.OWN_EXTERNAL_CLIENT, 'true')
|
||||
let phase = 'start', next = 0, maxResponseBytes = 0, stderrBytes = 0, bridgePid = null
|
||||
const responseLimitBytes = 1024 * 1024
|
||||
const pending = new Map(), cases = []
|
||||
const activeClients = new Set()
|
||||
let closing = false
|
||||
@@ -22,7 +23,7 @@ async function boundedFetch(input, init) {
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
for (;;) { const item = await reader.read(); if (item.done) break; count += item.value.length; if (count >= 1048576) { await reader.cancel(); throw Error('Own external client response bound') }; chunks.push(item.value) }
|
||||
for (;;) { const item = await reader.read(); if (item.done) break; count += item.value.length; if (count >= responseLimitBytes) { await reader.cancel(); throw Error('Own external client response bound') }; chunks.push(item.value) }
|
||||
} finally { reader.releaseLock() }
|
||||
}
|
||||
const bytes = Buffer.concat(chunks); maxResponseBytes = Math.max(maxResponseBytes, bytes.length)
|
||||
@@ -36,14 +37,14 @@ function content(result) {
|
||||
return parsed
|
||||
}
|
||||
async function denied(work) { try { const r = await work(); assert.equal(r?.isError, true) } catch (e) { if (e?.code === 'ERR_ASSERTION') throw e; assert(!/timeout|timed out/i.test(String(e?.message)), 'A timeout is not authorization denial') } }
|
||||
async function opened(mode, token) {
|
||||
async function opened(mode, token, maxBufferSize = 131072) {
|
||||
const client = new Client({ name: 'actual-external-process-' + mode, version: '1.0.0' })
|
||||
activeClients.add(client)
|
||||
let transport
|
||||
if (mode === 'http') transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { authorization: 'Bearer ' + token } }, fetch: boundedFetch })
|
||||
else {
|
||||
transport = new StdioClientTransport({ command: process.execPath, args: ['--import', import.meta.resolve('tsx'), process.env.OWN_BRIDGE_FILE],
|
||||
cwd: process.cwd(), env: { MCP_URL: endpoint.href, MCP_TOKEN: token }, stderr: 'pipe', maxBufferSize: 131072 })
|
||||
cwd: process.cwd(), env: { MCP_URL: endpoint.href, MCP_TOKEN: token }, stderr: 'pipe', maxBufferSize })
|
||||
transport.stderr?.on('data', chunk => { stderrBytes += chunk.length; const text = String(chunk); assert(!text.includes(token)); assert(stderrBytes < 1048576) })
|
||||
const start = transport.start.bind(transport)
|
||||
transport.start = async () => { await start(); bridgePid = transport.pid; assert(bridgePid && bridgePid !== process.pid && bridgePid !== Number(process.env.OWN_SERVER_PID)); process.send({ kind: 'bridge-pid', pid: bridgePid }) }
|
||||
@@ -68,6 +69,36 @@ try {
|
||||
phase = mode + ':initialize'; let client = await opened(mode, token)
|
||||
try {
|
||||
await scoped(client); cases.push(mode + ': separate SDK process initializes, filters tool/host scope, bounds page/result and redacts credentials')
|
||||
phase = mode + ':policy-edit'
|
||||
const editScope = async operationIds => {
|
||||
const response = await boundedFetch(origin + '/api/mcp/connections/' + initial.connectionId, {
|
||||
method: 'PUT', headers: { authorization: 'Bearer ' + process.env.OWN_PORTAL_JWT, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Owned external SDK process', mode: 'observer', expiresAt: new Date(Date.now() + 600000).toISOString(),
|
||||
permissions: ['mm:servers:read'], scope: { serverIds: [Number(process.env.OWN_ALLOWED_SERVER)], operationIds, allowGlobal: false }, limits: { callsPerMinute: 200 } }),
|
||||
})
|
||||
assert.equal(response.status, 200)
|
||||
}
|
||||
await editScope(['logs.query'])
|
||||
assert.deepEqual((await client.listTools({}, { timeout: 5000 })).tools.map(tool => tool.name), ['mm_logs_query'])
|
||||
const allowedServer = Number(process.env.OWN_ALLOWED_SERVER), forbiddenServer = Number(process.env.OWN_FORBIDDEN_SERVER)
|
||||
const queryLogs = arguments_ => client.callTool({ name: 'mm_logs_query', arguments: arguments_ }, undefined, { timeout: 5000 })
|
||||
const filters = { q: 'external-sdk-log-', limit: 1 }
|
||||
const first = content(await queryLogs(filters))
|
||||
assert.ok(first.nextCursor)
|
||||
const second = content(await queryLogs({ ...filters, cursor: first.nextCursor }))
|
||||
assert.equal(second.nextCursor, null)
|
||||
assert.deepEqual([first.entries, second.entries].map(entries => entries.map(({ serverId, message }) => ({ serverId, message }))), [
|
||||
[{ serverId: allowedServer, message: 'external-sdk-log-1' }],
|
||||
[{ serverId: allowedServer, message: 'external-sdk-log-2' }],
|
||||
])
|
||||
await denied(() => queryLogs({ ...filters, serverId: forbiddenServer }))
|
||||
cases.push(mode + ': populated log search pages return only allowed server records and reject the forbidden server')
|
||||
await denied(() => client.callTool({ name: 'mm_servers_list', arguments: {} }, { timeout: 5000 }))
|
||||
await editScope([])
|
||||
assert.deepEqual((await client.listTools({}, { timeout: 5000 })).tools, [])
|
||||
await editScope(['servers.list'])
|
||||
await scoped(client)
|
||||
cases.push(mode + ': established client observes removed and restored tool scope through the web policy API')
|
||||
if (mode === 'stdio' && process.env.OWN_EXTERNAL_CLEANUP_FAULT === 'failure') throw Error('Owned deliberate external client failure with bridge active')
|
||||
if (mode === 'stdio' && process.env.OWN_EXTERNAL_CLEANUP_FAULT === 'timeout') { process.send({ kind: 'arm-cleanup-timeout' }); await new Promise(() => {}) }
|
||||
phase = mode + ':result-bound'; await control('large-result')
|
||||
@@ -86,7 +117,7 @@ try {
|
||||
} finally { await client.close().catch(() => {}) }
|
||||
}
|
||||
phase = 'http:body-bound'; const bound = await control('new-connection')
|
||||
const oversized = await fetch(endpoint, { method: 'POST', headers: { authorization: 'Bearer ' + bound.token, 'content-type': 'application/json', accept: 'application/json,text/event-stream' },
|
||||
const oversized = await fetch(endpoint, { method: 'POST', headers: { authorization: 'Bearer ' + bound.token, 'content-type': 'application/json', accept: 'application/json,text/event-stream', connection: 'close' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'mm_servers_list', arguments: { padding: 'x'.repeat(1048576) } } }), signal: AbortSignal.timeout(5000) })
|
||||
assert.equal(oversized.status, 413); assert((await oversized.text()).length < 65536)
|
||||
cases.push('actual HTTP request envelope over1MiB refused before tool execution; successful client results remain bounded')
|
||||
@@ -126,8 +157,123 @@ try {
|
||||
await denied(() => oauthClient.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 }))
|
||||
cases.push('actual OAuth family revocation refuses established and fresh external transport; no implicit new consent granted')
|
||||
} finally { await oauthClient.close().catch(() => {}) }
|
||||
phase = 'discovery:issue'
|
||||
const catalogue = await control('discovery-connection')
|
||||
const expectedNames = catalogue.names.toSorted()
|
||||
assert(expectedNames.includes('mm_logs_follow'))
|
||||
const discoveryByTransport = {}
|
||||
for (const mode of ['http', 'stdio']) {
|
||||
phase = `discovery:${mode}`
|
||||
const client = await opened(mode, catalogue.token, responseLimitBytes)
|
||||
try {
|
||||
const names = [], cursors = new Set()
|
||||
let cursor, largestPageBytes = 0, pages = 0
|
||||
do {
|
||||
const page = await client.listTools(cursor ? { cursor } : {}, { timeout: 10000 })
|
||||
assert(page.tools.length > 0)
|
||||
for (const tool of page.tools) {
|
||||
assert.equal(tool.inputSchema.type, 'object')
|
||||
names.push(tool.name)
|
||||
}
|
||||
const encoded = JSON.stringify(page)
|
||||
assert(!encoded.includes(catalogue.token) && !encoded.includes(process.env.OWN_SERVER_SECRET))
|
||||
largestPageBytes = Math.max(largestPageBytes, Buffer.byteLength(encoded))
|
||||
assert(largestPageBytes < responseLimitBytes)
|
||||
pages++
|
||||
cursor = page.nextCursor
|
||||
if (cursor) { assert(!cursors.has(cursor)); cursors.add(cursor) }
|
||||
assert(names.length <= expectedNames.length)
|
||||
} while (cursor)
|
||||
assert.deepEqual(names.toSorted(), expectedNames)
|
||||
discoveryByTransport[mode] = { tools: names.length, pages, largestPageBytes }
|
||||
cases.push(mode + ': external SDK discovers every delegated tool exactly once with an object input schema')
|
||||
} finally { await client.close().catch(() => {}) }
|
||||
}
|
||||
phase = 'discovery:revoke'
|
||||
await control('revoke')
|
||||
for (const [mode, arm] of [['http', 'arm-log-revocation'], ['stdio', 'arm-log-revocation-after-commit'], ['http', 'arm-log-follow-revocation']]) {
|
||||
phase = mode + ':' + arm
|
||||
const { token } = await control(arm)
|
||||
const logClient = await opened(mode, token)
|
||||
try {
|
||||
const follow = arm === 'arm-log-follow-revocation'
|
||||
const tool = follow ? 'mm_logs_follow' : 'mm_logs_query'
|
||||
const filters = { q: follow ? 'external-sdk-live-event' : 'external-sdk-log-1', limit: 1 }
|
||||
if (follow) {
|
||||
Object.assign(filters, { serverId: Number(process.env.OWN_ALLOWED_SERVER), waitSeconds: 0 })
|
||||
const start = content(await logClient.callTool({ name: tool, arguments: filters }, undefined, { timeout: 5000 }))
|
||||
assert.deepEqual(start.entries, [])
|
||||
assert.equal(typeof start.cursor, 'string')
|
||||
filters.cursor = start.cursor
|
||||
filters.waitSeconds = 10
|
||||
}
|
||||
const query = logClient.callTool({ name: tool, arguments: filters }, undefined, { timeout: 10000 })
|
||||
const checked = query.then(result => {
|
||||
assert.equal(result.isError, true)
|
||||
const wire = JSON.stringify(result)
|
||||
assert.match(wire, /OWNER_CLAIMS_REVOKED|UNAUTHORIZED/)
|
||||
assert.doesNotMatch(wire, /external-sdk-log-1|external-sdk-live-event|"entries"|"nextCursor"|"cursor"/)
|
||||
})
|
||||
const deliver = async () => {
|
||||
if (follow) await control('publish-follow-fixture-row')
|
||||
await control('deliver-log-revocation')
|
||||
}
|
||||
await Promise.all([checked, deliver()])
|
||||
await assert.rejects(opened(mode, token))
|
||||
cases.push(mode + ': signed portal revocation ' + (mode === 'stdio' ? 'after' : 'before') + ' audit commit denies ' + tool + ' result and reconnect')
|
||||
} finally { await logClient.close().catch(() => {}) }
|
||||
}
|
||||
phase = 'portal:revoke-export-download'
|
||||
const exportAccess = await control('new-artifact-connection')
|
||||
const exportClient = await opened('http', exportAccess.token)
|
||||
try {
|
||||
const exported = content(await exportClient.callTool({ name: 'mm_logs_export', arguments: {
|
||||
serverId: Number(process.env.OWN_ALLOWED_SERVER), q: 'external-sdk-log-1', format: 'json',
|
||||
} }, undefined, { timeout: 5000 }))
|
||||
assert.equal(exported.rows, 1)
|
||||
assert.equal(exported.downloadPath, '/api/mcp/artifacts/' + exported.artifactId)
|
||||
assert.doesNotMatch(JSON.stringify(exported), /external-sdk-log-1/)
|
||||
const download = jwt => boundedFetch(origin + exported.downloadPath, { headers: { authorization: 'Bearer ' + jwt } })
|
||||
const before = await download(exportAccess.ownerJwt)
|
||||
assert.equal(before.status, 200)
|
||||
assert.equal(before.headers.get('cache-control'), 'private, no-store')
|
||||
const bytes = Buffer.from(await before.arrayBuffer())
|
||||
assert.equal(createHash('sha256').update(bytes).digest('hex'), exported.sha256)
|
||||
assert.match(bytes.toString('utf8'), /external-sdk-log-1/)
|
||||
const fresh = await control('revoke-artifact-owner')
|
||||
for (const jwt of [exportAccess.ownerJwt, fresh.ownerJwt]) {
|
||||
const refused = await download(jwt)
|
||||
assert.ok([401, 403].includes(refused.status))
|
||||
assert.equal(refused.headers.has('content-disposition'), false)
|
||||
assert.doesNotMatch(await refused.text(), /external-sdk-log-1/)
|
||||
}
|
||||
const management = await boundedFetch(origin + '/api/mcp/connections', { headers: { authorization: 'Bearer ' + fresh.ownerJwt } })
|
||||
assert.equal(management.status, 200)
|
||||
cases.push('portal owner revocation blocks an existing unexpired log artifact even with refreshed owner login')
|
||||
} finally { await exportClient.close().catch(() => {}) }
|
||||
phase = 'portal:revoke-approved-plan'
|
||||
const planAccess = await control('new-revocable-plan-connection')
|
||||
const planClient = await opened('http', planAccess.token)
|
||||
try {
|
||||
const plan = content(await planClient.callTool({ name: 'mm_logs_settings_update_plan', arguments: {
|
||||
settings: { enabled: false },
|
||||
} }, undefined, { timeout: 5000 }))
|
||||
assert.equal(plan.state, 'awaiting-approval')
|
||||
const approved = await boundedFetch(origin + '/api/mcp/plans/' + plan.planId + '/decision', {
|
||||
method: 'POST', headers: { authorization: 'Bearer ' + planAccess.ownerJwt, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ approve: true }),
|
||||
})
|
||||
assert.equal(approved.status, 200)
|
||||
assert.deepEqual(await approved.json(), { ok: true })
|
||||
await control('revoke-approved-plan-owner')
|
||||
await denied(() => planClient.callTool({ name: 'mm_change_apply', arguments: {
|
||||
planId: plan.planId, idempotencyKey: randomUUID(),
|
||||
} }, undefined, { timeout: 5000 }))
|
||||
cases.push('signed portal revocation invalidates the exact approved log settings plan before apply')
|
||||
} finally { await planClient.close().catch(() => {}) }
|
||||
process.send({ kind: 'result', result: { cases: cases.length, names: cases, externalClientPid: process.pid, serverPid: Number(process.env.OWN_SERVER_PID), bridgePid,
|
||||
sdkOnlyExternalClient: true, maxObservedHttpResponseBytes: maxResponseBytes, clientResponseLimitBytes: 1048576, stdioSdkBufferLimitBytes: 131072,
|
||||
sdkOnlyExternalClient: true, maxObservedHttpResponseBytes: maxResponseBytes, clientResponseLimitBytes: responseLimitBytes, stdioSdkBufferLimitBytes: 131072,
|
||||
discoveryByTransport, discoveryStdioSdkBufferLimitBytes: responseLimitBytes,
|
||||
actualPublicStringLimitObserved: 16000, wholeGenericResultEnvelopeLimitAccepted: false,
|
||||
syntheticPortalJwtAndConsent: true, liveAuthPortal: false, livePortalSender: false, endUserUi: false, wholeExternalDirectionComplete: false } })
|
||||
} catch (error) { process.send?.({ kind: 'failure', phase, errorType: error?.name ?? 'Error', completedCases: cases }); process.exitCode = 1 }
|
||||
|
||||
@@ -12,7 +12,9 @@ const output = resolve(sourceRoot, scenario === 'bridge-vlan' ? 'tmp/mcp-bridge-
|
||||
const paths = ['backend/src/scripts/mcp-stdio.ts', 'backend/src/routes/mcp-transport.ts', 'backend/src/routes/mcp-oauth.ts', 'backend/src/modules/mcp/store.ts',
|
||||
'backend/src/modules/mcp/runtime.ts', 'backend/src/modules/mcp/actions.ts', 'backend/src/modules/mcp/security.ts', 'backend/src/modules/mcp/protocol-limits.ts',
|
||||
'backend/src/modules/mcp/portal-revocation.ts', 'backend/src/plugins/auth.ts', 'backend/src/index.ts', 'node_modules/@modelcontextprotocol/client/package.json', 'node_modules/@modelcontextprotocol/server/package.json']
|
||||
if (scenario === 'bridge-vlan') paths.push('backend/scripts/mcp-external-process/bridge-vlan-client.mjs', 'backend/scripts/mcp-external-process/bridge-vlan-fixture.ts',
|
||||
if (scenario === 'acceptance') paths.push('backend/scripts/mcp-external-process/client.mjs', 'backend/scripts/mcp-external-process/server-fixture.ts',
|
||||
'backend/src/routes/mcp-portal.ts', 'backend/src/modules/mcp/portal-revocation-event.ts', 'backend/src/services/router-logs-query.ts')
|
||||
else paths.push('backend/scripts/mcp-external-process/bridge-vlan-client.mjs', 'backend/scripts/mcp-external-process/bridge-vlan-fixture.ts',
|
||||
'backend/src/modules/mcp/bridge-vlan-actions.ts', 'backend/src/services/bridge-vlan-configuration.ts', 'packages/contracts/src/bridge-vlan.ts')
|
||||
async function pins() { const result = []; for (const path of paths) result.push({ path, sha256: createHash('sha256').update(await readFile(resolve(sourceRoot, path))).digest('hex') }); return result }
|
||||
const before = await pins(), directory = await mkdtemp(join(resolve(sourceRoot, 'tmp'), 'mm-mcp-external-')), state = join(directory, 'state'); await mkdir(state)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { createHash } from "node:crypto"
|
||||
import { createHash, createHmac, randomUUID } from "node:crypto"
|
||||
import { spawn } from "node:child_process"
|
||||
import { createServer } from "node:net"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
@@ -20,6 +20,7 @@ const { pool, closePool } = await import(source("db/index.js"))
|
||||
const { applySqlMigrations } = await import(source("db/migrate.js"))
|
||||
const { mcpConnectionCreateSchema } = await import("@mmapp/contracts/mcp")
|
||||
const { createMcpConnection, rotateConnectionToken, setConnectionStatus, updateMcpSettings } = await import(source("modules/mcp/store.js"))
|
||||
const { listActions } = await import(source("modules/mcp/actions.js")) as typeof import("../../src/modules/mcp/actions.js")
|
||||
const { env } = await import(source("config.js"))
|
||||
const { buildApp } = await import(source("index.js"))
|
||||
const actor = { id: "owned-external-process-owner", email: "[email protected]", name: "Owned external SDK fixture", apps: ["mm"],
|
||||
@@ -28,7 +29,27 @@ let currentId: string | undefined, app: Awaited<ReturnType<typeof buildApp>> | u
|
||||
const credentials: string[] = []
|
||||
const bridgePids = new Set<number>()
|
||||
let childExit: Promise<number> | undefined
|
||||
let releaseLogRead: (() => void) | undefined
|
||||
let logReadCompleted: Promise<void> | undefined
|
||||
let revocationOwnerId: string | undefined
|
||||
const revokedLogRequests: { connectionId: string; tool: string }[] = []
|
||||
const originalConnect = pool.connect
|
||||
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
async function deliverPortalRevocation(ownerId: string) {
|
||||
const secret = randomUUID() + randomUUID()
|
||||
process.env.MCP_PORTAL_WEBHOOK_SECRET = secret
|
||||
const occurredAt = Date.now(), timestamp = String(Math.floor(occurredAt / 1000))
|
||||
const eventId = randomUUID(), body = JSON.stringify({ version: 2, eventId, userId: ownerId, type: "permissions.changed", eventOccurredAt: occurredAt })
|
||||
const signature = "sha256=" + createHmac("sha256", secret).update(timestamp + "." + body).digest("hex")
|
||||
const response = await fetch(origin + "/_internal/mcp/portal/revocations", {
|
||||
method: "POST", headers: { "content-type": "application/json", "x-mm-timestamp": timestamp, "x-mm-signature": signature },
|
||||
body, signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
assert.equal(response.status, 200)
|
||||
assert.deepEqual(await response.json(), { ok: true, duplicate: false })
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_portal_events WHERE id=$1", [eventId])).rows[0].count, 1)
|
||||
return occurredAt
|
||||
}
|
||||
function alive(pid: number) { try { process.kill(pid, 0); return true } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; throw error } }
|
||||
async function drainChildAndBridges() {
|
||||
if (child && child.exitCode === null && child.signalCode === null) { child.kill(); await Promise.race([childExit, delay(5000).then(() => { throw Error("Owned external child failed to exit") })]) }
|
||||
@@ -47,9 +68,18 @@ try {
|
||||
const secret = "OWNED_DEVICE_CREDENTIAL_MUST_NOT_ESCAPE"
|
||||
const allowed = (await pool.query("INSERT INTO servers(name,host,enabled,password) VALUES('owned allowed','192.0.2.1',false,$1) RETURNING id", [secret])).rows[0].id
|
||||
const forbidden = (await pool.query("INSERT INTO servers(name,host,enabled) VALUES('owned forbidden','192.0.2.2',false) RETURNING id")).rows[0].id
|
||||
await pool.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation)
|
||||
SELECT id,name,host,'fixture','00:00:01',now(),now()-n*interval '1 second',ARRAY['system'],'info','external-sdk-log-'||n,0
|
||||
FROM servers CROSS JOIN generate_series(1,2) AS n WHERE id=ANY($1::int[])`, [[allowed, forbidden]])
|
||||
await updateMcpSettings({ enabled: true }, actor)
|
||||
app = await buildApp({ startScheduler: false, logger: false }); await app.listen({ port, host: "127.0.0.1" })
|
||||
const jwt = await new SignJWT({ sub: actor.id, apps: actor.apps, permissions: actor.permissions }).setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret))
|
||||
const artifactOwner = { ...actor, id: actor.id + "-artifact" }
|
||||
const signArtifactOwner = () => new SignJWT({ sub: artifactOwner.id, apps: artifactOwner.apps, permissions: artifactOwner.permissions })
|
||||
.setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret))
|
||||
let artifactConnectionId: string | undefined
|
||||
const planOwner = { ...actor, id: actor.id + "-approved-plan", permissions: [...actor.permissions, "mm:mcp:approve"] }
|
||||
let planConnectionId: string | undefined
|
||||
const portalHeaders = { authorization: `Bearer ${jwt}`, "content-type": "application/json" }
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_connections WHERE owner_id=$1", [actor.id])).rows[0].count, 0)
|
||||
const callback = "http://localhost:45678/callback?registered=1"
|
||||
@@ -123,10 +153,131 @@ try {
|
||||
if (message.kind !== "control" || !Number.isSafeInteger(message.id)) return
|
||||
try {
|
||||
let value: unknown
|
||||
if (message.command === "new-connection") {
|
||||
if (message.command === "new-revocable-plan-connection") {
|
||||
assert(!planConnectionId)
|
||||
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({
|
||||
name: "Owned approved plan revocation", mode: "approval", permissions: ["mm:settings:admin"],
|
||||
expiresAt: new Date(Date.now() + 600000).toISOString(),
|
||||
scope: { serverIds: [Number(allowed), Number(forbidden)], operationIds: ["logs.settings.update", "changes.apply"], allowGlobal: true },
|
||||
}), planOwner)
|
||||
currentId = planConnectionId = issued.connection.id
|
||||
const ownerJwt = await new SignJWT({ sub: planOwner.id, apps: planOwner.apps, permissions: planOwner.permissions })
|
||||
.setProtectedHeader({ alg: "HS256" }).setIssuer(env.authIssuer).setIssuedAt().setExpirationTime("1h").sign(new TextEncoder().encode(env.jwtSecret))
|
||||
credentials.push(issued.token, ownerJwt)
|
||||
value = { token: issued.token, ownerJwt }
|
||||
} else if (message.command === "revoke-approved-plan-owner") {
|
||||
assert(planConnectionId && currentId === planConnectionId)
|
||||
const plans = (await pool.query("SELECT id,state,approved_by FROM mcp_plans WHERE connection_id=$1", [planConnectionId])).rows
|
||||
assert.equal(plans.length, 1)
|
||||
assert.equal(plans[0].state, "approved")
|
||||
assert.equal(plans[0].approved_by, planOwner.id)
|
||||
await deliverPortalRevocation(planOwner.id)
|
||||
value = { ok: true }
|
||||
} else if (message.command === "new-artifact-connection") {
|
||||
assert(!artifactConnectionId)
|
||||
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({
|
||||
name: "Owned export revocation", mode: "observer", permissions: ["mm:servers:read"],
|
||||
expiresAt: new Date(Date.now() + 600000).toISOString(),
|
||||
scope: { serverIds: [Number(allowed)], operationIds: ["logs.export"], allowGlobal: false },
|
||||
}), artifactOwner)
|
||||
currentId = artifactConnectionId = issued.connection.id
|
||||
const ownerJwt = await signArtifactOwner()
|
||||
credentials.push(issued.token, ownerJwt)
|
||||
value = { token: issued.token, ownerJwt }
|
||||
} else if (message.command === "revoke-artifact-owner") {
|
||||
assert(artifactConnectionId && currentId === artifactConnectionId)
|
||||
const occurredAt = await deliverPortalRevocation(artifactOwner.id)
|
||||
while (Math.floor(Date.now() / 1000) <= Math.floor(occurredAt / 1000)) await delay(25)
|
||||
const ownerJwt = await signArtifactOwner()
|
||||
credentials.push(ownerJwt)
|
||||
value = { ownerJwt }
|
||||
} else if (["arm-log-revocation", "arm-log-revocation-after-commit", "arm-log-follow-revocation"].includes(message.command ?? "")) {
|
||||
assert(!releaseLogRead)
|
||||
const afterCommit = message.command === "arm-log-revocation-after-commit"
|
||||
const follow = message.command === "arm-log-follow-revocation"
|
||||
const owner = { ...actor, id: `${actor.id}-${follow ? "follow" : afterCommit ? "postcommit" : "precommit"}` }
|
||||
revocationOwnerId = owner.id
|
||||
if (follow) {
|
||||
await pool.query("UPDATE servers SET enabled=true WHERE id=$1", [allowed])
|
||||
await pool.query("UPDATE router_log_settings SET enabled=true WHERE id=1")
|
||||
}
|
||||
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({
|
||||
name: "Owned portal revocation during log read", mode: "observer", permissions: ["mm:servers:read"],
|
||||
expiresAt: new Date(Date.now() + 600000).toISOString(),
|
||||
scope: { serverIds: [Number(allowed)], operationIds: [follow ? "logs.follow" : "logs.query"], allowGlobal: false },
|
||||
}), owner)
|
||||
currentId = issued.connection.id
|
||||
revokedLogRequests.push({ connectionId: currentId, tool: follow ? "mm_logs_follow" : "mm_logs_query" })
|
||||
credentials.push(issued.token)
|
||||
let completed!: () => void
|
||||
logReadCompleted = new Promise<void>(resolve => { completed = resolve })
|
||||
const hold = new Promise<void>(resolve => { releaseLogRead = resolve })
|
||||
pool.connect = ((callback?: unknown) => {
|
||||
if (typeof callback === "function") return Reflect.apply(originalConnect, pool, [callback])
|
||||
return (async () => {
|
||||
const client = await originalConnect.call(pool)
|
||||
const query = client.query, release = client.release
|
||||
let completingLog = false
|
||||
client.query = (async (...args: unknown[]) => {
|
||||
const params = args[1]
|
||||
if (typeof args[0] === "string" && args[0].startsWith("UPDATE mcp_requests SET status=$2,result=")
|
||||
&& Array.isArray(params) && params[1] === "completed" && String(params[2]).includes(follow ? "external-sdk-live-event" : "external-sdk-log-1")) {
|
||||
completingLog = true
|
||||
if (!afterCommit) { completed(); await hold }
|
||||
}
|
||||
const result = await Reflect.apply(query, client, args)
|
||||
if (afterCommit && completingLog && args[0] === "COMMIT") { completed(); await hold }
|
||||
return result
|
||||
}) as typeof query
|
||||
client.release = error => {
|
||||
client.query = query
|
||||
client.release = release
|
||||
release(error)
|
||||
}
|
||||
return client
|
||||
})()
|
||||
}) as typeof originalConnect
|
||||
value = { token: issued.token }
|
||||
} else if (message.command === "publish-follow-fixture-row") {
|
||||
assert(releaseLogRead && currentId && revokedLogRequests.at(-1)?.tool === "mm_logs_follow")
|
||||
const deadline = Date.now() + 3000
|
||||
for (;;) {
|
||||
const live = await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [currentId])
|
||||
if (live.rows[0].count === 1) break
|
||||
assert(Date.now() < deadline, "Owned follow did not acquire a live read lease")
|
||||
await delay(25)
|
||||
}
|
||||
await pool.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation)
|
||||
SELECT id,name,host,'fixture','00:00:01',now(),now(),ARRAY['system'],'info','external-sdk-live-event',0
|
||||
FROM servers WHERE id=$1`, [allowed])
|
||||
value = { ok: true }
|
||||
} else if (message.command === "deliver-log-revocation") {
|
||||
assert(logReadCompleted && releaseLogRead && currentId && revocationOwnerId)
|
||||
try {
|
||||
await Promise.race([logReadCompleted, delay(5000).then(() => { throw Error("Owned log read did not reach the barrier") })])
|
||||
await deliverPortalRevocation(revocationOwnerId)
|
||||
value = { ok: true }
|
||||
} finally {
|
||||
pool.connect = originalConnect
|
||||
releaseLogRead()
|
||||
releaseLogRead = undefined
|
||||
logReadCompleted = undefined
|
||||
revocationOwnerId = undefined
|
||||
}
|
||||
} else if (message.command === "new-connection") {
|
||||
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: "Owned external SDK process", mode: "observer", expiresAt: new Date(Date.now() + 1200000).toISOString(),
|
||||
permissions: ["mm:servers:read"], scope: { serverIds: [Number(allowed)], operationIds: ["servers.list"], allowGlobal: false }, limits: { callsPerMinute: 200 } }), actor)
|
||||
currentId = issued.connection.id; credentials.push(issued.token); value = { token: issued.token, connectionId: currentId }
|
||||
} else if (message.command === "discovery-connection") {
|
||||
const operations = listActions().map(action => action.operation)
|
||||
const permissions = [...new Set(operations.flatMap(operation => operation.requiredPermissions))]
|
||||
const issued = await createMcpConnection(mcpConnectionCreateSchema.parse({
|
||||
name: "Owned full catalogue discovery", mode: "approval", expiresAt: new Date(Date.now() + 600000).toISOString(),
|
||||
permissions, scope: { serverIds: [Number(allowed)], operationIds: operations.map(operation => operation.id), allowGlobal: true },
|
||||
}), { ...actor, permissions: [...new Set([...actor.permissions, ...permissions])] })
|
||||
currentId = issued.connection.id
|
||||
credentials.push(issued.token)
|
||||
value = { token: issued.token, names: operations.map(operation => operation.toolName) }
|
||||
} else if (message.command === "large-result" || message.command === "reset-result") {
|
||||
await pool.query("UPDATE servers SET comment=$2 WHERE id=$1", [allowed, message.command === "large-result" ? "Z".repeat(40000) + "OWNED_TRUNCATION_TAIL" : ""])
|
||||
value = { ok: true }
|
||||
@@ -144,6 +295,22 @@ try {
|
||||
const code = await childExit; clearTimeout(timer)
|
||||
if (code !== 0 || !result) { console.log("OWNED_EXTERNAL_PROCESS_FAILURE", JSON.stringify({ code, failure, stdout: stdout.slice(0,1000), stderr: stderr.slice(0,1000) })); throw Error("External SDK process did not complete") }
|
||||
const logs = JSON.stringify((await pool.query("SELECT tool,input,result,error_code FROM mcp_requests ORDER BY created_at")).rows)
|
||||
assert.equal(revokedLogRequests.length, 3)
|
||||
assert.ok(planConnectionId)
|
||||
const invalidated = (await pool.query("SELECT state FROM mcp_plans WHERE connection_id=$1", [planConnectionId])).rows
|
||||
assert.deepEqual(invalidated, [{ state: "invalidated" }])
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_operations WHERE connection_id=$1", [planConnectionId])).rows[0].count, 0)
|
||||
assert.equal((await pool.query("SELECT enabled FROM router_log_settings WHERE id=1")).rows[0].enabled, true)
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_artifacts WHERE connection_id=$1 AND expires_at>now()", [artifactConnectionId])).rows[0].count, 1)
|
||||
for (const { connectionId, tool } of revokedLogRequests) {
|
||||
const revokedRead = (await pool.query("SELECT status,error_code,result FROM mcp_requests WHERE connection_id=$1 AND tool=$2 AND status='error'", [connectionId, tool])).rows
|
||||
assert.equal(revokedRead.length, 1)
|
||||
assert.equal(revokedRead[0].status, "error")
|
||||
assert.deepEqual(Object.keys(revokedRead[0].result), ["message"])
|
||||
assert.doesNotMatch(JSON.stringify(revokedRead[0].result), /external-sdk-log-1|external-sdk-live-event|"entries"|"nextCursor"|"cursor"/)
|
||||
assert.ok(["OWNER_CLAIMS_REVOKED", "UNAUTHORIZED"].includes(revokedRead[0].error_code))
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS count FROM mcp_read_leases WHERE connection_id=$1", [connectionId])).rows[0].count, 0)
|
||||
}
|
||||
for (const token of [...credentials, jwt, secret]) { assert(!logs.includes(token)); assert(!stdout.includes(token)); assert(!stderr.includes(token)) }
|
||||
const final = { ...(result as object), actualOwnPg: true, actualProductionHttpServer: true, serverFixturePid: process.pid,
|
||||
controlTransitions: transcript, auditCredentialLeakAbsent: true, denialWithoutConnection: true,
|
||||
@@ -151,5 +318,7 @@ try {
|
||||
await writeFile(process.env.OWN_EXTERNAL_RESULT_FILE!, JSON.stringify(final, null, 2))
|
||||
console.log("Owned external process SDK result:", JSON.stringify(final))
|
||||
} finally {
|
||||
pool.connect = originalConnect
|
||||
releaseLogRead?.()
|
||||
try { await drainChildAndBridges() } finally { try { if (app) await app.close() } finally { await closePool() } }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {execFileSync} from 'node:child_process'
|
||||
import {randomBytes, X509Certificate} from 'node:crypto'
|
||||
import {mkdtemp, mkdir, cp, readFile, rm, chmod, access} from 'node:fs/promises'
|
||||
import {createServer} from 'node:net'
|
||||
import {tmpdir} from 'node:os'
|
||||
import {dirname, join, resolve} from 'node:path'
|
||||
import EmbeddedPostgres from 'embedded-postgres'
|
||||
import pg from 'pg'
|
||||
import {poolOptions, retainPublisherTlsPeer} from './writer-profile/operator-contract.mjs'
|
||||
import {sha} from './writer-profile/operator-files.mjs'
|
||||
|
||||
const openssl = process.env.MMAPP_TLS_FIXTURE_OPENSSL || 'openssl'
|
||||
const host = 'localhost'
|
||||
const reservePort = async () => {
|
||||
const listener = createServer()
|
||||
await new Promise((ready, reject) => listener.once('error', reject).listen(0, '127.0.0.1', ready))
|
||||
return listener
|
||||
}
|
||||
const releasePort = listener => new Promise((ready, reject) => listener.close(error => error ? reject(error) : ready()))
|
||||
const sourcePort = await reservePort()
|
||||
const clonePort = await reservePort()
|
||||
const port = sourcePort.address().port, clonePortNumber = clonePort.address().port
|
||||
await Promise.all([releasePort(sourcePort), releasePort(clonePort)])
|
||||
const transport = {host, port, user: 'mcp_test', password: randomBytes(24).toString('hex'), database: 'postgres'}
|
||||
const certFile = 'server.pem', keyFile = 'server-key.pem'
|
||||
const root = await mkdtemp(join(tmpdir(), 'mm-writer-tls-owned-'))
|
||||
const databaseDir = join(root, 'pg'), cloneDir = join(root, 'pg-clone')
|
||||
const sourceTlsDir = join(root, 'source-tls'), cloneTlsDir = join(root, 'clone-tls')
|
||||
const cluster = (databaseDir, port, tlsDir) => new EmbeddedPostgres({databaseDir, port, user: transport.user, password: transport.password,
|
||||
persistent: true, createPostgresUser: false, postgresFlags: ['-h', '127.0.0.1', '-c', 'ssl=on', '-c', `ssl_cert_file=${join(tlsDir, certFile)}`, '-c', `ssl_key_file=${join(tlsDir, keyFile)}`],
|
||||
onLog: () => {}, onError: () => {}})
|
||||
const postgres = cluster(databaseDir, port, sourceTlsDir), clone = cluster(cloneDir, clonePortNumber, cloneTlsDir)
|
||||
let sourceStarted = false, cloneStarted = false
|
||||
const fingerprint = ca => sha(new X509Certificate(ca).publicKey.export({format:'der', type:'spki'}))
|
||||
const issue = async tlsDir => {
|
||||
await mkdir(tlsDir, {mode:0o700})
|
||||
execFileSync(openssl, ['req', '-new', '-newkey', 'rsa:2048', '-x509', '-nodes', '-days', '1', '-subj', '/CN=localhost',
|
||||
'-addext', 'subjectAltName=DNS:localhost', '-keyout', keyFile, '-out', certFile], {cwd: tlsDir, stdio: 'ignore', windowsHide: true})
|
||||
await chmod(join(tlsDir, keyFile), 0o600)
|
||||
return readFile(join(tlsDir, certFile))
|
||||
}
|
||||
const withClient = async (config, work) => {
|
||||
const pool = new pg.Pool({connectionTimeoutMillis:5000, ...config})
|
||||
let client
|
||||
try { client = await pool.connect(); return await work(client) }
|
||||
finally { client?.release(true); await pool.end() }
|
||||
}
|
||||
const query = "SELECT (pg_control_system()).system_identifier::text AS system, oid::text AS oid FROM pg_database WHERE datname=current_database()"
|
||||
try {
|
||||
await postgres.initialise()
|
||||
const first = await issue(sourceTlsDir)
|
||||
await postgres.start(); sourceStarted = true
|
||||
const pin = {serverName:host, caSha256:sha(first), spkiSha256:fingerprint(first)}
|
||||
const request = {tls:pin, roles:{publisher:{name:transport.user}}, source:{databaseName:transport.database}}
|
||||
const baseline = await withClient(poolOptions(transport, request, first), async client => {
|
||||
const retained = retainPublisherTlsPeer(client, pin)
|
||||
retained()
|
||||
const {rows:[source]} = await client.query(query)
|
||||
const {rows:[status]} = await client.query('SELECT ssl FROM pg_stat_ssl WHERE pid=pg_backend_pid()')
|
||||
assert.equal(status.ssl, true)
|
||||
await client.query("CREATE TABLE owned_tls_fixture (note text PRIMARY KEY); INSERT INTO owned_tls_fixture VALUES ('original')")
|
||||
const connection = client.connection, socket = connection.stream
|
||||
connection.stream = undefined
|
||||
try { assert.throws(retained, /retained TLS peer unavailable/) } finally { connection.stream = socket }
|
||||
return source
|
||||
})
|
||||
await withClient({...transport, ssl:false}, async client => assert.throws(() => retainPublisherTlsPeer(client,pin), /retained TLS peer unavailable/))
|
||||
await postgres.stop(); sourceStarted = false
|
||||
await cp(databaseDir, cloneDir, {recursive:true, force:false, errorOnExist:true})
|
||||
await assert.rejects(access(join(cloneDir, keyFile)), {code:'ENOENT'})
|
||||
const second = await issue(cloneTlsDir)
|
||||
assert.notEqual(fingerprint(second), pin.spkiSha256)
|
||||
await postgres.start(); sourceStarted = true
|
||||
await clone.start(); cloneStarted = true
|
||||
const cloneTransport = {...transport, port:clonePortNumber}
|
||||
const bothTrusted = Buffer.concat([first,second])
|
||||
await assert.rejects(withClient(poolOptions(cloneTransport, {...request,tls:{...pin,caSha256:sha(bothTrusted)}}, bothTrusted),
|
||||
() => assert.fail('Wrong physical cluster must not reach application SQL')), /TLS SPKI refused/)
|
||||
await withClient({...cloneTransport, ssl:{ca:bothTrusted, servername:host, rejectUnauthorized:true}}, async client => {
|
||||
assert.equal(client.connection.stream.authorized, true)
|
||||
const {rows:[source]} = await client.query(query)
|
||||
assert.deepEqual(source, baseline)
|
||||
assert.throws(() => retainPublisherTlsPeer(client,pin), /TLS SPKI refused/)
|
||||
retainPublisherTlsPeer(client,{...pin,spkiSha256:fingerprint(second)})()
|
||||
assert.deepEqual((await client.query('SELECT note FROM owned_tls_fixture')).rows, [{note:'original'}])
|
||||
await client.query("INSERT INTO owned_tls_fixture VALUES ('clone-only')")
|
||||
})
|
||||
await withClient(poolOptions(transport, request, first), async client => {
|
||||
retainPublisherTlsPeer(client,pin)()
|
||||
assert.deepEqual((await client.query('SELECT note FROM owned_tls_fixture')).rows, [{note:'original'}])
|
||||
})
|
||||
console.log('Genuine PostgreSQL TLS: original accepted; independently writable physical clone with identical DB coordinates and a different CA-valid TLS key refused')
|
||||
} finally {
|
||||
const stops = await Promise.allSettled([
|
||||
cloneStarted ? clone.stop() : undefined,
|
||||
sourceStarted ? postgres.stop() : undefined,
|
||||
])
|
||||
assert(stops.every(result => result.status === 'fulfilled'), 'Owned cluster stop unconfirmed')
|
||||
assert.equal(dirname(resolve(root)), resolve(tmpdir()))
|
||||
assert.match(root, /mm-writer-tls-owned-/)
|
||||
await rm(root, {recursive:true, force:true, maxRetries:20, retryDelay:100})
|
||||
await assert.rejects(access(root), {code:'ENOENT'})
|
||||
console.log('Owned PostgreSQL and temporary TLS material removed')
|
||||
}
|
||||
@@ -165,6 +165,10 @@ const chrVpn = process.argv.includes('--chr-vpn-catalog')
|
||||
const chrFilters = process.argv.includes('--chr-filter-write')
|
||||
const chrLegacyOspf = process.argv.includes('--chr-ospf-legacy')
|
||||
const greRevision = process.argv.includes('--gre-revision')
|
||||
const usersBindings = process.argv.includes('--users-bindings')
|
||||
if (usersBindings && (process.argv.length !== 3 || process.argv[2] !== '--users-bindings')) throw new Error('User bindings acceptance requires its own disposable cluster')
|
||||
const bridgeVlanConfiguration = process.argv.includes('--bridge-vlan-configuration')
|
||||
if (bridgeVlanConfiguration && process.argv.slice(2).some(arg => arg !== '--bridge-vlan-configuration')) throw new Error('Bridge VLAN acceptance requires its own disposable cluster')
|
||||
const applicationAdapters = process.argv.includes('--application-adapters')
|
||||
const scopedRoutingDiagnostics = process.argv.includes('--scoped-routing-diagnostics')
|
||||
const interfaceEventsCompatibility = process.argv.includes('--interface-events-compatibility')
|
||||
@@ -246,7 +250,7 @@ try {
|
||||
if (regressionOnly) console.log('Selected regression only: primary integration and terminal restore are not run')
|
||||
if (profilesOnly) console.log('Profiles-only regression matrix: primary integration NOT RUN; terminal restore NOT RUN; full acceptance is not claimed')
|
||||
const result = regressionOnly || profilesOnly ? 0 : await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, pollMemory ? ['--max-old-space-size=192', '--max-semi-space-size=16', fileURLToPath(new URL('../dist-mcp-memory/services/server-poll-memory.owned.test.js', import.meta.url))] : ['--import', 'tsx', principalSecuritySource ? 'src/modules/mcp/principal-security-source.owned.test.ts' : pollJournalSnapshot ? 'src/services/server-poll-journal-snapshot.owned.test.ts' : pollTerminalArchive ? 'src/services/server-poll-terminal-archive.owned.test.ts' : pollArchiveSchema ? 'src/db/server-poll-terminal-archives.sql.test.ts' : telemetrySettingsConfiguration ? 'src/modules/mcp/telemetry-settings-configuration.owned.test.ts' : routingReservationSchema ? 'src/db/routing-reservation-schema.owned.test.ts' : routingForwardRounds ? 'src/modules/mcp/routing-network-forward-rounds.owned.test.ts' : flowActiveServices ? 'src/services/traffic-flow-active-services.owned.test.ts' : flowActiveRebuild ? 'src/services/traffic-flow-active-rebuild.owned.test.ts' : flowQuality ? 'src/services/traffic-flow-quality.owned.test.ts' : flowWorkerIpc ? 'src/services/traffic-flow-worker-ipc.owned.test.ts' : manualUptime ? 'src/modules/mcp/manual-uptime-http.test.ts' : manualPingGroup ? 'src/modules/mcp/manual-ping-group-http.test.ts' : manualTraffic ? 'src/modules/mcp/manual-traffic-http.test.ts' : manualApiPing ? 'src/modules/mcp/manual-api-ping-http.test.ts' : simpleSpeedChr ? 'src/modules/mcp/simple-speed-chr.test.ts' : simpleSpeed ? 'src/modules/mcp/simple-speed-http.test.ts' : externalPeerMetadata ? 'src/services/bandwidth-external-peer-metadata.sql.test.ts' : operatorPeerMetadata ? 'src/services/bandwidth-operator-metadata.sql.test.ts' : publicRegistryMetadata ? 'src/services/bandwidth-public-registry.sql.test.ts' : operatorDockerSql ? 'src/services/bandwidth-operator-docker-stop.sql.test.ts' : probeOperationRetirement ? 'src/services/server-probe-operation-retirement.owned.test.ts' : ordinaryOperationRetirement ? 'src/modules/mcp/ordinary-operation-retirement.owned.test.ts' : pollOperationRetirement ? 'src/services/server-poll-operation-retirement.owned.test.ts' : mcpOperationOrigin ? 'src/modules/mcp/operation-execution-origin.sql.test.ts' : mcpSpeedOriginal ? 'src/modules/mcp/speed-bandwidth-original.sql.test.ts' : routerConfigurationGuard ? 'src/services/router-configuration-guard.sql.test.ts' : scheduledSpeedOriginal ? 'src/services/bandwidth-scheduled-speed-original.sql.test.ts' : bandwidthSpeedWorkflow ? 'src/services/bandwidth-speed-workflow.sql.test.ts' : restSpeedOriginal ? 'src/services/bandwidth-rest-speed-original.sql.test.ts' : operationLocks ? 'src/services/operation-config-lock-lease.sql.test.ts' : serviceExecutionOrigin ? 'src/modules/mcp/service-execution-origin.sql.test.ts' : bandwidthNodeSource ? 'src/services/bandwidth-node-source.sql.test.ts' : bandwidthExternalRegistry ? 'src/services/bandwidth-external-node-registry.sql.test.ts' : bandwidthNodeRegistry ? 'src/services/bandwidth-node-registry.sql.test.ts' : bandwidthControlSource ? 'src/services/bandwidth-control-source.sql.test.ts' : bandwidthAdmission ? 'src/services/bandwidth-admission.sql.test.ts' : serverPollStorageFresh ? 'src/services/server-poll-storage-fresh.sql.test.ts' : serverPollStorage ? 'src/services/server-poll-storage.sql.test.ts' : probeStorageFresh ? 'src/services/server-probe-storage-fresh.sql.test.ts' : probeLegacyOrphans ? 'src/services/server-probe-legacy-orphans.sql.test.ts' : diagnosticCancelHost ? 'src/modules/mcp/diagnostic-cancellation-storage.sql.test.ts' : ipsecExportHttp ? 'src/modules/mcp/ipsec-existing-certificate-export-http.sql.test.ts' : ipsecExportJournal ? 'src/services/ipsec-existing-certificate-export-rest-journal.sql.test.ts' : migrationDataInventory ? 'src/db/migration-data-inventory.sql.test.ts' : probeStorageHistory ? 'src/services/server-probe-storage-history.sql.test.ts' : probeStorageJournal ? 'src/services/server-probe-storage-journal.sql.test.ts' : controlledSchemaReference ? 'src/db/controlled-schema-reference.sql.test.ts' : controlledMigrations ? 'src/db/controlled-migrations.sql.test.ts' : databaseSchemaControl ? 'src/db/database-schema-control.sql.test.ts' : migrationSchemaInventory ? 'src/db/migration-schema-inventory.sql.test.ts' : routingNetworkNative ? 'src/modules/mcp/routing-network-native.sql.test.ts' : routingReservationRuntime ? 'src/modules/mcp/routing-reservation-runtime.sql.test.ts' : routingReservations ? 'src/modules/mcp/routing-reservation.sql.test.ts' : collectorPartitionWriters ? 'src/services/collector-partition-writes.sql.test.ts' : serverPollDurable ? 'src/services/server-poll-durable-session.sql.test.ts' : routingRecoveryAuthority ? 'src/modules/mcp/routing-recovery-authority.sql.test.ts' : legacyFlowRebuild ? 'src/test/legacy-flow-rebuild-sql.ts' : probeJournal ? 'src/services/server-probe-journal.sql.test.ts' : flowHostKeys ? 'src/modules/mcp/flow-host-keys-http.test.ts' : flowRebuildMcp ? 'src/modules/mcp/flow-facts-rebuild-http.test.ts' : flowRebuildLine truncated
|
||||
const child = spawn(process.execPath, pollMemory ? ['--max-old-space-size=192', '--max-semi-space-size=16', fileURLToPath(new URL('../dist-mcp-memory/services/server-poll-memory.owned.test.js', import.meta.url))] : ['--import', 'tsx', usersBindings ? 'src/modules/mcp/users-bindings-owned.test.ts' : bridgeVlanConfiguration ? 'src/modules/mcp/bridge-vlan-configuration-http.test.ts' : principalSecuritySource ? 'src/modules/mcp/principal-security-source.owned.test.ts' : pollJournalSnapshot ? 'src/services/server-poll-journal-snapshot.owned.test.ts' : pollTerminalArchive ? 'src/services/server-poll-terminal-archive.owned.test.ts' : pollArchiveSchema ? 'src/db/server-poll-terminal-archives.sql.test.ts' : telemetrySettingsConfiguration ? 'src/modules/mcp/telemetry-settings-configuration.owned.test.ts' : routingReservationSchema ? 'src/db/routing-reservation-schema.owned.test.ts' : routingForwardRounds ? 'src/modules/mcp/routing-network-forward-rounds.owned.test.ts' : flowActiveServices ? 'src/services/traffic-flow-active-services.owned.test.ts' : flowActiveRebuild ? 'src/services/traffic-flow-active-rebuild.owned.test.ts' : flowQuality ? 'src/services/traffic-flow-quality.owned.test.ts' : flowWorkerIpc ? 'src/services/traffic-flow-worker-ipc.owned.test.ts' : manualUptime ? 'src/modules/mcp/manual-uptime-http.test.ts' : manualPingGroup ? 'src/modules/mcp/manual-ping-group-http.test.ts' : manualTraffic ? 'src/modules/mcp/manual-traffic-http.test.ts' : manualApiPing ? 'src/modules/mcp/manual-api-ping-http.test.ts' : simpleSpeedChr ? 'src/modules/mcp/simple-speed-chr.test.ts' : simpleSpeed ? 'src/modules/mcp/simple-speed-http.test.ts' : externalPeerMetadata ? 'src/services/bandwidth-external-peer-metadata.sql.test.ts' : operatorPeerMetadata ? 'src/services/bandwidth-operator-metadata.sql.test.ts' : publicRegistryMetadata ? 'src/services/bandwidth-public-registry.sql.test.ts' : operatorDockerSql ? 'src/services/bandwidth-operator-docker-stop.sql.test.ts' : probeOperationRetirement ? 'src/services/server-probe-operation-retirement.owned.test.ts' : ordinaryOperationRetirement ? 'src/modules/mcp/ordinary-operation-retirement.owned.test.ts' : pollOperationRetirement ? 'src/services/server-poll-operation-retirement.owned.test.ts' : mcpOperationOrigin ? 'src/modules/mcp/operation-execution-origin.sql.test.ts' : mcpSpeedOriginal ? 'src/modules/mcp/speed-bandwidth-original.sql.test.ts' : routerConfigurationGuard ? 'src/services/router-configuration-guard.sql.test.ts' : scheduledSpeedOriginal ? 'src/services/bandwidth-scheduled-speed-original.sql.test.ts' : bandwidthSpeedWorkflow ? 'src/services/bandwidth-speed-workflow.sql.test.ts' : restSpeedOriginal ? 'src/services/bandwidth-rest-speed-original.sql.test.ts' : operationLocks ? 'src/services/operation-config-lock-lease.sql.test.ts' : serviceExecutionOrigin ? 'src/modules/mcp/service-execution-origin.sql.test.ts' : bandwidthNodeSource ? 'src/services/bandwidth-node-source.sql.test.ts' : bandwidthExternalRegistry ? 'src/services/bandwidth-external-node-registry.sql.test.ts' : bandwidthNodeRegistry ? 'src/services/bandwidth-node-registry.sql.test.ts' : bandwidthControlSource ? 'src/services/bandwidth-control-source.sql.test.ts' : bandwidthAdmission ? 'src/services/bandwidth-admission.sql.test.ts' : serverPollStorageFresh ? 'src/services/server-poll-storage-fresh.sql.test.ts' : serverPollStorage ? 'src/services/server-poll-storage.sql.test.ts' : probeStorageFresh ? 'src/services/server-probe-storage-fresh.sql.test.ts' : probeLegacyOrphans ? 'src/services/server-probe-legacy-orphans.sql.test.ts' : diagnosticCancelHost ? 'src/modules/mcp/diagnostic-cancellation-storage.sql.test.ts' : ipsecExportHttp ? 'src/modules/mcp/ipsec-existing-certificate-export-http.sql.test.ts' : ipsecExportJournal ? 'src/services/ipsec-existing-certificate-export-rest-journal.sql.test.ts' : migrationDataInventory ? 'src/db/migration-data-inventory.sql.test.ts' : probeStorageHistory ? 'src/services/server-probe-storage-history.sql.test.ts' : probeStorageJournal ? 'src/services/server-probe-storage-journal.sql.test.ts' : controlledSchemaReference ? 'src/db/controlled-schema-reference.sql.test.ts' : controlledMigrations ? 'src/db/controlled-migrations.sql.test.ts' : databaseSchemaControl ? 'src/db/database-schema-control.sql.test.ts' : migrationSchemaInventory ? 'src/db/migration-schema-inventory.sql.test.ts' : routingNetworkNative ? 'src/modules/mcp/routing-network-native.sql.test.ts' : routingReservationRuntime ? 'src/modules/mcp/routing-reservation-runtime.sql.test.ts' : routingReservations ? 'src/modules/mcp/routing-reservation.sql.test.ts' : collectorPartitionWriters ? 'src/services/collector-partition-writes.sql.test.ts' : serverPollDurable ? 'src/services/server-poll-durable-session.sql.test.ts' : routingRecoveryAuthority ? 'src/modules/mcp/routing-recovery-authority.sql.test.ts' : legacyFlowRebuild ? 'src/test/legacy-flow-rebuild-sql.ts' : probeJournal ? 'src/services/server-probe-journal.sql.testLine truncated
|
||||
cwd: pollMemory ? directory : new URL('../', import.meta.url), stdio: 'inherit', env: { ...(pollMemory ? pollMemoryEnvironment : process.env),
|
||||
...(routingOriginalEffects ? { MMAPP_OWN_ROUTING_ORIGINAL_EFFECTS: 'true' } : {}),
|
||||
DATABASE_URL: `postgresql://mcp_test:${password}@127.0.0.1:${port}/postgres`,
|
||||
@@ -257,7 +261,7 @@ try {
|
||||
child.once('error', reject); child.once('exit', code => resolve(code ?? 1))
|
||||
})
|
||||
exitCode = result
|
||||
if (result === 0 && !regressionOnly && !profilesOnly && !pollMemory && !principalSecuritySource && !pollJournalSnapshot && !pollArchiveSchema && !telemetrySettingsConfiguration && !routingReservationSchema && !routingForwardRounds && !flowActiveServices && !flowActiveRebuild && !flowQuality && !flowWorkerIpc && !manualUptime && !manualPingGroup && !manualTraffic && !manualApiPing && !simpleSpeed && !simpleSpeedChr && !externalPeerMetadata && !operatorPeerMetadata && !publicRegistryMetadata && !operatorDockerSql && !mcpOperationOrigin && !mcpSpeedOriginal && !routerConfigurationGuard && !scheduledSpeedOriginal && !bandwidthSpeedWorkflow && !restSpeedOriginal && !operationLocks && !serviceExecutionOrigin && !bandwidthNodeSource && !bandwidthNodeRegistry && !bandwidthExternalRegistry && !bandwidthControlSource && !bandwidthAdmission && !probeLegacyOrphans && !probeStorageJournal && !probeStorageHistory && !probeJournal && !chr && !flowWrites && !flowRebuild && !flowRebuildMcp && (process.argv.includes('--restore') || process.env.MMAPP_PG_CLIENT_BIN)) {
|
||||
if (result === 0 && !regressionOnly && !profilesOnly && !usersBindings && !bridgeVlanConfiguration && !pollMemory && !principalSecuritySource && !pollJournalSnapshot && !pollArchiveSchema && !telemetrySettingsConfiguration && !routingReservationSchema && !routingForwardRounds && !flowActiveServices && !flowActiveRebuild && !flowQuality && !flowWorkerIpc && !manualUptime && !manualPingGroup && !manualTraffic && !manualApiPing && !simpleSpeed && !simpleSpeedChr && !externalPeerMetadata && !operatorPeerMetadata && !publicRegistryMetadata && !operatorDockerSql && !mcpOperationOrigin && !mcpSpeedOriginal && !routerConfigurationGuard && !scheduledSpeedOriginal && !bandwidthSpeedWorkflow && !restSpeedOriginal && !operationLocks && !serviceExecutionOrigin && !bandwidthNodeSource && !bandwidthNodeRegistry && !bandwidthExternalRegistry && !bandwidthControlSource && !bandwidthAdmission && !probeLegacyOrphans && !probeStorageJournal && !probeStorageHistory && !probeJournal && !chr && !flowWrites && !flowRebuild && !flowRebuildMcp && (process.argv.includes('--restore') || process.env.MMAPP_PG_CLIENT_BIN)) {
|
||||
exitCode = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['--import', 'tsx', 'src/modules/mcp/database-restore.test.ts'], {
|
||||
cwd: new URL('../', import.meta.url), stdio: 'inherit', env: { ...process.env,
|
||||
|
||||
@@ -6,10 +6,12 @@ import { randomBytes, createHash } from 'node:crypto'
|
||||
import { createServer } from 'node:net'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const allowed = new Set(['--default-scoped', '--main-only', '--check-only'])
|
||||
if (process.argv.slice(2).some(arg => !allowed.has(arg)) || new Set(process.argv.slice(2)).size !== process.argv.slice(2).length || process.argv.includes('--default-scoped') && process.argv.includes('--main-only')) throw new Error('Usage: node backend/scripts/test-routing-controller-baseline.mjs [--default-scoped | --main-only] [--check-only]')
|
||||
const modes = ['--default-scoped', '--main-only', '--scoped-bare-readback']
|
||||
const allowed = new Set([...modes, '--check-only']), args = process.argv.slice(2)
|
||||
const selected = modes.filter(flag => args.includes(flag))
|
||||
if (args.some(arg => !allowed.has(arg)) || new Set(args).size !== args.length || selected.length > 1) throw new Error('Usage: node backend/scripts/test-routing-controller-baseline.mjs [--default-scoped | --main-only | --scoped-bare-readback] [--check-only]')
|
||||
const root = resolve(import.meta.dirname, '../..'), require = createRequire(import.meta.url), ts = require('typescript')
|
||||
const mode = process.argv.includes('--default-scoped') ? 'default-scoped' : process.argv.includes('--main-only') ? 'main-only' : 'main-and-forward-rounds'
|
||||
const mode = selected[0]?.slice(2) ?? 'main-and-forward-rounds'
|
||||
const target = join(root, 'backend/src/modules/mcp/routing-controller-active-writer.ts')
|
||||
await readFile(target)
|
||||
await readFile(join(root, 'backend/src/modules/mcp/routing-main-transition.ts'))
|
||||
|
||||
@@ -7,7 +7,7 @@ export function approvedDefinition(value:unknown){
|
||||
assert(value&&typeof value==='object');const v=value as Record<string,unknown>
|
||||
assert.deepEqual(Object.keys(v).sort(),['format','provenance','template']);assert.equal(v.format,'application-writer-source44-definition-approval-v1')
|
||||
assert.equal(canonical(v.provenance),canonical(BUILD44_PROVENANCE));assert.equal(canonical(v.template),canonical(FIXED44_ROLE_TEMPLATE))
|
||||
return BUILD44_PROVENANCE.templateSha256
|
||||
return Object.freeze({definitionSha256:BUILD44_PROVENANCE.templateSha256,manifestSha256:BUILD44_PROVENANCE.manifestSha256})
|
||||
}
|
||||
/** Real incremental DATA verifier on caller's retained transaction. No pool or attestor. */
|
||||
export async function collectSource44Catalogue(s:{query:(sql:string,values?:unknown[])=>Promise<{rows:Record<string,unknown>[]} >},installation:{roleBindings:ConstructorParameters<typeof Fixed44CatalogueValueBridgeData>[0]}){
|
||||
@@ -21,7 +21,7 @@ export async function collectSource44Catalogue(s:{query:(sql:string,values?:unkn
|
||||
await s.query('DECLARE mm_publisher44 NO SCROLL CURSOR WITHOUT HOLD FOR '+FIXED44_CATALOGUE_BUILD_SQL,values);cursor=true
|
||||
for(;;){const page=(await s.query('FETCH FORWARD 128 FROM mm_publisher44')).rows;bridge.acceptPage(page);if(page.length===0)break}
|
||||
const result=bridge.finish();await s.query('CLOSE mm_publisher44');cursor=false
|
||||
return {catalogSha256:sha(canonical({values:result.values.snapshotSha256,catalogue:result.catalogue})),definitionSha256:result.catalogue.definitionSha256,leafCount:result.catalogue.leafRelations}
|
||||
return {catalogSha256:sha(canonical({values:result.values.snapshotSha256,catalogue:result.catalogue})),definitionSha256:result.catalogue.definitionSha256,manifestSha256:result.catalogue.manifestSha256,leafCount:result.catalogue.leafRelations}
|
||||
}catch(error){primary=error;failed=true;throw error}
|
||||
finally{
|
||||
try{if(cursor)await s.query('CLOSE mm_publisher44');await s.query("SELECT pg_catalog.set_config('search_path',$1,true),pg_catalog.set_config('TimeZone',$2,true),pg_catalog.set_config('DateStyle',$3,true)",[settings[0].path,settings[0].zone,settings[0].dates])}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {X509Certificate} from 'node:crypto'
|
||||
import {checkServerIdentity} from 'node:tls'
|
||||
import {checkServerIdentity,TLSSocket} from 'node:tls'
|
||||
import {sha} from './operator-files.mjs'
|
||||
export const roleKeys=Object.freeze(['runtime','operator','capture','publisher','control'])
|
||||
export const STARTUP_OPTIONS='-c statement_timeout=10000 -c lock_timeout=5000 -c client_encoding=UTF8'
|
||||
@@ -18,15 +18,29 @@ export function approval(value){
|
||||
}
|
||||
// Pinned historical inspection data only; never publication authorization.
|
||||
export function reconciliationApproval(value){return parsedApproval(value)}
|
||||
function peerError(pin,host,cert){
|
||||
const failed=checkServerIdentity(pin.serverName,cert);if(failed)return failed
|
||||
try{if(host!==pin.serverName||sha(new X509Certificate(cert.raw).publicKey.export({format:'der',type:'spki'}))!==pin.spkiSha256)return Error('Publisher TLS SPKI refused')}catch{return Error('Publisher TLS certificate refused')}
|
||||
}
|
||||
|
||||
// pg replaces connection.stream with the negotiated TLS socket. Hold its
|
||||
// identity on the retained client; handshake callbacks are not session evidence.
|
||||
export function retainPublisherTlsPeer(client,pin){
|
||||
const socket=client.connection?.stream
|
||||
const assertCurrent=()=>{
|
||||
assert(socket instanceof TLSSocket&&client.connection?.stream===socket&&socket.encrypted&&socket.authorized&&!socket.destroyed&&socket.writable,'Publisher retained TLS peer unavailable')
|
||||
}
|
||||
assertCurrent()
|
||||
const failed=peerError(pin,socket.servername,socket.getPeerCertificate());if(failed)throw failed
|
||||
socket.disableRenegotiation()
|
||||
return assertCurrent
|
||||
}
|
||||
export function poolOptions(transport,request,ca){
|
||||
assert(exact(transport,['host','port','user','password','database']))
|
||||
assert(typeof transport.host==='string'&&transport.host===request.tls.serverName&&Number.isSafeInteger(transport.port)&&transport.port>0&&transport.port<=65535&&transport.user===request.roles.publisher.name&&typeof transport.password==='string'&&transport.password.length>0&&transport.password.length<=4096&&transport.database===request.source.databaseName)
|
||||
assert(sha(ca)===request.tls.caSha256);new X509Certificate(ca)
|
||||
return {host:transport.host,port:transport.port,user:transport.user,password:transport.password,database:transport.database,max:1,connectionTimeoutMillis:5000,idleTimeoutMillis:5000,statement_timeout:10000,lock_timeout:5000,options:STARTUP_OPTIONS,application_name:'mmapp-writer-publisher',client_encoding:'UTF8',
|
||||
ssl:{ca,servername:request.tls.serverName,rejectUnauthorized:true,checkServerIdentity(host,cert){
|
||||
const failed=checkServerIdentity(request.tls.serverName,cert);if(failed)return failed
|
||||
try{if(host!==request.tls.serverName||sha(new X509Certificate(cert.raw).publicKey.export({format:'der',type:'spki'}))!==request.tls.spkiSha256)return Error('Publisher TLS SPKI refused')}catch{return Error('Publisher TLS certificate refused')}
|
||||
}}}
|
||||
ssl:{ca,servername:request.tls.serverName,rejectUnauthorized:true,checkServerIdentity:(host,cert)=>peerError(request.tls,host,cert)}}
|
||||
}
|
||||
export function assertPublicationApproval(current,catalogue,structure,pinned,manifestSha256){
|
||||
assert(pinned.expiresAt>Date.now()&&pinned.manifestSha256===manifestSha256&&pinned.definitionSha256===catalogue.definitionSha256)
|
||||
|
||||
@@ -3,6 +3,7 @@ import pg from 'pg'
|
||||
import {createPrivateKey,createPublicKey,sign} from 'node:crypto'
|
||||
import {open,lstat,realpath,unlink} from 'node:fs/promises'
|
||||
import {writeFile,withJournalLease} from './operator-files.mjs'
|
||||
import {retainPublisherTlsPeer} from './operator-contract.mjs'
|
||||
import path from 'node:path'
|
||||
import {captureDatabaseSourceObservation,assertDatabaseSourceObservationCurrent} from '../../src/db/database-source-observation.js'
|
||||
import {MIGRATION_MANIFEST,migrationManifestDigest} from '../../src/db/migration-manifest.js'
|
||||
@@ -10,7 +11,13 @@ import {MIGRATION_MANIFEST,migrationManifestDigest} from '../../src/db/migration
|
||||
|
||||
import {ownedContext,sameOwned,sourceCoordinates,protectedStructure,readCurrent,HISTORY,CURRENT} from './context.mjs'
|
||||
import {bytes,canonical,sha,nextEpoch,POLICY_DOMAIN,HEAD_DOMAIN,KEY,liveValidity,policySchema,headSchema,publicKey} from '../../src/db/application-writer-profile-format.js'
|
||||
export function createConnectedPublisher(installation,collectFixedCatalogue,sourceVerifier,approval){
|
||||
export function requireCurrentCatalogueDefinition(approval,definition){
|
||||
const manifestSha256=migrationManifestDigest()
|
||||
if(approval.manifestSha256!==manifestSha256||definition.manifestSha256!==manifestSha256||definition.definitionSha256!==approval.definitionSha256)throw Error('Approved catalogue definition is not bound to this build migration manifest')
|
||||
return manifestSha256
|
||||
}
|
||||
|
||||
export function createConnectedPublisher(installation,collectFixedCatalogue,sourceVerifier,approval,definition){
|
||||
const PUBLISHER=installation.roleBindings.publisher.name
|
||||
class ProfilePublicationError extends Error{
|
||||
automaticReplay=false
|
||||
@@ -29,13 +36,18 @@ async function privateKey(filename){
|
||||
// detached application callbacks or cleanup/COMMIT races.
|
||||
async function session(pool,deadlineAt,work){
|
||||
if(!(pool instanceof pg.Pool)||!Number.isSafeInteger(deadlineAt)||deadlineAt<=Date.now()||deadlineAt-Date.now()>120000)throw Error('Bounded genuine publisher pool/lifetime required')
|
||||
let client,retired=false,released=false,locked=false,begun=false,commitSent=false,committed=false,confirmedValue,proposal,phase='checkout',stopReject,cleanupUnconfirmed=false
|
||||
let client,assertTransport,retired=false,released=false,locked=false,begun=false,commitSent=false,committed=false,confirmedValue,proposal,phase='checkout',stopReject,cleanupUnconfirmed=false
|
||||
const stopped=new Promise((_,reject)=>{stopReject=reject});void stopped.catch(()=>{})
|
||||
const release=bad=>{if(!client||released)return;released=true;client.off('error',fault);client.off('end',fault);try{client.release(bad)}catch{cleanupUnconfirmed=true}}
|
||||
const fault=()=>{if(retired)return;retired=true;cleanupUnconfirmed=true;release(true);stopReject(Error('Publisher connection/lifetime ended'))}
|
||||
const timer=setTimeout(fault,Math.max(1,deadlineAt-Date.now())),bounded=p=>Promise.race([p,stopped])
|
||||
const query=async(text,values)=>{if(retired||Date.now()>=deadlineAt)throw Error('Publisher lifetime closed');return bounded(client.query(text,values))}
|
||||
const fence=()=>{
|
||||
if(retired||Date.now()>=deadlineAt)throw Error('Publisher lifetime closed')
|
||||
try{assertTransport()}catch(error){fault();throw error}
|
||||
}
|
||||
const query=async(text,values)=>{fence();return bounded(client.query(text,values))}
|
||||
const state={query,phase:value=>{phase=value},proposal:value=>{proposal=Object.freeze(value)},begun:value=>{begun=value},commit:async value=>{
|
||||
fence()
|
||||
confirmedValue=Object.freeze({...value})
|
||||
commitSent=true
|
||||
const response=await bounded(client.query('COMMIT').then(result=>{if(result.command==='COMMIT'){committed=true;begun=false}return result}))
|
||||
@@ -45,6 +57,7 @@ async function session(pool,deadlineAt,work){
|
||||
try{
|
||||
const pending=pool.connect().then(value=>{client=value;if(retired){release(true);return value}client.on('error',fault);client.on('end',fault);return value});void pending.catch(()=>{})
|
||||
await bounded(pending);if(retired)throw Error('Late publisher checkout')
|
||||
try{assertTransport=retainPublisherTlsPeer(client,installation.tls)}catch(error){retired=true;release(true);throw error}
|
||||
await query("SET statement_timeout='10s'");await query("SET lock_timeout='5s'")
|
||||
phase='exclusive-admission';await query('SELECT pg_catalog.pg_advisory_lock($1::bigint)',[KEY]);locked=true
|
||||
value=await work(state)
|
||||
@@ -62,7 +75,16 @@ async function session(pool,deadlineAt,work){
|
||||
if(failed||cleanupUnconfirmed||retired)throw new ProfilePublicationError(commitSent||begun?'unknown':'not-committed',phase,proposal,cleanupUnconfirmed||retired,primary)
|
||||
return value
|
||||
}
|
||||
async function publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256,journalDirectory,validityMs=86400000,deadlineAt=Date.now()+120000}){
|
||||
async function observeCurrentCatalogue(s,manifestSha256){
|
||||
const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s)
|
||||
if(catalogue.manifestSha256!==manifestSha256||catalogue.definitionSha256!==definition.definitionSha256)throw Error('Catalogue evidence differs from the approved migration manifest/definition')
|
||||
// Inspect one extra row so an unexpected marker cannot hide after the expected prefix.
|
||||
const expected=MIGRATION_MANIFEST.map(pin=>pin.file.slice(0,-4))
|
||||
const markers=(await s.query('SELECT id FROM public.schema_migrations ORDER BY id COLLATE "C" LIMIT $1',[expected.length+1])).rows.map(row=>row.id)
|
||||
if(canonical(markers)!==canonical(expected))throw Error('Exact current migration marker metadata required')
|
||||
return {security,catalogue,catalogSha256:sha(canonical({base:catalogue.catalogSha256,security:security.sha256}))}
|
||||
}
|
||||
async function publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256,journalDirectory,validityMs=86400000,deadlineAt=Date.now()+120000},manifestSha256){
|
||||
if(!Number.isSafeInteger(validityMs)||validityMs<1||validityMs>86400000||expectedHeadSha256!==null&&!(/^[a-f0-9]{64}$/).test(expectedHeadSha256))throw Error('Explicit previous head and bounded validity required')
|
||||
const key=await privateKey(privateKeyFile),directory=await realpath(journalDirectory),pendingFile=path.join(directory,'pending-publication.json')
|
||||
try{await lstat(pendingFile);throw Error('Pending publication must be reconciled before signing')}catch(error){if(error.code!=='ENOENT')throw error}
|
||||
@@ -73,18 +95,15 @@ async function publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256,jou
|
||||
const observation=await captureDatabaseSourceObservation(facade,options)
|
||||
await sourceVerifier.verify(facade,observation,options)
|
||||
if(canonical(sourceCoordinates(first))!==canonical(installation.expectedSource))throw Error('Pinned source mismatch')
|
||||
const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s)
|
||||
const markers=(await s.query('SELECT id FROM public.schema_migrations ORDER BY id LIMIT 45')).rows.map(row=>row.id)
|
||||
if(canonical(markers)!==canonical(MIGRATION_MANIFEST.map(pin=>pin.file.slice(0,-4))))throw Error('Exact current migration marker metadata required')
|
||||
const {security,catalogue,catalogSha256}=await observeCurrentCatalogue(s,manifestSha256)
|
||||
const old=await readCurrent(s,installation,{allowEmpty:true,validNow:false})
|
||||
if((old?.headSha256??null)!==expectedHeadSha256)throw Error('Expected protected current head mismatch')
|
||||
const catalogSha256=sha(canonical({base:catalogue.catalogSha256,security:security.sha256}))
|
||||
if(approval.expiresAt<=Date.now()||approval.manifestSha256!==migrationManifestDigest()||approval.definitionSha256!==catalogue.definitionSha256||approval.catalogSha256!==catalogSha256)throw Error('Independently approved policy differs from actual source44 catalogue')
|
||||
if(old&&(canonical(old.policy.source)!==canonical(sourceCoordinates(first))||canonical(old.policy.roles)!==canonical(security.roles)||old.policy.manifestSha256!==migrationManifestDigest()||old.policy.definitionSha256!==catalogue.definitionSha256||old.policy.catalogSha256!==catalogSha256))throw Error('Renewal cannot adopt a changed source/subject/policy')
|
||||
if(approval.expiresAt<=Date.now()||approval.catalogSha256!==catalogSha256)throw Error('Independently approved policy differs from actual catalogue')
|
||||
if(old&&(canonical(old.policy.source)!==canonical(sourceCoordinates(first))||canonical(old.policy.roles)!==canonical(security.roles)||old.policy.manifestSha256!==manifestSha256||old.policy.definitionSha256!==catalogue.definitionSha256||old.policy.catalogSha256!==catalogSha256))throw Error('Renewal cannot adopt a changed source/subject/policy')
|
||||
await sourceVerifier.verify(facade,observation,options);await assertDatabaseSourceObservationCurrent(facade,observation,options);sameOwned(first,await ownedContext(s,installation,PUBLISHER,'publisher-read'))
|
||||
await s.query('COMMIT');s.begun(false)
|
||||
const number=old?nextEpoch(old.epoch):'1',now=Date.now()
|
||||
const body=policySchema.parse({format:'application-writer-policy-v1',installationId:installation.installationId,epoch:number,issuedAt:now,notBefore:now,notAfter:now+validityMs,source:sourceCoordinates(first),sourceObservationSha256:sha(canonical(observation)),roles:security.roles,manifestSha256:migrationManifestDigest(),definitionSha256:catalogue.definitionSha256,catalogSha256,futureLeaves:'fixed12-native-policy-v1'})
|
||||
const body=policySchema.parse({format:'application-writer-policy-v1',installationId:installation.installationId,epoch:number,issuedAt:now,notBefore:now,notAfter:now+validityMs,source:sourceCoordinates(first),sourceObservationSha256:sha(canonical(observation)),roles:security.roles,manifestSha256,definitionSha256:catalogue.definitionSha256,catalogSha256,futureLeaves:'fixed12-native-policy-v1'})
|
||||
const envelope=(domain,body)=>bytes({body,signature:sign(null,Buffer.from(domain+canonical(body)),key).toString('base64url')})
|
||||
const policyBytes=envelope(POLICY_DOMAIN,body),policySha256=sha(policyBytes)
|
||||
const head=headSchema.parse({format:'application-writer-head-v1',installationId:installation.installationId,epoch:number,policySha256,previousHeadSha256:old?.headSha256??null})
|
||||
@@ -101,8 +120,8 @@ async function publishCurrentProfile(pool,{privateKeyFile,expectedHeadSha256,jou
|
||||
await s.query(`SELECT id FROM mmapp_control.${CURRENT} WHERE id=1 FOR UPDATE`)
|
||||
const current=await readCurrent(s,installation,{allowEmpty:true,validNow:false})
|
||||
if((current?.headSha256??null)!==expectedHeadSha256)throw Error('CAS predecessor changed')
|
||||
const againSecurity=await protectedStructure(s,installation),againCatalogue=await collectFixedCatalogue(s)
|
||||
if(sha(canonical({base:againCatalogue.catalogSha256,security:againSecurity.sha256}))!==catalogSha256)throw Error('Policy drift during retained exclusive publication')
|
||||
const again=await observeCurrentCatalogue(s,manifestSha256)
|
||||
if(again.catalogSha256!==catalogSha256)throw Error('Policy drift during retained exclusive publication')
|
||||
if(Date.now()>=body.notAfter||Date.now()>=approval.expiresAt)throw Error('Policy or approval expired before publication dispatch')
|
||||
await s.query(`INSERT INTO mmapp_control.${HISTORY}(installation_id,epoch,head_sha256,previous_installation_id,previous_epoch,previous_head_sha256,policy_sha256,policy_bytes,head_bytes) VALUES($1::uuid,$2::bigint,$3,$4::uuid,$5::bigint,$6,$7,$8::bytea,$9::bytea)`,[installation.installationId,number,headSha256,old?installation.installationId:null,old?.epoch??null,old?.headSha256??null,policySha256,policyBytes,headBytes])
|
||||
const changed=old?await s.query(`UPDATE mmapp_control.${CURRENT} SET epoch=$1::bigint,head_sha256=$2 WHERE id=1 AND installation_id=$3::uuid AND epoch=$4::bigint AND head_sha256=$5 RETURNING id`,[number,headSha256,installation.installationId,old.epoch,old.headSha256]):await s.query(`INSERT INTO mmapp_control.${CURRENT}(id,installation_id,epoch,head_sha256) VALUES(1,$1::uuid,$2::bigint,$3) RETURNING id`,[installation.installationId,number,headSha256])
|
||||
@@ -136,17 +155,17 @@ async function inspectPublication(pool,proposal,{deadlineAt=Date.now()+120000}={
|
||||
// Read-only diagnostics on the actual protected publisher session. This does
|
||||
// not admit application/operator writes, bootstrap, restore, or READY.
|
||||
async function preflightCurrentProfile(pool,{deadlineAt=Date.now()+120000}={}){
|
||||
const manifestSha256=requireCurrentCatalogueDefinition(approval,definition)
|
||||
return session(pool,deadlineAt,async s=>{
|
||||
s.phase('profile-preflight');await s.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');s.begun(true)
|
||||
const first=await ownedContext(s,installation,PUBLISHER,'publisher-read')
|
||||
if(canonical(sourceCoordinates(first))!==canonical(installation.expectedSource))throw Error('Pinned preflight source mismatch')
|
||||
const facade=Object.freeze({query:(sql,values)=>s.query(sql,values)}),options={deadlineAt}
|
||||
const observation=await captureDatabaseSourceObservation(facade,options),initialSource=await sourceVerifier.verify(facade,observation,options)
|
||||
const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s)
|
||||
const {security,catalogue,catalogSha256}=await observeCurrentCatalogue(s,manifestSha256)
|
||||
const current=await readCurrent(s,installation)
|
||||
if(!current)throw Error('Current signed policy required for preflight')
|
||||
const catalogSha256=sha(canonical({base:catalogue.catalogSha256,security:security.sha256})),manifestSha256=migrationManifestDigest()
|
||||
if(approval.expiresAt<=Date.now()||approval.manifestSha256!==manifestSha256||approval.definitionSha256!==catalogue.definitionSha256||approval.catalogSha256!==catalogSha256)throw Error('Independent preflight approval differs from actual source44 catalogue')
|
||||
if(approval.expiresAt<=Date.now()||approval.catalogSha256!==catalogSha256)throw Error('Independent preflight approval differs from actual catalogue')
|
||||
if(canonical(current.policy.source)!==canonical(sourceCoordinates(first))||canonical(current.policy.roles)!==canonical(security.roles)||current.policy.manifestSha256!==manifestSha256||current.policy.definitionSha256!==catalogue.definitionSha256||current.policy.catalogSha256!==catalogSha256)throw Error('Current signed policy differs from actual source/roles/catalogue')
|
||||
const terminalSource=await sourceVerifier.verify(facade,observation,options);await assertDatabaseSourceObservationCurrent(facade,observation,options)
|
||||
sameOwned(first,await ownedContext(s,installation,PUBLISHER,'publisher-read'))
|
||||
@@ -173,7 +192,10 @@ async function reconcilePendingPublication(pool,{journalDirectory,deadlineAt=Dat
|
||||
}
|
||||
return Object.freeze({
|
||||
preflightCurrentProfile,
|
||||
publishCurrentProfile:(pool,options)=>withJournalLease(options.journalDirectory,()=>publishCurrentProfile(pool,options)),
|
||||
publishCurrentProfile:async(pool,options)=>{
|
||||
const manifestSha256=requireCurrentCatalogueDefinition(approval,definition)
|
||||
return withJournalLease(options.journalDirectory,()=>publishCurrentProfile(pool,options,manifestSha256))
|
||||
},
|
||||
inspectPublication,
|
||||
reconcilePendingPublication:(pool,options)=>withJournalLease(options.journalDirectory,()=>reconcilePendingPublication(pool,options)),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/** Deployment operator state. Store outside PGDATA, backups and runtime mounts. */
|
||||
import assert from 'node:assert/strict'
|
||||
import {constants} from 'node:fs'
|
||||
import {lstat,open,rename} from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {z} from 'zod'
|
||||
import {sourceBindingBytes,sourceBindingCanonical,type DatabaseSourceBindingBody} from '../../src/db/database-source-binding.js'
|
||||
import {protectedDirectory,readProtected,sha,withJournalLease} from './operator-files.mjs'
|
||||
import {readProtectedDatabaseSourceBindingRegistry, type createProtectedDatabaseSourceBindingVerifier} from './protected-source-registry.mjs'
|
||||
|
||||
const digest=z.string().regex(/^[a-f0-9]{64}$/),uuid=z.string().uuid()
|
||||
const sourcePin=z.object({registryDirectory:z.string().min(1).max(4096),installationId:uuid,publicKeySpkiSha256:digest,currentHeadSha256:digest}).strict()
|
||||
const directoryPath=z.string().min(1).max(4096)
|
||||
const approval=z.object({format:z.literal('database-source-activation-approval-v1'),activationDirectory:directoryPath,sourcePin,tlsSpkiSha256:digest,
|
||||
expectedActivationSha256:digest.nullable()}).strict()
|
||||
const generation=z.object({generation:uuid,headSha256:digest,bindingSha256:digest,tlsSpkiSha256:digest}).strict()
|
||||
const stateSchema=z.object({format:z.literal('database-source-activation-v1'),activationDirectory:directoryPath,installationId:uuid,sourceIdentityId:uuid,
|
||||
publicKeySpkiSha256:digest,status:z.enum(['active','revoked']),generations:z.array(generation).min(1).max(256)}).strict()
|
||||
const maximum=131072, filename='source-activation.json'
|
||||
type State=z.infer<typeof stateSchema>
|
||||
const parse=(bytes:Buffer):unknown=>JSON.parse(new TextDecoder('utf8',{fatal:true}).decode(bytes))
|
||||
|
||||
async function present(file:string){
|
||||
try{await lstat(file);return true}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return false;throw error}
|
||||
}
|
||||
|
||||
// Caller holds the same protected lease used by all activation writers.
|
||||
async function current(directory:string){
|
||||
const file=path.join(directory,filename)
|
||||
assert(!await present(file+'.pending'),'Unresolved activation transition')
|
||||
if(!await present(file))return undefined
|
||||
const bytes=await readProtected(file,maximum),state=stateSchema.parse(parse(bytes))
|
||||
assert.equal(state.activationDirectory,directory,'Activation record belongs to another directory')
|
||||
assert(sourceBindingBytes(state).equals(bytes),'Noncanonical activation record')
|
||||
for(const field of ['generation','headSha256','tlsSpkiSha256'] as const){
|
||||
assert.equal(new Set(state.generations.map(item=>item[field].toLowerCase())).size,state.generations.length,'Reused activation identity')
|
||||
}
|
||||
return {state,activationSha256:sha(bytes)}
|
||||
}
|
||||
|
||||
async function persist(directory:string,state:State){
|
||||
await protectedDirectory(directory)
|
||||
const bytes=sourceBindingBytes(stateSchema.parse(state))
|
||||
assert(bytes.length<=maximum)
|
||||
const file=path.join(directory,filename),pending=file+'.pending'
|
||||
const parent=await open(directory,constants.O_RDONLY|constants.O_DIRECTORY|constants.O_NOFOLLOW)
|
||||
let confirmed=false,cleanupUnconfirmed=false
|
||||
try{
|
||||
const handle=await open(pending,constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600)
|
||||
try{await handle.writeFile(bytes);await handle.sync()}finally{await handle.close()}
|
||||
try{await rename(pending,file);await parent.sync()}
|
||||
catch{throw Object.assign(Error('Activation terminal requires inspection'),{code:'SOURCE_ACTIVATION_TERMINAL_UNKNOWN'})}
|
||||
confirmed=true
|
||||
}finally{try{await parent.close()}catch{cleanupUnconfirmed=confirmed}}
|
||||
return {activationSha256:sha(bytes),cleanupUnconfirmed}
|
||||
}
|
||||
|
||||
function receipt(state:State,activationSha256:string,outcome:'committed'|'observed'){
|
||||
return {event:'database-source-activation',outcome,activationSha256,status:state.status,
|
||||
ordinal:state.generations.length,...state.generations.at(-1),generationAuthenticated:false,readyAuthorized:false,automaticReplay:false}
|
||||
}
|
||||
|
||||
type SourceVerifier=ReturnType<typeof createProtectedDatabaseSourceBindingVerifier>
|
||||
|
||||
/** Keep revocation and generation changes fenced throughout one read-only SQL preflight. */
|
||||
export async function withActiveSourcePreflight<T>(input:{directory:string,expectedSha256:string,sourcePin:z.infer<typeof sourcePin>,expectedSource:DatabaseSourceBindingBody['source'],tlsSpkiSha256:string,deadlineAt:number},
|
||||
verifier:SourceVerifier,work:(guardedVerifier:SourceVerifier)=>Promise<T>){
|
||||
const {directory,deadlineAt}=input
|
||||
const expectedSha256=digest.parse(input.expectedSha256),tlsSpkiSha256=digest.parse(input.tlsSpkiSha256)
|
||||
const pin=sourcePin.parse(input.sourcePin)
|
||||
return withJournalLease(directory,async()=>{
|
||||
async function readActive(){
|
||||
assert(Date.now()<deadlineAt,'Activation preflight expired')
|
||||
const active=await current(directory)
|
||||
assert(active?.state.status==='active'&&active.activationSha256===expectedSha256,'Active activation digest changed')
|
||||
assert(Date.now()<deadlineAt,'Activation preflight expired')
|
||||
return active.state
|
||||
}
|
||||
const state=await readActive(),generation=state.generations.at(-1)!
|
||||
const registry=await readProtectedDatabaseSourceBindingRegistry(pin,{deadlineAt})
|
||||
assert(state.publicKeySpkiSha256===registry.publicKeySpkiSha256&&generation.tlsSpkiSha256===tlsSpkiSha256,'Activation key differs from preflight')
|
||||
function assertGeneration(evidence:{installationId:string,sourceIdentityId:string,generation:string,ordinal:number,headSha256:string,bindingSha256:string}){
|
||||
assert(evidence.installationId===state.installationId&&evidence.sourceIdentityId===state.sourceIdentityId&&
|
||||
evidence.generation===generation.generation&&evidence.ordinal===state.generations.length&&
|
||||
evidence.headSha256===generation.headSha256&&evidence.bindingSha256===generation.bindingSha256,'Activation differs from signed source generation')
|
||||
}
|
||||
assertGeneration({...registry.head,headSha256:registry.headSha256})
|
||||
assert.equal(sourceBindingCanonical(registry.body.source),sourceBindingCanonical(input.expectedSource),'Activation source differs from reviewed configuration')
|
||||
await readActive()
|
||||
let sourceChecks=0
|
||||
const guardedVerifier:SourceVerifier=Object.freeze({async verify(...args:Parameters<SourceVerifier['verify']>){
|
||||
await readActive()
|
||||
const evidence=await verifier.verify(...args)
|
||||
assert(evidence.platformProtectionVerified&&evidence.operatorSignatureVerified&&evidence.currentHeadPinVerified&&evidence.genuineSourceFactsMatched,'Unprotected activation source evidence')
|
||||
assertGeneration(evidence)
|
||||
await readActive()
|
||||
sourceChecks++
|
||||
return evidence
|
||||
}})
|
||||
const result=await work(guardedVerifier)
|
||||
assert.equal(sourceChecks,2,'Initial and terminal source evidence required')
|
||||
await readActive()
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
export async function sourceActivationMain(args:string[]){
|
||||
const command=args[0], flags=command==='source-activate'?['directory','approval','approval-sha256']:
|
||||
command==='source-revoke'?['directory','expected-sha256']:['directory']
|
||||
assert(['source-activate','source-revoke','source-status'].includes(command)&&args.length===1+2*flags.length)
|
||||
const values=new Map<string,string>()
|
||||
for(let i=1;i<args.length;i+=2){
|
||||
const name=args[i].slice(2)
|
||||
assert(args[i].startsWith('--')&&flags.includes(name)&&!values.has(name)&&args[i+1]);values.set(name,args[i+1])
|
||||
}
|
||||
const directory=values.get('directory')!
|
||||
return withJournalLease(directory,async()=>{
|
||||
const before=await current(directory)
|
||||
if(command==='source-status'){
|
||||
assert(before,'Activation record absent')
|
||||
return receipt(before.state,before.activationSha256,'observed')
|
||||
}
|
||||
if(command==='source-revoke'){
|
||||
const expected=digest.parse(values.get('expected-sha256'))
|
||||
assert(before?.activationSha256===expected&&before.state.status==='active','Active activation digest changed')
|
||||
const revoked={...before.state,status:'revoked' as const}
|
||||
const saved=await persist(directory,revoked)
|
||||
return {...receipt(revoked,saved.activationSha256,'committed'),cleanupUnconfirmed:saved.cleanupUnconfirmed}
|
||||
}
|
||||
const request=approval.parse(parse(await readProtected(values.get('approval')!,16384,digest.parse(values.get('approval-sha256')))))
|
||||
assert.equal(request.activationDirectory,directory,'Approval belongs to another activation directory')
|
||||
assert.equal(before?.activationSha256??null,request.expectedActivationSha256,'Activation predecessor changed')
|
||||
const registry=await readProtectedDatabaseSourceBindingRegistry(request.sourcePin,{deadlineAt:Date.now()+120000})
|
||||
const previous=before?.state.generations??[]
|
||||
assert.equal(registry.head.ordinal,previous.length+1,'Source ordinal changed')
|
||||
assert.equal(registry.head.previousHeadSha256,previous.at(-1)?.headSha256??null,'Signed predecessor differs')
|
||||
if(before){
|
||||
assert.equal(registry.head.installationId,before.state.installationId)
|
||||
assert.equal(registry.head.sourceIdentityId,before.state.sourceIdentityId)
|
||||
assert.equal(registry.publicKeySpkiSha256,before.state.publicKeySpkiSha256)
|
||||
}
|
||||
assert(!previous.some(item=>item.generation.toLowerCase()===registry.head.generation.toLowerCase()||item.tlsSpkiSha256===request.tlsSpkiSha256),'Generation or TLS key was already used')
|
||||
const next:State={format:'database-source-activation-v1',activationDirectory:directory,installationId:registry.head.installationId,
|
||||
sourceIdentityId:registry.head.sourceIdentityId,publicKeySpkiSha256:registry.publicKeySpkiSha256,status:'active',
|
||||
generations:[...previous,{generation:registry.head.generation,headSha256:registry.headSha256,
|
||||
bindingSha256:registry.head.bindingSha256,tlsSpkiSha256:request.tlsSpkiSha256}]}
|
||||
const saved=await persist(directory,next)
|
||||
return {...receipt(next,saved.activationSha256,'committed'),cleanupUnconfirmed:saved.cleanupUnconfirmed}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import {createHash,generateKeyPairSync,randomUUID} from 'node:crypto'
|
||||
import {createRequire} from 'node:module'
|
||||
import {runInNewContext} from 'node:vm'
|
||||
import {provisionDatabaseSourceBindingOffline} from '../database-source-binding-operator.ts'
|
||||
import {readDatabaseSourceBindingRegistry,sourceBindingBytes,sourceBindingCanonical,type DatabaseSourceDeploymentPin} from '../../src/db/database-source-binding.ts'
|
||||
import type * as Activation from './source-activation.mts'
|
||||
|
||||
const require=createRequire(import.meta.url),ts=require('typescript')
|
||||
const source=await fs.readFile(new URL('./source-activation.mts',import.meta.url),'utf8')
|
||||
const sha=(value:Buffer)=>createHash('sha256').update(value).digest('hex')
|
||||
|
||||
test('protected activation transition model preserves revocation and refuses concurrent, stale or reused generations',async()=>{
|
||||
const root=await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(),'mm-source-activation-test-')))
|
||||
try{
|
||||
// Genuine issuer signatures and filesystem files. Windows models the
|
||||
// protection/lease boundary and directory fsync used on the POSIX target.
|
||||
const lock=path.join(root,'test-lease'),state=path.join(root,'source-activation.json')
|
||||
const files={sha,protectedDirectory:async(directory:string)=>{assert.equal(directory,root)},
|
||||
readProtected:async(filename:string,maximum:number,expected?:string)=>{
|
||||
assert.equal(path.dirname(filename),root)
|
||||
const bytes=await fs.readFile(filename);assert(bytes.length<=maximum)
|
||||
if(expected)assert.equal(sha(bytes),expected)
|
||||
return bytes
|
||||
},async withJournalLease(directory:string,work:()=>Promise<unknown>){
|
||||
assert.equal(directory,root);const handle=await fs.open(lock,'wx')
|
||||
try{return await work()}finally{await handle.close();await fs.unlink(lock)}
|
||||
}}
|
||||
let parentFailure=''
|
||||
const modelFs={...fs,open:async(filename:string,flags:number,mode?:number)=>filename===root?
|
||||
{sync:async()=>{if(parentFailure==='sync')throw Error('fixture directory sync failure')},
|
||||
close:async()=>{if(parentFailure==='close')throw Error('fixture directory close failure')}}:fs.open(filename,flags,mode)}
|
||||
const imports:Record<string,unknown>={'node:fs/promises':modelFs,'./operator-files.mjs':files,
|
||||
'./protected-source-registry.mjs':{readProtectedDatabaseSourceBindingRegistry:readDatabaseSourceBindingRegistry},
|
||||
'../../src/db/database-source-binding.js':{sourceBindingBytes,sourceBindingCanonical}}
|
||||
const load=()=>{
|
||||
const exports={}
|
||||
const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
|
||||
runInNewContext(compiled,{exports,require:(name:string)=>imports[name]??require(name),Buffer,TextDecoder})
|
||||
return exports as typeof Activation
|
||||
}
|
||||
const {withActiveSourcePreflight}=load()
|
||||
let {sourceActivationMain:run}=load()
|
||||
const privateKeyFile=path.join(root,'issuer.pem'),key=generateKeyPairSync('ed25519').privateKey
|
||||
await fs.writeFile(privateKeyFile,key.export({format:'pem',type:'pkcs8'}),{mode:0o600})
|
||||
const installationId=randomUUID(),sourceIdentityId=randomUUID()
|
||||
const issue=async(previousPin?:DatabaseSourceDeploymentPin,generation=randomUUID())=>{
|
||||
return (await provisionDatabaseSourceBindingOffline({privateKeyFile,previousPin,outputDirectory:path.join(root,randomUUID()),
|
||||
request:{installationId,sourceIdentityId,generation,source:{systemIdentifier:'123',databaseOid:16384,databaseName:'synthetic',
|
||||
sessionUser:'publisher',currentUser:'publisher',inRecovery:false,serverVersionNum:180004}}})).deploymentPin
|
||||
}
|
||||
const status=['source-status','--directory',root]
|
||||
const revoke=(expected:string)=>['source-revoke','--directory',root,'--expected-sha256',expected]
|
||||
const activate=async(pin:DatabaseSourceDeploymentPin,tlsSpkiSha256:string,expectedActivationSha256:string|null,activationDirectory=root)=>{
|
||||
const bytes=sourceBindingBytes({format:'database-source-activation-approval-v1',activationDirectory,sourcePin:pin,tlsSpkiSha256,expectedActivationSha256})
|
||||
const file=path.join(root,randomUUID()+'.json');await fs.writeFile(file,bytes,{mode:0o600,flag:'wx'})
|
||||
return ['source-activate','--directory',root,'--approval',file,'--approval-sha256',sha(bytes)]
|
||||
}
|
||||
const g1=await issue(undefined,'5ac5e8bd-0eb2-498b-901a-dd2010cd4b31'),g2=await issue(g1),g3=await issue(g2)
|
||||
await assert.rejects(run(await activate(g1,'1'.repeat(64),null,path.join(root,'other'))),/another activation directory/)
|
||||
const start=await activate(g1,'1'.repeat(64),null)
|
||||
const races=await Promise.allSettled([run(start),run(start)])
|
||||
assert.deepEqual(races.map(value=>value.status).sort(),['fulfilled','rejected'],races.map(value=>value.status==='rejected'?String(value.reason):value.status).join(', '))
|
||||
const first=await run(status)
|
||||
assert.equal(first.ordinal,1);assert.equal(first.headSha256,g1.currentHeadSha256)
|
||||
assert.equal(first.generationAuthenticated,false)
|
||||
const registry=await readDatabaseSourceBindingRegistry(g1)
|
||||
const evidence={format:'database-source-binding-evidence-v1' as const,
|
||||
installationId:registry.head.installationId,sourceIdentityId:registry.head.sourceIdentityId,generation:registry.head.generation,
|
||||
ordinal:registry.head.ordinal,bindingSha256:registry.head.bindingSha256,headSha256:registry.headSha256,
|
||||
operatorSignatureVerified:true as const,currentHeadPinVerified:true as const,
|
||||
genuineSourceFactsMatched:true as const,platformProtectionVerified:true as const,generationAuthenticated:false as const,
|
||||
allWriterDrainAuthenticated:false as const,readyAuthorized:false as const}
|
||||
// The actual preflight exercises the checked-out PostgreSQL client. The guard fixture models its source verifier.
|
||||
type Verifier=Parameters<typeof withActiveSourcePreflight>[1]
|
||||
const verifierArgs=[] as unknown as Parameters<Verifier['verify']>
|
||||
const preflight={directory:root,sourcePin:g1,expectedSource:registry.body.source,expectedSha256:first.activationSha256,tlsSpkiSha256:'1'.repeat(64),deadlineAt:Date.now()+30000}
|
||||
const refuseConnect=()=>{throw Error('Reached SQL connection before activation was trusted')}
|
||||
const verifier={verify:async()=>evidence}
|
||||
await assert.rejects(withActiveSourcePreflight({...preflight,expectedSha256:'0'.repeat(64)},verifier,refuseConnect),/activation digest changed/)
|
||||
await assert.rejects(withActiveSourcePreflight({...preflight,tlsSpkiSha256:'2'.repeat(64)},verifier,refuseConnect),/Activation key differs/)
|
||||
await assert.rejects(withActiveSourcePreflight({...preflight,sourcePin:g2},verifier,refuseConnect),/differs from signed source generation/)
|
||||
await assert.rejects(withActiveSourcePreflight({...preflight,expectedSource:{...preflight.expectedSource,currentUser:'different'}},verifier,refuseConnect),/differs from reviewed configuration/)
|
||||
let verifications=0
|
||||
const result=await withActiveSourcePreflight(preflight,{verify:async()=>{verifications++;return evidence}},async guarded=>{
|
||||
assert.equal(await guarded.verify(...verifierArgs),evidence)
|
||||
await assert.rejects(run(revoke(first.activationSha256)),{code:'EEXIST'})
|
||||
assert.equal(await guarded.verify(...verifierArgs),evidence)
|
||||
return 'matched'
|
||||
})
|
||||
assert.equal(result,'matched');assert.equal(verifications,2)
|
||||
await assert.rejects(fs.access(lock),{code:'ENOENT'})
|
||||
await assert.rejects(withActiveSourcePreflight(preflight,verifier,async guarded=>{
|
||||
await guarded.verify(...verifierArgs)
|
||||
}),/Initial and terminal source evidence required/)
|
||||
let reads=0
|
||||
await assert.rejects(withActiveSourcePreflight(preflight,{verify:async()=>++reads===1?evidence:{...evidence,bindingSha256:'0'.repeat(64)}},async guarded=>{
|
||||
await guarded.verify(...verifierArgs)
|
||||
await guarded.verify(...verifierArgs)
|
||||
}),/differs from signed source generation/)
|
||||
await fs.writeFile(state+'.pending','interrupted',{flag:'wx'})
|
||||
await assert.rejects(withActiveSourcePreflight(preflight,verifier,refuseConnect),/Unresolved activation transition/)
|
||||
await fs.unlink(state+'.pending')
|
||||
await assert.rejects(run(start),/predecessor changed/)
|
||||
const revoked=await run(revoke(first.activationSha256))
|
||||
await assert.rejects(withActiveSourcePreflight({...preflight,expectedSha256:revoked.activationSha256},verifier,refuseConnect),/activation digest changed/)
|
||||
run=load().sourceActivationMain
|
||||
assert.equal((await run(status)).status,'revoked')
|
||||
await assert.rejects(run(revoke(first.activationSha256)),/digest changed/)
|
||||
await assert.rejects(run(await activate(g3,'3'.repeat(64),revoked.activationSha256)),/ordinal changed/)
|
||||
const second=await run(await activate(g2,'2'.repeat(64),revoked.activationSha256))
|
||||
assert.equal(second.ordinal,2);assert.equal(second.status,'active')
|
||||
await assert.rejects(run(await activate(g3,'1'.repeat(64),second.activationSha256)),/already used/)
|
||||
const repeatedGeneration=await issue(g2,first.generation!.toUpperCase())
|
||||
await assert.rejects(run(await activate(repeatedGeneration,'3'.repeat(64),second.activationSha256)),/already used/)
|
||||
await assert.rejects(run(await activate({...g3,currentHeadSha256:'0'.repeat(64)},'3'.repeat(64),second.activationSha256)))
|
||||
assert.equal((await run(status)).activationSha256,second.activationSha256)
|
||||
parentFailure='sync'
|
||||
await assert.rejects(run(revoke(second.activationSha256)),{code:'SOURCE_ACTIVATION_TERMINAL_UNKNOWN'})
|
||||
parentFailure='close'
|
||||
const observed=await run(status)
|
||||
assert.equal(observed.status,'revoked')
|
||||
const third=await run(await activate(g3,'3'.repeat(64),observed.activationSha256))
|
||||
assert.equal(third.outcome,'committed')
|
||||
assert('cleanupUnconfirmed' in third && third.cleanupUnconfirmed===true)
|
||||
await fs.writeFile(state+'.pending','interrupted',{flag:'wx'})
|
||||
await assert.rejects(run(status),/Unresolved activation transition/)
|
||||
await assert.rejects(run(revoke(third.activationSha256)),/Unresolved activation transition/)
|
||||
}finally{await fs.rm(root,{recursive:true,force:true})}
|
||||
})
|
||||
Reference in new issue
Block a user