@colixsystems/widget-sdk 0.12.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,23 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
6
6
 
7
7
  ## Status
8
8
 
9
- `v0.12.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.
22
+
23
+ ### What's new in 0.13.0
24
+
25
+ - **`WidgetTree` component + `useChildRenderer()` hook** wired. Container widgets (Tabs, Card, …) can now render arbitrary author-authored child page-tree nodes through the SDK without importing the host renderer. The host pre-binds the surrounding render context (breakpoint, page ctx, parent) into a closure on `ctx.renderer.renderNode(node)` — the widget just passes the child node. Additive.
10
26
 
11
27
  ### What's new in 0.12.0
12
28
 
@@ -85,10 +101,58 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
85
101
 
86
102
  - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
87
103
  - `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
88
- - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreMutation`, `useDirectory`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation` — 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.
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).
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.
89
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.
90
107
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
91
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
+
92
156
  ## Linter
93
157
 
94
158
  ```sh
package/dist/contract.cjs CHANGED
@@ -68,6 +68,15 @@ const HOOKS = [
68
68
  requiredContextSlice: ["user"],
69
69
  scopes: null,
70
70
  },
71
+ {
72
+ name: "useChildRenderer",
73
+ signature: "useChildRenderer()",
74
+ returnShape: {
75
+ renderNode: "(node) => ReactElement",
76
+ },
77
+ requiredContextSlice: ["renderer.renderNode"],
78
+ scopes: null,
79
+ },
71
80
  {
72
81
  name: "useNavigation",
73
82
  signature: "useNavigation()",
@@ -161,6 +170,76 @@ const HOOKS = [
161
170
  requiredContextSlice: ["payments.requestPayment"],
162
171
  scopes: ["payments.charge:appUser"],
163
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
+ },
164
243
  ];
165
244
 
166
245
  // REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
@@ -407,6 +486,12 @@ const WIDGET_CONTEXT_SHAPE = {
407
486
  required: true,
408
487
  fields: { get: "function" },
409
488
  },
489
+ renderer: {
490
+ description:
491
+ "Host child-node renderer. { renderNode(node) -> ReactElement }. Backs WidgetTree / useChildRenderer; lets a container widget (Tabs, Card, …) render arbitrary author-authored child nodes without importing the host renderer. The closure pre-binds breakpoint + page ctx + parent so the widget passes only the child node.",
492
+ required: true,
493
+ fields: { renderNode: "function" },
494
+ },
410
495
  events: {
411
496
  description: "{ emit(name, payload) }.",
412
497
  required: true,
@@ -418,6 +503,38 @@ const WIDGET_CONTEXT_SHAPE = {
418
503
  required: true,
419
504
  fields: { requestPayment: "function", getPayment: "function" },
420
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
+ },
421
538
  i18n: {
422
539
  description: "{ t(key, fallback?), locale }.",
423
540
  required: true,
@@ -510,7 +627,7 @@ function deepFreeze(value) {
510
627
  }
511
628
 
512
629
  const CONTRACT = deepFreeze({
513
- version: "1.2.0",
630
+ version: "1.4.0",
514
631
  hooks: HOOKS,
515
632
  primitives: PRIMITIVES,
516
633
  manifestSchema: MANIFEST_SCHEMA,
package/dist/contract.js CHANGED
@@ -68,6 +68,15 @@ const HOOKS = [
68
68
  requiredContextSlice: ["user"],
69
69
  scopes: null,
70
70
  },
71
+ {
72
+ name: "useChildRenderer",
73
+ signature: "useChildRenderer()",
74
+ returnShape: {
75
+ renderNode: "(node) => ReactElement",
76
+ },
77
+ requiredContextSlice: ["renderer.renderNode"],
78
+ scopes: null,
79
+ },
71
80
  {
72
81
  name: "useNavigation",
73
82
  signature: "useNavigation()",
@@ -162,6 +171,76 @@ const HOOKS = [
162
171
  requiredContextSlice: ["payments.requestPayment"],
163
172
  scopes: ["payments.charge:appUser"],
164
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
+ },
165
244
  ];
166
245
 
167
246
  // REQ-WSDK-RN-WEB: see contract.cjs for the source-of-truth comment.
@@ -401,6 +480,12 @@ const WIDGET_CONTEXT_SHAPE = {
401
480
  required: true,
402
481
  fields: { get: "function" },
403
482
  },
483
+ renderer: {
484
+ description:
485
+ "Host child-node renderer. { renderNode(node) -> ReactElement }. Backs WidgetTree / useChildRenderer; lets a container widget (Tabs, Card, …) render arbitrary author-authored child nodes without importing the host renderer. The closure pre-binds breakpoint + page ctx + parent so the widget passes only the child node.",
486
+ required: true,
487
+ fields: { renderNode: "function" },
488
+ },
404
489
  events: {
405
490
  description: "{ emit(name, payload) }.",
406
491
  required: true,
@@ -412,6 +497,38 @@ const WIDGET_CONTEXT_SHAPE = {
412
497
  required: true,
413
498
  fields: { requestPayment: "function", getPayment: "function" },
414
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
+ },
415
532
  i18n: {
416
533
  description: "{ t(key, fallback?), locale }.",
417
534
  required: true,
@@ -502,7 +619,7 @@ function deepFreeze(value) {
502
619
  }
503
620
 
504
621
  const CONTRACT = deepFreeze({
505
- version: "1.2.0",
622
+ version: "1.4.0",
506
623
  hooks: HOOKS,
507
624
  primitives: PRIMITIVES,
508
625
  manifestSchema: MANIFEST_SCHEMA,
package/dist/hooks.js CHANGED
@@ -517,6 +517,46 @@ export function useUser() {
517
517
  return ctx.user;
518
518
  }
519
519
 
520
+ /**
521
+ * Returns the host's child-node renderer:
522
+ * { renderNode(node) }.
523
+ *
524
+ * Widgets like Tabs / Card / a custom container call `renderNode(child)`
525
+ * to render an author-authored page-tree node nested inside themselves.
526
+ * The renderer closes over the surrounding render context (breakpoint,
527
+ * page ctx, parent) that the host already knows, so the widget doesn't
528
+ * need to plumb any of that through.
529
+ *
530
+ * Prefer the `WidgetTree` component for the common case
531
+ * (`<WidgetTree node={child} />`); reach for the hook when you need to
532
+ * branch on the node's shape before rendering.
533
+ */
534
+ export function useChildRenderer() {
535
+ const ctx = useWidgetContextOrThrow("useChildRenderer");
536
+ if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
537
+ throw new Error(
538
+ "useChildRenderer: host did not inject a child-node renderer",
539
+ );
540
+ }
541
+ return ctx.renderer;
542
+ }
543
+
544
+ /**
545
+ * Renders an author-authored page-tree node through the host's child
546
+ * renderer. The widget hands off a node (or null) and gets back a React
547
+ * element rendered with the same dispatch the top-level page uses, so
548
+ * Tabs / Card / custom container widgets can host arbitrary child
549
+ * widgets without importing the host.
550
+ */
551
+ export function WidgetTree({ node }) {
552
+ const ctx = useWidgetContextOrThrow("WidgetTree");
553
+ if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
554
+ return null;
555
+ }
556
+ if (!node) return null;
557
+ return ctx.renderer.renderNode(node);
558
+ }
559
+
520
560
  /**
521
561
  * Returns the host-provided navigation surface:
522
562
  * `{ goTo, goBack, push, replace, back, currentRoute }`.
@@ -647,6 +687,236 @@ export function usePayments() {
647
687
  * the key is missing. The host's i18n.t may or may not honour the two-arg
648
688
  * form; we degrade gracefully either way.
649
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
+
650
920
  export function useI18n() {
651
921
  const ctx = useWidgetContextOrThrow("useI18n");
652
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"
@@ -382,6 +385,22 @@ export function useDatastoreRecord(
382
385
  * refetch }`. The `url` is an absolute URL composed against the host's API
