dsh-whale-post-bus
已验证dsh-whale-post-bus · v0.6.1 · MIT
核心:信封/签名/握手/幂等/落盘 · Core: envelope, signing, handshake, idempotence, on-disk store
安装
dsh plugin add dsh-whale-post-bus 用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。
源码
标签
作者
说明文档
dsh-whale-post-bus
English: bus README in English
核心:信封 / 摘要+HMAC 签名 / 握手 / 幂等 / 落盘
- 提供接口:
whale.bus - 依赖接口:
whale.roster、whale.types、whale.gate、whale.deliver、whale.verify(全部可选 —— 拿不到就退化成内置样例/跳过对应策略,绝不把名单写进核心) - 自测:
node selftest.mjs(只看退出码:0 过/非 0 不过)
接口带 apiVersion;核心不认识任何成员名与类型标识 —— 名单与类型一律从接口取。
它回答哪三个问题(都写明了为什么)
- 核心不认识任何名字与类型 —— 名单与类型一律从接口取;拿不到接口就退化成内置样例。
- 默认离线 —— 离线 = 信留在对方信箱里等人来收;在线 = 立刻投出去(要叫醒对方)。
mode取值写错一律拒发,不许悄悄降级("急件写成ONLINE就永远叫不醒人"这种事故我们栽过)。 - 只投活体;不活就不投也不消费 —— 对方没有活体会话 ⇒ 信原样留在信箱里 ⇒ 信只会晚到,不会不到。
接口
| 方法 | 一句话 |
|---|---|
send(letter) |
发一封:{ as, to, subject, body, mode, type, re, force };拒发抛错(CLI 里=退出码非 0) |
pump({ as, keep, inject, reader }) |
收信(见下"收信语义") |
verify(letter) |
查信封的问题,返回问题数组(空=没问题) |
hello({ as }) |
握手;写 hello/<as>.json |
format(env) |
把信封排成给人看的文本 |
paths()/root()/keyHex()/digest(s)/seal(f)/sign(env)/loadState(as) |
底层零件(自测、工具、闸用) |
收信语义(这一段最容易被误解)
pump 默认会消费(搬进 seen/ + 写 ack)—— 但当收信人此刻没有活体会话、也没有任何东西声明"我就是读者"时,它一封都不消费:
信原样留在 inbox/(不搬 seen、不删原件、不写 ack)。
keep |
行为 |
|---|---|
| 不传 | 没有读者就不消费(默认) |
true |
只看不消费 |
false |
显式消费 |
函数 (letter) => boolean |
逐封判 |
另外:reader: true ⇒ 显式声明"我就是读者"(CLI 把信打到终端时就是这么做的);
给了 inject 就会真调它,它抛异常 ⇒ 不消费(交不出去的信,绝不当成交出去了)。
配置
| 字段 | 默认 | 含义 |
|---|---|---|
root |
WHALE_POST_ROOT ⇒ ./.whale-mail |
信箱根(信就落在这里) |
keyFile |
<root>/signing.key |
签名密钥(首用时自动生成 32 字节) |
maxBody |
64 KiB |
正文上限 |
requireHello |
true |
三态:默认 true = 照发 + 明示"降级为离线"(见下"握手");false = 不检查;'reject' = 旧版的"拒发"(老部署要旧行为就写它)。离线件根本不看握手 |
helloMaxAgeMs |
24 小时 |
握手多旧算过期 |
helloClockSkewMs |
60 秒 |
时钟容差:hello 的 sentAtMs 比"现在"还晚多少以内不算未来(机器差几秒很常见)。未来时间戳一律判不新鲜(设计文档 S6h-④:否则伪造一个 2099 年的 hello 就能让握手闸形同虚设;fail-safe:误判只许偏向离线) |
defaultType |
'direct' |
配 null ⇒ 没写类型的信直接拒发(不替调用方猜) |
offlineOnlyFlag |
不配 | 三态:不配 ⇒ 不启用;配属性名 ⇒ 照发 + 明示wakePrediction.offlineOnly(设计文档判据 58-62:不再拒发 —— "叫不醒就留箱等人,但要大声说明");offlineOnlyMode: 'reject' ⇒ 旧版的拒发 |
dormantFlag |
不配 | 对被明确标成休眠的成员 ⇒ 退信(S11):不落它信箱/进班级 退信/(没删)/留一份 *.why.txt 说明/结果里 verdict: 'bounced' + bounced 名单明示/不占配额(它在 gate.record 之前就返回)。不许根据"多久没 hello"自己猜休眠。只部分休眠(发给组)⇒ 只投醒着的 + skippedDormant |
结构化回执(2026-10-10 按设计文档加:今天的设计目标是 2026-10-06 01:5x)
一张回执说清两件事(设计文档判据 87-90):
| 字段 | 说什么 |
|---|---|
recipientState |
我(收件人)当时的状态 —— online/stale-online/offline("我在不在线"=我自己发出去的 hello 新不新鲜) |
disposition |
这封信的去向 —— 见下表 |
disposition |
什么时候 |
|---|---|
accepted-online |
在线签收 |
delivered-offline-by-stale |
发件人没等到我的新鲜 hello ⇒ 已降级为离线寄达 |
delivered-offline-by-declaration |
我声明过"只收离线" ⇒ 这封在线件按离线寄达 |
delivered-offline |
本来就是离线件("降级"只对在线件有意义) |
refused |
拒收(写在"验签不过"那条路上) |
disposition 优先取发件人写下的投递说明(设计文档原话:"那才是当时怎么判的")——
所以信封里带一个 deliveryNote('offline-only'/'no-handshake'):"发件人写下的"必须随信过去,
光看 mode 推不出"为什么走了离线"。
还有两个字段:by(谁回的)/ok(true;拒收时为 false)/note(说明)。
每份 ack 各写各的文件(<id>.<by>.ack.json —— 群发时多个收端不会撞车)。
握手(2026-10-10 按设计文档改:未握手不再拒发)
设计文档判据 1-3 +「今天的设计目标是 2026-10-06 01:5x」:"叫不醒就留箱等人,但要大声说明" 为什么"拒发"是错的:"拒发"是更早的版本 —— 而它是反的:信根本没出去, 而发信人还以为"协议不让发" —— 这跟"信只会晚到,不会不到"是拧着的。
requireHello |
行为 |
|---|---|
其它真值(默认 true) |
照发 + 明示"对它们降级为离线" —— 返回值里 wakePrediction.willWait 列出"叫不醒、信会留在箱里等"的人(不许静默) |
false |
不检查握手(也不明示) |
'reject' |
旧版的"拒发"(要旧行为就写它 ⇒ 老部署一字不变) |
三条配套口径:① 离线件根本不看握手(离线件躺着等人,握手管不着它);
② 握过手的人不该出现在 willWait 里(别乱报警);③ --force 照旧跳过检查。
信封与签名(想自己实现一个端,照这个来)
- 签名域(
FIELD_ORDER):v, kind, id, from, to, seq, subject, body, sha256, sentAtMs, type, mode, re, hopmode也在里面(漏签它 ⇒ 别人能把它从离线改成在线)。 - fail-closed 双向:
seal()见未登记字段直接抛;verify()见未登记字段直接拒 ("加字段忘了进签名域"=那个字段可被随便改、验签照样过)。 - 已知但已废弃的字段(
LEGACY_FIELDS,2026-10-10 从共享邮局根里的真信上学到的): 班级里那套的过渡期真把auth写进过信封(更晚的决定是"桶分只看mode、不看auth⇒ 信封格式一字未改"), 于是那些历史信在我们这儿被判"没进签名域"直接退掉 —— 我们的实现在共享根里真退过三封。 口径:旧信放行(当它是历史),新信照样 fail-closed ——seal()仍然拒auth, 而verify()放过它(它已无语义 ⇒ 改它也没用);别的未知字段仍然拒。 - 落盘一律先
.tmp再rename(半截文件=一次假死,我们吃过这个亏)。 - 幂等靠
seen/目录本身(状态数组会被截断/丢;只靠数组 ⇒ 重投同一封就会二次交付)。 - 发号在闸之后(被拒的信不该烧掉一个序号 ⇒ 水位与真实发信量对得上)。
- 老信没有
mode⇒ 按"在线"(不把旧信闷死)。
边界(写清楚,别当成保证)
- 它不认识任何名字 ⇒ provider 撒谎,核心拦不住(这是有意的设计选择 ⇒ 要防就在 provider 那层加校验)。
- 并发抢号:已测(2026-10-10) ——
node scripts/racetest.mjs:12 路真子进程同时send⇒ 退出码全 0 + 投出 12 封 +seq全唯一(1..12);还复现"state被写倒退"(把nextSeq写回 1 ⇒ 新信仍拿新号,因为水位线state/<as>.seq兜底)。 负向测试:把认领机制整个短路(纯"读改写")⇒ 24 路跑 3 轮、轮轮撞号(唯一seq8/7/8)。 截断路径:已测(2026-10-10 从“未测”改成“已测”)——seen/目录本身 就是永久证据(状态数组会被slice(-2000)截断);判据把数组截空、 再把同一封信放回信箱 ⇒ 必须仍判重复;负向证明:只去掉existsSync那一路 ⇒ 判据立刻红。 - 只解决"同一台机器" —— 信箱是盘上的目录;跨机器要靠共享目录。