@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.
- package/README.md +233 -0
- package/dist/_types/gen/analytics/index.d.ts +2 -0
- package/dist/_types/gen/analytics/queryOptions.gen.d.ts +74 -0
- package/dist/_types/gen/bindings.gen.d.ts +3 -0
- package/dist/_types/gen/brain/index.d.ts +2 -0
- package/dist/_types/gen/brain/queryOptions.gen.d.ts +166 -0
- package/dist/_types/gen/client.gen.d.ts +1087 -0
- package/dist/_types/gen/consent/index.d.ts +2 -0
- package/dist/_types/gen/consent/queryOptions.gen.d.ts +62 -0
- package/dist/_types/gen/content/index.d.ts +2 -0
- package/dist/_types/gen/content/queryOptions.gen.d.ts +126 -0
- package/dist/_types/gen/deployment/index.d.ts +2 -0
- package/dist/_types/gen/deployment/queryOptions.gen.d.ts +438 -0
- package/dist/_types/gen/mail/index.d.ts +2 -0
- package/dist/_types/gen/mail/queryOptions.gen.d.ts +206 -0
- package/dist/_types/gen/manifest.gen.d.ts +4 -0
- package/dist/_types/gen/observe/index.d.ts +2 -0
- package/dist/_types/gen/observe/queryOptions.gen.d.ts +142 -0
- package/dist/_types/gen/organization/index.d.ts +2 -0
- package/dist/_types/gen/organization/queryOptions.gen.d.ts +362 -0
- package/dist/_types/gen/realtime/index.d.ts +2 -0
- package/dist/_types/gen/realtime/queryOptions.gen.d.ts +114 -0
- package/dist/_types/gen/support/index.d.ts +2 -0
- package/dist/_types/gen/support/queryOptions.gen.d.ts +206 -0
- package/dist/_types/gen/tracking/index.d.ts +2 -0
- package/dist/_types/gen/tracking/queryOptions.gen.d.ts +150 -0
- package/dist/_types/gen/types.gen.d.ts +126011 -0
- package/dist/_types/gen/users/index.d.ts +2 -0
- package/dist/_types/gen/users/queryOptions.gen.d.ts +182 -0
- package/dist/_types/gen/waitlist/index.d.ts +2 -0
- package/dist/_types/gen/waitlist/queryOptions.gen.d.ts +30 -0
- package/dist/_types/gen/workflow/index.d.ts +2 -0
- package/dist/_types/gen/workflow/queryOptions.gen.d.ts +166 -0
- package/dist/_types/index.d.ts +11 -0
- package/dist/_types/package.json +1 -0
- package/dist/_types/react/index.d.ts +33 -0
- package/dist/_types/runtime/access-reason.d.ts +27 -0
- package/dist/_types/runtime/access.d.ts +40 -0
- package/dist/_types/runtime/compositions.d.ts +16 -0
- package/dist/_types/runtime/connectStream.d.ts +15 -0
- package/dist/_types/runtime/createLesslyApp.d.ts +10 -0
- package/dist/_types/runtime/csrf.d.ts +1 -0
- package/dist/_types/runtime/errors.d.ts +18 -0
- package/dist/_types/runtime/name.d.ts +26 -0
- package/dist/_types/runtime/operations.d.ts +13 -0
- package/dist/_types/runtime/permission-match.d.ts +62 -0
- package/dist/_types/runtime/request.d.ts +9 -0
- package/dist/_types/runtime/types.d.ts +129 -0
- package/dist/analytics/index.cjs +96 -0
- package/dist/analytics/index.cjs.map +1 -0
- package/dist/analytics/index.d.cts +1 -0
- package/dist/analytics/index.d.ts +1 -0
- package/dist/analytics/index.js +77 -0
- package/dist/analytics/index.js.map +1 -0
- package/dist/brain/index.cjs +211 -0
- package/dist/brain/index.cjs.map +1 -0
- package/dist/brain/index.d.cts +1 -0
- package/dist/brain/index.d.ts +1 -0
- package/dist/brain/index.js +169 -0
- package/dist/brain/index.js.map +1 -0
- package/dist/consent/index.cjs +81 -0
- package/dist/consent/index.cjs.map +1 -0
- package/dist/consent/index.d.cts +1 -0
- package/dist/consent/index.d.ts +1 -0
- package/dist/consent/index.js +65 -0
- package/dist/consent/index.js.map +1 -0
- package/dist/content/index.cjs +161 -0
- package/dist/content/index.cjs.map +1 -0
- package/dist/content/index.d.cts +1 -0
- package/dist/content/index.d.ts +1 -0
- package/dist/content/index.js +129 -0
- package/dist/content/index.js.map +1 -0
- package/dist/deployment/index.cjs +551 -0
- package/dist/deployment/index.cjs.map +1 -0
- package/dist/deployment/index.d.cts +1 -0
- package/dist/deployment/index.d.ts +1 -0
- package/dist/deployment/index.js +441 -0
- package/dist/deployment/index.js.map +1 -0
- package/dist/index.cjs +12565 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +12557 -0
- package/dist/index.js.map +1 -0
- package/dist/mail/index.cjs +261 -0
- package/dist/mail/index.cjs.map +1 -0
- package/dist/mail/index.d.cts +1 -0
- package/dist/mail/index.d.ts +1 -0
- package/dist/mail/index.js +209 -0
- package/dist/mail/index.js.map +1 -0
- package/dist/observe/index.cjs +181 -0
- package/dist/observe/index.cjs.map +1 -0
- package/dist/observe/index.d.cts +1 -0
- package/dist/observe/index.d.ts +1 -0
- package/dist/observe/index.js +145 -0
- package/dist/observe/index.js.map +1 -0
- package/dist/organization/index.cjs +456 -0
- package/dist/organization/index.cjs.map +1 -0
- package/dist/organization/index.d.cts +1 -0
- package/dist/organization/index.d.ts +1 -0
- package/dist/organization/index.js +365 -0
- package/dist/organization/index.js.map +1 -0
- package/dist/react/index.cjs +29 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +1 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +26 -0
- package/dist/react/index.js.map +1 -0
- package/dist/realtime/index.cjs +146 -0
- package/dist/realtime/index.cjs.map +1 -0
- package/dist/realtime/index.d.cts +1 -0
- package/dist/realtime/index.d.ts +1 -0
- package/dist/realtime/index.js +117 -0
- package/dist/realtime/index.js.map +1 -0
- package/dist/support/index.cjs +261 -0
- package/dist/support/index.cjs.map +1 -0
- package/dist/support/index.d.cts +1 -0
- package/dist/support/index.d.ts +1 -0
- package/dist/support/index.js +209 -0
- package/dist/support/index.js.map +1 -0
- package/dist/tracking/index.cjs +191 -0
- package/dist/tracking/index.cjs.map +1 -0
- package/dist/tracking/index.d.cts +1 -0
- package/dist/tracking/index.d.ts +1 -0
- package/dist/tracking/index.js +153 -0
- package/dist/tracking/index.js.map +1 -0
- package/dist/users/index.cjs +231 -0
- package/dist/users/index.cjs.map +1 -0
- package/dist/users/index.d.cts +1 -0
- package/dist/users/index.d.ts +1 -0
- package/dist/users/index.js +185 -0
- package/dist/users/index.js.map +1 -0
- package/dist/waitlist/index.cjs +41 -0
- package/dist/waitlist/index.cjs.map +1 -0
- package/dist/waitlist/index.d.cts +1 -0
- package/dist/waitlist/index.d.ts +1 -0
- package/dist/waitlist/index.js +33 -0
- package/dist/waitlist/index.js.map +1 -0
- package/dist/workflow/index.cjs +211 -0
- package/dist/workflow/index.cjs.map +1 -0
- package/dist/workflow/index.d.cts +1 -0
- package/dist/workflow/index.d.ts +1 -0
- package/dist/workflow/index.js +169 -0
- package/dist/workflow/index.js.map +1 -0
- package/docs/README.md +89 -0
- package/docs/recipes/access.md +781 -0
- package/docs/recipes/federation.md +285 -0
- package/docs/recipes/local-dev.md +111 -0
- package/docs/recipes/sdk-usage.md +194 -0
- package/docs/rules.md +522 -0
- package/package.json +212 -0
- package/src/gen/analytics/index.ts +3 -0
- package/src/gen/analytics/queryOptions.gen.ts +124 -0
- package/src/gen/bindings.gen.ts +11330 -0
- package/src/gen/brain/index.ts +3 -0
- package/src/gen/brain/queryOptions.gen.ts +278 -0
- package/src/gen/client.gen.ts +2288 -0
- package/src/gen/consent/index.ts +3 -0
- package/src/gen/consent/queryOptions.gen.ts +102 -0
- package/src/gen/content/index.ts +3 -0
- package/src/gen/content/queryOptions.gen.ts +202 -0
- package/src/gen/deployment/index.ts +3 -0
- package/src/gen/deployment/queryOptions.gen.ts +707 -0
- package/src/gen/mail/index.ts +3 -0
- package/src/gen/mail/queryOptions.gen.ts +331 -0
- package/src/gen/manifest.gen.ts +780 -0
- package/src/gen/observe/index.ts +3 -0
- package/src/gen/observe/queryOptions.gen.ts +235 -0
- package/src/gen/organization/index.ts +3 -0
- package/src/gen/organization/queryOptions.gen.ts +578 -0
- package/src/gen/realtime/index.ts +3 -0
- package/src/gen/realtime/queryOptions.gen.ts +185 -0
- package/src/gen/support/index.ts +3 -0
- package/src/gen/support/queryOptions.gen.ts +331 -0
- package/src/gen/tracking/index.ts +3 -0
- package/src/gen/tracking/queryOptions.gen.ts +249 -0
- package/src/gen/types.gen.ts +119506 -0
- package/src/gen/users/index.ts +3 -0
- package/src/gen/users/queryOptions.gen.ts +295 -0
- package/src/gen/waitlist/index.ts +3 -0
- package/src/gen/waitlist/queryOptions.gen.ts +50 -0
- package/src/gen/workflow/index.ts +3 -0
- 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.
|