@colixsystems/widget-sdk 0.13.0 → 0.14.1

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 CHANGED
@@ -6,7 +6,19 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
6
6
 
7
7
  ## Status
8
8
 
9
- `v0.13.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
9
+ `v0.14.1` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
10
+
11
+ ### What's new in 0.14.1
12
+
13
+ - **`groupRef` property type (REQ-USERMGMT M4 / §4.8).** Authors can now declare `{ type: 'groupRef', label: 'Group' }` in their `propertySchema` to render a Group picker in the Studio Properties Panel. The widget receives a bare `AppUserGroup` UUID — REQ-GEN-07 compliant, so tenant-copy walks the value transparently. Used by the built-in `appstudio.user-management` widget for its `defaultGroupId` prop and available to third-party widgets that need to anchor behaviour on a specific group.
14
+ - **Patch bump** — additive enumeration entry, no exported function signature changed. `CONTRACT.version` stays `1.4.0`.
15
+
16
+ ### What's new in 0.14.0
17
+
18
+ - **`useUsers()` + `useGroups()` — AppUser administration hooks (REQ-USERMGMT / REQ-ACL-SYS M3).** A widget can now invite, deactivate, reactivate, and remove members, and create / delete groups + add / remove members, from a published-app surface. Returns `{ users | groups, loading, error, refetch, ... }` plus imperative mutation methods. Reads gated by `users.read:*` / `groups.read:*`; mutations by `users.write:*` / `groups.write:*`. Rejections surface as a structured `DirectoryError` (new named export) with `code` ∈ `FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY`. The host signs an `X-Widget-Scopes` header against `JWT_SECRET` so an APP_USER cannot forge a scope set, and the backend additionally gates the request behind a SystemAcl `users.*` / `groups.*` capability grant (REQ-ACL-SYS M1 + M3).
19
+ - **Managing app users from a widget — see the section below.**
20
+ - **New linter rule `no-scope-mismatch-useUsers` / `no-scope-mismatch-useGroups`.** Calling `useUsers().invite()` / `.deactivate()` / `.reactivate()` / `.remove()` without `users.write:*` in the manifest's `requestedScopes` fails the lint; calling `useGroups()` mutation methods without `groups.write:*` fails the lint. Keeps the manifest honest at submission time.
21
+ - **`CONTRACT.version` → `1.4.0`** (additive: two new hooks, two new context slices, six new scope verbs, one new error class). No existing export changed signature.
10
22
 
11
23
  ### What's new in 0.13.0
12
24
 
@@ -89,11 +101,58 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
89
101
 
90
102
  - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
91
103
  - `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
