@colixsystems/widget-sdk 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -3
- package/dist/contract.cjs +64 -1
- package/dist/contract.js +71 -4
- package/dist/hooks.js +226 -0
- package/dist/index.d.ts +92 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,9 +6,16 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
6
6
|
|
|
7
7
|
## Status
|
|
8
8
|
|
|
9
|
-
`v0.
|
|
9
|
+
`v0.18.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**.
|
|
10
10
|
|
|
11
|
-
### What's new in 0.
|
|
11
|
+
### What's new in 0.18.0
|
|
12
|
+
|
|
13
|
+
REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission management from inside a widget.
|
|
14
|
+
|
|
15
|
+
- **`useRecordPermissions(tableId, recordId)` is wired** + the new `WidgetContext.recordPermissions` slice + the new `acl.write:records` scope. Returns `{ permissions, loading, error, grant, revoke, update, refetch }` where `permissions` is `Array<{ id, principalType: "USER" | "GROUP" | "PUBLIC", principalId, canRead, canWrite, canDelete, canGrant }>`. Hits the existing REQ-ACL-06 `/api/v1/tables/:tableId/records/:recordId/permissions` REST surface; the host normalises the wire shape (`userId` / `groupId`) into the `principalType` + `principalId` pair the hook returns. Rejections from `grant` / `revoke` / `update` surface as a new structured `PermissionError` (named export) with `code` ∈ `FORBIDDEN | VALIDATION | NOT_FOUND | CONFLICT | INTERNAL`. When `tableId` or `recordId` is null/empty the hook collapses to a stable empty no-op result without a network round-trip. Mutating requires `acl.write:records` AND `canGrant` on the target record — Studio owners short-circuit, APP_USER actors hold `canGrant` via REQ-ACL-05 (creator-grant) or a delegated REQ-ACL-06 grant. Backs the rewritten built-in Chat widget's "+ New channel" + DM-create flows, where the channel creator grants `canRead` / `canWrite` / `canGrant` to each invitee without going through Studio. Additive.
|
|
16
|
+
- **`CONTRACT.version` → `1.8.0`** (additive: one new hook, one new context slice, one new scope, one new error class). No existing export changed signature.
|
|
17
|
+
|
|
18
|
+
### What was in 0.17.0
|
|
12
19
|
|
|
13
20
|
REQ-WBLT-FORMBUILDER — a runtime schema resolver so widgets can render by column type.
|
|
14
21
|
|
|
@@ -132,7 +139,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
132
139
|
|
|
133
140
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
134
141
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
135
|
-
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `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). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] }` (structure only, no row data) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `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).
|
|
142
|
+
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `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). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] }` (structure only, no row data) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `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).
|
|
136
143
|
- `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.
|
|
137
144
|
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`, `DateTimePicker` wraps `@react-native-community/datetimepicker`). 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. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
|
|
138
145
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
@@ -184,6 +191,59 @@ The matching manifest declares the scopes:
|
|
|
184
191
|
|
|
185
192
|
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.
|
|
186
193
|
|
|
194
|
+
## Managing per-record permissions from a widget
|
|
195
|
+
|
|
196
|
+
REQ-ACL-06 / REQ-ACL-RELINHERIT-05 added `useRecordPermissions(tableId, recordId)`, the in-app surface for sharing a single record with another user or group. The chat widget uses it to invite members into a channel — the channel record's per-record grants ARE the membership list (Messages inherit those grants via REQ-ACL-RELINHERIT). The hook also covers project-workspace, document-sharing, and team-roster widgets that grant access on a record-by-record basis.
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
import { Text, View, Pressable, useRecordPermissions, PermissionError } from "@colixsystems/widget-sdk";
|
|
200
|
+
|
|
201
|
+
export default function ShareRecord({ tableId, recordId, partnerUserId }) {
|
|
202
|
+
const { permissions, loading, grant, revoke } = useRecordPermissions(tableId, recordId);
|
|
203
|
+
if (loading) return <Text>Loading members…</Text>;
|
|
204
|
+
return (
|
|
205
|
+
<View>
|
|
206
|
+
{permissions.map((p) => (
|
|
207
|
+
<View key={p.id}>
|
|
208
|
+
<Text>{p.principalType}:{p.principalId} — {p.canWrite ? "writer" : "reader"}</Text>
|
|
209
|
+
<Pressable onPress={() => revoke(p.id)}><Text>Remove</Text></Pressable>
|
|
210
|
+
</View>
|
|
211
|
+
))}
|
|
212
|
+
<Pressable
|
|
213
|
+
onPress={async () => {
|
|
214
|
+
try {
|
|
215
|
+
await grant({
|
|
216
|
+
principalType: "USER",
|
|
217
|
+
principalId: partnerUserId,
|
|
218
|
+
canRead: true,
|
|
219
|
+
canWrite: true,
|
|
220
|
+
canGrant: true,
|
|
221
|
+
});
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (err instanceof PermissionError && err.code === "FORBIDDEN") {
|
|
224
|
+
// The signed-in user lacks canGrant on this record.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
<Text>Invite partner</Text>
|
|
230
|
+
</Pressable>
|
|
231
|
+
</View>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The manifest declares the matching scope:
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
{
|
|
240
|
+
// ...
|
|
241
|
+
requestedScopes: ["acl.write:records"],
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The server-side gate is `canGrant` on the target record — Studio owners short-circuit, and APP_USER actors who hold `canGrant` (via REQ-ACL-05 creator-grant, or a delegated REQ-ACL-06 grant) pass. A caller without `canGrant` receives `PermissionError { code: "FORBIDDEN" }`. The hook collapses to a stable no-op when `tableId` or `recordId` is null/empty — so a widget can render its picker first, then bind to the picked record without any conditional hook tricks.
|
|
246
|
+
|
|
187
247
|
## Linter
|
|
188
248
|
|
|
189
249
|
```sh
|
package/dist/contract.cjs
CHANGED
|
@@ -264,6 +264,41 @@ const HOOKS = [
|
|
|
264
264
|
],
|
|
265
265
|
scopes: ["groups.read:*"],
|
|
266
266
|
},
|
|
267
|
+
// REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
|
|
268
|
+
// management for a single record. Mirror of contract.js.
|
|
269
|
+
{
|
|
270
|
+
name: "useRecordPermissions",
|
|
271
|
+
signature: "useRecordPermissions(tableId, recordId)",
|
|
272
|
+
description:
|
|
273
|
+
"Manage per-record VirtualPermission grants on a single record. Returns " +
|
|
274
|
+
"{ permissions, loading, error, grant, revoke, update, refetch } where " +
|
|
275
|
+
"permissions is Array<{ id, principalType: 'USER' | 'GROUP' | 'PUBLIC', " +
|
|
276
|
+
"principalId, canRead, canWrite, canDelete, canGrant }>. Mutating " +
|
|
277
|
+
"requires acl.write:records scope AND canGrant on the target record " +
|
|
278
|
+
"(REQ-ACL-RELINHERIT-05: APP_USER actors with canGrant are accepted, " +
|
|
279
|
+
"not only Studio owners). When tableId or recordId is null/empty the " +
|
|
280
|
+
"hook collapses to an empty no-op result without a network round-trip.",
|
|
281
|
+
returnShape: {
|
|
282
|
+
permissions:
|
|
283
|
+
"Array<{ id, principalType: 'USER' | 'GROUP' | 'PUBLIC', principalId, canRead, canWrite, canDelete, canGrant }>",
|
|
284
|
+
loading: "boolean",
|
|
285
|
+
error: "PermissionError | null",
|
|
286
|
+
grant:
|
|
287
|
+
"({ principalType, principalId?, canRead?, canWrite?, canDelete?, canGrant? }) => Promise<RecordPermission> // rejects with PermissionError",
|
|
288
|
+
revoke:
|
|
289
|
+
"(permissionId) => Promise<void> // rejects with PermissionError",
|
|
290
|
+
update:
|
|
291
|
+
"(permissionId, { canRead?, canWrite?, canDelete?, canGrant? }) => Promise<RecordPermission> // rejects with PermissionError",
|
|
292
|
+
refetch: "() => Promise<void>",
|
|
293
|
+
},
|
|
294
|
+
requiredContextSlice: [
|
|
295
|
+
"recordPermissions.list",
|
|
296
|
+
"recordPermissions.grant",
|
|
297
|
+
"recordPermissions.revoke",
|
|
298
|
+
"recordPermissions.update",
|
|
299
|
+
],
|
|
300
|
+
scopes: ["acl.write:records"],
|
|
301
|
+
},
|
|
267
302
|
// REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
|
|
268
303
|
{
|
|
269
304
|
name: "useClipboard",
|
|
@@ -596,6 +631,26 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
596
631
|
remove: "function",
|
|
597
632
|
},
|
|
598
633
|
},
|
|
634
|
+
// REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
|
|
635
|
+
// management facade backing useRecordPermissions(). Mirror of
|
|
636
|
+
// contract.js.
|
|
637
|
+
recordPermissions: {
|
|
638
|
+
description:
|
|
639
|
+
"Per-record VirtualPermission management. " +
|
|
640
|
+
"{ list(tableId, recordId) -> Promise<RecordPermission[]>, " +
|
|
641
|
+
"grant(tableId, recordId, body) -> Promise<RecordPermission>, " +
|
|
642
|
+
"revoke(tableId, recordId, permissionId) -> Promise<void>, " +
|
|
643
|
+
"update(tableId, recordId, permissionId, body) -> Promise<RecordPermission> }. " +
|
|
644
|
+
"Backs useRecordPermissions(); requires acl.write:records scope AND " +
|
|
645
|
+
"canGrant on the target record.",
|
|
646
|
+
required: true,
|
|
647
|
+
fields: {
|
|
648
|
+
list: "function",
|
|
649
|
+
grant: "function",
|
|
650
|
+
revoke: "function",
|
|
651
|
+
update: "function",
|
|
652
|
+
},
|
|
653
|
+
},
|
|
599
654
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration facade
|
|
600
655
|
// backing useGroups(). Reads gated by `groups.read:*`; mutations by
|
|
601
656
|
// `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as users.
|
|
@@ -865,7 +920,15 @@ const CONTRACT = deepFreeze({
|
|
|
865
920
|
// stored columnId to its column name / dataType / relation target at
|
|
866
921
|
// runtime (Form Builder renders inputs by column type). Reads the existing
|
|
867
922
|
// ACL-gated `GET /tables/:id` — structure only, no row data.
|
|
868
|
-
|
|
923
|
+
//
|
|
924
|
+
// 1.8.0: additive — new `useRecordPermissions(tableId, recordId)` hook +
|
|
925
|
+
// the `recordPermissions` host-context slice it reads + the
|
|
926
|
+
// `acl.write:records` scope it gates on. Hits the existing REQ-ACL-06
|
|
927
|
+
// /api/v1/tables/:tableId/records/:recordId/permissions REST surface; the
|
|
928
|
+
// host normalises the wire shape into a single principalType+principalId
|
|
929
|
+
// pair widgets branch on. Caller still needs canGrant on the target
|
|
930
|
+
// record (REQ-ACL-RELINHERIT-05).
|
|
931
|
+
version: "1.8.0",
|
|
869
932
|
hooks: HOOKS,
|
|
870
933
|
primitives: PRIMITIVES,
|
|
871
934
|
manifestSchema: MANIFEST_SCHEMA,
|
package/dist/contract.js
CHANGED
|
@@ -265,6 +265,47 @@ const HOOKS = [
|
|
|
265
265
|
],
|
|
266
266
|
scopes: ["groups.read:*"],
|
|
267
267
|
},
|
|
268
|
+
// REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
|
|
269
|
+
// management for a single record. Reads `ctx.recordPermissions` (the
|
|
270
|
+
// host injects this on web through `widgetHostDatastore.js` and on
|
|
271
|
+
// native through the compiled `WidgetHost` shell). The backend gates
|
|
272
|
+
// the call on `canGrant` for the target record (Studio owners short-
|
|
273
|
+
// circuit; APP_USER actors must hold `canGrant` via REQ-ACL-05 /
|
|
274
|
+
// REQ-ACL-06). A widget that declares the scope but whose caller
|
|
275
|
+
// lacks the grant receives `PermissionError { code: 'FORBIDDEN' }`.
|
|
276
|
+
{
|
|
277
|
+
name: "useRecordPermissions",
|
|
278
|
+
signature: "useRecordPermissions(tableId, recordId)",
|
|
279
|
+
description:
|
|
280
|
+
"Manage per-record VirtualPermission grants on a single record. Returns " +
|
|
281
|
+
"{ permissions, loading, error, grant, revoke, update, refetch } where " +
|
|
282
|
+
"permissions is Array<{ id, principalType: 'USER' | 'GROUP' | 'PUBLIC', " +
|
|
283
|
+
"principalId, canRead, canWrite, canDelete, canGrant }>. Mutating " +
|
|
284
|
+
"requires acl.write:records scope AND canGrant on the target record " +
|
|
285
|
+
"(REQ-ACL-RELINHERIT-05: APP_USER actors with canGrant are accepted, " +
|
|
286
|
+
"not only Studio owners). When tableId or recordId is null/empty the " +
|
|
287
|
+
"hook collapses to an empty no-op result without a network round-trip.",
|
|
288
|
+
returnShape: {
|
|
289
|
+
permissions:
|
|
290
|
+
"Array<{ id, principalType: 'USER' | 'GROUP' | 'PUBLIC', principalId, canRead, canWrite, canDelete, canGrant }>",
|
|
291
|
+
loading: "boolean",
|
|
292
|
+
error: "PermissionError | null",
|
|
293
|
+
grant:
|
|
294
|
+
"({ principalType, principalId?, canRead?, canWrite?, canDelete?, canGrant? }) => Promise<RecordPermission> // rejects with PermissionError",
|
|
295
|
+
revoke:
|
|
296
|
+
"(permissionId) => Promise<void> // rejects with PermissionError",
|
|
297
|
+
update:
|
|
298
|
+
"(permissionId, { canRead?, canWrite?, canDelete?, canGrant? }) => Promise<RecordPermission> // rejects with PermissionError",
|
|
299
|
+
refetch: "() => Promise<void>",
|
|
300
|
+
},
|
|
301
|
+
requiredContextSlice: [
|
|
302
|
+
"recordPermissions.list",
|
|
303
|
+
"recordPermissions.grant",
|
|
304
|
+
"recordPermissions.revoke",
|
|
305
|
+
"recordPermissions.update",
|
|
306
|
+
],
|
|
307
|
+
scopes: ["acl.write:records"],
|
|
308
|
+
},
|
|
268
309
|
// REQ-WSDK-PLATFORM §6 — Tier A SDK hooks.
|
|
269
310
|
{
|
|
270
311
|
name: "useClipboard",
|
|
@@ -590,6 +631,28 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
590
631
|
remove: "function",
|
|
591
632
|
},
|
|
592
633
|
},
|
|
634
|
+
// REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
|
|
635
|
+
// management facade backing useRecordPermissions(). Hits the existing
|
|
636
|
+
// /api/v1/tables/:tableId/records/:recordId/permissions REST surface;
|
|
637
|
+
// the host normalises the wire shape (userId / groupId / both-null =
|
|
638
|
+
// public) into the principalType+principalId pair widgets read.
|
|
639
|
+
recordPermissions: {
|
|
640
|
+
description:
|
|
641
|
+
"Per-record VirtualPermission management. " +
|
|
642
|
+
"{ list(tableId, recordId) -> Promise<RecordPermission[]>, " +
|
|
643
|
+
"grant(tableId, recordId, body) -> Promise<RecordPermission>, " +
|
|
644
|
+
"revoke(tableId, recordId, permissionId) -> Promise<void>, " +
|
|
645
|
+
"update(tableId, recordId, permissionId, body) -> Promise<RecordPermission> }. " +
|
|
646
|
+
"Backs useRecordPermissions(); requires acl.write:records scope AND " +
|
|
647
|
+
"canGrant on the target record.",
|
|
648
|
+
required: true,
|
|
649
|
+
fields: {
|
|
650
|
+
list: "function",
|
|
651
|
+
grant: "function",
|
|
652
|
+
revoke: "function",
|
|
653
|
+
update: "function",
|
|
654
|
+
},
|
|
655
|
+
},
|
|
593
656
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration facade
|
|
594
657
|
// backing useGroups(). Reads gated by `groups.read:*`; mutations by
|
|
595
658
|
// `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as users.
|
|
@@ -810,10 +873,14 @@ function deepFreeze(value) {
|
|
|
810
873
|
}
|
|
811
874
|
|
|
812
875
|
const CONTRACT = deepFreeze({
|
|
813
|
-
// 1.
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
|
|
876
|
+
// 1.8.0: additive — new useRecordPermissions(tableId, recordId) hook +
|
|
877
|
+
// the recordPermissions host-context slice it reads + the
|
|
878
|
+
// acl.write:records scope it gates on. Hits the existing REQ-ACL-06
|
|
879
|
+
// /api/v1/tables/:tableId/records/:recordId/permissions REST surface;
|
|
880
|
+
// the host normalises the wire shape into a single principalType+
|
|
881
|
+
// principalId pair widgets branch on. Caller still needs canGrant on
|
|
882
|
+
// the target record (REQ-ACL-RELINHERIT-05).
|
|
883
|
+
version: "1.8.0",
|
|
817
884
|
hooks: HOOKS,
|
|
818
885
|
primitives: PRIMITIVES,
|
|
819
886
|
manifestSchema: MANIFEST_SCHEMA,
|
package/dist/hooks.js
CHANGED
|
@@ -994,6 +994,232 @@ export function useGroups(query) {
|
|
|
994
994
|
return { groups, loading, error, refetch, create, remove, addMember, removeMember };
|
|
995
995
|
}
|
|
996
996
|
|
|
997
|
+
/**
|
|
998
|
+
* REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — structured error thrown by
|
|
999
|
+
* `useRecordPermissions` mutation callbacks (and surfaced by the hook's
|
|
1000
|
+
* `error` slot when the initial list fetch fails). Carries a stable
|
|
1001
|
+
* `code` so widgets can branch on the error class without parsing
|
|
1002
|
+
* message strings; mirrors the shape of `DirectoryError`.
|
|
1003
|
+
*
|
|
1004
|
+
* `code` is one of:
|
|
1005
|
+
* - "FORBIDDEN" — 403 from the host (caller lacks canGrant on the record).
|
|
1006
|
+
* - "VALIDATION" — 400 / 422 (missing principal, malformed body).
|
|
1007
|
+
* - "NOT_FOUND" — 404 (record absent, cross-tenant, or permission row
|
|
1008
|
+
* not found on revoke/update).
|
|
1009
|
+
* - "CONFLICT" — 409 (e.g. trying to edit a template-derived row).
|
|
1010
|
+
* - "INTERNAL" — anything else (network, 5xx).
|
|
1011
|
+
*/
|
|
1012
|
+
export class PermissionError extends Error {
|
|
1013
|
+
constructor(code, message, opts) {
|
|
1014
|
+
super(message);
|
|
1015
|
+
this.name = "PermissionError";
|
|
1016
|
+
this.code = code;
|
|
1017
|
+
if (opts && opts.status !== undefined) this.status = opts.status;
|
|
1018
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function toPermissionError(err) {
|
|
1023
|
+
if (err instanceof PermissionError) return err;
|
|
1024
|
+
const status =
|
|
1025
|
+
err && err.response && typeof err.response.status === "number"
|
|
1026
|
+
? err.response.status
|
|
1027
|
+
: null;
|
|
1028
|
+
const bodyCode =
|
|
1029
|
+
err && err.response && err.response.data && err.response.data.code;
|
|
1030
|
+
const bodyMessage =
|
|
1031
|
+
err && err.response && err.response.data && err.response.data.error;
|
|
1032
|
+
let code = "INTERNAL";
|
|
1033
|
+
if (status === 403) code = "FORBIDDEN";
|
|
1034
|
+
else if (status === 404) code = "NOT_FOUND";
|
|
1035
|
+
else if (status === 409) code = "CONFLICT";
|
|
1036
|
+
else if (status === 400 || status === 422) code = "VALIDATION";
|
|
1037
|
+
if (typeof bodyCode === "string" && bodyCode) {
|
|
1038
|
+
// Preserve the server's stable code over the status-derived one when
|
|
1039
|
+
// the server volunteered it (e.g. TEMPLATE_DERIVED on edit/delete of
|
|
1040
|
+
// an inherit/template row).
|
|
1041
|
+
code = bodyCode;
|
|
1042
|
+
}
|
|
1043
|
+
const message =
|
|
1044
|
+
(typeof bodyMessage === "string" && bodyMessage) ||
|
|
1045
|
+
(err && typeof err.message === "string"
|
|
1046
|
+
? err.message
|
|
1047
|
+
: "Record permission call failed");
|
|
1048
|
+
return new PermissionError(code, message, { status, cause: err });
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const _NOOP_PERMISSIONS_RESULT = Object.freeze({
|
|
1052
|
+
permissions: [],
|
|
1053
|
+
loading: false,
|
|
1054
|
+
error: null,
|
|
1055
|
+
// Mutation no-ops resolve with null so a widget that does
|
|
1056
|
+
// `await grant(...)` doesn't crash when called against an unbound
|
|
1057
|
+
// (tableId / recordId null) hook.
|
|
1058
|
+
grant: async () => null,
|
|
1059
|
+
revoke: async () => undefined,
|
|
1060
|
+
update: async () => null,
|
|
1061
|
+
refetch: async () => undefined,
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — manage per-record VirtualPermission
|
|
1066
|
+
* grants on a single record. Returns
|
|
1067
|
+
* `{ permissions, loading, error, grant, revoke, update, refetch }`.
|
|
1068
|
+
*
|
|
1069
|
+
* `permissions` is an array of rows:
|
|
1070
|
+
* `{ id, principalType: "USER" | "GROUP" | "PUBLIC", principalId, canRead, canWrite, canDelete, canGrant }`.
|
|
1071
|
+
*
|
|
1072
|
+
* The host's `ctx.recordPermissions` facade exposes:
|
|
1073
|
+
* - `list(tableId, recordId)` → `Promise<row[]>`
|
|
1074
|
+
* - `grant(tableId, recordId, body)` → `Promise<row>`
|
|
1075
|
+
* - `revoke(tableId, recordId, permissionId)` → `Promise<void>`
|
|
1076
|
+
* - `update(tableId, recordId, permissionId, body)` → `Promise<row>`
|
|
1077
|
+
*
|
|
1078
|
+
* The hook normalises the wire shape (`userId` / `groupId` / both-null =
|
|
1079
|
+
* public) into a single `{ principalType, principalId }` pair that
|
|
1080
|
+
* widgets can branch on without knowing the backend's column names.
|
|
1081
|
+
*
|
|
1082
|
+
* When `tableId` OR `recordId` is null / undefined / empty (e.g. the
|
|
1083
|
+
* widget hasn't picked an active channel yet), the hook collapses to a
|
|
1084
|
+
* stable empty result without a network round-trip. Mutation methods
|
|
1085
|
+
* are safe no-ops in that state so a widget can call them
|
|
1086
|
+
* unconditionally.
|
|
1087
|
+
*
|
|
1088
|
+
* Requires the `acl.write:records` scope in the widget manifest's
|
|
1089
|
+
* `requestedScopes`. The underlying REST endpoint gates the call on
|
|
1090
|
+
* `canGrant` for the target record (Studio owners short-circuit;
|
|
1091
|
+
* APP_USER actors must hold `canGrant` via REQ-ACL-05 / REQ-ACL-06).
|
|
1092
|
+
* A widget that declares the scope but whose caller lacks the grant
|
|
1093
|
+
* receives `PermissionError { code: "FORBIDDEN" }`.
|
|
1094
|
+
*/
|
|
1095
|
+
export function useRecordPermissions(tableId, recordId) {
|
|
1096
|
+
const ctx = useWidgetContextOrThrow("useRecordPermissions");
|
|
1097
|
+
if (
|
|
1098
|
+
!ctx.recordPermissions ||
|
|
1099
|
+
typeof ctx.recordPermissions.list !== "function" ||
|
|
1100
|
+
typeof ctx.recordPermissions.grant !== "function" ||
|
|
1101
|
+
typeof ctx.recordPermissions.revoke !== "function" ||
|
|
1102
|
+
typeof ctx.recordPermissions.update !== "function"
|
|
1103
|
+
) {
|
|
1104
|
+
throw new Error(
|
|
1105
|
+
"useRecordPermissions: host did not inject a recordPermissions client",
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
const ready = Boolean(tableId && recordId);
|
|
1109
|
+
const [permissions, setPermissions] = useState([]);
|
|
1110
|
+
const [loading, setLoading] = useState(ready);
|
|
1111
|
+
const [error, setError] = useState(null);
|
|
1112
|
+
|
|
1113
|
+
// Same ref discipline as useDirectory / useDatastoreQuery — the host
|
|
1114
|
+
// rebuilds the WidgetContext value every render, so we capture the
|
|
1115
|
+
// live ids + client in refs to keep refetch + grant + revoke + update
|
|
1116
|
+
// stable callback identities.
|
|
1117
|
+
const tableIdRef = useRef(tableId);
|
|
1118
|
+
const recordIdRef = useRef(recordId);
|
|
1119
|
+
const clientRef = useRef(ctx.recordPermissions);
|
|
1120
|
+
tableIdRef.current = tableId;
|
|
1121
|
+
recordIdRef.current = recordId;
|
|
1122
|
+
clientRef.current = ctx.recordPermissions;
|
|
1123
|
+
|
|
1124
|
+
const runRef = useRef(0);
|
|
1125
|
+
|
|
1126
|
+
const doFetch = useCallback(async () => {
|
|
1127
|
+
const myRun = ++runRef.current;
|
|
1128
|
+
const t = tableIdRef.current;
|
|
1129
|
+
const r = recordIdRef.current;
|
|
1130
|
+
if (!t || !r) {
|
|
1131
|
+
// Collapse to a stable empty result without a network round-trip
|
|
1132
|
+
// so a widget rendering before an active record is picked stays
|
|
1133
|
+
// loop-free.
|
|
1134
|
+
setLoading(false);
|
|
1135
|
+
setError(null);
|
|
1136
|
+
setPermissions([]);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
setLoading(true);
|
|
1140
|
+
setError(null);
|
|
1141
|
+
try {
|
|
1142
|
+
const rows = await clientRef.current.list(t, r);
|
|
1143
|
+
if (runRef.current !== myRun) return;
|
|
1144
|
+
setPermissions(Array.isArray(rows) ? rows : []);
|
|
1145
|
+
setLoading(false);
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
if (runRef.current !== myRun) return;
|
|
1148
|
+
setError(toPermissionError(err));
|
|
1149
|
+
setLoading(false);
|
|
1150
|
+
}
|
|
1151
|
+
}, []);
|
|
1152
|
+
|
|
1153
|
+
useEffect(() => {
|
|
1154
|
+
if (!ready) {
|
|
1155
|
+
// Reset the slot when the widget unbinds (e.g. user navigates
|
|
1156
|
+
// back to the picker) so the next render doesn't show stale
|
|
1157
|
+
// permissions from a previous record.
|
|
1158
|
+
setPermissions([]);
|
|
1159
|
+
setLoading(false);
|
|
1160
|
+
setError(null);
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
doFetch();
|
|
1164
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1165
|
+
}, [tableId, recordId]);
|
|
1166
|
+
|
|
1167
|
+
const refetch = useCallback(async () => {
|
|
1168
|
+
await doFetch();
|
|
1169
|
+
}, [doFetch]);
|
|
1170
|
+
|
|
1171
|
+
const grant = useCallback(async (body) => {
|
|
1172
|
+
const t = tableIdRef.current;
|
|
1173
|
+
const r = recordIdRef.current;
|
|
1174
|
+
if (!t || !r) return null;
|
|
1175
|
+
try {
|
|
1176
|
+
const row = await clientRef.current.grant(t, r, body);
|
|
1177
|
+
// Refresh after the mutation so subsequent reads observe the
|
|
1178
|
+
// grant. The host's facade returns the created row too, which we
|
|
1179
|
+
// hand back to the caller for inline use.
|
|
1180
|
+
await doFetch();
|
|
1181
|
+
return row;
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
throw toPermissionError(err);
|
|
1184
|
+
}
|
|
1185
|
+
}, [doFetch]);
|
|
1186
|
+
|
|
1187
|
+
const revoke = useCallback(async (permissionId) => {
|
|
1188
|
+
const t = tableIdRef.current;
|
|
1189
|
+
const r = recordIdRef.current;
|
|
1190
|
+
if (!t || !r) return undefined;
|
|
1191
|
+
try {
|
|
1192
|
+
await clientRef.current.revoke(t, r, permissionId);
|
|
1193
|
+
await doFetch();
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
throw toPermissionError(err);
|
|
1196
|
+
}
|
|
1197
|
+
}, [doFetch]);
|
|
1198
|
+
|
|
1199
|
+
const update = useCallback(async (permissionId, body) => {
|
|
1200
|
+
const t = tableIdRef.current;
|
|
1201
|
+
const r = recordIdRef.current;
|
|
1202
|
+
if (!t || !r) return null;
|
|
1203
|
+
try {
|
|
1204
|
+
const row = await clientRef.current.update(t, r, permissionId, body);
|
|
1205
|
+
await doFetch();
|
|
1206
|
+
return row;
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
throw toPermissionError(err);
|
|
1209
|
+
}
|
|
1210
|
+
}, [doFetch]);
|
|
1211
|
+
|
|
1212
|
+
if (!ready) {
|
|
1213
|
+
// Mutation no-ops are safe: a widget that does
|
|
1214
|
+
// `await grant({...})` against an unbound hook resolves to null
|
|
1215
|
+
// rather than throwing. Same shape as the populated case so the
|
|
1216
|
+
// caller can branch on `tableId/recordId` once, at the top.
|
|
1217
|
+
return _NOOP_PERMISSIONS_RESULT;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
return { permissions, loading, error, grant, revoke, update, refetch };
|
|
1221
|
+
}
|
|
1222
|
+
|
|
997
1223
|
export function useI18n() {
|
|
998
1224
|
const ctx = useWidgetContextOrThrow("useI18n");
|
|
999
1225
|
const i18n = ctx.i18n || {};
|
package/dist/index.d.ts
CHANGED
|
@@ -642,6 +642,98 @@ export interface GroupsApi {
|
|
|
642
642
|
*/
|
|
643
643
|
export function useGroups(query?: GroupsQuery): GroupsApi;
|
|
644
644
|
|
|
645
|
+
// ----------------------------------------------------- useRecordPermissions
|
|
646
|
+
//
|
|
647
|
+
// REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
|
|
648
|
+
// management for a single record. Reuses the existing
|
|
649
|
+
// `/api/v1/tables/:tableId/records/:recordId/permissions` REST surface;
|
|
650
|
+
// the host normalises the wire shape (`userId` / `groupId` /
|
|
651
|
+
// both-null = public) into the `{ principalType, principalId }` pair
|
|
652
|
+
// returned to widgets so the call site is portable.
|
|
653
|
+
|
|
654
|
+
export interface RecordPermission {
|
|
655
|
+
id: string;
|
|
656
|
+
principalType: "USER" | "GROUP" | "PUBLIC";
|
|
657
|
+
/** `null` when `principalType === "PUBLIC"`. */
|
|
658
|
+
principalId: string | null;
|
|
659
|
+
canRead: boolean;
|
|
660
|
+
canWrite: boolean;
|
|
661
|
+
canDelete: boolean;
|
|
662
|
+
canGrant: boolean;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export interface RecordPermissionGrantInput {
|
|
666
|
+
/** Either a concrete principal (`USER` / `GROUP`) or `"PUBLIC"`. */
|
|
667
|
+
principalType: "USER" | "GROUP" | "PUBLIC";
|
|
668
|
+
/** Required when `principalType !== "PUBLIC"`. */
|
|
669
|
+
principalId?: string | null;
|
|
670
|
+
canRead?: boolean;
|
|
671
|
+
canWrite?: boolean;
|
|
672
|
+
canDelete?: boolean;
|
|
673
|
+
canGrant?: boolean;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export interface RecordPermissionUpdateInput {
|
|
677
|
+
canRead?: boolean;
|
|
678
|
+
canWrite?: boolean;
|
|
679
|
+
canDelete?: boolean;
|
|
680
|
+
canGrant?: boolean;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export interface RecordPermissionsResult {
|
|
684
|
+
permissions: RecordPermission[];
|
|
685
|
+
loading: boolean;
|
|
686
|
+
error: PermissionError | null;
|
|
687
|
+
/**
|
|
688
|
+
* Grant a new permission on the active record. Resolves to the created
|
|
689
|
+
* row. Rejects with `PermissionError` on HTTP failure.
|
|
690
|
+
*/
|
|
691
|
+
grant(body: RecordPermissionGrantInput): Promise<RecordPermission | null>;
|
|
692
|
+
/** Revoke an existing permission row. Rejects with `PermissionError`. */
|
|
693
|
+
revoke(permissionId: string): Promise<void>;
|
|
694
|
+
/**
|
|
695
|
+
* Patch the flags on an existing permission row. Resolves to the
|
|
696
|
+
* updated row. Rejects with `PermissionError`.
|
|
697
|
+
*/
|
|
698
|
+
update(
|
|
699
|
+
permissionId: string,
|
|
700
|
+
body: RecordPermissionUpdateInput,
|
|
701
|
+
): Promise<RecordPermission | null>;
|
|
702
|
+
refetch(): Promise<void>;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export class PermissionError extends Error {
|
|
706
|
+
code:
|
|
707
|
+
| "FORBIDDEN"
|
|
708
|
+
| "VALIDATION"
|
|
709
|
+
| "NOT_FOUND"
|
|
710
|
+
| "CONFLICT"
|
|
711
|
+
| "INTERNAL"
|
|
712
|
+
| string;
|
|
713
|
+
status?: number;
|
|
714
|
+
constructor(
|
|
715
|
+
code: PermissionError["code"],
|
|
716
|
+
message: string,
|
|
717
|
+
opts?: { status?: number; cause?: unknown },
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
|
|
723
|
+
* management. Requires `acl.write:records` in the manifest's
|
|
724
|
+
* `requestedScopes`. The backend gates the call on `canGrant` for the
|
|
725
|
+
* target record; a widget that declares the scope but whose caller
|
|
726
|
+
* lacks the grant receives `PermissionError { code: "FORBIDDEN" }`.
|
|
727
|
+
*
|
|
728
|
+
* When `tableId` OR `recordId` is null / empty, the hook collapses to a
|
|
729
|
+
* stable empty result without a network round-trip; mutation methods
|
|
730
|
+
* are safe no-ops in that state.
|
|
731
|
+
*/
|
|
732
|
+
export function useRecordPermissions(
|
|
733
|
+
tableId: string | null | undefined,
|
|
734
|
+
recordId: string | null | undefined,
|
|
735
|
+
): RecordPermissionsResult;
|
|
736
|
+
|
|
645
737
|
export function WidgetContextProvider(props: {
|
|
646
738
|
value: WidgetContext;
|
|
647
739
|
children?: ReactNode;
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
DatastoreError,
|
|
11
11
|
PaymentError,
|
|
12
12
|
DirectoryError,
|
|
13
|
+
PermissionError,
|
|
13
14
|
useDatastoreQuery,
|
|
14
15
|
useDatastoreRecord,
|
|
15
16
|
useDatastoreSchema,
|
|
@@ -18,6 +19,7 @@ export {
|
|
|
18
19
|
useDirectory,
|
|
19
20
|
useUsers,
|
|
20
21
|
useGroups,
|
|
22
|
+
useRecordPermissions,
|
|
21
23
|
useWidgetEvent,
|
|
22
24
|
usePayments,
|
|
23
25
|
useTheme,
|
package/dist/index.native.js
CHANGED
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
DatastoreError,
|
|
11
11
|
PaymentError,
|
|
12
12
|
DirectoryError,
|
|
13
|
+
PermissionError,
|
|
13
14
|
useDatastoreQuery,
|
|
14
15
|
useDatastoreRecord,
|
|
15
16
|
useDatastoreSchema,
|
|
@@ -18,6 +19,7 @@ export {
|
|
|
18
19
|
useDirectory,
|
|
19
20
|
useUsers,
|
|
20
21
|
useGroups,
|
|
22
|
+
useRecordPermissions,
|
|
21
23
|
useWidgetEvent,
|
|
22
24
|
usePayments,
|
|
23
25
|
useTheme,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
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 src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/linter-users-scope.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__/hooks-schema.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/linter-users-scope.test.js"
|
|
39
39
|
},
|
|
40
40
|
"engines": {
|
|
41
41
|
"node": ">=18"
|