@colixsystems/widget-sdk 0.16.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 +69 -2
- package/dist/contract.cjs +94 -3
- package/dist/contract.js +95 -3
- package/dist/hooks.js +301 -0
- package/dist/index.d.ts +145 -0
- package/dist/index.js +3 -0
- package/dist/index.native.js +3 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,7 +6,21 @@ 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
|
+
|
|
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
|
|
19
|
+
|
|
20
|
+
REQ-WBLT-FORMBUILDER — a runtime schema resolver so widgets can render by column type.
|
|
21
|
+
|
|
22
|
+
- **`useDatastoreSchema(tableId)` is wired** + the new `WidgetContext.datastore.schema` slice. Returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] }` (`null` until loaded). It reads the **existing** ACL-gated `GET /api/v1/tables/:id` — structure only, never row data — so a public-grant table resolves for anonymous visitors exactly like a record read. Use it to resolve a stored `columnId` to its column name / dataType / relation target at runtime (the built-in Form Builder uses it to render an input per column type). Reads need the `datastore.read:<table>` scope. Backed by a new `datastore.schema(tableId)` host facade (web: `widgetHostDatastore`; native: `hostDatastore` in the export's `widgetHost.js`). Additive.
|
|
23
|
+
- **`CONTRACT.version` → `1.7.0`** (additive: one new hook, one new `datastore.schema` context field). No existing export changed signature.
|
|
10
24
|
|
|
11
25
|
### What's new in 0.16.0
|
|
12
26
|
|
|
@@ -125,7 +139,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
125
139
|
|
|
126
140
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
127
141
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
128
|
-
- `useDatastoreQuery`, `useDatastoreRecord`, `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). `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).
|
|
129
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.
|
|
130
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.
|
|
131
145
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
@@ -177,6 +191,59 @@ The matching manifest declares the scopes:
|
|
|
177
191
|
|
|
178
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.
|
|
179
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
|
+
|
|
180
247
|
## Linter
|
|
181
248
|
|
|
182
249
|
```sh
|
package/dist/contract.cjs
CHANGED
|
@@ -130,6 +130,28 @@ const HOOKS = [
|
|
|
130
130
|
requiredContextSlice: ["datastore.records"],
|
|
131
131
|
scopes: ["datastore.read:*"],
|
|
132
132
|
},
|
|
133
|
+
{
|
|
134
|
+
name: "useDatastoreSchema",
|
|
135
|
+
signature: "useDatastoreSchema(tableId)",
|
|
136
|
+
description:
|
|
137
|
+
"Reads a table's structural schema — its columns and their types — so a " +
|
|
138
|
+
"widget can resolve a stored columnId to the column's name / dataType / " +
|
|
139
|
+
"relation target at runtime (e.g. a form that renders inputs by column " +
|
|
140
|
+
"type). Returns { schema, loading, error, refetch } where schema is " +
|
|
141
|
+
"{ id, name, columns: [{ id, name, dataType, required, relationType, " +
|
|
142
|
+
"targetTableId, isIdentification }] } (null until loaded). Reads only the " +
|
|
143
|
+
"structure, never row data; gated by the same datastore.read ACL as " +
|
|
144
|
+
"record reads, so a public-grant table resolves for anonymous visitors.",
|
|
145
|
+
returnShape: {
|
|
146
|
+
schema:
|
|
147
|
+
"{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] } | null",
|
|
148
|
+
loading: "boolean",
|
|
149
|
+
error: "DatastoreError | null",
|
|
150
|
+
refetch: "() => Promise<void>",
|
|
151
|
+
},
|
|
152
|
+
requiredContextSlice: ["datastore.schema"],
|
|
153
|
+
scopes: ["datastore.read:<table>"],
|
|
154
|
+
},
|
|
133
155
|
{
|
|
134
156
|
name: "useDatastoreMutation",
|
|
135
157
|
signature: "useDatastoreMutation(tableId)",
|
|
@@ -242,6 +264,41 @@ const HOOKS = [
|
|
|
242
264
|
],
|
|
243
265
|
scopes: ["groups.read:*"],
|
|
244
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
|
+
},
|
|
245
302
|
// REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
|
|
246
303
|
{
|
|
247
304
|
name: "useClipboard",
|
|
@@ -524,9 +581,9 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
524
581
|
},
|
|
525
582
|
datastore: {
|
|
526
583
|
description:
|
|
527
|
-
"{ records(table) -> { list(query?), get(id), create(values), update(id, values), delete(id) } }.",
|
|
584
|
+
"{ records(table) -> { list(query?), get(id), create(values), update(id, values), delete(id) }, schema(table) -> Promise<{ id, name, columns: [...] }> }. `records` backs the query/record/mutation hooks; `schema` backs useDatastoreSchema() and resolves a table's column structure (no row data).",
|
|
528
585
|
required: true,
|
|
529
|
-
fields: { records: "function" },
|
|
586
|
+
fields: { records: "function", schema: "function" },
|
|
530
587
|
},
|
|
531
588
|
directory: {
|
|
532
589
|
description:
|
|
@@ -574,6 +631,26 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
574
631
|
remove: "function",
|
|
575
632
|
},
|
|
576
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
|
+
},
|
|
577
654
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration facade
|
|
578
655
|
// backing useGroups(). Reads gated by `groups.read:*`; mutations by
|
|
579
656
|
// `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as users.
|
|
@@ -837,7 +914,21 @@ const CONTRACT = deepFreeze({
|
|
|
837
914
|
// so the tenant's Theme Settings "Secondary Color" flows through
|
|
838
915
|
// `useTheme().colors.secondary` (Button secondary variant + any widget
|
|
839
916
|
// that wants the brand's second accent).
|
|
840
|
-
|
|
917
|
+
//
|
|
918
|
+
// 1.7.0: additive — new `useDatastoreSchema(tableId)` hook + the
|
|
919
|
+
// `datastore.schema` host-context slice it reads. Lets a widget resolve a
|
|
920
|
+
// stored columnId to its column name / dataType / relation target at
|
|
921
|
+
// runtime (Form Builder renders inputs by column type). Reads the existing
|
|
922
|
+
// ACL-gated `GET /tables/:id` — structure only, no row data.
|
|
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",
|
|
841
932
|
hooks: HOOKS,
|
|
842
933
|
primitives: PRIMITIVES,
|
|
843
934
|
manifestSchema: MANIFEST_SCHEMA,
|
package/dist/contract.js
CHANGED
|
@@ -130,6 +130,28 @@ const HOOKS = [
|
|
|
130
130
|
requiredContextSlice: ["datastore.records"],
|
|
131
131
|
scopes: ["datastore.read:*"],
|
|
132
132
|
},
|
|
133
|
+
{
|
|
134
|
+
name: "useDatastoreSchema",
|
|
135
|
+
signature: "useDatastoreSchema(tableId)",
|
|
136
|
+
description:
|
|
137
|
+
"Reads a table's structural schema — its columns and their types — so a " +
|
|
138
|
+
"widget can resolve a stored columnId to the column's name / dataType / " +
|
|
139
|
+
"relation target at runtime (e.g. a form that renders inputs by column " +
|
|
140
|
+
"type). Returns { schema, loading, error, refetch } where schema is " +
|
|
141
|
+
"{ id, name, columns: [{ id, name, dataType, required, relationType, " +
|
|
142
|
+
"targetTableId, isIdentification }] } (null until loaded). Reads only the " +
|
|
143
|
+
"structure, never row data; gated by the same datastore.read ACL as " +
|
|
144
|
+
"record reads, so a public-grant table resolves for anonymous visitors.",
|
|
145
|
+
returnShape: {
|
|
146
|
+
schema:
|
|
147
|
+
"{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] } | null",
|
|
148
|
+
loading: "boolean",
|
|
149
|
+
error: "DatastoreError | null",
|
|
150
|
+
refetch: "() => Promise<void>",
|
|
151
|
+
},
|
|
152
|
+
requiredContextSlice: ["datastore.schema"],
|
|
153
|
+
scopes: ["datastore.read:<table>"],
|
|
154
|
+
},
|
|
133
155
|
{
|
|
134
156
|
name: "useDatastoreMutation",
|
|
135
157
|
signature: "useDatastoreMutation(tableId)",
|
|
@@ -243,6 +265,47 @@ const HOOKS = [
|
|
|
243
265
|
],
|
|
244
266
|
scopes: ["groups.read:*"],
|
|
245
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
|
+
},
|
|
246
309
|
// REQ-WSDK-PLATFORM §6 — Tier A SDK hooks.
|
|
247
310
|
{
|
|
248
311
|
name: "useClipboard",
|
|
@@ -518,9 +581,9 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
518
581
|
},
|
|
519
582
|
datastore: {
|
|
520
583
|
description:
|
|
521
|
-
"{ records(table) -> { list(query?), get(id), create(values), update(id, values), delete(id) } }.",
|
|
584
|
+
"{ records(table) -> { list(query?), get(id), create(values), update(id, values), delete(id) }, schema(table) -> Promise<{ id, name, columns: [...] }> }. `records` backs the query/record/mutation hooks; `schema` backs useDatastoreSchema() and resolves a table's column structure (no row data).",
|
|
522
585
|
required: true,
|
|
523
|
-
fields: { records: "function" },
|
|
586
|
+
fields: { records: "function", schema: "function" },
|
|
524
587
|
},
|
|
525
588
|
directory: {
|
|
526
589
|
description:
|
|
@@ -568,6 +631,28 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
568
631
|
remove: "function",
|
|
569
632
|
},
|
|
570
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
|
+
},
|
|
571
656
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration facade
|
|
572
657
|
// backing useGroups(). Reads gated by `groups.read:*`; mutations by
|
|
573
658
|
// `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as users.
|
|
@@ -788,7 +873,14 @@ function deepFreeze(value) {
|
|
|
788
873
|
}
|
|
789
874
|
|
|
790
875
|
const CONTRACT = deepFreeze({
|
|
791
|
-
|
|
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",
|
|
792
884
|
hooks: HOOKS,
|
|
793
885
|
primitives: PRIMITIVES,
|
|
794
886
|
manifestSchema: MANIFEST_SCHEMA,
|
package/dist/hooks.js
CHANGED
|
@@ -297,6 +297,81 @@ export function useDatastoreRecord(table, recordId) {
|
|
|
297
297
|
return { data, loading, error, refetch };
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Stateful table-schema resolver hook. Returns { schema, loading, error,
|
|
302
|
+
* refetch } where `schema` is `{ id, name, columns: [{ id, name, dataType,
|
|
303
|
+
* required, relationType, targetTableId, isIdentification }] }` (`null` until
|
|
304
|
+
* loaded).
|
|
305
|
+
*
|
|
306
|
+
* The host's datastore client exposes `schema(table)` which resolves to the
|
|
307
|
+
* table's structural metadata — columns and their types, NOT row data. Use it
|
|
308
|
+
* to resolve a stored `columnId` to its column name / dataType / relation
|
|
309
|
+
* target at runtime (e.g. a form that renders an input per column type). It
|
|
310
|
+
* reads the existing ACL-gated `GET /tables/:id`, so a public-grant table
|
|
311
|
+
* resolves for anonymous visitors exactly like a record read.
|
|
312
|
+
*
|
|
313
|
+
* When `tableId` is falsy (the author hasn't bound a `tableRef` yet) the hook
|
|
314
|
+
* collapses to { schema: null, loading: false, error: null, refetch } without
|
|
315
|
+
* a network round-trip. A 404 (table absent / not readable / cross-tenant)
|
|
316
|
+
* surfaces as a DatastoreError with `code: "NOT_FOUND"`.
|
|
317
|
+
*/
|
|
318
|
+
export function useDatastoreSchema(tableId) {
|
|
319
|
+
const ctx = useWidgetContextOrThrow("useDatastoreSchema");
|
|
320
|
+
if (!ctx.datastore || typeof ctx.datastore.schema !== "function") {
|
|
321
|
+
throw new Error(
|
|
322
|
+
"useDatastoreSchema: host did not inject a datastore schema client",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const ready = Boolean(tableId);
|
|
326
|
+
const [schema, setSchema] = useState(null);
|
|
327
|
+
const [loading, setLoading] = useState(ready);
|
|
328
|
+
const [error, setError] = useState(null);
|
|
329
|
+
|
|
330
|
+
// Same ref discipline as useDatastoreRecord — `ctx` is a fresh object
|
|
331
|
+
// identity on every host render, so we hold the live inputs in refs to
|
|
332
|
+
// keep `refetch` a stable callback.
|
|
333
|
+
const tableIdRef = useRef(tableId);
|
|
334
|
+
const schemaFnRef = useRef(ctx.datastore.schema);
|
|
335
|
+
tableIdRef.current = tableId;
|
|
336
|
+
schemaFnRef.current = ctx.datastore.schema;
|
|
337
|
+
|
|
338
|
+
const runRef = useRef(0);
|
|
339
|
+
|
|
340
|
+
const doFetch = useCallback(async () => {
|
|
341
|
+
const myRun = ++runRef.current;
|
|
342
|
+
const t = tableIdRef.current;
|
|
343
|
+
if (!t) {
|
|
344
|
+
setLoading(false);
|
|
345
|
+
setError(null);
|
|
346
|
+
setSchema(null);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
setLoading(true);
|
|
350
|
+
setError(null);
|
|
351
|
+
try {
|
|
352
|
+
const result = await schemaFnRef.current(t);
|
|
353
|
+
if (runRef.current !== myRun) return;
|
|
354
|
+
setSchema(result || null);
|
|
355
|
+
setLoading(false);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
if (runRef.current !== myRun) return;
|
|
358
|
+
setError(toDatastoreError(err));
|
|
359
|
+
setLoading(false);
|
|
360
|
+
}
|
|
361
|
+
}, []);
|
|
362
|
+
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
doFetch();
|
|
365
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
366
|
+
}, [tableId]);
|
|
367
|
+
|
|
368
|
+
const refetch = useCallback(async () => {
|
|
369
|
+
await doFetch();
|
|
370
|
+
}, [doFetch]);
|
|
371
|
+
|
|
372
|
+
return { schema, loading, error, refetch };
|
|
373
|
+
}
|
|
374
|
+
|
|
300
375
|
/**
|
|
301
376
|
* Stateful file-asset resolver hook. Returns { url, file, loading, error,
|
|
302
377
|
* refetch }.
|
|
@@ -919,6 +994,232 @@ export function useGroups(query) {
|
|
|
919
994
|
return { groups, loading, error, refetch, create, remove, addMember, removeMember };
|
|
920
995
|
}
|
|
921
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
|
+
|
|
922
1223
|
export function useI18n() {
|
|
923
1224
|
const ctx = useWidgetContextOrThrow("useI18n");
|
|
924
1225
|
const i18n = ctx.i18n || {};
|
package/dist/index.d.ts
CHANGED
|
@@ -383,6 +383,59 @@ export function useDatastoreRecord(
|
|
|
383
383
|
refetch(): Promise<void>;
|
|
384
384
|
};
|
|
385
385
|
|
|
386
|
+
/**
|
|
387
|
+
* One column in a table's schema, as returned by `useDatastoreSchema`.
|
|
388
|
+
* Structural metadata only — never row data.
|
|
389
|
+
*/
|
|
390
|
+
export interface DatastoreSchemaColumn {
|
|
391
|
+
id: string;
|
|
392
|
+
name: string;
|
|
393
|
+
dataType:
|
|
394
|
+
| "STRING"
|
|
395
|
+
| "TEXT"
|
|
396
|
+
| "NUMBER"
|
|
397
|
+
| "FLOAT"
|
|
398
|
+
| "BOOL"
|
|
399
|
+
| "DATE"
|
|
400
|
+
| "FILE"
|
|
401
|
+
| "STRING_ARRAY"
|
|
402
|
+
| "INT_ARRAY"
|
|
403
|
+
| "RELATION"
|
|
404
|
+
| "USER"
|
|
405
|
+
| "USER_GROUP";
|
|
406
|
+
required: boolean;
|
|
407
|
+
/** For RELATION columns only. */
|
|
408
|
+
relationType?: "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_MANY" | null;
|
|
409
|
+
/** For RELATION columns only — the id of the table this column points at. */
|
|
410
|
+
targetTableId?: string | null;
|
|
411
|
+
/** True when this column is the table's display/identification column. */
|
|
412
|
+
isIdentification?: boolean;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export interface DatastoreSchema {
|
|
416
|
+
id: string;
|
|
417
|
+
name: string;
|
|
418
|
+
columns: DatastoreSchemaColumn[];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export interface SchemaResult {
|
|
422
|
+
schema: DatastoreSchema | null;
|
|
423
|
+
loading: boolean;
|
|
424
|
+
error: DatastoreError | null;
|
|
425
|
+
refetch(): Promise<void>;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Stateful table-schema resolver hook. Returns `{ schema, loading, error,
|
|
430
|
+
* refetch }` where `schema` is the bound table's column structure (`null`
|
|
431
|
+
* until loaded). Reads the existing ACL-gated `GET /tables/:id` — structure
|
|
432
|
+
* only, no row data. Use it to resolve a stored `columnId` to its column
|
|
433
|
+
* name / dataType / relation target at runtime.
|
|
434
|
+
*/
|
|
435
|
+
export function useDatastoreSchema(
|
|
436
|
+
tableId: string | null | undefined,
|
|
437
|
+
): SchemaResult;
|
|
438
|
+
|
|
386
439
|
/**
|
|
387
440
|
* Stateful file-asset resolver hook. Returns `{ url, file, loading, error,
|
|
388
441
|
* refetch }`. The `url` is an absolute URL composed against the host's API
|
|
@@ -589,6 +642,98 @@ export interface GroupsApi {
|
|
|
589
642
|
*/
|
|
590
643
|
export function useGroups(query?: GroupsQuery): GroupsApi;
|
|
591
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
|
+
|
|
592
737
|
export function WidgetContextProvider(props: {
|
|
593
738
|
value: WidgetContext;
|
|
594
739
|
children?: ReactNode;
|
package/dist/index.js
CHANGED
|
@@ -10,13 +10,16 @@ export {
|
|
|
10
10
|
DatastoreError,
|
|
11
11
|
PaymentError,
|
|
12
12
|
DirectoryError,
|
|
13
|
+
PermissionError,
|
|
13
14
|
useDatastoreQuery,
|
|
14
15
|
useDatastoreRecord,
|
|
16
|
+
useDatastoreSchema,
|
|
15
17
|
useFile,
|
|
16
18
|
useDatastoreMutation,
|
|
17
19
|
useDirectory,
|
|
18
20
|
useUsers,
|
|
19
21
|
useGroups,
|
|
22
|
+
useRecordPermissions,
|
|
20
23
|
useWidgetEvent,
|
|
21
24
|
usePayments,
|
|
22
25
|
useTheme,
|
package/dist/index.native.js
CHANGED
|
@@ -10,13 +10,16 @@ export {
|
|
|
10
10
|
DatastoreError,
|
|
11
11
|
PaymentError,
|
|
12
12
|
DirectoryError,
|
|
13
|
+
PermissionError,
|
|
13
14
|
useDatastoreQuery,
|
|
14
15
|
useDatastoreRecord,
|
|
16
|
+
useDatastoreSchema,
|
|
15
17
|
useFile,
|
|
16
18
|
useDatastoreMutation,
|
|
17
19
|
useDirectory,
|
|
18
20
|
useUsers,
|
|
19
21
|
useGroups,
|
|
22
|
+
useRecordPermissions,
|
|
20
23
|
useWidgetEvent,
|
|
21
24
|
usePayments,
|
|
22
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__/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"
|