dsh-whale-post-bus
Verifieddsh-whale-post-bus · v0.4.0 · MIT
核心:信封/签名/握手/幂等/落盘
Install
dsh plugin add dsh-whale-post-bus Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
dsh-whale-post-bus
核心:信封 / 摘要+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, hop★★mode也在里面 ✗(漏签它 ⇒ 别人能把它从离线改成在线 ✓)。 - ★★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 ✓)。 ★★仍未测 ✗:seen状态数组超长后的截断路径(只做了人工造件)。 - ★只解决"同一台机器" ✓ —— 信箱是盘上的目录;跨机器要靠共享目录 ✓。