@colixsystems/widget-sdk 0.18.0 → 0.19.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/dist/hooks.js CHANGED
@@ -8,6 +8,14 @@
8
8
  // exported app) own the runtime semantics — this file is the SDK surface
9
9
  // widgets call.
10
10
  //
11
+ // The hooks below are GROUPED by the domain client they read, so the
12
+ // four-client mapping is obvious at a glance:
13
+ // - CORE — WidgetContext + non-data hooks (no domain client)
14
+ // - DATASTORE — ctx.datastore (@colixsystems/datastore-client)
15
+ // - FILES — ctx.files (@colixsystems/files-client)
16
+ // - DIRECTORY — ctx.directory (@colixsystems/directory-client)
17
+ // - PAYMENTS — ctx.payments (@colixsystems/payments-client)
18
+ //
11
19
  // This file avoids JSX so it can ship as a plain .js without a transform.
12
20
 
13
21
  import React, {
@@ -22,6 +30,172 @@ import React, {
22
30
  /** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
23
31
  const HostWidgetContext = createContext(null);
24
32
 
33
+ /* ============================================================================
34
+ * CORE — WidgetContext + non-data hooks (no domain client)
35
+ *
36
+ * The provider, the context accessor, and the hooks that read host-provided
37
+ * state directly off the WidgetContext (events, theme, user, renderer,
38
+ * navigation, i18n) rather than through one of the four domain clients.
39
+ * ==========================================================================*/
40
+
41
+ /**
42
+ * Wraps children with the host-provided WidgetContext.
43
+ * The host (Studio/Player/native shell) builds the value and renders this provider.
44
+ */
45
+ export function WidgetContextProvider({ value, children }) {
46
+ return React.createElement(HostWidgetContext.Provider, { value }, children);
47
+ }
48
+
49
+ // Exported for ./toast.js + future hook modules outside hooks.js. Internal
50
+ // utility — widget code should not import this directly.
51
+ export function useWidgetContextOrThrow(hookName) {
52
+ const ctx = useContext(HostWidgetContext);
53
+ if (ctx == null) {
54
+ throw new Error(
55
+ `${hookName} must be used inside a WidgetContextProvider. ` +
56
+ `The host (Studio, Player, or exported app) is responsible for mounting it.`,
57
+ );
58
+ }
59
+ return ctx;
60
+ }
61
+
62
+ /**
63
+ * Emit a named widget event through ctx.events.emit. Page-level event
64
+ * bindings (subscribed by the host) decide what happens next.
65
+ */
66
+ export function useWidgetEvent(name) {
67
+ const ctx = useWidgetContextOrThrow("useWidgetEvent");
68
+ return useCallback((payload) => ctx.events.emit(name, payload), [ctx, name]);
69
+ }
70
+
71
+ /**
72
+ * Returns the host-provided theme tokens. The host guarantees every field
73
+ * documented in CONTRACT.themeTokens is present (defaults merged with
74
+ * tenant overrides), so widgets read `theme.colors.primary` without
75
+ * optional chaining.
76
+ */
77
+ export function useTheme() {
78
+ const ctx = useWidgetContextOrThrow("useTheme");
79
+ return ctx.workspace.theme;
80
+ }
81
+
82
+ /**
83
+ * Returns the active end-user identity VERBATIM from the host, e.g.
84
+ * `{ id, email, display_name, roles, group_ids }` (snake_case fields).
85
+ *
86
+ * `id` is `null` for anonymous visitors (and on the Studio canvas preview,
87
+ * which renders widgets as if signed-out so the public branch shows). The
88
+ * host (`buildHostWidgetContext` + the native `WidgetHost`) populates the
89
+ * fields; widgets read them directly. The current principal is host-provided
90
+ * — this hook does NOT read a data-client.
91
+ *
92
+ * Use this to render the signed-in user's name in a header, branch on
93
+ * roles, or stamp a created-by field.
94
+ */
95
+ export function useUser() {
96
+ const ctx = useWidgetContextOrThrow("useUser");
97
+ return ctx.user;
98
+ }
99
+
100
+ /**
101
+ * Returns the host's child-node renderer:
102
+ * { renderNode(node) }.
103
+ *
104
+ * Widgets like Tabs / Card / a custom container call `renderNode(child)`
105
+ * to render an author-authored page-tree node nested inside themselves.
106
+ * The renderer closes over the surrounding render context (breakpoint,
107
+ * page ctx, parent) that the host already knows, so the widget doesn't
108
+ * need to plumb any of that through.
109
+ *
110
+ * Prefer the `WidgetTree` component for the common case
111
+ * (`<WidgetTree node={child} />`); reach for the hook when you need to
112
+ * branch on the node's shape before rendering.
113
+ */
114
+ export function useChildRenderer() {
115
+ const ctx = useWidgetContextOrThrow("useChildRenderer");
116
+ if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
117
+ throw new Error(
118
+ "useChildRenderer: host did not inject a child-node renderer",
119
+ );
120
+ }
121
+ return ctx.renderer;
122
+ }
123
+
124
+ /**
125
+ * Renders an author-authored page-tree node through the host's child
126
+ * renderer. The widget hands off a node (or null) and gets back a React
127
+ * element rendered with the same dispatch the top-level page uses, so
128
+ * Tabs / Card / custom container widgets can host arbitrary child
129
+ * widgets without importing the host.
130
+ */
131
+ export function WidgetTree({ node }) {
132
+ const ctx = useWidgetContextOrThrow("WidgetTree");
133
+ if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
134
+ return null;
135
+ }
136
+ if (!node) return null;
137
+ return ctx.renderer.renderNode(node);
138
+ }
139
+
140
+ /**
141
+ * Returns the host-provided navigation surface:
142
+ * `{ goTo, goBack, push, replace, back, currentRoute }`.
143
+ *
144
+ * `goTo(pageId, params?)` navigates to an internal app page. `goBack()`
145
+ * pops the stack. `push` / `replace` / `back` are aliases that map to
146
+ * the platform's native navigator (react-router on web, react-navigation
147
+ * on native). Missing methods degrade to no-ops on the Studio canvas
148
+ * preview, where there is no live router.
149
+ *
150
+ * For EXTERNAL URLs use the `Linking` primitive — `Linking.openURL(url)`
151
+ * works on both platforms.
152
+ */
153
+ export function useNavigation() {
154
+ const ctx = useWidgetContextOrThrow("useNavigation");
155
+ return ctx.navigation;
156
+ }
157
+
158
+ /**
159
+ * Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
160
+ * the host's translation table and falls back to `fallback ?? key` when
161
+ * the key is missing. The host's i18n.t may or may not honour the two-arg
162
+ * form; we degrade gracefully either way.
163
+ */
164
+ export function useI18n() {
165
+ const ctx = useWidgetContextOrThrow("useI18n");
166
+ const i18n = ctx.i18n || {};
167
+ const locale = typeof i18n.locale === "string" ? i18n.locale : "en";
168
+ const hostT = typeof i18n.t === "function" ? i18n.t : null;
169
+ const t = useCallback(
170
+ (key, fallback) => {
171
+ if (typeof key !== "string" || !key) {
172
+ return typeof fallback === "string" ? fallback : "";
173
+ }
174
+ if (hostT) {
175
+ // Try the two-arg form first; if the host's `t` ignores `fallback`
176
+ // and returns the bare key on a miss, swap in the fallback.
177
+ const out = hostT(key, fallback);
178
+ if (typeof out === "string" && out.length > 0 && out !== key) {
179
+ return out;
180
+ }
181
+ // Host returned the key (unresolved) or nothing usable.
182
+ return typeof fallback === "string" ? fallback : key;
183
+ }
184
+ return typeof fallback === "string" ? fallback : key;
185
+ },
186
+ [hostT],
187
+ );
188
+ return { t, locale };
189
+ }
190
+
191
+ /* ============================================================================
192
+ * DATASTORE CLIENT — ctx.datastore (@colixsystems/datastore-client)
193
+ *
194
+ * Records, schema, aggregate, and record-level permissions. Covers:
195
+ * useDatastoreQuery, useDatastoreRecord, useDatastoreSchema,
196
+ * useDatastoreMutation, useRecordPermissions.
197
+ * ==========================================================================*/
198
+
25
199
  /**
26
200
  * Structured error thrown by `useDatastoreMutation` callbacks (and surfaced
27
201
  * by `useDatastoreQuery` in its `error` slot). Carries a stable `code` so
@@ -53,29 +227,6 @@ export class DatastoreError extends Error {
53
227
  }
54
228
  }
55
229
 
56
- /**
57
- * Wraps children with the host-provided WidgetContext.
58
- * The host (Studio/Player/native shell) builds the value and renders this provider.
59
- */
60
- export function WidgetContextProvider({ value, children }) {
61
- return React.createElement(HostWidgetContext.Provider, { value }, children);
62
- }
63
-
64
- // Exported for ./toast.js + future hook modules outside hooks.js. Internal
65
- // utility — widget code should not import this directly.
66
- export function useWidgetContextOrThrow(hookName) {
67
- const ctx = useContext(HostWidgetContext);
68
- if (ctx == null) {
69
- throw new Error(
70
- `${hookName} must be used inside a WidgetContextProvider. ` +
71
- `The host (Studio, Player, or exported app) is responsible for mounting it.`,
72
- );
73
- }
74
- return ctx;
75
- }
76
-
77
- // --------------------------------------------------------------- helpers
78
-
79
230
  /**
80
231
  * Coerce an arbitrary thrown value (axios error, plain Error, string) into
81
232
  * a DatastoreError with a stable `.code`. Reads `error.response.status` if
@@ -127,15 +278,16 @@ function toDatastoreError(err) {
127
278
  });
128
279
  }
129
280
 
130
- // --------------------------------------------------------------- hooks
131
-
132
281
  /**
133
282
  * Stateful datastore query hook. Returns { data, loading, error, refetch }.
134
283
  *
135
- * The host's datastore client exposes `records(table).list(query)` which
136
- * returns a Promise<Record[]>. We hold the result in component state and
137
- * re-fetch when [table, JSON.stringify(query)] changes. `refetch` re-runs
138
- * the same call on demand.
284
+ * The host injects a `@colixsystems/datastore-client` instance at
285
+ * `ctx.datastore`. We call `ctx.datastore.records(table).list(query)`, which
286
+ * resolves to the `{ data, meta }` list envelope VERBATIM (the client no
287
+ * longer unwraps to a bare array), so we read `res.data` (defaulting to `[]`)
288
+ * into component state. Author column values inside each row keep whatever
289
+ * the author named them. We re-fetch when [table, JSON.stringify(query)]
290
+ * changes. `refetch` re-runs the same call on demand.
139
291
  *
140
292
  * When `table` is falsy (e.g. the user hasn't bound a `tableRef` property
141
293
  * yet), the hook resolves to { data: [], loading: false, error: null,
@@ -189,10 +341,13 @@ export function useDatastoreQuery(table, query) {
189
341
  setError(null);
190
342
  try {
191
343
  const ns = recordsRef.current(t);
192
- const rows = await ns.list(queryRef.current);
344
+ const res = await ns.list(queryRef.current);
345
+ // The datastore-client returns the { data, meta } list envelope
346
+ // verbatim — unwrap to the rows array (default []).
347
+ const rows = res && Array.isArray(res.data) ? res.data : [];
193
348
  // Discard the result if a newer fetch has started since we kicked off.
194
349
  if (runRef.current !== myRun) return;
195
- setData(Array.isArray(rows) ? rows : []);
350
+ setData(rows);
196
351
  setLoading(false);
197
352
  } catch (err) {
198
353
  if (runRef.current !== myRun) return;
@@ -225,10 +380,12 @@ export function useDatastoreQuery(table, query) {
225
380
  * Stateful single-record query hook. Returns { data, loading, error, refetch }
226
381
  * where `data` is one row (`null` until loaded, never an array).
227
382
  *
228
- * The host's datastore client exposes `records(table).get(id)` which
229
- * resolves to a single record object. We hold the result in component
230
- * state and re-fetch when [table, id] changes. `refetch` re-runs the
231
- * call on demand.
383
+ * The host injects a `@colixsystems/datastore-client` instance at
384
+ * `ctx.datastore`. We call `ctx.datastore.records(table).get(id)`, which
385
+ * resolves to a single record object (no `{ data, meta }` envelope — `get`
386
+ * returns the row directly). Author column values keep whatever the author
387
+ * named them. We hold the result in component state and re-fetch when
388
+ * [table, id] changes. `refetch` re-runs the call on demand.
232
389
  *
233
390
  * When `table` OR `recordId` is falsy (e.g. the author hasn't bound the
234
391
  * record id yet), the hook resolves to { data: null, loading: false,
@@ -372,82 +529,27 @@ export function useDatastoreSchema(tableId) {
372
529
  return { schema, loading, error, refetch };
373
530
  }
374
531
 
375
- /**
376
- * Stateful file-asset resolver hook. Returns { url, file, loading, error,
377
- * refetch }.
378
- *
379
- * The host's file client exposes `files.get(fileId)` which resolves to
380
- * `{ url, ...meta }` — the absolute URL is composed against the host's
381
- * API base so the widget can drop it straight into an `<Image source>`
382
- * without knowing where the API lives. A missing/soft-deleted asset
383
- * surfaces as `{ url: null, error: <DatastoreError NOT_FOUND> }`.
384
- *
385
- * When `fileId` is falsy the hook collapses to { url: null, file: null,
386
- * loading: false, error: null, refetch } without a network round-trip,
387
- * so a widget rendering before the author has bound an asset stays
388
- * loop-free.
389
- */
390
- export function useFile(fileId) {
391
- const ctx = useWidgetContextOrThrow("useFile");
392
- if (!ctx.files || typeof ctx.files.get !== "function") {
393
- throw new Error("useFile: host did not inject a files client");
394
- }
395
- const ready = Boolean(fileId);
396
- const [file, setFile] = useState(null);
397
- const [loading, setLoading] = useState(ready);
398
- const [error, setError] = useState(null);
399
-
400
- const fileIdRef = useRef(fileId);
401
- const getRef = useRef(ctx.files.get);
402
- fileIdRef.current = fileId;
403
- getRef.current = ctx.files.get;
404
-
405
- const runRef = useRef(0);
406
-
407
- const doFetch = useCallback(async () => {
408
- const myRun = ++runRef.current;
409
- const id = fileIdRef.current;
410
- if (!id) {
411
- setLoading(false);
412
- setError(null);
413
- setFile(null);
414
- return;
415
- }
416
- setLoading(true);
417
- setError(null);
418
- try {
419
- const f = await getRef.current(id);
420
- if (runRef.current !== myRun) return;
421
- setFile(f || null);
422
- setLoading(false);
423
- } catch (err) {
424
- if (runRef.current !== myRun) return;
425
- setError(toDatastoreError(err));
426
- setLoading(false);
427
- }
428
- }, []);
429
-
430
- useEffect(() => {
431
- doFetch();
432
- // eslint-disable-next-line react-hooks/exhaustive-deps
433
- }, [fileId]);
434
-
435
- const refetch = useCallback(async () => {
436
- await doFetch();
437
- }, [doFetch]);
438
-
439
- const url =
440
- file && typeof file.url === "string" && file.url.length > 0
441
- ? file.url
442
- : null;
443
- return { url, file, loading, error, refetch };
444
- }
445
-
446
532
  /**
447
533
  * Datastore mutation hook. Returns { create, update, delete }, each method
448
- * returning a Promise. Rejected promises throw a DatastoreError carrying a
449
- * stable `.code` (`VALIDATION`, `CONSTRAINT_VIOLATION`, `FORBIDDEN`,
450
- * `NOT_FOUND`, `INTERNAL`) plus an optional `fieldErrors` map.
534
+ * returning a Promise. Routes through the injected
535
+ * `@colixsystems/datastore-client` at `ctx.datastore`:
536
+ * `ctx.datastore.records(table).{create,update,delete}`. `update` is a
537
+ * partial PATCH (the client issues PATCH), so callers pass only the changed
538
+ * fields. Values pass through verbatim (snake_case / author-named columns).
539
+ * Rejected promises throw a DatastoreError carrying a stable `.code`
540
+ * (`VALIDATION`, `CONSTRAINT_VIOLATION`, `FORBIDDEN`, `NOT_FOUND`,
541
+ * `INTERNAL`) plus an optional `fieldErrors` map.
542
+ *
543
+ * When `table` is falsy (the author hasn't bound a `tableRef` yet) the hook
544
+ * does NOT throw at render — it returns callbacks that reject with a
545
+ * `DatastoreError { code: "VALIDATION" }` only if actually invoked. This
546
+ * mirrors the guarded posture of `useDatastoreQuery` / `useDatastoreRecord`:
547
+ * a widget calls this hook unconditionally at the top of its body (Rules of
548
+ * Hooks) and renders its unbound empty state without crashing. The
549
+ * `records(table)` namespace is resolved lazily inside each callback — the
550
+ * client throws on an empty table, so building it eagerly at render would
551
+ * crash any widget mounted before its table is bound (the failure the Form
552
+ * Input widget hit).
451
553
  */
