매장 포스에 "매출 취소" 버튼이 있다. 점장만 눌러야 한다.
보통 이렇게 한다. 로그인한 사람이 알바면 그 버튼을 안 그린다.
그리고 그게 권한 처리라고 부른다. 아니다.
안 그리는 것으로는 아무것도 못 막는다
버튼을 안 그리는 게 막는 것은 그 버튼을 눌러서 요청이 나가는 경로 하나뿐이다. 요청은 다른 데서도 나갈 수 있다. 화면 정의를 고칠 수도 있고, 아예 화면 없이 도구를 부를 수도 있다. 화면은 클라이언트고, 클라이언트는 남의 것이다.
이 샘플은 그래서 반대로 만들었다. 버튼을 둘 다에게 보여 준다.

알바 화면이다. "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}} 을 표시한다. 그건 서버가 말해 준 것을 그리는 일이고, 아무것도 결정하지 않는다.