From 6e7b2ec0ba07a9afdedf5d21d54ca05a6d40abf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E7=85=9C?= <2082529121@qq.com> Date: Fri, 4 Sep 2026 10:26:48 +0800 Subject: [PATCH] test: bind order candidate 1.0.1 to exact demo stable --- .cool-saas-candidate.json | 10 +- bootstrap.js | 5 +- package.json | 7 +- src/saas/generated/order/models.ts | 6 +- src/saas/generated/order/module.ts | 8 +- src/saas/generated/order/saas-actions.ts | 12 +- .../order/saas-implementation.expected.json | 6 +- .../generated/order/saas-schema.lock.json | 24 +-- start-stable.js | 186 +++++++++++++++++ test/start-stable.test.cjs | 195 ++++++++++++++++++ 10 files changed, 422 insertions(+), 37 deletions(-) create mode 100644 start-stable.js create mode 100644 test/start-stable.test.cjs diff --git a/.cool-saas-candidate.json b/.cool-saas-candidate.json index 17cc509..82e8dec 100644 --- a/.cool-saas-candidate.json +++ b/.cool-saas-candidate.json @@ -1,8 +1,8 @@ { "moduleCode": "order", - "candidateVersion": "1.0.0-rc.1", - "buildRevision": 1, - "buildChecksum": "5cf032fdac2b0403fbcad8b44b326a29141ba556bbbb9710ac9329b5d7c031dd", - "schemaInputChecksum": "1b3eec01e5b04880166e20e8e6df70b79f227129ea9165e38cfbb925be3c2d23", - "branchName": "release/1.0.0-rc.1" + "candidateVersion": "1.0.1-rc.2", + "buildRevision": 2, + "buildChecksum": "e71d92cc440437054c77fef0dbee098c6ceb8700d9a7d828dcad86420f230f22", + "schemaInputChecksum": "34ed49d330f45bd9933b582bc9265cf0f4e2e2683833d0f39e38a357d138ab65", + "branchName": "release/1.0.1-rc.2" } diff --git a/bootstrap.js b/bootstrap.js index 8f9f4d2..bc51b4e 100644 --- a/bootstrap.js +++ b/bootstrap.js @@ -11,10 +11,11 @@ function loadGeneratedSaasModule() { return require('./dist/saas/generated').GeneratedSaasModule; } -async function start() { - await prepareSaasServiceHost({ +async function start(options = {}) { + const session = await prepareSaasServiceHost({ generated: loadGeneratedSaasModule(), }); + if (options.verifySession) await options.verifySession(session); requireExactReleaseEnvironment(); return Bootstrap.configure({ baseDir: path.join(__dirname, 'dist'), diff --git a/package.json b/package.json index 819cf99..ba9937c 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,16 @@ "build": "rimraf dist && mwtsc --cleanOutDir", "publish": "cool saas-model finalize", "start": "node bootstrap.js", + "start:stable": "node start-stable.js", + "test:stable": "node --test test/start-stable.test.cjs", "start:local": "cross-env NODE_ENV=local SAAS_RUNTIME_BOOTSTRAP_LOCAL_FALLBACK_ENABLED=true node bootstrap.js" }, "coolSaas": { "lifecycle": { "dev": "pnpm run build && pnpm run start:local", - "test": "pnpm run typecheck", - "build": "pnpm run build" + "test": "pnpm run typecheck && pnpm run test:stable", + "build": "pnpm run build", + "artifactDir": "dist" } }, "dependencies": { diff --git a/src/saas/generated/order/models.ts b/src/saas/generated/order/models.ts index 598bf5d..ffef33f 100644 --- a/src/saas/generated/order/models.ts +++ b/src/saas/generated/order/models.ts @@ -20,9 +20,9 @@ export class Orders extends SaasTableModel { static readonly definition = defineSaasTableModel({ "kind": "table", "moduleCode": "order", - "revision": 1, - "schemaVersion": 1, - "schemaInputChecksum": "1b3eec01e5b04880166e20e8e6df70b79f227129ea9165e38cfbb925be3c2d23", + "revision": 2, + "schemaVersion": 2, + "schemaInputChecksum": "34ed49d330f45bd9933b582bc9265cf0f4e2e2683833d0f39e38a357d138ab65", "tableCode": "orders", "fields": [ { diff --git a/src/saas/generated/order/module.ts b/src/saas/generated/order/module.ts index a585a97..aa86ded 100644 --- a/src/saas/generated/order/module.ts +++ b/src/saas/generated/order/module.ts @@ -4,9 +4,9 @@ import { defineGeneratedSaasModule } from '@cool-midway/module-runtime'; export const GeneratedSaasModule = defineGeneratedSaasModule({ "formatVersion": 2, "moduleCode": "order", - "revision": 1, - "schemaVersion": 1, - "schemaInputChecksum": "1b3eec01e5b04880166e20e8e6df70b79f227129ea9165e38cfbb925be3c2d23", - "runtimeContractChecksum": "3d025d1b634c413449eb91164fb1f859a388d8930d2de112980a606b591befce", + "revision": 2, + "schemaVersion": 2, + "schemaInputChecksum": "34ed49d330f45bd9933b582bc9265cf0f4e2e2683833d0f39e38a357d138ab65", + "runtimeContractChecksum": "ba026a37cab981d8bdc1ed59db977b7c10df5f0528eaed02202e1303dc3d1194", "implementationManifestChecksum": "f25647d2dd90e14d6226683d2ff67ba32132b2dfdeb861015805240a96d4635b" }); diff --git a/src/saas/generated/order/saas-actions.ts b/src/saas/generated/order/saas-actions.ts index 9c5e184..13f9a11 100644 --- a/src/saas/generated/order/saas-actions.ts +++ b/src/saas/generated/order/saas-actions.ts @@ -83,10 +83,10 @@ export const SaasActions = { "actionChecksum": "3978a7ba9a3e0f91842031fd323ec63b3816eef68cb8dc82ffad6d187de8b7ea", "outputChecksum": "fe644424ef6a9e462b56edc9f70f48bb218783c69469469d4310de48589a0df5", "contractVersion": 1, - "providerReleaseId": 38, + "providerReleaseId": 41, "consumerModuleCode": "order", "providerModuleCode": "demo", - "providerReleaseVersion": "1.0.0-rc.1" + "providerReleaseVersion": "1.0.1" }), GoodsReleaseStock: defineInjectedSaasAction({ "kind": "action", @@ -148,10 +148,10 @@ export const SaasActions = { "outputChecksum": "a632b9f89518c3c17861454b74173a4880d5e8482639d3ff7f761aee4d061bd9", "contractVersion": 1, "commandEventType": "saas.workflow.demo.goods.releaseStock", - "providerReleaseId": 38, + "providerReleaseId": 41, "consumerModuleCode": "order", "providerModuleCode": "demo", - "providerReleaseVersion": "1.0.0-rc.1", + "providerReleaseVersion": "1.0.1", "commandEventContractChecksum": "18a727b91cf9410c3b7ac14e3a8799dbbfa8937f84667bfe7e31e21e662396be" }), GoodsReserveStock: defineInjectedSaasAction({ @@ -208,10 +208,10 @@ export const SaasActions = { "outputChecksum": "545bd8b0bf6eb8670b21497236038187ee0f1df1544a318b16a399458fcb796e", "contractVersion": 1, "commandEventType": "saas.workflow.demo.goods.reserveStock", - "providerReleaseId": 38, + "providerReleaseId": 41, "consumerModuleCode": "order", "providerModuleCode": "demo", - "providerReleaseVersion": "1.0.0-rc.1", + "providerReleaseVersion": "1.0.1", "commandEventContractChecksum": "ffbecad3a3d63b8bf92fde701dfaa11c8502a2c5f9f6bbb89b755e214024c3ae" }) } diff --git a/src/saas/generated/order/saas-implementation.expected.json b/src/saas/generated/order/saas-implementation.expected.json index 0442cb7..a9b42a2 100644 --- a/src/saas/generated/order/saas-implementation.expected.json +++ b/src/saas/generated/order/saas-implementation.expected.json @@ -1,11 +1,11 @@ { "manifestVersion": 1, "moduleCode": "order", - "revision": 1, + "revision": 2, "schemaChecksum": null, - "schemaInputChecksum": "1b3eec01e5b04880166e20e8e6df70b79f227129ea9165e38cfbb925be3c2d23", + "schemaInputChecksum": "34ed49d330f45bd9933b582bc9265cf0f4e2e2683833d0f39e38a357d138ab65", "actionManifestChecksum": null, - "runtimeContractChecksum": "3d025d1b634c413449eb91164fb1f859a388d8930d2de112980a606b591befce", + "runtimeContractChecksum": "ba026a37cab981d8bdc1ed59db977b7c10df5f0528eaed02202e1303dc3d1194", "implementationManifestChecksum": "f25647d2dd90e14d6226683d2ff67ba32132b2dfdeb861015805240a96d4635b", "actions": [ "OrdersAdd", diff --git a/src/saas/generated/order/saas-schema.lock.json b/src/saas/generated/order/saas-schema.lock.json index 35237f5..c91d61d 100644 --- a/src/saas/generated/order/saas-schema.lock.json +++ b/src/saas/generated/order/saas-schema.lock.json @@ -1,13 +1,13 @@ { "formatVersion": 2, "moduleCode": "order", - "revision": 1, + "revision": 2, "releaseVersion": null, - "schemaVersion": 1, - "schemaInputChecksum": "1b3eec01e5b04880166e20e8e6df70b79f227129ea9165e38cfbb925be3c2d23", + "schemaVersion": 2, + "schemaInputChecksum": "34ed49d330f45bd9933b582bc9265cf0f4e2e2683833d0f39e38a357d138ab65", "schemaChecksum": null, "actionManifestChecksum": null, - "runtimeContractChecksum": "3d025d1b634c413449eb91164fb1f859a388d8930d2de112980a606b591befce", + "runtimeContractChecksum": "ba026a37cab981d8bdc1ed59db977b7c10df5f0528eaed02202e1303dc3d1194", "implementationManifestChecksum": "f25647d2dd90e14d6226683d2ff67ba32132b2dfdeb861015805240a96d4635b", "models": [ { @@ -2031,10 +2031,10 @@ "actionChecksum": "3978a7ba9a3e0f91842031fd323ec63b3816eef68cb8dc82ffad6d187de8b7ea", "outputChecksum": "fe644424ef6a9e462b56edc9f70f48bb218783c69469469d4310de48589a0df5", "contractVersion": 1, - "providerReleaseId": 38, + "providerReleaseId": 41, "consumerModuleCode": "order", "providerModuleCode": "demo", - "providerReleaseVersion": "1.0.0-rc.1" + "providerReleaseVersion": "1.0.1" } }, { @@ -2101,10 +2101,10 @@ "outputChecksum": "a632b9f89518c3c17861454b74173a4880d5e8482639d3ff7f761aee4d061bd9", "contractVersion": 1, "commandEventType": "saas.workflow.demo.goods.releaseStock", - "providerReleaseId": 38, + "providerReleaseId": 41, "consumerModuleCode": "order", "providerModuleCode": "demo", - "providerReleaseVersion": "1.0.0-rc.1", + "providerReleaseVersion": "1.0.1", "commandEventContractChecksum": "18a727b91cf9410c3b7ac14e3a8799dbbfa8937f84667bfe7e31e21e662396be" } }, @@ -2166,10 +2166,10 @@ "outputChecksum": "545bd8b0bf6eb8670b21497236038187ee0f1df1544a318b16a399458fcb796e", "contractVersion": 1, "commandEventType": "saas.workflow.demo.goods.reserveStock", - "providerReleaseId": 38, + "providerReleaseId": 41, "consumerModuleCode": "order", "providerModuleCode": "demo", - "providerReleaseVersion": "1.0.0-rc.1", + "providerReleaseVersion": "1.0.1", "commandEventContractChecksum": "ffbecad3a3d63b8bf92fde701dfaa11c8502a2c5f9f6bbb89b755e214024c3ae" } } @@ -2209,8 +2209,8 @@ }, "additionalProperties": false }, - "providerReleaseId": 38, - "providerReleaseVersion": "1.0.0-rc.1" + "providerReleaseId": 41, + "providerReleaseVersion": "1.0.1" } ], "crud": [ diff --git a/start-stable.js b/start-stable.js new file mode 100644 index 0000000..cabdaef --- /dev/null +++ b/start-stable.js @@ -0,0 +1,186 @@ +'use strict'; + +// Starts an already-built, frozen directory. There is intentionally no build, +// codegen, Git checkout, publish, or fallback-to-RC operation in this entrypoint. +const { createHash } = require('node:crypto'); +const { readdirSync, lstatSync, readFileSync } = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const SHA256 = /^[a-f0-9]{64}$/; +const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/; +const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +function ensure(condition, message) { + if (!condition) throw new Error(`Stable startup refused: ${message}`); +} + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`; + return JSON.stringify(value); +} + +function candidateLockChecksum(lock) { + ensure(Object.keys(lock || {}).sort().join(',') === 'branchName,buildChecksum,buildRevision,candidateVersion,moduleCode,schemaInputChecksum', 'invalid Candidate lock fields'); + return createHash('sha256').update(canonicalJson(lock), 'utf8').digest('hex'); +} + +function calculateCompiledArtifactDigest(projectDir) { + const root = path.resolve(projectDir, 'dist'); + ensure(lstatSync(root, { throwIfNoEntry: false })?.isDirectory(), 'dist does not exist; deploy the frozen build, do not rebuild during startup'); + const files = []; + function visit(directory) { + for (const name of readdirSync(directory)) { + const file = path.join(directory, name); + const stat = lstatSync(file); + ensure(!stat.isSymbolicLink(), 'compiled artifact must not contain symbolic links'); + if (stat.isDirectory()) visit(file); + else { + ensure(stat.isFile(), 'compiled artifact contains a non-file entry'); + files.push([path.relative(root, file).replace(/\\/g, '/'), file]); + } + } + } + visit(root); + ensure(files.length > 0, 'compiled artifact is empty'); + // Exact algorithm used by cool saas-model finalize with artifactDir: dist. + const hash = createHash('sha256').update('COOL_SAAS_ARTIFACT_DIRECTORY_V1\0', 'utf8'); + for (const [name, file] of files.sort(([left], [right]) => left.localeCompare(right))) { + const content = readFileSync(file); + hash.update(`${Buffer.byteLength(name, 'utf8')}:`, 'utf8'); + hash.update(name, 'utf8'); + hash.update(`:${content.length}:`, 'utf8'); + hash.update(content); + } + return `sha256:${hash.digest('hex')}`; +} + +function sourceCommit(projectDir, environment) { + const gitOptions = { cwd: projectDir, encoding: 'utf8', windowsHide: true, timeout: 30_000 }; + const root = spawnSync('git', ['rev-parse', '--show-toplevel'], gitOptions); + if (root.status === 0) { + ensure(path.resolve(root.stdout.trim()) === path.resolve(projectDir), 'project must be its own exact Git checkout'); + const status = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], gitOptions); + ensure(status.status === 0 && !status.stdout.trim(), 'Git checkout is dirty'); + const commit = spawnSync('git', ['rev-parse', 'HEAD'], gitOptions); + ensure(commit.status === 0 && COMMIT.test(commit.stdout.trim()), 'Git HEAD is unavailable'); + ensure(!environment.BUILD_SHA || environment.BUILD_SHA === commit.stdout.trim(), 'BUILD_SHA differs from Git HEAD'); + return commit.stdout.trim(); + } + ensure(!lstatSync(path.join(projectDir, '.git'), { throwIfNoEntry: false }), 'Git metadata exists but could not be verified'); + // Git-free deployment archives must carry the trusted orchestrator's commit. + // This is cross-checked against both the authoritative Frozen Artifact and + // its actual compiled bytes, never accepted as a digest substitute. + ensure(COMMIT.test(environment.BUILD_SHA || ''), 'Git-free deployments require an exact BUILD_SHA'); + return environment.BUILD_SHA; +} + +async function fetchReleaseEvidence(environment, fetcher = fetch) { + const releaseId = Number(environment.RPC_RELEASE_ID); + ensure(Number.isSafeInteger(releaseId) && releaseId > 0, 'RPC_RELEASE_ID must select a Stable release'); + const base = new URL(environment.SAAS_CONTROL_PLANE_URL || environment.COOL_SAAS_CONTROL_PLANE_URL || 'http://127.0.0.1:8080'); + const local = ['local', 'test'].includes(environment.NODE_ENV); + ensure(base.protocol === 'https:' || (local && base.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname)), 'use HTTPS (loopback HTTP is local/test only)'); + ensure(!base.username && !base.password, 'control-plane URL must not contain credentials'); + ensure(String(environment.COOL_SAAS_TOKEN || '').trim(), 'a short-lived CLI/CI Bearer credential is required to verify immutable release evidence'); + const url = new URL('/admin/saas/module_release/info', base); + url.searchParams.set('id', String(releaseId)); + const response = await fetcher(url, { + method: 'GET', headers: { Authorization: `Bearer ${environment.COOL_SAAS_TOKEN}` }, + redirect: 'error', signal: AbortSignal.timeout(10_000) + }); + ensure(response.ok, `control plane rejected release evidence (${response.status})`); + const body = await response.json(); + ensure(body?.code === 1000 && body.data, 'control plane did not return release evidence'); + return body.data; +} + +function verifyStableEvidence({ release, lock, generated, digest, commit, releaseId }) { + const build = release?.build; + const artifact = release?.frozenArtifact; + ensure(release?.id === Number(releaseId) && release.releaseType === 'stable' && release.status === 'published' && release.lifecycleVersion === 1, 'target is not a published lifecycle Stable release'); + ensure(STABLE.test(release.version || '') && release.serviceVersion === release.version, 'Stable version identity is invalid'); + ensure(build && artifact && Number(release.promotedFromReleaseId) === Number(artifact.rcReleaseId) && Number(release.frozenArtifactId) === Number(artifact.id), 'Stable does not reference its source RC frozen artifact'); + ensure(Number(build.id) === Number(release.buildSnapshotId) && Number(build.moduleId) === Number(release.moduleId) && Number(artifact.moduleId) === Number(release.moduleId), 'Build/module/artifact association differs'); + ensure(Number(build.revision) === Number(lock.buildRevision) && Number(release.buildRevision) === Number(lock.buildRevision) && Number(artifact.buildRevision) === Number(lock.buildRevision), 'Build Revision differs from the Candidate lock'); + ensure(SHA256.test(build.buildChecksum || '') && build.buildChecksum === lock.buildChecksum && build.schemaInputChecksum === lock.schemaInputChecksum, 'Build checksums differ from the Candidate lock'); + ensure(lock.candidateVersion === `${release.version}-rc.${lock.buildRevision}` && lock.branchName === `release/${lock.candidateVersion}` && artifact.sourceBranch === lock.branchName && artifact.tagName === `v${lock.candidateVersion}`, 'source Candidate branch/version/tag differs'); + ensure(candidateLockChecksum(lock) === artifact.generatedLockChecksum, 'generated Candidate lock checksum differs'); + ensure(COMMIT.test(artifact.sourceCommit || '') && artifact.sourceCommit === commit, 'source commit differs from the Frozen Artifact'); + ensure(/^sha256:[a-f0-9]{64}$/.test(artifact.artifactDigest || ''), 'Frozen Artifact has no compiled digest; old Git-only evidence cannot be upgraded or forged here'); + ensure(artifact.artifactDigest === digest, 'compiled artifact digest differs from the Frozen Artifact'); + ensure(SHA256.test(artifact.artifactIdentityChecksum || '') && SHA256.test(artifact.implementationManifestChecksum || ''), 'frozen implementation identity is incomplete'); + ensure(generated.moduleCode === lock.moduleCode && generated.revision === lock.buildRevision && generated.schemaInputChecksum === lock.schemaInputChecksum, 'compiled generated identity differs from the immutable Build'); + ensure(!generated.releaseVersion || generated.releaseVersion === release.version, 'generated runtime embeds a different Release version'); + ensure(!generated.implementationManifestChecksum || generated.implementationManifestChecksum === artifact.implementationManifestChecksum, 'compiled implementation checksum differs'); + const manifest = release.schemaSnapshot?.actionManifest; + const contracts = release.schemaSnapshot?.runtimeContracts; + ensure(manifest && contracts?.implementationManifestChecksum === artifact.implementationManifestChecksum && manifest.artifactDigest === artifact.artifactDigest, 'Stable release envelope differs from the frozen implementation/artifact'); + ensure(generated.runtimeContractChecksum === contracts.runtimeContractChecksum && SHA256.test(contracts.runtimeContractChecksum || ''), 'compiled Runtime contract checksum differs'); + ensure(generated.schemaVersion === release.schemaVersion && build.schemaVersion === release.schemaVersion, 'compiled Schema version differs'); + return { + SAAS_RUNTIME_MODE: 'production', RPC_REQUIRE_EXACT_RELEASE: 'true', + RPC_MODULE_CODE: lock.moduleCode, RPC_RELEASE_ID: String(release.id), + RPC_RELEASE_VERSION: release.version, RPC_SERVICE_VERSION: release.serviceVersion, + SAAS_BUILD_REVISION: String(build.revision), SAAS_BUILD_CHECKSUM: build.buildChecksum, + SAAS_SCHEMA_INPUT_CHECKSUM: build.schemaInputChecksum, + RPC_MIN_SCHEMA_VERSION: String(release.schemaVersion), RPC_MAX_SCHEMA_VERSION: String(release.schemaVersion), + RPC_SCHEMA_CHECKSUM: release.schemaChecksum, + RPC_ACTION_MANIFEST_CHECKSUM: release.schemaSnapshot.actionManifestChecksum, + RPC_RUNTIME_CONTRACT_CHECKSUM: contracts.runtimeContractChecksum, + RPC_IMPLEMENTATION_MANIFEST_CHECKSUM: artifact.implementationManifestChecksum, + RPC_ARTIFACT_DIGEST: artifact.artifactDigest, + RPC_ARTIFACT_IDENTITY_CHECKSUM: artifact.artifactIdentityChecksum, + BUILD_SHA: artifact.sourceCommit + }; +} + +function applyExactEnvironment(environment, exact) { + for (const [key, value] of Object.entries(exact)) { + ensure(value != null && String(value).length > 0, `authoritative field ${key} is missing`); + ensure(!environment[key] || environment[key] === String(value), `${key} conflicts with authoritative Stable evidence`); + environment[key] = String(value); + } +} + +function verifyBootstrapSession(session, exact) { + ensure(session?.moduleCode === exact.RPC_MODULE_CODE && session.revision === Number(exact.SAAS_BUILD_REVISION), 'bootstrap module/Revision differs'); + const mapping = { + releaseId: 'RPC_RELEASE_ID', releaseVersion: 'RPC_RELEASE_VERSION', serviceVersion: 'RPC_SERVICE_VERSION', + runtimeMode: 'SAAS_RUNTIME_MODE', buildRevision: 'SAAS_BUILD_REVISION', buildChecksum: 'SAAS_BUILD_CHECKSUM', + schemaInputChecksum: 'SAAS_SCHEMA_INPUT_CHECKSUM', implementationManifestChecksum: 'RPC_IMPLEMENTATION_MANIFEST_CHECKSUM', + artifactDigest: 'RPC_ARTIFACT_DIGEST', artifactIdentityChecksum: 'RPC_ARTIFACT_IDENTITY_CHECKSUM' + }; + for (const [field, key] of Object.entries(mapping)) ensure(String(session.release?.[field]) === exact[key], `authenticated bootstrap ${field} differs from Stable evidence`); +} + +async function startStable(options = {}) { + const projectDir = path.resolve(options.projectDir || __dirname); + const environment = options.environment || process.env; + const release = await fetchReleaseEvidence(environment, options.fetch); + const lock = JSON.parse(readFileSync(path.join(projectDir, '.cool-saas-candidate.json'), 'utf8')); + const digest = calculateCompiledArtifactDigest(projectDir); + const commit = sourceCommit(projectDir, environment); + // Do not execute generated JavaScript before its compiled digest is verified. + ensure(digest === release.frozenArtifact?.artifactDigest, 'compiled artifact digest differs from the Frozen Artifact'); + const generated = (options.loadGenerated || (() => require(path.join(projectDir, 'dist/saas/generated')).GeneratedSaasModule))(); + const exact = verifyStableEvidence({ release, lock, generated, digest, commit, releaseId: environment.RPC_RELEASE_ID }); + applyExactEnvironment(environment, exact); + environment.SAAS_CONTROL_PLANE_URL ||= environment.COOL_SAAS_CONTROL_PLANE_URL || 'http://127.0.0.1:8080'; + delete environment.COOL_SAAS_TOKEN; + delete environment.NODE_AUTH_TOKEN; + delete environment.npm_config__authToken; + const start = options.start || require(path.join(projectDir, 'bootstrap.js')).start; + return start({ verifySession: session => { + verifyBootstrapSession(session, exact); + ensure(calculateCompiledArtifactDigest(projectDir) === digest, 'compiled bytes changed during bootstrap'); + } }); +} + +if (require.main === module) startStable().catch(error => { + process.stderr.write(`${error?.message || error}\n`); + process.exitCode = 1; +}); + +module.exports = { startStable, verifyStableEvidence, verifyBootstrapSession, applyExactEnvironment, calculateCompiledArtifactDigest, candidateLockChecksum, fetchReleaseEvidence, sourceCommit }; diff --git a/test/start-stable.test.cjs b/test/start-stable.test.cjs new file mode 100644 index 0000000..685dcb1 --- /dev/null +++ b/test/start-stable.test.cjs @@ -0,0 +1,195 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { spawnSync } = require('node:child_process'); +const { createHash } = require('node:crypto'); +const { + startStable, verifyStableEvidence, verifyBootstrapSession, applyExactEnvironment, + calculateCompiledArtifactDigest, candidateLockChecksum, fetchReleaseEvidence, sourceCommit +} = require('../start-stable'); + +function fixture() { + const lock = { moduleCode: 'demo', candidateVersion: '1.0.1-rc.2', buildRevision: 2, buildChecksum: 'b'.repeat(64), schemaInputChecksum: 'c'.repeat(64), branchName: 'release/1.0.1-rc.2' }; + const generated = { moduleCode: 'demo', revision: 2, schemaVersion: 3, schemaInputChecksum: lock.schemaInputChecksum, implementationManifestChecksum: 'd'.repeat(64), runtimeContractChecksum: 'e'.repeat(64) }; + const commit = 'a'.repeat(40); + const digest = `sha256:${'f'.repeat(64)}`; + const release = { + id: 42, moduleId: 2, releaseType: 'stable', status: 'published', lifecycleVersion: 1, + version: '1.0.1', serviceVersion: '1.0.1', promotedFromReleaseId: 41, buildSnapshotId: 52, + buildRevision: 2, frozenArtifactId: 62, schemaVersion: 3, schemaChecksum: '3'.repeat(64), + build: { id: 52, moduleId: 2, revision: 2, schemaVersion: 3, buildChecksum: lock.buildChecksum, schemaInputChecksum: lock.schemaInputChecksum }, + frozenArtifact: { id: 62, rcReleaseId: 41, moduleId: 2, buildRevision: 2, sourceBranch: lock.branchName, tagName: `v${lock.candidateVersion}`, sourceCommit: commit, generatedLockChecksum: candidateLockChecksum(lock), artifactDigest: digest, artifactIdentityChecksum: '1'.repeat(64), implementationManifestChecksum: generated.implementationManifestChecksum }, + schemaSnapshot: { actionManifest: { artifactDigest: digest }, actionManifestChecksum: '2'.repeat(64), runtimeContracts: { implementationManifestChecksum: generated.implementationManifestChecksum, runtimeContractChecksum: generated.runtimeContractChecksum } } + }; + return { release, lock, generated, digest, commit, releaseId: 42 }; +} + +function sessionFor(exact) { + return { moduleCode: exact.RPC_MODULE_CODE, revision: Number(exact.SAAS_BUILD_REVISION), release: { + releaseId: Number(exact.RPC_RELEASE_ID), releaseVersion: exact.RPC_RELEASE_VERSION, serviceVersion: exact.RPC_SERVICE_VERSION, + runtimeMode: exact.SAAS_RUNTIME_MODE, buildRevision: Number(exact.SAAS_BUILD_REVISION), buildChecksum: exact.SAAS_BUILD_CHECKSUM, + schemaInputChecksum: exact.SAAS_SCHEMA_INPUT_CHECKSUM, implementationManifestChecksum: exact.RPC_IMPLEMENTATION_MANIFEST_CHECKSUM, + artifactDigest: exact.RPC_ARTIFACT_DIGEST, artifactIdentityChecksum: exact.RPC_ARTIFACT_IDENTITY_CHECKSUM + } }; +} + +test('Stable derives its own deployment envelope from the same frozen Candidate and Build', () => { + const input = fixture(); + const before = JSON.stringify(input); + const exact = verifyStableEvidence(input); + assert.equal(exact.RPC_RELEASE_ID, '42'); + assert.equal(exact.RPC_RELEASE_VERSION, '1.0.1'); + assert.equal(exact.SAAS_RUNTIME_MODE, 'production'); + assert.equal(exact.SAAS_BUILD_REVISION, '2'); + assert.equal(exact.BUILD_SHA, input.commit); + assert.equal(exact.RPC_ARTIFACT_DIGEST, input.digest); + assert.equal(JSON.stringify(input), before, 'must never rewrite the Candidate or frozen evidence'); + verifyBootstrapSession(sessionFor(exact), exact); +}); + +test('fail closed on RC targets, history-only artifacts, or any frozen identity mismatch', () => { + const edits = [ + data => { data.release.releaseType = 'rc'; }, + data => { data.release.status = 'retired'; }, + data => { data.release.version = '1.0.1-rc.2'; }, + data => { data.release.frozenArtifact.artifactDigest = null; }, + data => { data.release.frozenArtifact.rcReleaseId++; }, + data => { data.release.buildSnapshotId++; }, + data => { data.release.build.buildChecksum = '9'.repeat(64); }, + data => { data.release.buildRevision++; }, + data => { data.release.frozenArtifact.generatedLockChecksum = '9'.repeat(64); }, + data => { data.release.frozenArtifact.tagName = 'v9.0.0'; }, + data => { data.commit = '9'.repeat(40); }, + data => { data.digest = `sha256:${'9'.repeat(64)}`; }, + data => { data.generated.revision++; }, + data => { data.generated.releaseVersion = '1.0.1-rc.2'; }, + data => { data.generated.implementationManifestChecksum = '9'.repeat(64); }, + data => { data.generated.runtimeContractChecksum = '9'.repeat(64); }, + data => { data.generated.schemaVersion++; }, + data => { data.release.schemaSnapshot.actionManifest.artifactDigest = `sha256:${'9'.repeat(64)}`; } + ]; + for (const edit of edits) { + const input = fixture(); + edit(input); + assert.throws(() => verifyStableEvidence(input), /Stable startup refused/); + } +}); + +test('environment and authenticated bootstrap cannot silently replace Stable with RC or a different Build', () => { + const exact = verifyStableEvidence(fixture()); + assert.throws(() => applyExactEnvironment({ RPC_RELEASE_ID: '41' }, exact), /conflicts/); + const environment = {}; + applyExactEnvironment(environment, exact); + assert.equal(environment.RPC_ARTIFACT_DIGEST, exact.RPC_ARTIFACT_DIGEST); + for (const key of ['releaseId', 'releaseVersion', 'buildChecksum', 'runtimeMode', 'artifactDigest', 'artifactIdentityChecksum']) { + const session = sessionFor(exact); + session.release[key] = 'incorrect'; + assert.throws(() => verifyBootstrapSession(session, exact), /differs/); + } +}); + +test('compiled digest uses the exact finalize byte format and detects changes', t => { + const root = mkdtempSync(path.join(os.tmpdir(), 'stable-digest-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(path.join(root, 'dist/nested'), { recursive: true }); + writeFileSync(path.join(root, 'dist/a.js'), 'first'); + writeFileSync(path.join(root, 'dist/nested/b.js'), 'second'); + const exactHash = createHash('sha256').update('COOL_SAAS_ARTIFACT_DIRECTORY_V1\0', 'utf8'); + for (const [name, content] of [['a.js', 'first'], ['nested/b.js', 'second']]) { + exactHash.update(`${Buffer.byteLength(name)}:`).update(name).update(`:${Buffer.byteLength(content)}:`).update(content); + } + assert.equal(calculateCompiledArtifactDigest(root), `sha256:${exactHash.digest('hex')}`); + const oldDigest = calculateCompiledArtifactDigest(root); + writeFileSync(path.join(root, 'dist/a.js'), 'changed'); + assert.notEqual(calculateCompiledArtifactDigest(root), oldDigest); +}); + +test('missing, empty, and linked compiled directories are refused', t => { + const root = mkdtempSync(path.join(os.tmpdir(), 'stable-linked-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + assert.throws(() => calculateCompiledArtifactDigest(root), /does not exist/); + mkdirSync(path.join(root, 'dist')); + assert.throws(() => calculateCompiledArtifactDigest(root), /empty/); + mkdirSync(path.join(root, 'outside')); + writeFileSync(path.join(root, 'outside/escaped.js'), 'outside the frozen directory'); + symlinkSync(path.join(root, 'outside'), path.join(root, 'dist/linked'), 'junction'); + assert.throws(() => calculateCompiledArtifactDigest(root), /symbolic links/); +}); + +test('control-plane evidence requires a Bearer credential and local HTTP cannot target a remote host', async () => { + const environment = { NODE_ENV: 'local', RPC_RELEASE_ID: '42', SAAS_CONTROL_PLANE_URL: 'http://127.0.0.1:8080', COOL_SAAS_TOKEN: 'test-short-lived-credential' }; + const release = fixture().release; + assert.equal(await fetchReleaseEvidence(environment, async (url, options) => { + assert.equal(url.searchParams.get('id'), '42'); + assert.equal(options.headers.Authorization, `Bearer ${environment.COOL_SAAS_TOKEN}`); + assert.equal(options.redirect, 'error'); + return { ok: true, json: async () => ({ code: 1000, data: release }) }; + }), release); + await assert.rejects(fetchReleaseEvidence({ ...environment, COOL_SAAS_TOKEN: '' }), /credential/); + await assert.rejects(fetchReleaseEvidence({ ...environment, SAAS_CONTROL_PLANE_URL: 'http://example.com' }), /HTTPS/); + await assert.rejects(fetchReleaseEvidence(environment, async () => ({ ok: false, status: 401 })), /rejected/); +}); + +test('actual startup verifies existing bytes, removes CLI secrets, and never invokes a build', async t => { + const root = mkdtempSync(path.join(os.tmpdir(), 'stable-start-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(path.join(root, 'dist'), { recursive: true }); + writeFileSync(path.join(root, 'dist/configuration.js'), 'frozen compiled bytes'); + const data = fixture(); + data.digest = calculateCompiledArtifactDigest(root); + data.release.frozenArtifact.artifactDigest = data.digest; + data.release.schemaSnapshot.actionManifest.artifactDigest = data.digest; + writeFileSync(path.join(root, '.cool-saas-candidate.json'), JSON.stringify(data.lock)); + const environment = { NODE_ENV: 'local', RPC_RELEASE_ID: '42', BUILD_SHA: data.commit, COOL_SAAS_TOKEN: 'short-lived-test', NODE_AUTH_TOKEN: 'must-not-inherit' }; + let starts = 0; + const options = { + projectDir: root, environment, + fetch: async () => ({ ok: true, json: async () => ({ code: 1000, data: data.release }) }), + loadGenerated: () => data.generated, + start: ({ verifySession }) => { + starts++; + assert.equal(environment.COOL_SAAS_TOKEN, undefined); + assert.equal(environment.NODE_AUTH_TOKEN, undefined); + verifySession(sessionFor(environment)); + return 'started'; + } + }; + assert.equal(await startStable(options), 'started'); + assert.equal(starts, 1); + assert.equal(readFileSync(path.join(root, 'dist/configuration.js'), 'utf8'), 'frozen compiled bytes'); + writeFileSync(path.join(root, 'dist/configuration.js'), 'tampered'); + environment.COOL_SAAS_TOKEN = 'short-lived-test'; + options.loadGenerated = () => { throw new Error('must not execute unverified compiled code'); }; + await assert.rejects(startStable(options), /compiled artifact digest differs/); + assert.equal(starts, 1); + writeFileSync(path.join(root, 'dist/configuration.js'), 'frozen compiled bytes'); + environment.COOL_SAAS_TOKEN = 'short-lived-test'; + await assert.rejects(startStable({ + ...options, + loadGenerated: () => data.generated, + start: ({ verifySession }) => { + writeFileSync(path.join(root, 'dist/configuration.js'), 'changed during bootstrap'); + verifySession(sessionFor(environment)); + } + }), /changed during bootstrap/); +}); + +test('dirty Git checkouts cannot be presented as the frozen source commit', t => { + const root = mkdtempSync(path.join(os.tmpdir(), 'stable-git-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const git = (...args) => { + const result = spawnSync('git', args, { cwd: root, encoding: 'utf8', windowsHide: true }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + git('init'); git('config', 'user.email', 'test@example.invalid'); git('config', 'user.name', 'Stable Test'); + writeFileSync(path.join(root, 'business.js'), 'original'); + git('add', '.'); git('commit', '-m', 'frozen'); + assert.equal(sourceCommit(root, {}), git('rev-parse', 'HEAD')); + writeFileSync(path.join(root, 'business.js'), 'modified'); + assert.throws(() => sourceCommit(root, {}), /dirty/); +});