#!/usr/bin/env node
import { execFile, spawn } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { constants as fsConstants } from 'node:fs'
import { lstat, open, rename, unlink } from 'node:fs/promises'
import { isIP } from 'node:net'
import path from 'node:path'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
import { publishFleetChannel } from './publish-fleet-channel.mjs'
const execFileAsync = promisify(execFile)
const scriptPath = fileURLToPath(import.meta.url)
const scriptDir = path.dirname(scriptPath)
const defaultRepoRoot = path.resolve(scriptDir, '..')
const defaultObserver = path.join(scriptDir, 'observe-public-hive-gateway-rollout.mjs')
const CANONICAL_GATEWAY_MANIFEST = 'fleet/public-hive-gateway-release.json'
const RECEIPT_SCHEMA = 'hiverelay-public-gateway-deploy-transition-v1'
const OBSERVATION_SCHEMA = 'hiverelay-public-gateway-observation-session-v1'
const WINDOW_STATE_SCHEMA = 'hiverelay-public-gateway-window-state-v1'
const RELEASE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/
const OBJECT_ID_PATTERN = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/
const MAX_RECEIPT_BYTES = 256 * 1024
const MAX_EVIDENCE_BYTES = 16 * 1024 * 1024
const MIN_OBSERVATION_WINDOW_MS = 24 * 60 * 60 * 1000
const GIT_PROGRAM = '/usr/bin/git'
const UNSAFE_GIT_ENV = new Set([
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_COMMON_DIR', 'GIT_CONFIG',
'GIT_CONFIG_COUNT', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_PARAMETERS',
'GIT_CONFIG_SYSTEM', 'GIT_DIR', 'GIT_EXEC_PATH', 'GIT_GRAFT_FILE',
'GIT_INDEX_FILE', 'GIT_NAMESPACE', 'GIT_OBJECT_DIRECTORY', 'GIT_PREFIX',
'GIT_PROXY_COMMAND', 'GIT_QUARANTINE_PATH', 'GIT_REPLACE_REF_BASE',
'GIT_SHALLOW_FILE', 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_SSL_CAINFO',
'GIT_SSL_CAPATH', 'GIT_SSL_NO_VERIFY', 'GIT_WORK_TREE'
])
const usage = `
Usage:
node scripts/public-hive-gateway-deploy.mjs canary [options]
node scripts/public-hive-gateway-deploy.mjs observe [options]
node scripts/public-hive-gateway-deploy.mjs stable [options]
node scripts/public-hive-gateway-deploy.mjs recover-canary [options]
Common release options:
--target <vX.Y.Z> Exact signed release tag
--target-sha <sha> Exact tagged commit authorized by the operator
--repo <absolute-path> Clean control worktree (default: repository root)
--remote <name> Git remote (default: origin)
--branch <name> Control branch (default: main)
--allowed-signers <path> Absolute trusted OpenSSH allowed_signers path
--relays <path> Absolute current full fleet inventory path
--channels <path> Absolute channel file (observer only)
--gateway-ops-evidence-dir <absolute-dir>
Fresh signed-contract ops artifacts (canary/stable)
Canary publication:
--publish Publish after an internal validation-only pass
--authorize <token> Exact publish-canary:<tag>:<sha> token
--receipt <path> New private canary transition receipt (publish)
Observation:
--canary-receipt <path> Completed canary publication receipt
--session <path> Private controller observation-session record
--known-hosts <path> Absolute pinned fleet known_hosts file
--gateway-evidence <path> Absolute evidence path on each cohort relay
--gateway-window-state <p> Absolute new/resumed local window-state path
--evidence <path> Absolute new/resumed rollout-evidence path
--gateway-manifest <path> Tagged repo-relative manifest (canonical default)
--resume Resume this wrapper's existing observing session
--sample-interval-ms <ms> Observer cadence
--max-runtime-ms <ms> Observer runtime budget
--ssh-command <path> Alternate checker SSH command (fixtures only)
--ssh-key <path> Fleet SSH key
--ssh-user <name> Fleet SSH user
--remote-repo-dir <path> Repository path on cohort relays
--service <name> Relay service name
--api <url> Loopback management API URL
--timeout-ms <ms> Per-check rollout convergence budget
--interval-ms <ms> Per-check rollout retry cadence
--ssh-timeout-ms <ms> Per-relay SSH timeout
Stable publication:
--canary-receipt <path> Completed canary publication receipt
--session <path> Completed controller observation session
--publish Publish after an internal validation-only pass
--authorize <token> Exact publish-stable:<tag>:<sha> token
--receipt <path> New private stable transition receipt (publish)
Forward canary recovery:
--canary-receipt <path> Failed canary publication receipt being recovered
--target/--target-sha New signed recovery tag/commit descending from it
--publish Publish only after normal manifest/operator gates
--authorize <token> Exact receipt-bound forward recovery token
--receipt <path> New private recovery transition receipt (publish)
Canary and stable default to validation-only. Stable is never called by
observation. Observation continuity is controller-retained and bound to the
signed manifest; it is not independent T3/witness or external timestamp proof.`
if (path.resolve(process.argv[1] || '') === scriptPath) {
try {
const input = parseArgs(process.argv.slice(2))
if (input.help) {
console.log(usage.trim())
} else {
console.log(JSON.stringify(await runPublicHiveGatewayDeploy(input), null, 2))
}
} catch (err) {
console.error(`public gateway deploy: ${safeError(err)}`)
process.exitCode = 1
}
}
export async function runPublicHiveGatewayDeploy (input, injected = {}) {
const options = normalizeOptions(input)
if (options.action === 'canary') return runCanary(options, injected)
if (options.action === 'observe') return runObservation(options, injected)
if (options.action === 'stable') return runStable(options, injected)
if (options.action === 'abort-canary') {
throw new Error('abort-canary is retired: create a new signed recovery tag descending from the failed canary and use recover-canary')
}
if (options.action === 'recover-canary') return runCanaryRecovery(options, injected)
throw new Error('action must be exactly canary, observe, stable, recover-canary, or abort-canary (migration error only)')
}
async function runCanaryRecovery (options, injected) {
requireReleaseInputs(options)
rejectOptions(options, observationOnlyOptions(['resume', 'session']), 'recover-canary')
requireCanonicalPublicationInputs(options)
const publish = injected.publish || publishFleetChannel
const resolveSha = injected.resolveTargetSha || resolveTargetSha
const targetSha = await requireTargetSha(options, resolveSha)
const failedReceiptPath = requireAbsolutePath(options.canaryReceipt, 'canary-receipt')
const failedReceipt = await readCanaryReceipt(failedReceiptPath)
await requireReceiptTrust(failedReceipt, options)
if (failedReceipt.repo !== options.repo || failedReceipt.remote !== options.remote || failedReceipt.branch !== options.branch) {
throw new Error('failed canary receipt does not bind this control repository')
}
const failedTarget = failedReceipt.target
const failedTargetSha = failedReceipt.targetSha
if (options.target === failedTarget || targetSha === failedTargetSha) {
throw new Error('recovery must use a new forward tag and commit')
}
const isAncestor = injected.isAncestor || isCommitAncestor
if (!await isAncestor(options.repo, failedTargetSha, targetSha)) {
throw new Error('recovery target must descend from the failed canary target; revert behavior in a new commit')
}
const channelsPath = options.channels || path.join(options.repo, 'fleet', 'channels.json')
const channels = await readChannels(channelsPath)
if (requireChannelTarget(channels, 'canary') !== failedTarget) {
throw new Error('canary channel no longer points at the failed receipt-bound release')
}
if (requireChannelTarget(channels, 'stable') === failedTarget) {
throw new Error('cannot recover a canary that was already published to stable')
}
const failedReceiptSha256 = await sha256SafeFile(failedReceiptPath, MAX_RECEIPT_BYTES, 'failed canary receipt')
const allowedSignersSha256 = await sha256SafeFile(options.allowedSigners, MAX_RECEIPT_BYTES, 'allowed signers')
const publisherOptions = {
...publicationOptions(options, 'canary', false),
expectedCurrentTarget: failedTarget,
expectedCurrentTargetSha: failedTargetSha
}
const validation = requireGatewayPublication(await publish(publisherOptions), 'canary')
const operatorContracts = normalizeOperatorContracts(validation.operatorContracts,
'recovery publisher operator contracts', true)
const gatewayOpsEvidenceDir = operatorContracts.length > 0 ? options.gatewayOpsEvidenceDir : null
if ((operatorContracts.length > 0) !== Boolean(gatewayOpsEvidenceDir)) {
throw new Error('recovery operator readiness did not bind its evidence directory')
}
const recoveryBindingSha256 = sha256(Buffer.from(JSON.stringify({
failedReceiptSha256,
allowedSignersSha256,
gatewayOpsEvidenceDir,
operatorContracts
})))
const authorization = `recover-canary:${failedTarget}:${failedTargetSha}->${options.target}:${targetSha}:${recoveryBindingSha256}`
const base = transitionReport('recover-canary', options, targetSha, validation, {
failedTarget,
failedTargetSha,
failedCanaryReceipt: failedReceiptPath,
failedCanaryReceiptSha256: failedReceiptSha256,
allowedSignersSha256,
gatewayOpsEvidenceDir,
operatorContracts,
recoveryBindingSha256,
authorizationRequired: authorization
})
if (!options.publish) {
if (options.authorize) throw new Error('--authorize is valid only with --publish')
if (options.receipt) throw new Error('--receipt is valid only with --publish')
return base
}
requireAuthorization(options, authorization)
const receiptPath = requireAbsolutePath(options.receipt, 'receipt')
const receipt = {
schema: RECEIPT_SCHEMA,
action: 'recover-canary',
status: 'publishing',
target: options.target,
targetSha,
failedTarget,
failedTargetSha,
failedCanaryReceipt: failedReceiptPath,
failedCanaryReceiptSha256: failedReceiptSha256,
allowedSigners: options.allowedSigners,
allowedSignersSha256,
gatewayOpsEvidenceDir,
operatorContracts,
recoveryBindingSha256,
repo: options.repo,
remote: options.remote,
branch: options.branch,
startedAt: nowIso(injected)
}
await createPrivateJson(receiptPath, receipt)
try {
const result = requireGatewayPublication(await publish({ ...publisherOptions, publish: true }), 'canary')
requireSameOperatorContracts(
normalizeOperatorContracts(result.operatorContracts, 'published recovery operator contracts', true),
operatorContracts,
'canary recovery publication'
)
if (await sha256SafeFile(failedReceiptPath, MAX_RECEIPT_BYTES, 'failed canary receipt') !== failedReceiptSha256) {
throw new Error('failed canary receipt changed during recovery publication')
}
if (await sha256SafeFile(options.allowedSigners, MAX_RECEIPT_BYTES, 'allowed signers') !== allowedSignersSha256) {
throw new Error('allowed signers changed during recovery publication')
}
if (result.status === 'unchanged') throw new Error('recovery publication cannot succeed as unchanged')
const completed = {
...receipt,
status: result.status === 'unchanged' ? 'unchanged' : 'published',
completedAt: nowIso(injected),
publishedHead: requireObjectId(result.publishedHead, 'published head'),
remoteHead: requireObjectId(result.remoteHead || result.publishedHead, 'remote head')
}
await replacePrivateJson(receiptPath, completed)
return { ...base, status: completed.status, receipt: receiptPath, publication: result }
} catch (err) {
await replacePrivateJson(receiptPath, { ...receipt, status: 'failed', failedAt: nowIso(injected) }).catch(() => {})
throw err
}
}
async function runCanary (options, injected) {
requireReleaseInputs(options)
rejectOptions(options, observationOnlyOptions(['resume', 'session', 'canaryReceipt']), 'canary')
requireCanonicalPublicationInputs(options)
const resolveSha = injected.resolveTargetSha || resolveTargetSha
const publish = injected.publish || publishFleetChannel
const targetSha = await requireTargetSha(options, resolveSha)
const channelsPath = options.channels || path.join(options.repo, 'fleet', 'channels.json')
const channels = await readChannels(channelsPath)
const previousTarget = requireChannelTarget(channels, 'canary')
const previousTargetSha = await resolveSha(options.repo, previousTarget)
const allowedSignersSha256 = await sha256SafeFile(options.allowedSigners, MAX_RECEIPT_BYTES, 'allowed signers')
const publisherOptions = publicationOptions(options, 'canary', false)
const validation = requireGatewayPublication(await publish(publisherOptions), 'canary')
const operatorContracts = normalizeOperatorContracts(validation.operatorContracts,
'canary publisher operator contracts', true)
const gatewayOpsEvidenceDir = operatorContracts.length > 0 ? options.gatewayOpsEvidenceDir : null
if ((operatorContracts.length > 0) !== Boolean(gatewayOpsEvidenceDir)) {
throw new Error('canary operator readiness did not bind its evidence directory')
}
const authorization = `publish-canary:${options.target}:${targetSha}`
const base = transitionReport('canary', options, targetSha, validation, {
previousTarget,
previousTargetSha,
allowedSignersSha256,
gatewayOpsEvidenceDir,
operatorContracts,
authorizationRequired: authorization
})
if (!options.publish) {
if (options.authorize) throw new Error('--authorize is valid only with --publish')
if (options.receipt) throw new Error('--receipt is valid only with --publish')
return base
}
requireAuthorization(options, authorization)
const receiptPath = requireAbsolutePath(options.receipt, 'receipt')
const startedAt = nowIso(injected)
const receipt = {
schema: RECEIPT_SCHEMA,
action: 'canary',
status: 'publishing',
target: options.target,
targetSha,
previousTarget,
previousTargetSha,
allowedSigners: options.allowedSigners,
allowedSignersSha256,
gatewayOpsEvidenceDir,
operatorContracts,
repo: options.repo,
remote: options.remote,
branch: options.branch,
startedAt
}
await createPrivateJson(receiptPath, receipt)
try {
const result = requireGatewayPublication(await publish({ ...publisherOptions, publish: true }), 'canary')
requireSameOperatorContracts(
normalizeOperatorContracts(result.operatorContracts, 'published canary operator contracts', true),
operatorContracts,
'canary publication'
)
if (await sha256SafeFile(options.allowedSigners, MAX_RECEIPT_BYTES, 'allowed signers') !== allowedSignersSha256) {
throw new Error('allowed signers changed during canary publication')
}
const completed = {
...receipt,
status: result.status === 'unchanged' ? 'unchanged' : 'published',
completedAt: nowIso(injected),
publishedHead: requireObjectId(result.publishedHead, 'published head'),
remoteHead: requireObjectId(result.remoteHead || result.publishedHead, 'remote head')
}
await replacePrivateJson(receiptPath, completed)
return { ...base, status: completed.status, receipt: receiptPath, publication: result }
} catch (err) {
await replacePrivateJson(receiptPath, {
...receipt,
status: 'failed',
failedAt: nowIso(injected)
}).catch(() => {})
throw err
}
}
async function runObservation (options, injected) {
requireReleaseInputs(options)
rejectOptions(options, ['publish', 'authorize', 'receipt', 'gatewayOpsEvidenceDir'], 'observe')
requireCanonicalPublicationInputs(options)
const resolveSha = injected.resolveTargetSha || resolveTargetSha
const targetSha = await requireTargetSha(options, resolveSha)
const canaryReceiptPath = requireAbsolutePath(options.canaryReceipt, 'canary-receipt')
const canaryReceipt = await readCanaryReceipt(canaryReceiptPath)
requireReceiptRelease(canaryReceipt, options, targetSha)
await requireReceiptTrust(canaryReceipt, options)
const canaryReceiptSha256 = await sha256SafeFile(canaryReceiptPath, MAX_RECEIPT_BYTES, 'canary receipt')
const sessionPath = requireAbsolutePath(options.session, 'session')
const statePath = requireAbsolutePath(options.gatewayWindowState, 'gateway-window-state')
const evidencePath = requireAbsolutePath(options.evidence, 'evidence')
requireAbsolutePath(options.knownHosts, 'known-hosts')
requireAbsolutePath(options.gatewayEvidence, 'gateway-evidence')
const observerArgs = buildObserverArgs(options, targetSha, statePath, evidencePath)
const observerArgsSha256 = sha256(Buffer.from(JSON.stringify(observerArgs)))
const knownHostsSha256 = await sha256SafeFile(options.knownHosts, MAX_RECEIPT_BYTES, 'known hosts')
const relaysSha256 = await sha256SafeFile(options.relays, MAX_EVIDENCE_BYTES, 'fleet inventory')
const channelsSha256 = await sha256SafeFile(options.channels, MAX_RECEIPT_BYTES, 'fleet channels')
const sessionBase = {
schema: OBSERVATION_SCHEMA,
status: 'observing',
target: options.target,
targetSha,
repo: options.repo,
allowedSigners: options.allowedSigners,
relays: options.relays,
channels: options.channels,
knownHosts: options.knownHosts,
canaryReceipt: canaryReceiptPath,
canaryReceiptSha256,
gatewayOpsEvidenceDir: canaryReceipt.gatewayOpsEvidenceDir,
operatorContracts: canaryReceipt.operatorContracts,
gatewayManifest: options.gatewayManifest,
gatewayWindowState: statePath,
rolloutEvidence: evidencePath,
observerArgsSha256,
knownHostsSha256,
relaysSha256,
channelsSha256
}
let session
if (options.resume) {
session = await readObservationSession(sessionPath)
if (session.status !== 'observing') throw new Error('only an observing session can be resumed')
requireOnlyKeys('observing session', session, [
'schema', 'status', 'target', 'targetSha', 'repo', 'allowedSigners',
'relays', 'channels', 'knownHosts', 'canaryReceipt',
'canaryReceiptSha256', 'gatewayOpsEvidenceDir', 'operatorContracts',
'gatewayManifest', 'gatewayWindowState',
'rolloutEvidence', 'observerArgsSha256', 'knownHostsSha256',
'relaysSha256', 'channelsSha256', 'startedAt'
])
for (const name of Object.keys(sessionBase)) {
const matches = name === 'operatorContracts'
? JSON.stringify(session[name]) === JSON.stringify(sessionBase[name])
: session[name] === sessionBase[name]
if (!matches) throw new Error(`observation session ${name} does not match this invocation`)
}
requireIsoTimestamp(session.startedAt, 'session startedAt')
} else {
await requireMissing(sessionPath, 'session')
await requireMissing(statePath, 'gateway-window-state')
await requireMissing(evidencePath, 'evidence')
session = { ...sessionBase, startedAt: nowIso(injected) }
await createPrivateJson(sessionPath, session)
}
const runObserver = injected.runObserver || runObserverProcess
let result
try {
result = await runObserver(observerArgs, { observer: defaultObserver })
} catch (err) {
await replacePrivateJson(sessionPath, {
...session,
status: 'failed',
failedAt: nowIso(injected),
observerExit: 1
}).catch(() => {})
throw err
}
const exitCode = normalizeExitCode(result)
if (exitCode !== 0) {
await replacePrivateJson(sessionPath, {
...session,
status: 'failed',
failedAt: nowIso(injected),
observerExit: exitCode
})
throw new Error(`authoritative observer stopped with exit ${exitCode}; session is failed and cannot resume`)
}
await requireReceiptTrust(canaryReceipt, options)
await requireObservationInputHashes(session, options)
const state = await readBoundedJson(statePath, MAX_EVIDENCE_BYTES, 'gateway window state')
const evidence = await readBoundedJson(evidencePath, MAX_EVIDENCE_BYTES, 'canary rollout evidence')
const observationWindowMs = validateCompletedObservation(state, evidence, options, targetSha)
const completedAt = nowIso(injected)
const elapsedMs = Date.parse(completedAt) - Date.parse(session.startedAt)
if (!Number.isSafeInteger(elapsedMs) || elapsedMs < observationWindowMs) {
await replacePrivateJson(sessionPath, {
...session,
status: 'failed',
failedAt: completedAt,
observerExit: 0
})
throw new Error('observer returned success before this controller session spanned the manifest observation window')
}
const completed = {
...session,
status: 'complete',
completedAt,
elapsedMs,
observationWindowMs,
windowStateSha256: await sha256SafeFile(statePath, MAX_EVIDENCE_BYTES, 'gateway window state'),
rolloutEvidenceSha256: await sha256SafeFile(evidencePath, MAX_EVIDENCE_BYTES, 'canary rollout evidence')
}
await replacePrivateJson(sessionPath, completed)
return {
schema: RECEIPT_SCHEMA,
action: 'observe',
status: 'complete',
target: options.target,
targetSha,
session: sessionPath,
elapsedMs,
observationWindowMs,
stableRemainsExplicit: true,
continuityAuthority: 'controller-retained-manifest-bound',
independentWitnessProof: false
}
}
async function runStable (options, injected) {
requireReleaseInputs(options)
rejectOptions(options, observationOnlyOptions(['resume']), 'stable')
requireCanonicalPublicationInputs(options)
const resolveSha = injected.resolveTargetSha || resolveTargetSha
const publish = injected.publish || publishFleetChannel
const targetSha = await requireTargetSha(options, resolveSha)
const canaryReceiptPath = requireAbsolutePath(options.canaryReceipt, 'canary-receipt')
const canaryReceipt = await readCanaryReceipt(canaryReceiptPath)
requireReceiptRelease(canaryReceipt, options, targetSha)
await requireReceiptTrust(canaryReceipt, options)
const operatorContracts = requireReceiptOperatorReadiness(canaryReceipt, options)
const sessionPath = requireAbsolutePath(options.session, 'session')
const session = await readObservationSession(sessionPath)
validateCompleteSession(session, options, targetSha, canaryReceiptPath)
if (session.gatewayOpsEvidenceDir !== canaryReceipt.gatewayOpsEvidenceDir) {
throw new Error('observation session changed the operator-readiness evidence directory')
}
requireSameOperatorContracts(
normalizeOperatorContracts(session.operatorContracts, 'observation session operator contracts'),
operatorContracts,
'observation session'
)
await requireObservationInputHashes(session, options)
const currentCanaryReceiptSha = await sha256SafeFile(canaryReceiptPath, MAX_RECEIPT_BYTES, 'canary receipt')
if (currentCanaryReceiptSha !== session.canaryReceiptSha256) throw new Error('canary receipt changed after observation started')
const stateSha = await sha256SafeFile(session.gatewayWindowState, MAX_EVIDENCE_BYTES, 'gateway window state')
const evidenceSha = await sha256SafeFile(session.rolloutEvidence, MAX_EVIDENCE_BYTES, 'canary rollout evidence')
if (stateSha !== session.windowStateSha256 || evidenceSha !== session.rolloutEvidenceSha256) {
throw new Error('observation evidence changed after the controller session completed')
}
const state = await readBoundedJson(session.gatewayWindowState, MAX_EVIDENCE_BYTES, 'gateway window state')
const evidence = await readBoundedJson(session.rolloutEvidence, MAX_EVIDENCE_BYTES, 'canary rollout evidence')
if (validateCompletedObservation(state, evidence, options, targetSha) !== session.observationWindowMs) {
throw new Error('completed observation session window drifted from its retained evidence')
}
const channelsPath = options.channels || path.join(options.repo, 'fleet', 'channels.json')
const channels = await readChannels(channelsPath)
if (requireChannelTarget(channels, 'canary') !== options.target) {
throw new Error('canary channel no longer points at the observed release')
}
const previousTarget = requireChannelTarget(channels, 'stable')
const publisherOptions = {
...publicationOptions(options, 'stable', false),
canaryEvidence: session.rolloutEvidence,
gatewayWindowState: session.gatewayWindowState
}
const validation = requireGatewayPublication(await publish(publisherOptions), 'stable')
requireSameOperatorContracts(
normalizeOperatorContracts(validation.operatorContracts, 'stable publisher operator contracts', true),
operatorContracts,
'stable validation'
)
const authorization = `publish-stable:${options.target}:${targetSha}`
const base = transitionReport('stable', options, targetSha, validation, {
previousTarget,
observationSession: sessionPath,
gatewayOpsEvidenceDir: canaryReceipt.gatewayOpsEvidenceDir,
operatorContracts,
authorizationRequired: authorization,
continuityAuthority: 'controller-retained-manifest-bound',
independentWitnessProof: false
})
if (!options.publish) {
if (options.authorize) throw new Error('--authorize is valid only with --publish')
if (options.receipt) throw new Error('--receipt is valid only with --publish')
return base
}
requireAuthorization(options, authorization)
const receiptPath = requireAbsolutePath(options.receipt, 'receipt')
const startedAt = nowIso(injected)
const receipt = {
schema: RECEIPT_SCHEMA,
action: 'stable',
status: 'publishing',
target: options.target,
targetSha,
previousTarget,
observationSession: sessionPath,
observationSessionSha256: await sha256SafeFile(sessionPath, MAX_RECEIPT_BYTES, 'observation session'),
gatewayOpsEvidenceDir: canaryReceipt.gatewayOpsEvidenceDir,
operatorContracts,
repo: options.repo,
remote: options.remote,
branch: options.branch,
startedAt
}
await createPrivateJson(receiptPath, receipt)
try {
const result = requireGatewayPublication(await publish({ ...publisherOptions, publish: true }), 'stable')
requireSameOperatorContracts(
normalizeOperatorContracts(result.operatorContracts, 'published stable operator contracts', true),
operatorContracts,
'stable publication'
)
await requireReceiptTrust(canaryReceipt, options)
const completed = {
...receipt,
status: result.status === 'unchanged' ? 'unchanged' : 'published',
completedAt: nowIso(injected),
publishedHead: requireObjectId(result.publishedHead, 'published head'),
remoteHead: requireObjectId(result.remoteHead || result.publishedHead, 'remote head')
}
await replacePrivateJson(receiptPath, completed)
return { ...base, status: completed.status, receipt: receiptPath, publication: result }
} catch (err) {
await replacePrivateJson(receiptPath, { ...receipt, status: 'failed', failedAt: nowIso(injected) }).catch(() => {})
throw err
}
}
function normalizeOptions (input) {
const options = { ...(input || {}) }
if (!['canary', 'observe', 'stable', 'recover-canary', 'abort-canary'].includes(options.action)) {
throw new Error('action must be exactly canary, observe, stable, recover-canary, or abort-canary')
}
options.repo = requireAbsolutePath(path.resolve(options.repo || defaultRepoRoot), 'repo')
options.remote = options.remote || 'origin'
options.branch = options.branch || 'main'
options.gatewayManifest = options.gatewayManifest || CANONICAL_GATEWAY_MANIFEST
options.relays = options.relays || path.join(options.repo, 'fleet', 'relays.json')
options.channels = options.channels || path.join(options.repo, 'fleet', 'channels.json')
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(options.remote)) throw new Error('--remote is invalid')
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/.test(options.branch) || options.branch.includes('..')) {
throw new Error('--branch is invalid')
}
if (!isSafeRepoRelativePath(options.gatewayManifest)) throw new Error('--gateway-manifest must be a safe repo-relative path')
for (const name of [
'allowedSigners', 'relays', 'channels', 'receipt', 'canaryReceipt', 'session',
'knownHosts', 'gatewayEvidence', 'gatewayWindowState', 'evidence', 'sshKey',
'gatewayOpsEvidenceDir'
]) {
if (options[name] != null) options[name] = requireAbsolutePath(options[name], kebab(name))
}
options.publish = options.publish === true
options.resume = options.resume === true
return options
}
function requireReleaseInputs (options) {
if (!RELEASE_TAG_PATTERN.test(options.target || '')) throw new Error('--target must be an immutable release tag like v1.2.3')
requireObjectId(options.targetSha, 'target SHA')
requireAbsolutePath(options.allowedSigners, 'allowed-signers')
}
async function requireTargetSha (options, resolver) {
const expected = requireObjectId(options.targetSha, 'target SHA')
const actual = await resolver(options.repo, options.target)
if (actual !== expected) throw new Error(`--target-sha does not match ${options.target}^{commit}`)
return actual
}
function publicationOptions (options, channel, publish) {
const result = {
repo: options.repo,
remote: options.remote,
branch: options.branch,
channel,
target: options.target,
allowedSigners: options.allowedSigners,
publish
}
if (options.relays) result.relays = options.relays
if (options.gatewayOpsEvidenceDir) result.gatewayOpsEvidenceDir = options.gatewayOpsEvidenceDir
return result
}
function requireGatewayPublication (result, channel) {
requirePublicationShape(result, channel)
if (result.publicGatewayRequired !== true) {
throw new Error(`${channel} transition is restricted to an enabled canonical public gateway release`)
}
if (result.operatorContracts.length < 1) {
throw new Error(`${channel} transition requires public-t1 operator contracts for every enabled gateway cohort relay`)
}
return result
}
function requirePublicationShape (result, channel, target) {
if (!result || typeof result !== 'object' || Array.isArray(result) ||
result.schema !== 'hiverelay-fleet-channel-publication-v1' ||
result.channel !== channel || (target && result.target !== target) ||
!['dry-run', 'published', 'unchanged'].includes(result.status) ||
typeof result.publicGatewayRequired !== 'boolean' ||
!Array.isArray(result.operatorContracts)) {
throw new Error('publisher returned an invalid transition result')
}
normalizeOperatorContracts(result.operatorContracts, 'publisher operator contracts', true)
return result
}
function normalizeOperatorContracts (value, label, publication = false) {
if (!Array.isArray(value) || value.length > 64) throw new Error(`${label} must be a bounded array`)
const contracts = value.map((contract, index) => {
if (!contract || typeof contract !== 'object' || Array.isArray(contract)) {
throw new Error(`${label}[${index}] must be an object`)
}
const invariantKeys = [
'relay', 'operatorId', 'registrableDomain', 'suffix',
'operatorContractSha256', 'certificateSpkiSha256', 'expectedAddresses'
]
requireOnlyKeys(`${label}[${index}]`, contract,
publication ? [...invariantKeys, 'checkedAt', 'evidenceSha256'] : invariantKeys)
for (const name of ['relay', 'operatorId']) {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(contract[name] || '')) {
throw new Error(`${label}[${index}] ${name} is invalid`)
}
}
for (const name of ['registrableDomain', 'suffix']) {
if (!/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])$/.test(contract[name] || '') ||
!contract[name].includes('.')) {
throw new Error(`${label}[${index}] ${name} is invalid`)
}
}
for (const name of ['operatorContractSha256', 'certificateSpkiSha256']) {
if (!/^[a-f0-9]{64}$/.test(contract[name] || '')) throw new Error(`${label}[${index}] ${name} is invalid`)
}
if (!Array.isArray(contract.expectedAddresses) || contract.expectedAddresses.length < 1 ||
contract.expectedAddresses.length > 16) {
throw new Error(`${label}[${index}] expectedAddresses is invalid`)
}
const expectedAddresses = contract.expectedAddresses.map(address => {
const normalized = String(address || '').toLowerCase()
if (normalized.length < 2 || normalized.length > 64 || hasControlChars(normalized) || isIP(normalized) === 0) {
throw new Error(`${label}[${index}] expected address is invalid`)
}
return normalized
}).sort()
if (new Set(expectedAddresses).size !== expectedAddresses.length) {
throw new Error(`${label}[${index}] expectedAddresses contains duplicates`)
}
if (publication) {
requireIsoTimestamp(contract.checkedAt, `${label}[${index}] checkedAt`)
if (!/^[a-f0-9]{64}$/.test(contract.evidenceSha256 || '')) {
throw new Error(`${label}[${index}] evidenceSha256 is invalid`)
}
}
return {
relay: contract.relay,
operatorId: contract.operatorId,
registrableDomain: contract.registrableDomain,
suffix: contract.suffix,
operatorContractSha256: contract.operatorContractSha256,
certificateSpkiSha256: contract.certificateSpkiSha256,
expectedAddresses
}
}).sort((left, right) => left.relay.localeCompare(right.relay))
if (new Set(contracts.map(contract => contract.relay)).size !== contracts.length) {
throw new Error(`${label} contains duplicate relay bindings`)
}
return contracts
}
function requireSameOperatorContracts (actual, expected, label) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${label} changed the signed operator-contract bindings`)
}
}
function transitionReport (action, options, targetSha, validation, extra) {
return {
schema: RECEIPT_SCHEMA,
action,
status: 'validated',
target: options.target,
targetSha,
repo: options.repo,
remote: options.remote,
branch: options.branch,
publicGatewayRequired: validation.publicGatewayRequired,
wouldChange: validation.wouldChange,
...extra
}
}
function requireAuthorization (options, expected) {
if (options.authorize !== expected) throw new Error(`--authorize must exactly equal ${expected}`)
}
function rejectOptions (options, names, action) {
for (const name of names) {
if (options[name]) throw new Error(`--${kebab(name)} is not valid for ${action}`)
}
}
function observationOnlyOptions (extra = []) {
return [
...extra,
'knownHosts', 'gatewayEvidence', 'gatewayWindowState', 'evidence',
'sampleIntervalMs', 'maxRuntimeMs', 'sshCommand', 'sshKey', 'sshUser',
'remoteRepoDir', 'service', 'api', 'timeoutMs', 'intervalMs', 'sshTimeoutMs'
]
}
function requireCanonicalPublicationInputs (options) {
if (options.gatewayManifest !== CANONICAL_GATEWAY_MANIFEST) {
throw new Error(`public gateway publication requires ${CANONICAL_GATEWAY_MANIFEST}`)
}
const canonicalChannels = path.join(options.repo, 'fleet', 'channels.json')
if (options.channels && options.channels !== canonicalChannels) {
throw new Error('public gateway publication requires the canonical repo fleet/channels.json')
}
}
async function readCanaryReceipt (file) {
const receipt = await readPrivateJson(file, MAX_RECEIPT_BYTES, 'canary receipt')
requireOnlyKeys('canary receipt', receipt, [
'schema', 'action', 'status', 'target', 'targetSha', 'previousTarget',
'previousTargetSha', 'allowedSigners', 'allowedSignersSha256', 'repo',
'gatewayOpsEvidenceDir', 'operatorContracts', 'remote', 'branch',
'startedAt', 'completedAt', 'publishedHead', 'remoteHead'
])
if (receipt.schema !== RECEIPT_SCHEMA || receipt.action !== 'canary' ||
!['published', 'unchanged'].includes(receipt.status)) {
throw new Error('canary receipt is not a completed canary publication')
}
if (!RELEASE_TAG_PATTERN.test(receipt.target) || !RELEASE_TAG_PATTERN.test(receipt.previousTarget)) {
throw new Error('canary receipt contains invalid release tags')
}
requireObjectId(receipt.targetSha, 'canary receipt target SHA')
requireObjectId(receipt.previousTargetSha, 'canary receipt previous target SHA')
requireObjectId(receipt.publishedHead, 'canary receipt published head')
requireObjectId(receipt.remoteHead, 'canary receipt remote head')
requireAbsolutePath(receipt.allowedSigners, 'canary receipt allowed-signers')
if (!/^[a-f0-9]{64}$/.test(receipt.allowedSignersSha256 || '')) {
throw new Error('canary receipt allowed signers digest is invalid')
}
requireIsoTimestamp(receipt.startedAt, 'canary receipt startedAt')
requireIsoTimestamp(receipt.completedAt, 'canary receipt completedAt')
const operatorContracts = normalizeOperatorContracts(receipt.operatorContracts, 'canary receipt operator contracts')
if (receipt.gatewayOpsEvidenceDir !== null) {
receipt.gatewayOpsEvidenceDir = requireAbsolutePath(receipt.gatewayOpsEvidenceDir, 'canary receipt gateway-ops-evidence-dir')
}
if ((operatorContracts.length > 0) !== Boolean(receipt.gatewayOpsEvidenceDir)) {
throw new Error('canary receipt operator contracts do not bind an evidence directory')
}
receipt.operatorContracts = operatorContracts
return receipt
}
function requireReceiptRelease (receipt, options, targetSha) {
if (receipt.target !== options.target || receipt.targetSha !== targetSha ||
receipt.repo !== options.repo || receipt.remote !== options.remote || receipt.branch !== options.branch) {
throw new Error('canary receipt does not bind this exact release and control repository')
}
}
async function requireReceiptTrust (receipt, options) {
if (receipt.allowedSigners !== options.allowedSigners) {
throw new Error('allowed signers path changed after canary publication')
}
const current = await sha256SafeFile(options.allowedSigners, MAX_RECEIPT_BYTES, 'allowed signers')
if (current !== receipt.allowedSignersSha256) {
throw new Error('allowed signers bytes changed after canary publication')
}
}
function requireReceiptOperatorReadiness (receipt, options) {
const invokedDirectory = options.gatewayOpsEvidenceDir || null
if (receipt.gatewayOpsEvidenceDir !== invokedDirectory) {
throw new Error('operator-readiness evidence directory changed after canary publication')
}
return normalizeOperatorContracts(receipt.operatorContracts, 'canary receipt operator contracts')
}
async function requireObservationInputHashes (session, options) {
if (session.allowedSigners !== options.allowedSigners || session.relays !== options.relays ||
session.channels !== options.channels || (options.knownHosts && session.knownHosts !== options.knownHosts)) {
throw new Error('observation trust or fleet input paths changed')
}
const current = {
knownHostsSha256: await sha256SafeFile(session.knownHosts, MAX_RECEIPT_BYTES, 'known hosts'),
relaysSha256: await sha256SafeFile(session.relays, MAX_EVIDENCE_BYTES, 'fleet inventory'),
channelsSha256: await sha256SafeFile(session.channels, MAX_RECEIPT_BYTES, 'fleet channels')
}
for (const [name, digest] of Object.entries(current)) {
if (session[name] !== digest) throw new Error(`observation input ${name} changed during the retained session`)
}
}
async function readObservationSession (file) {
const session = await readPrivateJson(file, MAX_RECEIPT_BYTES, 'observation session')
if (session.schema !== OBSERVATION_SCHEMA) throw new Error('observation session schema is invalid')
return session
}
function validateCompleteSession (session, options, targetSha, canaryReceiptPath) {
requireOnlyKeys('completed observation session', session, [
'schema', 'status', 'target', 'targetSha', 'repo', 'allowedSigners',
'relays', 'channels', 'knownHosts', 'canaryReceipt',
'canaryReceiptSha256', 'gatewayOpsEvidenceDir', 'operatorContracts',
'gatewayManifest', 'gatewayWindowState',
'rolloutEvidence', 'observerArgsSha256', 'knownHostsSha256',
'relaysSha256', 'channelsSha256', 'startedAt', 'completedAt', 'elapsedMs',
'observationWindowMs', 'windowStateSha256', 'rolloutEvidenceSha256'
])
if (session.status !== 'complete') throw new Error('stable requires a completed observation session')
if (session.target !== options.target || session.targetSha !== targetSha || session.repo !== options.repo ||
session.canaryReceipt !== canaryReceiptPath || session.gatewayManifest !== options.gatewayManifest ||
session.allowedSigners !== options.allowedSigners || session.relays !== options.relays ||
session.channels !== options.channels) {
throw new Error('completed observation session does not bind this stable transition')
}
requireIsoTimestamp(session.startedAt, 'session startedAt')
requireIsoTimestamp(session.completedAt, 'session completedAt')
if (!Number.isSafeInteger(session.observationWindowMs) || session.observationWindowMs < MIN_OBSERVATION_WINDOW_MS ||
!Number.isSafeInteger(session.elapsedMs) || session.elapsedMs < session.observationWindowMs ||
Date.parse(session.completedAt) - Date.parse(session.startedAt) !== session.elapsedMs) {
throw new Error('completed observation session did not span its manifest window')
}
for (const name of [
'canaryReceiptSha256', 'windowStateSha256', 'rolloutEvidenceSha256',
'knownHostsSha256', 'relaysSha256', 'channelsSha256'
]) {
if (!/^[a-f0-9]{64}$/.test(session[name] || '')) throw new Error(`session ${name} is invalid`)
}
if (!/^[a-f0-9]{64}$/.test(session.observerArgsSha256 || '')) throw new Error('session observerArgsSha256 is invalid')
requireAbsolutePath(session.gatewayWindowState, 'session gateway-window-state')
requireAbsolutePath(session.rolloutEvidence, 'session rollout-evidence')
requireAbsolutePath(session.knownHosts, 'session known-hosts')
requireAbsolutePath(session.relays, 'session relays')
requireAbsolutePath(session.channels, 'session channels')
if (session.gatewayOpsEvidenceDir !== null) {
requireAbsolutePath(session.gatewayOpsEvidenceDir, 'session gateway-ops-evidence-dir')
}
const operatorContracts = normalizeOperatorContracts(session.operatorContracts, 'session operator contracts')
if ((operatorContracts.length > 0) !== Boolean(session.gatewayOpsEvidenceDir)) {
throw new Error('session operator contracts do not bind an evidence directory')
}
}
function validateCompletedObservation (state, evidence, options, targetSha) {
if (state.schema !== WINDOW_STATE_SCHEMA || state.releaseTarget !== options.target ||
state.releaseSha !== targetSha || state.channel !== 'canary') {
throw new Error('observer window state does not bind the exact canary release')
}
if (!Number.isSafeInteger(state.observationWindowMs) || state.observationWindowMs < MIN_OBSERVATION_WINDOW_MS) {
throw new Error('observer window state weakens the minimum 24-hour window')
}
if (!evidence || evidence.schemaVersion !== 2 || evidence.status !== 'verified' ||
evidence.target?.tag !== options.target || evidence.target?.sha !== targetSha ||
evidence.target?.channel !== 'canary' || evidence.publicGateway?.window?.complete !== true) {
throw new Error('observer did not produce completed manifest-bound canary evidence')
}
return state.observationWindowMs
}
function buildObserverArgs (options, targetSha, statePath, evidencePath) {
const args = []
for (const [name, value] of [
['sample-interval-ms', options.sampleIntervalMs],
['max-runtime-ms', options.maxRuntimeMs]
]) {
if (value) args.push(`--${name}`, exactIntegerString(value, name))
}
args.push('--')
const forwarded = [
['target', options.target],
['target-sha', targetSha],
['channel', 'canary'],
['repo', options.repo],
['relays', options.relays],
['channels', options.channels],
['known-hosts', options.knownHosts],
['allowed-signers', options.allowedSigners],
['gateway-evidence', options.gatewayEvidence],
['gateway-manifest', options.gatewayManifest],
['gateway-window-state', statePath],
['evidence', evidencePath],
['ssh-command', options.sshCommand],
['ssh-key', options.sshKey],
['ssh-user', options.sshUser],
['remote-repo-dir', options.remoteRepoDir],
['service', options.service],
['api', options.api],
['timeout-ms', options.timeoutMs],
['interval-ms', options.intervalMs],
['ssh-timeout-ms', options.sshTimeoutMs]
]
for (const [name, value] of forwarded) {
if (value != null && value !== '') args.push(`--${name}`, String(value))
}
return args
}
async function runObserverProcess (args, { observer }) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [observer, ...args], { stdio: 'inherit', env: process.env })
child.once('error', reject)
child.once('exit', (code, signal) => {
if (signal) reject(new Error(`observer terminated by ${signal}`))
else resolve({ code: Number.isInteger(code) ? code : 1 })
})
})
}
function normalizeExitCode (result) {
if (Number.isInteger(result)) return result
if (result && Number.isInteger(result.code)) return result.code
throw new Error('observer runner returned an invalid exit result')
}
async function resolveTargetSha (repo, target) {
try {
const result = await execFileAsync(GIT_PROGRAM, [
'-C', repo, '--no-replace-objects', 'rev-parse', '--verify', `refs/tags/${target}^{commit}`
], {
encoding: 'utf8',
env: hardenedGitEnv(),
maxBuffer: 1024 * 1024,
timeout: 15_000
})
return requireObjectId(result.stdout.trim().toLowerCase(), `${target} commit`)
} catch (err) {
throw new Error(`cannot resolve exact release tag ${target}: ${safeError(err?.stderr || err)}`)
}
}
async function isCommitAncestor (repo, ancestor, descendant) {
try {
await execFileAsync(GIT_PROGRAM, [
'-C', repo, '--no-replace-objects', 'merge-base', '--is-ancestor', ancestor, descendant
], {
encoding: 'utf8',
env: hardenedGitEnv(),
maxBuffer: 1024 * 1024,
timeout: 15_000
})
return true
} catch (err) {
if (err?.code === 1) return false
throw new Error(`cannot prove recovery ancestry: ${safeError(err?.stderr || err)}`)
}
}
async function readChannels (file) {
const value = await readBoundedJson(file, MAX_RECEIPT_BYTES, 'fleet channels')
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('fleet channels must be a JSON object')
return value
}
function requireChannelTarget (channels, channel) {
const target = channels[channel]
if (!RELEASE_TAG_PATTERN.test(target || '')) throw new Error(`fleet channel ${channel} does not contain a release tag`)
return target
}
async function readPrivateJson (file, maxBytes, label) {
const snapshot = await statSafeFile(file, maxBytes, label, true)
return parseJsonObject(snapshot.buffer, label)
}
async function readBoundedJson (file, maxBytes, label) {
const snapshot = await statSafeFile(file, maxBytes, label, false)
return parseJsonObject(snapshot.buffer, label)
}
async function statSafeFile (file, maxBytes, label, privateMode) {
let handle
try {
if (typeof fsConstants.O_NOFOLLOW !== 'number') throw new Error('platform lacks O_NOFOLLOW')
handle = await open(file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW)
const before = await handle.stat({ bigint: true })
if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > BigInt(maxBytes)) {
throw new Error('must be a bounded single-link regular file')
}
if (privateMode && (before.mode & 0o077n) !== 0n) throw new Error('must not be group/world accessible')
const buffer = Buffer.allocUnsafe(Number(before.size))
let offset = 0
while (offset < buffer.length) {
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset)
if (bytesRead === 0) break
offset += bytesRead
}
const after = await handle.stat({ bigint: true })
if (offset !== buffer.length || !sameSnapshot(before, after)) throw new Error('changed while being read')
return { buffer, stat: after }
} catch (err) {
throw new Error(`${label} cannot be read safely: ${safeError(err)}`)
} finally {
await handle?.close().catch(() => {})
}
}
async function sha256SafeFile (file, maxBytes, label) {
const { buffer } = await statSafeFile(file, maxBytes, label, false)
return createHash('sha256').update(buffer).digest('hex')
}
async function createPrivateJson (file, value) {
const bytes = encodeJson(value)
let handle
try {
if (typeof fsConstants.O_NOFOLLOW !== 'number') throw new Error('platform lacks O_NOFOLLOW')
handle = await open(file,
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
0o600)
await handle.writeFile(bytes)
await handle.sync()
} catch (err) {
throw new Error(`cannot create private transition record ${file}: ${safeError(err)}`)
} finally {
await handle?.close().catch(() => {})
}
}
async function replacePrivateJson (file, value) {
await readPrivateJson(file, MAX_RECEIPT_BYTES, 'existing transition record')
const bytes = encodeJson(value)
const temp = path.join(path.dirname(file), `.${path.basename(file)}.tmp-${process.pid}-${randomUUID()}`)
let handle
try {
handle = await open(temp,
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
0o600)
await handle.writeFile(bytes)
await handle.sync()
await handle.close()
handle = null
await rename(temp, file)
} catch (err) {
throw new Error(`cannot update private transition record ${file}: ${safeError(err)}`)
} finally {
await handle?.close().catch(() => {})
await unlink(temp).catch(() => {})
}
}
async function requireMissing (file, label) {
try {
await lstat(file)
} catch (err) {
if (err?.code === 'ENOENT') return
throw new Error(`cannot inspect ${label}: ${safeError(err)}`)
}
throw new Error(`${label} must not already exist; use --resume only with this wrapper's observing session`)
}
function encodeJson (value) {
const bytes = Buffer.from(JSON.stringify(value, null, 2) + '\n')
if (bytes.length < 2 || bytes.length > MAX_RECEIPT_BYTES || bytes.includes(0)) {
throw new Error('transition record exceeds its closed size bound')
}
return bytes
}
function parseJsonObject (buffer, label) {
let value
try {
value = JSON.parse(buffer.toString('utf8'))
} catch {
throw new Error(`${label} is not valid JSON`)
}
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must contain a JSON object`)
return value
}
function sha256 (value) {
return createHash('sha256').update(value).digest('hex')
}
function requireOnlyKeys (label, value, allowed) {
const allowedSet = new Set(allowed)
for (const key of Object.keys(value)) {
if (!allowedSet.has(key)) throw new Error(`${label} has unsupported field ${key}`)
}
for (const key of allowed) {
if (!(key in value)) throw new Error(`${label} is missing field ${key}`)
}
}
function sameSnapshot (left, right) {
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode &&
left.nlink === right.nlink && left.size === right.size && left.mtimeNs === right.mtimeNs &&
left.ctimeNs === right.ctimeNs
}
function requireObjectId (value, label) {
const normalized = String(value || '').toLowerCase()
if (!OBJECT_ID_PATTERN.test(normalized)) throw new Error(`${label} must be an exact Git object ID`)
return normalized
}
function requireIsoTimestamp (value, label) {
if (typeof value !== 'string' || new Date(value).toISOString() !== value) throw new Error(`${label} must be a canonical ISO timestamp`)
return value
}
function requireAbsolutePath (value, label) {
if (typeof value !== 'string' || !path.isAbsolute(value) || value.length > 4096 || hasControlChars(value)) {
throw new Error(`--${label} must be a bounded absolute path`)
}
return path.resolve(value)
}
function isSafeRepoRelativePath (value) {
return typeof value === 'string' && value.length > 0 && value.length <= 4096 &&
!path.isAbsolute(value) && !hasControlChars(value) && !value.split('/').includes('..')
}
function exactIntegerString (value, label) {
const text = String(value)
if (!/^[1-9][0-9]*$/.test(text) || !Number.isSafeInteger(Number(text))) throw new Error(`--${label} must be a safe positive integer`)
return text
}
function nowIso (injected) {
const value = typeof injected.now === 'function' ? injected.now() : Date.now()
return new Date(value).toISOString()
}
function hasControlChars (value) {
for (const character of String(value)) {
const code = character.charCodeAt(0)
if (code <= 31 || code === 127) return true
}
return false
}
function hardenedGitEnv () {
const env = { ...process.env }
for (const name of Object.keys(env)) {
if (UNSAFE_GIT_ENV.has(name) || /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(name)) delete env[name]
}
return {
...env,
GIT_GRAFT_FILE: '/dev/null/hiverelay-disabled',
GIT_NO_REPLACE_OBJECTS: '1',
GIT_OPTIONAL_LOCKS: '0'
}
}
function parseArgs (argv) {
if (argv.length === 0) throw new Error('action is required')
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) return { help: true }
const action = argv[0]
if (action.startsWith('-')) throw new Error('action must precede options')
const out = { action }
const booleans = new Set(['publish', 'resume', 'help'])
const values = new Set([
'target', 'target-sha', 'repo', 'remote', 'branch', 'allowed-signers',
'relays', 'channels', 'receipt', 'canary-receipt', 'session', 'known-hosts',
'gateway-evidence', 'gateway-window-state', 'evidence', 'gateway-manifest',
'gateway-ops-evidence-dir',
'sample-interval-ms', 'max-runtime-ms', 'ssh-command', 'ssh-key', 'ssh-user',
'remote-repo-dir', 'service', 'api', 'timeout-ms', 'interval-ms',
'ssh-timeout-ms', 'authorize'
])
const seen = new Set()
for (let i = 1; i < argv.length; i++) {
const raw = argv[i]
if (raw === '-h') {
if (seen.has('help')) throw new Error('duplicate --help')
seen.add('help')
out.help = true
continue
}
if (!raw.startsWith('--')) throw new Error(`unexpected positional argument ${JSON.stringify(raw)}`)
const name = raw.slice(2)
if (!booleans.has(name) && !values.has(name)) throw new Error(`unknown option --${name}`)
if (seen.has(name)) throw new Error(`duplicate option --${name}`)
seen.add(name)
const key = camel(name)
if (booleans.has(name)) {
out[key] = true
continue
}
const value = argv[++i]
if (!value || value.startsWith('--')) throw new Error(`missing value for --${name}`)
out[key] = value
}
return out
}
function camel (value) {
return value.replace(/-([a-z])/g, (_, character) => character.toUpperCase())
}
function kebab (value) {
return value.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`)
}
function safeError (err) {
return String(err?.message || err || 'unknown error').replace(/[\r\n\0]/g, ' ').slice(0, 1000)
}