452
554
  export function useDatastoreMutation(table) {
453
555
  const ctx = useWidgetContextOrThrow("useDatastoreMutation");
@@ -456,314 +558,406 @@ export function useDatastoreMutation(table) {
456
558
  "useDatastoreMutation: host did not inject a datastore client",
457
559
  );
458
560
  }
459
- const ns = ctx.datastore.records(table);
460
- return {
461
- create: async (values) => {
561
+
562
+ // Same ref discipline as useDatastoreQuery — `ctx` is a fresh object
563
+ // identity on every host render, so we hold the live table + records
564
+ // factory in refs to keep the returned callbacks stable. We deliberately
565
+ // do NOT call `records(table)` here at render.
566
+ const tableRef = useRef(table);
567
+ const recordsRef = useRef(ctx.datastore.records);
568
+ tableRef.current = table;
569
+ recordsRef.current = ctx.datastore.records;
570
+
571
+ // Resolve records(table) lazily. Returns null when no table is bound so
572
+ // the callbacks reject with a clear VALIDATION error rather than letting
573
+ // the client's raw TypeError surface (or crashing at render).
574
+ const resolveNs = useCallback(() => {
575
+ const t = tableRef.current;
576
+ if (!t) return null;
577
+ return recordsRef.current(t);
578
+ }, []);
579
+
580
+ const create = useCallback(
581
+ async (values) => {
582
+ const ns = resolveNs();
583
+ if (!ns) {
584
+ throw new DatastoreError(
585
+ "VALIDATION",
586
+ "useDatastoreMutation: no table bound — set a source table before creating a record.",
587
+ );
588
+ }
462
589
  try {
463
590
  return await ns.create(values);
464
591
  } catch (err) {
465
592
  throw toDatastoreError(err);
466
593
  }
467
594
  },
468
- update: async (id, values) => {
595
+ [resolveNs],
596
+ );
597
+
598
+ const update = useCallback(
599
+ async (id, values) => {
600
+ const ns = resolveNs();
601
+ if (!ns) {
602
+ throw new DatastoreError(
603
+ "VALIDATION",
604
+ "useDatastoreMutation: no table bound — set a source table before updating a record.",
605
+ );
606
+ }
469
607
  try {
470
608
  return await ns.update(id, values);
471
609
  } catch (err) {
472
610
  throw toDatastoreError(err);
473
611
  }
474
612
  },
475
- delete: async (id) => {
613
+ [resolveNs],
614
+ );
615
+
616
+ const del = useCallback(
617
+ async (id) => {
618
+ const ns = resolveNs();
619
+ if (!ns) {
620
+ throw new DatastoreError(
621
+ "VALIDATION",
622
+ "useDatastoreMutation: no table bound — set a source table before deleting a record.",
623
+ );
624
+ }
476
625
  try {
477
626
  return await ns.delete(id);
478
627
  } catch (err) {
479
628
  throw toDatastoreError(err);
480
629
  }
481
630
  },
482
- };
483
- }
631
+ [resolveNs],
632
+ );
633
+
634
+ return { create, update, delete: del };
635
+ }
484
636
 
485
637
  /**
486
- * Stateful user-directory query hook. Returns { users, loading, error,
487
- * refetch }.
638
+ * REQ-ACL-06 / REQ-ACL-RELINHERIT-05 structured error thrown by
639
+ * `useRecordPermissions` mutation callbacks (and surfaced by the hook's
640
+ * `error` slot when the initial list fetch fails). Carries a stable
641
+ * `code` so widgets can branch on the error class without parsing
642
+ * message strings; mirrors the shape of `DirectoryError`.
488
643
  *
489
- * The host's directory client exposes `listUsers(query)` which resolves
490
- * to an array of `{ id, name, role }` rows — the privacy-reduced
491
- * directory projection the backend hands to non-Studio (Player) callers.
492
- * Use it to build a chat people-list, an @-mention picker, or to resolve
493
- * an author id to a display name. Mutating users is NOT part of this
494
- * surface; the directory is read-only from a widget.
644
+ * `code` is one of:
645
+ * - "FORBIDDEN" — 403 from the host (caller lacks canGrant on the record).
646
+ * - "VALIDATION" 400 / 422 (missing principal, malformed body).
647
+ * - "NOT_FOUND" — 404 (record absent, cross-tenant, or permission row
648
+ * not found on revoke/update).
649
+ * - "CONFLICT" — 409 (e.g. trying to edit a template-derived row).
650
+ * - "INTERNAL" — anything else (network, 5xx).
651
+ */
652
+ export class PermissionError extends Error {
653
+ constructor(code, message, opts) {
654
+ super(message);
655
+ this.name = "PermissionError";
656
+ this.code = code;
657
+ if (opts && opts.status !== undefined) this.status = opts.status;
658
+ if (opts && opts.cause) this.cause = opts.cause;
659
+ }
660
+ }
661
+
662
+ function toPermissionError(err) {
663
+ if (err instanceof PermissionError) return err;
664
+ const status =
665
+ err && err.response && typeof err.response.status === "number"
666
+ ? err.response.status
667
+ : null;
668
+ const bodyCode =
669
+ err && err.response && err.response.data && err.response.data.code;
670
+ const bodyMessage =
671
+ err && err.response && err.response.data && err.response.data.error;
672
+ let code = "INTERNAL";
673
+ if (status === 403) code = "FORBIDDEN";
674
+ else if (status === 404) code = "NOT_FOUND";
675
+ else if (status === 409) code = "CONFLICT";
676
+ else if (status === 400 || status === 422) code = "VALIDATION";
677
+ if (typeof bodyCode === "string" && bodyCode) {
678
+ // Preserve the server's stable code over the status-derived one when
679
+ // the server volunteered it (e.g. TEMPLATE_DERIVED on edit/delete of
680
+ // an inherit/template row).
681
+ code = bodyCode;
682
+ }
683
+ const message =
684
+ (typeof bodyMessage === "string" && bodyMessage) ||
685
+ (err && typeof err.message === "string"
686
+ ? err.message
687
+ : "Record permission call failed");
688
+ return new PermissionError(code, message, { status, cause: err });
689
+ }
690
+
691
+ const _NOOP_PERMISSIONS_RESULT = Object.freeze({
692
+ permissions: [],
693
+ loading: false,
694
+ error: null,
695
+ // Mutation no-ops resolve with null so a widget that does
696
+ // `await grant(...)` doesn't crash when called against an unbound
697
+ // (tableId / recordId null) hook.
698
+ grant: async () => null,
699
+ revoke: async () => undefined,
700
+ update: async () => null,
701
+ refetch: async () => undefined,
702
+ });
703
+
704
+ /**
705
+ * REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — manage per-record VirtualPermission
706
+ * grants on a single record. Returns
707
+ * `{ permissions, loading, error, grant, revoke, update, refetch }`.
495
708
  *
496
- * `query` is an optional `{ q?, role?, isActive?, limit?, offset? }`
497
- * object. `q` substring-matches the display name; `role` is `"USER"`
498
- * (default) or `"INTEGRATION"` or `"ALL"`. The hook re-fetches whenever
499
- * `JSON.stringify(query)` changes and exposes `refetch` for on-demand
500
- * reloads (e.g. a chat roster refresh).
709
+ * `permissions` is an array of snake_case rows VERBATIM, e.g.
710
+ * `{ id, user_id, group_id, can_read, can_write, can_delete, can_grant, ... }`.
501
711
  *
502
- * Requires the `directory.read:users` scope in the widget manifest's
503
- * `requestedScopes`.
712
+ * Reads through the injected `@colixsystems/datastore-client` at
713
+ * `ctx.datastore.records(tableId).permissions(recordId)`, which exposes:
714
+ * - `list()` → `Promise<{ data, meta }>` (envelope; we unwrap `res.data`)
715
+ * - `grant(body)` → `Promise<row>`
716
+ * - `update(permissionId, patch)` → `Promise<row>`
717
+ * - `revoke(permissionId)` → `Promise<void>`
718
+ *
719
+ * Grant / update bodies are snake_case verbatim
720
+ * (`{ user_id | group_id, can_read, can_write, can_delete, can_grant }`) —
721
+ * the SDK does NOT transform them. Rows come back snake_case too; widgets
722
+ * read those fields directly.
723
+ *
724
+ * When `tableId` OR `recordId` is null / undefined / empty (e.g. the
725
+ * widget hasn't picked an active channel yet), the hook collapses to a
726
+ * stable empty result without a network round-trip. Mutation methods
727
+ * are safe no-ops in that state so a widget can call them
728
+ * unconditionally.
729
+ *
730
+ * Requires the `acl.write:records` scope in the widget manifest's
731
+ * `requestedScopes`. The underlying REST endpoint gates the call on
732
+ * `can_grant` for the target record (Studio owners short-circuit;
733
+ * APP_USER actors must hold `can_grant` via REQ-ACL-05 / REQ-ACL-06).
734
+ * A widget that declares the scope but whose caller lacks the grant
735
+ * receives `PermissionError { code: "FORBIDDEN" }`.
504
736
  */
505
- export function useDirectory(query) {
506
- const ctx = useWidgetContextOrThrow("useDirectory");
507
- if (!ctx.directory || typeof ctx.directory.listUsers !== "function") {
508
- throw new Error("useDirectory: host did not inject a directory client");
737
+ export function useRecordPermissions(tableId, recordId) {
738
+ const ctx = useWidgetContextOrThrow("useRecordPermissions");
739
+ if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
740
+ throw new Error(
741
+ "useRecordPermissions: host did not inject a datastore client (ctx.datastore.records)",
742
+ );
509
743
  }
510
- const [users, setUsers] = useState([]);
511
- const [loading, setLoading] = useState(true);
744
+ const ready = Boolean(tableId && recordId);
745
+ const [permissions, setPermissions] = useState([]);
746
+ const [loading, setLoading] = useState(ready);
512
747
  const [error, setError] = useState(null);
513
748
 
514
- // Same ref discipline as useDatastoreQuery: the host rebuilds the
515
- // WidgetContext value every render, so we capture the live query +
516
- // client in refs to keep `refetch` a stable identity.
517
- const queryRef = useRef(query);
518
- const listUsersRef = useRef(ctx.directory.listUsers);
519
- queryRef.current = query;
520
- listUsersRef.current = ctx.directory.listUsers;
749
+ // Same ref discipline as useDirectory / useDatastoreQuery the host
750
+ // rebuilds the WidgetContext value every render, so we capture the
751
+ // live ids + client in refs to keep refetch + grant + revoke + update
752
+ // stable callback identities.
753
+ const tableIdRef = useRef(tableId);
754
+ const recordIdRef = useRef(recordId);
755
+ const recordsRef = useRef(ctx.datastore.records);
756
+ tableIdRef.current = tableId;
757
+ recordIdRef.current = recordId;
758
+ recordsRef.current = ctx.datastore.records;
759
+
760
+ // Resolve the per-record permissions namespace
761
+ // (ctx.datastore.records(tableId).permissions(recordId)) for the bound
762
+ // ids. Returns null when either id is missing.
763
+ const resolveNs = useCallback(() => {
764
+ const t = tableIdRef.current;
765
+ const r = recordIdRef.current;
766
+ if (!t || !r) return null;
767
+ const recordsNs = recordsRef.current(t);
768
+ if (!recordsNs || typeof recordsNs.permissions !== "function") {
769
+ throw new Error(
770
+ "useRecordPermissions: datastore client does not expose records(table).permissions(recordId)",
771
+ );
772
+ }
773
+ return recordsNs.permissions(r);
774
+ }, []);
521
775
 
522
776
  const runRef = useRef(0);
523
777
 
524
778
  const doFetch = useCallback(async () => {
525
779
  const myRun = ++runRef.current;
780
+ const ns = resolveNs();
781
+ if (!ns) {
782
+ // Collapse to a stable empty result without a network round-trip
783
+ // so a widget rendering before an active record is picked stays
784
+ // loop-free.
785
+ setLoading(false);
786
+ setError(null);
787
+ setPermissions([]);
788
+ return;
789
+ }
526
790
  setLoading(true);
527
791
  setError(null);
528
792
  try {
529
- const rows = await listUsersRef.current(queryRef.current);
793
+ const res = await ns.list();
794
+ // permissions().list() returns the { data, meta } envelope verbatim.
795
+ const rows = res && Array.isArray(res.data) ? res.data : [];
530
796
  if (runRef.current !== myRun) return;
531
- setUsers(Array.isArray(rows) ? rows : []);
797
+ setPermissions(rows);
532
798
  setLoading(false);
533
799
  } catch (err) {
534
800
  if (runRef.current !== myRun) return;
535
- setError(toDatastoreError(err));
801
+ setError(toPermissionError(err));
536
802
  setLoading(false);
537
803
  }
538
- }, []);
804
+ }, [resolveNs]);
539
805
 
540
- const queryKey = (() => {
541
- try {
542
- return JSON.stringify(query);
543
- } catch (_e) {
544
- return null;
545
- }
546
- })();
547
806
  useEffect(() => {
807
+ if (!ready) {
808
+ // Reset the slot when the widget unbinds (e.g. user navigates
809
+ // back to the picker) so the next render doesn't show stale
810
+ // permissions from a previous record.
811
+ setPermissions([]);
812
+ setLoading(false);
813
+ setError(null);
814
+ return;
815
+ }
548
816
  doFetch();
549
817
  // eslint-disable-next-line react-hooks/exhaustive-deps
550
- }, [queryKey]);
818
+ }, [tableId, recordId]);
551
819
 
