diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index e8b6c24c..001986ff 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -29,7 +29,7 @@ - D-305/ADR-0393(已接受):Plugin Package Manifest 的逻辑 Secret requirement 获得按 resource generation 固定的不可变 binding。binding 精确覆盖 Manifest requirements,只保存同 Project、显式 version 的 `qlsecret://` 引用与 `approved-action-execution|local-owner-confirmation` authority evidence digest,不保存 Secret 明文;required 不可为空,optional 可显式为 `null`。发布由当前 active installation head、lock、generation 与 Manifest digest 联合 fencing,相同事实幂等、不同事实冲突;domain-separated digest、最多 64 项和 64 KiB 单行预算同时约束 Local 与 Cluster。SQLite 追加 `0091` ledger 与 capability v46,PostgreSQL 追加 `pg-0059`、capability v58,并只向 package executor 授予 `SELECT, INSERT`。不新增 package、daemon、timer、watcher、连接、缓存或集群 workload,低配路由设备只承担一个有界表和三个索引。D-305 不冒充 Secret 已进入执行路径:现有 materialization 拒绝仍保留,D-306 再完成用户授权、Secret resolution、runtime consumption 与 lifecycle/rebinding 语义。core 509/509、SQLite 232/232、PostgreSQL 316 pass/1 条件 skip;完整 18-package clean build/test 退出 0,backend 1,188 pass/2 skip,五项边界审计零 finding,workspace 仍无 single-source/shallow-source package。PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `acf0fea7ca7699989dfe70f5dd0061cdf5fb1968c691094331fea06ce01b96dc`。 - D-306A/ADR-0394(已接受):Package Task source 以 `package-secret` placeholder 引用逻辑 requirement,materialization 只用当前 generation 的 D-305 binding 编译为已有、固定 version 的 Task `SecretRef`;Package source 直接携带 SecretRef、缺失 binding、未批准 `secret.use`、跨 binding 引用和 optional/required 漂移全部失败关闭。binding 快照不含明文并进入 materialized revision digest,Local/Cluster 启动发布复用既有 repository/pool,Task dispatch、Local 短时解密和 Cluster offer/lease-fenced delivery 不另造协议。SQLite/PostgreSQL INSERT trigger 同时防止直接写库绕过;Local 只读 readiness 继续不加载 DDL。Local contract v47、Cluster v59;不新增 package、表、索引、连接、daemon、watcher、timer、cache 或 workload。完整 18-package clean build/test 退出 0;backend 1,188 pass/2 条件 skip/0 fail;五项 package/dependency/edge/service-manager/local-image 审计零 finding,workspace 仍无 single-source/shallow-source package,两个有序 migration ledger 精确为 PostgreSQL 61、SQLite 95 个 source;PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `f9107e8e54892a788779758f0573ac8d6a80f6d086516a1f5f5bbacb59bbb4be`。D-306A 不冒充产品闭环:Local bind/rebind 命令、Cluster Approved Action/API 与新 generation rotation/revocation 编排属于 D-306B。 - D-306B1/ADR-0395(已接受):当前 active、尚未绑定 Package generation 的首次 Secret binding 已形成 Local 与 Cluster 产品闭环,且不允许原地 rebind。共享 content-free plan 由服务端从 installation/proposal/lock/Manifest/generation 重建;Local 使用短生命周期 `ql3-package`、Owner human confirmation 与单 SQLite transaction,Cluster 使用既有 package-management HTTPS/CLI、package-manager separation-of-duty Approval 和短生命周期 package-executor。三节点 K3s `v1.34.3+k3s1` arm64 现场门已在真实 PostgreSQL `18.4` 上完成两个 management Pod 跨节点部署、正式 client `plan→跨副本 replay→propose→双人 decide→inspect`、真实 executor Job 与只读 Kubernetes Secret projection。management/executor 均无 Secret API 读取权和 ServiceAccount token;management 不挂载 Package value,executor 只验证投影元数据;最终恰好一条 immutable binding,Approval consumed、execution succeeded,数据库敏感值扫描为 0。16/16 gate 的 owner-private、低敏报告通过独立 exact-shape 审计,SHA-256 为 `aaabb5ebea77c50bce671f91dd3051671fd20875c11a8f787fe8933f29dbfa4d`。完整 18-package clean build/test、backend 与七项边界审计,以及 PostgreSQL 18.4 physical HA 125 gate/timeline `1→2` 证据继续有效;没有新增 workspace package、migration、表、索引、依赖或常驻 workload。B2 rebind/rotation/revocation 必须通过新 Package generation 独立推进。 -- D-306B2/ADR-0396(进行中):Secret rebind/rotation/revocation 不更新历史 binding,而是作为下一 Package generation 的 activation 前置事实。共享 transition plan v1 同时绑定上一 active target、可选的上一 binding、durable install history 的最后尝试 generation、新 target、可选下一 binding plan、逐 requirement 与 SecretRef 差异及独立 digest;上一 active Manifest 没有 Secret requirement 时 binding 可空,但 target/lock/generation lineage 不可省略。失败 install 也永久消耗 generation,重试必须使用 `lastAttemptGeneration + 1`,active lineage 继续由 `previousActiveLockDigest` 指回旧代。服务端分别推导 requirement 的 add/remove/tighten/relax 和 SecretRef 的 bind/revoke/forward-rotate/rebind;版本倒退与换名均不能冒充 rotation,删除最后一个 requirement 以 `nextBindingPlan: null` 表达而不制造空 binding。最终顺序冻结为 install approval/stage→transition approval→目标 binding publish/materialize→active CAS,失败不得移动旧 active;紧急 revoke 先复用 disable/quarantine 阻止新 admission,再做代际收敛,不声称追回运行中明文。公共 contract 归入既有 runtime-core Secret binding 聚合目录,不新增 package、依赖或常驻资源;Local/Cluster 持久化与产品纵切面仍待完成。 +- D-306B2/ADR-0396(进行中):Secret rebind/rotation/revocation 不更新历史 binding,而是作为下一 Package generation 的 activation 前置事实。共享 transition plan v1 同时绑定上一 active target、可选的上一 binding、durable install history 的最后尝试 generation、新 target、可选下一 binding plan、逐 requirement 与 SecretRef 差异及独立 digest;上一 active Manifest 没有 Secret requirement 时 binding 可空,但 target/lock/generation lineage 不可省略。失败 install 也永久消耗 generation,重试必须使用 `lastAttemptGeneration + 1`,active lineage 继续由 `previousActiveLockDigest` 指回旧代。服务端分别推导 requirement 的 add/remove/tighten/relax 和 SecretRef 的 bind/revoke/forward-rotate/rebind;版本倒退与换名均不能冒充 rotation,删除最后一个 requirement 以 `nextBindingPlan: null` 表达而不制造空 binding。SQLite capability v48(0095/0096)与 PostgreSQL capability v61(pg0062)现已在 repository 与数据库 trigger 两层只允许当前 active 或具完整上一 active lineage、且为 durable install history 最大 generation 的 staged target;queued/activating/failed、陈旧/跳代、断链和直接 SQL 绕过均失败关闭,readiness 校验 exact trigger/function。完整 18-package 串行门、backend 1194 项(1192 pass/2 条件 skip)及真实 PostgreSQL 18.4 B2 role/trigger 门通过。最终顺序仍为 install approval/stage→transition approval→目标 binding publish/materialize→active CAS,失败不得移动旧 active;紧急 revoke 先复用 disable/quarantine 阻止新 admission,再做代际收敛,不声称追回运行中明文。公共 contract 归入既有 runtime-core Secret binding 聚合目录;本切片未新增 package、第三方依赖、表、连接或常驻资源。Local Owner 与 Cluster management/executor 产品编排、真实 Kubernetes rotation/revoke 和低配物理证据仍待完成。 - D-302/ADR-0390(已接受) Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、 diff --git a/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md b/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md index 97763eff..2d01c01d 100644 --- a/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md +++ b/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md @@ -32,4 +32,6 @@ D-306B1 只允许给当前 active 且尚未绑定的 Package generation 做首 ## 当前进度 -- 已冻结共享 transition plan v1 的 lineage 与差异分类语义;Local/Cluster activation 前发布和持久化门仍待实现,因此 ADR 保持 Proposed。 +- 已冻结共享 transition plan v1 的 lineage 与差异分类语义,并完成 Local/Cluster 的 activation 前持久化门:SQLite capability v48 通过 migration 0095/0096、PostgreSQL capability v61 通过 migration pg0062,把 repository 写入和数据库 trigger 同时约束为“当前 active target”或“由完整上一 active lineage 支撑、且为 durable install history 最大 generation 的 staged target”。`queued|activating|failed`、陈旧/跳代 staged target、断裂 lineage 与直接 SQL 绕过均失败关闭;readiness 校验 exact trigger/function attachment,避免 schema 名义升级但约束缺失。 +- 本切片没有新增 workspace package、第三方依赖、表、连接、daemon、timer、watcher 或常驻资源;Edge/Standalone 只增加 SQLite schema guard,Cluster 复用既有 PostgreSQL Pool/role。完整 18-package 串行测试与 backend 门已通过;真实 PostgreSQL 18.4 的 migration/runtime role 门证明 active、合规 staged 与 exact replay 可写,activating、陈旧/非最大 staged 以及直接 SQL 可被拒绝。 +- Local Owner 与 Cluster management/executor 尚未消费 transition plan,完成“审批 → 发布 binding → materialize → active CAS”的产品纵切面;真实 Kubernetes rotation/revoke、升级失败回滚及物理低配设备证据也仍待完成。因此 ADR 继续保持 Proposed,下一切片只补产品编排,不再扩张 package 或常驻部署面。 diff --git a/docs/ql3-package-boundaries.json b/docs/ql3-package-boundaries.json index b269e125..8696d2ff 100644 --- a/docs/ql3-package-boundaries.json +++ b/docs/ql3-package-boundaries.json @@ -6,13 +6,13 @@ "reviewedDenseDirectories": [ { "kind": "ordered_ledger", - "maxDirectSourceFiles": 62, + "maxDirectSourceFiles": 63, "path": "packages/ql3-cluster-postgres/src/migrations", "rationale": "PostgreSQL migrations are an append-only version ledger whose ordering and discoverability are safer in one reviewed directory." }, { "kind": "ordered_ledger", - "maxDirectSourceFiles": 95, + "maxDirectSourceFiles": 97, "path": "packages/ql3-local-sqlite/src/migrations", "rationale": "SQLite migrations are an append-only version ledger whose ordering and discoverability are safer in one reviewed directory." } diff --git a/packages/ql3-cluster-admin/test/bootstrap.test.cjs b/packages/ql3-cluster-admin/test/bootstrap.test.cjs index a2759b54..64d67fdf 100644 --- a/packages/ql3-cluster-admin/test/bootstrap.test.cjs +++ b/packages/ql3-cluster-admin/test/bootstrap.test.cjs @@ -131,6 +131,16 @@ function database(serverVersionNum = '160014') { })), }; } + if (text.includes('FROM pg_trigger triggers')) { + return { + rows: contract.triggers.map((definition) => ({ + triggerName: definition.name, + tableName: definition.tableName, + functionName: definition.functionName, + enabled: 'O', + })), + }; + } if (text.includes('FROM pg_catalog.pg_roles')) { return { rows: [ @@ -160,6 +170,7 @@ function database(serverVersionNum = '160014') { 'commit_plugin_package_lifecycle', 'commit_plugin_package_task_reconciliation', 'commit_plugin_package_quarantine', + 'enforce_plugin_package_secret_binding_target', 'enforce_plugin_package_secret_materialization', 'enforce_plugin_package_stage_provenance', 'lock_active_plugin_package_project', diff --git a/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs index 64f65856..d0c789e4 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs @@ -167,6 +167,16 @@ function database(serverVersionNum = '160014') { })), }; } + if (text.includes('FROM pg_trigger triggers')) { + return { + rows: contract.triggers.map((definition) => ({ + triggerName: definition.name, + tableName: definition.tableName, + functionName: definition.functionName, + enabled: 'O', + })), + }; + } if (text.includes('FROM pg_catalog.pg_roles')) { return { rows: [ @@ -193,6 +203,7 @@ function database(serverVersionNum = '160014') { rows: contract.functions.map(({ name: functionName }) => ({ functionName, executeAllowed: ![ + 'enforce_plugin_package_secret_binding_target', 'enforce_plugin_package_secret_materialization', 'enforce_plugin_package_stage_provenance', 'plugin_package_automation_start_allowed', diff --git a/packages/ql3-cluster-control/test/application.test.cjs b/packages/ql3-cluster-control/test/application.test.cjs index 5af9831b..bf49abca 100644 --- a/packages/ql3-cluster-control/test/application.test.cjs +++ b/packages/ql3-cluster-control/test/application.test.cjs @@ -301,6 +301,16 @@ function databaseResource(events, options = {}) { })), }; } + if (text.includes('FROM pg_trigger triggers')) { + return { + rows: contract.triggers.map((definition) => ({ + triggerName: definition.name, + tableName: definition.tableName, + functionName: definition.functionName, + enabled: 'O', + })), + }; + } if (text.includes('FROM pg_catalog.pg_roles')) { return { rows: [ diff --git a/packages/ql3-cluster-control/test/bootstrap.test.cjs b/packages/ql3-cluster-control/test/bootstrap.test.cjs index 5963dcd6..140b546e 100644 --- a/packages/ql3-cluster-control/test/bootstrap.test.cjs +++ b/packages/ql3-cluster-control/test/bootstrap.test.cjs @@ -215,6 +215,16 @@ function databaseResource(events, overrides = {}) { })), }; } + if (text.includes('FROM pg_trigger triggers')) { + return { + rows: contract.triggers.map((definition) => ({ + triggerName: definition.name, + tableName: definition.tableName, + functionName: definition.functionName, + enabled: 'O', + })), + }; + } if (text.includes('FROM pg_catalog.pg_roles')) { return { rows: [ diff --git a/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts b/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts index e878d789..d613954b 100644 --- a/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts +++ b/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts @@ -313,5 +313,10 @@ export const postgresqlMainMigrationManifest: MigrationStreamManifest = checksum: 'c995b7846ae8a57d3abb4b5523961e81aeba890e7405a030bcb505dfc6be3d25', }), + Object.freeze({ + id: 'pg-0062-plugin-package-secret-binding-target-guard', + checksum: + 'cd4f92d8702da6b92dd9ae5153b5180400b94442f56393692b6ec038f998596b', + }), ]), }); diff --git a/packages/ql3-cluster-postgres/src/migrations/index.ts b/packages/ql3-cluster-postgres/src/migrations/index.ts index 89839d3a..d369a541 100644 --- a/packages/ql3-cluster-postgres/src/migrations/index.ts +++ b/packages/ql3-cluster-postgres/src/migrations/index.ts @@ -64,6 +64,7 @@ import { pg0058PluginPackageAutomationDispositionEventsMigration } from './pg-00 import { pg0059PluginPackageSecretBindingsMigration } from './pg-0059-plugin-package-secret-bindings'; import { pg0060PluginPackageSecretMaterializationGuardMigration } from './pg-0060-plugin-package-secret-materialization-guard'; import { pg0061PluginPackageSecretBindingApprovalPlansMigration } from './pg-0061-plugin-package-secret-binding-approval-plans'; +import { pg0062PluginPackageSecretBindingTargetGuardMigration } from './pg-0062-plugin-package-secret-binding-target-guard'; export const postgresqlMainMigrationStream: MigrationStreamDefinition = Object.freeze({ @@ -133,5 +134,6 @@ export const postgresqlMainMigrationStream: MigrationStreamDefinition> 'manifestDigest' = NEW.manifest_digest + AND ( + ( + install.state = 'active' AND + install.active_lock_digest = install.lock_digest + ) OR ( + install.state = 'staged' AND + install.previous_active_lock_digest IS NOT NULL AND + install.active_lock_digest = install.previous_active_lock_digest AND + install.target_generation = ( + SELECT MAX(history.target_generation) + FROM "ql3"."plugin_package_installs" AS history + WHERE history.project_id = install.project_id + AND history.package_name = install.package_name + ) AND + EXISTS ( + SELECT 1 + FROM "ql3"."plugin_package_installs" AS previous + WHERE previous.project_id = install.project_id + AND previous.package_name = install.package_name + AND previous.lock_digest = install.previous_active_lock_digest + AND previous.state = 'active' + AND previous.active_lock_digest = previous.lock_digest + AND previous.target_generation < install.target_generation + ) + ) + ) + FOR SHARE OF head, install; + + IF NOT FOUND THEN + RAISE EXCEPTION + 'Plugin Package Secret binding target is not current active or reviewed staged generation' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END +$ql3$ + `.trim(), + `REVOKE ALL ON FUNCTION "ql3"."enforce_plugin_package_secret_binding_target"() FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress`, + `CREATE TRIGGER ql3_plugin_package_secret_binding_target_guard BEFORE INSERT ON "ql3"."plugin_package_secret_bindings" FOR EACH ROW EXECUTE FUNCTION "ql3"."enforce_plugin_package_secret_binding_target"()`, + `DO $ql3$ BEGIN UPDATE "ql3"."schema_capabilities" SET contract_version = 61, migration_id = 'pg-0062-plugin-package-secret-binding-target-guard', capabilities = '${CAPABILITIES_V61}'::jsonb, updated_at_ms = floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint WHERE contract_name = 'control-core' AND contract_version = 60 AND migration_id = 'pg-0061-plugin-package-secret-binding-approval-plans' AND capabilities = '${CAPABILITIES_V60}'::jsonb; IF NOT FOUND THEN RAISE EXCEPTION 'control-core capability is not at version 60' USING ERRCODE = 'check_violation'; END IF; END $ql3$`, + ], + }); diff --git a/packages/ql3-cluster-postgres/src/plugin-package/installation/pluginPackageSecretBindingRepository.ts b/packages/ql3-cluster-postgres/src/plugin-package/installation/pluginPackageSecretBindingRepository.ts index 0800b3c1..6e6de60e 100644 --- a/packages/ql3-cluster-postgres/src/plugin-package/installation/pluginPackageSecretBindingRepository.ts +++ b/packages/ql3-cluster-postgres/src/plugin-package/installation/pluginPackageSecretBindingRepository.ts @@ -193,10 +193,31 @@ export class PostgresPluginPackageSecretBindingRepository AND install.project_id = $2 AND install.package_name = $3 AND install.lock_digest = $5 - AND install.active_lock_digest = $5 AND install.target_generation = $6 - AND install.state = 'active' AND install.lock_json ->> 'manifestDigest' = $7 + AND ( + (install.state = 'active' AND + install.active_lock_digest = install.lock_digest) OR + (install.state = 'staged' AND + install.previous_active_lock_digest IS NOT NULL AND + install.active_lock_digest = install.previous_active_lock_digest AND + install.target_generation = ( + SELECT MAX(history.target_generation) + FROM "ql3"."plugin_package_installs" AS history + WHERE history.project_id = install.project_id + AND history.package_name = install.package_name + ) AND + EXISTS ( + SELECT 1 + FROM "ql3"."plugin_package_installs" AS previous + WHERE previous.project_id = install.project_id + AND previous.package_name = install.package_name + AND previous.lock_digest = install.previous_active_lock_digest + AND previous.state = 'active' + AND previous.active_lock_digest = previous.lock_digest + AND previous.target_generation < install.target_generation + )) + ) ON CONFLICT (generation_digest) DO NOTHING RETURNING generation_digest`, [ @@ -217,7 +238,7 @@ export class PostgresPluginPackageSecretBindingRepository const stored = await this.findStored(binding.target.generationDigest); if (!stored) { throw new PluginPackageSecretBindingConflictError( - 'binding target is not the current active Package generation', + 'binding target is not the current active or reviewed staged Package generation', ); } if (JSON.stringify(stored) !== bindingJson) { diff --git a/packages/ql3-cluster-postgres/src/schema/schemaContract.ts b/packages/ql3-cluster-postgres/src/schema/schemaContract.ts index 76d2f9ee..5eff6ddf 100644 --- a/packages/ql3-cluster-postgres/src/schema/schemaContract.ts +++ b/packages/ql3-cluster-postgres/src/schema/schemaContract.ts @@ -12,11 +12,17 @@ export interface PostgresSchemaContractFunction { readonly configuration: readonly string[]; } +export interface PostgresSchemaContractTrigger { + readonly name: string; + readonly tableName: string; + readonly functionName: string; +} + export interface PostgresSchemaContract { readonly schema: 'ql3'; readonly contractName: 'control-core'; - readonly contractVersion: 60; - readonly migrationId: 'pg-0061-plugin-package-secret-binding-approval-plans'; + readonly contractVersion: 61; + readonly migrationId: 'pg-0062-plugin-package-secret-binding-target-guard'; readonly minimumServerMajor: 16; readonly maximumServerMajor: 18; readonly capabilities: Readonly<{ @@ -57,6 +63,7 @@ export interface PostgresSchemaContract { plugin_package_materialized_revision: 1; plugin_package_secret_binding: 1; plugin_package_secret_binding_approval_plan: 1; + plugin_package_secret_binding_transition: 1; plugin_package_secret_materialization: 1; plugin_package_proposal: 1; plugin_package_publisher_provenance: 1; @@ -94,6 +101,7 @@ export interface PostgresSchemaContract { readonly checks: readonly string[]; readonly foreignKeys: readonly string[]; readonly functions: readonly PostgresSchemaContractFunction[]; + readonly triggers: readonly PostgresSchemaContractTrigger[]; } function table( @@ -107,8 +115,8 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = Object.freeze({ schema: 'ql3', contractName: 'control-core', - contractVersion: 60, - migrationId: 'pg-0061-plugin-package-secret-binding-approval-plans', + contractVersion: 61, + migrationId: 'pg-0062-plugin-package-secret-binding-target-guard', minimumServerMajor: 16, maximumServerMajor: 18, capabilities: Object.freeze({ @@ -142,6 +150,7 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = plugin_package_materialized_revision: 1, plugin_package_secret_binding: 1, plugin_package_secret_binding_approval_plan: 1, + plugin_package_secret_binding_transition: 1, plugin_package_secret_materialization: 1, plugin_package_proposal: 1, plugin_package_publisher_provenance: 1, @@ -2384,6 +2393,14 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = volatility: 'volatile', configuration: Object.freeze(['search_path=pg_catalog, ql3']), }), + Object.freeze({ + name: 'enforce_plugin_package_secret_binding_target', + identityArguments: '', + owner: 'ql3_migration', + securityDefiner: false, + volatility: 'volatile', + configuration: Object.freeze(['search_path=pg_catalog, ql3']), + }), Object.freeze({ name: 'enforce_plugin_package_secret_materialization', identityArguments: '', @@ -2516,4 +2533,16 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = configuration: Object.freeze(['search_path=pg_catalog, ql3']), }), ]), + triggers: Object.freeze([ + Object.freeze({ + name: 'ql3_plugin_package_secret_binding_target_guard', + tableName: 'plugin_package_secret_bindings', + functionName: 'enforce_plugin_package_secret_binding_target', + }), + Object.freeze({ + name: 'ql3_plugin_package_secret_materialization_guard', + tableName: 'plugin_package_materialized_revisions', + functionName: 'enforce_plugin_package_secret_materialization', + }), + ]), }); diff --git a/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts b/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts index e38f9378..113ce48a 100644 --- a/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts +++ b/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts @@ -89,6 +89,13 @@ interface FunctionRow extends Record { publicExecute: unknown; } +interface TriggerRow extends Record { + triggerName: unknown; + tableName: unknown; + functionName: unknown; + enabled: unknown; +} + interface SchemaPrivilegeRow extends Record { schemaUsage: unknown; schemaCreate: unknown; @@ -1523,6 +1530,7 @@ const REQUIRED_RUNTIME_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = commit_plugin_package_quarantine: false, commit_plugin_package_task_reconciliation: false, enforce_plugin_package_secret_materialization: false, + enforce_plugin_package_secret_binding_target: false, enforce_plugin_package_stage_provenance: false, lock_active_plugin_package_project: false, lock_approval_policy_fence: false, @@ -1544,6 +1552,7 @@ const REQUIRED_PACKAGE_MANAGER_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = commit_plugin_package_quarantine: false, commit_plugin_package_task_reconciliation: false, enforce_plugin_package_secret_materialization: false, + enforce_plugin_package_secret_binding_target: false, enforce_plugin_package_stage_provenance: false, lock_active_plugin_package_project: false, lock_approval_policy_fence: true, @@ -1565,6 +1574,7 @@ const REQUIRED_PACKAGE_EXECUTOR_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges commit_plugin_package_quarantine: true, commit_plugin_package_task_reconciliation: true, enforce_plugin_package_secret_materialization: false, + enforce_plugin_package_secret_binding_target: false, enforce_plugin_package_stage_provenance: false, lock_active_plugin_package_project: true, lock_approval_policy_fence: true, @@ -1728,7 +1738,13 @@ async function assertSchemaContract( queryable: PostgresMigrationQueryable, contract: PostgresSchemaContract, ): Promise { - const [columnsResult, indexesResult, constraintsResult, functionsResult] = + const [ + columnsResult, + indexesResult, + constraintsResult, + functionsResult, + triggersResult, + ] = await Promise.all([ queryable.query( ` @@ -1803,6 +1819,27 @@ ORDER BY routines.proname, pg_get_function_identity_arguments(routines.oid) `.trim(), [contract.schema], ), + queryable.query( + ` +SELECT + triggers.tgname AS "triggerName", + tables.relname AS "tableName", + routines.proname AS "functionName", + triggers.tgenabled AS "enabled" +FROM pg_trigger triggers +JOIN pg_class tables ON tables.oid = triggers.tgrelid +JOIN pg_namespace schemas ON schemas.oid = tables.relnamespace +JOIN pg_proc routines ON routines.oid = triggers.tgfoid +WHERE schemas.nspname = $1 + AND NOT triggers.tgisinternal + AND triggers.tgname = ANY($2::text[]) +ORDER BY triggers.tgname + `.trim(), + [ + contract.schema, + contract.triggers.map(({ name }) => name), + ], + ), ]); const actualTables = new Map>(); for (const row of columnsResult.rows) { @@ -1943,6 +1980,35 @@ ORDER BY routines.proname, pg_get_function_identity_arguments(routines.oid) findings.push(`unknown-function:${identity}`); } } + const actualTriggers = new Map(); + for (const row of triggersResult.rows) { + if ( + typeof row.triggerName !== 'string' || + typeof row.tableName !== 'string' || + typeof row.functionName !== 'string' || + typeof row.enabled !== 'string' + ) { + throw new PostgresSchemaReadinessError('schema_contract_invalid'); + } + actualTriggers.set(row.triggerName, row); + } + for (const expected of contract.triggers) { + const actual = actualTriggers.get(expected.name); + if (!actual) { + findings.push(`missing-trigger:${expected.name}`); + continue; + } + if ( + actual.tableName !== expected.tableName || + actual.functionName !== expected.functionName || + actual.enabled !== 'O' + ) { + findings.push(`trigger-contract:${expected.name}`); + } + } + if (actualTriggers.size !== contract.triggers.length) { + findings.push('trigger-contract-row-count'); + } if (findings.length > 0) { throw new PostgresSchemaReadinessError( 'schema_contract_invalid', diff --git a/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingRepository.test.cjs b/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingRepository.test.cjs index cf122b5c..a48834b0 100644 --- a/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingRepository.test.cjs +++ b/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingRepository.test.cjs @@ -129,6 +129,14 @@ test('publishes, exact-replays and finds one binding', async () => { value.queries.find(({ text }) => text.startsWith('INSERT')).text, /install\.state = 'active'/, ); + assert.match( + value.queries.find(({ text }) => text.startsWith('INSERT')).text, + /install\.state = 'staged'/, + ); + assert.match( + value.queries.find(({ text }) => text.startsWith('INSERT')).text, + /MAX\(history\.target_generation\)/, + ); }); test('rejects inactive targets and conflicting content', async () => { diff --git a/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingTransition.integration.test.cjs b/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingTransition.integration.test.cjs new file mode 100644 index 00000000..fb69046e --- /dev/null +++ b/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingTransition.integration.test.cjs @@ -0,0 +1,461 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + PluginPackageSecretBindingConflictError, + createPluginPackageSecretBinding, +} = require('@qinglong/runtime-core/plugin-package-secret-binding'); +const { + createPluginPackageResourceGeneration, +} = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + assertPostgresPackageExecutorSchemaReady, + createPostgresDatabaseOpener, + PostgresPluginPackageSecretBindingRepository, +} = require('../dist/entrypoints/packageExecutor'); +const { + runPostgresMigrations, +} = require('../dist/migration/migration'); + +const migrationConnectionString = + process.env.QL3_TEST_POSTGRES_MIGRATION_URL; +const executorConnectionString = + process.env.QL3_TEST_POSTGRES_PACKAGE_EXECUTOR_URL; + +function manifest(packageName) { + return { + apiVersion: 'qinglong.io/v1alpha1', + kind: 'Package', + metadata: { + name: packageName, + displayName: packageName, + version: '1.0.0', + description: 'PostgreSQL Secret binding transition gate', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['cluster-control'], + }, + runtimes: [], + resources: { + memory: { recommended: '16Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets: [{ name: 'TOKEN', required: true }], + tools: [], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }; +} + +function binding({ + projectId, + packageName, + installationId, + lockDigest, + generation, + previousActiveLockDigest, + digestSeed, +}) { + const packageManifest = manifest(packageName); + const resourceGeneration = createPluginPackageResourceGeneration({ + installationId, + projectId, + packageName, + lockDigest, + generation, + previousActiveLockDigest, + contentDigest: digestSeed.repeat(64), + contents: packageManifest.spec.contents, + }); + return createPluginPackageSecretBinding({ + generation: resourceGeneration, + manifest: packageManifest, + assignments: [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId, + name: 'runtime-token', + version: generation, + }), + }, + ], + authority: { + kind: 'approved-action-execution', + evidenceDigest: digestSeed.repeat(64), + }, + boundAtMs: 100 + generation, + }); +} + +async function insertProject(pool, projectId) { + await pool.query( + `INSERT INTO "ql3"."projects" ( + id, name, slug, status, version, created_at_ms, updated_at_ms + ) VALUES ($1, $1, $1, 'active', 1, 1, 1)`, + [projectId], + ); +} + +async function insertInstall( + pool, + { + projectId, + packageName, + installationId, + lockDigest, + targetGeneration, + previousActiveLockDigest, + activeLockDigest, + state, + manifestDigest, + createdAtMs, + }, +) { + const recordDigest = lockDigest; + const mutationDigest = lockDigest; + const lockJson = { + lockDigest, + projectId, + packageName, + manifestDigest, + }; + const recordJson = { + installationId, + projectId, + packageName, + lockDigest, + state, + version: 1, + recordDigest, + }; + await pool.query( + `INSERT INTO "ql3"."plugin_package_installs" ( + installation_id, project_id, package_name, package_version, + operation, lock_digest, target_generation, + previous_active_lock_digest, active_lock_digest, state, version, + last_mutation_id, last_mutation_digest, lock_json, record_json, + record_digest, created_at_ms, updated_at_ms + ) VALUES ( + $1, $2, $3, '1.0.0', $4, $5, $6, $7, $8, $9, 1, + $10, $11, $12::jsonb, $13::jsonb, $14, $15, $15 + )`, + [ + installationId, + projectId, + packageName, + targetGeneration === 1 ? 'install' : 'upgrade', + lockDigest, + targetGeneration, + previousActiveLockDigest, + activeLockDigest, + state, + `mutation-${installationId}`, + mutationDigest, + JSON.stringify(lockJson), + JSON.stringify(recordJson), + recordDigest, + createdAtMs, + ], + ); +} + +async function insertHead(pool, value) { + await pool.query( + `INSERT INTO "ql3"."plugin_package_install_heads" ( + project_id, package_name, installation_id + ) VALUES ($1, $2, $3)`, + [value.projectId, value.packageName, value.installationId], + ); +} + +async function insertBindingDirectly(pool, value) { + return pool.query( + `INSERT INTO "ql3"."plugin_package_secret_bindings" ( + generation_digest, project_id, package_name, installation_id, + lock_digest, generation, manifest_digest, authority_kind, + evidence_digest, bound_at_ms, binding_digest, binding_json + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb + )`, + [ + value.target.generationDigest, + value.target.projectId, + value.target.packageName, + value.target.installationId, + value.target.lockDigest, + value.target.generation, + value.target.manifestDigest, + value.authority.kind, + value.authority.evidenceDigest, + value.boundAtMs, + value.bindingDigest, + JSON.stringify(value), + ], + ); +} + +async function open(role, connectionString) { + return createPostgresDatabaseOpener({ + role, + connection: { + connectionString, + tls: { mode: 'disable' }, + }, + pool: { + maxConnections: 1, + applicationName: `ql3-b2-secret-binding-${role}`, + }, + onPoolError(error) { + throw error; + }, + })(); +} + +if (!migrationConnectionString || !executorConnectionString) { + test('PostgreSQL Secret binding transition gate requires migration and executor URLs', { + skip: true, + }); +} else { + test('PostgreSQL enforces active and reviewed staged Secret binding targets', async () => { + const migrationDatabase = await open( + 'migration', + migrationConnectionString, + ); + let executorDatabase; + try { + await runPostgresMigrations({ pool: migrationDatabase.pool }); + executorDatabase = await open( + 'package-executor', + executorConnectionString, + ); + const readiness = await assertPostgresPackageExecutorSchemaReady( + executorDatabase.pool, + ); + assert.equal(readiness.ready, true); + assert.equal(readiness.contractVersion, 61); + + const repository = new PostgresPluginPackageSecretBindingRepository( + executorDatabase.pool, + ); + const namespace = `b2-${process.pid}-${Date.now()}`; + + const activeProjectId = `${namespace}-active`; + const activePackageName = 'active-binding'; + const activeInstallationId = `${namespace}-active-install`; + const activeLockDigest = 'a'.repeat(64); + const activeBinding = binding({ + projectId: activeProjectId, + packageName: activePackageName, + installationId: activeInstallationId, + lockDigest: activeLockDigest, + generation: 1, + previousActiveLockDigest: null, + digestSeed: 'b', + }); + await insertProject(migrationDatabase.pool, activeProjectId); + await insertInstall(migrationDatabase.pool, { + projectId: activeProjectId, + packageName: activePackageName, + installationId: activeInstallationId, + lockDigest: activeLockDigest, + targetGeneration: 1, + previousActiveLockDigest: null, + activeLockDigest, + state: 'active', + manifestDigest: activeBinding.target.manifestDigest, + createdAtMs: 1, + }); + await insertHead(migrationDatabase.pool, { + projectId: activeProjectId, + packageName: activePackageName, + installationId: activeInstallationId, + }); + assert.equal((await repository.publish(activeBinding)).status, 'created'); + + const stagedProjectId = `${namespace}-staged`; + const stagedPackageName = 'staged-binding'; + const previousInstallationId = `${namespace}-previous-install`; + const stagedInstallationId = `${namespace}-staged-install`; + const previousLockDigest = 'c'.repeat(64); + const stagedLockDigest = 'd'.repeat(64); + const stagedBinding = binding({ + projectId: stagedProjectId, + packageName: stagedPackageName, + installationId: stagedInstallationId, + lockDigest: stagedLockDigest, + generation: 2, + previousActiveLockDigest: previousLockDigest, + digestSeed: 'e', + }); + await insertProject(migrationDatabase.pool, stagedProjectId); + await insertInstall(migrationDatabase.pool, { + projectId: stagedProjectId, + packageName: stagedPackageName, + installationId: previousInstallationId, + lockDigest: previousLockDigest, + targetGeneration: 1, + previousActiveLockDigest: null, + activeLockDigest: previousLockDigest, + state: 'active', + manifestDigest: stagedBinding.target.manifestDigest, + createdAtMs: 1, + }); + await insertInstall(migrationDatabase.pool, { + projectId: stagedProjectId, + packageName: stagedPackageName, + installationId: stagedInstallationId, + lockDigest: stagedLockDigest, + targetGeneration: 2, + previousActiveLockDigest: previousLockDigest, + activeLockDigest: previousLockDigest, + state: 'staged', + manifestDigest: stagedBinding.target.manifestDigest, + createdAtMs: 2, + }); + await insertHead(migrationDatabase.pool, { + projectId: stagedProjectId, + packageName: stagedPackageName, + installationId: stagedInstallationId, + }); + assert.equal((await repository.publish(stagedBinding)).status, 'created'); + assert.equal((await repository.publish(stagedBinding)).status, 'existing'); + + const activatingProjectId = `${namespace}-activating`; + const activatingPackageName = 'activating-binding'; + const activatingPreviousId = `${namespace}-activating-previous`; + const activatingInstallationId = `${namespace}-activating-install`; + const activatingPreviousLock = 'f'.repeat(64); + const activatingLock = '1'.repeat(64); + const activatingBinding = binding({ + projectId: activatingProjectId, + packageName: activatingPackageName, + installationId: activatingInstallationId, + lockDigest: activatingLock, + generation: 2, + previousActiveLockDigest: activatingPreviousLock, + digestSeed: '2', + }); + await insertProject(migrationDatabase.pool, activatingProjectId); + await insertInstall(migrationDatabase.pool, { + projectId: activatingProjectId, + packageName: activatingPackageName, + installationId: activatingPreviousId, + lockDigest: activatingPreviousLock, + targetGeneration: 1, + previousActiveLockDigest: null, + activeLockDigest: activatingPreviousLock, + state: 'active', + manifestDigest: activatingBinding.target.manifestDigest, + createdAtMs: 1, + }); + await insertInstall(migrationDatabase.pool, { + projectId: activatingProjectId, + packageName: activatingPackageName, + installationId: activatingInstallationId, + lockDigest: activatingLock, + targetGeneration: 2, + previousActiveLockDigest: activatingPreviousLock, + activeLockDigest: activatingPreviousLock, + state: 'activating', + manifestDigest: activatingBinding.target.manifestDigest, + createdAtMs: 2, + }); + await insertHead(migrationDatabase.pool, { + projectId: activatingProjectId, + packageName: activatingPackageName, + installationId: activatingInstallationId, + }); + await assert.rejects( + repository.publish(activatingBinding), + PluginPackageSecretBindingConflictError, + ); + await assert.rejects( + insertBindingDirectly(executorDatabase.pool, activatingBinding), + (error) => + error?.code === '23514' && + /not current active or reviewed staged generation/.test( + error.message, + ), + ); + + const staleProjectId = `${namespace}-stale`; + const stalePackageName = 'stale-binding'; + const stalePreviousId = `${namespace}-stale-previous`; + const staleInstallationId = `${namespace}-stale-install`; + const newerInstallationId = `${namespace}-newer-install`; + const stalePreviousLock = '3'.repeat(64); + const staleLock = '4'.repeat(64); + const staleBinding = binding({ + projectId: staleProjectId, + packageName: stalePackageName, + installationId: staleInstallationId, + lockDigest: staleLock, + generation: 2, + previousActiveLockDigest: stalePreviousLock, + digestSeed: '5', + }); + await insertProject(migrationDatabase.pool, staleProjectId); + await insertInstall(migrationDatabase.pool, { + projectId: staleProjectId, + packageName: stalePackageName, + installationId: stalePreviousId, + lockDigest: stalePreviousLock, + targetGeneration: 1, + previousActiveLockDigest: null, + activeLockDigest: stalePreviousLock, + state: 'active', + manifestDigest: staleBinding.target.manifestDigest, + createdAtMs: 1, + }); + await insertInstall(migrationDatabase.pool, { + projectId: staleProjectId, + packageName: stalePackageName, + installationId: staleInstallationId, + lockDigest: staleLock, + targetGeneration: 2, + previousActiveLockDigest: stalePreviousLock, + activeLockDigest: stalePreviousLock, + state: 'staged', + manifestDigest: staleBinding.target.manifestDigest, + createdAtMs: 2, + }); + await insertInstall(migrationDatabase.pool, { + projectId: staleProjectId, + packageName: stalePackageName, + installationId: newerInstallationId, + lockDigest: '6'.repeat(64), + targetGeneration: 3, + previousActiveLockDigest: stalePreviousLock, + activeLockDigest: stalePreviousLock, + state: 'failed', + manifestDigest: staleBinding.target.manifestDigest, + createdAtMs: 3, + }); + await insertHead(migrationDatabase.pool, { + projectId: staleProjectId, + packageName: stalePackageName, + installationId: staleInstallationId, + }); + await assert.rejects( + repository.publish(staleBinding), + PluginPackageSecretBindingConflictError, + ); + await assert.rejects( + insertBindingDirectly(executorDatabase.pool, staleBinding), + (error) => error?.code === '23514', + ); + } finally { + if (executorDatabase) await executorDatabase.close(); + await migrationDatabase.close(); + } + }); +} diff --git a/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs b/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs index 62b10a2a..f6a49e14 100644 --- a/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs +++ b/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs @@ -112,6 +112,7 @@ test('defines the immutable PostgreSQL capability and Run core stream', async () 'pg-0059-plugin-package-secret-bindings', 'pg-0060-plugin-package-secret-materialization-guard', 'pg-0061-plugin-package-secret-binding-approval-plans', + 'pg-0062-plugin-package-secret-binding-target-guard', ], ); for (const migration of postgresqlMainMigrationStream.migrations) { @@ -554,6 +555,11 @@ test('freezes every published PostgreSQL migration checksum', () => { checksum: 'c995b7846ae8a57d3abb4b5523961e81aeba890e7405a030bcb505dfc6be3d25', }, + { + id: 'pg-0062-plugin-package-secret-binding-target-guard', + checksum: + 'cd4f92d8702da6b92dd9ae5153b5180400b94442f56393692b6ec038f998596b', + }, ]; assert.deepEqual( postgresqlMainMigrationStream.migrations.map(({ id, checksum }) => ({ @@ -2083,3 +2089,41 @@ test('advances capability v60 with least-privilege Package Secret binding approv /migration_id = 'pg-0060-plugin-package-secret-materialization-guard'/, ); }); + +test('advances capability v61 with a staged-target Secret binding database guard', async () => { + const migration = migrationById( + 'pg-0062-plugin-package-secret-binding-target-guard', + ); + const statements = []; + await migration.up({ + async query(statement) { + statements.push(statement); + return { rows: [] }; + }, + }); + const sql = statements.join('\n'); + assert.match( + sql, + /CREATE FUNCTION "ql3"\."enforce_plugin_package_secret_binding_target"\(\)/, + ); + assert.match( + sql, + /CREATE TRIGGER ql3_plugin_package_secret_binding_target_guard BEFORE INSERT/, + ); + assert.match(sql, /install\.state = 'active'/); + assert.match(sql, /install\.state = 'staged'/); + assert.match(sql, /MAX\(history\.target_generation\)/); + assert.match(sql, /previous\.state = 'active'/); + assert.doesNotMatch(sql, /SECURITY DEFINER/); + assert.match( + sql, + /REVOKE ALL ON FUNCTION [^;]+ FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress/, + ); + assert.match(sql, /contract_version = 61/); + assert.match(sql, /"plugin_package_secret_binding_transition":1/); + assert.match(sql, /contract_version = 60/); + assert.match( + sql, + /migration_id = 'pg-0061-plugin-package-secret-binding-approval-plans'/, + ); +}); diff --git a/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs b/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs index ff1621db..d8f2c81c 100644 --- a/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs +++ b/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs @@ -716,6 +716,19 @@ function queryable(overrides = {}) { })), }; } + if (text.includes('FROM pg_trigger triggers')) { + return { + rows: contract.triggers + .filter(({ name }) => name !== overrides.missingTrigger) + .map((definition) => ({ + triggerName: definition.name, + tableName: definition.tableName, + functionName: definition.functionName, + enabled: + definition.name === overrides.disabledTrigger ? 'D' : 'O', + })), + }; + } if (text.includes('has_schema_privilege')) { return { rows: [ @@ -763,7 +776,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro serverMajor: 16, currentUser: 'ql3_runtime', contractName: 'control-core', - contractVersion: 60, + contractVersion: 61, migrationIds: [ 'pg-0001-schema-capability', 'pg-0002-run-core', @@ -826,6 +839,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro 'pg-0059-plugin-package-secret-bindings', 'pg-0060-plugin-package-secret-materialization-guard', 'pg-0061-plugin-package-secret-binding-approval-plans', + 'pg-0062-plugin-package-secret-binding-target-guard', ], }); }); @@ -856,10 +870,10 @@ test('accepts the exact schema and isolated least-privilege admin role', async ( }), ); assert.equal(report.currentUser, 'ql3_admin'); - assert.equal(report.contractVersion, 60); + assert.equal(report.contractVersion, 61); assert.equal( report.migrationIds.at(-1), - 'pg-0061-plugin-package-secret-binding-approval-plans', + 'pg-0062-plugin-package-secret-binding-target-guard', ); }); @@ -872,10 +886,10 @@ test('accepts the isolated least-privilege automation manager role', async () => }), ); assert.equal(report.currentUser, 'ql3_automation_manager'); - assert.equal(report.contractVersion, 60); + assert.equal(report.contractVersion, 61); assert.equal( report.migrationIds.at(-1), - 'pg-0061-plugin-package-secret-binding-approval-plans', + 'pg-0062-plugin-package-secret-binding-target-guard', ); const widened = automationManagerPrivileges(); @@ -904,10 +918,10 @@ test('accepts the isolated least-privilege human Approval manager role', async ( }), ); assert.equal(report.currentUser, 'ql3_approval_manager'); - assert.equal(report.contractVersion, 60); + assert.equal(report.contractVersion, 61); assert.equal( report.migrationIds.at(-1), - 'pg-0061-plugin-package-secret-binding-approval-plans', + 'pg-0062-plugin-package-secret-binding-target-guard', ); const widened = approvalManagerPrivileges(); @@ -938,10 +952,10 @@ test('accepts the isolated least-privilege Run manager role', async () => { }), ); assert.equal(report.currentUser, 'ql3_run_manager'); - assert.equal(report.contractVersion, 60); + assert.equal(report.contractVersion, 61); assert.equal( report.migrationIds.at(-1), - 'pg-0061-plugin-package-secret-binding-approval-plans', + 'pg-0062-plugin-package-secret-binding-target-guard', ); const widened = runManagerPrivileges(); @@ -1073,10 +1087,10 @@ test('accepts the exact schema and isolated Worker ingress role', async () => { }), ); assert.equal(report.currentUser, 'ql3_worker_ingress'); - assert.equal(report.contractVersion, 60); + assert.equal(report.contractVersion, 61); assert.equal( report.migrationIds.at(-1), - 'pg-0061-plugin-package-secret-binding-approval-plans', + 'pg-0062-plugin-package-secret-binding-target-guard', ); }); @@ -1159,6 +1173,35 @@ test('rejects unknown ql3 objects and an over-privileged runtime role', async () ); }); +test('fails closed when a reviewed Package Secret trigger is missing or disabled', async () => { + await assert.rejects( + assertPostgresSchemaReady( + queryable({ + missingTrigger: 'ql3_plugin_package_secret_binding_target_guard', + }), + ), + (error) => + error instanceof PostgresSchemaReadinessError && + error.code === 'schema_contract_invalid' && + error.facts.includes( + 'missing-trigger:ql3_plugin_package_secret_binding_target_guard', + ), + ); + await assert.rejects( + assertPostgresSchemaReady( + queryable({ + disabledTrigger: 'ql3_plugin_package_secret_materialization_guard', + }), + ), + (error) => + error instanceof PostgresSchemaReadinessError && + error.code === 'schema_contract_invalid' && + error.facts.includes( + 'trigger-contract:ql3_plugin_package_secret_materialization_guard', + ), + ); +}); + test('preserves database availability errors for the outer readiness layer', async () => { const unavailable = new Error('database unavailable'); let calls = 0; diff --git a/packages/ql3-local-owner-cli/test/localDeployment.test.cjs b/packages/ql3-local-owner-cli/test/localDeployment.test.cjs index df23f98f..4f7580d4 100644 --- a/packages/ql3-local-owner-cli/test/localDeployment.test.cjs +++ b/packages/ql3-local-owner-cli/test/localDeployment.test.cjs @@ -363,9 +363,9 @@ function composeDockerHarness( '/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js', ], Labels: { - 'io.qinglong.local.sqlite-contract-min': '47', - 'io.qinglong.local.sqlite-contract-max': '47', - 'io.qinglong.local.sqlite-write-contract': '47', + 'io.qinglong.local.sqlite-contract-min': '48', + 'io.qinglong.local.sqlite-contract-max': '48', + 'io.qinglong.local.sqlite-write-contract': '48', 'io.qinglong.local.application-config': '2', 'io.qinglong.local.compose-selection': '1', 'io.qinglong.ai': 'excluded', @@ -975,9 +975,9 @@ test('preflights exact local image, Compose merge and SQLite capability', async '/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js', ], Labels: { - 'io.qinglong.local.sqlite-contract-min': '47', - 'io.qinglong.local.sqlite-contract-max': '47', - 'io.qinglong.local.sqlite-write-contract': '47', + 'io.qinglong.local.sqlite-contract-min': '48', + 'io.qinglong.local.sqlite-contract-max': '48', + 'io.qinglong.local.sqlite-write-contract': '48', 'io.qinglong.local.application-config': '2', 'io.qinglong.local.compose-selection': '1', 'io.qinglong.ai': 'excluded', @@ -1029,7 +1029,7 @@ test('preflights exact local image, Compose merge and SQLite capability', async assert.equal(result.status, 'ready'); assert.equal(result.generation, 1); assert.equal(result.profile, 'edge'); - assert.equal(result.sqlite.contractVersion, 47); + assert.equal(result.sqlite.contractVersion, 48); assert.equal(result.image.architecture, 'arm64'); assert.equal(calls.length, 2); assert.deepEqual(calls[0].slice(0, 2), ['image', 'inspect']); @@ -1129,8 +1129,8 @@ test('applies one Compose generation and exactly replays its health receipt', as assert.equal(mode(receiptPath), 0o600); const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8')); assert.deepEqual(receipt.sqlite, { - contractVersion: 47, - writeContractVersion: 47, + contractVersion: 48, + writeContractVersion: 48, writeObservation: 'unchanged', backup: null, }); @@ -1427,8 +1427,8 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async `${command.request.rolloutId}.sqlite`, ); assert.equal(mode(backupPath), 0o600); - assert.equal(receipt.sqlite.contractVersion, 47); - assert.equal(receipt.sqlite.writeContractVersion, 47); + assert.equal(receipt.sqlite.contractVersion, 48); + assert.equal(receipt.sqlite.writeContractVersion, 48); assert.equal(receipt.sqlite.writeObservation, 'changed'); assert.match(receipt.sqlite.backup.sha256, /^[0-9a-f]{64}$/); assert.equal(receipt.sqlite.backup.bytes > 0, true); diff --git a/packages/ql3-local-owner-cli/test/localReadiness.test.cjs b/packages/ql3-local-owner-cli/test/localReadiness.test.cjs index 1dd4281d..bc54c42c 100644 --- a/packages/ql3-local-owner-cli/test/localReadiness.test.cjs +++ b/packages/ql3-local-owner-cli/test/localReadiness.test.cjs @@ -34,8 +34,8 @@ test('inspects the exact fresh Profile schema without exposing its path', async assert.equal(result.status, 'ready'); assert.equal(result.profile, 'edge'); assert.equal(result.storage.contractName, 'local-control-core'); - assert.equal(result.storage.contractVersion, 47); - assert.equal(result.storage.migrationCount, 94); + assert.equal(result.storage.contractVersion, 48); + assert.equal(result.storage.migrationCount, 96); assert.equal(result.storage.journalMode, 'delete'); assert.equal(JSON.stringify(result).includes(state.directory), false); }); diff --git a/packages/ql3-local-sqlite/src/migration/migration.ts b/packages/ql3-local-sqlite/src/migration/migration.ts index 22735cd2..3f88f009 100644 --- a/packages/ql3-local-sqlite/src/migration/migration.ts +++ b/packages/ql3-local-sqlite/src/migration/migration.ts @@ -104,6 +104,8 @@ import { local0091PluginPackageSecretBindingsMigration } from '../migrations/009 import { local0092CapabilityV46Migration } from '../migrations/0092-capability-v46'; import { local0093PluginPackageSecretMaterializationGuardMigration } from '../migrations/0093-plugin-package-secret-materialization-guard'; import { local0094CapabilityV47Migration } from '../migrations/0094-capability-v47'; +import { local0095PluginPackageSecretBindingTargetGuardMigration } from '../migrations/0095-plugin-package-secret-binding-target-guard'; +import { local0096CapabilityV48Migration } from '../migrations/0096-capability-v48'; import type { LocalSqliteMigrationContext } from '../migrations/sqlMigration'; import { LOCAL_SQLITE_MIGRATION_STREAM_ID, @@ -220,6 +222,8 @@ export const localSqliteMigrationDefinition: MigrationStreamDefinition left.name.localeCompare(right.name)); if ( @@ -2564,10 +2574,10 @@ export async function auditLocalSqliteReadiness( capability.contract_name !== LOCAL_SQLITE_CONTRACT_NAME || capability.contract_version !== LOCAL_SQLITE_CONTRACT_VERSION || capability.migration_id !== - '0093-plugin-package-secret-materialization-guard' || + '0095-plugin-package-secret-binding-target-guard' || typeof capability.capabilities !== 'string' || capability.capabilities !== - '{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_secret_binding":1,"plugin_package_secret_materialization":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' || + '{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_secret_binding":1,"plugin_package_secret_binding_transition":1,"plugin_package_secret_materialization":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' || typeof capability.updated_at_ms !== 'number' || !Number.isSafeInteger(capability.updated_at_ms) || capability.updated_at_ms < 0 diff --git a/packages/ql3-local-sqlite/test/database.test.cjs b/packages/ql3-local-sqlite/test/database.test.cjs index 269f1bb1..7fb195dd 100644 --- a/packages/ql3-local-sqlite/test/database.test.cjs +++ b/packages/ql3-local-sqlite/test/database.test.cjs @@ -144,9 +144,11 @@ test('creates a reviewed edge database and opens runtime only after readiness', '0092-capability-v46', '0093-plugin-package-secret-materialization-guard', '0094-capability-v47', + '0095-plugin-package-secret-binding-target-guard', + '0096-capability-v48', ]); assert.equal(migrated.readiness.contractName, 'local-control-core'); - assert.equal(migrated.readiness.contractVersion, 47); + assert.equal(migrated.readiness.contractVersion, 48); assert.equal(migrated.readiness.journalMode, 'delete'); assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600); @@ -592,8 +594,8 @@ test('backfills v14 execution revisions with a verified independent digest', asy .get(), }, { - contract_version: 47, - migration_id: '0093-plugin-package-secret-materialization-guard', + contract_version: 48, + migration_id: '0095-plugin-package-secret-binding-target-guard', }, ); } finally { diff --git a/packages/ql3-local-sqlite/test/pluginPackageSecretBindingRepository.test.cjs b/packages/ql3-local-sqlite/test/pluginPackageSecretBindingRepository.test.cjs index bf7f5cf2..ead1b8cb 100644 --- a/packages/ql3-local-sqlite/test/pluginPackageSecretBindingRepository.test.cjs +++ b/packages/ql3-local-sqlite/test/pluginPackageSecretBindingRepository.test.cjs @@ -80,11 +80,38 @@ function fixture(boundAtMs = 100) { return { binding, generation }; } -async function harness(active = true) { +async function harness(state = 'active') { const client = new DatabaseSync(':memory:'); client.exec('PRAGMA foreign_keys = ON'); await migrateLocalSqliteDatabase(client); - const { binding, generation } = fixture(); + const isActive = state === 'active'; + const isStaged = state === 'staged'; + const source = fixture(); + const previousLockDigest = 'f'.repeat(64); + const generation = isStaged + ? createPluginPackageResourceGeneration({ + installationId: 'install-2', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: LOCK_DIGEST, + generation: 2, + previousActiveLockDigest: previousLockDigest, + contentDigest: 'b'.repeat(64), + contents: MANIFEST.spec.contents, + }) + : source.generation; + const binding = isStaged + ? createPluginPackageSecretBinding({ + generation, + manifest: MANIFEST, + assignments: source.binding.entries.map(({ name, secretRef }) => ({ + name, + secretRef, + })), + authority: source.binding.authority, + boundAtMs: source.binding.boundAtMs, + }) + : source.binding; client .prepare( `INSERT INTO "QingLong3Projects" @@ -100,11 +127,11 @@ async function harness(active = true) { manifestDigest: binding.target.manifestDigest, }); const recordJson = JSON.stringify({ - installationId: 'install-1', + installationId: isStaged ? 'install-2' : 'install-1', projectId: 'project-1', packageName: 'example-monitor', lockDigest: LOCK_DIGEST, - state: active ? 'active' : 'failed', + state, version: 1, recordDigest, }); @@ -116,16 +143,18 @@ async function harness(active = true) { previous_active_lock_digest, active_lock_digest, state, version, last_mutation_id, last_mutation_digest, lock_json, record_json, record_digest, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, '1.0.0', 'install', ?, 1, NULL, ?, ?, 1, + ) VALUES (?, ?, ?, '1.0.0', 'install', ?, ?, ?, ?, ?, 1, 'mutation-1', ?, ?, ?, ?, 1, 1)`, ) .run( - 'install-1', + isStaged ? 'install-2' : 'install-1', 'project-1', 'example-monitor', LOCK_DIGEST, - active ? LOCK_DIGEST : null, - active ? 'active' : 'failed', + generation.generation, + isStaged ? previousLockDigest : null, + isActive ? LOCK_DIGEST : isStaged ? previousLockDigest : null, + state, 'e'.repeat(64), lockJson, recordJson, @@ -135,9 +164,47 @@ async function harness(active = true) { .prepare( `INSERT INTO "QingLong3PluginPackageInstallHeads" (project_id, package_name, installation_id) - VALUES ('project-1', 'example-monitor', 'install-1')`, + VALUES ('project-1', 'example-monitor', ?)`, ) - .run(); + .run(isStaged ? 'install-2' : 'install-1'); + if (isStaged) { + const previousRecordDigest = '9'.repeat(64); + const previousLockJson = JSON.stringify({ + lockDigest: previousLockDigest, + projectId: 'project-1', + packageName: 'example-monitor', + manifestDigest: '8'.repeat(64), + }); + const previousRecordJson = JSON.stringify({ + installationId: 'install-1', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: previousLockDigest, + state: 'active', + version: 1, + recordDigest: previousRecordDigest, + }); + client + .prepare( + `INSERT INTO "QingLong3PluginPackageInstalls" ( + installation_id, project_id, package_name, package_version, + operation, lock_digest, target_generation, + previous_active_lock_digest, active_lock_digest, state, version, + last_mutation_id, last_mutation_digest, lock_json, record_json, + record_digest, created_at_ms, updated_at_ms + ) VALUES ('install-1', 'project-1', 'example-monitor', '0.9.0', + 'install', ?, 1, NULL, ?, 'active', 1, + 'mutation-previous', ?, ?, ?, ?, 0, 0)`, + ) + .run( + previousLockDigest, + previousLockDigest, + '7'.repeat(64), + previousLockJson, + previousRecordJson, + previousRecordDigest, + ); + } return { client, binding, @@ -161,7 +228,7 @@ test('publishes and exact-replays one active generation binding', async (t) => { }); test('rejects inactive targets and conflicting content', async (t) => { - const inactive = await harness(false); + const inactive = await harness('failed'); t.after(() => inactive.client.close()); await assert.rejects( inactive.repository.publish(inactive.binding), @@ -177,6 +244,21 @@ test('rejects inactive targets and conflicting content', async (t) => { ); }); +test('publishes a reviewed current staged generation but rejects post-stage states', async (t) => { + const staged = await harness('staged'); + t.after(() => staged.client.close()); + assert.equal((await staged.repository.publish(staged.binding)).status, 'created'); + + for (const state of ['queued', 'activating']) { + const rejected = await harness(state); + t.after(() => rejected.client.close()); + await assert.rejects( + rejected.repository.publish(rejected.binding), + PluginPackageSecretBindingConflictError, + ); + } +}); + test('fails closed when durable binding JSON is changed in place', async (t) => { const value = await harness(); t.after(() => value.client.close()); diff --git a/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs b/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs index 1e15ba94..fa009494 100644 --- a/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs +++ b/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs @@ -156,7 +156,7 @@ test('atomically admits one generation-bound Workflow Run and exactly replays it }, { runs: 1, steps: 2, events: 3, mutations: 2, admissions: 1 }, ); - assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 47); + assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 48); }); test('runs an optional authorization guard inside new and replay transactions', async (t) => { @@ -288,7 +288,7 @@ test('exactly replays immutable admission after the Workflow StepRun advances', }, { status: 'running', version: 5, eventSequence: 5 }, ); - assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 47); + assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 48); }); test('fails closed before writing when the exact installation is not active', async (t) => { diff --git a/packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskAttemptAdmissionRepository.test.cjs b/packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskAttemptAdmissionRepository.test.cjs index 107aef2e..84bdcd79 100644 --- a/packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskAttemptAdmissionRepository.test.cjs +++ b/packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskAttemptAdmissionRepository.test.cjs @@ -231,7 +231,7 @@ test('atomically admits the exact reconciled local Task revision and replays it' stepAttemptCount: 0, }, ); - assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 47); + assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 48); }); test('bounds candidate paging before SQL and fences cancellation', async (t) => { diff --git a/packages/ql3-local-sqlite/test/rolloutSafety.test.cjs b/packages/ql3-local-sqlite/test/rolloutSafety.test.cjs index a2b55acc..1828678b 100644 --- a/packages/ql3-local-sqlite/test/rolloutSafety.test.cjs +++ b/packages/ql3-local-sqlite/test/rolloutSafety.test.cjs @@ -40,9 +40,9 @@ test('creates and exactly replays a reviewed rollout backup', async (t) => { await migrateLocalSqlitePath(state); const prepared = await createLocalSqliteRolloutBackup(state); assert.equal(prepared.status, 'prepared'); - assert.equal(prepared.contractVersion, 47); - assert.equal(prepared.writeContractVersion, 47); - assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 47); + assert.equal(prepared.contractVersion, 48); + assert.equal(prepared.writeContractVersion, 48); + assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 48); assert.match(prepared.sha256, /^[0-9a-f]{64}$/); assert.equal(prepared.bytes > 0, true); assert.equal(prepared.pageCount > 0, true); diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index c341e5df..6876b6c6 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -74,12 +74,12 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( [ { directory: 'packages/ql3-cluster-postgres/src/migrations', - directSourceFiles: 62, + directSourceFiles: 63, reviewKind: 'ordered_ledger', }, { directory: 'packages/ql3-local-sqlite/src/migrations', - directSourceFiles: 95, + directSourceFiles: 97, reviewKind: 'ordered_ledger', }, ], @@ -299,10 +299,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: runtimeCore.rootSourceFileRoles, }, { - sourceFiles: 156, + sourceFiles: 157, rootSourceFiles: 1, rootSourceLines: 160, - nestedSourceFiles: 155, + nestedSourceFiles: 156, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, ); @@ -421,10 +421,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterPostgres.rootSourceFileRoles, }, { - sourceFiles: 159, + sourceFiles: 160, rootSourceFiles: 1, rootSourceLines: 126, - nestedSourceFiles: 158, + nestedSourceFiles: 159, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, ); @@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localSqlite.rootSourceFileRoles, }, { - sourceFiles: 188, + sourceFiles: 191, rootSourceFiles: 1, rootSourceLines: 31, - nestedSourceFiles: 187, + nestedSourceFiles: 190, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, );