Skip to main content

futu_auth/
scope.rs

1//! Scope: 能力分组
2
3use std::fmt;
4use std::str::FromStr;
5
6use futu_core::proto_id;
7use serde::{Deserialize, Serialize};
8
9/// API Key 能力分组
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(try_from = "String", into = "String")]
12#[non_exhaustive]
13pub enum Scope {
14    /// 行情只读(11 个工具)
15    QotRead,
16    /// 行情/用户数据写。v1.8 起新 QOT mutation 必须使用;历史写接口在
17    /// breaking migration 前可同时接受 qot:read。
18    QotWrite,
19    /// 账户只读(5 个工具)
20    AccRead,
21    /// 模拟交易写
22    TradeSimulate,
23    /// 真实交易写
24    TradeReal,
25    /// 允许自动 unlock_trade(从 keychain 读密码)
26    TradeUnlock,
27    /// v1.4.32+ daemon 管理 (`/api/admin/status|reload|shutdown`)。
28    /// 权限危险,只给运维 / 监控 key;LLM key 永远不要加这个。
29    Admin,
30    /// v1.4.90 P1-A: trade 类 super-scope。**仅在 REST middleware
31    /// `scope_for_path` 用作"需要任意 trade* scope"的占位需求**:持有
32    /// [`Scope::TradeReal`] / [`Scope::TradeSimulate`] / [`Scope::TradeUnlock`]
33    /// 任一即满足。**不应**写入 keys.json(KeyRecord.scopes 里出现
34    /// `Scope::Trade` 没意义,等价于不分 sim/real/unlock 的旧式权限)。
35    /// env 是 sim 还是 real 由 handler 层用 KeyRecord 真实 scopes 二次校验。
36    Trade,
37    /// v1.4.106 codex 0542 F1 [P2 SECURITY]: `/metrics` 端点 scope-gated 的
38    /// 专用 scope. default secure — 不再像 v1.4.105 之前那样无 auth 暴露
39    /// `key_id` 标签 (= API key id 明文 cardinality enumeration channel,
40    /// 任意本机 process / agent skill 都能 fingerprint).
41    ///
42    /// **行为**:
43    /// - 持 `MetricsRead` 的 key → `/metrics` 通过, `key_id=` label 仍 redact
44    ///   为 `kh_<8hex>` (短 SHA256 hash, 反查 key id 需要离线 dictionary 攻击)
45    /// - 不持 `MetricsRead` → 401 (legacy 模式) 或 403
46    /// - **opt-out**: 老用户 dashboard 依赖明文 key_id 时设
47    ///   `FUTU_METRICS_PUBLIC=1` 环境变量回退 v1.4.105 行为 (无 auth + 明文
48    ///   key_id). 此为 backward-compat 边界 trade-off — secure default + 明示
49    ///   opt-out, 而非 opt-in.
50    ///
51    /// 与 [`Scope::Admin`] 区别: Admin 含 mutating endpoint (shutdown/reload),
52    /// MetricsRead 仅 read-only Prometheus 抓取. dashboard / Prometheus
53    /// scraper 应持 MetricsRead 而不是 Admin.
54    MetricsRead,
55    /// Pre-login authentication setup, including Verification(1006).
56    /// This scope grants no quote/account/trade capability.
57    AuthSetup,
58}
59
60impl Scope {
61    pub const ALL: &'static [Scope] = &[
62        Scope::QotRead,
63        Scope::QotWrite,
64        Scope::AccRead,
65        Scope::TradeSimulate,
66        Scope::TradeReal,
67        Scope::TradeUnlock,
68        Scope::Admin,
69        Scope::MetricsRead,
70        Scope::AuthSetup,
71    ];
72
73    #[must_use]
74    pub fn as_str(&self) -> &'static str {
75        match self {
76            Scope::QotRead => "qot:read",
77            Scope::QotWrite => "qot:write",
78            Scope::AccRead => "acc:read",
79            Scope::TradeSimulate => "trade:simulate",
80            Scope::TradeReal => "trade:real",
81            Scope::TradeUnlock => "trade:unlock",
82            Scope::Admin => "admin",
83            Scope::Trade => "trade",
84            Scope::MetricsRead => "metrics:read",
85            Scope::AuthSetup => "auth:setup",
86        }
87    }
88
89    /// v1.4.90 P1-A: super-scope `Scope::Trade` 的成员集合。REST
90    /// middleware 在 mutating trade endpoint 的需求侧用 `Scope::Trade`
91    /// 占位,持有任一成员即视为满足;handler 层再用真实 scopes
92    /// 二次校验 env (sim/real/unlock).
93    #[must_use]
94    pub fn trade_super_members() -> &'static [Scope] {
95        &[Scope::TradeReal, Scope::TradeSimulate, Scope::TradeUnlock]
96    }
97}
98
99/// 将交易环境的 real/simulate 事实映射为唯一的写权限 scope。
100///
101/// Transport adapter 仍负责按自身 wire shape 解析环境;解析成功后必须调用
102/// 本函数,避免 REST、gRPC 与 MCP 各自维护一份映射。
103#[must_use]
104pub const fn trade_scope_for_env_is_real(is_real: bool) -> Scope {
105    if is_real {
106        Scope::TradeReal
107    } else {
108        Scope::TradeSimulate
109    }
110}
111
112impl fmt::Display for Scope {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(self.as_str())
115    }
116}
117
118#[derive(Debug, thiserror::Error)]
119#[error(
120    "unknown scope {0:?} (valid: qot:read, qot:write, acc:read, trade:simulate, trade:real, trade:unlock, admin, metrics:read, auth:setup)"
121)]
122pub struct ScopeParseError(pub String);
123
124impl FromStr for Scope {
125    type Err = ScopeParseError;
126
127    fn from_str(s: &str) -> Result<Self, Self::Err> {
128        match s {
129            "qot:read" => Ok(Scope::QotRead),
130            "qot:write" => Ok(Scope::QotWrite),
131            "acc:read" => Ok(Scope::AccRead),
132            "trade:simulate" => Ok(Scope::TradeSimulate),
133            "trade:real" => Ok(Scope::TradeReal),
134            "trade:unlock" => Ok(Scope::TradeUnlock),
135            "admin" => Ok(Scope::Admin),
136            // 注意:`"trade"` 仅作为 super-scope 内部占位 (Scope::Trade),
137            // 不允许从 keys.json / CLI --scopes 解析,避免用户误以为
138            // bare trade 会授予 trade:real / trade:simulate / trade:unlock。
139            // v1.4.106 codex 0542 F1 [P2 SECURITY]: /metrics 端点专用 scope.
140            "metrics:read" => Ok(Scope::MetricsRead),
141            "auth:setup" => Ok(Scope::AuthSetup),
142            other => Err(ScopeParseError(other.to_string())),
143        }
144    }
145}
146
147impl TryFrom<String> for Scope {
148    type Error = ScopeParseError;
149    fn try_from(value: String) -> Result<Self, Self::Error> {
150        value.parse()
151    }
152}
153
154impl From<Scope> for String {
155    fn from(s: Scope) -> String {
156        s.as_str().to_string()
157    }
158}
159
160/// Futu API protocol id → 所需 scope 的**通用映射**
161///
162/// gRPC 和核心 WS 都用这个函数做 scope 检查。proto_id 常量定义在 futu-core
163/// (circular dep 顾虑下这里手动枚举);新增 proto 时必须同步更新这里的 match
164/// 分支,否则落到 catch-all `TradeReal` 被拒(fail-closed)。
165///
166/// **v1.4.104 codex round 1 F4 (P2) fix**: 显式 trade/acc protos 用
167/// [`SCOPED_TRADE_REAL_PROTOS`] / [`SCOPED_TRADE_UNLOCK_PROTOS`] /
168/// [`SCOPED_ACC_READ_PROTOS`] 暴露给 invariant test, 让
169/// `body_aware::build_check_ctxs` + `response_filter::FilterRegistry` 共同
170/// 覆盖. 加新 scoped proto 时:
171/// 1. match 分支加 → 让 scope check 知道新 proto
172/// 2. 把 proto_id 加到对应的 `SCOPED_*_PROTOS` const list (机械 enumeration)
173/// 3. 其中 一处 (body_aware OR response_filter OR EXPLICIT_NO_ACC_ID_PROTOS)
174///    必须 cover, 否则 cross_surface_invariants test 挂.
175///
176/// | proto_id 范围 | 所需 scope |
177/// |---|---|
178/// | 1xxx 系统(InitConnect / GetGlobalState / KeepAlive / …) | 无(放行) |
179/// | 3xxx 行情(含 push updates) | `qot:read` |
180/// | 2005 UnlockTrade | `trade:unlock`(v1.4.104 codex F1 P1 fix) |
181/// | 2202 PlaceOrder / 2205 ModifyOrder / 2227 PlaceComboOrder / 2237 ReconfirmOrder | `trade:real` |
182/// | 2xxx 账户只读(AccList / Funds / Positions / Orders / Deals / 费率 / push) | `acc:read` |
183/// | 其他 | catch-all `trade:real`(fail-closed) |
184pub fn scope_for_proto_id(proto_id: u32) -> Option<Scope> {
185    match proto_id {
186        // v1.4.110 GetUsedQuota / v1.4.98 T2-8 GET_TOKEN_STATE 落在 1xxx
187        // 范围, 但有明确业务权限语义. 单独前置, 避免被下面
188        // 1000..=1999 => None 兜住.
189        1010 => Some(Scope::QotRead),
190        1006 => Some(Scope::AuthSetup),
191        // NN+MM token 状态查询, unlock-trade 失败时第一线诊断.
192        // 否则被下面 1000..=1999 => None 兜住.
193        1326 => Some(Scope::AccRead),
194        // v1.8 exact trading-session local extensions are read-only QOT surfaces.
195        proto_id::QOT_GET_SECURITY_TRADING_SESSIONS
196        | proto_id::QOT_GET_MARKET_TRADING_SESSIONS
197        | proto_id::QOT_GET_HOT_NEWS
198        | proto_id::QOT_GET_LATEST_NEWS
199        | proto_id::QOT_GET_WATCHLIST_NEWS
200        | proto_id::QOT_GET_WATCHLIST_ANNOUNCEMENT
201        | proto_id::QOT_GET_WATCHLIST_RATING
202        | proto_id::QOT_GET_STOCK_NEWS
203        | proto_id::QOT_GET_KLINE_PATTERN
204        | proto_id::QOT_GET_KLINE_PATTERN_STATISTICS
205        | proto_id::QOT_GET_KLINE_PATTERN_STOCKS
206        | proto_id::QOT_GET_KLINE_PATTERN_PERFORMANCE
207        | proto_id::QOT_GET_KLINE_PATTERN_CATALOG => Some(Scope::QotRead),
208        proto_id::QOT_ETF_SCREEN
209        | proto_id::QOT_FUND_SCREEN
210        | proto_id::QOT_FUTURE_SCREEN
211        | proto_id::QOT_BOND_SCREEN => Some(Scope::QotRead),
212        proto_id::QOT_GET_STOCK_NOTE_LABELS
213        | proto_id::QOT_GET_STOCK_NOTES
214        | proto_id::QOT_UPDATE_STOCK_NOTE => Some(Scope::AccRead),
215        proto_id::QOT_MODIFY_USER_SECURITY
216        | proto_id::QOT_SET_PRICE_REMINDER
217        | proto_id::QOT_SET_OPTION_EVENT_ALERT
218        | proto_id::QOT_SET_STOCK_NOTE => Some(Scope::QotWrite),
219        proto_id::TRD_PREVIEW_ORDER_IMPACT
220        | proto_id::TRD_GET_ASSET_TREND
221        | proto_id::TRD_GET_YIELD_TREND
222        | proto_id::TRD_GET_RETURN_CALENDAR
223        | proto_id::TRD_GET_ORDER_RELATIONS
224        | proto_id::TRD_GET_POSITION_CORPORATE_ACTIONS
225        | proto_id::TRD_GET_ALGO_ORDER_LOGS => Some(Scope::AccRead),
226        proto_id::TRD_PLACE_ORDER_GROUP
227        | proto_id::TRD_MODIFY_ORDER_GROUP
228        | proto_id::TRD_CANCEL_ORDER_GROUP
229        | proto_id::TRD_DELETE_ORDER_GROUP
230        | proto_id::TRD_PLACE_ALGO_ORDER
231        | proto_id::TRD_MODIFY_ALGO_ORDER
232        | proto_id::TRD_REVERSE_POSITION
233        | proto_id::TRD_ROLL_POSITION
234        | proto_id::TRD_CLEAR_FUTURES_POSITIONS
235        | proto_id::TRD_BATCH_CLOSE_POSITIONS => Some(Scope::TradeReal),
236
237        // 1xxx 系统 / 连接管理:InitConnect / GlobalState / KeepAlive / UserInfo ...
238        1000..=1999 => None,
239
240        // 3xxx 全部行情(请求 + push 全挂 qot:read)
241        3000..=3999 => Some(Scope::QotRead),
242
243        // v1.4.115 financial / IPO calendar: mobile-driven read-only quote
244        // endpoints live outside the legacy 3xxx quote range, so they need
245        // explicit QotRead coverage before the 2xxx trade/account branches.
246        20025 | 20426 => Some(Scope::QotRead),
247
248        // 2005 UnlockTrade —— v1.4.104 codex round 1 F1 (P1) fix:
249        // 之前 mapping 是 TradeReal (推理 "未解锁不能下单, 视同 trade:real"
250        // 是错的). UnlockTrade 是独立 scope:caller 持 trade:unlock 才能解锁,
251        // 不应让 trade:real 通过. v1.4.103 codex F5.3 已让 MCP futu_unlock_trade
252        // 走 trade:unlock, 但 gRPC/raw WS 直调 proto 2005 时仍走 TradeReal —
253        // narrow Bearer (trade:real only, 无 trade:unlock) 可绕过 unlock scope.
254        // v1.4.104 阶段 7-5 改 MCP futu_unlock_trade 走 caller-specific
255        // pipeline (TradeUnlock check), 但 proto 2005 mapping 还是 TradeReal —
256        // codex round 1 F1 抓出 silent gap. 现统一改 TradeUnlock 关闭 4 surface
257        // 一致.
258        2005 => Some(Scope::TradeUnlock),
259
260        // 2202 PlaceOrder / 2205 ModifyOrder / 2227 PlaceComboOrder / 2237 ReconfirmOrder
261        2202 | 2205 | 2227 | 2237 => Some(Scope::TradeReal),
262
263        // 2xxx 账户只读:list / funds / positions / orders / deals / push / 费率
264        2001
265        | 2008
266        | proto_id::TRD_UNSUB_ACC_PUSH_LOCAL
267        | 2101
268        | 2102
269        | 2111
270        | 2112
271        | 2201
272        | 2208
273        | 2211
274        | 2218
275        | 2221
276        | 2222
277        | 2223
278        | 2225
279        | 2226
280        | 2240 => Some(Scope::AccRead),
281
282        // v1.4.94 / v1.4.95 Tier M (mobile-driven extensions, 22701-22710):
283        // 全 only-read 性质 (账户资金 / 业务分组 / margin / 合规 / 债券 holdings) →
284        // acc:read scope 统一. 不显式覆盖会 fall-through 到 TradeReal,
285        // 让 acc:read-only 的 LLM agent 调不到这些 endpoint.
286        //
287        // | proto_id | endpoint                  | 含义              |
288        // |----------|---------------------------|-------------------|
289        // | 22701    | TRD_GET_CASH_LOG          | v1.4.94 M1        |
290        // | 22702    | TRD_GET_CASH_DETAIL       | v1.4.94 M1        |
291        // | 22703    | TRD_GET_BIZ_GROUP         | v1.4.94 M1        |
292        // | 22704    | TRD_GET_MARGIN_INFO       | v1.4.95 U2-D      |
293        // | 22705    | TRD_GET_ACCOUNT_FLAG      | v1.4.95 U2-A      |
294        // | 22706    | TRD_GET_BOND_TOTAL_ASSET  | v1.4.95 U2-B      |
295        // | 22707    | TRD_GET_BOND_SINGLE_ASSET | v1.4.95 U2-B      |
296        // | 22708    | TRD_GET_BOND_POSITION_LIST| v1.4.95 U2-B      |
297        // | 22709    | TRD_GET_BOND_ANSWER_STATE | v1.4.95 U2-B      |
298        // | 22710    | TRD_GET_BOND_TRADE_REMIND | v1.4.95 U2-B      |
299        22701..=22710 => Some(Scope::AccRead),
300
301        // v1.4.98 T2-* (mobile-source-audit Phase 2): quote 类 read-only endpoint.
302        // - 6503 QOT_GET_SPREAD_TABLE: 摆盘步长
303        // - 20231 QOT_GET_RISK_FREE_RATE: 无风险利率 (期权定价)
304        // - 6365 / 6366 QOT_GET_TICKER_STATISTIC: 逐笔统计 + push
305        // (cmd 1326 GET_TOKEN_STATE 已前置 acc:read, 避免 1xxx None 兜底)
306        6503 | 6365 | 6366 | 20231 => Some(Scope::QotRead),
307
308        // 未覆盖 → fail-closed,统一拒(返回 TradeReal 让上游 check_scope 比对最严格)
309        _ => Some(Scope::TradeReal),
310    }
311}
312
313// ─────────────────────────────────────────────────────────────────────────────
314// v1.4.104 codex round 1 F4 (P2) fix: scoped proto_id 机械枚举
315//
316// 让 `futu-auth-pipeline::body_aware` / `response_filter` / 显式 exception
317// list 通过 `coverage_invariant` 测试**机械**对齐 — 加新 scoped proto 时
318// 漏一处必挂. 与 v1.4.103/104 之前 hand-maintained covered vec 不同, 现
319// 不再依赖人记忆.
320// ─────────────────────────────────────────────────────────────────────────────
321
322/// 显式 enumerate 所有需要 acc_id 白名单或响应 filter 的 trade write proto_id.
323/// `body_aware::build_check_ctxs` 必须 decode 这些 proto.
324pub const SCOPED_TRADE_REAL_PROTOS: &[u32] = &[
325    2202, // TRD_PLACE_ORDER
326    2205, // TRD_MODIFY_ORDER
327    2227, // TRD_PLACE_COMBO_ORDER
328    2237, // TRD_RECONFIRM_ORDER
329    proto_id::TRD_PLACE_ORDER_GROUP,
330    proto_id::TRD_MODIFY_ORDER_GROUP,
331    proto_id::TRD_CANCEL_ORDER_GROUP,
332    proto_id::TRD_DELETE_ORDER_GROUP,
333    proto_id::TRD_PLACE_ALGO_ORDER,
334    proto_id::TRD_MODIFY_ALGO_ORDER,
335    proto_id::TRD_REVERSE_POSITION,
336    proto_id::TRD_ROLL_POSITION,
337    proto_id::TRD_CLEAR_FUTURES_POSITIONS,
338    proto_id::TRD_BATCH_CLOSE_POSITIONS,
339];
340
341/// 显式 enumerate trade unlock proto_id (caller-specific TradeUnlock scope).
342/// v1.4.104 codex F1 (P1) 加.
343pub const SCOPED_TRADE_UNLOCK_PROTOS: &[u32] = &[
344    2005, // TRD_UNLOCK_TRADE
345];
346
347/// 显式 enumerate acc:read proto_id. 大多数走 `body_aware` decode acc_id
348/// whitelist. 例外见 [`EXPLICIT_NO_ACC_ID_PROTOS`].
349pub const SCOPED_ACC_READ_PROTOS: &[u32] = &[
350    1326,                               // GET_TOKEN_STATE — 无 acc_id, 走 explicit exception
351    2001,                               // TRD_GET_ACC_LIST — request 无 acc_id, 走 response-filter
352    2008,                               // TRD_SUB_ACC_PUSH (multi acc_id_list)
353    proto_id::TRD_UNSUB_ACC_PUSH_LOCAL, // Rust-daemon local public unsub (multi acc_id_list)
354    2101,
355    2102,
356    2111,
357    2112, // funds / positions / max_trd_qtys / combo_max_trd_qtys
358    2201,
359    2208,
360    2211,
361    2218, // order list / order_update push / fill list / fill_update push
362    2221,
363    2222, // history orders / history fills
364    2223,
365    2225,
366    2226, // margin ratio / order fee / flow summary
367    2240, // notify push
368    // Tier M (v1.4.94/95)
369    22701,
370    22702,
371    22703, // cash log / detail / biz group
372    22704, // margin info
373    22705, // account flag
374    22706,
375    22707,
376    22708,
377    22709,
378    22710, // bond × 5
379    proto_id::TRD_PREVIEW_ORDER_IMPACT,
380    proto_id::TRD_GET_ASSET_TREND,
381    proto_id::TRD_GET_YIELD_TREND,
382    proto_id::TRD_GET_RETURN_CALENDAR,
383    proto_id::TRD_GET_ORDER_RELATIONS,
384    proto_id::TRD_GET_POSITION_CORPORATE_ACTIONS,
385    proto_id::TRD_GET_ALGO_ORDER_LOGS,
386];
387
388/// **v1.4.106 ζ28 redo (codex 0532 F4 P3)**: typed coverage exception kind
389/// — 替代无类型 `EXPLICIT_NO_BODY_AWARE_PROTOS` 数组. 每个 exception 必须
390/// 显式分类, 让 "为什么这个 proto 不走 body_aware" 的意图保留在代码里
391/// (而非靠注释推).
392///
393/// 4 个 variant 涵盖所有 "非 body-aware" 场景:
394///
395/// - [`Self::ResponseFiltered`] — request 无 acc_id, 但 response 含 acc_list[]
396///   走 [`futu_auth_pipeline::FilterRegistry`] (e.g. 2001 TRD_GET_ACC_LIST).
397/// - [`Self::PushOnly`] — push event 不是 request, 无 request-side body
398///   (e.g. 2208 TRD_UPDATE_ORDER / 2218 TRD_UPDATE_ORDER_FILL / 2240 TRD_NOTIFY).
399///   pipeline 不应 dispatch push proto 作 request, 但 scope check 仍跑.
400/// - [`Self::MetaNoAccount`] — meta query 无 acc_id 概念 (e.g. 1326
401///   GET_TOKEN_STATE NN/MM token 状态).
402/// - [`Self::InternalOnly`] — daemon-internal proto_id (高位 0x8000_0000 bit),
403///   不应从公开 surface 进入 (gRPC / raw WS / raw TCP). v1.4.106 codex 0532 F3
404///   public surface 显式 reject (见 [`is_internal_proto_id`]).
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406#[non_exhaustive]
407pub enum CoverageException {
408    ResponseFiltered,
409    PushOnly,
410    MetaNoAccount,
411    InternalOnly,
412}
413
414/// `(proto_id, CoverageException)` 显式分类表 — v1.4.106 ζ28 替代无类型
415/// `EXPLICIT_NO_BODY_AWARE_PROTOS`.
416///
417/// 每个 entry 在 invariant test 强制 match 某个 variant, 不允许 hand-roll
418/// "我加进去就行" 漏类型.
419pub const COVERAGE_EXCEPTIONS: &[(u32, CoverageException)] = &[
420    // ResponseFiltered — request 无 acc_id, response s2c.acc_list[] 走 FilterRegistry
421    (2001, CoverageException::ResponseFiltered),
422    // PushOnly — push event 不是 request
423    (2208, CoverageException::PushOnly),
424    (2218, CoverageException::PushOnly),
425    (2240, CoverageException::PushOnly),
426    // MetaNoAccount — meta query 无 acc_id 概念
427    (1326, CoverageException::MetaNoAccount),
428];
429
430/// 列出本 daemon 所有 proto_id → exception 映射表的 proto_id 集合.
431/// 与 [`COVERAGE_EXCEPTIONS`] 同步 (v1.4.106 ζ28 起 source-of-truth 是
432/// `COVERAGE_EXCEPTIONS`, 此 const 仅作 backward compat alias).
433///
434/// **保留供 backward compat**: `body_aware::extract_coverage` 用此 set
435/// 判 NoAccIdConcept 还是 NotRegistered. 加新 exception 走
436/// [`COVERAGE_EXCEPTIONS`] 自动反映在此.
437pub const EXPLICIT_NO_BODY_AWARE_PROTOS: &[u32] = &[
438    1326, // GET_TOKEN_STATE — meta query (NN/MM token state, 无 acc_id)
439    2001, // TRD_GET_ACC_LIST — 走 response-side filter (FilterRegistry)
440    2208, // TRD_UPDATE_ORDER push — 不是 request, 无 body_aware
441    2218, // TRD_UPDATE_ORDER_FILL push
442    2240, // TRD_NOTIFY push
443];
444
445/// **v1.4.106 ζ28 redo (codex 0532 F3 P2)**: 判一个 proto_id 是否是
446/// daemon-internal (高位 `0x8000_0000` bit set).
447///
448/// daemon-internal proto_id (e.g. `TRD_UNSUB_ACC_PUSH_INTERNAL = 0x8000_0000 |
449/// 2008` v1.4.102 codex 44 F1 fix) **绝不应**从公开 surface (gRPC / raw WS /
450/// raw TCP) 进入 — 仅 REST `/api/unsub-acc-push` handler 内部合成给 router.
451///
452/// 公开 surface 看到此 bit set 立即 reject (`Forbidden` 等价 wire error)
453/// 防探测 daemon 内部 routing.
454#[must_use]
455pub fn is_internal_proto_id(proto_id: u32) -> bool {
456    (proto_id & 0x8000_0000) != 0
457}
458
459#[cfg(test)]
460mod tests;