@lepsto/sdk-app 84.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/README.md +233 -0
  2. package/dist/_types/gen/analytics/index.d.ts +2 -0
  3. package/dist/_types/gen/analytics/queryOptions.gen.d.ts +74 -0
  4. package/dist/_types/gen/bindings.gen.d.ts +3 -0
  5. package/dist/_types/gen/brain/index.d.ts +2 -0
  6. package/dist/_types/gen/brain/queryOptions.gen.d.ts +166 -0
  7. package/dist/_types/gen/client.gen.d.ts +1087 -0
  8. package/dist/_types/gen/consent/index.d.ts +2 -0
  9. package/dist/_types/gen/consent/queryOptions.gen.d.ts +62 -0
  10. package/dist/_types/gen/content/index.d.ts +2 -0
  11. package/dist/_types/gen/content/queryOptions.gen.d.ts +126 -0
  12. package/dist/_types/gen/deployment/index.d.ts +2 -0
  13. package/dist/_types/gen/deployment/queryOptions.gen.d.ts +438 -0
  14. package/dist/_types/gen/mail/index.d.ts +2 -0
  15. package/dist/_types/gen/mail/queryOptions.gen.d.ts +206 -0
  16. package/dist/_types/gen/manifest.gen.d.ts +4 -0
  17. package/dist/_types/gen/observe/index.d.ts +2 -0
  18. package/dist/_types/gen/observe/queryOptions.gen.d.ts +142 -0
  19. package/dist/_types/gen/organization/index.d.ts +2 -0
  20. package/dist/_types/gen/organization/queryOptions.gen.d.ts +362 -0
  21. package/dist/_types/gen/realtime/index.d.ts +2 -0
  22. package/dist/_types/gen/realtime/queryOptions.gen.d.ts +114 -0
  23. package/dist/_types/gen/support/index.d.ts +2 -0
  24. package/dist/_types/gen/support/queryOptions.gen.d.ts +206 -0
  25. package/dist/_types/gen/tracking/index.d.ts +2 -0
  26. package/dist/_types/gen/tracking/queryOptions.gen.d.ts +150 -0
  27. package/dist/_types/gen/types.gen.d.ts +126011 -0
  28. package/dist/_types/gen/users/index.d.ts +2 -0
  29. package/dist/_types/gen/users/queryOptions.gen.d.ts +182 -0
  30. package/dist/_types/gen/waitlist/index.d.ts +2 -0
  31. package/dist/_types/gen/waitlist/queryOptions.gen.d.ts +30 -0
  32. package/dist/_types/gen/workflow/index.d.ts +2 -0
  33. package/dist/_types/gen/workflow/queryOptions.gen.d.ts +166 -0
  34. package/dist/_types/index.d.ts +11 -0
  35. package/dist/_types/package.json +1 -0
  36. package/dist/_types/react/index.d.ts +33 -0
  37. package/dist/_types/runtime/access-reason.d.ts +27 -0
  38. package/dist/_types/runtime/access.d.ts +40 -0
  39. package/dist/_types/runtime/compositions.d.ts +16 -0
  40. package/dist/_types/runtime/connectStream.d.ts +15 -0
  41. package/dist/_types/runtime/createLesslyApp.d.ts +10 -0
  42. package/dist/_types/runtime/csrf.d.ts +1 -0
  43. package/dist/_types/runtime/errors.d.ts +18 -0
  44. package/dist/_types/runtime/name.d.ts +26 -0
  45. package/dist/_types/runtime/operations.d.ts +13 -0
  46. package/dist/_types/runtime/permission-match.d.ts +62 -0
  47. package/dist/_types/runtime/request.d.ts +9 -0
  48. package/dist/_types/runtime/types.d.ts +129 -0
  49. package/dist/analytics/index.cjs +96 -0
  50. package/dist/analytics/index.cjs.map +1 -0
  51. package/dist/analytics/index.d.cts +1 -0
  52. package/dist/analytics/index.d.ts +1 -0
  53. package/dist/analytics/index.js +77 -0
  54. package/dist/analytics/index.js.map +1 -0
  55. package/dist/brain/index.cjs +211 -0
  56. package/dist/brain/index.cjs.map +1 -0
  57. package/dist/brain/index.d.cts +1 -0
  58. package/dist/brain/index.d.ts +1 -0
  59. package/dist/brain/index.js +169 -0
  60. package/dist/brain/index.js.map +1 -0
  61. package/dist/consent/index.cjs +81 -0
  62. package/dist/consent/index.cjs.map +1 -0
  63. package/dist/consent/index.d.cts +1 -0
  64. package/dist/consent/index.d.ts +1 -0
  65. package/dist/consent/index.js +65 -0
  66. package/dist/consent/index.js.map +1 -0
  67. package/dist/content/index.cjs +161 -0
  68. package/dist/content/index.cjs.map +1 -0
  69. package/dist/content/index.d.cts +1 -0
  70. package/dist/content/index.d.ts +1 -0
  71. package/dist/content/index.js +129 -0
  72. package/dist/content/index.js.map +1 -0
  73. package/dist/deployment/index.cjs +551 -0
  74. package/dist/deployment/index.cjs.map +1 -0
  75. package/dist/deployment/index.d.cts +1 -0
  76. package/dist/deployment/index.d.ts +1 -0
  77. package/dist/deployment/index.js +441 -0
  78. package/dist/deployment/index.js.map +1 -0
  79. package/dist/index.cjs +12565 -0
  80. package/dist/index.cjs.map +1 -0
  81. package/dist/index.d.cts +1 -0
  82. package/dist/index.d.ts +1 -0
  83. package/dist/index.js +12557 -0
  84. package/dist/index.js.map +1 -0
  85. package/dist/mail/index.cjs +261 -0
  86. package/dist/mail/index.cjs.map +1 -0
  87. package/dist/mail/index.d.cts +1 -0
  88. package/dist/mail/index.d.ts +1 -0
  89. package/dist/mail/index.js +209 -0
  90. package/dist/mail/index.js.map +1 -0
  91. package/dist/observe/index.cjs +181 -0
  92. package/dist/observe/index.cjs.map +1 -0
  93. package/dist/observe/index.d.cts +1 -0
  94. package/dist/observe/index.d.ts +1 -0
  95. package/dist/observe/index.js +145 -0
  96. package/dist/observe/index.js.map +1 -0
  97. package/dist/organization/index.cjs +456 -0
  98. package/dist/organization/index.cjs.map +1 -0
  99. package/dist/organization/index.d.cts +1 -0
  100. package/dist/organization/index.d.ts +1 -0
  101. package/dist/organization/index.js +365 -0
  102. package/dist/organization/index.js.map +1 -0
  103. package/dist/react/index.cjs +29 -0
  104. package/dist/react/index.cjs.map +1 -0
  105. package/dist/react/index.d.cts +1 -0
  106. package/dist/react/index.d.ts +1 -0
  107. package/dist/react/index.js +26 -0
  108. package/dist/react/index.js.map +1 -0
  109. package/dist/realtime/index.cjs +146 -0
  110. package/dist/realtime/index.cjs.map +1 -0
  111. package/dist/realtime/index.d.cts +1 -0
  112. package/dist/realtime/index.d.ts +1 -0
  113. package/dist/realtime/index.js +117 -0
  114. package/dist/realtime/index.js.map +1 -0
  115. package/dist/support/index.cjs +261 -0
  116. package/dist/support/index.cjs.map +1 -0
  117. package/dist/support/index.d.cts +1 -0
  118. package/dist/support/index.d.ts +1 -0
  119. package/dist/support/index.js +209 -0
  120. package/dist/support/index.js.map +1 -0
  121. package/dist/tracking/index.cjs +191 -0
  122. package/dist/tracking/index.cjs.map +1 -0
  123. package/dist/tracking/index.d.cts +1 -0
  124. package/dist/tracking/index.d.ts +1 -0
  125. package/dist/tracking/index.js +153 -0
  126. package/dist/tracking/index.js.map +1 -0
  127. package/dist/users/index.cjs +231 -0
  128. package/dist/users/index.cjs.map +1 -0
  129. package/dist/users/index.d.cts +1 -0
  130. package/dist/users/index.d.ts +1 -0
  131. package/dist/users/index.js +185 -0
  132. package/dist/users/index.js.map +1 -0
  133. package/dist/waitlist/index.cjs +41 -0
  134. package/dist/waitlist/index.cjs.map +1 -0
  135. package/dist/waitlist/index.d.cts +1 -0
  136. package/dist/waitlist/index.d.ts +1 -0
  137. package/dist/waitlist/index.js +33 -0
  138. package/dist/waitlist/index.js.map +1 -0
  139. package/dist/workflow/index.cjs +211 -0
  140. package/dist/workflow/index.cjs.map +1 -0
  141. package/dist/workflow/index.d.cts +1 -0
  142. package/dist/workflow/index.d.ts +1 -0
  143. package/dist/workflow/index.js +169 -0
  144. package/dist/workflow/index.js.map +1 -0
  145. package/docs/README.md +89 -0
  146. package/docs/recipes/access.md +781 -0
  147. package/docs/recipes/federation.md +285 -0
  148. package/docs/recipes/local-dev.md +111 -0
  149. package/docs/recipes/sdk-usage.md +194 -0
  150. package/docs/rules.md +522 -0
  151. package/package.json +212 -0
  152. package/src/gen/analytics/index.ts +3 -0
  153. package/src/gen/analytics/queryOptions.gen.ts +124 -0
  154. package/src/gen/bindings.gen.ts +11330 -0
  155. package/src/gen/brain/index.ts +3 -0
  156. package/src/gen/brain/queryOptions.gen.ts +278 -0
  157. package/src/gen/client.gen.ts +2288 -0
  158. package/src/gen/consent/index.ts +3 -0
  159. package/src/gen/consent/queryOptions.gen.ts +102 -0
  160. package/src/gen/content/index.ts +3 -0
  161. package/src/gen/content/queryOptions.gen.ts +202 -0
  162. package/src/gen/deployment/index.ts +3 -0
  163. package/src/gen/deployment/queryOptions.gen.ts +707 -0
  164. package/src/gen/mail/index.ts +3 -0
  165. package/src/gen/mail/queryOptions.gen.ts +331 -0
  166. package/src/gen/manifest.gen.ts +780 -0
  167. package/src/gen/observe/index.ts +3 -0
  168. package/src/gen/observe/queryOptions.gen.ts +235 -0
  169. package/src/gen/organization/index.ts +3 -0
  170. package/src/gen/organization/queryOptions.gen.ts +578 -0
  171. package/src/gen/realtime/index.ts +3 -0
  172. package/src/gen/realtime/queryOptions.gen.ts +185 -0
  173. package/src/gen/support/index.ts +3 -0
  174. package/src/gen/support/queryOptions.gen.ts +331 -0
  175. package/src/gen/tracking/index.ts +3 -0
  176. package/src/gen/tracking/queryOptions.gen.ts +249 -0
  177. package/src/gen/types.gen.ts +119506 -0
  178. package/src/gen/users/index.ts +3 -0
  179. package/src/gen/users/queryOptions.gen.ts +295 -0
  180. package/src/gen/waitlist/index.ts +3 -0
  181. package/src/gen/waitlist/queryOptions.gen.ts +50 -0
  182. package/src/gen/workflow/index.ts +3 -0
  183. package/src/gen/workflow/queryOptions.gen.ts +274 -0