552
820
  const refetch = useCallback(async () => {
553
821
  await doFetch();
554
822
  }, [doFetch]);
555
823
 
556
- return { users, loading, error, refetch };
557
- }
558
-
559
- /**
560
- * Emit a named widget event through ctx.events.emit. Page-level event
561
- * bindings (subscribed by the host) decide what happens next.
562
- */
563
- export function useWidgetEvent(name) {
564
- const ctx = useWidgetContextOrThrow("useWidgetEvent");
565
- return useCallback((payload) => ctx.events.emit(name, payload), [ctx, name]);
566
- }
824
+ const grant = useCallback(async (body) => {
825
+ const ns = resolveNs();
826
+ if (!ns) return null;
827
+ try {
828
+ // body is snake_case verbatim ({ user_id | group_id, can_read,
829
+ // can_write, can_delete, can_grant }) passed straight through.
830
+ const row = await ns.grant(body);
831
+ // Refresh after the mutation so subsequent reads observe the
832
+ // grant. The client returns the created row too, which we hand
833
+ // back to the caller for inline use.
834
+ await doFetch();
835
+ return row;
836
+ } catch (err) {
837
+ throw toPermissionError(err);
838
+ }
839
+ }, [resolveNs, doFetch]);
567
840
 
568
- /**
569
- * Returns the host-provided theme tokens. The host guarantees every field
570
- * documented in CONTRACT.themeTokens is present (defaults merged with
571
- * tenant overrides), so widgets read `theme.colors.primary` without
572
- * optional chaining.
573
- */
574
- export function useTheme() {
575
- const ctx = useWidgetContextOrThrow("useTheme");
576
- return ctx.workspace.theme;
577
- }
841
+ const revoke = useCallback(async (permissionId) => {
842
+ const ns = resolveNs();
843
+ if (!ns) return undefined;
844
+ try {
845
+ await ns.revoke(permissionId);
846
+ await doFetch();
847
+ } catch (err) {
848
+ throw toPermissionError(err);
849
+ }
850
+ }, [resolveNs, doFetch]);
578
851
 
