@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
package/docs/rules.md ADDED
@@ -0,0 +1,522 @@
1
+ # App Rules
2
+
3
+ Normative rules for building a Lessly App on `@lessly/sdk-app`. Read this
4
+ before writing code; see `README.md` for the map of the rest of the guide.
5
+
6
+ Severity (RFC 2119): **MUST** blocks merge, **SHOULD** is a strong
7
+ recommendation, **MAY** is an allowed option.
8
+
9
+ Rule IDs (`APP-NNN`) are immutable once merged: a rule's ID and meaning never
10
+ change, even if its wording is later clarified. Some of these rules cover
11
+ ground shared with extensions and the platform's Module Federation host; where
12
+ that's true, this file references the existing `FED-*` rule ID from the
13
+ toolkit-guide instead of restating it — go there for the full rule text,
14
+ rationale, and examples.
15
+
16
+ ---
17
+
18
+ ### APP-001 (MUST) — All platform data access goes through `@lessly/sdk-app`
19
+
20
+ Every read or write to a Lessly platform API MUST go through a
21
+ `@lessly/sdk-app` client created with `createLesslyApp`, or through the
22
+ generated `*QueryOptions`/`*MutationOptions` factories built on top of it. An
23
+ App MUST NOT `fetch` a platform host directly, and MUST NOT use any other HTTP
24
+ client to reach platform data.
25
+
26
+ **Streaming.** The same rule covers WebSockets. Platform streaming MUST go
27
+ through the generated `*Connect` factories (`<toolId>Connect(sdk, params)`,
28
+ imported from the tool's namespace subpath), which are emitted for every
29
+ catalog tool that carries a `ws` binding and are backed by the SDK's
30
+ `connectStream` runtime. An App MUST NOT construct `new WebSocket(...)` against
31
+ a platform host, exactly as it must not `fetch` one — and MUST NOT hand-build a
32
+ `ws(s)://` URL from `baseUrl`. A tool with no `ws` binding has no sanctioned
33
+ stream: there is nothing to open, and reaching for a raw socket is a violation,
34
+ not a workaround.
35
+
36
+ `new WebSocket(...)` against an App's OWN non-platform host (a third-party
37
+ service the App integrates) is outside this rule.
38
+
39
+ ### APP-002 (MUST) — Frontend-only
40
+
41
+ An App MUST NOT ship a backend service, a database, or an MCP endpoint. All
42
+ of its logic runs in the browser, built on `@lessly/sdk-app` and the Module
43
+ Federation contract below. If a feature needs server-side state or a
44
+ service-to-service call, it belongs in an extension, not an App.
45
+
46
+ ### APP-003 (MUST) — Manifest is `lessly.app.yaml`, schema v1
47
+
48
+ The manifest file is `lessly.app.yaml`, and its top-level surface is exactly:
49
+ `app_schema_version: 1`, `id`, `title`, `version`, `nav`. No other top-level
50
+ field is valid — in particular, no extension/backend manifest field (`s2s`,
51
+ `webhooks`, `billing`, `analytics`, `routing`, `public`, ...) belongs in an App
52
+ manifest.
53
+
54
+ `nav` has exactly two keys: `nav.icon` (**required**, the rail icon) and
55
+ `nav.sections` (**optional**, the App's own rail menu — APP-011). Any other key
56
+ under `nav` is rejected, and rejection is fatal: registration validation is
57
+ whitelist-strict, so an unknown key fails the whole registration rather than
58
+ being stripped.
59
+
60
+ ### APP-004 (MUST) — Module Federation contract
61
+
62
+ An App exposes `./App` as its Module Federation remote entry, implementing
63
+ the shell's remote contract (FED-001). The Module Federation `name` MUST equal
64
+ the manifest `id` (FED-004). React, React DOM, React Router, and `@lessly/ui`
65
+ MUST be declared as shared singletons, never bundled (FED-005), using
66
+ `@module-federation/vite` pinned at exactly `1.16.12` (FED-006). The asset
67
+ base in federation mode is the absolute path `/~/<id>/`. The build MUST
68
+ produce one merged dual-mode `dist/` covering both standalone and federation
69
+ artifacts (FED-007).
70
+
71
+ `./App` is the **only** module an App exposes. There is no `./navigation`
72
+ contract: the shell no longer loads such a module, there is no
73
+ `subscribeNavItems` and no `navContractVersion` negotiation, and the menu the
74
+ shell renders comes from **your own** `lessly.app.yaml` — the `nav.sections`
75
+ block (APP-011) — not from your App code, and not from a toolkit manifest.
76
+ FED-011 is **RETIRED (2026-08)**. Still mapping the expose is inert rather than
77
+ an error — it just ships a chunk nobody fetches — but a new App MUST NOT add
78
+ it, and an existing one SHOULD delete `src/federation/navigation.ts`, drop the expose, and move
79
+ whatever that module fed the menu (environment lists, tool groups, badges,
80
+ status dots, a settings gear) into its own pages under `./App`'s relative
81
+ routes (FED-003).
82
+
83
+ This rule states the contract; it does not restate FED-001, FED-004, FED-005,
84
+ FED-006, or FED-007 — see those rules for exact wording, rationale, and
85
+ examples.
86
+
87
+ ### APP-005 (MUST) — Auth is ambient; an App does not own it
88
+
89
+ Authentication is ambient: the hosting shell owns cookies in production and a
90
+ bearer proxy in local dev. An App MUST NOT implement login, token handling,
91
+ refresh, or product switching — those belong to the shell. An App MUST read
92
+ the active product from its props or the `X-Product-Id` header, never by
93
+ decoding a token.
94
+
95
+ An App MUST NOT rely on the `user` prop for anything beyond display. In
96
+ dev-standalone mode, `user` is a stub (`{ id }` sourced from the local dev
97
+ JWT, `displayName: 'Dev User'`) — it is not a real session. The real,
98
+ shell-authenticated user only arrives once the app is composed via
99
+ federation, through the `./App` props.
100
+
101
+ ### APP-006 (SHOULD) — Use the generated query/mutation option factories
102
+
103
+ Prefer the generated `*QueryOptions`/`*MutationOptions` factories
104
+ (`@lessly/sdk-app/<namespace>`) over hand-rolling request paths against the
105
+ SDK client directly. They keep query keys, request shapes, and cache
106
+ invalidation consistent with the published API surface, and stay in sync with
107
+ it automatically on upgrade.
108
+
109
+ ### APP-007 (MUST) — Versioning hygiene
110
+
111
+ Bump the manifest `version` on every release. Keep the SDK and this guide
112
+ current with:
113
+
114
+ ```sh
115
+ npm i @lessly/sdk-app@latest
116
+ ```
117
+
118
+ Use this exact `@latest` form — not `npm update`. `@lessly/sdk-app` is
119
+ pre-1.0 and pinned with a caret range, so `npm update` never crosses a minor
120
+ version; since this SDK also bumps its minor version whenever the platform
121
+ ships a new tool, `npm update` would never actually deliver those updates.
122
+
123
+ ### APP-008 (MUST) — The federation CSS entry is slim and global-free
124
+
125
+ An App has two CSS entries: the **federation** entry (imported by the `./App`
126
+ remote module) and the **standalone** entry (imported by the SPA entry). The
127
+ federation entry's whole transitive graph — that stylesheet, everything it
128
+ `@import`s, and every `@layer base { … }` block the App writes itself — MUST
129
+ NOT contain:
130
+
131
+ - `@font-face`;
132
+ - a `@layer base { … }` block;
133
+ - any rule declaring an inherited or visual property — `font-family`,
134
+ `font-size`, `line-height`, `color`, `background`, `margin`, `padding`,
135
+ `border-*`, `box-sizing`, `-webkit-text-size-adjust` — on `html`, `body`,
136
+ `:root` or `*`;
137
+ - an `@import` of a web font, of `@lessly/ui/styles.css`, or of the full
138
+ `tailwindcss` entry (which bundles **preflight**).
139
+
140
+ The prohibition holds wherever the rule comes from, `@apply` included:
141
+ `body { @apply bg-bg-primary … }` in the App's own base layer is exactly as
142
+ forbidden as an imported reset. The App's base layer belongs in a separate
143
+ `src/base.css` that only the **standalone** entry imports. The standalone entry
144
+ is unconstrained by this rule: it keeps the fonts, `@lessly/ui/styles.css`, the
145
+ full `tailwindcss` import and the base layer, because there the App owns the
146
+ page.
147
+
148
+ **Custom properties are the boundary.** Declarations of `--*` on `html`,
149
+ `body`, `:root` or `*` are fine — they paint nothing until something inside the
150
+ App's own subtree reads them. Inherited and visual properties are what is
151
+ forbidden, because the App is composed inside the shell's already-painted page
152
+ and a `body` or `*` rule addresses the whole live document, shell chrome
153
+ included. This line is load-bearing: `@lessly/ui/styles-federated.css`
154
+ legitimately ships a `*, ::before, ::after, ::backdrop { --tw-*: … }` block
155
+ (Tailwind v4's internal variable shim), and a guard written against selectors
156
+ instead of declarations fails on the very import this rule prescribes.
157
+
158
+ **Kit tokens.** An App that needs the design-system variables MUST import
159
+ `@lessly/ui/styles-federated.css` — `styles.css` minus the `@font-face` blocks
160
+ and the `@layer base`, keeping the token variables, the `--color-*` aliases,
161
+ `.link`, the `.motion-*` helpers and the compiled tw-animate-css layer. Depend
162
+ on `@lessly/ui` at `^4.0.0`, the range the shell itself runs. An App MUST NOT
163
+ hand-copy the kit's tokens into a local `theme.css` (it silently drifts from
164
+ the kit), and MUST NOT hand-filter `@lessly/ui/styles.css` with a prebuild
165
+ script (it breaks on any restructure of a file the App does not own).
166
+
167
+ **Scanning.** Tailwind generates only the classes it finds, and it scans the
168
+ project, not `node_modules`. The federation entry MUST therefore
169
+ `@source` the kit's compiled output
170
+ (`@source '../node_modules/@lessly/ui/dist/index.js';`) or the classes
171
+ `@lessly/ui` components use are never emitted — and it SHOULD exclude test
172
+ files (`@source not '../src/**/*.test.tsx';`), since a utility class named as a
173
+ test fixture is just a string to Tailwind and would otherwise be compiled into
174
+ the shipped stylesheet.
175
+
176
+ **Gate test.** The repository MUST carry a test asserting all of the above over
177
+ the **built** federation CSS, not over the entry file's import list: the source
178
+ imports can read clean while an imported stylesheet carries the base layer. Two
179
+ shapes must not be confused — `@layer base, components;` (semicolon) only
180
+ declares layer order and is harmless; the forbidden shape is the block,
181
+ `@layer\s+base\s*\{`. And a universal rule is a violation only if it declares
182
+ more than custom properties.
183
+
184
+ For the production defect this comes from — a remote's late-loading `body`
185
+ rule re-fonting the shell's chrome — see FED-008 in the toolkit-guide.
186
+ `recipes/federation.md` has the canonical stylesheets.
187
+
188
+ ### APP-009 (MUST) — One source of scale vocabulary: `lesslyPreset`
189
+
190
+ An App takes its **scale vocabulary** — type, spacing, radii, font weight,
191
+ line height, letter spacing — from the design-system preset the shell is built
192
+ on: `lesslyPreset`, exported by `@lessly/ui/tailwind-preset`. Concretely:
193
+
194
+ - `tailwind.config.ts` sets `presets: [lesslyPreset]`;
195
+ - **both** CSS entries carry `@config '../tailwind.config.ts'`;
196
+ - the App declares no scales of its own and keeps no `@theme` block bridging
197
+ to them;
198
+ - the federation graph MUST NOT import `tailwindcss/theme.css` at all — not in
199
+ `layer(theme)`, and not with `theme(reference)`.
200
+
201
+ **`theme(reference)` does not solve this.** It removes the `:root` emission,
202
+ but Tailwind still inlines the literal as the utility's fallback
203
+ (`var(--text-sm,.875rem)`), so the utility keeps its own value and keeps
204
+ landing on the shell's markup. Measured: the emission went away and the page
205
+ stayed broken.
206
+
207
+ **The norm is "one source of vocabulary", not "no variables on `:root`".**
208
+ Variables that coincide on `:root` between the App and the shell are expected
209
+ and harmless — that is one source reaching the page twice. The absence
210
+ criterion measures **green on a broken page**: the shell declares no `--text-*`
211
+ variable at all (its preset compiles literals into the utilities), so three
212
+ repositories passed that check while the shell's sidebar type was being resized
213
+ by their sheets. Do not write a guard that way.
214
+
215
+ When both sides build on the same preset, rules that share a class name compile
216
+ identically and the collision stops existing. See FED-013 in the
217
+ toolkit-guide for the measurements (13px → 14px, line-height 19.5 → 20) and
218
+ for the three wrong fixes it rules out.
219
+
220
+ ### APP-010 (MUST) — Federation utilities land in `layer(remote-utilities)`
221
+
222
+ The **federation** entry MUST import Tailwind's utilities into a dedicated
223
+ layer:
224
+
225
+ ```css
226
+ @import 'tailwindcss/utilities.css' layer(remote-utilities);
227
+ ```
228
+
229
+ never `layer(utilities)`. The **standalone** entry is exempt — it keeps the
230
+ full `tailwindcss` import (APP-008), where the App owns the page and there is
231
+ nothing to rank against.
232
+
233
+ This works because the shell pre-declares the layer order in
234
+ `lessly-workspace:apps/web/src/index.css`:
235
+
236
+ ```css
237
+ @layer theme, base, components, remote-utilities, utilities;
238
+ ```
239
+
240
+ so every App utility ranks below every shell utility whatever order the sheets
241
+ happen to load in. The App's federation entry SHOULD repeat that same order
242
+ declaration as its first line, so the layer positions are fixed before Tailwind
243
+ emits its own.
244
+
245
+ **Rollout order is strict and asymmetric.** That shell-side declaration MUST be
246
+ in production before an App adopts the layer. Against a shell that has not
247
+ declared the order, `remote-utilities` becomes the last-declared — and
248
+ therefore **highest**-priority — layer, so every App utility beats every shell
249
+ utility: strictly worse than the defect. Verify the shell first; adopting early
250
+ is a regression, not a head start.
251
+
252
+ See FED-014 in the toolkit-guide for the measurement (App `.px-2` against
253
+ shell `.sm:px-2.5` — different rule names, so no shared preset can reach them;
254
+ 5 of 283 shell chrome nodes affected) and for the accepted symmetric risk on
255
+ the App's own nodes.
256
+
257
+ ### APP-011 (SHOULD) — Declare the rail menu in your own manifest
258
+
259
+ The rail menu the shell draws while your App is open comes from your App's own
260
+ `lessly.app.yaml`, in an optional `nav.sections` block. An App with more than
261
+ one page SHOULD declare it; without it the shell has nothing of yours to draw
262
+ (see *No block* below).
263
+
264
+ ```yaml
265
+ app_schema_version: 1
266
+ id: my-app
267
+ title: My App
268
+ version: 0.2.0
269
+ nav:
270
+ icon: flask-conical
271
+ sections:
272
+ - id: overview
273
+ items:
274
+ - label: Dashboard
275
+ path: ''
276
+ match: exact
277
+ icon: gauge
278
+ - label: Activity
279
+ path: activity
280
+ icon: activity
281
+ - id: settings
282
+ header: Settings
283
+ items:
284
+ - label: Members
285
+ path: settings/members
286
+ icon: users-round
287
+ - label: API keys
288
+ path: settings/api-keys
289
+ icon: key
290
+ ```
291
+
292
+ **Field constraints.** These are validated on registration by the platform's
293
+ shared `NavSectionDto`/`NavItemDto` — literally the same DTO classes the
294
+ toolkit manifest validates against (MFST-022 in the toolkit-guide), so the two
295
+ manifests speak exactly one menu language. A violation is a hard reject of the
296
+ registration, not a silent strip.
297
+
298
+ | Field | Required | Rule |
299
+ | --- | --- | --- |
300
+ | `nav.sections` | no | array, 1–20 sections; `id` unique across the array |
301
+ | `sections[].id` | yes | 1–50 chars, lowercase-kebab `^[a-z0-9-]+$` |
302
+ | `sections[].header` | no | 1–50 chars; absent → the group renders with no header line |
303
+ | `sections[].items` | yes | array, 1–50 items |
304
+ | `items[].label` | yes | 1–50 chars, non-empty |
305
+ | `items[].path` | yes | 0–200 chars, `^[a-z0-9-_/]*$`, **no leading slash**, no `..` segment; `''` = your tool root |
306
+ | `items[].icon` | no | 1–50 chars, kebab-case Lucide icon name (e.g. `rocket`) |
307
+ | `items[].match` | no | `exact` or `prefix`; default `prefix` |
308
+
309
+ `path` is relative to your App's own base path (`/<productSlug>/~/<id>`), so a
310
+ manifest can name a page **inside** your App and nothing else — no absolute
311
+ paths, no external URLs, no traversal. Use `match: exact` on a row whose path
312
+ is a prefix of a sibling's (typically the `''` root row), or it stays lit while
313
+ the sibling is open.
314
+
315
+ **No block.** `nav.sections` absent is legal. The shell then falls back to its
316
+ transitional table (`extension-menus.ts`, keyed by slug, entity-agnostic) if
317
+ your slug is listed there; if it is not, your rail row stays a **single plain
318
+ link** into the App, with no disclosure mark promising rows that do not exist.
319
+ The fallback is transitional: an App leaves that table the moment its manifest
320
+ ships the menu, and the entry there should be deleted by the same PR. Narrowing
321
+ of the served block is **all-or-nothing** — a half-valid menu falls back to the
322
+ table rather than drawing a partial disclosure.
323
+
324
+ **Shipping a change.** The menu is picked up on re-registration, including a
325
+ **same-version** one: the registration overlay replaces the whole `nav` blob,
326
+ so adding, editing and removing sections all land without a `version` bump.
327
+ Bumping `version` on release remains APP-007.
328
+
329
+ **The scaffold's linter does not check this block.** `create-lessly-app lint`
330
+ validates the top-level surface and `nav.icon` only; it neither rejects nor
331
+ warns on `nav.sections`, and it will not catch a malformed one. The platform's
332
+ registration validation is the authority — a menu that lints clean locally can
333
+ still fail registration.
334
+
335
+ ### APP-012 (SHOULD) — Reflect access in the UI, but never enforce it there
336
+
337
+ An App SHOULD reflect the caller's access in its UI: **disable, with a reason**
338
+ (not hide) a primary action the caller cannot perform, using `useCan` from
339
+ `@lessly/sdk-app/react` or `app.access.can()` directly, and render a 403 that
340
+ `isAccessDenied()` recognises as a **neutral empty state** rather than an error.
341
+ That reason MUST live on a `Tooltip` wrapper around the disabled control, never
342
+ on a native `title=` of the control itself: the base `buttonVariants` carry
343
+ `disabled:pointer-events-none`, so a disabled control fires no hover for the
344
+ browser to render a `title` from, and a native tooltip is unreachable by
345
+ keyboard and by touch in any case. An App that draws such a tooltip MUST mount
346
+ its own `TooltipProvider` once at the root of its remote — `Tooltip.Root` throws
347
+ without one, and the standalone build, the dev entry and the tests all render
348
+ outside the shell.
349
+
350
+ That reason MUST be exactly one sentence, the same in every App:
351
+
352
+ > `Requires level:<op.level> (<op.operationKey>). Ask an admin of this product.`
353
+
354
+ — e.g. `Requires level:admin (mail_domain_create). Ask an admin of this product.`
355
+ Both values MUST be read from the generated method, never written as literals.
356
+ `@lessly/sdk-app` exports `accessReason(op)`, which is that sentence; an App
357
+ SHOULD use it rather than assembling the text itself.
358
+
359
+ Where a control has only an operation key and no generated method to point at,
360
+ the App MUST NOT guess the level or render `level:undefined`; it says instead:
361
+
362
+ > `Requires access to <operationKey>. Ask an admin of this product.`
363
+
364
+ That string form is a **degradation** for operations no generated method covers,
365
+ not licence to pass a key where a method exists — the accessor path is a lossy
366
+ view of the key. An App whose every gate holds an `Operation` never uses it, and
367
+ that is the norm rather than an omission. `accessReason` explains ONE operation;
368
+ choosing which one to explain among a button's several is the caller's, not the
369
+ helper's.
370
+ It names what an admin can grant, not the caller's role: a role name gives them
371
+ nothing to act on. It names the operation key as well as the level because a
372
+ level alone does not identify the grant — seventeen controls on one screen can
373
+ all require `level:write` — and an admin grants both by level and by exact key,
374
+ so the user has to be able to pass on both. Building it from the method also
375
+ keeps it from drifting when the catalog re-levels a tool. Whatever renders it MUST be verified reachable **from the keyboard** —
376
+ Tab to the wrapper, the tooltip opens (in tests: `userEvent.tab()` then
377
+ `findByRole('tooltip')`). Asserting only that a hint attribute is present does
378
+ not satisfy this: that assertion is green against a hint no user can reach.
379
+
380
+ The level MUST come from the generated method itself (`op.level`, on the
381
+ `Operation` the method satisfies) and from nowhere else: not a lookup table in
382
+ the App, not a literal, and never a rule of thumb about verbs — `analytics_*_delete`
383
+ is `write` while `mail_domain_create` is `admin`, and only the catalog knows.
384
+
385
+ Where there is no method to read from — a test double, a runtime probe over keys
386
+ it was handed — the App MUST NOT hand-type the level either. `@lessly/sdk-app`
387
+ exports `operations`, the catalog's own level map
388
+ (`Readonly<Record<string, ToolLevel>>`, keyed by operation key), regenerated at
389
+ publish time from the live catalog; read the level from it. A key it does not
390
+ carry answers `undefined`, which is exactly the case `accessReason`'s string form
391
+ covers — no level is invented for it.
392
+
393
+ The gate belongs to the OPERATION, not to a gesture: where a write is reachable
394
+ by several paths (drag and arrow keys, click and Enter, button and hotkey) all of
395
+ them MUST be gated together, or the hole left behind is one only keyboard users
396
+ find. Implicit form submission is such a path: a disabled submit button does not
397
+ reliably stop Enter inside a field, so the `onSubmit` handler MUST be gated (or
398
+ the fields made `readOnly`) rather than the button alone.
399
+
400
+ A gate that disables a capability across a WHOLE SURFACE — dragging any node on
401
+ a canvas, a bulk action over a list or a grid — MUST attach the reason to the
402
+ control that owns the capability (the toggle, the toolbar, the surface header)
403
+ and MUST NOT attach it to every affected element: thirty identical focusable
404
+ wrappers are thirty extra tab stops and one sentence thirty times, not thirty
405
+ explanations. Keyboard reachability is satisfied on the owning control; the
406
+ elements themselves just go inert.
407
+
408
+ Where NO control owns the capability — a calendar whose Month/Week toggle owns
409
+ the view and not the move — the App MUST NOT hang the reason on a control that
410
+ owns a DIFFERENT action: the reason names the operation actually gated, and a
411
+ level and key under a week switcher describe a gate that switcher does not hold.
412
+ It MUST instead render ONE `CardNote` with `accessReason(op)` on the surface
413
+ itself, with the elements left in their inactive state (`draggable={false}`) and
414
+ no tooltip among them.
415
+
416
+ A control that STAGES a change — it writes nothing itself, the write is sent
417
+ later by a Save or an Apply — MUST be gated on the operation the staged change
418
+ will send, UNLESS the upstream control that reaches it is already gated on that
419
+ same operation. Never on nothing (the caller does the work and meets the refusal
420
+ later, somewhere else) and never twice in a row.
421
+
422
+ A surface `CardNote` carrying `accessReason` of that SAME operation IS such an
423
+ upstream gate: the staging controls under it MUST NOT be gated again — one
424
+ explanation, not N. What drops is the DUPLICATE EXPLANATION, not the disabled
425
+ state: the staging or submit control stays disabled and carries no tooltip of its
426
+ own (the `CardNote` speaks for it), while the draft's fields and switches stay
427
+ editable. An App MUST NOT re-enable that control "because the gateway will
428
+ refuse" — that is the exact scenario this rule exists against.
429
+
430
+ Anything that sends NOTHING — parsing an upload, rendering a preview, editing a
431
+ local draft — is reading, and the App MUST NOT gate it. This rule reaches only
432
+ the control after which a caller without the right would meet the refusal having
433
+ already done the work and with no explanation before it.
434
+
435
+ A control that performs SEVERAL operations per click is disabled if ANY of them
436
+ is denied. A DISPATCHER control — one operation per click, selected by a mode or
437
+ a switch — MUST instead be gated on the operation it is about to call, and name
438
+ that operation's level and key; gating it on the union greys out a button the caller can
439
+ in fact use.
440
+
441
+ An App SHOULD state the constraint once in a card footer (`CardNote`) rather than
442
+ on every control of a form whose save is deferred: the reader meets the sentence
443
+ before editing rather than after, and one line replaces fifteen identical
444
+ tooltips. The fields stay inactive and the save stays disabled either way — the
445
+ note replaces the per-control hints, not the gate. And the `Tooltip` trigger MUST sit outside every element carrying
446
+ `pointer-events-none` — the App's own lists, chips and overlays as much as the
447
+ kit's disabled buttons — since the hint is swallowed by the same mechanics either
448
+ way.
449
+
450
+ This applies to every gated write, not only the ones with a button. A drag
451
+ handle, an inline-editable cell and a field that autosaves on blur are gated the
452
+ same way: the native inactive state for that control (`draggable={false}`,
453
+ `readOnly`, `disabled`) plus the reason on a keyboard-reachable `Tooltip`
454
+ wrapper. Leaving a write ungated for the gateway to refuse is permitted ONLY
455
+ where the element has no wrapper that can be made focusable, and that exception
456
+ MUST be written into the App's own spec with its reason. A button that performs
457
+ several operations is disabled if ANY of them is denied, and its reason names
458
+ the level and key of the operation that blocked it.
459
+
460
+ The reason is claimed only when the App actually knows it. With
461
+ `access.state === 'error'` the `/me` call failed and **nothing** is known about
462
+ the caller's role: the App MUST make no prediction — leave the control enabled,
463
+ name no missing operation, and let the gateway answer, rendering any refusal through
464
+ `isAccessDenied`. Disabling on error makes the client the authority and tells
465
+ the user their role forbids something no one has established. (`can()` itself is
466
+ unaffected: it stays `false` unless the state is `ready`.) While the state is
467
+ `idle` or `loading` the control SHOULD start disabled and enable itself, but
468
+ SHOULD NOT name a missing level or operation it does not yet know.
469
+
470
+ An App MUST NOT treat that prediction as enforcement. `can()` answers from a
471
+ snapshot fetched once from the `organization_product_me` tool; the gateway is
472
+ the only authority, grants can change between the load and the click, and the
473
+ prediction is `false` for a caller whose snapshot simply has not loaded yet.
474
+ Concretely: never skip a call because `can()` returned false, never present
475
+ `can() === true` to the user as authorisation, and never drop the
476
+ `isAccessDenied` branch from a screen because every button on it is gated.
477
+
478
+ Two failure modes are worth naming, because both ship regularly:
479
+
480
+ - **Hiding rather than disabling.** A user who cannot see the button cannot ask
481
+ their admin for the permission; they report the App as broken instead, and
482
+ the admin who could have granted it in seconds never hears about it.
483
+ - **Painting a denial red.** A permission boundary is a normal, expected
484
+ condition and not the user's mistake. Rendering it as a crash trains people
485
+ to ignore the colour reserved for real failures. `isAccessDenied()` is true
486
+ for exactly the three gateway codes that mean the caller's access was the
487
+ reason (`role_denied`, `operation_uncatalogued`, `no_tool_permission`) and
488
+ false for every other 403 — a `tenant_blocked` is not a permission problem.
489
+
490
+ State MUST keep the error OBJECT, never a string derived from it. A reducer, a
491
+ queue item or a store slice typed `error: string` has thrown away the only thing
492
+ `isAccessDenied` can read, so a refusal by role arrives red out of a file that
493
+ has no render site at all — and a sweep of render sites never finds it. State
494
+ holds `unknown`; ONE shared component classifies it (`isAccessDenied` → the
495
+ neutral sentence, red for everything else). Audit the reducers, queues and
496
+ stores, not the screens.
497
+
498
+ Pass the generated method rather than a hand-written tool id
499
+ (`app.access.can(app.mail.domain.create)`): the accessor path is a lossy view
500
+ of the tool id, so a literal string can silently drift from the call it gates.
501
+
502
+ One gap is known and accepted: `@lessly/ui`'s `ConfirmDialog` takes an `onError`
503
+ that returns a string, so the kit picks the tone and a refusal raised inside the
504
+ dialog cannot be painted neutrally yet. An App SHOULD still route that error
505
+ through `isAccessDenied()` for the *words*, and MUST NOT reimplement the confirm
506
+ flow to win the colour — it would forfeit the focus trap, busy state, confirm
507
+ phrase and step-up. Tracked as platform #2915.
508
+
509
+ The snapshot has no TTL, so staleness is the App's to invalidate:
510
+
511
+ - The access snapshot lives in the client instance until `invalidate()` or until
512
+ the client is recreated — there is no expiry and no background refresh.
513
+ - A module-level singleton client survives SPA navigation and bfcache
514
+ back/forward, so a "reload" that does not remount the remote keeps the old
515
+ role. After a role change an App MUST call `app.access.invalidate()` and then
516
+ `load()` (or do a full page reload).
517
+ - Before measuring staleness, compare the role the App's own `/me` request
518
+ returned (Network tab) with what the API answers now — never assume it from
519
+ memory. The server sends no `Cache-Control` for `/me` today and the browser
520
+ only revalidates via `ETag`, so the HTTP cache alone is never the explanation.
521
+
522
+ See `recipes/access.md` for the load-once pattern and the empty-state shape.