门店 POS 上有个「撤销销售」按钮。只有店长该按。
一般是这么做的:如果登录的人是店员,就不画那个按钮。
然后管这叫权限处理。它不是。
不画它什么也拦不住
不画按钮拦住的只是「按那个按钮把请求发出去」这一条路径。 请求可以从别处发出。画面定义可以改,甚至完全不要画面直接调工具也行。画面是客户端,而客户端是别人的。
所以这个样例反着来。把按钮给两个人都看。

这是店员的画面。「Void last sale」和「Open drawer」都原样在那儿。按得下去。
按下去是这样。
[staff] sale.ring -> "rang 3000" (sales=4 voided=0 refused=0)
[staff] sale.void -> "sale.void is not yours to press" (sales=4 voided=0 refused=1)
[staff] drawer.open -> "drawer.open is not yours to press"(sales=4 voided=0 refused=2)
卖是卖成了,撤销没成。 不是画面拦住的,是服务器没做。
店长打开同一个画面

[manager] sale.ring -> "rang 3000" (sales=4 voided=0 refused=0)
[manager] sale.void -> "voided 3000" (sales=3 voided=1 refused=0)
[manager] drawer.open -> "drawer opened" (sales=3 voided=1 refused=0)
同样的按钮、同样的顺序、不同的结果。
而且画面文件是同一个文件。 这正是这一篇的主张,所以测试装置把哈希打了出来。
screen ui/pages/register.json sha256:72288a2c655a — one file, both roles
the screen offers 3 buttons and shows all of them to everyone
角色不是参数
这是这个样例里唯一的设计决定。
/// Who this session is. Fixed for its lifetime, never taken from a call.
final String role;
假设 sale.void 把 role 当参数收。
sale.void { "role": "manager" }
那么任何能调用它的人都可以自称是店长。 那一刻真正在拦着的,就只剩「画面不发这个值」,于是我们又回到了「不画按钮」。
所以角色在会话打开时就定下来。在这个样例里是启动参数,在真实部署里则是那条连接所认证到的身份。
final role = args
.firstWhere((a) => a.startsWith('--role='), orElse: () => '--role=staff')
.substring('--role='.length);
画面不发送角色。就算想发也发不了。
验证守住这一点 — 画面定义里一旦出现角色名,就判失败。
# The screen may *display* the role it was told. It must never name one — the
# moment "manager" or "staff" appears in a screen definition, some branch is
# being decided in the wrong place.
if grep -qiE '"[^"]*(manager|staff)' register.mbd/ui/pages/register.json; then
echo " the screen definition names a role — the rule has leaked out of the tool"
exit 1
fi
画面显示 {{role}}。那是把服务器告诉它的东西画出来,它不决定任何事。