发现于 #4910 开发过程(入站 rateLimit seam),与本单修复无关,按 Prime Directive #10 单独立单,未认领。
事实
composeStacks(packages/spec/src/stack.zod.ts)只合并三类东西:
manifest —— 按 manifest 策略择一;
i18n —— last-wins;
objects + CONCAT_ARRAY_FIELDS 里列出的数组集合 —— 拼接。
其余一律不进 composed。它从一个空对象 {} 开始逐项填充,所以任何既不是 manifest/i18n、也不在数组清单里的顶层键,合成后直接消失,没有告警。
今天受影响的顶层键:
| 键 |
谁消费它 |
合成后 |
api(enableProjectScoping / projectResolution / enforceProjectMembership) |
objectstack serve(serve.ts),转发给 REST + dispatcher |
丢 |
server(security.rateLimit / trustProxy,#4910 新增) |
objectstack serve → dispatcher 的入站限流器 |
丢 |
datasourceMapping |
数据源路由 |
丢 |
datasourceMapping 尤其值得一看:它是数组,只是没被列进 CONCAT_ARRAY_FIELDS(需实测确认)。
为什么这是 declared ≠ enforced 的形状
作者写下 server.security.rateLimit,单栈下真限流;把同一个栈丢进 composeStacks([base, addon]),限流静默消失,defineStack 不报错、validate 不报错、启动日志也不会说少了什么 —— 因为消费方看到的就是 undefined,与「没写过」无法区分。这正是 #4686 那一类缺陷换了个入口。
api.enforceProjectMembership 走同一条路径,后果是每环境成员 403 闸门在合成栈里悄悄关掉,安全影响比限流更直接。
复现(未跑,依据是源码 —— 请开工时先实测确认)
const a = defineStack({ manifest, server: { security: { rateLimit: { enabled: true, maxRequests: 5 } } } });
const b = defineStack({ manifest });
composeStacks([a, b]).server // → undefined
注意 composeStacks 的短路:stacks.length === 1 时原样返回,所以单元素合成看起来是好的,只有真正 ≥2 个栈才丢 —— 这也解释了为什么至今没被发现。
待裁决(不要猜)
顶层标量/对象键的合成语义没有先例可循,需要维护者定一次,而不是每个键各定各的:
- A. last-wins(与
i18n 一致)—— 最省事,但 api / server 是安全配置,后一个包静默覆盖前一个包的限流预算是危险的默认。
- B. 深合并 + 冲突报错 —— 两个栈都声明
server.security.rateLimit 且值不同 → composeStacks 抛错,和 objectConflict: 'error' 的既有姿态一致。
- C. 显式策略参数(
ComposeStacksOptions 加一项,默认 B) —— 最贵,但把选择权交给作者。
另有一个与语义无关、无论选哪个都该做的:合成时丢弃任何未处理的顶层键,应该至少 warn 一次并点名。今天它是完全静默的,这才是真正让人查不出来的部分 —— 定了语义之后,凡是新增顶层键忘了接进 composeStacks 的,也会立刻自曝,而不是等下一次有人做 #4910 这样的活儿时偶然撞见。
关联:#4910(引入 server: 的单)、#4686(三份 RateLimitConfig 零 reader)、Prime Directive #10 / #12。
发现于 #4910 开发过程(入站 rateLimit seam),与本单修复无关,按 Prime Directive #10 单独立单,未认领。
事实
composeStacks(packages/spec/src/stack.zod.ts)只合并三类东西:manifest—— 按manifest策略择一;i18n—— last-wins;objects+CONCAT_ARRAY_FIELDS里列出的数组集合 —— 拼接。其余一律不进
composed。它从一个空对象{}开始逐项填充,所以任何既不是manifest/i18n、也不在数组清单里的顶层键,合成后直接消失,没有告警。今天受影响的顶层键:
api(enableProjectScoping/projectResolution/enforceProjectMembership)objectstack serve(serve.ts),转发给 REST + dispatcherserver(security.rateLimit/trustProxy,#4910 新增)objectstack serve→ dispatcher 的入站限流器datasourceMappingdatasourceMapping尤其值得一看:它是数组,只是没被列进CONCAT_ARRAY_FIELDS(需实测确认)。为什么这是 declared ≠ enforced 的形状
作者写下
server.security.rateLimit,单栈下真限流;把同一个栈丢进composeStacks([base, addon]),限流静默消失,defineStack不报错、validate不报错、启动日志也不会说少了什么 —— 因为消费方看到的就是undefined,与「没写过」无法区分。这正是 #4686 那一类缺陷换了个入口。api.enforceProjectMembership走同一条路径,后果是每环境成员 403 闸门在合成栈里悄悄关掉,安全影响比限流更直接。复现(未跑,依据是源码 —— 请开工时先实测确认)
注意
composeStacks的短路:stacks.length === 1时原样返回,所以单元素合成看起来是好的,只有真正 ≥2 个栈才丢 —— 这也解释了为什么至今没被发现。待裁决(不要猜)
顶层标量/对象键的合成语义没有先例可循,需要维护者定一次,而不是每个键各定各的:
i18n一致)—— 最省事,但api/server是安全配置,后一个包静默覆盖前一个包的限流预算是危险的默认。server.security.rateLimit且值不同 →composeStacks抛错,和objectConflict: 'error'的既有姿态一致。ComposeStacksOptions加一项,默认 B) —— 最贵,但把选择权交给作者。另有一个与语义无关、无论选哪个都该做的:合成时丢弃任何未处理的顶层键,应该至少 warn 一次并点名。今天它是完全静默的,这才是真正让人查不出来的部分 —— 定了语义之后,凡是新增顶层键忘了接进
composeStacks的,也会立刻自曝,而不是等下一次有人做 #4910 这样的活儿时偶然撞见。关联:#4910(引入
server:的单)、#4686(三份 RateLimitConfig 零 reader)、Prime Directive #10 / #12。