579
- /**
580
- * Returns the active end-user identity:
581
- * `{ id, email, displayName, roles, groupIds }`.
582
- *
583
- * `id` is `null` for anonymous visitors (and on the Studio canvas preview,
584
- * which renders widgets as if signed-out so the public branch shows). All
585
- * fields are guaranteed present by the host (`buildHostWidgetContext`
586
- * + the native `WidgetHost`); widgets read them without optional chaining.
587
- *
588
- * Use this to render the signed-in user's name in a header, branch on
589
- * roles, or stamp a created-by field. Email is opaque to widgets that
590
- * only need a display name — prefer `displayName` for UI strings.
591
- */
592
- export function useUser() {
593
- const ctx = useWidgetContextOrThrow("useUser");
594
- return ctx.user;
595
- }
852
+ const update = useCallback(async (permissionId, body) => {
853
+ const ns = resolveNs();
854
+ if (!ns) return null;
855
+ try {
856
+ // body (the patch) is snake_case verbatim.
857
+ const row = await ns.update(permissionId, body);
858
+ await doFetch();
859
+ return row;
860
+ } catch (err) {
861
+ throw toPermissionError(err);
862
+ }
863
+ }, [resolveNs, doFetch]);
596
864
 
597
- /**
598
- * Returns the host's child-node renderer:
599
- * { renderNode(node) }.
600
- *
601
- * Widgets like Tabs / Card / a custom container call `renderNode(child)`
602
- * to render an author-authored page-tree node nested inside themselves.
603
- * The renderer closes over the surrounding render context (breakpoint,
604
- * page ctx, parent) that the host already knows, so the widget doesn't
605
- * need to plumb any of that through.
606
- *
607
- * Prefer the `WidgetTree` component for the common case
608
- * (`<WidgetTree node={child} />`); reach for the hook when you need to
609
- * branch on the node's shape before rendering.
610
- */
611
- export function useChildRenderer() {
612
- const ctx = useWidgetContextOrThrow("useChildRenderer");
613
- if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
614
- throw new Error(
615
- "useChildRenderer: host did not inject a child-node renderer",
616
- );
865
+ if (!ready) {
866
+ // Mutation no-ops are safe: a widget that does
867
+ // `await grant({...})` against an unbound hook resolves to null
868
+ // rather than throwing. Same shape as the populated case so the
869
+ // caller can branch on `tableId/recordId` once, at the top.
870
+ return _NOOP_PERMISSIONS_RESULT;
617
871
  }
618
- return ctx.renderer;
619
- }
620
872
 
