@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 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.16.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
9
+ `v0.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
- version: "1.6.0",
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
- version: "1.6.0",
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,
@@ -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.16.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"