@@ -0,0 +1,781 @@
1
+ # Recipe: reflecting the caller's access in the UI
2
+
3
+ Some of your users cannot do some of the things your App offers. This recipe
4
+ shows how to grey out those controls and how to render the refusal you will
5
+ still occasionally get — without ever making your App the authority on who is
6
+ allowed to do what.
7
+
8
+ **The one rule everything here rests on:** `can()` is a *prediction*, the server
9
+ is the authority. See APP-012 in `../rules.md`.
10
+
11
+ ---
12
+
13
+ ## What `app.access` is
14
+
15
+ Every client from `createLesslyApp` carries an `access` object:
16
+
17
+ ```ts
18
+ import { createLesslyApp } from '@lessly/sdk-app';
19
+
20
+ const app = createLesslyApp({ baseUrl: '/api', productId });
21
+
22
+ await app.access.load(); // one GET; cached; concurrent calls share it
23
+ app.access.role; // 'owner' | 'admin' | 'member' | a custom role name
24
+ app.access.state; // 'idle' | 'loading' | 'ready' | 'error'
25
+ app.access.can('mail_domain_create'); // boolean, synchronous
26
+ app.access.invalidate(); // drop the cache; the next load() refetches
27
+ app.access.subscribe(() => {}); // fires on every state change
28
+ ```
29
+
30
+ `load()` fetches the caller's own membership of the active product — their role
31
+ and their allow/deny grant patterns — and `can()` answers from that snapshot
32
+ using the *same matcher the gateway uses to decide*. That is the only reason a
33
+ client-side prediction is worth anything.
34
+
35
+ `can()` is **false whenever the snapshot is not ready**: before `load()`, while
36
+ it is in flight, after a failure, and after `invalidate()`. So a control wired
37
+ to it starts disabled and enables itself — never the reverse. A control that
38
+ starts enabled and greys out a moment later is worse than one that never greyed
39
+ out at all.
40
+
41
+ `can()` being false is not by itself a reason to *tell the user their role is
42
+ the problem*, though — a failed `/me` is not a denial. See the state table in
43
+ §2 for what each `state` means for a control.
44
+
45
+ The snapshot lives **in the client instance** until `invalidate()` or until the
46
+ client itself is recreated. There is no TTL and no background refresh: a client
47
+ made once holds the role it loaded once, for as long as it lives.
48
+
49
+ That matters because a module-level singleton `createLesslyApp` client survives
50
+ SPA navigation and bfcache back/forward. A "reload" that does not remount the
51
+ remote — a router push, a browser Back into a restored page — keeps the old
52
+ snapshot, and the UI can lag the real role by minutes while `GET
53
+ products/:id/me` would answer correctly if anyone asked it. After a role change,
54
+ call `app.access.invalidate()` and then `load()` again (or do a full page
55
+ reload); do not wait for the snapshot to expire, because it never does.
56
+
57
+ Before you measure staleness, compare the role the App's own `/me` request
58
+ returned — read it in the Network tab — with what the API says now. Never assume
59
+ the role from memory: the two disagree exactly in the case you are debugging.
60
+ And it is never the HTTP cache alone: the server sends no `Cache-Control` for
61
+ `/me` today, so the browser only revalidates via `ETag`. Staleness you see is
62
+ the in-memory snapshot.
63
+
64
+ You can pass either a tool id or a generated method, which carries its own
65
+ identity:
66
+
67
+ ```ts
68
+ app.access.can('mail_domain_create'); // a tool id
69
+ app.access.can(app.mail.domain.create); // the method itself — no id to keep in sync
70
+ ```
71
+
72
+ Prefer the second form. The accessor path is a lossy view of the tool id (a
73
+ hyphenated resource joins several id segments), so a hand-written string can
74
+ drift from the method it is meant to describe; the method cannot.
75
+
76
+ ---
77
+
78
+ ## 1. Load once at app mount
79
+
80
+ ```tsx
81
+ // src/App.tsx
82
+ import { useEffect } from 'react';
83
+ import { createLesslyApp } from '@lessly/sdk-app';
84
+ import { TooltipProvider } from '@lessly/ui';
85
+
86
+ export default function App({ productId }: { productId: string }) {
87
+ const app = useMemo(() => createLesslyApp({ baseUrl: '/api', productId }), [productId]);
88
+
89
+ useEffect(() => {
90
+ // Fire and forget: a failure is reflected in app.access.state, and can() stays false
91
+ // until the snapshot is ready.
92
+ void app.access.load().catch(() => {});
93
+ }, [app]);
94
+
95
+ // One TooltipProvider per App canvas — see §2. Never one per control, and never
96
+ // borrowed from the shell: your standalone build and your tests render outside it.
97
+ return (
98
+ <TooltipProvider>
99
+ <Routes app={app} />
100
+ </TooltipProvider>
101
+ );
102
+ }
103
+ ```
104
+
105
+ You do not have to do this. `useAccess`/`useCan` load on mount themselves, and
106
+ `load()` is de-duplicated inside the client — ten components mounting at once
107
+ produce **one** request. Loading at the root is just the cheapest way to have
108
+ the snapshot ready before the first screen paints.
109
+
110
+ Call `app.access.invalidate()` after anything that can change the caller's own
111
+ grants (they accepted an invitation, an admin changed their role in a settings
112
+ screen you own). Nothing else invalidates it: the snapshot is deliberately not
113
+ refetched on a timer.
114
+
115
+ ---
116
+
117
+ ## 2. `useCan` on primary actions — disable, don't hide
118
+
119
+ ```tsx
120
+ import { accessReason } from '@lessly/sdk-app';
121
+ import { useAccess, useCan } from '@lessly/sdk-app/react';
122
+ import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@lessly/ui';
123
+
124
+ function AddDomainButton({ app, onClick }: { app: App; onClick: () => void }) {
125
+ const { state } = useAccess(app);
126
+ const canCreate = useCan(app, app.mail.domain.create);
127
+
128
+ // 'error' is NOT a denial: nothing is known about the role, so make no prediction.
129
+ const disabled = state === 'ready' ? !canCreate : state !== 'error';
130
+ // The reason names the level AND the operation key, both read off the method —
131
+ // never hand-typed per button, and never the caller's role.
132
+ const reason =
133
+ state === 'ready' && !canCreate
134
+ ? accessReason(app.mail.domain.create)
135
+ : undefined;
136
+
137
+ const button = (
138
+ <Button disabled={disabled} onClick={onClick}>
139
+ Add domain
140
+ </Button>
141
+ );
142
+
143
+ if (reason === undefined) return button;
144
+
145
+ return (
146
+ <Tooltip>
147
+ {/* The trigger is the WRAPPER, not the Button: the base buttonVariants set
148
+ disabled:pointer-events-none, so a disabled control fires no hover and a
149
+ tooltip mounted on it never opens. */}
150
+ <TooltipTrigger asChild>
151
+ <span tabIndex={0}>{button}</span>
152
+ </TooltipTrigger>
153
+ <TooltipContent>{reason}</TooltipContent>
154
+ </Tooltip>
155
+ );
156
+ }
157
+ ```
158
+
159
+ ### The reason goes on a wrapper, never on `title=`
160
+
161
+ A `title` on a disabled Button is unreachable twice over: `disabled:pointer-events-none`
162
+ swallows the hover the browser needs to show it, and a native tooltip is unreachable
163
+ by keyboard and by touch even on an enabled control. That class sits in the **base**
164
+ `buttonVariants`, not in an icon-only branch — so the wrapper pattern applies to
165
+ *every* Button, not just the icon-only shape. The wrapping trigger carries
166
+ `tabIndex={0}` for the same reason: a keyboard user has to be able to reach the hint.
167
+ This mirrors what `@lessly/ui` documents above `ButtonIconOnlyProps` in `button.tsx`
168
+ (`Button --disabled-with-tooltip` shows both halves).
169
+
170
+ Controls that take no `title` at all — the kit's `Switch`, for one — have no other
171
+ option: the wrapper is the only place the reason can live.
172
+
173
+ **The trigger must sit outside every `pointer-events-none`, including your own.**
174
+ The kit's `disabled:pointer-events-none` is the common case, but the mechanism is
175
+ not the kit's: a list row, a chip or an overlay in your own App that you set
176
+ `pointer-events-none` on swallows hover identically, and a trigger nested inside
177
+ it is just as silent — in your code this time. Walk outwards from the control to
178
+ the first ancestor that still receives pointer events, and put the trigger there.
179
+
180
+ ### One reason string, built from the method
181
+
182
+ Every App says the same sentence, and both of its values come off the generated
183
+ method:
184
+
185
+ ```tsx
186
+ import { accessReason } from '@lessly/sdk-app';
187
+
188
+ accessReason(app.mail.domain.create);
189
+ // "Requires level:admin (mail_domain_create). Ask an admin of this product."
190
+ ```
191
+
192
+ Use it in every example below; nothing in an App should assemble this text by
193
+ hand. It never throws and never renders the word `undefined`: an object with a
194
+ key but no usable level degrades to the key-only sentence, and an input with no
195
+ usable key at all falls back to `"Not available for your role. Ask an admin of
196
+ this product."` (plus one `console.warn` outside production, because that input
197
+ is a caller bug).
198
+
199
+ It takes **one** operation. Choosing which operation to explain — the first one
200
+ `can()` denied, out of the several a button performs — is the caller's job, and
201
+ it stays the caller's job: the helper accepts no array, because the rule for
202
+ picking is not the same in the two shapes below and a helper that guessed would
203
+ be wrong in one of them.
204
+
205
+ Its signature is `Operation | string`, and the two arms are not equals. Passing a
206
+ method is the norm. The string arm is a **degradation**, there for the controls
207
+ that have no generated method to point at — a runtime probe, an operation no
208
+ method covers — and it says less because less is known:
209
+
210
+ ```tsx
211
+ accessReason('some_runtime_probe');
212
+ // "Requires access to some_runtime_probe. Ask an admin of this product."
213
+ ```
214
+
215
+ It drops the level rather than inventing one: with only a key there is nothing to
216
+ read a level from, and `level:undefined` in front of a user is worse than saying
217
+ less. It is not permission to pass strings where a method exists — the accessor
218
+ path is a lossy view of the key, so a hand-written string can drift from the
219
+ operation it names, and only the method's arm gives an admin both halves of what
220
+ they grant on. An App whose every gate receives an `Operation` never writes the
221
+ string form at all; that is the normal shape of an App, not a gap in it.
222
+
223
+ `can()` is cautious with a bare string in the same way. It resolves the level
224
+ from the catalog when the key is one the SDK knows, but a key with **no** catalog
225
+ entry can only be matched by key pattern — no level grant reaches it, which is
226
+ exactly what the gateway does with an uncatalogued key.
227
+
228
+ > **On an older SDK.** `accessReason` ships from `@lessly/sdk-app`. On a version
229
+ > before it landed, define it locally with exactly this signature and body, then
230
+ > replace it with the import when you upgrade:
231
+ >
232
+ > ```ts
233
+ > import type { Operation } from '@lessly/sdk-app';
234
+ >
235
+ > const ok = (v: unknown): v is string => typeof v === 'string' && v.trim() !== '';
236
+ >
237
+ > export const accessReason = (op: Operation | string): string => {
238
+ > if (ok(op)) return `Requires access to ${op}. Ask an admin of this product.`;
239
+ > // A generated method is a CALLABLE carrying operationKey/level, so not just 'object'.
240
+ > if (op !== null && (typeof op === 'object' || typeof op === 'function')) {
241
+ > const { operationKey, level } = op as { operationKey?: unknown; level?: unknown };
242
+ > if (ok(operationKey)) {
243
+ > return ok(level) && ['read', 'write', 'admin'].includes(level)
244
+ > ? `Requires level:${level} (${operationKey}). Ask an admin of this product.`
245
+ > : `Requires access to ${operationKey}. Ask an admin of this product.`;
246
+ > }
247
+ > }
248
+ > return 'Not available for your role. Ask an admin of this product.';
249
+ > };
250
+ > ```
251
+ >
252
+ > It never throws — a render helper that crashes takes the whole screen with it —
253
+ > and never renders `level:undefined`.
254
+
255
+ **Why both values.** The reason says what an admin has to *grant*, not what the
256
+ caller happens to be — a role name gives them nothing to act on, and "your role
257
+ cannot do this" sends the user into a conversation with no next step. But a level
258
+ alone is not enough either: seventeen controls on a screen can all read
259
+ `level:write` and be seventeen different grants. An admin grants both **by level
260
+ and by exact operation key**, so the user has to be able to pass on both. The key
261
+ is what makes the sentence actionable; the level is what makes it
262
+ comprehensible.
263
+
264
+ **Both come only from the method.** `op.level` and `op.operationKey` — every
265
+ generated method satisfies `interface Operation { operationKey, level }`. No
266
+ lookup table in your App, no literal in the JSX, and above all no rule of thumb
267
+ about which verbs are which level. There is no such rule: `analytics_dashboard_delete`
268
+ and `analytics_insight_delete` are `write`, `mail_domain_create` is `admin`, and
269
+ the whole `users` namespace has exactly six `admin` operations. Only the catalog
270
+ knows, the method carries what the catalog said, and a reason built from the
271
+ method cannot drift the day an operation is re-levelled. The accessor path is a
272
+ lossy view of the key, so never rebuild `operationKey` from it either — read it
273
+ off the method.
274
+
275
+ ### Reading a level without a method
276
+
277
+ Sometimes there is no method to read `op.level` from: an App test double that has
278
+ to answer `can()` for a key, a runtime probe over keys it was handed. The answer
279
+ is still not a literal and still not a table in the App — `@lessly/sdk-app`
280
+ exports the catalog's own level map:
281
+
282
+ ```ts
283
+ import { operations, accessReason } from '@lessly/sdk-app';
284
+
285
+ operations['mail_domain_create']; // 'admin'
286
+ operations['analytics_insight_delete']; // 'write'
287
+ ```
288
+
289
+ `operations` is `Readonly<Record<string, ToolLevel>>`, keyed by operation key. It
290
+ is regenerated at publish time from the live catalog, so it re-levels with the
291
+ platform exactly as the generated methods do — which is the whole reason it may
292
+ be read where a hand-written table may not (APP-012).
293
+
294
+ A key the map does not carry answers `undefined`, and that is the case
295
+ `accessReason`'s string form already covers: pass the bare key and get
296
+ *"Requires access to `<key>`. Ask an admin of this product."* — no level invented,
297
+ no `level:undefined` in front of a user. Where you do hold a method, keep reading
298
+ `op.level` off it: the map is for the keys no method covers, not a second way to
299
+ do what the method already does.
300
+
301
+ ### Your App mounts its own `TooltipProvider`
302
+
303
+ Mount **one `TooltipProvider` per App canvas** — at the root of your remote, as in
304
+ §1 — and never one per control. Do not rely on the shell for it: `Tooltip.Root`
305
+ *throws* without a provider above it, so a remote that inherits one only by luck
306
+ does not lose its hint, it crashes — and your standalone build, your dev entry and
307
+ your tests all render outside the shell entirely.
308
+
309
+ ### What each access state means for a control
310
+
311
+ | `state` | Control | Reason shown |
312
+ | --- | --- | --- |
313
+ | `idle` / `loading` | disabled | none |
314
+ | `ready` | disabled iff `can()` is false | only when `can()` is false |
315
+ | `error` | **enabled** | none — no prediction |
316
+
317
+ `idle`/`loading` start disabled and enable themselves — a control that starts
318
+ enabled and greys out a moment later is worse than one that never greyed out.
319
+ But it claims **no reason** while the snapshot is still in flight: nothing is
320
+ known yet about which level is missing, so there is nothing to say.
321
+
322
+ `error` (the `/me` call failed: a 500, a dropped connection, an unparsable product
323
+ id) is the case worth getting right: **make no prediction**. Leave the control
324
+ enabled, name no missing level, let the gateway decide, and render the refusal
325
+ neutrally through `isAccessDenied` — disabling on error makes your client the
326
+ authority and tells the user a level is missing when nothing at all is known
327
+ about their access.
328
+
329
+ `can()` itself is unchanged by any of this: it is `false` unless the state is
330
+ `ready`. The `error` branch is a decision your *control* makes, not something
331
+ `can()` reports.
332
+
333
+ **Disable, with a hint. Do not hide.** A user who cannot see the button cannot
334
+ tell their admin which permission they are missing — they file a "the app is
335
+ broken" ticket instead, and the admin who could have fixed it in ten seconds
336
+ never hears about it. The greyed-out control with a one-line reason *is* the
337
+ support channel.
338
+
339
+ `useAccess` gives you the same information plus the role, for the cases where
340
+ one flag is not enough:
341
+
342
+ ```tsx
343
+ const { state, role, reload } = useAccess(app);
344
+
345
+ if (state === 'loading') return <Spinner />;
346
+ if (state === 'error') return <p>Could not load your permissions. <button onClick={reload}>Retry</button></p>;
347
+ ```
348
+
349
+ React is an **optional peer** of this package: `@lessly/sdk-app/react` is only
350
+ loaded if you import it, and installing the SDK does not pull React in.
351
+
352
+ ### Actions without a button
353
+
354
+ A write does not stop being a write because nothing on screen says "Save". Drag to
355
+ reorder, an inline-editable cell, a field that autosaves on blur — each is a
356
+ gated operation, and each is gated the **same way** a button is: put the control
357
+ into its inactive state the native way for that control, then hang the reason on
358
+ a keyboard-reachable `Tooltip` wrapper exactly as above.
359
+
360
+ | Control | Inactive state |
361
+ | --- | --- |
362
+ | Drag handle / sortable row | `draggable={false}` (and drop the drag listeners) |
363
+ | Inline-editable field | `readOnly` |
364
+ | Autosave-on-blur input | `readOnly` |
365
+ | Switch / checkbox | `disabled` |
366
+
367
+ ```tsx
368
+ function ReorderableRow({ app, row }: { app: App; row: Row }) {
369
+ const { state } = useAccess(app);
370
+ const canReorder = useCan(app, app.mail.domain.update);
371
+ const denied = state === 'ready' && !canReorder;
372
+
373
+ const item = (
374
+ <li draggable={!denied} onDragStart={denied ? undefined : startDrag}>
375
+ {row.name}
376
+ </li>
377
+ );
378
+
379
+ if (!denied) return item;
380
+
381
+ return (
382
+ <Tooltip>
383
+ <TooltipTrigger asChild>
384
+ <span tabIndex={0}>{item}</span>
385
+ </TooltipTrigger>
386
+ <TooltipContent>
387
+ {accessReason(app.mail.domain.update)}
388
+ </TooltipContent>
389
+ </Tooltip>
390
+ );
391
+ }
392
+ ```
393
+
394
+ **Gate the operation, not the gesture.** A write is often reachable by more than
395
+ one path — a row that reorders by mouse drag *and* by arrow keys, a control that
396
+ fires on click *and* on Enter, an action with a button *and* a hotkey. The gate
397
+ has to silence all of them at once. Gating the drag and leaving the arrow keys
398
+ live is not a cosmetic miss: it is a hole, and it is a hole that only keyboard
399
+ users fall into. Derive the flag once per operation and let every path read it,
400
+ rather than attaching a check to each handler. Acceptance under a viewer checks
401
+ the alternative path too, not just the obvious one.
402
+
403
+ **Enter inside a form field is one of those paths.** A form with a single text
404
+ input submits on Enter whether or not you wired anything to it — that is implicit
405
+ submission, and a disabled submit button does not reliably stop it. Gate the
406
+ `onSubmit` handler itself, or make the fields `readOnly`; a greyed-out button
407
+ beside a live Enter key is the same keyboard-only hole in a different costume.
408
+
409
+ ```tsx
410
+ <form
411
+ onSubmit={(event) => {
412
+ event.preventDefault();
413
+ if (denied) return; // the button being disabled is NOT enough
414
+ save();
415
+ }}
416
+ >
417
+ ```
418
+
419
+ "Don't gate it, let the gateway refuse" is allowed for exactly one case: the
420
+ element physically has no wrapper that can be made focusable. That is rare, and
421
+ it is not a judgement call you make silently — write it into your App's spec as a
422
+ named exception with the reason. An ungated write that nobody wrote down is
423
+ indistinguishable from one nobody thought about.
424
+
425
+ ### One reason for a surface-wide gate
426
+
427
+ Where a gate disables a *capability across a whole surface* — dragging any node on
428
+ a canvas, a bulk action over a list or a grid — the reason goes on the control
429
+ that **owns** the capability: the toggle, the toolbar button, the surface header.
430
+ Not on every affected element. Thirty identical focusable wrappers are thirty
431
+ extra tab stops and one sentence repeated thirty times; that is noise, not thirty
432
+ explanations. Keyboard reachability (below) is satisfied on the owning control,
433
+ and the elements themselves just go inert — `draggable={false}`, `readOnly`,
434
+ `disabled` — with no wrapper of their own.
435
+
436
+ Some surfaces have no such control. A calendar's Month/Week toggle owns the
437
+ *view*, not the move; a board's filter bar owns the filter, not the drop. Do not
438
+ hang the reason on it: the reason names the operation actually gated, and
439
+ `accessReason(app.content.publications.reschedule)` under a control that switches
440
+ weeks explains a gate that control does not hold. Where nothing owns the
441
+ capability, the reason goes on the **surface itself, as one `CardNote`** — the
442
+ same shape as a deferred-save form:
443
+
444
+ ```tsx
445
+ <Card title="Schedule">
446
+ <Calendar events={events} draggable={false} />
447
+ {denied && <CardNote>{accessReason(app.content.publications.reschedule)}</CardNote>}
448
+ </Card>
449
+ ```
450
+
451
+ One line for the surface, elements inert, no tooltip anywhere on the grid.
452
+
453
+ ### Buttons that touch more than one operation
454
+
455
+ Two shapes, and they gate differently. Get this wrong and you either grey out a
456
+ working button or leave a broken one live.
457
+
458
+ **A button that performs several operations per click** — "Deploy" that creates,
459
+ uploads and promotes — is disabled if **any** of them is denied, and names the
460
+ level and key of the one that blocked it, not those of the headline operation. Call
461
+ `useAccess` once and use the synchronous `access.can()` for each; `useCan` is a
462
+ hook and cannot be called in a loop.
463
+
464
+ ```tsx
465
+ const OPS = [app.mail.domain.create, app.mail.domain.verify] as const;
466
+
467
+ function AddAndVerifyButton({ app, onClick }: { app: App; onClick: () => void }) {
468
+ const { state, access } = useAccess(app);
469
+ const blocking = state === 'ready' ? OPS.find((op) => !access.can(op)) : undefined;
470
+
471
+ const disabled = state === 'ready' ? blocking !== undefined : state !== 'error';
472
+ const reason = blocking
473
+ ? accessReason(blocking)
474
+ : undefined;
475
+
476
+ // …same wrapper as above when `reason` is set.
477
+ }
478
+ ```
479
+
480
+ **A dispatcher button** — one operation per click, picked by a mode or a switch
481
+ beside it: *Import* over `users | waitlist`, *Save* that creates or updates
482
+ depending on whether the record exists — is gated by the operation it is
483
+ **actually about to call**, and its reason names *that* operation's level and key. Not
484
+ the union: a caller who may import users but not the waitlist keeps a working
485
+ Import button, and it greys out when they flip the switch.
486
+
487
+ ```tsx
488
+ const op = mode === 'users' ? app.users.users.import : app.users.waitlist.import;
489
+
490
+ const { state, access } = useAccess(app);
491
+ const denied = state === 'ready' && !access.can(op);
492
+ const reason = denied
493
+ ? accessReason(op)
494
+ : undefined;
495
+ ```
496
+
497
+ Both shapes hand the method to `accessReason`, so neither needs to know which
498
+ operation it ended up gating on.
499
+
500
+ ### Controls that stage a change
501
+
502
+ A control that **stages** a change — it writes nothing itself; the write is sent
503
+ later, by a Save or an Apply — is gated on the operation that staged change will
504
+ eventually send. Two failure modes, and both are common:
505
+
506
+ - **Gated on nothing.** A Viewer rearranges, fills in and stages happily, and
507
+ meets the refusal at Save — somewhere else on the screen, after the work.
508
+ - **Gated twice in a row.** If the entry point that reaches the staging control
509
+ is already gated on that same operation, the staging control does not repeat
510
+ the gate: the reader has met the sentence once and cannot get here without
511
+ passing it.
512
+
513
+ So: gate the staging control on the staged operation *unless* the upstream
514
+ control is already gated on the same one.
515
+
516
+ A surface-level `CardNote` counts as that upstream gate. If the card already
517
+ carries `accessReason` for the *same* operation, its staging controls are not
518
+ gated on it a second time: the reader has met the sentence before starting, and
519
+ N repetitions of it explain nothing the one line did not.
520
+
521
+ What that removes is the **duplicate explanation, not the disabled state**. The
522
+ staging or submit button stays `disabled` — with no tooltip of its own, because
523
+ the `CardNote` is the explanation — while the draft's own fields and switches
524
+ stay editable (they send nothing; see below). Never re-enable the button
525
+ "because the gateway will refuse anyway": that is precisely the case this rule
526
+ exists against, the caller doing the whole job and meeting the refusal at the end.
527
+
528
+ And gate only what actually writes. Parsing a pasted CSV, rendering a preview,
529
+ editing a local draft — anything that calls no SDK method — is **reading**, and
530
+ gating it is forbidden: it costs the caller a capability nobody denied them.
531
+ This rule covers the control after which a user without the right would reach
532
+ the refusal having already done the work, with no explanation before it. A
533
+ preview they can look at and discard is not that control.
534
+
535
+ ### Forms with a deferred save
536
+
537
+ A form the reader fills in and *then* saves is the one case where a hint per
538
+ control is the wrong shape. Fifteen disabled fields, each explaining itself on
539
+ hover, tell the same sentence fifteen times — and the reader who is going to be
540
+ refused learns it only after typing.
541
+
542
+ Prefer **one line in the card's footer**, stating the constraint before anyone
543
+ starts editing:
544
+
545
+ ```tsx
546
+ import { Card, CardNote } from '@lessly/ui';
547
+
548
+ <Card title="Domain settings">
549
+ {fields}
550
+ {denied && (
551
+ <CardNote>{accessReason(app.mail.domain.update)}</CardNote>
552
+ )}
553
+ </Card>
554
+ ```
555
+
556
+ `CardNote` is exactly this line — a lock and one sentence at the card's foot,
557
+ with no tone and no variant, precisely so a frozen card does not grow a box it
558
+ did not have when it was live. It speaks for the card, not for one control on it;
559
+ a constraint that covers a single row still belongs on that row.
560
+
561
+ The fields themselves stay `readOnly` and the Save button stays disabled — the
562
+ footer note replaces the *per-control tooltips*, not the gate.
563
+
564
+ ### Verify the hint from the keyboard
565
+
566
+ The whole point of the wrapper is that the reason survives the disabled state, and
567
+ the only proof of that is **reaching it with the keyboard**. Tab to the wrapper;
568
+ the tooltip opens.
569
+
570
+ ```tsx
571
+ it('explains the missing level to a keyboard user', async () => {
572
+ const user = userEvent.setup();
573
+ render(<AddDomainButton app={viewerApp} onClick={() => {}} />);
574
+
575
+ await user.tab(); // focus lands on the wrapper
576
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(
577
+ 'Requires level:admin (mail_domain_create). Ask an admin of this product.'
578
+ );
579
+ });
580
+ ```
581
+
582
+ Assert the **rendered tooltip**, not an attribute. A test that only checks that
583
+ some `title`, `aria-describedby` or `data-*` is present passes cheerfully against
584
+ a hint no user can ever see — which is exactly the bug this pattern exists to fix.
585
+ Manual acceptance is the same check by hand: Tab to the control, the reason
586
+ appears, no mouse involved.
587
+
588
+ ---
589
+
590
+ ## 3. `isAccessDenied` in the shared query state — a neutral empty state
591
+
592
+ A prediction is not a guarantee, so keep handling the real refusal. Grants can
593
+ change between the load and the click, and a screen can always be reached
594
+ before the snapshot is.
595
+
596
+ ```tsx
597
+ import { isAccessDenied } from '@lessly/sdk-app';
598
+ import { useQuery } from '@tanstack/react-query';
599
+ import { mailDomainListQueryOptions } from '@lessly/sdk-app/mail';
600
+
601
+ function DomainList({ app }: { app: App }) {
602
+ const { data, error, isPending } = useQuery(mailDomainListQueryOptions(app, {}));
603
+
604
+ if (isPending) return <Spinner />;
605
+
606
+ // A permission boundary is a NORMAL condition, not a failure of your App.
607
+ if (isAccessDenied(error)) {
608
+ return <EmptyState title="You don't have access to domains" body="Ask an admin of this product to grant it." />;
609
+ }
610
+
611
+ if (error) return <ErrorState error={error} />; // red, retryable — real failures only
612
+
613
+ return <Table rows={data.domains} />;
614
+ }
615
+ ```
616
+
617
+ Put that branch in your shared query-state component once, rather than in every
618
+ screen. `isAccessDenied` is true for exactly the three gateway codes that mean
619
+ *the caller's access was the reason* — `role_denied`,
620
+ `operation_uncatalogued`, `no_tool_permission` — and false for every other 403.
621
+ A `tenant_blocked` 403 is not a permission problem and must not be rendered as
622
+ one: it sends the user to an admin who can do nothing about it.
623
+
624
+ **Never render an access denial in red.** A permission boundary is expected,
625
+ frequent and not the user's mistake. Painting it as a crash teaches people to
626
+ ignore the colour you need for real failures.
627
+
628
+
629
+ ### State keeps the error OBJECT, not its message
630
+
631
+ A refusal is only classifiable while it is still an object. The moment a reducer,
632
+ a queue item or a store slice flattens it to a string — `error: errorMessage(err)`,
633
+ `error: err.message`, `` error: `Upload failed: ${err}` `` — `isAccessDenied` has
634
+ nothing left to read, and a denial by role arrives red from a file that has no
635
+ render site at all. Sweeping your render sites never finds it: the classification
636
+ was lost upstream, in state.
637
+
638
+ So state holds `unknown`, and exactly one shared component classifies it:
639
+
640
+ ```tsx
641
+ // before — the refusal is a string by the time anything renders it
642
+ type UploadItem = { id: string; error: string };
643
+ dispatch({ type: 'failed', id, error: errorMessage(err) });
644
+ {item.error && <ErrorState>{item.error}</ErrorState>} // always red
645
+
646
+ // after — the object survives; one component decides the words and the tone
647
+ type UploadItem = { id: string; error: unknown };
648
+ dispatch({ type: 'failed', id, error: err });
649
+ {item.error && <RequestError error={item.error} />} // isAccessDenied → neutral
650
+ ```
651
+
652
+ `RequestError` is the same classifier as your shared query state — it calls
653
+ `isAccessDenied(error)` and renders the neutral sentence for a denial, red only
654
+ for everything else. Write it once; give every error in the App one way out.
655
+
656
+ When you audit an App for this, do not comb the render sites. Comb the **state**:
657
+ every reducer, every queue, every store slice, every `useState` whose type says
658
+ `string | null` where an error goes. Content shipped this bug twice — an upload
659
+ queue and a calendar — and both were invisible to a review of the screens.
660
+
661
+ ### A refusal inside the kit's `ConfirmDialog`
662
+
663
+ `ConfirmDialog` takes `onError?: (error: unknown) => string` — a string, and the
664
+ kit picks the tone from it. So a 403 raised by the confirmed action cannot yet be
665
+ rendered *neutrally* inside the dialog the way it is on a screen: you own the
666
+ words, the kit owns the colour.
667
+
668
+ Do the half you own. Route the error through the same classifier your shared
669
+ query state uses, so the sentence a denied caller reads is the same neutral one
670
+ everywhere:
671
+
672
+ ```tsx
673
+ <ConfirmDialog
674
+ title="Delete domain"
675
+ onConfirm={() => app.mail.domain.delete({ id })}
676
+ onError={(error) =>
677
+ isAccessDenied(error)
678
+ ? "You don't have access to delete domains. Ask an admin of this product."
679
+ : 'Could not delete the domain. Try again.'
680
+ }
681
+ />
682
+ ```
683
+
684
+ **Do not move the confirm flow into your App to win the colour.** Reimplementing
685
+ the dialog costs you the focus trap, the busy state, the confirm phrase and the
686
+ step-up — all to repaint one line of text.
687
+
688
+ This is known kit debt, tracked as platform **#2915** (`onError` returning
689
+ `{ text, tone }` so the kit can render a denial neutrally). It is tolerable in
690
+ the meantime because a denied caller rarely reaches the dialog at all: the
691
+ control that opens it is disabled under exactly the roles that would be refused.
692
+ A red refusal inside the dialog is only reachable in the snapshot's `error`
693
+ state, where — by the rule above — the App deliberately makes no prediction and
694
+ lets the gateway answer.
695
+
696
+ ---
697
+
698
+ ## 4. Access fixtures in e2e
699
+
700
+ Do not point an e2e run at a real product to get a real role. Mock
701
+ `GET /governance/api/v1/products/:productId/me` per scenario instead: it is one
702
+ flat body, and the role you need is whichever one the scenario is about.
703
+
704
+ Never hard-code a real product uuid in the repo, and never create a long-lived
705
+ "smoke test" product to own these roles — both rot into a shared fixture nobody
706
+ dares change, and both make the suite depend on grants that live outside it.
707
+
708
+ ```ts
709
+ // An owner: everything, minus the deny patterns seeded on every product.
710
+ const OWNER = {
711
+ role: 'owner',
712
+ allow: ['*'],
713
+ deny: [],
714
+ };
715
+
716
+ // A member: read and write, no governance and no billing.
717
+ const MEMBER = {
718
+ role: 'member',
719
+ allow: ['level:read', 'level:write'],
720
+ deny: [
721
+ '*_roles_create',
722
+ '*_roles_update',
723
+ '*_roles_delete',
724
+ '*_members_assign-role',
725
+ '*_billing_*',
726
+ ],
727
+ };
728
+
729
+ // A viewer: read only — the scenario that greys out every primary action.
730
+ const VIEWER = {
731
+ role: 'viewer',
732
+ allow: ['level:read'],
733
+ deny: [
734
+ '*_roles_create',
735
+ '*_roles_update',
736
+ '*_roles_delete',
737
+ '*_members_assign-role',
738
+ '*_billing_*',
739
+ ],
740
+ };
741
+ ```
742
+
743
+ Two more responses are worth a scenario each, because they are the two the UI
744
+ gets wrong: a **failing** `/me` (500 or a dropped connection — every control
745
+ stays enabled and claims nothing about the role) and a **403** from the action
746
+ itself with a `role_denied` body (the neutral empty state, not the red one).
747
+
748
+ ---
749
+
750
+ ## What not to do
751
+
752
+ | Don't | Why |
753
+ | --- | --- |
754
+ | Skip the call because `can()` returned false | The snapshot can be stale or simply not loaded. Let the server answer; handle the 403. |
755
+ | Treat `can() === true` as authorisation | It is a prediction. The gateway may still refuse, and only the gateway's answer is a fact. |
756
+ | Drop the `isAccessDenied` branch because every button is gated | A screen can be deep-linked, and grants change mid-session. |
757
+ | Hide controls the caller cannot use | Makes the missing permission unreportable. Disable with a reason instead. |
758
+ | Rebuild a tool id from the accessor path | The path is a lossy view of the id. Pass the method: `app.mail.domain.create`. |
759
+ | Poll `load()` on a timer | The snapshot changes when a role changes; call `invalidate()` then, and only then. |
760
+ | Hand-write the reason text | It goes stale when the catalog re-levels the tool. Build it with `accessReason(op)`. |
761
+ | Name the caller's role in the reason | A role is not something an admin grants. Name the level and the operation key. |
762
+ | Assert only that a hint attribute exists | Green against a tooltip no user can reach. Tab to it and assert the rendered `role="tooltip"`. |
763
+ | Rely on a disabled submit button to stop Enter | Implicit submission fires anyway. Gate `onSubmit`, or make the fields `readOnly`. |
764
+ | Gate a dispatcher button on the union of its modes | It calls one operation per click. Gate the one it is about to call. |
765
+ | Repeat the same tooltip on every field of a deferred-save form | One `CardNote` in the footer, read before editing rather than after. |
766
+ | Gate the drag but not the arrow keys | Same operation, two paths. The hole is keyboard-only, which is the worst place for one. |
767
+ | Nest the Tooltip trigger inside your own `pointer-events-none` | Same silence as the kit's disabled button, in your code. Put the trigger outside it. |
768
+ | Infer a level from the verb ("delete means admin") | There is no such rule: analytics deletes are `write`. Read `op.level`. |
769
+ | Say only the level, with no operation key | Seventeen controls can share one level. The key is what an admin grants on. |
770
+ | Render `level:undefined` for a key with no method | Use the string arm of `accessReason`: name the key, drop the level. |
771
+ | Pass a string key where the method exists | The string arm is a degradation for methodless operations, not an equal input. |
772
+ | Hand `accessReason` a list of operations | It explains one. Pick the denied one first; the rule differs per button shape. |
773
+ | Reimplement `ConfirmDialog` to repaint a denial | Costs the focus trap, busy state, confirm phrase and step-up for one line of text. Kit debt #2915. |
774
+ | Leave a drag handle or inline edit ungated because it has no button | It is the same write. Set the native inactive state and wrap it. Skipping the gate is a spec'd exception, not a default. |
775
+ | Name the headline operation on a multi-operation button | Name the level and key of the operation that actually blocked it. |
776
+ | Put the reason in a native `title=` on the control | `disabled:pointer-events-none` swallows the hover, and a native tooltip is unreachable by keyboard and by touch. Wrap the control in a `TooltipTrigger`. |
777
+ | Hang a surface's reason on a control that owns a different action | The reason must name the operation actually gated. No owning control means one `CardNote` on the surface. |
778
+ | Gate a staging control the surface's `CardNote` already explains | One explanation for the same operation, not N. |
779
+ | Gate a parse, a preview or a local draft | Nothing is sent, so nothing can be refused. Gating reading takes a capability nobody denied. |
780
+ | Store an error as a string in a reducer, queue or store | `isAccessDenied` needs the object. A flattened refusal arrives red from a file with no render site. |
781
+ | Disable a control because `state === 'error'` | Nothing is known about the role. Stay enabled, say nothing about the role, let the gateway answer. |