@lessly/sdk-app 61.0.2 → 61.2.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.
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+
5
+ // src/react/index.ts
6
+ function useAccess(app) {
7
+ const { access } = app;
8
+ const subscribe = react.useCallback((cb) => access.subscribe(cb), [access]);
9
+ const getState = react.useCallback(() => access.state, [access]);
10
+ const state = react.useSyncExternalStore(subscribe, getState, getState);
11
+ react.useEffect(() => {
12
+ void access.load().catch(() => {
13
+ });
14
+ }, [access]);
15
+ const reload = react.useCallback(() => {
16
+ access.invalidate();
17
+ return access.load();
18
+ }, [access]);
19
+ return { state, role: access.role, access, reload };
20
+ }
21
+ function useCan(app, op) {
22
+ const { state, access } = useAccess(app);
23
+ return state === "ready" && access.can(op);
24
+ }
25
+
26
+ exports.useAccess = useAccess;
27
+ exports.useCan = useCan;
28
+ //# sourceMappingURL=index.cjs.map
29
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/react/index.ts"],"names":["useCallback","useSyncExternalStore","useEffect"],"mappings":";;;;;AA4BO,SAAS,UAAU,GAAA,EAA+B;AACvD,EAAA,MAAM,EAAE,QAAO,GAAI,GAAA;AACnB,EAAA,MAAM,SAAA,GAAYA,iBAAA,CAAY,CAAC,EAAA,KAAmB,MAAA,CAAO,UAAU,EAAE,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAGhF,EAAA,MAAM,WAAWA,iBAAA,CAAY,MAAM,OAAO,KAAA,EAAO,CAAC,MAAM,CAAC,CAAA;AACzD,EAAA,MAAM,KAAA,GAAQC,0BAAA,CAAqB,SAAA,EAAW,QAAA,EAAU,QAAQ,CAAA;AAEhE,EAAAC,eAAA,CAAU,MAAM;AAId,IAAA,KAAK,MAAA,CAAO,IAAA,EAAK,CAAE,KAAA,CAAM,MAAM;AAAA,IAAC,CAAC,CAAA;AAAA,EACnC,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,MAAM,MAAA,GAASF,kBAAY,MAAM;AAC/B,IAAA,MAAA,CAAO,UAAA,EAAW;AAClB,IAAA,OAAO,OAAO,IAAA,EAAK;AAAA,EACrB,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,OAAO,EAAE,KAAA,EAAO,IAAA,EAAM,MAAA,CAAO,IAAA,EAAM,QAAQ,MAAA,EAAO;AACpD;AASO,SAAS,MAAA,CAAO,KAAc,EAAA,EAAiC;AACpE,EAAA,MAAM,EAAE,KAAA,EAAO,MAAA,EAAO,GAAI,UAAU,GAAG,CAAA;AACvC,EAAA,OAAO,KAAA,KAAU,OAAA,IAAW,MAAA,CAAO,GAAA,CAAI,EAAE,CAAA;AAC3C","file":"index.cjs","sourcesContent":["import { useCallback, useEffect, useSyncExternalStore } from 'react';\nimport type { Access, AccessApi, AccessState } from '../runtime/access';\nimport type { Operation } from '../runtime/types';\n\n/**\n * The only thing these hooks need from a client — so a test double is one object literal, and\n * (deliberately) no React type appears in any exported signature: `react` is an OPTIONAL peer,\n * so the published declarations must typecheck for a consumer who has not installed it.\n */\nexport interface AppLike {\n readonly access: AccessApi;\n}\n\nexport interface UseAccessResult {\n state: AccessState;\n role: string | undefined;\n access: AccessApi;\n reload: () => Promise<Access>;\n}\n\n/**\n * Subscribe a component to the client's access snapshot, loading it once on mount.\n *\n * `useSyncExternalStore` rather than `useState` + an effect: the snapshot lives outside React\n * (several components share one `AccessApi`), and this is the hook that is safe against tearing\n * under concurrent rendering. `load()` is de-duplicated inside `createAccess`, so every component\n * may call this without coordinating — two components produce one request.\n */\nexport function useAccess(app: AppLike): UseAccessResult {\n const { access } = app;\n const subscribe = useCallback((cb: () => void) => access.subscribe(cb), [access]);\n // Server snapshot is the same getter: there is nothing to hydrate, an unloaded AccessApi\n // reports 'idle' on both sides.\n const getState = useCallback(() => access.state, [access]);\n const state = useSyncExternalStore(subscribe, getState, getState);\n\n useEffect(() => {\n // The rejection is already reflected in `state === 'error'`; swallowing it here only stops an\n // unhandled rejection from reaching the page. A caller who wants the error awaits load()\n // (or reload()) itself.\n void access.load().catch(() => {});\n }, [access]);\n\n const reload = useCallback(() => {\n access.invalidate();\n return access.load();\n }, [access]);\n\n return { state, role: access.role, access, reload };\n}\n\n/**\n * True iff the caller is PREDICTED to be allowed to perform `op`. False until the snapshot is\n * ready — so a control wired to this starts disabled and enables itself, never the reverse.\n *\n * Loads on mount through `useAccess`, so a component may use it without the App having called\n * `load()` anywhere. This is a hint for the UI, never authorisation: see APP-012.\n */\nexport function useCan(app: AppLike, op: Operation | string): boolean {\n const { state, access } = useAccess(app);\n return state === 'ready' && access.can(op);\n}\n"]}
@@ -0,0 +1 @@
1
+ export * from '../_types/react/index.js';
@@ -0,0 +1 @@
1
+ export * from '../_types/react/index.js';
@@ -0,0 +1,26 @@
1
+ import { useCallback, useSyncExternalStore, useEffect } from 'react';
2
+
3
+ // src/react/index.ts
4
+ function useAccess(app) {
5
+ const { access } = app;
6
+ const subscribe = useCallback((cb) => access.subscribe(cb), [access]);
7
+ const getState = useCallback(() => access.state, [access]);
8
+ const state = useSyncExternalStore(subscribe, getState, getState);
9
+ useEffect(() => {
10
+ void access.load().catch(() => {
11
+ });
12
+ }, [access]);
13
+ const reload = useCallback(() => {
14
+ access.invalidate();
15
+ return access.load();
16
+ }, [access]);
17
+ return { state, role: access.role, access, reload };
18
+ }
19
+ function useCan(app, op) {
20
+ const { state, access } = useAccess(app);
21
+ return state === "ready" && access.can(op);
22
+ }
23
+
24
+ export { useAccess, useCan };
25
+ //# sourceMappingURL=index.js.map
26
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/react/index.ts"],"names":[],"mappings":";;;AA4BO,SAAS,UAAU,GAAA,EAA+B;AACvD,EAAA,MAAM,EAAE,QAAO,GAAI,GAAA;AACnB,EAAA,MAAM,SAAA,GAAY,WAAA,CAAY,CAAC,EAAA,KAAmB,MAAA,CAAO,UAAU,EAAE,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAGhF,EAAA,MAAM,WAAW,WAAA,CAAY,MAAM,OAAO,KAAA,EAAO,CAAC,MAAM,CAAC,CAAA;AACzD,EAAA,MAAM,KAAA,GAAQ,oBAAA,CAAqB,SAAA,EAAW,QAAA,EAAU,QAAQ,CAAA;AAEhE,EAAA,SAAA,CAAU,MAAM;AAId,IAAA,KAAK,MAAA,CAAO,IAAA,EAAK,CAAE,KAAA,CAAM,MAAM;AAAA,IAAC,CAAC,CAAA;AAAA,EACnC,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,MAAM,MAAA,GAAS,YAAY,MAAM;AAC/B,IAAA,MAAA,CAAO,UAAA,EAAW;AAClB,IAAA,OAAO,OAAO,IAAA,EAAK;AAAA,EACrB,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,OAAO,EAAE,KAAA,EAAO,IAAA,EAAM,MAAA,CAAO,IAAA,EAAM,QAAQ,MAAA,EAAO;AACpD;AASO,SAAS,MAAA,CAAO,KAAc,EAAA,EAAiC;AACpE,EAAA,MAAM,EAAE,KAAA,EAAO,MAAA,EAAO,GAAI,UAAU,GAAG,CAAA;AACvC,EAAA,OAAO,KAAA,KAAU,OAAA,IAAW,MAAA,CAAO,GAAA,CAAI,EAAE,CAAA;AAC3C","file":"index.js","sourcesContent":["import { useCallback, useEffect, useSyncExternalStore } from 'react';\nimport type { Access, AccessApi, AccessState } from '../runtime/access';\nimport type { Operation } from '../runtime/types';\n\n/**\n * The only thing these hooks need from a client — so a test double is one object literal, and\n * (deliberately) no React type appears in any exported signature: `react` is an OPTIONAL peer,\n * so the published declarations must typecheck for a consumer who has not installed it.\n */\nexport interface AppLike {\n readonly access: AccessApi;\n}\n\nexport interface UseAccessResult {\n state: AccessState;\n role: string | undefined;\n access: AccessApi;\n reload: () => Promise<Access>;\n}\n\n/**\n * Subscribe a component to the client's access snapshot, loading it once on mount.\n *\n * `useSyncExternalStore` rather than `useState` + an effect: the snapshot lives outside React\n * (several components share one `AccessApi`), and this is the hook that is safe against tearing\n * under concurrent rendering. `load()` is de-duplicated inside `createAccess`, so every component\n * may call this without coordinating — two components produce one request.\n */\nexport function useAccess(app: AppLike): UseAccessResult {\n const { access } = app;\n const subscribe = useCallback((cb: () => void) => access.subscribe(cb), [access]);\n // Server snapshot is the same getter: there is nothing to hydrate, an unloaded AccessApi\n // reports 'idle' on both sides.\n const getState = useCallback(() => access.state, [access]);\n const state = useSyncExternalStore(subscribe, getState, getState);\n\n useEffect(() => {\n // The rejection is already reflected in `state === 'error'`; swallowing it here only stops an\n // unhandled rejection from reaching the page. A caller who wants the error awaits load()\n // (or reload()) itself.\n void access.load().catch(() => {});\n }, [access]);\n\n const reload = useCallback(() => {\n access.invalidate();\n return access.load();\n }, [access]);\n\n return { state, role: access.role, access, reload };\n}\n\n/**\n * True iff the caller is PREDICTED to be allowed to perform `op`. False until the snapshot is\n * ready — so a control wired to this starts disabled and enables itself, never the reverse.\n *\n * Loads on mount through `useAccess`, so a component may use it without the App having called\n * `load()` anywhere. This is a hint for the UI, never authorisation: see APP-012.\n */\nexport function useCan(app: AppLike, op: Operation | string): boolean {\n const { state, access } = useAccess(app);\n return state === 'ready' && access.can(op);\n}\n"]}
package/docs/README.md CHANGED
@@ -73,6 +73,7 @@ you need to touch it directly.
73
73
  | `rules.md` | Always — normative `APP-*` rules. Read this first, before writing any code. |
74
74
  | `recipes/sdk-usage.md` | You need to call the platform API: creating the SDK client, handling `LesslyApiError`, using the generated `*QueryOptions`/`*MutationOptions` factories. |
75
75
  | `recipes/federation.md` | You're touching the Module Federation setup: shared singletons, the dual-mode build, or the dual-mode CSS graph (the federation vs standalone stylesheets). |
76
+ | `recipes/access.md` | You're gating UI on the caller's permissions: `app.access`, `useCan`, and rendering a 403 as an empty state rather than an error (APP-012). |
76
77
  | `recipes/local-dev.md` | You're running the app locally: the dev proxy, device-code login, `.env.local`, or composing your app under a local shell. |
77
78
 
78
79
  ## Keeping this guide current
@@ -0,0 +1,177 @@
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 anything you
37
+ render from it must start in the disabled state and enable itself — never the
38
+ reverse. A control that starts enabled and greys out a moment later is worse
39
+ than one that never greyed out at all.
40
+
41
+ You can pass either a tool id or a generated method, which carries its own
42
+ identity:
43
+
44
+ ```ts
45
+ app.access.can('mail_domain_create'); // a tool id
46
+ app.access.can(app.mail.domain.create); // the method itself — no id to keep in sync
47
+ ```
48
+
49
+ Prefer the second form. The accessor path is a lossy view of the tool id (a
50
+ hyphenated resource joins several id segments), so a hand-written string can
51
+ drift from the method it is meant to describe; the method cannot.
52
+
53
+ ---
54
+
55
+ ## 1. Load once at app mount
56
+
57
+ ```tsx
58
+ // src/App.tsx
59
+ import { useEffect } from 'react';
60
+ import { createLesslyApp } from '@lessly/sdk-app';
61
+
62
+ export default function App({ productId }: { productId: string }) {
63
+ const app = useMemo(() => createLesslyApp({ baseUrl: '/api', productId }), [productId]);
64
+
65
+ useEffect(() => {
66
+ // Fire and forget: a failure is reflected in app.access.state, and every consumer of
67
+ // can() is already fail-closed.
68
+ void app.access.load().catch(() => {});
69
+ }, [app]);
70
+
71
+ return <Routes app={app} />;
72
+ }
73
+ ```
74
+
75
+ You do not have to do this. `useAccess`/`useCan` load on mount themselves, and
76
+ `load()` is de-duplicated inside the client — ten components mounting at once
77
+ produce **one** request. Loading at the root is just the cheapest way to have
78
+ the snapshot ready before the first screen paints.
79
+
80
+ Call `app.access.invalidate()` after anything that can change the caller's own
81
+ grants (they accepted an invitation, an admin changed their role in a settings
82
+ screen you own). Nothing else invalidates it: the snapshot is deliberately not
83
+ refetched on a timer.
84
+
85
+ ---
86
+
87
+ ## 2. `useCan` on primary actions — disable, don't hide
88
+
89
+ ```tsx
90
+ import { useCan } from '@lessly/sdk-app/react';
91
+
92
+ function AddDomainButton({ app, onClick }: { app: App; onClick: () => void }) {
93
+ const canCreate = useCan(app, app.mail.domain.create);
94
+
95
+ return (
96
+ <button
97
+ onClick={onClick}
98
+ disabled={!canCreate}
99
+ title={canCreate ? undefined : 'Your role cannot add domains. Ask an admin of this product.'}
100
+ >
101
+ Add domain
102
+ </button>
103
+ );
104
+ }
105
+ ```
106
+
107
+ **Disable, with a hint. Do not hide.** A user who cannot see the button cannot
108
+ tell their admin which permission they are missing — they file a "the app is
109
+ broken" ticket instead, and the admin who could have fixed it in ten seconds
110
+ never hears about it. The greyed-out control with a one-line reason *is* the
111
+ support channel.
112
+
113
+ `useAccess` gives you the same information plus the role, for the cases where
114
+ one flag is not enough:
115
+
116
+ ```tsx
117
+ const { state, role, reload } = useAccess(app);
118
+
119
+ if (state === 'loading') return <Spinner />;
120
+ if (state === 'error') return <p>Could not load your permissions. <button onClick={reload}>Retry</button></p>;
121
+ ```
122
+
123
+ React is an **optional peer** of this package: `@lessly/sdk-app/react` is only
124
+ loaded if you import it, and installing the SDK does not pull React in.
125
+
126
+ ---
127
+
128
+ ## 3. `isAccessDenied` in the shared query state — a neutral empty state
129
+
130
+ A prediction is not a guarantee, so keep handling the real refusal. Grants can
131
+ change between the load and the click, and a screen can always be reached
132
+ before the snapshot is.
133
+
134
+ ```tsx
135
+ import { isAccessDenied } from '@lessly/sdk-app';
136
+ import { useQuery } from '@tanstack/react-query';
137
+ import { mailDomainListQueryOptions } from '@lessly/sdk-app/mail';
138
+
139
+ function DomainList({ app }: { app: App }) {
140
+ const { data, error, isPending } = useQuery(mailDomainListQueryOptions(app, {}));
141
+
142
+ if (isPending) return <Spinner />;
143
+
144
+ // A permission boundary is a NORMAL condition, not a failure of your App.
145
+ if (isAccessDenied(error)) {
146
+ return <EmptyState title="You don't have access to domains" body="Ask an admin of this product to grant it." />;
147
+ }
148
+
149
+ if (error) return <ErrorState error={error} />; // red, retryable — real failures only
150
+
151
+ return <Table rows={data.domains} />;
152
+ }
153
+ ```
154
+
155
+ Put that branch in your shared query-state component once, rather than in every
156
+ screen. `isAccessDenied` is true for exactly the three gateway codes that mean
157
+ *the caller's access was the reason* — `role_denied`,
158
+ `operation_uncatalogued`, `no_tool_permission` — and false for every other 403.
159
+ A `tenant_blocked` 403 is not a permission problem and must not be rendered as
160
+ one: it sends the user to an admin who can do nothing about it.
161
+
162
+ **Never render an access denial in red.** A permission boundary is expected,
163
+ frequent and not the user's mistake. Painting it as a crash teaches people to
164
+ ignore the colour you need for real failures.
165
+
166
+ ---
167
+
168
+ ## What not to do
169
+
170
+ | Don't | Why |
171
+ | --- | --- |
172
+ | Skip the call because `can()` returned false | The snapshot can be stale or simply not loaded. Let the server answer; handle the 403. |
173
+ | Treat `can() === true` as authorisation | It is a prediction. The gateway may still refuse, and only the gateway's answer is a fact. |
174
+ | Drop the `isAccessDenied` branch because every button is gated | A screen can be deep-linked, and grants change mid-session. |
175
+ | Hide controls the caller cannot use | Makes the missing permission unreportable. Disable with a reason instead. |
176
+ | Rebuild a tool id from the accessor path | The path is a lossy view of the id. Pass the method: `app.mail.domain.create`. |
177
+ | Poll `load()` on a timer | The snapshot changes when a role changes; call `invalidate()` then, and only then. |
package/docs/rules.md CHANGED
@@ -331,3 +331,36 @@ validates the top-level surface and `nav.icon` only; it neither rejects nor
331
331
  warns on `nav.sections`, and it will not catch a malformed one. The platform's
332
332
  registration validation is the authority — a menu that lints clean locally can
333
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
+
342
+ An App MUST NOT treat that prediction as enforcement. `can()` answers from a
343
+ snapshot fetched once from the `organization_product_me` tool; the gateway is
344
+ the only authority, grants can change between the load and the click, and the
345
+ prediction is `false` for a caller whose snapshot simply has not loaded yet.
346
+ Concretely: never skip a call because `can()` returned false, never present
347
+ `can() === true` to the user as authorisation, and never drop the
348
+ `isAccessDenied` branch from a screen because every button on it is gated.
349
+
350
+ Two failure modes are worth naming, because both ship regularly:
351
+
352
+ - **Hiding rather than disabling.** A user who cannot see the button cannot ask
353
+ their admin for the permission; they report the App as broken instead, and
354
+ the admin who could have granted it in seconds never hears about it.
355
+ - **Painting a denial red.** A permission boundary is a normal, expected
356
+ condition and not the user's mistake. Rendering it as a crash trains people
357
+ to ignore the colour reserved for real failures. `isAccessDenied()` is true
358
+ for exactly the three gateway codes that mean the caller's access was the
359
+ reason (`role_denied`, `operation_uncatalogued`, `no_tool_permission`) and
360
+ false for every other 403 — a `tenant_blocked` is not a permission problem.
361
+
362
+ Pass the generated method rather than a hand-written tool id
363
+ (`app.access.can(app.mail.domain.create)`): the accessor path is a lossy view
364
+ of the tool id, so a literal string can silently drift from the call it gates.
365
+
366
+ See `recipes/access.md` for the load-once pattern and the empty-state shape.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessly/sdk-app",
3
- "version": "61.0.2",
3
+ "version": "61.2.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "engines": {
@@ -27,13 +27,26 @@
27
27
  "test": "vitest run"
28
28
  },
29
29
  "devDependencies": {
30
+ "@testing-library/react": "^16.3.3",
30
31
  "@types/node": "^20.0.0",
32
+ "@types/react": "^18.3.31",
33
+ "jsdom": "^25.0.1",
31
34
  "json-schema-to-typescript": "^15.0.3",
35
+ "react": "^18.3.1",
36
+ "react-dom": "^18.3.1",
32
37
  "tsup": "^8.3.0",
33
38
  "tsx": "^4.19.0",
34
39
  "typescript": "^5.6.0",
35
40
  "vitest": "^2.1.0"
36
41
  },
42
+ "peerDependencies": {
43
+ "react": ">=18"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "react": {
47
+ "optional": true
48
+ }
49
+ },
37
50
  "exports": {
38
51
  ".": {
39
52
  "import": {
@@ -45,6 +58,16 @@
45
58
  "default": "./dist/index.cjs"
46
59
  }
47
60
  },
61
+ "./react": {
62
+ "import": {
63
+ "types": "./dist/react/index.d.ts",
64
+ "default": "./dist/react/index.js"
65
+ },
66
+ "require": {
67
+ "types": "./dist/react/index.d.cts",
68
+ "default": "./dist/react/index.cjs"
69
+ }
70
+ },
48
71
  "./analytics": {
49
72
  "import": {
50
73
  "types": "./dist/analytics/index.d.ts",