383
386
  * base; safe to pass straight to `<Image source>`.
384
387
  */
388
+ /**
389
+ * The host's child-node renderer surface. `renderNode(node)` returns a
390
+ * React element rendered with the same dispatch the top-level page uses;
391
+ * the closure pre-binds breakpoint / page ctx / parent.
392
+ */
393
+ export function useChildRenderer(): {
394
+ renderNode(node: unknown): unknown;
395
+ };
396
+
397
+ /**
398
+ * Renders an author-authored page-tree node through the host's child
399
+ * renderer. Prefer this over `useChildRenderer()` for the common case
400
+ * (`<WidgetTree node={child} />`).
401
+ */
402
+ export const WidgetTree: (props: { node: unknown }) => unknown;
403
+
385
404
  export function useFile(fileId: string | null | undefined): {
386
405
  url: string | null;
387
406
  file: {
@@ -464,6 +483,109 @@ export class DatastoreError extends Error {
464
483
  );
465
484
  }
466
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
+
467
589
  export function WidgetContextProvider(props: {
468
590
  value: WidgetContext;
469
591
  children?: ReactNode;
package/dist/index.js CHANGED
@@ -9,17 +9,22 @@ 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,
20
23
  useI18n,
21
24
  useUser,
22
25
  useNavigation,
26
+ useChildRenderer,
27
+ WidgetTree,
23
28
  } from "./hooks.js";
24
29
  export {
25
30
  Text,
@@ -9,17 +9,22 @@ 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,
20
23
  useI18n,
21
24
  useUser,
22
25
  useNavigation,
26
+ useChildRenderer,
27
+ WidgetTree,
23
28
  } from "./hooks.js";
24
29
  export {
25
30
  Text,
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.12.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"