621
- /**
622
- * Renders an author-authored page-tree node through the host's child
623
- * renderer. The widget hands off a node (or null) and gets back a React
624
- * element rendered with the same dispatch the top-level page uses, so
625
- * Tabs / Card / custom container widgets can host arbitrary child
626
- * widgets without importing the host.
627
- */
628
- export function WidgetTree({ node }) {
629
- const ctx = useWidgetContextOrThrow("WidgetTree");
630
- if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
631
- return null;
632
- }
633
- if (!node) return null;
634
- return ctx.renderer.renderNode(node);
873
+ return { permissions, loading, error, grant, revoke, update, refetch };
635
874
  }
636
875
 
637
- /**
638
- * Returns the host-provided navigation surface:
639
- * `{ goTo, goBack, push, replace, back, currentRoute }`.
640
- *
641
- * `goTo(pageId, params?)` navigates to an internal app page. `goBack()`
642
- * pops the stack. `push` / `replace` / `back` are aliases that map to
643
- * the platform's native navigator (react-router on web, react-navigation
644
- * on native). Missing methods degrade to no-ops on the Studio canvas
645
- * preview, where there is no live router.
876
+ /* ============================================================================
877
+ * FILES CLIENT ctx.files (@colixsystems/files-client)
646
878
  *
647
- * For EXTERNAL URLs use the `Linking` primitive — `Linking.openURL(url)`
648
- * works on both platforms.
649
- */
650
- export function useNavigation() {
651
- const ctx = useWidgetContextOrThrow("useNavigation");
652
- return ctx.navigation;
653
- }
879
+ * Files, folders, shares. Covers: useFile.
880
+ * ==========================================================================*/
654
881
 
655
882
  /**
656
- * Structured error thrown by `usePayments` callbacks. Carries a stable
657
- * `code` so widgets can branch without parsing message strings.
883
+ * Stateful file-asset resolver hook. Returns { url, file, loading, error,
884
+ * refetch }.
658
885
  *
659
- * `code` is one of:
660
- * - "AUTH_REQUIRED" — no signed-in app user
661
- * - "PAYMENTS_SCOPE_NOT_GRANTED"widget lacks payments.charge:appUser
662
- * - "INVALID_AMOUNT" / "AMOUNT_TOO_LARGE" / "VALIDATION" bad request
663
- * - "CONNECT_NOT_READY" / "PAYMENTS_DISABLED" provider not ready
664
- * - "DECLINED" — the charge was declined
665
- * - "INTERNAL" — anything else
886
+ * The host injects a `@colixsystems/files-client` instance at `ctx.files`,
887
+ * FLATTENED so file ops are top-level. We call `ctx.files.get(fileId)`,
888
+ * which resolves to `{ url, ...meta }` the returned file already carries
889
+ * an absolute URL so the widget can drop it straight into an `<Image
890
+ * source>` without knowing where the API lives. A missing/soft-deleted
891
+ * asset surfaces as `{ url: null, error: <DatastoreError NOT_FOUND> }`.
892
+ *
893
+ * When `fileId` is falsy the hook collapses to { url: null, file: null,
894
+ * loading: false, error: null, refetch } without a network round-trip,
895
+ * so a widget rendering before the author has bound an asset stays
896
+ * loop-free.
666
897
  */
