@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 +61 -2
- package/dist/contract.cjs +103 -1
- package/dist/contract.js +103 -1
- package/dist/hooks.js +230 -0
- package/dist/index.d.ts +106 -0
- package/dist/index.js +3 -0
- package/dist/index.native.js +3 -0
- package/dist/linter.cjs +96 -1
- package/dist/linter.js +120 -1
- package/dist/property-schema.js +7 -0
- package/package.json +2 -2
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.
|
|
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.
|
|
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.
|
|
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,
|
package/dist/index.native.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,
|
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
|
-
|
|
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
|
}
|
package/dist/property-schema.js
CHANGED
|
@@ -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.
|
|
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"
|