92
- - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreMutation`, `useDirectory`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer` — hooks that read from the host-provided `WidgetContext`. `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (`id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useFile(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case).
104
+ - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer` — hooks that read from the host-provided `WidgetContext`. `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (`id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useFile(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case).
93
105
  - `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
94
106
  - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet` — re-exported from `react-native`. The web build aliases `react-native` to `react-native-web` so widgets render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. See https://reactnative.dev/docs/ for per-component props.
95
107
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
96
108
 
109
+ ## Managing app users from a widget
110
+
111
+ REQ-USERMGMT / REQ-ACL-SYS M3 added two hooks that let a widget invite, deactivate, reactivate, and remove members, plus create / delete groups and add / remove their members. The hooks are gated by two layers: the widget's manifest must declare the scope (so the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability in the tenant (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller does not hold the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that to the end-user as a "you do not have permission to do that" message.
112
+
113
+ ```js
114
+ import { Text, View, Pressable, useUsers, useGroups } from "@colixsystems/widget-sdk";
115
+
116
+ export default function MemberManager() {
117
+ const { users, loading, invite, deactivate, remove } = useUsers({ q: "" });
118
+ const { groups, addMember } = useGroups();
119
+ if (loading) return <Text>Loading…</Text>;
120
+ return (
121
+ <View>
122
+ {users.map((u) => (
123
+ <View key={u.id}>
124
+ <Text>{u.name} — {u.isActive ? "active" : "inactive"}</Text>
125
+ <Pressable onPress={() => deactivate(u.id)}><Text>Deactivate</Text></Pressable>
126
+ <Pressable onPress={() => remove(u.id)}><Text>Remove</Text></Pressable>
127
+ </View>
128
+ ))}
129
+ <Pressable
130
+ onPress={async () => {
131
+ try {
132
+ await invite({ email: "a@b.com", name: "New User", groupIds: [groups[0]?.id].filter(Boolean) });
133
+ } catch (err) {
134
+ // err.code is one of FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY
135
+ }
136
+ }}
137
+ >
138
+ <Text>Invite</Text>
139
+ </Pressable>
140
+ </View>
141
+ );
142
+ }
143
+ ```
144
+
145
+ The matching manifest declares the scopes:
146
+
147
+ ```js
148
+ {
149
+ // ...
150
+ requestedScopes: ["users.read:*", "users.write:*", "groups.read:*", "groups.write:*"],
151
+ }
152
+ ```
153
+
154
+ The host rejects calls whose scope is not declared in the manifest (the SDK linter catches this statically too). Declaring a write scope is also a consent prompt the Studio admin sees at install time — the wider the scope set, the more careful the admin is about granting the install.
155
+
97
156
  ## Linter
98
157
 
99
158
  ```sh
package/dist/contract.cjs CHANGED
@@ -170,6 +170,76 @@ const HOOKS = [
170
170
  requiredContextSlice: ["payments.requestPayment"],
171
171
  scopes: ["payments.charge:appUser"],
172
172
  },
173
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
174
+ // `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
175
+ // Reads need `users.read:*` scope; mutations additionally need
176
+ // `users.write:*`. The `invite` call accepts `{ email, name, groupIds? }`
177
+ // and returns the resulting AppUserInvite row (the email is sent by the
178
+ // host). Mutating users from a widget requires the corresponding
179
+ // SystemAcl `users.write` capability grant in the tenant; widgets that
180
+ // only call read methods need only `users.read:*`.
181
+ {
182
+ name: "useUsers",
183
+ signature: "useUsers(query?)",
184
+ description:
185
+ "AppUser administration. Returns { users, loading, error, refetch, invite, " +
186
+ "deactivate, reactivate, remove }. Reads need users.read:* scope; mutations " +
187
+ "need users.write:*. The `invite` call accepts { email, name, groupIds? } " +
188
+ "and returns the resulting AppUserInvite row (the email is sent by the host).",
189
+ returnShape: {
190
+ users: "Array<{ id, name, email?, role, isActive }>",
191
+ loading: "boolean",
192
+ error: "DirectoryError | null",
193
+ refetch: "() => Promise<void>",
194
+ invite:
195
+ "({ email, name, groupIds? }) => Promise<Invite> // rejects with DirectoryError",
196
+ deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
197
+ reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
198
+ remove: "(userId) => Promise<void> // rejects with DirectoryError",
199
+ },
200
+ requiredContextSlice: [
201
+ "users.listUsers",
202
+ "users.invite",
203
+ "users.deactivate",
204
+ "users.reactivate",
205
+ "users.remove",
206
+ ],
207
+ scopes: ["users.read:*"],
208
+ },
209
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
210
+ // `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
211
+ // Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
212
+ // member requires the corresponding `users.write` capability since it
213
+ // affects the user's effective access.
214
+ {
215
+ name: "useGroups",
216
+ signature: "useGroups(query?)",
217
+ description:
218
+ "AppUserGroup administration. Returns { groups, loading, error, refetch, " +
219
+ "create, remove, addMember, removeMember }. Reads need groups.read:*; " +
220
+ "mutations need groups.write:*.",
221
+ returnShape: {
222
+ groups: "Array<{ id, name, memberCount }>",
223
+ loading: "boolean",
224
+ error: "DirectoryError | null",
225
+ refetch: "() => Promise<void>",
226
+ create:
227
+ "({ name }) => Promise<Group> // rejects with DirectoryError",
228
+ remove: "(groupId) => Promise<void> // rejects with DirectoryError",
229
+ addMember:
230
+ "(groupId, userId) => Promise<void> // rejects with DirectoryError",
231
+ removeMember:
232
+ "(groupId, userId) => Promise<void> // rejects with DirectoryError",
233
+ },
234
+ requiredContextSlice: [
235
+ "groups.listGroups",
236
+ "groups.create",
237
+ "groups.remove",
238
+ "groups.addMember",
239
+ "groups.removeMember",
240
+ ],
241
+ scopes: ["groups.read:*"],
242
+ },
173
243
  ];
174
244
 
175
245
  // REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
@@ -433,6 +503,38 @@ const WIDGET_CONTEXT_SHAPE = {
433
503
  required: true,
434
504
  fields: { requestPayment: "function", getPayment: "function" },
435
505
  },