667
- export class PaymentError extends Error {
668
- constructor(code, message, opts) {
669
- super(message);
670
- this.name = "PaymentError";
671
- this.code = code;
672
- if (opts && opts.cause) this.cause = opts.cause;
898
+ export function useFile(fileId) {
899
+ const ctx = useWidgetContextOrThrow("useFile");
900
+ if (!ctx.files || typeof ctx.files.get !== "function") {
901
+ throw new Error("useFile: host did not inject a files client");
673
902
  }
674
- }
903
+ const ready = Boolean(fileId);
904
+ const [file, setFile] = useState(null);
905
+ const [loading, setLoading] = useState(ready);
906
+ const [error, setError] = useState(null);
675
907
 
676
- function toPaymentError(err) {
677
- if (err instanceof PaymentError) return err;
678
- const status =
679
- err && err.response && typeof err.response.status === "number"
680
- ? err.response.status
681
- : null;
682
- const bodyCode =
683
- err && err.response && err.response.data && err.response.data.code;
684
- const bodyMessage =
685
- err && err.response && err.response.data && err.response.data.error;
686
- let code = "INTERNAL";
687
- if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
688
- else if (status === 401) code = "AUTH_REQUIRED";
689
- else if (status === 402) code = "DECLINED";
690
- else if (status === 403) code = "FORBIDDEN";
691
- else if (status === 400) code = "VALIDATION";
692
- const message =
693
- (typeof bodyMessage === "string" && bodyMessage) ||
694
- (err && typeof err.message === "string"
695
- ? err.message
696
- : "Payment request failed");
697
- return new PaymentError(code, message, { cause: err });
698
- }
908
+ const fileIdRef = useRef(fileId);
909
+ const getRef = useRef(ctx.files.get);
910
+ fileIdRef.current = fileId;
911
+ getRef.current = ctx.files.get;
699
912
 
700
- /**
701
- * Incoming app-user payments (REQ-BILL-07-WIDGETPAY). Returns
702
- * `{ requestPayment, getPayment }`.
703
- *
704
- * requestPayment({ amountCents, currency?, description, metadata? })
705
- * → Promise<{ id, status, checkoutUrl?, ... }>. The host either
706
- * auto-confirms (mock provider, `status: "PAID"`, no redirect) or
707
- * returns a hosted-Checkout `checkoutUrl` the widget should open
708
- * (Stripe provider, `status: "PENDING"`). Rejects with a
709
- * `PaymentError`.
710
- * getPayment(paymentId) → Promise<payment> — poll the terminal status.
711
- *
712
- * Requires the `payments.charge:appUser` scope in the manifest's
713
- * `requestedScopes`. The charge settles to the workspace owner; the app
714
- * user confirms the amount in hosted Checkout. No card data touches the
715
- * widget — never collect card fields yourself.
716
- */
717
- export function usePayments() {
718
- const ctx = useWidgetContextOrThrow("usePayments");
719
- if (!ctx.payments || typeof ctx.payments.requestPayment !== "function") {
720
- throw new Error(
721
- "usePayments: host did not inject a payments client. The widget must " +
722
- "declare the payments.charge:appUser scope and be installed in a " +
723
- "workspace whose host supports payments.",
724
- );
725
- }
726
- const requestRef = useRef(ctx.payments.requestPayment);
727
- const getRef = useRef(
728
- typeof ctx.payments.getPayment === "function"
729
- ? ctx.payments.getPayment
730
- : null,
731
- );
732
- requestRef.current = ctx.payments.requestPayment;
733
- getRef.current =
734
- typeof ctx.payments.getPayment === "function"
735
- ? ctx.payments.getPayment
736
- : null;
913
+ const runRef = useRef(0);
737
914
 
738
- const requestPayment = useCallback(async (args) => {
739
- try {
740
- return await requestRef.current(args);
741
- } catch (err) {
742
- throw toPaymentError(err);
743
- }
744
- }, []);
745
- const getPayment = useCallback(async (paymentId) => {
746
- if (!getRef.current) {
747
- throw new PaymentError(
748
- "INTERNAL",
749
- "getPayment is not supported by this host.",
750
- );
915
+ const doFetch = useCallback(async () => {
916
+ const myRun = ++runRef.current;
917
+ const id = fileIdRef.current;
918
+ if (!id) {
919
+ setLoading(false);
920
+ setError(null);
921
+ setFile(null);
922
+ return;
751
923
  }
924
+ setLoading(true);
925
+ setError(null);
752
926
  try {
753
- return await getRef.current(paymentId);
927
+ const f = await getRef.current(id);
928
+ if (runRef.current !== myRun) return;
929
+ setFile(f || null);
930
+ setLoading(false);
754
931
  } catch (err) {
755
- throw toPaymentError(err);
932
+ if (runRef.current !== myRun) return;
933
+ setError(toDatastoreError(err));
934
+ setLoading(false);
756
935
  }
757
936
  }, []);
758
- return { requestPayment, getPayment };
937
+
938
+ useEffect(() => {
939
+ doFetch();
940
+ // eslint-disable-next-line react-hooks/exhaustive-deps
941
+ }, [fileId]);
942
+
943
+ const refetch = useCallback(async () => {
944
+ await doFetch();
945
+ }, [doFetch]);
946
+
947
+ const url =
948
+ file && typeof file.url === "string" && file.url.length > 0
949
+ ? file.url
950
+ : null;
951
+ return { url, file, loading, error, refetch };
759
952
  }
760
953
 
761
- /**
762
- * Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
763
- * the host's translation table and falls back to `fallback ?? key` when
764
- * the key is missing. The host's i18n.t may or may not honour the two-arg
765
- * form; we degrade gracefully either way.
766
- */
954
+ /* ============================================================================
955
+ * DIRECTORY CLIENT ctx.directory (@colixsystems/directory-client)
956
+ *
957
+ * Me, users, groups, invites. Covers:
958
+ * useDirectory, useUsers, useGroups.
959
+ * ==========================================================================*/
960
+
767
961
  /**
768
962
  * REQ-USERMGMT / REQ-ACL-SYS M3 — structured error thrown by `useUsers` /
769
963
  * `useGroups` callbacks. Carries a stable `code` so widgets can branch
@@ -814,14 +1008,103 @@ function toDirectoryError(err) {
814
1008
  return new DirectoryError(code, message, { cause: err });
815
1009
  }
816
1010
 
1011
+ /**
1012
+ * Stateful user-directory query hook. Returns { users, loading, error,
1013
+ * refetch }.
1014
+ *
1015
+ * The host injects a `@colixsystems/directory-client` instance at
1016
+ * `ctx.directory`. We call `ctx.directory.users.list(query)`, which resolves
1017
+ * to the `{ data, meta }` list envelope VERBATIM; we unwrap `res.data`
1018
+ * (default `[]`) into the `users` slot. Rows are snake_case (`id`, `name`,
1019
+ * `role`, …) — the privacy-reduced directory projection the backend hands to
1020
+ * non-Studio (Player) callers. Use it to build a chat people-list, an
1021
+ * @-mention picker, or to resolve an author id to a display name. Mutating
1022
+ * users is NOT part of this surface; the directory is read-only here (see
1023
+ * useUsers for administration).
1024
+ *
1025
+ * `query` is an optional `{ q?, role?, is_active?, limit?, offset? }`
1026
+ * object passed through verbatim. The hook re-fetches whenever
1027
+ * `JSON.stringify(query)` changes and exposes `refetch` for on-demand
1028
+ * reloads (e.g. a chat roster refresh).
1029
+ *
1030
+ * Requires the `directory.read:users` scope in the widget manifest's
1031
+ * `requestedScopes`.
1032
+ */
1033
+ export function useDirectory(query) {
1034
+ const ctx = useWidgetContextOrThrow("useDirectory");
1035
+ if (
1036
+ !ctx.directory ||
1037
+ !ctx.directory.users ||
1038
+ typeof ctx.directory.users.list !== "function"
1039
+ ) {
1040
+ throw new Error(
1041
+ "useDirectory: host did not inject a directory client (ctx.directory.users.list)",
1042
+ );
1043
+ }
1044
+ const [users, setUsers] = useState([]);
1045
+ const [loading, setLoading] = useState(true);
1046
+ const [error, setError] = useState(null);
1047
+
1048
+ // Same ref discipline as useDatastoreQuery: the host rebuilds the
1049
+ // WidgetContext value every render, so we capture the live query +
1050
+ // client in refs to keep `refetch` a stable identity.
1051
+ const queryRef = useRef(query);
1052
+ const usersNsRef = useRef(ctx.directory.users);
1053
+ queryRef.current = query;
1054
+ usersNsRef.current = ctx.directory.users;
1055
+
1056
+ const runRef = useRef(0);
1057
+
1058
+ const doFetch = useCallback(async () => {
1059
+ const myRun = ++runRef.current;
1060
+ setLoading(true);
1061
+ setError(null);
1062
+ try {
1063
+ const res = await usersNsRef.current.list(queryRef.current);
1064
+ // Directory list returns the { data, meta } envelope verbatim.
1065
+ const rows = res && Array.isArray(res.data) ? res.data : [];
1066
+ if (runRef.current !== myRun) return;
1067
+ setUsers(rows);
1068
+ setLoading(false);
1069
+ } catch (err) {
1070
+ if (runRef.current !== myRun) return;
1071
+ setError(toDatastoreError(err));
1072
+ setLoading(false);
1073
+ }
1074
+ }, []);
1075
+
1076
+ const queryKey = (() => {
1077
+ try {
1078
+ return JSON.stringify(query);
1079
+ } catch (_e) {
1080
+ return null;
1081
+ }
1082
+ })();
1083
+ useEffect(() => {
1084
+ doFetch();
1085
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1086
+ }, [queryKey]);
1087
+
1088
+ const refetch = useCallback(async () => {
1089
+ await doFetch();
1090
+ }, [doFetch]);
1091
+
1092
+ return { users, loading, error, refetch };
1093
+ }
1094
+
817
1095
  /**
818
1096
  * REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook.
819
1097
  *
820
1098
  * Returns `{ users, loading, error, refetch, invite, deactivate,
821
- * reactivate, remove }`. The list refetches whenever
822
- * `JSON.stringify(query)` changes; the imperative methods reject with a
823
- * `DirectoryError`. Reads require the `users.read:*` scope; mutations
824
- * additionally require `users.write:*`. The host's signed
1099
+ * reactivate, remove }`. Reads through the injected
1100
+ * `@colixsystems/directory-client` at `ctx.directory.users.{list, get,
1101
+ * invite, deactivate, reactivate}` `list` resolves to the `{ data, meta }`
1102
+ * envelope VERBATIM, so we unwrap `res.data` (default `[]`). User rows are
1103
+ * snake_case (`id`, `name`, `email`, `role`, `is_active`, …) and bodies
1104
+ * (e.g. `{ email, name, group_ids? }`) pass through verbatim. The list
1105
+ * refetches whenever `JSON.stringify(query)` changes; the imperative methods
1106
+ * reject with a `DirectoryError`. Reads require the `users.read:*` scope;
1107
+ * mutations additionally require `users.write:*`. The host's signed
825
1108
  * `X-Widget-Scopes` header + a tenant-scoped SystemAcl `users.read` /
826
1109
  * `users.write` capability grant gate the underlying endpoint
827
1110
  * (REQ-ACL-SYS M3 §4.3) — a widget that declares the scopes but whose
@@ -829,17 +1112,23 @@ function toDirectoryError(err) {
829
1112
  */
830
1113
  export function useUsers(query) {
831
1114
  const ctx = useWidgetContextOrThrow("useUsers");
832
- if (!ctx.users || typeof ctx.users.listUsers !== "function") {
833
- throw new Error("useUsers: host did not inject a users client");
1115
+ if (
1116
+ !ctx.directory ||
1117
+ !ctx.directory.users ||
1118
+ typeof ctx.directory.users.list !== "function"
1119
+ ) {
1120
+ throw new Error(
1121
+ "useUsers: host did not inject a directory client (ctx.directory.users)",
1122
+ );
834
1123
  }
835
1124
  const [users, setUsers] = useState([]);
836
1125
  const [loading, setLoading] = useState(true);
837
1126
  const [error, setError] = useState(null);
838
1127
 
839
1128
  const queryRef = useRef(query);
840
- const usersRef = useRef(ctx.users);
1129
+ const usersRef = useRef(ctx.directory.users);
841
1130
  queryRef.current = query;
842
- usersRef.current = ctx.users;
1131
+ usersRef.current = ctx.directory.users;
843
1132
 
844
1133
  const runRef = useRef(0);
845
1134
 
@@ -848,9 +1137,11 @@ export function useUsers(query) {
848
1137
  setLoading(true);
849
1138
  setError(null);
850
1139
  try {
851
- const rows = await usersRef.current.listUsers(queryRef.current);
1140
+ const res = await usersRef.current.list(queryRef.current);
1141
+ // Directory users.list returns the { data, meta } envelope verbatim.
1142
+ const rows = res && Array.isArray(res.data) ? res.data : [];
852
1143
  if (runRef.current !== myRun) return;
853
- setUsers(Array.isArray(rows) ? rows : []);
1144
+ setUsers(rows);
854
1145
  setLoading(false);
855
1146
  } catch (err) {
856
1147
  if (runRef.current !== myRun) return;
@@ -911,22 +1202,33 @@ export function useUsers(query) {
911
1202
  * REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration hook.
912
1203
  *
913
1204
  * Returns `{ groups, loading, error, refetch, create, remove, addMember,
914
- * removeMember }`. Reads require `groups.read:*`; mutations require
915
- * `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as `useUsers`.
1205
+ * removeMember }`. Reads through the injected
1206
+ * `@colixsystems/directory-client` at `ctx.directory.groups.{list, create,
1207
+ * remove, addMember, removeMember, listMine}` — `list` resolves to the
1208
+ * `{ data, meta }` envelope VERBATIM, so we unwrap `res.data` (default `[]`).
1209
+ * Group rows are snake_case and bodies pass through verbatim. Reads require
1210
+ * `groups.read:*`; mutations require `groups.write:*`. Same X-Widget-Scopes +
1211
+ * SystemAcl gating as `useUsers`.
916
1212
  */
917
1213
  export function useGroups(query) {
918
1214
  const ctx = useWidgetContextOrThrow("useGroups");
919
- if (!ctx.groups || typeof ctx.groups.listGroups !== "function") {
920
- throw new Error("useGroups: host did not inject a groups client");
1215
+ if (
1216
+ !ctx.directory ||
1217
+ !ctx.directory.groups ||
1218
+ typeof ctx.directory.groups.list !== "function"
1219
+ ) {
1220
+ throw new Error(
1221
+ "useGroups: host did not inject a directory client (ctx.directory.groups)",
1222
+ );
921
1223
  }
922
1224
  const [groups, setGroups] = useState([]);
923
1225
  const [loading, setLoading] = useState(true);
924
1226
  const [error, setError] = useState(null);
925
1227
 
926
1228
  const queryRef = useRef(query);
927
- const groupsRef = useRef(ctx.groups);
1229
+ const groupsRef = useRef(ctx.directory.groups);
928
1230
  queryRef.current = query;
929
- groupsRef.current = ctx.groups;
1231
+ groupsRef.current = ctx.directory.groups;
930
1232
 
931
1233
  const runRef = useRef(0);
932
1234
 
@@ -935,9 +1237,11 @@ export function useGroups(query) {
935
1237
  setLoading(true);
936
1238
  setError(null);
937
1239
  try {
938
- const rows = await groupsRef.current.listGroups(queryRef.current);
1240
+ const res = await groupsRef.current.list(queryRef.current);
1241
+ // Directory groups.list returns the { data, meta } envelope verbatim.
1242
+ const rows = res && Array.isArray(res.data) ? res.data : [];
939
1243
  if (runRef.current !== myRun) return;
940
- setGroups(Array.isArray(rows) ? rows : []);
1244
+ setGroups(rows);
941
1245
  setLoading(false);
942
1246
  } catch (err) {
943
1247
  if (runRef.current !== myRun) return;
@@ -994,33 +1298,35 @@ export function useGroups(query) {
994
1298
  return { groups, loading, error, refetch, create, remove, addMember, removeMember };
995
1299
  }
996
1300
 
1301
+ /* ============================================================================
1302
+ * PAYMENTS CLIENT — ctx.payments (@colixsystems/payments-client)
1303
+ *
1304
+ * requestPayment, getPayment. Covers: usePayments.
1305
+ * ==========================================================================*/
1306
+
997
1307
  /**
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`.
1308
+ * Structured error thrown by `usePayments` callbacks. Carries a stable
1309
+ * `code` so widgets can branch without parsing message strings.
1003
1310
  *
1004
1311
  * `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).
1312
+ * - "AUTH_REQUIRED" no signed-in app user
1313
+ * - "PAYMENTS_SCOPE_NOT_GRANTED"— widget lacks payments.charge:appUser
1314
+ * - "INVALID_AMOUNT" / "AMOUNT_TOO_LARGE" / "VALIDATION" bad request
1315
+ * - "CONNECT_NOT_READY" / "PAYMENTS_DISABLED" — provider not ready
1316
+ * - "DECLINED" the charge was declined
1317
+ * - "INTERNAL" — anything else
1011
1318
  */
1012
- export class PermissionError extends Error {
1319
+ export class PaymentError extends Error {
1013
1320
  constructor(code, message, opts) {
1014
1321
  super(message);
1015
- this.name = "PermissionError";
1322
+ this.name = "PaymentError";
1016
1323
  this.code = code;
1017
- if (opts && opts.status !== undefined) this.status = opts.status;
1018
1324
  if (opts && opts.cause) this.cause = opts.cause;
1019
1325
  }
1020
1326
  }
1021
1327
 
1022
- function toPermissionError(err) {
1023
- if (err instanceof PermissionError) return err;
1328
+ function toPaymentError(err) {
1329
+ if (err instanceof PaymentError) return err;
1024
1330
  const status =
1025
1331
  err && err.response && typeof err.response.status === "number"
1026
1332
  ? err.response.status
@@ -1030,219 +1336,76 @@ function toPermissionError(err) {
1030
1336
  const bodyMessage =
1031
1337
  err && err.response && err.response.data && err.response.data.error;
1032
1338
  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
- }
1339
+ if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
1340
+ else if (status === 401) code = "AUTH_REQUIRED";
1341
+ else if (status === 402) code = "DECLINED";
1342
+ else if (status === 403) code = "FORBIDDEN";
1343
+ else if (status === 400) code = "VALIDATION";
1043
1344
  const message =
1044
1345
  (typeof bodyMessage === "string" && bodyMessage) ||
1045
1346
  (err && typeof err.message === "string"
1046
1347
  ? err.message
1047
- : "Record permission call failed");
1048
- return new PermissionError(code, message, { status, cause: err });
1348
+ : "Payment request failed");
1349
+ return new PaymentError(code, message, { cause: err });
1049
1350
  }
1050
1351
 
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
1352
  /**
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.
1353
+ * Incoming app-user payments (REQ-BILL-07-WIDGETPAY). Returns
1354
+ * `{ requestPayment, getPayment }`.
1081
1355
  *
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.
1356
+ * requestPayment({ amountCents, currency?, description, metadata? })
1357
+ * Promise<{ id, status, checkoutUrl?, ... }>. The host either
1358
+ * auto-confirms (mock provider, `status: "PAID"`, no redirect) or
1359
+ * returns a hosted-Checkout `checkoutUrl` the widget should open
1360
+ * (Stripe provider, `status: "PENDING"`). Rejects with a
1361
+ * `PaymentError`.
1362
+ * getPayment(paymentId) → Promise<payment> — poll the terminal status.
1087
1363
  *
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" }`.
1364
+ * Requires the `payments.charge:appUser` scope in the manifest's
1365
+ * `requestedScopes`. The charge settles to the workspace owner; the app
1366
+ * user confirms the amount in hosted Checkout. No card data touches the
1367
+ * widget never collect card fields yourself.
1094
1368
  */
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
- ) {
1369
+ export function usePayments() {
1370
+ const ctx = useWidgetContextOrThrow("usePayments");
1371
+ if (!ctx.payments || typeof ctx.payments.requestPayment !== "function") {
1104
1372
  throw new Error(
1105
- "useRecordPermissions: host did not inject a recordPermissions client",
1373
+ "usePayments: host did not inject a payments client. The widget must " +
1374
+ "declare the payments.charge:appUser scope and be installed in a " +
1375
+ "workspace whose host supports payments.",
1106
1376
  );
1107
1377
  }
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);
1378
+ const requestRef = useRef(ctx.payments.requestPayment);
1379
+ const getRef = useRef(
1380
+ typeof ctx.payments.getPayment === "function"
1381
+ ? ctx.payments.getPayment
1382
+ : null,
1383
+ );
1384
+ requestRef.current = ctx.payments.requestPayment;
1385
+ getRef.current =
1386
+ typeof ctx.payments.getPayment === "function"
1387
+ ? ctx.payments.getPayment
1388
+ : null;
1125
1389
 
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);
1390
+ const requestPayment = useCallback(async (args) => {
1141
1391
  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);
1392
+ return await requestRef.current(args);
1146
1393
  } catch (err) {
1147
- if (runRef.current !== myRun) return;
1148
- setError(toPermissionError(err));
1149
- setLoading(false);
1394
+ throw toPaymentError(err);
1150
1395
  }
1151
1396
  }, []);
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);
1397
+ const getPayment = useCallback(async (paymentId) => {
1398
+ if (!getRef.current) {
1399
+ throw new PaymentError(
1400
+ "INTERNAL",
1401
+ "getPayment is not supported by this host.",
1402
+ );
1196
1403
  }
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
1404
  try {
1204
- const row = await clientRef.current.update(t, r, permissionId, body);
1205
- await doFetch();
1206
- return row;
1405
+ return await getRef.current(paymentId);
1207
1406
  } catch (err) {
1208
- throw toPermissionError(err);
1407
+ throw toPaymentError(err);
1209
1408
  }
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
-
1223
- export function useI18n() {
1224
- const ctx = useWidgetContextOrThrow("useI18n");
1225
- const i18n = ctx.i18n || {};
1226
- const locale = typeof i18n.locale === "string" ? i18n.locale : "en";
1227
- const hostT = typeof i18n.t === "function" ? i18n.t : null;
1228
- const t = useCallback(
1229
- (key, fallback) => {
1230
- if (typeof key !== "string" || !key) {
1231
- return typeof fallback === "string" ? fallback : "";
1232
- }
1233
- if (hostT) {
1234
- // Try the two-arg form first; if the host's `t` ignores `fallback`
1235
- // and returns the bare key on a miss, swap in the fallback.
1236
- const out = hostT(key, fallback);
1237
- if (typeof out === "string" && out.length > 0 && out !== key) {
1238
- return out;
1239
- }
1240
- // Host returned the key (unresolved) or nothing usable.
1241
- return typeof fallback === "string" ? fallback : key;
1242
- }
1243
- return typeof fallback === "string" ? fallback : key;
1244
- },
1245
- [hostT],
1246
- );
1247
- return { t, locale };
1409
+ }, []);
1410
+ return { requestPayment, getPayment };
1248
1411
  }