#!/usr/bin/env node
/* eslint-disable quotes */
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import b4a from 'b4a'
import {
CLIENT_COMPOSITION_SCHEMA_NAMES_V1,
CLIENT_COMPOSITION_VECTOR_EXPECTATIONS_V1,
encodeClientCompositionFormatAuthorityV1,
verifyClientCompositionAuthorityV1
} from '../packages/blind-protocol/client-composition-authority.js'
import { buildClientCompositionFixtureVectorsV1 } from '../packages/blind-protocol/client-composition-runtime-vectors.js'
import {
encodeVectorManifest,
hashClientCompositionFormat,
hashClientCompositionVectorSet
} from '../packages/blind-protocol/hashes.js'
import { SCHEMA_CATEGORY } from '../packages/blind-protocol/registry.js'
import {
compileMasterSchemaCatalogForCategory,
encodeSchemaCatalog
} from '../packages/blind-protocol/schema-meta.js'
const root = fileURLToPath(new URL('..', import.meta.url))
const check = process.argv.slice(2).includes('--check')
const unknownArguments = process.argv.slice(2).filter(argument => argument !== '--check')
if (unknownArguments.length > 0) throw new Error(`unknown arguments: ${unknownArguments.join(' ')}`)
const relative = Object.freeze({
specification: 'docs/protocol/HIVERELAY-BLIND-CLIENT-COMPOSITION-V1.md',
master: 'docs/protocol/BLIND-APP-AGNOSTIC-HIVERELAY-MASTER-SPEC.md',
formatAuthority: 'packages/blind-protocol/hiverelay-blind-client-composition-format-v1.cenc',
schemaCatalog: 'packages/blind-protocol/hiverelay-blind-client-composition-schema-catalog-v1.cenc',
vectorManifest: 'packages/blind-protocol/hiverelay-blind-client-composition-vector-manifest-v1.cenc',
metadata: 'packages/blind-protocol/hiverelay-blind-client-composition-authority-v1.json',
generatedModule: 'packages/blind-protocol/client-composition-authority-generated.js',
vectorRoot: 'packages/blind-protocol/vectors/client-composition'
})
const [specBytesSnapshot, masterBytesSnapshot] = await Promise.all([
fs.readFile(path.join(root, relative.specification)),
fs.readFile(path.join(root, relative.master))
])
const masterTextSnapshot = masterBytesSnapshot.toString('utf8')
if (!Buffer.from(masterTextSnapshot, 'utf8').equals(masterBytesSnapshot)) {
throw new Error('stable master is not strict UTF-8')
}
function jsonBytes (value) {
return b4a.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8')
}
function generatedModuleBytes (metadata) {
const schemaNames = JSON.stringify(metadata.schemaNames, null, 2)
const metadataFields = { ...metadata }
delete metadataFields.schemaNames
return b4a.from(`/* eslint-disable */\n// Generated by scripts/generate-blind-client-composition-authority.mjs. Do not edit.\n` +
`export const CLIENT_COMPOSITION_AUTHORITY_V1 = Object.freeze({\n` +
` ...${JSON.stringify(metadataFields, null, 2)},\n` +
` schemaNames: Object.freeze(${schemaNames})\n` +
`})\n\n` +
`export const CLIENT_COMPOSITION_AUTHORITY_STATUS = Object.freeze({\n` +
` profile: 'client-composition-authority-v1',\n` +
` authorityArtifactPath: ${JSON.stringify(metadata.formatAuthorityArtifact)},\n` +
` schemaCatalogArtifactPath: ${JSON.stringify(metadata.schemaCatalogArtifact)},\n` +
` vectorManifestPath: ${JSON.stringify(metadata.vectorManifestArtifact)},\n` +
` metadataPath: ${JSON.stringify(relative.metadata)},\n` +
` schemaNames: Object.freeze(${schemaNames}),\n` +
` schemaCount: ${metadata.schemaCount},\n` +
` vectorCount: ${metadata.vectorCount},\n` +
` formatAuthorityPublished: true,\n` +
` vectorAuthorityPublished: true,\n` +
` releaseBlockers: Object.freeze([]),\n` +
` releaseReady: true\n` +
`})\n\n` +
`export function assertClientCompositionAuthorityReady () {\n` +
` if (CLIENT_COMPOSITION_AUTHORITY_STATUS.releaseReady) return CLIENT_COMPOSITION_AUTHORITY_STATUS\n` +
` const error = new Error('client-composition binary authority is incomplete')\n` +
` error.code = 'BLIND_CLIENT_COMPOSITION_AUTHORITY_INCOMPLETE'\n` +
` error.releaseBlockers = [...CLIENT_COMPOSITION_AUTHORITY_STATUS.releaseBlockers]\n` +
` throw error\n` +
`}\n`, 'utf8')
}
function buildOutputs () {
const compiled = compileMasterSchemaCatalogForCategory(masterTextSnapshot, SCHEMA_CATEGORY.CLIENT_EXAMPLE)
const names = compiled.entries.map(entry => b4a.toString(entry.schemaName, 'ascii'))
if (JSON.stringify(names) !== JSON.stringify(CLIENT_COMPOSITION_SCHEMA_NAMES_V1)) {
throw new Error(`client-composition schema inventory drifted: ${names.join(',')}`)
}
const schemaCatalogBytes = encodeSchemaCatalog(compiled.entries, {
minimum: CLIENT_COMPOSITION_SCHEMA_NAMES_V1.length,
maximum: CLIENT_COMPOSITION_SCHEMA_NAMES_V1.length,
name: 'client-composition schema catalog'
})
const formatAuthorityBytes = encodeClientCompositionFormatAuthorityV1(specBytesSnapshot, schemaCatalogBytes)
const vectors = buildClientCompositionFixtureVectorsV1()
vectors.set('registry/client-composition-schema-catalog-v1.cenc', b4a.from(schemaCatalogBytes))
const expectedPaths = CLIENT_COMPOSITION_VECTOR_EXPECTATIONS_V1.map(entry => entry.path)
const actualPaths = [...vectors.keys()].sort()
if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) {
throw new Error(`client-composition vector inventory drifted: ${actualPaths.join(',')}`)
}
const vectorManifestBytes = encodeVectorManifest(
[...vectors].map(([vectorPath, bytes]) => ({ path: vectorPath, bytes }))
)
const formatHash = hashClientCompositionFormat(formatAuthorityBytes)
const vectorSetHash = hashClientCompositionVectorSet(vectorManifestBytes)
verifyClientCompositionAuthorityV1({
formatAuthorityBytes,
specBytes: specBytesSnapshot,
schemaCatalogBytes,
vectorManifestBytes,
vectors,
expectedFormatHash: formatHash,
expectedVectorSetHash: vectorSetHash
})
const metadata = Object.freeze({
profile: 'client-composition-authority-v1',
authorityVersion: 1,
formatMajor: 1,
formatMinor: 0,
specificationArtifact: relative.specification,
formatAuthorityArtifact: relative.formatAuthority,
schemaCatalogArtifact: relative.schemaCatalog,
vectorManifestArtifact: relative.vectorManifest,
vectorRoot: relative.vectorRoot,
formatHash: b4a.toString(formatHash, 'hex'),
vectorSetHash: b4a.toString(vectorSetHash, 'hex'),
schemaCount: CLIENT_COMPOSITION_SCHEMA_NAMES_V1.length,
vectorCount: CLIENT_COMPOSITION_VECTOR_EXPECTATIONS_V1.length,
schemaNames: CLIENT_COMPOSITION_SCHEMA_NAMES_V1
})
const outputs = new Map([
[relative.formatAuthority, formatAuthorityBytes],
[relative.schemaCatalog, schemaCatalogBytes],
[relative.vectorManifest, vectorManifestBytes],
[relative.metadata, jsonBytes(metadata)],
[relative.generatedModule, generatedModuleBytes(metadata)]
])
for (const [vectorPath, bytes] of vectors) {
outputs.set(path.posix.join(relative.vectorRoot, vectorPath), bytes)
}
return { outputs, metadata }
}
function assertDeterministic (first, second) {
if (first.outputs.size !== second.outputs.size) throw new Error('client-composition generation is nondeterministic')
for (const [file, bytes] of first.outputs) {
if (!second.outputs.has(file) || !b4a.equals(bytes, second.outputs.get(file))) {
throw new Error(`client-composition generation is nondeterministic for ${file}`)
}
}
}
async function listFiles (directory, prefix = '') {
const output = []
let entries
try {
entries = await fs.readdir(directory, { withFileTypes: true })
} catch (error) {
if (error.code === 'ENOENT') return output
throw error
}
for (const entry of entries) {
const name = prefix === '' ? entry.name : `${prefix}/${entry.name}`
if (entry.isDirectory()) output.push(...await listFiles(path.join(directory, entry.name), name))
else if (entry.isFile()) output.push(name)
else throw new Error(`client-composition vector tree contains a non-file entry: ${name}`)
}
return output.sort()
}
async function checkOutputs (outputs) {
for (const [file, expected] of outputs) {
let actual
try {
actual = await fs.readFile(path.join(root, file))
} catch (error) {
if (error.code === 'ENOENT') throw new Error(`missing generated client-composition artifact: ${file}`)
throw error
}
if (!b4a.equals(actual, expected)) throw new Error(`stale generated client-composition artifact: ${file}`)
}
const actualVectorFiles = await listFiles(path.join(root, relative.vectorRoot))
const expectedVectorFiles = [...outputs.keys()]
.filter(file => file.startsWith(`${relative.vectorRoot}/`))
.map(file => file.slice(relative.vectorRoot.length + 1))
.sort()
if (JSON.stringify(actualVectorFiles) !== JSON.stringify(expectedVectorFiles)) {
throw new Error('client-composition vector directory has missing or unexpected files')
}
}
async function writeOutputs (outputs) {
const temporaryFiles = []
try {
let ordinal = 0
for (const [file, bytes] of outputs) {
const target = path.join(root, file)
await fs.mkdir(path.dirname(target), { recursive: true })
const temporary = `${target}.tmp-${process.pid}-${ordinal++}`
temporaryFiles.push(temporary)
await fs.writeFile(temporary, bytes, { flag: 'wx' })
await fs.rename(temporary, target)
temporaryFiles.pop()
}
} finally {
await Promise.all(temporaryFiles.map(file => fs.rm(file, { force: true })))
}
await checkOutputs(outputs)
}
const first = buildOutputs()
const second = buildOutputs()
assertDeterministic(first, second)
if (check) await checkOutputs(first.outputs)
else await writeOutputs(first.outputs)
process.stdout.write(`${check ? 'verified' : 'generated'} client-composition authority ` +
`${first.metadata.formatHash}/${first.metadata.vectorSetHash} ` +
`(${first.metadata.schemaCount} schemas, ${first.metadata.vectorCount} vectors)\n`)