From 8e4c9d6a6ffd7b66ecadab00ba94e9fd068edf10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E7=85=9C?= <2082529121@qq.com> Date: Fri, 11 Sep 2026 12:11:23 +0800 Subject: [PATCH] feat(saas): enforce signed release evidence and verified deployments --- README.md | 48 +++--- RELEASE-V2.md | 23 +++ package.json | 7 +- pnpm-lock.yaml | 11 +- release-artifact.js | 270 ++++++++++++++++++++++++++++++++ scripts/published-packages.json | 172 +++++++++++--------- start-frozen.js | 10 ++ start-stable.js | 235 +++++++++++++++++++++++++++ test/start-stable.test.cjs | 249 +++++++++++++++++++++++++++++ 9 files changed, 921 insertions(+), 104 deletions(-) create mode 100644 RELEASE-V2.md create mode 100644 release-artifact.js create mode 100644 start-frozen.js create mode 100644 start-stable.js create mode 100644 test/start-stable.test.cjs diff --git a/README.md b/README.md index 8a91b6b..2b8200d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +本项目固定使用私服发行的 Core 8.1.0、Module Runtime 9.0.4 和 RPC 9.0.7;精确依赖、锁文件与逐文件校验基线一并提交。 + +V2 发布与受控开发监听以 [RELEASE-V2.md](RELEASE-V2.md) 为准;历史 V1 操作说明不适用于新 V2 候选。 + # COOL SaaS 模块微服务起始项目 这是一个可复制到 `cool-midway-biz/microservices/` 的干净起始项目。 @@ -54,36 +58,42 @@ pnpm build 生成后可随时执行 `pnpm exec cool saas-model verify` 检查当前分支、候选锁、生成文件与 控制面的精确 Candidate 是否一致。`pnpm dev` 会先执行同一校验,再调用 `package.json` 中 `coolSaas.lifecycle.dev` 的非递归命令构建并启动原有开发宿主;因此 -配置里不要写回 `pnpm dev`。项目接入自己的 watch/reload 工具后,可将该命令替换为对应 -的开发启动命令。 +配置里不要写回 `pnpm dev`。持续开发使用 `pnpm exec cool saas-model dev --watch`,由外层 +CLI 捕获源码身份、构建独立快照、排空旧实例并发布新代次。不要用仅重启内层宿主的 watch +替代这个入口;依赖或候选锁变化需要结束当前 watch 后重新启动。 CLI 生成的 `models.ts`、`contracts.ts`、`saas-actions.ts`、`saas-events.ts`、 `module.ts`、`index.ts` 和锁文件属于同一个不可变 Build,请整体保留且不要手工修改。 ## 固化 Candidate -开发联调完成后,先提交并将当前 release 分支推送到 `origin`,然后执行: +开发联调完成后,停止开发 watch,提交并推送当前候选分支,然后使用外部 builder 配置: ```powershell -pnpm run publish +pnpm run publish --builder-config <外部绝对配置路径> ``` -该脚本只调用 `cool saas-model finalize`。CLI 从 `package.json` 的 `coolSaas.lifecycle` -读取并依次执行 `test` 与 `build`(本模板分别为 typecheck 和原有 build),确认工作区仍然 -干净且当前提交已推送后,创建并推送 `v` Tag,最后向控制面幂等提交 -freeze 证据。生命周期子命令不得调用 `pnpm dev`、`pnpm publish` 或再次调用 -`cool saas-model dev/finalize`,以免递归。 +`publish` 脚本执行 `cool saas-model finalize`,不会发布 npm 包或直接晋升 Stable。 +Core 8.1.0 先领取平台签名构建任务,从已推送的 Git 提交提取独立源码目录,复制并核对实际 +依赖,再按 `coolSaas.lifecycle` 执行测试和构建。源码、启动脚本、依赖在构建后发生变化 +都会拒绝固化。CLI 签名保存完整 bundle、推送版本 Tag,并幂等提交平台 freeze。 +生命周期子命令不能再次调用 `pnpm dev`、`pnpm publish` 或 `cool saas-model dev/finalize`。 -freeze 成功后,原来的 development 实例会因身份不再匹配而停止参与路由。再次执行 -`pnpm dev`,CLI 会读取候选的 Frozen Artifact,只允许从完全一致且干净的 Git commit -以 `runtimeMode=frozen` 启动。请用这个固化实例完成最终验收;验收通过后才能在管理端 -晋升 Stable。`accepted` 状态也可以用同一命令重启精确固化实例,但已晋升、拒绝、替代 -或放弃的候选会被拒绝。 +保存 CLI 输出的 frozen deployment directory 及其外部构建回执。目录内包含已经构建的 +`dist`、启动器和独立依赖;不要重新安装、构建或修改其中内容。freeze 后原 development +身份不能继续参与路由,V2 候选也不能再用 `pnpm dev` 重建。配置精确候选 Release ID、 +BUILD_SHA、独立 Runtime 票据/状态、租户凭证、外部 builder 公钥信任文件和短期制品读取凭证, +从该目录执行 `pnpm run start:frozen`,再由平台的专用 runner 执行自动验收。 -控制面地址与凭证继续使用 `COOL_SAAS_CONTROL_PLANE_URL`、`COOL_SAAS_TOKEN`,或 CLI 的 -`--endpoint`、`--token` 参数。CLI token 只用于校验/freeze 请求,不会写入候选锁,也 -不会传给测试、构建或开发宿主子进程。freeze 调用 -`/internal/saas/module_release/freeze`,只发送 Bearer 凭证,不依赖浏览器 Cookie。 +管理员查看不可变报告,选择有效的 run 单独批准,随后晋升 Stable。Stable 使用同一份 +deployment directory 与独立运行身份执行 `pnpm run start:stable`。已晋升来源 RC 如需继续 +运行,也必须保留原制品和有效关联;拒绝、放弃、撤销或关联失效的候选不能重新启动。 +完整配置、回执恢复和验收顺序见 [RELEASE-V2.md](RELEASE-V2.md)。 + +控制面地址与凭证使用 `COOL_SAAS_CONTROL_PLANE_URL`、`COOL_SAAS_TOKEN`,或 CLI 的 +`--endpoint`、`--token` 参数。CLI token 不写入候选锁,也不传给测试、构建或开发宿主 +子进程。freeze 调用 `/internal/saas/module_release/freeze`,使用 Bearer 凭证和受限的二进制 +请求体传输完整 bundle,不依赖浏览器 Cookie。 ## 依赖与发布 @@ -116,7 +126,7 @@ SAAS_CONTROL_PLANE_URL=https://control-plane.example.com 生产环境由受信部署编排器在应用配置之外完成以下工作: - 签发绑定精确 Build/Release 的 bootstrap ticket,并注入 `BUILD_SHA`、Redis 和 - RPC HMAC 材料;Release 身份及摘要由控制面权威响应回填,不要求手工填写。 + 每个进程独立的 Ed25519 私钥及受限公钥信任文件;Release 身份及摘要由控制面权威响应回填。 - 为每个副本分别挂载短期、一次性且绑定模块/Build/Release/制品的 `SAAS_RUNTIME_BOOTSTRAP_TOKEN_FILE`,不能让两个副本共用一个票据。票据签发结果会 同时返回并绑定该副本的 `instanceId`。 diff --git a/RELEASE-V2.md b/RELEASE-V2.md new file mode 100644 index 0000000..f6c099a --- /dev/null +++ b/RELEASE-V2.md @@ -0,0 +1,23 @@ +# V2 开发、固化与验收 + +本项目固定使用私服发行的 Core 8.1.0、Module Runtime 9.0.4 和 RPC 9.0.7;精确依赖、锁文件与逐文件校验基线一并提交。 + +配套控制面使用 SaaS 9.2.0 的签名构建、自动验收、独立批准与证据撤销接口。 + +健康的 locked RC 仍可直接推送 development/test 租户。正式发布采用下面的顺序。 + +1. 在平台锁定 Build,用 CLI generate 获取精确候选分支及生成文件。 +2. 使用 `pnpm exec cool saas-model dev --watch` 开发。只替换本进程拥有的 generation;构建错误、依赖变化、平台停用不会让旧身份运行新代码。 +3. 结束开发,停止 watch,提交并推送候选分支。 +4. 使用 `pnpm exec cool saas-model finalize --builder-config <外部绝对配置路径>`。配置必须提供独立 Ed25519 builder 身份、平台任务公钥及外部 artifactRoot。CLI 从 Git 提交提取源码、独立复制实际 node_modules,然后测试和构建;源码、启动脚本及依赖在构建后被改写则拒绝固化。 +5. 保存 CLI 输出的 frozen deployment directory。平台保留已签名的 bundle,包含 dist、启动器、候选锁、依赖锁,以及每个已安装依赖文件和链接目标的清单。不要在部署时再次安装依赖或构建。 +6. 在这份目录设置精确 Release ID、BUILD_SHA、独立 Runtime state/ticket/租户凭证、`SAAS_RELEASE_TRUST_FILE` 外部 builder 公钥信任文件与短期制品读取凭证,运行 `pnpm start:frozen`。任何文件与签名制品不一致都必须先处理,再启动。 +7. 平台创建验收任务;专用 runner 执行固定的授权、业务、进程恢复套件,签名提交不可变报告。正常角色账号必须预先准备;中途改菜单、权限、路由、数据库连接或安装代次会使本次验收失效,恢复旧值也不能消除变化记录。 +8. 管理员查看报告,选择有效 run 单独批准,然后晋升 Stable。浏览器人工填写通过结论不能替代自动报告。 +9. 使用同一份 frozen deployment directory,切换为 Stable 的独立运行身份并运行 `pnpm start:stable`。启动器读取当前控制面证据及撤销状态,复核可信 builder 签名和完整本地字节,再验证 Runtime bootstrap 的精确身份。 + +响应丢失时,保留 build-task.json、freeze-submission.json 与 deployment。使用 `finalize --resume-submission <原提交文件的外部绝对路径> --builder-config <原配置>` 重传完全相同的固化请求;它不重新构建、签名或申请任务。只有原任务、源码、远端 Tag、签名和字节均一致时才允许重试。 + +task issuer、builder、runner 使用不同密钥。私钥和控制面凭据不得进入 Git、npm 或部署 bundle。执行器及其启动入口属于信任边界;独立构建目录不等同于操作系统沙箱。历史 V1 记录只走显式兼容流程,不能伪造为 V2 自动验收证据。 + +本项目的启动器测试属于本地校验;真实租户业务验收由平台的独立 runner 执行并保存报告。 diff --git a/package.json b/package.json index 4045c97..676421f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "publish": "cool saas-model finalize", "start": "node bootstrap.js", "start:local": "cross-env NODE_ENV=local SAAS_RUNTIME_BOOTSTRAP_LOCAL_FALLBACK_ENABLED=true node bootstrap.js", - "verify:packages": "node scripts/verify-local-packages.cjs" + "verify:packages": "node scripts/verify-local-packages.cjs", + "start:frozen": "node start-frozen.js", + "start:stable": "node start-stable.js", + "test:stable": "node --test test/start-stable.test.cjs" }, "coolSaas": { "lifecycle": { @@ -33,7 +36,7 @@ "typeorm": "npm:@cool-midway/typeorm@0.3.20" }, "devDependencies": { - "@cool-midway/core": "8.0.12", + "@cool-midway/core": "8.1.0", "@types/node": "22.10.7", "cross-env": "^7.0.3", "mwtsc": "^1.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2a87ef..8b5b3c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,8 +40,8 @@ importers: version: '@cool-midway/typeorm@0.3.20(ioredis@5.11.1)(mysql2@3.24.3(@types/node@22.10.7))' devDependencies: '@cool-midway/core': - specifier: 8.0.12 - version: 8.0.12(debug@4.4.3) + specifier: 8.1.0 + version: 8.1.0(debug@4.4.3) '@types/node': specifier: 22.10.7 version: 22.10.7 @@ -68,8 +68,8 @@ packages: resolution: {integrity: sha512-Bl43bVCUm0V+w1wkfOicRfiHHZtaH1mQCp06baNapG86uC7OiXwMrml5+cvZD+mYSHdP07qUxtqkL8oUhrgAfg==} engines: {node: '>=8.0.0'} - '@cool-midway/core@8.0.12': - resolution: {integrity: sha512-PZZ50p2xjtU2AoaOjrDCh/cigDHvxsXvD6NzA80F74D1JEc6zbWzA/uJSW5AG7Vgqz6bOlA3C4knwk6czdlz2w==} + '@cool-midway/core@8.1.0': + resolution: {integrity: sha512-FrkiZE4/QtTAePAaHh2pX5gEhz5r1VP9OrYX7El+Z6OJi6tvfak6kl9PBhUw/5Kln6/T+c3RE3QRXeEBAtA4ow==} hasBin: true '@cool-midway/module-runtime@9.0.4': @@ -588,6 +588,7 @@ packages: charm@0.1.2: resolution: {integrity: sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} @@ -2469,7 +2470,7 @@ snapshots: dependencies: lockfile: 1.0.4 - '@cool-midway/core@8.0.12(debug@4.4.3)': + '@cool-midway/core@8.1.0(debug@4.4.3)': dependencies: '@cool-midway/cache-manager-fs-hash': 7.0.0 '@midwayjs/cache-manager': 3.20.24 diff --git a/release-artifact.js b/release-artifact.js new file mode 100644 index 0000000..62df91c --- /dev/null +++ b/release-artifact.js @@ -0,0 +1,270 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.releaseCanonicalJson = releaseCanonicalJson; +exports.releaseJsonChecksum = releaseJsonChecksum; +exports.assertReleaseArtifactPath = assertReleaseArtifactPath; +exports.compiledArtifactDigestFromEntries = compiledArtifactDigestFromEntries; +exports.calculateReleaseCompiledDigest = calculateReleaseCompiledDigest; +exports.captureReleaseDependencies = captureReleaseDependencies; +exports.createReleaseArtifactBundle = createReleaseArtifactBundle; +exports.inspectReleaseArtifactBundle = inspectReleaseArtifactBundle; +exports.verifyLocalReleaseArtifact = verifyLocalReleaseArtifact; +exports.signReleaseEvidence = signReleaseEvidence; +exports.verifyReleaseEvidence = verifyReleaseEvidence; +exports.validateReleaseDependencyManifest = validateReleaseDependencyManifest; +exports.captureReleaseSourceInputs = captureReleaseSourceInputs; +exports.materializeCommittedReleaseSource = materializeCommittedReleaseSource; +const fs_1 = require("fs"); +const path_1 = require("path"); +const child_process_1 = require("child_process"); +const crypto_1 = require("crypto"); +const MAX_BUNDLE_BYTES = 64 * 1024 * 1024; +const MAX_FILES = 10000; +const MAX_DEPENDENCIES = 150000; +const SHA = /^[a-f0-9]{64}$/; +const SIGNATURE_DOMAINS = { + build: 'COOL_SAAS_RELEASE_BUILD_V2\0', + task: 'COOL_SAAS_RELEASE_TASK_V2\0', + run: 'COOL_SAAS_RELEASE_RUN_V2\0', + claim: 'COOL_SAAS_RELEASE_CLAIM_V2\0', +}; +function ensure(value, message) { + if (!value) + throw new Error(`Release artifact refused: ${message}`); +} +function releaseCanonicalJson(value) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return JSON.stringify(value); + if (typeof value === 'number') { + ensure(Number.isFinite(value) && !Object.is(value, -0), 'invalid JSON number'); + return JSON.stringify(value); + } + if (Array.isArray(value)) + return `[${value.map(releaseCanonicalJson).join(',')}]`; + ensure(value && typeof value === 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(value)), 'invalid JSON value'); + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${releaseCanonicalJson(value[key])}`).join(',')}}`; +} +function releaseJsonChecksum(value) { + return (0, crypto_1.createHash)('sha256').update(releaseCanonicalJson(value), 'utf8').digest('hex'); +} +function assertReleaseArtifactPath(name) { + ensure(typeof name === 'string' && name.length > 0 && name.length <= 1024 && !/[\\:\x00-\x1f]/.test(name), 'invalid relative artifact path'); + ensure(!name.startsWith('/') && name.split('/').every(part => part && part !== '.' && part !== '..' && !/[. ]$/.test(part) && !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)), 'unsafe relative artifact path'); + return name; +} +function inside(root, target) { + const name = (0, path_1.relative)(root, target); + return name === '' || (!(0, path_1.isAbsolute)(name) && name !== '..' && !name.startsWith(`..${path_1.sep}`)); +} +function regularDirectoryFiles(root) { + var _a; + ensure(((_a = (0, fs_1.lstatSync)(root, { throwIfNoEntry: false })) === null || _a === void 0 ? void 0 : _a.isDirectory()) && !(0, fs_1.lstatSync)(root).isSymbolicLink(), 'artifact directory is missing or linked'); + const files = []; + const visit = (directory, depth = 0) => { + ensure(depth <= 64, 'artifact directory nesting is too deep'); + for (const name of (0, fs_1.readdirSync)(directory).sort()) { + const absolute = (0, path_1.resolve)(directory, name); + const stat = (0, fs_1.lstatSync)(absolute); + ensure(!stat.isSymbolicLink(), 'artifact must not contain symbolic links'); + if (stat.isDirectory()) + visit(absolute, depth + 1); + else { + ensure(stat.isFile(), 'artifact contains a non-file entry'); + files.push({ path: assertReleaseArtifactPath((0, path_1.relative)(root, absolute).replace(/\\/g, '/')), absolute }); + ensure(files.length <= MAX_FILES, 'too many artifact files'); + } + } + }; + visit(root); + ensure(files.length > 0, 'compiled artifact is empty'); + return files.sort((left, right) => left.path.localeCompare(right.path)); +} +function compiledArtifactDigestFromEntries(entries) { + ensure(entries.length > 0, 'compiled artifact is empty'); + const hash = (0, crypto_1.createHash)('sha256').update('COOL_SAAS_ARTIFACT_DIRECTORY_V1\0', 'utf8'); + const seen = new Set(); + for (const { path, bytes } of [...entries].sort((left, right) => left.path.localeCompare(right.path))) { + assertReleaseArtifactPath(path); + ensure(!seen.has(path.toLowerCase()), 'duplicate or case-colliding compiled path'); + seen.add(path.toLowerCase()); + hash.update(`${Buffer.byteLength(path, 'utf8')}:`, 'utf8').update(path, 'utf8').update(`:${bytes.length}:`, 'utf8').update(bytes); + } + return `sha256:${hash.digest('hex')}`; +} +function calculateReleaseCompiledDigest(artifactDir) { + return compiledArtifactDigestFromEntries(regularDirectoryFiles((0, path_1.resolve)(artifactDir)).map(file => ({ path: file.path, bytes: (0, fs_1.readFileSync)(file.absolute) }))); +} +/** Captures the installed dependency bytes, including pnpm's physical store. + * Links must resolve inside node_modules. Absolute OS link text is normalized + * to its target relative to that root so copying the same tree is portable. */ +function captureReleaseDependencies(projectDir) { + const root = (0, path_1.resolve)(projectDir, 'node_modules'); + const rootStat = (0, fs_1.lstatSync)(root, { throwIfNoEntry: false }); + ensure((rootStat === null || rootStat === void 0 ? void 0 : rootStat.isDirectory()) && !rootStat.isSymbolicLink(), 'node_modules must be an independent local directory'); + const actualRoot = (0, fs_1.realpathSync)(root); + const entries = []; + const visit = (directory, depth = 0) => { + ensure(depth <= 64, 'dependency nesting is too deep'); + for (const name of (0, fs_1.readdirSync)(directory).sort()) { + const absolute = (0, path_1.resolve)(directory, name); + const entryPath = assertReleaseArtifactPath((0, path_1.relative)(root, absolute).replace(/\\/g, '/')); + const stat = (0, fs_1.lstatSync)(absolute); + if (stat.isSymbolicLink()) { + const target = (0, fs_1.realpathSync)(absolute); + ensure(inside(actualRoot, target) && target !== actualRoot, 'dependency link escapes node_modules'); + entries.push({ path: entryPath, type: 'link', target: assertReleaseArtifactPath((0, path_1.relative)(actualRoot, target).replace(/\\/g, '/')) }); + } + else if (stat.isDirectory()) + visit(absolute, depth + 1); + else { + ensure(stat.isFile() && stat.size <= 256 * 1024 * 1024, 'invalid or oversized dependency file'); + const bytes = (0, fs_1.readFileSync)(absolute); + entries.push({ path: entryPath, type: 'file', size: bytes.length, sha256: (0, crypto_1.createHash)('sha256').update(bytes).digest('hex') }); + } + ensure(entries.length <= MAX_DEPENDENCIES, 'too many dependency entries'); + } + }; + visit(root); + ensure(entries.length > 0, 'dependencies are empty'); + return { formatVersion: 1, entries: entries.sort((a, b) => a.path.localeCompare(b.path)) }; +} +function createReleaseArtifactBundle(projectDir) { + const root = (0, path_1.resolve)(projectDir); + const files = []; + const required = ['.cool-saas-candidate.json', '.cool-saas-service.json', 'bootstrap.js', 'release-environment.js', 'start-stable.js', 'start-frozen.js', 'release-artifact.js', 'package.json', 'scripts/published-packages.json']; + const locks = ['pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock'].filter(name => (0, fs_1.lstatSync)((0, path_1.resolve)(root, name), { throwIfNoEntry: false })); + ensure(locks.length === 1, 'exactly one dependency lock is required'); + for (const name of [...required, ...locks]) { + const absolute = (0, path_1.resolve)(root, name); + const stat = (0, fs_1.lstatSync)(absolute, { throwIfNoEntry: false }); + ensure((stat === null || stat === void 0 ? void 0 : stat.isFile()) && !stat.isSymbolicLink(), `required deployment file is missing or linked: ${name}`); + ensure(inside((0, fs_1.realpathSync)(root), (0, fs_1.realpathSync)(absolute)), 'deployment file escapes project'); + files.push({ path: name, content: (0, fs_1.readFileSync)(absolute).toString('base64') }); + } + for (const file of regularDirectoryFiles((0, path_1.resolve)(root, 'dist'))) + files.push({ path: `dist/${file.path}`, content: (0, fs_1.readFileSync)(file.absolute).toString('base64') }); + const dependencies = captureReleaseDependencies(root); + files.push({ path: 'release-dependencies.json', content: Buffer.from(releaseCanonicalJson(dependencies), 'utf8').toString('base64') }); + const bundle = { formatVersion: 1, files: files.sort((a, b) => a.path.localeCompare(b.path)) }; + inspectReleaseArtifactBundle(bundle); + return bundle; +} +function inspectReleaseArtifactBundle(bundle) { + ensure(bundle && Object.keys(bundle).sort().join(',') === 'files,formatVersion' && bundle.formatVersion === 1 && Array.isArray(bundle.files), 'invalid bundle shape'); + ensure(bundle.files.length > 0 && bundle.files.length <= MAX_FILES, 'invalid bundle file count'); + ensure(Buffer.byteLength(releaseCanonicalJson(bundle), 'utf8') <= MAX_BUNDLE_BYTES, 'artifact bundle exceeds size limit'); + const files = new Map(); + const names = new Set(); + for (const file of bundle.files) { + ensure(file && Object.keys(file).sort().join(',') === 'content,path', 'invalid artifact file shape'); + assertReleaseArtifactPath(file.path); + ensure(typeof file.content === 'string', 'invalid artifact content'); + const bytes = Buffer.from(file.content, 'base64'); + ensure(bytes.toString('base64') === file.content, 'non-canonical base64 artifact content'); + ensure(!names.has(file.path.toLowerCase()), 'duplicate or case-colliding artifact path'); + names.add(file.path.toLowerCase()); + files.set(file.path, bytes); + } + const compiled = [...files].filter(([name]) => name.startsWith('dist/')).map(([name, bytes]) => ({ path: name.slice(5), bytes })); + return { files, artifactDigest: compiledArtifactDigestFromEntries(compiled), bundleDigest: `sha256:${releaseJsonChecksum(bundle)}` }; +} +function verifyLocalReleaseArtifact(projectDir, bundle) { + const inspected = inspectReleaseArtifactBundle(bundle); + const dependencyBytes = inspected.files.get('release-dependencies.json'); + ensure(dependencyBytes, 'installed dependency evidence is missing'); + const expected = JSON.parse(dependencyBytes.toString('utf8')); + const actual = captureReleaseDependencies(projectDir); + ensure(releaseJsonChecksum(expected) === releaseJsonChecksum(actual), 'installed dependency bytes or link targets differ from the signed build'); + for (const [name, bytes] of inspected.files) { + if (name === 'release-dependencies.json') + continue; + const absolute = (0, path_1.resolve)(projectDir, name); + const stat = (0, fs_1.lstatSync)(absolute, { throwIfNoEntry: false }); + ensure((stat === null || stat === void 0 ? void 0 : stat.isFile()) && !stat.isSymbolicLink() && inside((0, fs_1.realpathSync)(projectDir), (0, fs_1.realpathSync)(absolute)), `deployment file is missing or linked: ${name}`); + ensure((0, fs_1.readFileSync)(absolute).equals(bytes), `deployment file differs from signed build: ${name}`); + } + ensure(calculateReleaseCompiledDigest((0, path_1.resolve)(projectDir, 'dist')) === inspected.artifactDigest, 'compiled file set differs from signed build'); + return inspected; +} +function signReleaseEvidence(kind, keyId, claims, privateKeyPem) { + ensure(/^[a-z0-9][a-z0-9._-]{0,63}$/.test(keyId), 'invalid signing key id'); + const key = (0, crypto_1.createPrivateKey)(privateKeyPem); + ensure(key.asymmetricKeyType === 'ed25519', 'release evidence requires Ed25519'); + const unsigned = { algorithm: 'Ed25519', keyId, claims }; + const bytes = Buffer.from(SIGNATURE_DOMAINS[kind] + releaseCanonicalJson(unsigned), 'utf8'); + return { ...unsigned, signature: (0, crypto_1.sign)(null, bytes, key).toString('base64url') }; +} +function verifyReleaseEvidence(kind, envelope, trustedKeys) { + ensure(envelope && Object.keys(envelope).sort().join(',') === 'algorithm,claims,keyId,signature' && envelope.algorithm === 'Ed25519', 'invalid signed evidence envelope'); + ensure(Object.prototype.hasOwnProperty.call(trustedKeys, envelope.keyId), 'untrusted evidence signer'); + const signature = Buffer.from(envelope.signature || '', 'base64url'); + ensure(signature.length === 64 && signature.toString('base64url') === envelope.signature, 'invalid evidence signature encoding'); + const key = (0, crypto_1.createPublicKey)(trustedKeys[envelope.keyId]); + ensure(key.asymmetricKeyType === 'ed25519', 'invalid trusted evidence key'); + const unsigned = { algorithm: envelope.algorithm, keyId: envelope.keyId, claims: envelope.claims }; + ensure((0, crypto_1.verify)(null, Buffer.from(SIGNATURE_DOMAINS[kind] + releaseCanonicalJson(unsigned), 'utf8'), key, signature), 'release evidence signature does not match'); + return envelope.claims; +} +function validateReleaseDependencyManifest(value) { + ensure(value && value.formatVersion === 1 && Array.isArray(value.entries) && value.entries.length > 0 && value.entries.length <= MAX_DEPENDENCIES, 'invalid dependency manifest'); + const paths = new Set(); + for (const entry of value.entries) { + assertReleaseArtifactPath(entry.path); + ensure(!paths.has(entry.path.toLowerCase()), 'duplicate dependency path'); + paths.add(entry.path.toLowerCase()); + if (entry.type === 'file') + ensure(Number.isSafeInteger(entry.size) && entry.size >= 0 && SHA.test(entry.sha256 || ''), 'invalid dependency file proof'); + else { + ensure(entry.type === 'link', 'invalid dependency entry type'); + assertReleaseArtifactPath(entry.target); + } + } +} +/** Captures every authored input, including unanticipated build-script inputs. + * Only conventional generated output roots are excluded. */ +function captureReleaseSourceInputs(projectDir) { + const root = (0, fs_1.realpathSync)(projectDir); + const files = []; + const walk = (directory) => { + for (const entry of (0, fs_1.readdirSync)(directory, { withFileTypes: true })) { + const absolute = (0, path_1.resolve)(directory, entry.name); + const name = (0, path_1.relative)(root, absolute).replace(/\\/g, '/'); + if (/^(?:dist|node_modules|logs?|coverage|\.cache|\.git)(?:\/|$)/.test(name) || /^[^/]+\.tsbuildinfo$/.test(name)) + continue; + assertReleaseArtifactPath(name); + const stat = (0, fs_1.lstatSync)(absolute); + ensure(!stat.isSymbolicLink(), `source input is linked: ${name}`); + if (stat.isDirectory()) + walk(absolute); + else { + ensure(stat.isFile(), `source input is not a regular file: ${name}`); + files.push({ path: name, sha256: (0, crypto_1.createHash)('sha256').update((0, fs_1.readFileSync)(absolute)).digest('hex') }); + } + } + }; + walk(root); + return files.sort((a, b) => a.path.localeCompare(b.path)); +} +/** Reads exact Git blobs instead of a mutable checkout while a build runs. */ +function materializeCommittedReleaseSource(repository, commit, destination) { + ensure(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit), 'invalid source commit'); + ensure(!(0, fs_1.lstatSync)(destination, { throwIfNoEntry: false }), 'source snapshot destination already exists'); + const entries = (0, child_process_1.execFileSync)('git', ['ls-tree', '-r', '-z', commit], { cwd: repository, windowsHide: true, maxBuffer: 32 * 1024 * 1024 }).toString('utf8').split('\0').filter(Boolean); + ensure(entries.length > 0 && entries.length <= 30000, 'invalid source snapshot size'); + (0, fs_1.mkdirSync)(destination, { recursive: true }); + for (const entry of entries) { + const match = /^(100644|100755) blob ([a-f0-9]{40,64})\t([\s\S]+)$/.exec(entry); + ensure(match, 'source snapshot contains a link or submodule'); + const name = assertReleaseArtifactPath(match[3]); + // Credentials are never build inputs, even if an old repository tracked one. + ensure(!/(^|\/)(?:\.env(?:\..*)?|.*\.pem|.*\.private\.(?:json|txt))$/i.test(name), 'private configuration must not be tracked in a release'); + if (/(^|\/)\.npmrc$/i.test(name)) + continue; + ensure(!name.startsWith('node_modules/') && !name.startsWith('dist/'), 'compiled dependencies/artifacts must not be tracked as source'); + const target = (0, path_1.resolve)(destination, name); + ensure(inside((0, path_1.resolve)(destination), target), 'source snapshot path escaped destination'); + const bytes = (0, child_process_1.execFileSync)('git', ['cat-file', 'blob', match[2]], { cwd: repository, windowsHide: true, maxBuffer: 64 * 1024 * 1024 }); + (0, fs_1.mkdirSync)((0, path_1.dirname)(target), { recursive: true }); + (0, fs_1.writeFileSync)(target, bytes, { flag: 'wx', mode: match[1] === '100755' ? 0o755 : 0o644 }); + } +} diff --git a/scripts/published-packages.json b/scripts/published-packages.json index dcddb6f..db821c6 100644 --- a/scripts/published-packages.json +++ b/scripts/published-packages.json @@ -3,93 +3,109 @@ "packages": [ { "name": "@cool-midway/core", - "version": "8.0.12", - "registry": "https://npm.blendercg.art/", - "tarball": "https://npm.blendercg.art/@cool-midway/core/-/core-8.0.12.tgz", - "integrity": "sha512-PZZ50p2xjtU2AoaOjrDCh/cigDHvxsXvD6NzA80F74D1JEc6zbWzA/uJSW5AG7Vgqz6bOlA3C4knwk6czdlz2w==", - "sha256": "d50a0ce219e4b31d1682653fb6f671c583d6e684ae9e1cad16a149105bc52257", + "version": "8.1.0", + "sha256": "782b2f1f14a87dc62f92c6e832bd367f01cd549b17c6186b6567fb2b45250078", + "integrity": "sha512-FrkiZE4/QtTAePAaHh2pX5gEhz5r1VP9OrYX7El+Z6OJi6tvfak6kl9PBhUw/5Kln6/T+c3RE3QRXeEBAtA4ow==", "files": { - "dist/bin/check.d.ts": "ad0ad50e02705414d917e4d7a6ee5140eebe8155312252299d944e246605ce05", - "dist/bin/check.js": "ae79d40c58bea4864ad914d148870b8bf9cc9d2bc40c2ef3408b91089915fcc9", - "dist/bin/entity.d.ts": "762d7c22631edf51f93fb3f587102fbd7c6c88b1f73fa2511cbf262a6ece1cb4", - "dist/bin/entity.js": "91dcc9e31bc16e6b7bc2824f1aede47c88b441d5ca5c453df423660b33f5e1ad", - "dist/bin/index.d.ts": "43e818adf60173644896298637f47b01d5819b17eda46eaa32d0c7d64724d012", - "dist/bin/index.js": "b36efbcab0f16b7ce886041f16840ad0fb98dfb89e0b3341c2c8aa2dcc1bd6df", - "dist/bin/obfuscate.d.ts": "685b95a0e000d3a796d60d31cb1766d52d00f98a25e9455158b5ff58d962cd4f", - "dist/bin/obfuscate.js": "803798e672a537c54e167837de34499592d0f5c991273a6f05515770357fa6ec", - "dist/bin/saas-model.d.ts": "af7900a770d59df795cd37f79839da84f92aa088503c3135348bdee632f1673a", - "dist/bin/saas-model.js": "483220e19f10f172bada3d9bf5157e070ca1029a85b568ec2e5ffcb3f9d037f4", - "dist/cache/store.d.ts": "a0adf1432d64d871ed02adcf0a9665b30143fba768326ff4e791ea51e9db8880", - "dist/cache/store.js": "3281e7851cd86dce35ab4db9b1915e00e11ff72cea7d1113b27721f0a547f04c", - "dist/config/config.default.d.ts": "2ad1c815b50f99f6adde59633395afddef5a323205c6387e76bd34bff8ca9c10", - "dist/config/config.default.js": "6ca05c58aff5f8ed6f6a340b27cc131587634e642c1bf72a97314f415f4f133d", - "dist/configuration.d.ts": "687f08e182e41372c41d257bf357ca03de2d44a623d7320d81dda1f37dc12505", - "dist/configuration.js": "51020f70b9f92cf07ad9c32b228450dc535a970b7de8863711ed920bfc003bae", - "dist/constant/global.d.ts": "22868dfe8944c356ea28b5d7cc08926b751df5d46abcb4a90410e01c3d5bd543", - "dist/constant/global.js": "e64496eead711739e85900659e72587115d02c52a1a10904758b632c4663345f", - "dist/controller/base.d.ts": "011b63eec356a69520b6a733f0dc5b2280f302c91f9c3c04fdc77b541340ab04", "dist/controller/base.js": "c68dcb2ee7fe99389039333a5618cc3dfaf0ac87365b24b1bb443b670e81a46d", - "dist/decorator/cache.d.ts": "0f1df2f901d7c1015bde4d294a6a5a2dbbfe0e6d613478fc50e07144b26da0cc", - "dist/decorator/cache.js": "524b850c621e2efe30a54243857ffeeaab1ca14c2976ca9eb4e605d9dc61a729", - "dist/decorator/controller.d.ts": "0de521ff44f02336bf877f3952c56b3bf2ff4b0f0f7ae4787080d05f49ccaf34", - "dist/decorator/controller.js": "d3633ec4d10ea4f871f17ffdbcd91d58eda1fbd5f2191c8fcb5ff6738d4efb86", - "dist/decorator/event.d.ts": "f3c872d94fd6678719a2849f53b7747d6ac956d4e2a9a8df8fc488991c07bed3", - "dist/decorator/event.js": "69c91edf56c479fd36fbe27cdf4a5f215cb44efbe6aa7539bf361c0d2e239b20", - "dist/decorator/index.d.ts": "f53a6c43fd0d0f5d3b5d78d889ec62b3fc1909bf247c2eb99b3831c137092ac6", - "dist/decorator/index.js": "f93c258081679f1e5d11517bc29bcc6ca066749b1813d72da927ded484a51550", - "dist/decorator/tag.d.ts": "40fb36914821cf54a8d95ec54589af2d332b2a3c1dcf1f3e72b5c62b437220df", - "dist/decorator/tag.js": "9db13ad2fefc16675b87545fd994297c0250975e3d4ab057111e95554068089b", - "dist/decorator/transaction.d.ts": "7675127009885859307b9389bcf2bd28775005495a3eff4e86ddb9410fb999a1", - "dist/decorator/transaction.js": "39bef98a66b7a2c28af8c820671a0962ae9d12442a5bac67f0613e13a8ee91d1", - "dist/entity/base.d.ts": "50960ab93ad375f7e8296b3f8263d7def0c7af3ae4b7d35cc55612fb38fec9c8", "dist/entity/base.js": "8363fa0ab183aae1cce4555070bb8b7cd454274fe873e80467371f54ea5c47af", - "dist/entity/mongo.d.ts": "6379516a8b1da880a11e0734ecb7b2e66728977c320386c9f73e1a926d6edee3", - "dist/entity/mongo.js": "73a13671d213521aa59ffadf10b072ec1ce3c9c1ad72f06bd30d1bd22beb8965", - "dist/entity/typeorm.d.ts": "78ca457d1f1a9e47efdfcb1b140d27019677c776b8ded7e8fc43127c9a844127", - "dist/entity/typeorm.js": "e1829a29ff62787a3fa7c206f6695098e260dd94363619dcf9800c3ab61a6f75", - "dist/event/index.d.ts": "5e14df52a58984680890b13d471044cf71b2ce50a0a8e157088297696e09692e", - "dist/event/index.js": "d6e1fd1e0e4949cad7299d4b332cc96549fd4174e20b1b85a44d1a6eabd6a31a", - "dist/exception/base.d.ts": "3e24d6c029e0f29a863b24b7d0f0509c7b0a3fbc74ee1aba4eb486ecd127e6ed", "dist/exception/base.js": "d9e1f1cd65b051cfa2c5c67b58bde507da71986cedc35ee9a3f4b6e702ad1522", - "dist/exception/comm.d.ts": "0bf544d6a3c0712c94858eb14e82baa5e2f9890d65963a4082ea726b58413fcd", - "dist/exception/comm.js": "d747a79a35c8a81faaba46426e36612fd3b7092c4019be086d32d41048f6be42", - "dist/exception/core.d.ts": "50948b0bc83b5ba3bebab64c554a5cd931cf2c0deeaa5b4d320b2634f6e374ed", - "dist/exception/core.js": "10cd2c6ef01ed4e139a56aa9947cc536a3360e2d4662452bd91dbb91078b1d31", - "dist/exception/filter.d.ts": "6d78a77bd64fcb039991a885215e7d02fcd5dad1b18ef5ff593d6985cdb39d18", - "dist/exception/filter.js": "9c4eefb65168979ea7ae4b609407789ce3cc8f3a7f2d7baa8541dda0911c3195", - "dist/exception/validate.d.ts": "d34062b220d8cdfd38aabc595c935c7bcc35182c1ad7ff549b49a282a52473c5", - "dist/exception/validate.js": "be57f7f715f714dde594013860fc14a18db5115ad8be754ecfb8532d4d8440d6", - "dist/index.d.ts": "a260cd478d8f6ef04880a0a9cdb412599596a928ef7ac5f36920a5b2adce3927", - "dist/index.js": "6a5d80cb2841d17c810d872ff412c60de617227971a84e923b5994e8ec334906", - "dist/interface.d.ts": "71596c601080e12af569f82134e8c4a9dec24710ec9a9a33f32d7c6b79aeb80e", - "dist/interface.js": "7e811aba01a1704883608710e99deabe017128fdb69a9b760e541c9f3fd6f138", - "dist/module/config.d.ts": "10c5151ac83da5bf21c0319dcb9cee9091a1624633688e2310f10fcda0ccb1eb", - "dist/module/config.js": "d043cc225850b2d4785c8dbf06646dca08a0d7ed85e55931460ed0bb6cb0c860", - "dist/module/import.d.ts": "e6802cbf02453ac71573bcff5ae8bf053423411b12330b57ae5f684fea9bb2d7", - "dist/module/import.js": "f03e602f1bf5298b7f034d25fb4c72d82fa80e1c65b8f56c292d739dab4cc522", - "dist/module/menu.d.ts": "9758ddea9ca191cacc1784510472d2e3bf25cefd50aec6399482c69dd55c6cf9", - "dist/module/menu.js": "6c81c2372580ad11ea2507f84237eeb7654411abda008f6030ea813a24925c20", - "dist/rest/eps.d.ts": "3d9d3fe9e6a37d71a1670f12184d469726f737dbd82ed002d01cbfee89f3759d", - "dist/rest/eps.js": "22507382f7e0c09e718e3743671155eacac3211ca7b33b38e8b3cf3259667ba1", - "dist/service/base.d.ts": "352fca2284346c216a6883dde2fb7b728e822f66b8eb43285468525c0832a8a8", "dist/service/base.js": "d8f5276b77045900e2d7b3c6e10cda74cd8d81bac872bbf944b11a0b7484b370", - "dist/service/mysql.d.ts": "5f3d0d44c41f477a55c92c4c8bc6e3e7cb897981b70bb9c904bc1d99d3827a96", - "dist/service/mysql.js": "34ea6da1e9bd1c811436073d94c5b9ddb343448726e6a8a71d2cb5222ab9f7a8", - "dist/service/postgres.d.ts": "7bf898ca2c277ad03521b719c87d56fd162e2e1f9e424042a268c245750a640e", - "dist/service/postgres.js": "007d4b1a1ed3ea4aed05ff37fb1832a1ac8f0b16a1becee56e534e77167a492b", - "dist/service/query-safety.d.ts": "6ae69ec171f34f276da7eb77cd75eb2684ba8f623da5576103fd73b001bef5fe", - "dist/service/query-safety.js": "7470e7fd1e3d616b6a31a19054ab86768c4086485f6e6a3eea0fd7165916fad8", - "dist/service/sqlite.d.ts": "7490be15aa4189a9c54a47169e7f3f0b745c0a0a4ddb3aae3ebbabb35353a0a1", - "dist/service/sqlite.js": "7afa7127967f13211b6314f8a1baa03e8ca9eb3e5fb1b0958efbe70d3595a29b", - "dist/tag/data.d.ts": "fa1a7b8bba229cc7c00b9e2cb8a69c917ab98d147adf743b544a77143bac87b5", + "dist/decorator/cache.js": "524b850c621e2efe30a54243857ffeeaab1ca14c2976ca9eb4e605d9dc61a729", + "dist/bin/check.js": "ae79d40c58bea4864ad914d148870b8bf9cc9d2bc40c2ef3408b91089915fcc9", + "dist/exception/comm.js": "d747a79a35c8a81faaba46426e36612fd3b7092c4019be086d32d41048f6be42", + "dist/config/config.default.js": "6ca05c58aff5f8ed6f6a340b27cc131587634e642c1bf72a97314f415f4f133d", + "dist/module/config.js": "d043cc225850b2d4785c8dbf06646dca08a0d7ed85e55931460ed0bb6cb0c860", + "dist/configuration.js": "51020f70b9f92cf07ad9c32b228450dc535a970b7de8863711ed920bfc003bae", + "dist/decorator/controller.js": "d3633ec4d10ea4f871f17ffdbcd91d58eda1fbd5f2191c8fcb5ff6738d4efb86", + "dist/exception/core.js": "10cd2c6ef01ed4e139a56aa9947cc536a3360e2d4662452bd91dbb91078b1d31", "dist/tag/data.js": "a498d491b221f8cc19973c0450c966c93ac1c9ac35bfe383a026ea20362f329c", - "dist/util/func.d.ts": "9fe99dc5dcda2b3b77215eeeb3fb845de408d9aea86340a4c857f55d7fd45c3f", + "dist/bin/entity.js": "91dcc9e31bc16e6b7bc2824f1aede47c88b441d5ca5c453df423660b33f5e1ad", + "dist/rest/eps.js": "22507382f7e0c09e718e3743671155eacac3211ca7b33b38e8b3cf3259667ba1", + "dist/decorator/event.js": "69c91edf56c479fd36fbe27cdf4a5f215cb44efbe6aa7539bf361c0d2e239b20", + "dist/exception/filter.js": "9c4eefb65168979ea7ae4b609407789ce3cc8f3a7f2d7baa8541dda0911c3195", "dist/util/func.js": "64ce78b9b5115de344c0c11790f658f2034bde0bd262640edb1d263a5130004e", - "dist/util/location.d.ts": "bfbacbcd75579c1b6bd54cdafc6f7a152468802321a8fe0f7de3756073fd2334", + "dist/constant/global.js": "e64496eead711739e85900659e72587115d02c52a1a10904758b632c4663345f", + "dist/module/import.js": "f03e602f1bf5298b7f034d25fb4c72d82fa80e1c65b8f56c292d739dab4cc522", + "dist/bin/index.js": "b77d91deae05013d82244249a5e74132b8395de81024a37b342ea8a60b1fd8e6", + "dist/decorator/index.js": "f93c258081679f1e5d11517bc29bcc6ca066749b1813d72da927ded484a51550", + "dist/event/index.js": "d6e1fd1e0e4949cad7299d4b332cc96549fd4174e20b1b85a44d1a6eabd6a31a", + "dist/index.js": "6a5d80cb2841d17c810d872ff412c60de617227971a84e923b5994e8ec334906", + "dist/interface.js": "7e811aba01a1704883608710e99deabe017128fdb69a9b760e541c9f3fd6f138", "dist/util/location.js": "2f1d906b82b6cae0a46797fc1683f822b688ab40800671268de565c0497ceb91", + "dist/module/menu.js": "6c81c2372580ad11ea2507f84237eeb7654411abda008f6030ea813a24925c20", + "dist/entity/mongo.js": "73a13671d213521aa59ffadf10b072ec1ce3c9c1ad72f06bd30d1bd22beb8965", + "dist/service/mysql.js": "34ea6da1e9bd1c811436073d94c5b9ddb343448726e6a8a71d2cb5222ab9f7a8", + "dist/bin/obfuscate.js": "803798e672a537c54e167837de34499592d0f5c991273a6f05515770357fa6ec", + "dist/service/postgres.js": "007d4b1a1ed3ea4aed05ff37fb1832a1ac8f0b16a1becee56e534e77167a492b", + "dist/service/query-safety.js": "7470e7fd1e3d616b6a31a19054ab86768c4086485f6e6a3eea0fd7165916fad8", + "dist/bin/saas-dev-watch-child.js": "cb33b8b338515f9bebb6cf0ec412788ac43dd1ad92d90464431b02eec86663cc", + "dist/bin/saas-dev-watch-files.js": "bf9aae8a5f413403c7850ada4bdf11e22a4c6708b7bcb27ba130af410289fd22", + "dist/bin/saas-dev-watch-process.js": "d4d44ccaa2a739c5e36518dd40456cb2c3bdf778a1de0f1017cc57c8d0a03e65", + "dist/bin/saas-dev-watch.js": "0d075d75e5f662eb06672d271a9aa67e99e233c6989f0fa140d713e8657b9cbb", + "dist/bin/saas-model.js": "c7c3c3f70e754d18b8f6c8bbc22fc6d30fc03139bcb629448cf36fa434fef82a", + "dist/bin/saas-release-artifact.js": "fae63fa5cadd726f0b0548bd7e6ed3572bae051b0a2fafd93aac73d09da5a671", + "dist/bin/saas-release-finalize.js": "9833b9d9196553295aa8b00a24a929b9c817740cbc9228b881b1c683340f02e3", + "dist/bin/saas-release-resume.js": "b01f3efe047cb1497593cd1d663ca96be2fa14812c06bee01b9f5e9d513d4148", + "dist/service/sqlite.js": "7afa7127967f13211b6314f8a1baa03e8ca9eb3e5fb1b0958efbe70d3595a29b", + "dist/cache/store.js": "3281e7851cd86dce35ab4db9b1915e00e11ff72cea7d1113b27721f0a547f04c", + "dist/decorator/tag.js": "9db13ad2fefc16675b87545fd994297c0250975e3d4ab057111e95554068089b", + "dist/decorator/transaction.js": "39bef98a66b7a2c28af8c820671a0962ae9d12442a5bac67f0613e13a8ee91d1", + "dist/entity/typeorm.js": "e1829a29ff62787a3fa7c206f6695098e260dd94363619dcf9800c3ab61a6f75", + "dist/exception/validate.js": "be57f7f715f714dde594013860fc14a18db5115ad8be754ecfb8532d4d8440d6", + "package.json": "b90f4ac1a45fc63e34958b180d406af54b0f5ac63987c0cbc16327fb1715a2e0", + "dist/controller/base.d.ts": "011b63eec356a69520b6a733f0dc5b2280f302c91f9c3c04fdc77b541340ab04", + "dist/entity/base.d.ts": "50960ab93ad375f7e8296b3f8263d7def0c7af3ae4b7d35cc55612fb38fec9c8", + "dist/exception/base.d.ts": "3e24d6c029e0f29a863b24b7d0f0509c7b0a3fbc74ee1aba4eb486ecd127e6ed", + "dist/service/base.d.ts": "352fca2284346c216a6883dde2fb7b728e822f66b8eb43285468525c0832a8a8", + "dist/decorator/cache.d.ts": "0f1df2f901d7c1015bde4d294a6a5a2dbbfe0e6d613478fc50e07144b26da0cc", + "dist/bin/check.d.ts": "ad0ad50e02705414d917e4d7a6ee5140eebe8155312252299d944e246605ce05", + "dist/exception/comm.d.ts": "0bf544d6a3c0712c94858eb14e82baa5e2f9890d65963a4082ea726b58413fcd", + "dist/module/config.d.ts": "10c5151ac83da5bf21c0319dcb9cee9091a1624633688e2310f10fcda0ccb1eb", + "dist/config/config.default.d.ts": "2ad1c815b50f99f6adde59633395afddef5a323205c6387e76bd34bff8ca9c10", + "dist/configuration.d.ts": "687f08e182e41372c41d257bf357ca03de2d44a623d7320d81dda1f37dc12505", + "dist/decorator/controller.d.ts": "0de521ff44f02336bf877f3952c56b3bf2ff4b0f0f7ae4787080d05f49ccaf34", + "dist/exception/core.d.ts": "50948b0bc83b5ba3bebab64c554a5cd931cf2c0deeaa5b4d320b2634f6e374ed", + "dist/tag/data.d.ts": "fa1a7b8bba229cc7c00b9e2cb8a69c917ab98d147adf743b544a77143bac87b5", + "dist/bin/entity.d.ts": "762d7c22631edf51f93fb3f587102fbd7c6c88b1f73fa2511cbf262a6ece1cb4", + "dist/rest/eps.d.ts": "3d9d3fe9e6a37d71a1670f12184d469726f737dbd82ed002d01cbfee89f3759d", + "dist/decorator/event.d.ts": "f3c872d94fd6678719a2849f53b7747d6ac956d4e2a9a8df8fc488991c07bed3", + "dist/exception/filter.d.ts": "6d78a77bd64fcb039991a885215e7d02fcd5dad1b18ef5ff593d6985cdb39d18", + "dist/util/func.d.ts": "9fe99dc5dcda2b3b77215eeeb3fb845de408d9aea86340a4c857f55d7fd45c3f", + "dist/constant/global.d.ts": "22868dfe8944c356ea28b5d7cc08926b751df5d46abcb4a90410e01c3d5bd543", + "dist/module/import.d.ts": "e6802cbf02453ac71573bcff5ae8bf053423411b12330b57ae5f684fea9bb2d7", + "dist/bin/index.d.ts": "43e818adf60173644896298637f47b01d5819b17eda46eaa32d0c7d64724d012", + "dist/decorator/index.d.ts": "f53a6c43fd0d0f5d3b5d78d889ec62b3fc1909bf247c2eb99b3831c137092ac6", + "dist/event/index.d.ts": "5e14df52a58984680890b13d471044cf71b2ce50a0a8e157088297696e09692e", + "dist/index.d.ts": "a260cd478d8f6ef04880a0a9cdb412599596a928ef7ac5f36920a5b2adce3927", "index.d.ts": "b764a7488d90d3db5bc04ed44bbe06994574d25c052a139c15e9b11ffe1be954", - "package.json": "dcd4fc7fe938b32677091f4530dfd102330f8710a940b2ccf6c1f73fa194b9bb" - } + "dist/interface.d.ts": "71596c601080e12af569f82134e8c4a9dec24710ec9a9a33f32d7c6b79aeb80e", + "dist/util/location.d.ts": "bfbacbcd75579c1b6bd54cdafc6f7a152468802321a8fe0f7de3756073fd2334", + "dist/module/menu.d.ts": "9758ddea9ca191cacc1784510472d2e3bf25cefd50aec6399482c69dd55c6cf9", + "dist/entity/mongo.d.ts": "6379516a8b1da880a11e0734ecb7b2e66728977c320386c9f73e1a926d6edee3", + "dist/service/mysql.d.ts": "5f3d0d44c41f477a55c92c4c8bc6e3e7cb897981b70bb9c904bc1d99d3827a96", + "dist/bin/obfuscate.d.ts": "685b95a0e000d3a796d60d31cb1766d52d00f98a25e9455158b5ff58d962cd4f", + "dist/service/postgres.d.ts": "7bf898ca2c277ad03521b719c87d56fd162e2e1f9e424042a268c245750a640e", + "dist/service/query-safety.d.ts": "6ae69ec171f34f276da7eb77cd75eb2684ba8f623da5576103fd73b001bef5fe", + "dist/bin/saas-dev-watch-child.d.ts": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881", + "dist/bin/saas-dev-watch-files.d.ts": "a7c8d6359d11564548809a9e1a5122e003a5f578d5c9f79a7b0f5a4852b51cc3", + "dist/bin/saas-dev-watch-process.d.ts": "efc1661083614da00f3f8907f618c185161f0418ad0ddc69a9b5acab1fa7eee1", + "dist/bin/saas-dev-watch.d.ts": "4308f0195f99515d4bc92fa8d3e7a6b007caf59f6d1ae3f3eb6ec7bfacb64755", + "dist/bin/saas-model.d.ts": "bfa30029644bc94fc5c3926939f935d770029b322e8bf18d9282459ecedd8329", + "dist/bin/saas-release-artifact.d.ts": "4bd3770b85215304ba752b543ace28524aa05ad44cfe21b43c6bfd264ad85eb1", + "dist/bin/saas-release-finalize.d.ts": "315fb866b0a1a7832b02bb2a64c3162fecde0d1741d977a538171c6562e3c6c7", + "dist/bin/saas-release-resume.d.ts": "9c42f26f074cf919e57c7f06d89a9c4838a85f3fe0b59e1fcf44ad6842e3d274", + "dist/service/sqlite.d.ts": "7490be15aa4189a9c54a47169e7f3f0b745c0a0a4ddb3aae3ebbabb35353a0a1", + "dist/cache/store.d.ts": "a0adf1432d64d871ed02adcf0a9665b30143fba768326ff4e791ea51e9db8880", + "dist/decorator/tag.d.ts": "40fb36914821cf54a8d95ec54589af2d332b2a3c1dcf1f3e72b5c62b437220df", + "dist/decorator/transaction.d.ts": "7675127009885859307b9389bcf2bd28775005495a3eff4e86ddb9410fb999a1", + "dist/entity/typeorm.d.ts": "78ca457d1f1a9e47efdfcb1b140d27019677c776b8ded7e8fc43127c9a844127", + "dist/exception/validate.d.ts": "d34062b220d8cdfd38aabc595c935c7bcc35182c1ad7ff549b49a282a52473c5" + }, + "fileCount": 97, + "credentialScanPassed": true, + "registry": "https://npm.blendercg.art/", + "tarball": "https://npm.blendercg.art/@cool-midway/core/-/core-8.1.0.tgz" }, { "name": "@cool-midway/module-runtime", diff --git a/start-frozen.js b/start-frozen.js new file mode 100644 index 0000000..49706ae --- /dev/null +++ b/start-frozen.js @@ -0,0 +1,10 @@ +'use strict'; + +// Explicit acceptance mode for an already built and signed V2 RC artifact. +// The same deployment directory is used later by start-stable.js after promotion. +const { startStable } = require('./start-stable'); +if (require.main === module) startStable({ mode: 'frozen' }).catch(error => { + process.stderr.write(`${error?.message || error}\n`); + process.exitCode = 1; +}); +module.exports = { startFrozen: options => startStable({ ...options, mode: 'frozen' }) }; diff --git a/start-stable.js b/start-stable.js new file mode 100644 index 0000000..eadc1ac --- /dev/null +++ b/start-stable.js @@ -0,0 +1,235 @@ +'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, realpathSync } = 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, mode = 'production' }) { + const build = release?.build; + const artifact = release?.frozenArtifact; + const stable = mode === 'production'; + ensure(['production', 'frozen'].includes(mode), 'unsupported artifact runtime mode'); + ensure(release?.id === Number(releaseId) && release.status === 'published' && release.lifecycleVersion === 1 && + (stable ? release.releaseType === 'stable' : release.releaseType === 'rc' && ['frozen', 'accepted', 'promoted'].includes(release.candidateStatus) && release.releaseEvidenceVersion === 2), 'target is not a published lifecycle Stable release or explicit V2 frozen RC'); + const targetVersion = stable ? release.version : release.targetVersion; + ensure(STABLE.test(targetVersion || '') && release.serviceVersion === release.version, 'Stable version identity is invalid'); + ensure(build && artifact && Number(stable ? release.promotedFromReleaseId : release.id) === 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 === `${targetVersion}-rc.${lock.buildRevision}` && lock.branchName === `release/${lock.candidateVersion}` && artifact.sourceBranch === lock.branchName && artifact.tagName === `v${lock.candidateVersion}` && (stable || release.version === 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 === targetVersion, '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; + // A frozen RC keeps its original immutable envelope. Its actual bytes are + // selected by the separately signed Frozen Artifact; Stable's new envelope + // must explicitly reuse that digest. + ensure(manifest && contracts?.implementationManifestChecksum === artifact.implementationManifestChecksum && + (stable ? manifest.artifactDigest === artifact.artifactDigest : /^sha256:[a-f0-9]{64}$/.test(manifest.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: mode, 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', + schemaChecksum: 'RPC_SCHEMA_CHECKSUM', actionManifestChecksum: 'RPC_ACTION_MANIFEST_CHECKSUM', runtimeContractChecksum: 'RPC_RUNTIME_CONTRACT_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 verifyTrustedReleaseBundle(projectDir, environment, release, fetcher = fetch) { + const file = String(environment.SAAS_RELEASE_TRUST_FILE || ''); + ensure(path.isAbsolute(file) && lstatSync(file, {throwIfNoEntry:false})?.isFile() && !lstatSync(file).isSymbolicLink(), 'V2 startup needs an external build trust configuration'); + const relative = path.relative(realpathSync(projectDir), realpathSync(file)); + ensure(path.isAbsolute(relative) || relative === '..' || relative.startsWith('..' + path.sep), 'build trust must be supplied outside the signed deployment'); + const trust = JSON.parse(readFileSync(file, 'utf8')); + const artifact = release.frozenArtifact; + const keyId = artifact?.buildEvidence?.keyId; + const builder = trust.builders?.[keyId]; + ensure(builder && builder.enabled !== false && builder.moduleIds?.includes(release.moduleId) && builder.repositories?.includes(artifact.sourceRepository), 'build signer is not trusted for this module and repository'); + const helper = require('./release-artifact'); + const claims = helper.verifyReleaseEvidence('build', artifact.buildEvidence, {[keyId]:builder.publicKey}); + ensure(release.releaseEvidenceVersion === 2 && artifact.releaseEvidenceVersion === 2 && claims.version === 2, 'release evidence version differs'); + for (const [field, expected] of Object.entries({ + releaseId: artifact.rcReleaseId, moduleId: release.moduleId, buildRevision: release.buildRevision, buildChecksum: release.build.buildChecksum, + sourceRepository: artifact.sourceRepository, sourceBranch: artifact.sourceBranch, sourceCommit: artifact.sourceCommit, tagName: artifact.tagName, + generatedLockChecksum: artifact.generatedLockChecksum, implementationManifestChecksum: artifact.implementationManifestChecksum, + dependencyLockChecksum: artifact.dependencyLockChecksum, bundleDigest: artifact.bundleDigest, artifactDigest: artifact.artifactDigest, + })) ensure(claims[field] === expected, `build signature ${field} differs from the frozen artifact`); + const base = new URL(environment.SAAS_CONTROL_PLANE_URL || environment.COOL_SAAS_CONTROL_PLANE_URL || 'http://127.0.0.1:8080'); + const url = new URL('/admin/saas/module_release/artifact-bundle',base); url.searchParams.set('releaseId',String(release.id)); + const response = await fetcher(url,{headers:{Authorization:`Bearer ${environment.COOL_SAAS_TOKEN}`},redirect:'error',signal:AbortSignal.timeout(30000)}); + ensure(response.ok, `trusted artifact is unavailable or revoked (${response.status})`); + const body = await response.json(); + ensure(body?.code === 1000 && body.data, 'trusted artifact response is invalid'); + const evidence = body.data; + ensure(evidence.bundleDigest === artifact.bundleDigest && evidence.artifactDigest === artifact.artifactDigest && + helper.releaseJsonChecksum(evidence.buildEvidence) === helper.releaseJsonChecksum(artifact.buildEvidence), 'stored artifact and signed source differ'); + const inspected = helper.verifyLocalReleaseArtifact(projectDir,evidence.bundle); + ensure(inspected.artifactDigest === artifact.artifactDigest && inspected.bundleDigest === artifact.bundleDigest, 'deployed bytes differ from the trusted artifact'); + ensure(createHash('sha256').update(inspected.files.get('pnpm-lock.yaml')).digest('hex') === artifact.dependencyLockChecksum, 'signed dependency lock differs'); + return { recheck: () => helper.verifyLocalReleaseArtifact(projectDir,evidence.bundle) }; +} + +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 mode = options.mode || 'production'; + 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'); + ensure(release.releaseEvidenceVersion == null || [1, 2].includes(release.releaseEvidenceVersion), 'unknown release evidence version'); + const trusted = release.releaseEvidenceVersion === 2 + ? await verifyTrustedReleaseBundle(projectDir,environment,release,options.fetch) + : null; + 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, mode }); + 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'); + trusted?.recheck(); + } }); +} + +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, verifyTrustedReleaseBundle }; diff --git a/test/start-stable.test.cjs b/test/start-stable.test.cjs new file mode 100644 index 0000000..7fb5380 --- /dev/null +++ b/test/start-stable.test.cjs @@ -0,0 +1,249 @@ +'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, generateKeyPairSync } = 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, + schemaChecksum: exact.RPC_SCHEMA_CHECKSUM, actionManifestChecksum: exact.RPC_ACTION_MANIFEST_CHECKSUM, runtimeContractChecksum: exact.RPC_RUNTIME_CONTRACT_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/); +}); + +test('V2 frozen and Stable starts reuse signed executable and dependency bytes and reject tampering before generated code loads', async t => { + const helper = require('../release-artifact'); + const parent = mkdtempSync(path.join(os.tmpdir(), 'stable-v2-test-')); + t.after(() => rmSync(parent, { recursive:true, force:true })); + const root = path.join(parent, 'deployment'); + for (const name of ['dist/saas/generated', 'scripts', 'node_modules/pkg']) mkdirSync(path.join(root,name), {recursive:true}); + const data = fixture(); + for (const name of ['bootstrap.js','release-environment.js','start-stable.js','start-frozen.js','release-artifact.js','dist/configuration.js','dist/saas/generated/index.js']) writeFileSync(path.join(root,name), 'module.exports = {};'); + writeFileSync(path.join(root,'.cool-saas-candidate.json'),JSON.stringify(data.lock)); + for (const name of ['.cool-saas-service.json','package.json','scripts/published-packages.json']) writeFileSync(path.join(root,name),'{}'); + writeFileSync(path.join(root,'pnpm-lock.yaml'),'lockfileVersion: 9\n'); + writeFileSync(path.join(root,'node_modules/pkg/index.js'),'exports.identity = "frozen";'); + const bundle = helper.createReleaseArtifactBundle(root); + const inspected = helper.inspectReleaseArtifactBundle(bundle); + const keys = generateKeyPairSync('ed25519'); + const privatePem = keys.privateKey.export({type:'pkcs8',format:'pem'}).toString(); + const publicKey = keys.publicKey.export({type:'spki',format:'pem'}).toString(); + const trustFile = path.join(parent,'trust.json'); + writeFileSync(trustFile,JSON.stringify({builders:{'builder-1':{publicKey,moduleIds:[2],repositories:['https://git.example.invalid/demo.git']}}})); + const artifact = data.release.frozenArtifact; + Object.assign(artifact,{releaseEvidenceVersion:2,sourceRepository:'https://git.example.invalid/demo.git', + artifactDigest:inspected.artifactDigest,bundleDigest:inspected.bundleDigest, + dependencyLockChecksum:createHash('sha256').update(inspected.files.get('pnpm-lock.yaml')).digest('hex')}); + const claims = {version:2,taskId:'build-task',taskNonce:'nonce',releaseId:41,moduleId:2,buildRevision:2,buildChecksum:data.lock.buildChecksum}; + for (const name of ['sourceRepository','sourceBranch','sourceCommit','tagName','generatedLockChecksum','implementationManifestChecksum','artifactDigest','bundleDigest','dependencyLockChecksum']) claims[name] = artifact[name]; + artifact.buildEvidence = helper.signReleaseEvidence('build','builder-1',claims,privatePem); + data.release.releaseEvidenceVersion = 2; + data.release.schemaSnapshot.actionManifest.artifactDigest = inspected.artifactDigest; + const originalBytes = helper.releaseJsonChecksum(helper.createReleaseArtifactBundle(root)); + let starts = 0, loads = 0; + async function launch(mode, edit) { + const release = structuredClone(data.release); + if (mode === 'frozen') Object.assign(release,{id:41,releaseType:'rc',candidateStatus:'promoted',targetVersion:'1.0.1',version:data.lock.candidateVersion,serviceVersion:data.lock.candidateVersion,promotedReleaseId:42}); + edit?.(release); + const environment = {NODE_ENV:'local',RPC_RELEASE_ID:String(release.id),BUILD_SHA:data.commit,COOL_SAAS_TOKEN:'short-lived-test',SAAS_RELEASE_TRUST_FILE:trustFile}; + return startStable({projectDir:root,mode,environment,fetch:async url => ({ok:true,json:async()=>({code:1000,data:url.pathname.endsWith('/artifact-bundle') ? {bundle,...inspected,files:undefined,buildEvidence:artifact.buildEvidence} : release})}), + loadGenerated:()=>{loads++;return data.generated;},start:({verifySession})=>{starts++;verifySession(sessionFor(environment));return 'started';}}); + } + assert.equal(await launch('frozen'),'started'); + assert.equal(await launch('production'),'started'); + assert.equal(starts,2); + assert.equal(helper.releaseJsonChecksum(helper.createReleaseArtifactBundle(root)),originalBytes); + for (const name of ['bootstrap.js','node_modules/pkg/index.js','pnpm-lock.yaml']) { + const file = path.join(root,name), bytes = readFileSync(file); + writeFileSync(file,'tampered'); + await assert.rejects(launch('production'),/differs|differ/); + writeFileSync(file,bytes); + } + await assert.rejects(launch('production',release=>{release.frozenArtifact.buildEvidence.signature='a'.repeat(86);}),/signature/); + await assert.rejects(launch('production',release=>{release.frozenArtifact.sourceCommit='9'.repeat(40);}),/sourceCommit/); + assert.equal(loads,2,'tampered bytes must fail before loading generated JavaScript'); +});