506
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration facade backing
507
+ // useUsers(). Reads gated by `users.read:*`; mutations by `users.write:*`.
508
+ // The host signs an `X-Widget-Scopes` header against JWT_SECRET so an
509
+ // APP_USER cannot forge scope claims, and the request is additionally
510
+ // gated by a SystemAcl `users.read` / `users.write` capability grant.
511
+ users: {
512
+ description:
513
+ "AppUser administration. { listUsers(query?) -> Promise<User[]>, invite({ email, name, groupIds? }) -> Promise<Invite>, deactivate(userId) -> Promise<User>, reactivate(userId) -> Promise<User>, remove(userId) -> Promise<void> }. Backs useUsers(); reads require users.read:*, mutations require users.write:*.",
514
+ required: true,
515
+ fields: {
516
+ listUsers: "function",
517
+ invite: "function",
518
+ deactivate: "function",
519
+ reactivate: "function",
520
+ remove: "function",
521
+ },
522
+ },
523
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration facade
524
+ // backing useGroups(). Reads gated by `groups.read:*`; mutations by
525
+ // `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as users.
526
+ groups: {
527
+ description:
528
+ "AppUserGroup administration. { listGroups(query?) -> Promise<Group[]>, create({ name }) -> Promise<Group>, remove(groupId) -> Promise<void>, addMember(groupId, userId) -> Promise<void>, removeMember(groupId, userId) -> Promise<void> }. Backs useGroups(); reads require groups.read:*, mutations require groups.write:*.",
529
+ required: true,
530
+ fields: {
531
+ listGroups: "function",
532
+ create: "function",
533
+ remove: "function",
534
+ addMember: "function",
535
+ removeMember: "function",
536
+ },
537
+ },
436
538
  i18n: {
437
539
  description: "{ t(key, fallback?), locale }.",
438
540
  required: true,
@@ -525,7 +627,7 @@ function deepFreeze(value) {
525
627
  }
526
628
 
527
629
  const CONTRACT = deepFreeze({
528
- version: "1.2.0",
630
+ version: "1.4.0",
529
631
  hooks: HOOKS,
530
632
  primitives: PRIMITIVES,
531
633
  manifestSchema: MANIFEST_SCHEMA,
package/dist/contract.js CHANGED
@@ -171,6 +171,76 @@ const HOOKS = [
171
171
  requiredContextSlice: ["payments.requestPayment"],
172
172
  scopes: ["payments.charge:appUser"],
173
173
  },
174
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
175
+ // `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
176
+ // Reads need `users.read:*` scope; mutations additionally need
177
+ // `users.write:*`. The `invite` call accepts `{ email, name, groupIds? }`
178
+ // and returns the resulting AppUserInvite row (the email is sent by the
179
+ // host). Mutating users from a widget requires the corresponding
180
+ // SystemAcl `users.write` capability grant in the tenant; widgets that
181
+ // only call read methods need only `users.read:*`.
182
+ {
183
+ name: "useUsers",
184
+ signature: "useUsers(query?)",
185
+ description:
186
+ "AppUser administration. Returns { users, loading, error, refetch, invite, " +
187
+ "deactivate, reactivate, remove }. Reads need users.read:* scope; mutations " +
188
+ "need users.write:*. The `invite` call accepts { email, name, groupIds? } " +
189
+ "and returns the resulting AppUserInvite row (the email is sent by the host).",
190
+ returnShape: {
191
+ users: "Array<{ id, name, email?, role, isActive }>",
192
+ loading: "boolean",
193
+ error: "DirectoryError | null",
194
+ refetch: "() => Promise<void>",
195
+ invite:
196
+ "({ email, name, groupIds? }) => Promise<Invite> // rejects with DirectoryError",
197
+ deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
198
+ reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
199
+ remove: "(userId) => Promise<void> // rejects with DirectoryError",
200
+ },
201
+ requiredContextSlice: [
202
+ "users.listUsers",
203
+ "users.invite",
204
+ "users.deactivate",
205
+ "users.reactivate",
206
+ "users.remove",
207
+ ],
208
+ scopes: ["users.read:*"],
209
+ },
210
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
211
+ // `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
212
+ // Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
213
+ // member requires the corresponding `users.write` capability since it
214
+ // affects the user's effective access.
215
+ {
216
+ name: "useGroups",
217
+ signature: "useGroups(query?)",
218
+ description:
219
+ "AppUserGroup administration. Returns { groups, loading, error, refetch, " +
220
+ "create, remove, addMember, removeMember }. Reads need groups.read:*; " +
221
+ "mutations need groups.write:*.",
222
+ returnShape: {
223
+ groups: "Array<{ id, name, memberCount }>",
224
+ loading: "boolean",
225
+ error: "DirectoryError | null",
226
+ refetch: "() => Promise<void>",
227
+ create:
228
+ "({ name }) => Promise<Group> // rejects with DirectoryError",
229
+ remove: "(groupId) => Promise<void> // rejects with DirectoryError",
230
+ addMember:
231
+ "(groupId, userId) => Promise<void> // rejects with DirectoryError",
232
+ removeMember:
233
+ "(groupId, userId) => Promise<void> // rejects with DirectoryError",
234
+ },
235
+ requiredContextSlice: [
236
+ "groups.listGroups",
237
+ "groups.create",
238
+ "groups.remove",
239
+ "groups.addMember",
240
+ "groups.removeMember",
241
+ ],
242
+ scopes: ["groups.read:*"],
243
+ },
174
244
  ];
175
245
 
176
246
  // REQ-WSDK-RN-WEB: see contract.cjs for the source-of-truth comment.
@@ -427,6 +497,38 @@ const WIDGET_CONTEXT_SHAPE = {
427
497
  required: true,
428
498
  fields: { requestPayment: "function", getPayment: "function" },
429
499
  },
500
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration facade backing
501
+ // useUsers(). Reads gated by `users.read:*`; mutations by `users.write:*`.
502
+ // The host signs an `X-Widget-Scopes` header against JWT_SECRET so an
503
+ // APP_USER cannot forge scope claims, and the request is additionally
504
+ // gated by a SystemAcl `users.read` / `users.write` capability grant.
505
+ users: {
506
+ description:
507
+ "AppUser administration. { listUsers(query?) -> Promise<User[]>, invite({ email, name, groupIds? }) -> Promise<Invite>, deactivate(userId) -> Promise<User>, reactivate(userId) -> Promise<User>, remove(userId) -> Promise<void> }. Backs useUsers(); reads require users.read:*, mutations require users.write:*.",
508
+ required: true,
509
+ fields: {
510
+ listUsers: "function",
511
+ invite: "function",
512
+ deactivate: "function",
513
+ reactivate: "function",
514
+ remove: "function",
515
+ },
516
+ },
517
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration facade
518
+ // backing useGroups(). Reads gated by `groups.read:*`; mutations by
519
+ // `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as users.
520
+ groups: {
521
+ description:
522
+ "AppUserGroup administration. { listGroups(query?) -> Promise<Group[]>, create({ name }) -> Promise<Group>, remove(groupId) -> Promise<void>, addMember(groupId, userId) -> Promise<void>, removeMember(groupId, userId) -> Promise<void> }. Backs useGroups(); reads require groups.read:*, mutations require groups.write:*.",
523
+ required: true,
524
+ fields: {
525
+ listGroups: "function",
526
+ create: "function",
527
+ remove: "function",
528
+ addMember: "function",
529
+ removeMember: "function",
530
+ },
531
+ },
430
532
  i18n: {
431
533
  description: "{ t(key, fallback?), locale }.",
432
534
  required: true,
@@ -517,7 +619,7 @@ function deepFreeze(value) {
517
619
  }
518
620
 
519
621
  const CONTRACT = deepFreeze({
520
- version: "1.2.0",
622
+ version: "1.4.0",
521
623
  hooks: HOOKS,
522
624
  primitives: PRIMITIVES,
523
625
  manifestSchema: MANIFEST_SCHEMA,
package/dist/hooks.js CHANGED
@@ -687,6 +687,236 @@ export function usePayments() {
687
687
  * the key is missing. The host's i18n.t may or may not honour the two-arg
688
688
  * form; we degrade gracefully either way.
689
689
  */
690
+ /**
691
+ * REQ-USERMGMT / REQ-ACL-SYS M3 — structured error thrown by `useUsers` /
692
+ * `useGroups` callbacks. Carries a stable `code` so widgets can branch
693
+ * without parsing message strings.
694
+ *
695
+ * `code` is one of:
696
+ * - "FORBIDDEN" — 403 from the host (capability or scope missing).
697
+ * - "VALIDATION" — 400 / 422 (bad email, duplicate, etc.).
698
+ * - "NOT_FOUND" — 404 (group / user does not exist or cross-tenant).
699
+ * - "INVITE_ONLY" — 409 from accept-invite-style flows where the tenant
700
+ * is invite-only and the email is not on the list.
701
+ * - "INTERNAL" — anything else (network, 5xx).
702
+ */
703
+ export class DirectoryError extends Error {
704
+ constructor(code, message, opts) {
705
+ super(message);
706
+ this.name = "DirectoryError";
707
+ this.code = code;
708
+ if (opts && opts.cause) this.cause = opts.cause;
709
+ }
710
+ }
711
+
712
+ function toDirectoryError(err) {
713
+ if (err instanceof DirectoryError) return err;
714
+ const status =
715
+ err && err.response && typeof err.response.status === "number"
716
+ ? err.response.status
717
+ : null;
718
+ const bodyCode =
719
+ err && err.response && err.response.data && err.response.data.code;
720
+ const bodyMessage =
721
+ err && err.response && err.response.data && err.response.data.error;
722
+ let code = "INTERNAL";
723
+ if (bodyCode === "INVITE_ONLY") code = "INVITE_ONLY";
724
+ else if (status === 403) code = "FORBIDDEN";
725
+ else if (status === 404) code = "NOT_FOUND";
726
+ else if (status === 400 || status === 422) code = "VALIDATION";
727
+ else if (status === 409) {
728
+ // 409 is invite-only on the invite endpoints; treat the rest as
729
+ // validation conflicts (duplicate email, etc.).
730
+ code = bodyCode === "INVITE_ONLY" ? "INVITE_ONLY" : "VALIDATION";
731
+ }
732
+ const message =
733
+ (typeof bodyMessage === "string" && bodyMessage) ||
734
+ (err && typeof err.message === "string"
735
+ ? err.message
736
+ : "Directory call failed");
737
+ return new DirectoryError(code, message, { cause: err });
738
+ }
739
+
740
+ /**
741
+ * REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook.
742
+ *
743
+ * Returns `{ users, loading, error, refetch, invite, deactivate,
744
+ * reactivate, remove }`. The list refetches whenever
745
+ * `JSON.stringify(query)` changes; the imperative methods reject with a
746
+ * `DirectoryError`. Reads require the `users.read:*` scope; mutations
747
+ * additionally require `users.write:*`. The host's signed
748
+ * `X-Widget-Scopes` header + a tenant-scoped SystemAcl `users.read` /
749
+ * `users.write` capability grant gate the underlying endpoint
750
+ * (REQ-ACL-SYS M3 §4.3) — a widget that declares the scopes but whose
751
+ * caller does not hold the grant gets a `FORBIDDEN`.
752
+ */
753
+ export function useUsers(query) {
754
+ const ctx = useWidgetContextOrThrow("useUsers");
755
+ if (!ctx.users || typeof ctx.users.listUsers !== "function") {
756
+ throw new Error("useUsers: host did not inject a users client");
757
+ }
758
+ const [users, setUsers] = useState([]);
759
+ const [loading, setLoading] = useState(true);
760
+ const [error, setError] = useState(null);
761
+
762
+ const queryRef = useRef(query);
763
+ const usersRef = useRef(ctx.users);
764
+ queryRef.current = query;
765
+ usersRef.current = ctx.users;
766
+
767
+ const runRef = useRef(0);
768
+
769
+ const doFetch = useCallback(async () => {
770
+ const myRun = ++runRef.current;
771
+ setLoading(true);
772
+ setError(null);
773
+ try {
774
+ const rows = await usersRef.current.listUsers(queryRef.current);
775
+ if (runRef.current !== myRun) return;
776
+ setUsers(Array.isArray(rows) ? rows : []);
777
+ setLoading(false);
778
+ } catch (err) {
779
+ if (runRef.current !== myRun) return;
780
+ setError(toDirectoryError(err));
781
+ setLoading(false);
782
+ }
783
+ }, []);
784
+
785
+ const queryKey = (() => {
786
+ try {
787
+ return JSON.stringify(query);
788
+ } catch (_e) {
789
+ return null;
790
+ }
791
+ })();
792
+ useEffect(() => {
793
+ doFetch();
794
+ // eslint-disable-next-line react-hooks/exhaustive-deps
795
+ }, [queryKey]);
796
+
797
+ const refetch = useCallback(async () => {
798
+ await doFetch();
799
+ }, [doFetch]);
800
+
801
+ const invite = useCallback(async (args) => {
802
+ try {
803
+ return await usersRef.current.invite(args);
804
+ } catch (err) {
805
+ throw toDirectoryError(err);
806
+ }
807
+ }, []);
808
+ const deactivate = useCallback(async (userId) => {
809
+ try {
810
+ return await usersRef.current.deactivate(userId);
811
+ } catch (err) {
812
+ throw toDirectoryError(err);
813
+ }
814
+ }, []);
815
+ const reactivate = useCallback(async (userId) => {
816
+ try {
817
+ return await usersRef.current.reactivate(userId);
818
+ } catch (err) {
819
+ throw toDirectoryError(err);
820
+ }
821
+ }, []);
822
+ const remove = useCallback(async (userId) => {
823
+ try {
824
+ return await usersRef.current.remove(userId);
825
+ } catch (err) {
826
+ throw toDirectoryError(err);
827
+ }
828
+ }, []);
829
+
830
+ return { users, loading, error, refetch, invite, deactivate, reactivate, remove };
831
+ }
832
+
833
+ /**
834
+ * REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration hook.
835
+ *
836
+ * Returns `{ groups, loading, error, refetch, create, remove, addMember,
837
+ * removeMember }`. Reads require `groups.read:*`; mutations require
838
+ * `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as `useUsers`.
839
+ */
840
+ export function useGroups(query) {
841
+ const ctx = useWidgetContextOrThrow("useGroups");
842
+ if (!ctx.groups || typeof ctx.groups.listGroups !== "function") {
843
+ throw new Error("useGroups: host did not inject a groups client");
844
+ }
845
+ const [groups, setGroups] = useState([]);
846
+ const [loading, setLoading] = useState(true);
847
+ const [error, setError] = useState(null);
848
+
849
+ const queryRef = useRef(query);
850
+ const groupsRef = useRef(ctx.groups);
851
+ queryRef.current = query;
852
+ groupsRef.current = ctx.groups;
853
+
854
+ const runRef = useRef(0);
855
+
856
+ const doFetch = useCallback(async () => {
857
+ const myRun = ++runRef.current;
858
+ setLoading(true);
859
+ setError(null);
860
+ try {
861
+ const rows = await groupsRef.current.listGroups(queryRef.current);
862
+ if (runRef.current !== myRun) return;
863
+ setGroups(Array.isArray(rows) ? rows : []);
864
+ setLoading(false);
865
+ } catch (err) {
866
+ if (runRef.current !== myRun) return;
867
+ setError(toDirectoryError(err));
868
+ setLoading(false);
869
+ }
870
+ }, []);
871
+
872
+ const queryKey = (() => {
873
+ try {
874
+ return JSON.stringify(query);
875
+ } catch (_e) {
876
+ return null;
877
+ }
878
+ })();
879
+ useEffect(() => {
880
+ doFetch();
881
+ // eslint-disable-next-line react-hooks/exhaustive-deps
882
+ }, [queryKey]);
883
+
884
+ const refetch = useCallback(async () => {
885
+ await doFetch();
886
+ }, [doFetch]);
887
+
888
+ const create = useCallback(async (args) => {
889
+ try {
890
+ return await groupsRef.current.create(args);
891
+ } catch (err) {
892
+ throw toDirectoryError(err);
893
+ }
894
+ }, []);
895
+ const remove = useCallback(async (groupId) => {
896
+ try {
897
+ return await groupsRef.current.remove(groupId);
898
+ } catch (err) {
899
+ throw toDirectoryError(err);
900
+ }
901
+ }, []);
902
+ const addMember = useCallback(async (groupId, userId) => {
903
+ try {
904
+ return await groupsRef.current.addMember(groupId, userId);
905
+ } catch (err) {
906
+ throw toDirectoryError(err);
907
+ }
908
+ }, []);
909
+ const removeMember = useCallback(async (groupId, userId) => {
910
+ try {
911
+ return await groupsRef.current.removeMember(groupId, userId);
912
+ } catch (err) {
913
+ throw toDirectoryError(err);
914
+ }
915
+ }, []);
916
+
917
+ return { groups, loading, error, refetch, create, remove, addMember, removeMember };
918
+ }
919
+
690
920
  export function useI18n() {
691
921
  const ctx = useWidgetContextOrThrow("useI18n");
692
922
  const i18n = ctx.i18n || {};
package/dist/index.d.ts CHANGED
@@ -28,6 +28,9 @@ export type WidgetPropertyType =
28
28
  | "tableRef"
29
29
  | "columnRef"
30
30
  | "recordBinding"
31
+ // REQ-USERMGMT M4 / §4.8: Group picker that emits a bare
32
+ // AppUserGroup UUID into the page JSON.
33
+ | "groupRef"
31
34
  | "expression"
32
35
  | "eventBinding"
33
36
  | "object"
@@ -480,6 +483,109 @@ export class DatastoreError extends Error {
480
483
  );
481
484
  }
482
485
 
486
+ /**
487
+ * REQ-USERMGMT / REQ-ACL-SYS M3 — error class thrown by `useUsers` and
488
+ * `useGroups` callbacks. The `code` is a stable categorisation widgets
489
+ * can branch on.
490
+ */
491
+ export class DirectoryError extends Error {
492
+ code:
493
+ | "FORBIDDEN"
494
+ | "VALIDATION"
495
+ | "NOT_FOUND"
496
+ | "INVITE_ONLY"
497
+ | "INTERNAL";
498
+ constructor(
499
+ code: DirectoryError["code"],
500
+ message: string,
501
+ opts?: { cause?: unknown },
502
+ );
503
+ }
504
+
505
+ // --------------------------------------------------------------- useUsers
506
+ //
507
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook.
508
+
509
+ export interface AppUserRow {
510
+ id: string;
511
+ name: string;
512
+ email?: string;
513
+ role: "USER" | "INTEGRATION";
514
+ isActive: boolean;
515
+ }
516
+
517
+ export interface UsersQuery {
518
+ q?: string;
519
+ role?: "USER" | "INTEGRATION" | "ALL";
520
+ isActive?: boolean;
521
+ limit?: number;
522
+ offset?: number;
523
+ }
524
+
525
+ export interface InviteArgs {
526
+ email: string;
527
+ name?: string;
528
+ groupIds?: string[];
529
+ }
530
+
531
+ export interface AppUserInviteRow {
532
+ id: string;
533
+ email: string;
534
+ status: string;
535
+ invitedAt?: string;
536
+ expiresAt?: string;
537
+ }
538
+
539
+ export interface UsersApi {
540
+ users: AppUserRow[];
541
+ loading: boolean;
542
+ error: DirectoryError | null;
543
+ refetch(): Promise<void>;
544
+ invite(args: InviteArgs): Promise<AppUserInviteRow>;
545
+ deactivate(userId: string): Promise<AppUserRow>;
546
+ reactivate(userId: string): Promise<AppUserRow>;
547
+ remove(userId: string): Promise<void>;
548
+ }
549
+
550
+ /**
551
+ * AppUser administration. Reads require the `users.read:*` scope in the
552
+ * manifest; mutations additionally require `users.write:*`. Widgets that
553
+ * declare the scopes but whose calling APP_USER lacks the corresponding
554
+ * SystemAcl `users.*` capability grant get a `FORBIDDEN` DirectoryError.
555
+ */
556
+ export function useUsers(query?: UsersQuery): UsersApi;
557
+
558
+ // --------------------------------------------------------------- useGroups
559
+
560
+ export interface AppUserGroupRow {
561
+ id: string;
562
+ name: string;
563
+ memberCount?: number;
564
+ }
565
+
566
+ export interface GroupsQuery {
567
+ q?: string;
568
+ limit?: number;
569
+ offset?: number;
570
+ }
571
+
572
+ export interface GroupsApi {
573
+ groups: AppUserGroupRow[];
574
+ loading: boolean;
575
+ error: DirectoryError | null;
576
+ refetch(): Promise<void>;
577
+ create(args: { name: string }): Promise<AppUserGroupRow>;
578
+ remove(groupId: string): Promise<void>;
579
+ addMember(groupId: string, userId: string): Promise<void>;
580
+ removeMember(groupId: string, userId: string): Promise<void>;
581
+ }
582
+
583
+ /**
584
+ * AppUserGroup administration. Reads require `groups.read:*`; mutations
585
+ * require `groups.write:*`. Same SystemAcl gating as `useUsers`.
586
+ */
587
+ export function useGroups(query?: GroupsQuery): GroupsApi;
588
+
483
589
  export function WidgetContextProvider(props: {
484
590
  value: WidgetContext;
485
591
  children?: ReactNode;
package/dist/index.js CHANGED
@@ -9,11 +9,14 @@ export {
9
9
  WidgetContextProvider,
10
10
  DatastoreError,
11
11
  PaymentError,
12
+ DirectoryError,
12
13
  useDatastoreQuery,
13
14
  useDatastoreRecord,
14
15
  useFile,
15
16
  useDatastoreMutation,
16
17
  useDirectory,
18
+ useUsers,
19
+ useGroups,
17
20
  useWidgetEvent,
18
21
  usePayments,
19
22
  useTheme,
@@ -9,11 +9,14 @@ export {
9
9
  WidgetContextProvider,
10
10
  DatastoreError,
11
11
  PaymentError,
12
+ DirectoryError,
12
13
  useDatastoreQuery,
13
14
  useDatastoreRecord,
14
15
  useFile,
15
16
  useDatastoreMutation,
16
17
  useDirectory,
18
+ useUsers,
19
+ useGroups,
17
20
  useWidgetEvent,
18
21
  usePayments,
19
22
  useTheme,
package/dist/linter.cjs CHANGED
@@ -74,7 +74,101 @@ function bannedIdentifiers() {
74
74
  return CONTRACT.bannedApis.map((b) => b.identifier);
75
75
  }
76
76
 
77
- function lintSource(source) {
77
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation. See
78
+ // linter.js for the rationale comment. The two files must stay in
79
+ // lockstep (the contract test asserts behaviour-equivalence).
80
+ const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate", "remove"];
81
+ const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
82
+ const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
83
+
84
+ function _scopeRules(source, manifest) {
85
+ const findings = [];
86
+ if (!manifest || typeof manifest !== "object") return findings;
87
+ const declared = new Set(
88
+ Array.isArray(manifest.requestedScopes)
89
+ ? manifest.requestedScopes.filter((s) => typeof s === "string")
90
+ : [],
91
+ );
92
+ const usesUsersHook = /\buseUsers\s*\(/.test(source);
93
+ const usesGroupsHook = /\buseGroups\s*\(/.test(source);
94
+
95
+ if (usesUsersHook) {
96
+ const reads = declared.has("users.read:*") || declared.has("users.read");
97
+ if (!reads) {
98
+ findings.push({
99
+ rule: "scope-required-for-useUsers",
100
+ label:
101
+ "useUsers() requires `users.read:*` in manifest.requestedScopes",
102
+ line: 0,
103
+ snippet: "",
104
+ });
105
+ }
106
+ }
107
+ if (usesGroupsHook) {
108
+ const reads = declared.has("groups.read:*") || declared.has("groups.read");
109
+ if (!reads) {
110
+ findings.push({
111
+ rule: "scope-required-for-useGroups",
112
+ label:
113
+ "useGroups() requires `groups.read:*` in manifest.requestedScopes",
114
+ line: 0,
115
+ snippet: "",
116
+ });
117
+ }
118
+ }
119
+
120
+ const lines = source.split(/\r?\n/);
121
+ let firedUsersWrite = false;
122
+ let firedGroupsWrite = false;
123
+ for (let i = 0; i < lines.length; i++) {
124
+ const line = lines[i];
125
+ if (SKIP_COMMENT.test(line)) continue;
126
+ if (usesUsersHook && !firedUsersWrite) {
127
+ for (const m of USER_MUTATION_METHODS) {
128
+ const re = new RegExp(`\\.${m}\\s*\\(`);
129
+ if (re.test(line)) {
130
+ if (
131
+ !declared.has("users.write:*") &&
132
+ !declared.has("users.write")
133
+ ) {
134
+ findings.push({
135
+ rule: "scope-required-for-user-mutation",
136
+ label: `useUsers().${m}() requires \`users.write:*\` in manifest.requestedScopes`,
137
+ line: i + 1,
138
+ snippet: line.trim().slice(0, 200),
139
+ });
140
+ }
141
+ firedUsersWrite = true;
142
+ break;
143
+ }
144
+ }
145
+ }
146
+ if (usesGroupsHook && !firedGroupsWrite) {
147
+ for (const m of GROUP_MUTATION_METHODS) {
148
+ const re = new RegExp(`\\.${m}\\s*\\(`);
149
+ if (re.test(line)) {
150
+ if (m === "remove" && usesUsersHook) continue;
151
+ if (
152
+ !declared.has("groups.write:*") &&
153
+ !declared.has("groups.write")
154
+ ) {
155
+ findings.push({
156
+ rule: "scope-required-for-group-mutation",
157
+ label: `useGroups().${m}() requires \`groups.write:*\` in manifest.requestedScopes`,
158
+ line: i + 1,
159
+ snippet: line.trim().slice(0, 200),
160
+ });
161
+ }
162
+ firedGroupsWrite = true;
163
+ break;
164
+ }
165
+ }
166
+ }
167
+ }
168
+ return findings;
169
+ }
170
+
171
+ function lintSource(source, options) {
78
172
  if (typeof source !== "string") {
79
173
  return {
80
174
  ok: false,
@@ -98,6 +192,7 @@ function lintSource(source) {
98
192
  }
99
193
  }
100
194
  }
195
+ findings.push(..._scopeRules(source, options && options.manifest));
101
196
  return { ok: findings.length === 0, findings };
102
197
  }
103
198
 
package/dist/linter.js CHANGED
@@ -83,12 +83,127 @@ export function bannedIdentifiers() {
83
83
  return CONTRACT.bannedApis.map((b) => b.identifier);
84
84
  }
85
85
 
86
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation.
87
+ //
88
+ // A widget that calls `useUsers().invite()` / `.deactivate()` /
89
+ // `.reactivate()` / `.remove()` MUST declare `users.write:*` in its
90
+ // manifest's `requestedScopes`; similarly `useGroups()` mutation methods
91
+ // require `groups.write:*`. The rule is enforced statically so a manifest
92
+ // that drifts from the source (e.g. an author forgot to add the scope
93
+ // after wiring up the mutation) is rejected at submission time.
94
+ //
95
+ // The check is pattern-based: scan the source for `.invite(`, `.deactivate(`
96
+ // etc. and only fire when the source ALSO contains `useUsers(` / `useGroups(`
97
+ // nearby (i.e. the destructured callee likely originated from the hook).
98
+ // False positives are accepted in exchange for keeping the linter
99
+ // AST-free; an author whose code happened to spell `.invite(` for an
100
+ // unrelated reason can opt out with a `// @appstudio-skip-scope-check`
101
+ // trailing comment on the offending line.
102
+ const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate", "remove"];
103
+ const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
104
+ const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
105
+
106
+ function _scopeRules(source, manifest) {
107
+ const findings = [];
108
+ if (!manifest || typeof manifest !== "object") return findings;
109
+ const declared = new Set(
110
+ Array.isArray(manifest.requestedScopes)
111
+ ? manifest.requestedScopes.filter((s) => typeof s === "string")
112
+ : [],
113
+ );
114
+ const usesUsersHook = /\buseUsers\s*\(/.test(source);
115
+ const usesGroupsHook = /\buseGroups\s*\(/.test(source);
116
+
117
+ if (usesUsersHook) {
118
+ const reads = declared.has("users.read:*") || declared.has("users.read");
119
+ if (!reads) {
120
+ findings.push({
121
+ rule: "scope-required-for-useUsers",
122
+ label:
123
+ "useUsers() requires `users.read:*` in manifest.requestedScopes",
124
+ line: 0,
125
+ snippet: "",
126
+ });
127
+ }
128
+ }
129
+ if (usesGroupsHook) {
130
+ const reads = declared.has("groups.read:*") || declared.has("groups.read");
131
+ if (!reads) {
132
+ findings.push({
133
+ rule: "scope-required-for-useGroups",
134
+ label:
135
+ "useGroups() requires `groups.read:*` in manifest.requestedScopes",
136
+ line: 0,
137
+ snippet: "",
138
+ });
139
+ }
140
+ }
141
+
142
+ const lines = source.split(/\r?\n/);
143
+ let firedUsersWrite = false;
144
+ let firedGroupsWrite = false;
145
+ for (let i = 0; i < lines.length; i++) {
146
+ const line = lines[i];
147
+ if (SKIP_COMMENT.test(line)) continue;
148
+ if (usesUsersHook && !firedUsersWrite) {
149
+ for (const m of USER_MUTATION_METHODS) {
150
+ const re = new RegExp(`\\.${m}\\s*\\(`);
151
+ if (re.test(line)) {
152
+ if (
153
+ !declared.has("users.write:*") &&
154
+ !declared.has("users.write")
155
+ ) {
156
+ findings.push({
157
+ rule: "scope-required-for-user-mutation",
158
+ label: `useUsers().${m}() requires \`users.write:*\` in manifest.requestedScopes`,
159
+ line: i + 1,
160
+ snippet: line.trim().slice(0, 200),
161
+ });
162
+ }
163
+ firedUsersWrite = true;
164
+ break;
165
+ }
166
+ }
167
+ }
168
+ if (usesGroupsHook && !firedGroupsWrite) {
169
+ for (const m of GROUP_MUTATION_METHODS) {
170
+ const re = new RegExp(`\\.${m}\\s*\\(`);
171
+ if (re.test(line)) {
172
+ // `.remove(` is also a useUsers method; only blame groups
173
+ // when the source also uses the groups hook AND the source
174
+ // doesn't ALSO use the users hook (an ambiguous .remove(
175
+ // is best left to the user-mutation rule above).
176
+ if (m === "remove" && usesUsersHook) continue;
177
+ if (
178
+ !declared.has("groups.write:*") &&
179
+ !declared.has("groups.write")
180
+ ) {
181
+ findings.push({
182
+ rule: "scope-required-for-group-mutation",
183
+ label: `useGroups().${m}() requires \`groups.write:*\` in manifest.requestedScopes`,
184
+ line: i + 1,
185
+ snippet: line.trim().slice(0, 200),
186
+ });
187
+ }
188
+ firedGroupsWrite = true;
189
+ break;
190
+ }
191
+ }
192
+ }
193
+ }
194
+ return findings;
195
+ }
196
+
86
197
  /**
87
198
  * Lint a JavaScript source string.
199
+ *
88
200
  * @param {string} source
201
+ * @param {{ manifest?: { requestedScopes?: string[] } }} [options] — when a
202
+ * manifest is supplied, scope-aware rules also fire (see
203
+ * `scope-required-for-user-mutation`).
89
204
  * @returns {{ ok: boolean, findings: Array<{ rule: string, label: string, line: number, snippet: string }> }}
90
205
  */
91
- export function lintSource(source) {
206
+ export function lintSource(source, options) {
92
207
  if (typeof source !== "string") {
93
208
  return {
94
209
  ok: false,
@@ -112,5 +227,9 @@ export function lintSource(source) {
112
227
  }
113
228
  }
114
229
  }
230
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
231
+ // line-by-line scan so banned-identifier findings stay first in the
232
+ // output.
233
+ findings.push(..._scopeRules(source, options && options.manifest));
115
234
  return { ok: findings.length === 0, findings };
116
235
  }
@@ -6,6 +6,12 @@ const VALID_TYPES = new Set([
6
6
  "color", "icon", "image",
7
7
  "select", "multiselect",
8
8
  "tableRef", "columnRef", "recordBinding",
9
+ // REQ-USERMGMT M4 / §4.8: `groupRef` is a Group picker that emits a bare
10
+ // AppUserGroup UUID into the page JSON. Renders via `GroupSelector` in
11
+ // the Studio Properties Panel. REQ-GEN-07 compliance: no typed UUIDs —
12
+ // value is the raw uuid string so the tenant-copy walker can remap it
13
+ // through `_remapJson` without per-prop hooks.
14
+ "groupRef",
9
15
  "expression", "eventBinding",
10
16
  "object", "array",
11
17
  ]);
@@ -87,6 +93,7 @@ function coerceLeaf(def, value, path, errors) {
87
93
  case "tableRef":
88
94
  case "columnRef":
89
95
  case "recordBinding":
96
+ case "groupRef":
90
97
  case "expression":
91
98
  case "eventBinding":
92
99
  if (typeof value !== "string") errors.push(`${path}: expected string`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -35,7 +35,7 @@
35
35
  ],
36
36
  "scripts": {
37
37
  "build": "node scripts/build.js",
38
- "test": "node --test src/__tests__/contract.test.js"
38
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/linter-users-scope.test.js"
39
39
  },
40
40
  "engines": {
41
41
  "node": ">=18"