diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4f1ae322f..5a01edd7b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -60,6 +60,7 @@ - 背景:后台模型定价原先保存到运行时 override JSON 文件,生产部署需要额外保证目录可写,且不符合当前 `server-rs + SpacetimeDB` 的配置事实源边界。 - 决策:模型定价默认 JSON 继续保留在 `server-rs/crates/api-server/config/editor-generation-pricing.default.json` 作为空表和数据库不可达时的兜底;运行时事实源改为 SpacetimeDB `editor_generation_pricing_config` 全局表,固定 `config_id = global`,以强类型 `models` 保存模型、单位和档位,不在 procedure / client 边界传递不透明 JSON。首次初始化通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时写入,并把 `ctx.sender()` 记为 `writer_identity`;表存在后 bootstrap secret 不得接管 writer,迁移操作员修复价格也必须保留 writer。runtime queue / 钱包 guard 只接受精确 writer,identity 轮换只能由迁移操作员调用独立 procedure,并写入 `editor_generation_runtime_identity_rotation` 审计表;migration operator 与 runtime writer 必须身份互斥,任何 operator 不能成为 writer,当前 writer 也不能授权为 operator,已有任一 operator 后 bootstrap secret 不得新增或接管 operator;生产统一由 `scripts/deploy/production-runtime-writer-identity-rotate.mjs` 双录新 identity 后执行并核对审计。后台 `/admin/api/editor-generation-pricing` 保存时必须入库,不再写 override 文件;主站 `/api/editor/generation-pricing` 和后端扣费入口优先读取 SpacetimeDB。旧 override 文件只作为启动本地缓存和首次空表种子的兼容来源。外部生成队列必须保存入队时价格,worker 的扣费、退款、响应和资产成本统一使用该冻结价格,配置更新不得改变已入队任务金额。队列 attempt 结算通过 `asset_operation_wallet_settlement` 持久化 consume/refund 配对或取消 intent;退款先到时,后续迟到 consume 必须失败,重复 ledger 只有用户、金额和来源一致才算幂等。lease 过期仅在 `attempt < max_attempts` 时允许重领;最终 attempt 耗尽后由 claim transaction 直接收口为 failed 并结算当前 attempt,不能再次进入 provider executor。运行时服务首次授权必须使用固定 64 位十六进制原始 bootstrap secret,WASM 只嵌入其 SHA-256,发布 artifact 不保存原文:本地 dev 把专用 API token 与按 server/database 作用域的 secret 分别持久化为 gitignored `0600` 文件,只注入 api-server;人工 production release 自动生成的原文只写 `server-rs/.spacetimedb/build-secrets/.txt`,目录 `0700`、文件 `0600`。生产 Jenkins 构建和发布阶段分别挂载同一个受保护 Secret File,Build / Publish 的 credential ID 必须相同;构建阶段只计算并注入 SHA-256,Stdb release manifest 以 `migration_bootstrap_secret_sha256` 记录摘要,发布阶段使用同一 Secret File 重算摘要并与 manifest 强制匹配后,才交给 `production-stdb-publish.sh` 安装为 root 持有、`genarrative` 组只读的固定运行时文件,归档和 `copyArtifacts` 都不含原文。Full Build 先发 Stdb、后发 API,所以 Stdb publish 必须同步补齐旧 API / worker env 的 FILE 配置并重启 active API / controller / worker,日志和前端子进程不得接触明文。重启前的 systemd 状态查询、active worker 枚举、重启后 active 复核以及原 active API 的本机 `/healthz` readiness 都是退出维护模式前的硬门禁,任一步查询或验证失败都必须保留维护模式。 +- 追加约束(2026-07-10):`writer_identity` 只保留在 private 表和轮换审计内,不进入定价 procedure 返回快照;只有 HTTP 角色负责空表 seed,worker / controller 启动改用受 writer 鉴权的 queue-stats procedure 做只读预检并继续 fail-fast。后台保存携带 `AppConfig` 已读取的受保护 bootstrap secret,只在配置行意外缺失时用于原子首写,已有配置仍按 writer / operator 鉴权且不能隐式轮换身份。 - 影响范围:`editor_generation_pricing_config`、`editor_generation_runtime_identity_rotation`、`asset_operation_wallet_settlement`、`editor_generation_config`、`spacetime-client` editor project facade、后台模型定价页、编辑器生成扣费链路、Stdb Build / Publish、Server-Provision、API deploy、生产 env 示例和模型定价文档。 - 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、模型定价路由定向测试、部署脚本 `bash -n`、`node --check scripts/dev.mjs scripts/check-production-ops-guardrails.mjs`、`npm run check:production-ops`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 8a6364eea..36a5d4e0c 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -543,7 +543,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorGenerationPricingConfig` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布生成类模型定价全局配置表,当前使用固定 `config_id = global`。`models: Vec` 强类型保存模型、定价单位、单价或档位列表,procedure / `spacetime-client` 边界不传递不透明 JSON;模块事务会再次校验完整正式模型矩阵、必需档位、单位、正价格和重复键。`writer_identity` 记录首次初始化的真实 `ctx.sender()`;后续 `upsert_editor_generation_pricing_config_and_return` 只允许同一 identity 或已授权迁移操作员修改价格,但始终保留原 writer。表为空时 `api-server` 通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时种子入库;表存在时 bootstrap secret 不能接管 writer。原始 bootstrap secret 固定为 64 位十六进制;WASM 只嵌入其 SHA-256,procedure 对入参原文重新计算摘要并做常量时间比较。runtime queue / 钱包 procedure 只接受精确 writer,迁移操作员不自动获得在线运行权限,且 operator / writer 身份必须互斥。SpacetimeDB 不可达时仅使用默认 JSON 或旧 override 缓存兜底。 +- 说明:图片画布生成类模型定价全局配置表,当前使用固定 `config_id = global`。`models: Vec` 强类型保存模型、定价单位、单价或档位列表,procedure / `spacetime-client` 边界不传递不透明 JSON;模块事务会再次校验完整正式模型矩阵、必需档位、单位、正价格和重复键。`writer_identity` 只保留在 private 表内,记录首次初始化的真实 `ctx.sender()`,不进入 procedure 返回快照;后续 `upsert_editor_generation_pricing_config_and_return` 只允许同一 identity 或已授权迁移操作员修改价格,但始终保留原 writer。表为空时只有 HTTP 角色通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时种子入库;worker / controller 启动只调用受鉴权的 queue-stats procedure 做只读身份预检。后台保存请求携带 `AppConfig` 中的受保护 bootstrap secret,以便配置行意外缺失时原子恢复;表存在时 bootstrap secret 不能接管 writer。原始 bootstrap secret 固定为 64 位十六进制;WASM 只嵌入其 SHA-256,procedure 对入参原文重新计算摘要并做常量时间比较。runtime queue / 钱包 procedure 只接受精确 writer,当前生产 API / worker / controller 因此继承同一 runtime token;迁移操作员不自动获得在线运行权限,且 operator / writer 身份必须互斥。SpacetimeDB 不可达时仅使用默认 JSON 或旧 override 缓存兜底。 - 索引:主键 `config_id`。 ### `editor_generation_runtime_identity_rotation` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 15242aab2..b9c713dfa 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -57,7 +57,7 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg 本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前进入持久队列的外部生成动作包括:拼图 `compile_puzzle_draft` / `generate_puzzle_images` / `generate_puzzle_ui_background`,跳一跳 `compile-draft` / `regenerate-tiles`,拼消消 `compile-draft` / `regenerate-atlas`,敲木鱼 `compile-draft` / `regenerate-hit-object`,以及图片画布 `editor_image_generation` / `editor_image_edit` / `editor_background_removal` / `editor_icon_spritesheet_generation` / `editor_ui_design_asset_extraction` / `editor_character_animation_generation` / `editor_video_generation` / `editor_sound_effect_generation` / `editor_background_music_generation`。非外部 provider 生成动作继续 inline,不进入队列。显式把本地进程角色设为 `api` 且没有 worker 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。 -生产拆分角色时,`external-generation-worker` 和 `external-generation-controller` 的专属 env 示例会把 `GENARRATIVE_SPACETIME_POOL_SIZE` 覆盖为 `1`;非 HTTP 角色只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure,且不再订阅 API 读模型连接池。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 与 controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。 +生产拆分角色时,`external-generation-worker` 和 `external-generation-controller` 的专属 env 示例会把 `GENARRATIVE_SPACETIME_POOL_SIZE` 覆盖为 `1`;非 HTTP 角色只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure,且不再订阅 API 读模型连接池。worker / controller 不执行模型定价 seed,启动时先调用受 runtime writer 鉴权的 queue-stats procedure 做只读预检,身份不匹配时 fail-fast;当前正式 systemd unit 通过共同加载 `/etc/genarrative/api-server.env` 继承同一 `GENARRATIVE_SPACETIME_TOKEN`,专属角色 env 示例不重复配置该 token。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 与 controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。 生产 worker 默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600`,只覆盖 worker 心跳抖动和短暂断连窗口,不再把 lease 当成完整任务时长;默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900`,角色动画 / 视频类长任务使用 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800`。worker 在单次尝试超过执行预算后会停止当前尝试、写入失败 / 重试状态并释放 worker 槽位;如果 SpacetimeDB 当时不可写,当前租约最多再保留到 lease 过期,之后任务重新变为可领取。生产部署和 provision 脚本会给 `/etc/genarrative/api-server.env` 与 `/etc/genarrative/external-generation-worker.env` 补齐这些变量;已有自定义值不覆盖,只会把历史旧默认 `3600` 迁移为 `600`。 diff --git a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md index 339c349ef..1a13c31b7 100644 --- a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md +++ b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md @@ -10,7 +10,7 @@ - 运行时事实源:SpacetimeDB `editor_generation_pricing_config` 全局配置表,固定 `config_id = global`,以强类型 `models` 列保存模型、单位、单价和档位列表。 - 旧运行时覆盖文件仅作为迁移兼容种子读取;当前兼容路径为 `/var/lib/genarrative/editor-generation-pricing/editor-generation-pricing.override.json`,并在默认新路径不存在时兼容读取旧 `.app/editor-generation-pricing.override.json`。后台保存不再写文件。 -默认文件进入 Git,作为空表或 SpacetimeDB 暂不可达时的兜底。首次读取发现表为空时,`api-server` 会用当前本地缓存尝试写入 `editor_generation_pricing_config`;本地缓存可来自默认 JSON,也可来自旧 override 文件。生产 API 发布脚本会在切换 `current` 前把旧 release 下的 `.app/editor-generation-pricing.override.json` 迁移到 `/var/lib/genarrative/editor-generation-pricing/`,避免既有后台自定义价格被默认值覆盖。 +默认文件进入 Git,作为空表或 SpacetimeDB 暂不可达时的兜底。只有 HTTP 角色会在启动恢复阶段用当前本地缓存尝试初始化空的 `editor_generation_pricing_config`;本地缓存可来自默认 JSON,也可来自旧 override 文件。`external-generation-worker` / `external-generation-controller` 不调用定价 initializer,而是在启动时通过只读的 queue-stats procedure 验证当前 SpacetimeDB identity 是否具备队列运行权限。生产 API 发布脚本会在切换 `current` 前把旧 release 下的 `.app/editor-generation-pricing.override.json` 迁移到 `/var/lib/genarrative/editor-generation-pricing/`,避免既有后台自定义价格被默认值覆盖。 ## 配置结构 @@ -57,13 +57,13 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝 - `GET /admin/api/editor-generation-pricing`:后台读取当前模型定价。 - `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,并写入 SpacetimeDB `editor_generation_pricing_config`;只有 procedure 入库成功后才更新进程内缓存并返回成功,不能把“仅内存生效”当作保存成功。 -后端 `AppState` 启动时加载默认配置和旧 override 作为本地缓存;接口读取优先走 SpacetimeDB。表为空时调用 `initialize_editor_generation_pricing_config_if_missing_and_return`,在单事务内仅缺失时种子入库,不能使用“先读空、再无条件 upsert”的两事务流程。首次写入把真实 `ctx.sender()` 保存为 `writer_identity`;表已存在时 initializer 只接受同一 writer,bootstrap secret 和迁移操作员都不能借该入口接管既有 writer。后续后台保存只允许同一 writer identity 或已授权迁移操作员,但即使由迁移操作员修复价格也必须保留原 writer。后台用户 ID 只记录审计信息,不能充当数据库授权。SpacetimeDB 暂不可达时才使用本地缓存兜底。 +后端 `AppState` 启动时加载默认配置和旧 override 作为本地缓存;接口读取优先走 SpacetimeDB。表为空时调用 `initialize_editor_generation_pricing_config_if_missing_and_return`,在单事务内仅缺失时种子入库,不能使用“先读空、再无条件 upsert”的两事务流程。首次写入把真实 `ctx.sender()` 保存为表内 `writer_identity`;procedure 对外返回的定价快照不包含该身份字段,公开主站和后台仍只经 BFF 读取价格。表已存在时 initializer 只接受同一 writer,bootstrap secret 和迁移操作员都不能借该入口接管既有 writer。后续后台保存只允许同一 writer identity 或已授权迁移操作员,但即使由迁移操作员修复价格也必须保留原 writer;若运行中的配置行意外缺失,保存请求会携带 `AppConfig` 已读取的受保护 bootstrap secret 完成原子首次写入,已有配置不会消费该 secret,也不会隐式轮换 writer。后台用户 ID 只记录审计信息,不能充当数据库授权。SpacetimeDB 暂不可达时才使用本地缓存兜底。 所有会调用外部生成 provider 的编辑器生成请求都必须由后端计算价格,前端请求不提交价格字段;同步执行按当前运行时配置进入 `execute_billable_asset_operation_with_cost` 预扣泥点,预扣失败不得继续调用上游。外部生成队列在入队时把价格写入 `external_generation_job.price_mud_points`,worker 必须用该冻结价格完成扣费、退款、响应和资产成本持久化,配置更新不得改变已入队任务金额。普通图片、规范、角色、UI 设计、宣发素材、快速编辑 / 图片修改、图标 spritesheet、UI 设计图提取素材、视频、角色动作、音效和背景音乐均遵循该规则。背景色决策(gpt-5-mini)本身也是一次上游调用,同样必须在预扣泥点之后发起:预扣前只做颜色无关的算价 / 校验(动画用默认色占位算价),决策放进 billable 闭包,余额不足则决策不跑、决策失败走失败退款。需要向前端展示实际扣费时,由后端在响应中返回 `priceMudPoints`。 ## 运行时身份首次授权 -模型定价 writer、外部生成队列和钱包调用都以真实 SpacetimeDB `ctx.sender()` 校验运行时服务 identity。原始 bootstrap secret 固定为 64 位十六进制;首次授权使用与当前 `spacetime_module.wasm` 构建时注入 SHA-256 摘要对应的原始值,模块收到原始值后重新计算 SHA-256 并做常量时间比较,WASM 只嵌入摘要、不嵌入原文。bootstrap secret 只能在配置表为空时建立首个受信身份,表存在后不能重复使用。queue 和钱包 runtime guard 只接受精确 `writer_identity`,迁移操作员身份不自动获得在线生成或钱包权限。migration operator 与 runtime writer 必须互斥:任何已登记 operator 都不能成为 writer,当前 writer 也不能被授权为 operator;一旦已有 operator,bootstrap secret 不得再新增或接管 operator。 +模型定价 writer、外部生成队列和钱包调用都以真实 SpacetimeDB `ctx.sender()` 校验运行时服务 identity。原始 bootstrap secret 固定为 64 位十六进制;首次授权使用与当前 `spacetime_module.wasm` 构建时注入 SHA-256 摘要对应的原始值,模块收到原始值后重新计算 SHA-256 并做常量时间比较,WASM 只嵌入摘要、不嵌入原文。bootstrap secret 只能在配置表为空时建立首个受信身份,表存在后不能重复使用。queue 和钱包 runtime guard 只接受精确 `writer_identity`,迁移操作员身份不自动获得在线生成或钱包权限;因此当前生产 API、worker 和 controller 必须继承同一份 runtime token。非 HTTP 角色只做 queue procedure 鉴权预检,不具备 seed 或轮换身份的职责。migration operator 与 runtime writer 必须互斥:任何已登记 operator 都不能成为 writer,当前 writer 也不能被授权为 operator;一旦已有 operator,bootstrap secret 不得再新增或接管 operator。 运行时 token 轮换必须由已授权迁移操作员显式调用 `rotate_editor_generation_runtime_service_identity_and_return`,传入新 identity、操作员用户 ID 和原因。该 procedure 会拒绝把新 writer 设成当前 writer 或任一已登记 migration operator,只修改 writer,不覆盖模型价格,并向 `editor_generation_runtime_identity_rotation` 写入旧 writer、新 writer、迁移操作员 identity、操作人、原因和服务端时间。生产使用 `scripts/deploy/production-runtime-writer-identity-rotate.mjs`:要求当前 SpacetimeDB CLI 登录 identity 与 `--operator-identity` 一致,并通过 `--next-writer-identity` / `--confirm-next-writer-identity` 双录确认;执行成功后必须核对审计行,再切换 API token。不得把 API 启动、普通定价 upsert 或 bootstrap secret 复用成隐式轮换流程。 @@ -89,7 +89,7 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝 ## 验证 -- 后端配置解析、模块强校验与 writer 授权、SpacetimeDB 保存 / 重启读取、路由保存与公开读取测试。 +- 后端配置解析、模块强校验与 writer 授权、SpacetimeDB 保存 / 重启读取、仅 HTTP 角色 seed、非 HTTP 角色 queue-stats 鉴权预检、路由保存与公开读取不返回 `writer_identity` 的测试。 - 外部生成任务在配置变更前后仍按入队价格扣费并落资产的测试;旧 attempt 退款先于迟到 consume 时,持久化 settlement intent 必须阻止迟到扣费;最终 attempt lease 过期后必须直接失败结算且不再返回 provider executor。 - 部署脚本 Bash 语法、生产运维静态门禁、64 位十六进制 secret 校验、manifest 摘要匹配、Build / Publish credential ID 一致性、bootstrap secret 明文日志扫描和 FILE 权限 / 服务重启检查。 - 前端价格读取、运行时覆盖、图片尺寸档位计算测试。 diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 8aafb836d..725704d2c 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -203,9 +203,12 @@ async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> { )) })?; state - .ensure_editor_generation_runtime_service_identity() + .spacetime_client() + .get_external_generation_queue_stats() .await - .map_err(|error| io::Error::other(format!("初始化模型定价服务身份失败:{error}")))?; + .map_err(|error| { + io::Error::other(format!("验证外部生成队列运行时服务身份失败:{error}")) + })?; spawn_common_app_state_background_workers(&state); info!( process_role = process_role.as_str(), @@ -239,6 +242,10 @@ fn should_restore_auth_store_for_startup(process_role: ProcessRole) -> bool { process_role.runs_http() } +fn should_initialize_editor_generation_pricing_for_startup(process_role: ProcessRole) -> bool { + process_role.runs_http() +} + async fn run_http_role(config: AppConfig) -> Result<(), io::Error> { let bind_address = config.bind_socket_addr(); let listen_backlog = config.listen_backlog; @@ -470,6 +477,7 @@ async fn restore_app_state_for_startup( async fn try_restore_app_state_for_startup( config: AppConfig, ) -> Result { + let process_role = config.process_role; let state = match timeout( AUTH_STORE_STARTUP_RESTORE_TIMEOUT, AppState::try_restore_auth_store_from_spacetime(config), @@ -487,14 +495,16 @@ async fn try_restore_app_state_for_startup( )); } }; - state - .ensure_editor_generation_runtime_service_identity() - .await - .map_err(|error| { - state::AppStateInitError::DependencyUnavailable(format!( - "初始化模型定价服务身份失败:{error}" - )) - })?; + if should_initialize_editor_generation_pricing_for_startup(process_role) { + state + .ensure_editor_generation_runtime_service_identity() + .await + .map_err(|error| { + state::AppStateInitError::DependencyUnavailable(format!( + "初始化模型定价服务身份失败:{error}" + )) + })?; + } Ok(state) } @@ -574,6 +584,7 @@ fn is_valid_env_key(key: &str) -> bool { mod tests { use super::{ AUTH_STORE_STARTUP_RETRY_INTERVAL, is_valid_env_key, protected_env_keys_from, + should_initialize_editor_generation_pricing_for_startup, should_restore_auth_store_for_startup, should_start_profile_recharge_expiration_listener, strip_env_value, }; @@ -636,6 +647,22 @@ mod tests { )); } + #[test] + fn editor_generation_pricing_initialization_is_limited_to_http_roles() { + assert!(should_initialize_editor_generation_pricing_for_startup( + ProcessRole::Api + )); + assert!(should_initialize_editor_generation_pricing_for_startup( + ProcessRole::All + )); + assert!(!should_initialize_editor_generation_pricing_for_startup( + ProcessRole::ExternalGenerationWorker + )); + assert!(!should_initialize_editor_generation_pricing_for_startup( + ProcessRole::ExternalGenerationController + )); + } + #[test] fn profile_recharge_expiration_listener_is_limited_to_http_roles() { assert!(should_start_profile_recharge_expiration_listener(ProcessRole::Api)); diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index d54baa92b..dc0da4fc0 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -406,6 +406,23 @@ fn editor_generation_pricing_from_record( Ok(config) } +fn editor_generation_pricing_upsert_input( + config: &AppConfig, + admin_user_id: String, + models: Vec, + updated_at_micros: i64, +) -> EditorGenerationPricingConfigUpsertRecordInput { + EditorGenerationPricingConfigUpsertRecordInput { + admin_user_id, + models, + updated_at_micros, + bootstrap_secret: config + .spacetime_runtime_service_bootstrap_secret + .clone() + .unwrap_or_default(), + } +} + impl AppState { #[cfg(test)] pub fn new(config: AppConfig) -> Result { @@ -605,12 +622,12 @@ impl AppState { let record = self .spacetime_client .upsert_editor_generation_pricing_config( - EditorGenerationPricingConfigUpsertRecordInput { + editor_generation_pricing_upsert_input( + &self.config, admin_user_id, models, - updated_at_micros: crate::editor_project::current_utc_micros(), - bootstrap_secret: String::new(), - }, + crate::editor_project::current_utc_micros(), + ), ) .await .map_err(|error| EditorGenerationPricingError::Persistence(error.to_string()))?; @@ -637,16 +654,12 @@ impl AppState { let record = self .spacetime_client .initialize_editor_generation_pricing_config_if_missing( - EditorGenerationPricingConfigUpsertRecordInput { - admin_user_id: "system:editor-generation-pricing".to_string(), + editor_generation_pricing_upsert_input( + &self.config, + "system:editor-generation-pricing".to_string(), models, - updated_at_micros: crate::editor_project::current_utc_micros(), - bootstrap_secret: self - .config - .spacetime_runtime_service_bootstrap_secret - .clone() - .unwrap_or_default(), - }, + crate::editor_project::current_utc_micros(), + ), ) .await .map_err(|error| EditorGenerationPricingError::Persistence(error.to_string()))?; @@ -2002,7 +2015,6 @@ mod tests { updated_by_admin_user_id: Some("admin:test".to_string()), updated_at: "2026-07-10T00:00:00Z".to_string(), updated_at_micros: 1, - writer_identity_hex: "11".repeat(32), }; let actual = editor_generation_pricing_from_record(record) @@ -2011,6 +2023,30 @@ mod tests { assert_eq!(actual, expected); } + #[test] + fn editor_generation_pricing_upsert_input_uses_runtime_service_bootstrap_secret() { + let mut config = AppConfig::default(); + let without_secret = editor_generation_pricing_upsert_input( + &config, + "admin:test".to_string(), + Vec::new(), + 123, + ); + assert_eq!(without_secret.bootstrap_secret, ""); + + config.spacetime_runtime_service_bootstrap_secret = Some("11".repeat(32)); + let with_secret = editor_generation_pricing_upsert_input( + &config, + "admin:test".to_string(), + Vec::new(), + 123, + ); + + assert_eq!(with_secret.bootstrap_secret, "11".repeat(32)); + assert_eq!(with_secret.admin_user_id, "admin:test"); + assert_eq!(with_secret.updated_at_micros, 123); + } + #[test] fn editor_generation_pricing_typed_record_rejects_duplicate_model() { let config = crate::editor_generation_config::parse_editor_generation_pricing_json( @@ -2027,7 +2063,6 @@ mod tests { updated_by_admin_user_id: None, updated_at: "2026-07-10T00:00:00Z".to_string(), updated_at_micros: 1, - writer_identity_hex: "11".repeat(32), }; let error = diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index 98a9fd2b5..4f96a5fc0 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -197,7 +197,6 @@ pub struct EditorGenerationPricingConfigRecord { pub updated_by_admin_user_id: Option, pub updated_at: String, pub updated_at_micros: i64, - pub writer_identity_hex: String, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -1341,7 +1340,6 @@ fn map_editor_generation_pricing_config_snapshot( updated_by_admin_user_id: snapshot.updated_by_admin_user_id, updated_at: format_timestamp_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, - writer_identity_hex: snapshot.writer_identity.to_hex().to_string(), } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_snapshot_type.rs index fa3790569..cc7a3cca7 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_snapshot_type.rs @@ -13,7 +13,6 @@ pub struct EditorGenerationPricingConfigSnapshot { pub models: Vec, pub updated_by_admin_user_id: Option, pub updated_at_micros: i64, - pub writer_identity: __sdk::Identity, } impl __sdk::InModule for EditorGenerationPricingConfigSnapshot { diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 08e12589d..d54fde3af 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -536,7 +536,6 @@ pub struct EditorGenerationPricingConfigSnapshot { pub models: Vec, pub updated_by_admin_user_id: Option, pub updated_at_micros: i64, - pub writer_identity: Identity, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -3057,7 +3056,6 @@ fn editor_generation_pricing_config_snapshot_from_row( models: row.models, updated_by_admin_user_id: row.updated_by_admin_user_id, updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - writer_identity: row.writer_identity, } } @@ -4151,6 +4149,18 @@ mod tests { assert_eq!(normalized[0].model, EDITOR_GENERATION_SOUND_EFFECT_MODEL); } + #[test] + fn editor_generation_pricing_procedure_snapshot_omits_writer_identity() { + let snapshot = EditorGenerationPricingConfigSnapshot { + config_id: EDITOR_GENERATION_PRICING_CONFIG_ID.to_string(), + models: valid_editor_generation_pricing_models(), + updated_by_admin_user_id: Some("admin:test".to_string()), + updated_at_micros: 1, + }; + + assert!(!format!("{snapshot:?}").contains("writer_identity")); + } + #[test] fn normalize_editor_generation_pricing_models_rejects_missing_required_model() { let mut models = valid_editor_generation_pricing_models();