@colixsystems/widget-sdk 0.18.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, {
@@ -18,10 +26,224 @@ import React, {
18
26
  useRef,
19
27
  useState,
20
28
  } from "react";
29
+ // REQ-L10N-WIDGET: the per-widget translation key format lives in ONE place
30
+ // (contract.js). useI18n (lookup) and the backend seeder (write) both call it
31
+ // so the namespaced key can never drift between the two sides.
32
+ import { widgetTranslationKey } from "./contract.js";
21
33
 
22
34
  /** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
23
35
  const HostWidgetContext = createContext(null);
24
36
 
37
+ /* ============================================================================
38
+ * CORE — WidgetContext + non-data hooks (no domain client)
39
+ *
40
+ * The provider, the context accessor, and the hooks that read host-provided
41
+ * state directly off the WidgetContext (events, theme, user, renderer,
42
+ * navigation, i18n) rather than through one of the four domain clients.
43
+ * ==========================================================================*/
44
+
45
+ /**
46
+ * Wraps children with the host-provided WidgetContext.
47
+ * The host (Studio/Player/native shell) builds the value and renders this provider.
48
+ */
49
+ export function WidgetContextProvider({ value, children }) {
50
+ return React.createElement(HostWidgetContext.Provider, { value }, children);
51
+ }
52
+
53
+ // Exported for ./toast.js + future hook modules outside hooks.js. Internal
54
+ // utility — widget code should not import this directly.
55
+ export function useWidgetContextOrThrow(hookName) {
56
+ const ctx = useContext(HostWidgetContext);
57
+ if (ctx == null) {
58
+ throw new Error(
59
+ `${hookName} must be used inside a WidgetContextProvider. ` +
60
+ `The host (Studio, Player, or exported app) is responsible for mounting it.`,
61
+ );
62
+ }
63
+ return ctx;
64
+ }
65
+
66
+ /**
67
+ * Emit a named widget event through ctx.events.emit. Page-level event
68
+ * bindings (subscribed by the host) decide what happens next.
69
+ */
70
+ export function useWidgetEvent(name) {
71
+ const ctx = useWidgetContextOrThrow("useWidgetEvent");
72
+ return useCallback((payload) => ctx.events.emit(name, payload), [ctx, name]);
73
+ }
74
+
75
+ /**
76
+ * Returns the host-provided theme tokens. The host guarantees every field
77
+ * documented in CONTRACT.themeTokens is present (defaults merged with
78
+ * tenant overrides), so widgets read `theme.colors.primary` without
79
+ * optional chaining.
80
+ */
81
+ export function useTheme() {
82
+ const ctx = useWidgetContextOrThrow("useTheme");
83
+ return ctx.workspace.theme;
84
+ }
85
+
86
+ /**
87
+ * Returns the active end-user identity VERBATIM from the host, e.g.
88
+ * `{ id, email, display_name, roles, group_ids }` (snake_case fields).
89
+ *
90
+ * `id` is `null` for anonymous visitors (and on the Studio canvas preview,
91
+ * which renders widgets as if signed-out so the public branch shows). The
92
+ * host (`buildHostWidgetContext` + the native `WidgetHost`) populates the
93
+ * fields; widgets read them directly. The current principal is host-provided
94
+ * — this hook does NOT read a data-client.
95
+ *
96
+ * Use this to render the signed-in user's name in a header, branch on
97
+ * roles, or stamp a created-by field.
98
+ */
99
+ export function useUser() {
100
+ const ctx = useWidgetContextOrThrow("useUser");
101
+ return ctx.user;
102
+ }
103
+
104
+ /**
105
+ * Returns `true` when the host has sized this widget to fill the available
106
+ * height of its layout slot — today that means a page-grid tile whose author
107
+ * chose "Fill tile height" (or a widget type that fills by default, like
108
+ * containers and media). When `true`, a widget that has a meaningful filled
109
+ * form (Image, Chart, Map, Video, …) should switch from its intrinsic height
110
+ * to a stretch style (`flex: 1` / `height: "100%"`); widgets with no useful
111
+ * filled form may ignore it. Defaults to `false` everywhere the host has not
112
+ * opted the widget into filling, so calling it is always safe.
113
+ *
114
+ * The SAME value is injected by the web Player host and the native export
115
+ * host (CLAUDE.md §3), so a widget's fill behaviour is identical on both
116
+ * platforms — there is one source file and one `fill` flag driving it.
117
+ */
118
+ export function useFill() {
119
+ const ctx = useWidgetContextOrThrow("useFill");
120
+ return ctx.fill === true;
121
+ }
122
+
123
+ /**
124
+ * Returns the host's child-node renderer:
125
+ * { renderNode(node) }.
126
+ *
127
+ * Widgets like Tabs / Card / a custom container call `renderNode(child)`
128
+ * to render an author-authored page-tree node nested inside themselves.
129
+ * The renderer closes over the surrounding render context (breakpoint,
130
+ * page ctx, parent) that the host already knows, so the widget doesn't
131
+ * need to plumb any of that through.
132
+ *
133
+ * Prefer the `WidgetTree` component for the common case
134
+ * (`<WidgetTree node={child} />`); reach for the hook when you need to
135
+ * branch on the node's shape before rendering.
136
+ */
137
+ export function useChildRenderer() {
138
+ const ctx = useWidgetContextOrThrow("useChildRenderer");
139
+ if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
140
+ throw new Error(
141
+ "useChildRenderer: host did not inject a child-node renderer",
142
+ );
143
+ }
144
+ return ctx.renderer;
145
+ }
146
+
147
+ /**
148
+ * Renders an author-authored page-tree node through the host's child
149
+ * renderer. The widget hands off a node (or null) and gets back a React
150
+ * element rendered with the same dispatch the top-level page uses, so
151
+ * Tabs / Card / custom container widgets can host arbitrary child
152
+ * widgets without importing the host.
153
+ */
154
+ export function WidgetTree({ node }) {
155
+ const ctx = useWidgetContextOrThrow("WidgetTree");
156
+ if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
157
+ return null;
158
+ }
159
+ if (!node) return null;
160
+ return ctx.renderer.renderNode(node);
161
+ }
162
+
163
+ /**
164
+ * Returns the host-provided navigation surface:
165
+ * `{ goTo, goBack, push, replace, back, currentRoute }`.
166
+ *
167
+ * `goTo(pageId, params?)` navigates to an internal app page. `goBack()`
168
+ * pops the stack. `push` / `replace` / `back` are aliases that map to
169
+ * the platform's native navigator (react-router on web, react-navigation
170
+ * on native). Missing methods degrade to no-ops on the Studio canvas
171
+ * preview, where there is no live router.
172
+ *
173
+ * For EXTERNAL URLs use the `Linking` primitive — `Linking.openURL(url)`
174
+ * works on both platforms.
175
+ */
176
+ export function useNavigation() {
177
+ const ctx = useWidgetContextOrThrow("useNavigation");
178
+ return ctx.navigation;
179
+ }
180
+
181
+ /**
182
+ * Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
183
+ * the host's translation table and falls back to `fallback ?? key` when
184
+ * the key is missing. The host's i18n.t may or may not honour the two-arg
185
+ * form; we degrade gracefully either way.
186
+ */
187
+ export function useI18n() {
188
+ const ctx = useWidgetContextOrThrow("useI18n");
189
+ const i18n = ctx.i18n || {};
190
+ const locale = typeof i18n.locale === "string" ? i18n.locale : "en";
191
+ const hostT = typeof i18n.t === "function" ? i18n.t : null;
192
+ // REQ-L10N-WIDGET: the widget's own manifest translations are stored in the
193
+ // tenant dictionary under `widget.<id>.<key>`. Derive the namespace from the
194
+ // host-provided widget id so the author calls `t("greeting")` and never
195
+ // types the prefix — and so the SAME hook gives this behaviour on web and in
196
+ // the exported app (both hosts set ctx.widget.id).
197
+ const widgetId =
198
+ ctx.widget && typeof ctx.widget.id === "string" && ctx.widget.id
199
+ ? ctx.widget.id
200
+ : null;
201
+ const t = useCallback(
202
+ (key, fallback) => {
203
+ if (typeof key !== "string" || !key) {
204
+ return typeof fallback === "string" ? fallback : "";
205
+ }
206
+ if (hostT) {
207
+ // 1) Try the widget-namespaced key first. A genuine hit is a string
208
+ // that is neither the bare key nor a `{{t:…}}` placeholder (the
209
+ // miss form both hosts emit). No fallback is passed here so a miss
210
+ // is unambiguous and we can fall through to the raw key.
211
+ if (widgetId) {
212
+ const namespaced = widgetTranslationKey(widgetId, key);
213
+ const scoped = hostT(namespaced);
214
+ if (
215
+ typeof scoped === "string" &&
216
+ scoped.length > 0 &&
217
+ scoped !== namespaced &&
218
+ !scoped.startsWith("{{t:")
219
+ ) {
220
+ return scoped;
221
+ }
222
+ }
223
+ // 2) Fall back to the raw key (shared app keys + widgets with no
224
+ // manifest translations — unchanged from pre-1.10 behaviour).
225
+ const out = hostT(key, fallback);
226
+ if (typeof out === "string" && out.length > 0 && out !== key) {
227
+ return out;
228
+ }
229
+ // Host returned the key (unresolved) or nothing usable.
230
+ return typeof fallback === "string" ? fallback : key;
231
+ }
232
+ return typeof fallback === "string" ? fallback : key;
233
+ },
234
+ [hostT, widgetId],
235
+ );
236
+ return { t, locale };
237
+ }
238
+
239
+ /* ============================================================================
240
+ * DATASTORE CLIENT — ctx.datastore (@colixsystems/datastore-client)
241
+ *
242
+ * Records, schema, aggregate, and record-level permissions. Covers:
243
+ * useDatastoreQuery, useDatastoreRecord, useDatastoreSchema,
244
+ * useDatastoreMutation, useRecordPermissions.
245
+ * ==========================================================================*/
246
+
25
247
  /**
26
248
  * Structured error thrown by `useDatastoreMutation` callbacks (and surfaced
27
249
  * by `useDatastoreQuery` in its `error` slot). Carries a stable `code` so
@@ -53,29 +275,6 @@ export class DatastoreError extends Error {
53
275
  }
54
276
  }
55
277
 
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
278
  /**
80
279
  * Coerce an arbitrary thrown value (axios error, plain Error, string) into
81
280
  * a DatastoreError with a stable `.code`. Reads `error.response.status` if
@@ -127,15 +326,16 @@ function toDatastoreError(err) {
127
326
  });
128
327
  }
129
328
 
130
- // --------------------------------------------------------------- hooks
131
-
132
329
  /**
133
330
  * Stateful datastore query hook. Returns { data, loading, error, refetch }.
134
331
  *
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.
332
+ * The host injects a `@colixsystems/datastore-client` instance at
333
+ * `ctx.datastore`. We call `ctx.datastore.records(table).list(query)`, which
334
+ * resolves to the `{ data, meta }` list envelope VERBATIM (the client no
335
+ * longer unwraps to a bare array), so we read `res.data` (defaulting to `[]`)
336
+ * into component state. Author column values inside each row keep whatever
337
+ * the author named them. We re-fetch when [table, JSON.stringify(query)]
338
+ * changes. `refetch` re-runs the same call on demand.
139
339
  *
140
340
  * When `table` is falsy (e.g. the user hasn't bound a `tableRef` property
141
341
  * yet), the hook resolves to { data: [], loading: false, error: null,
@@ -189,10 +389,13 @@ export function useDatastoreQuery(table, query) {
189
389
  setError(null);
190
390
  try {
191
391
  const ns = recordsRef.current(t);
192
- const rows = await ns.list(queryRef.current);
392
+ const res = await ns.list(queryRef.current);
393
+ // The datastore-client returns the { data, meta } list envelope
394
+ // verbatim — unwrap to the rows array (default []).
395
+ const rows = res && Array.isArray(res.data) ? res.data : [];
193
396
  // Discard the result if a newer fetch has started since we kicked off.
194
397
  if (runRef.current !== myRun) return;
195
- setData(Array.isArray(rows) ? rows : []);
398
+ setData(rows);
196
399
  setLoading(false);
197
400
  } catch (err) {
198
401
  if (runRef.current !== myRun) return;
@@ -225,10 +428,12 @@ export function useDatastoreQuery(table, query) {
225
428
  * Stateful single-record query hook. Returns { data, loading, error, refetch }
226
429
  * where `data` is one row (`null` until loaded, never an array).
227
430
  *
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.
431
+ * The host injects a `@colixsystems/datastore-client` instance at
432
+ * `ctx.datastore`. We call `ctx.datastore.records(table).get(id)`, which
433
+ * resolves to a single record object (no `{ data, meta }` envelope — `get`
434
+ * returns the row directly). Author column values keep whatever the author
435
+ * named them. We hold the result in component state and re-fetch when
436
+ * [table, id] changes. `refetch` re-runs the call on demand.
232
437
  *
233
438
  * When `table` OR `recordId` is falsy (e.g. the author hasn't bound the
234
439
  * record id yet), the hook resolves to { data: null, loading: false,
@@ -372,82 +577,27 @@ export function useDatastoreSchema(tableId) {
372
577
  return { schema, loading, error, refetch };
373
578
  }
374
579
 
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
580
  /**
447
581
  * 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.
582
+ * returning a Promise. Routes through the injected
583
+ * `@colixsystems/datastore-client` at `ctx.datastore`:
584
+ * `ctx.datastore.records(table).{create,update,delete}`. `update` is a
585
+ * partial PATCH (the client issues PATCH), so callers pass only the changed
586
+ * fields. Values pass through verbatim (snake_case / author-named columns).
587
+ * Rejected promises throw a DatastoreError carrying a stable `.code`
588
+ * (`VALIDATION`, `CONSTRAINT_VIOLATION`, `FORBIDDEN`, `NOT_FOUND`,
589
+ * `INTERNAL`) plus an optional `fieldErrors` map.
590
+ *
591
+ * When `table` is falsy (the author hasn't bound a `tableRef` yet) the hook
592
+ * does NOT throw at render — it returns callbacks that reject with a
593
+ * `DatastoreError { code: "VALIDATION" }` only if actually invoked. This
594
+ * mirrors the guarded posture of `useDatastoreQuery` / `useDatastoreRecord`:
595
+ * a widget calls this hook unconditionally at the top of its body (Rules of
596
+ * Hooks) and renders its unbound empty state without crashing. The
597
+ * `records(table)` namespace is resolved lazily inside each callback — the
598
+ * client throws on an empty table, so building it eagerly at render would
599
+ * crash any widget mounted before its table is bound (the failure the Form
600
+ * Input widget hit).
451
601
  */
452
602
  export function useDatastoreMutation(table) {
453
603
  const ctx = useWidgetContextOrThrow("useDatastoreMutation");
@@ -456,314 +606,406 @@ export function useDatastoreMutation(table) {
456
606
  "useDatastoreMutation: host did not inject a datastore client",
457
607
  );
458
608
  }
459
- const ns = ctx.datastore.records(table);
460
- return {
461
- create: async (values) => {
609
+
610
+ // Same ref discipline as useDatastoreQuery — `ctx` is a fresh object
611
+ // identity on every host render, so we hold the live table + records
612
+ // factory in refs to keep the returned callbacks stable. We deliberately
613
+ // do NOT call `records(table)` here at render.
614
+ const tableRef = useRef(table);
615
+ const recordsRef = useRef(ctx.datastore.records);
616
+ tableRef.current = table;
617
+ recordsRef.current = ctx.datastore.records;
618
+
619
+ // Resolve records(table) lazily. Returns null when no table is bound so
620
+ // the callbacks reject with a clear VALIDATION error rather than letting
621
+ // the client's raw TypeError surface (or crashing at render).
622
+ const resolveNs = useCallback(() => {
623
+ const t = tableRef.current;
624
+ if (!t) return null;
625
+ return recordsRef.current(t);
626
+ }, []);
627
+
628
+ const create = useCallback(
629
+ async (values) => {
630
+ const ns = resolveNs();
631
+ if (!ns) {
632
+ throw new DatastoreError(
633
+ "VALIDATION",
634
+ "useDatastoreMutation: no table bound — set a source table before creating a record.",
635
+ );
636
+ }
462
637
  try {
463
638
  return await ns.create(values);
464
639
  } catch (err) {
465
640
  throw toDatastoreError(err);
466
641
  }
467
642
  },
468
- update: async (id, values) => {
643
+ [resolveNs],
644
+ );
645
+
646
+ const update = useCallback(
647
+ async (id, values) => {
648
+ const ns = resolveNs();
649
+ if (!ns) {
650
+ throw new DatastoreError(
651
+ "VALIDATION",
652
+ "useDatastoreMutation: no table bound — set a source table before updating a record.",
653
+ );
654
+ }
469
655
  try {
470
656
  return await ns.update(id, values);
471
657
  } catch (err) {
472
658
  throw toDatastoreError(err);
473
659
  }
474
660
  },
475
- delete: async (id) => {
661
+ [resolveNs],
662
+ );
663
+
664
+ const del = useCallback(
665
+ async (id) => {
666
+ const ns = resolveNs();
667
+ if (!ns) {
668
+ throw new DatastoreError(
669
+ "VALIDATION",
670
+ "useDatastoreMutation: no table bound — set a source table before deleting a record.",
671
+ );
672
+ }
476
673
  try {
477
674
  return await ns.delete(id);
478
675
  } catch (err) {
479
676
  throw toDatastoreError(err);
480
677
  }
481
678
  },
482
- };
679
+ [resolveNs],
680
+ );
681
+
682
+ return { create, update, delete: del };
483
683
  }
484
684
 
485
685
  /**
486
- * Stateful user-directory query hook. Returns { users, loading, error,
487
- * refetch }.
686
+ * REQ-ACL-06 / REQ-ACL-RELINHERIT-05 structured error thrown by
687
+ * `useRecordPermissions` mutation callbacks (and surfaced by the hook's
688
+ * `error` slot when the initial list fetch fails). Carries a stable
689
+ * `code` so widgets can branch on the error class without parsing
690
+ * message strings; mirrors the shape of `DirectoryError`.
488
691
  *
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.
692
+ * `code` is one of:
693
+ * - "FORBIDDEN" — 403 from the host (caller lacks canGrant on the record).
694
+ * - "VALIDATION" 400 / 422 (missing principal, malformed body).
695
+ * - "NOT_FOUND" — 404 (record absent, cross-tenant, or permission row
696
+ * not found on revoke/update).
697
+ * - "CONFLICT" — 409 (e.g. trying to edit a template-derived row).
698
+ * - "INTERNAL" — anything else (network, 5xx).
699
+ */
700
+ export class PermissionError extends Error {
701
+ constructor(code, message, opts) {
702
+ super(message);
703
+ this.name = "PermissionError";
704
+ this.code = code;
705
+ if (opts && opts.status !== undefined) this.status = opts.status;
706
+ if (opts && opts.cause) this.cause = opts.cause;
707
+ }
708
+ }
709
+
710
+ function toPermissionError(err) {
711
+ if (err instanceof PermissionError) return err;
712
+ const status =
713
+ err && err.response && typeof err.response.status === "number"
714
+ ? err.response.status
715
+ : null;
716
+ const bodyCode =
717
+ err && err.response && err.response.data && err.response.data.code;
718
+ const bodyMessage =
719
+ err && err.response && err.response.data && err.response.data.error;
720
+ let code = "INTERNAL";
721
+ if (status === 403) code = "FORBIDDEN";
722
+ else if (status === 404) code = "NOT_FOUND";
723
+ else if (status === 409) code = "CONFLICT";
724
+ else if (status === 400 || status === 422) code = "VALIDATION";
725
+ if (typeof bodyCode === "string" && bodyCode) {
726
+ // Preserve the server's stable code over the status-derived one when
727
+ // the server volunteered it (e.g. TEMPLATE_DERIVED on edit/delete of
728
+ // an inherit/template row).
729
+ code = bodyCode;
730
+ }
731
+ const message =
732
+ (typeof bodyMessage === "string" && bodyMessage) ||
733
+ (err && typeof err.message === "string"
734
+ ? err.message
735
+ : "Record permission call failed");
736
+ return new PermissionError(code, message, { status, cause: err });
737
+ }
738
+
739
+ const _NOOP_PERMISSIONS_RESULT = Object.freeze({
740
+ permissions: [],
741
+ loading: false,
742
+ error: null,
743
+ // Mutation no-ops resolve with null so a widget that does
744
+ // `await grant(...)` doesn't crash when called against an unbound
745
+ // (tableId / recordId null) hook.
746
+ grant: async () => null,
747
+ revoke: async () => undefined,
748
+ update: async () => null,
749
+ refetch: async () => undefined,
750
+ });
751
+
752
+ /**
753
+ * REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — manage per-record VirtualPermission
754
+ * grants on a single record. Returns
755
+ * `{ permissions, loading, error, grant, revoke, update, refetch }`.
495
756
  *
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).
757
+ * `permissions` is an array of snake_case rows VERBATIM, e.g.
758
+ * `{ id, user_id, group_id, can_read, can_write, can_delete, can_grant, ... }`.
501
759
  *
502
- * Requires the `directory.read:users` scope in the widget manifest's
503
- * `requestedScopes`.
760
+ * Reads through the injected `@colixsystems/datastore-client` at
761
+ * `ctx.datastore.records(tableId).permissions(recordId)`, which exposes:
762
+ * - `list()` → `Promise<{ data, meta }>` (envelope; we unwrap `res.data`)
763
+ * - `grant(body)` → `Promise<row>`
764
+ * - `update(permissionId, patch)` → `Promise<row>`
765
+ * - `revoke(permissionId)` → `Promise<void>`
766
+ *
767
+ * Grant / update bodies are snake_case verbatim
768
+ * (`{ user_id | group_id, can_read, can_write, can_delete, can_grant }`) —
769
+ * the SDK does NOT transform them. Rows come back snake_case too; widgets
770
+ * read those fields directly.
771
+ *
772
+ * When `tableId` OR `recordId` is null / undefined / empty (e.g. the
773
+ * widget hasn't picked an active channel yet), the hook collapses to a
774
+ * stable empty result without a network round-trip. Mutation methods
775
+ * are safe no-ops in that state so a widget can call them
776
+ * unconditionally.
777
+ *
778
+ * Requires the `acl.write:records` scope in the widget manifest's
779
+ * `requestedScopes`. The underlying REST endpoint gates the call on
780
+ * `can_grant` for the target record (Studio owners short-circuit;
781
+ * APP_USER actors must hold `can_grant` via REQ-ACL-05 / REQ-ACL-06).
782
+ * A widget that declares the scope but whose caller lacks the grant
783
+ * receives `PermissionError { code: "FORBIDDEN" }`.
504
784
  */
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");
785
+ export function useRecordPermissions(tableId, recordId) {
786
+ const ctx = useWidgetContextOrThrow("useRecordPermissions");
787
+ if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
788
+ throw new Error(
789
+ "useRecordPermissions: host did not inject a datastore client (ctx.datastore.records)",
790
+ );
509
791
  }
510
- const [users, setUsers] = useState([]);
511
- const [loading, setLoading] = useState(true);
792
+ const ready = Boolean(tableId && recordId);
793
+ const [permissions, setPermissions] = useState([]);
794
+ const [loading, setLoading] = useState(ready);
512
795
  const [error, setError] = useState(null);
513
796
 
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;
797
+ // Same ref discipline as useDirectory / useDatastoreQuery the host
798
+ // rebuilds the WidgetContext value every render, so we capture the
799
+ // live ids + client in refs to keep refetch + grant + revoke + update
800
+ // stable callback identities.
801
+ const tableIdRef = useRef(tableId);
802
+ const recordIdRef = useRef(recordId);
803
+ const recordsRef = useRef(ctx.datastore.records);
804
+ tableIdRef.current = tableId;
805
+ recordIdRef.current = recordId;
806
+ recordsRef.current = ctx.datastore.records;
807
+
808
+ // Resolve the per-record permissions namespace
809
+ // (ctx.datastore.records(tableId).permissions(recordId)) for the bound
810
+ // ids. Returns null when either id is missing.
811
+ const resolveNs = useCallback(() => {
812
+ const t = tableIdRef.current;
813
+ const r = recordIdRef.current;
814
+ if (!t || !r) return null;
815
+ const recordsNs = recordsRef.current(t);
816
+ if (!recordsNs || typeof recordsNs.permissions !== "function") {
817
+ throw new Error(
818
+ "useRecordPermissions: datastore client does not expose records(table).permissions(recordId)",
819
+ );
820
+ }
821
+ return recordsNs.permissions(r);
822
+ }, []);
521
823
 
522
824
  const runRef = useRef(0);
523
825
 
524
826
  const doFetch = useCallback(async () => {
525
827
  const myRun = ++runRef.current;
828
+ const ns = resolveNs();
829
+ if (!ns) {
830
+ // Collapse to a stable empty result without a network round-trip
831
+ // so a widget rendering before an active record is picked stays
832
+ // loop-free.
833
+ setLoading(false);
834
+ setError(null);
835
+ setPermissions([]);
836
+ return;
837
+ }
526
838
  setLoading(true);
527
839
  setError(null);
528
840
  try {
529
- const rows = await listUsersRef.current(queryRef.current);
841
+ const res = await ns.list();
842
+ // permissions().list() returns the { data, meta } envelope verbatim.
843
+ const rows = res && Array.isArray(res.data) ? res.data : [];
530
844
  if (runRef.current !== myRun) return;
531
- setUsers(Array.isArray(rows) ? rows : []);
845
+ setPermissions(rows);
532
846
  setLoading(false);
533
847
  } catch (err) {
534
848
  if (runRef.current !== myRun) return;
535
- setError(toDatastoreError(err));
849
+ setError(toPermissionError(err));
536
850
  setLoading(false);
537
851
  }
538
- }, []);
852
+ }, [resolveNs]);
539
853
 
540
- const queryKey = (() => {
541
- try {
542
- return JSON.stringify(query);
543
- } catch (_e) {
544
- return null;
545
- }
546
- })();
547
854
  useEffect(() => {
855
+ if (!ready) {
856
+ // Reset the slot when the widget unbinds (e.g. user navigates
857
+ // back to the picker) so the next render doesn't show stale
858
+ // permissions from a previous record.
859
+ setPermissions([]);
860
+ setLoading(false);
861
+ setError(null);
862
+ return;
863
+ }
548
864
  doFetch();
549
865
  // eslint-disable-next-line react-hooks/exhaustive-deps
550
- }, [queryKey]);
866
+ }, [tableId, recordId]);
551
867
 
552
868
  const refetch = useCallback(async () => {
553
869
  await doFetch();
554
870
  }, [doFetch]);
555
871
 
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
- }
872
+ const grant = useCallback(async (body) => {
873
+ const ns = resolveNs();
874
+ if (!ns) return null;
875
+ try {
876
+ // body is snake_case verbatim ({ user_id | group_id, can_read,
877
+ // can_write, can_delete, can_grant }) passed straight through.
878
+ const row = await ns.grant(body);
879
+ // Refresh after the mutation so subsequent reads observe the
880
+ // grant. The client returns the created row too, which we hand
881
+ // back to the caller for inline use.
882
+ await doFetch();
883
+ return row;
884
+ } catch (err) {
885
+ throw toPermissionError(err);
886
+ }
887
+ }, [resolveNs, doFetch]);
567
888
 
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
- }
889
+ const revoke = useCallback(async (permissionId) => {
890
+ const ns = resolveNs();
891
+ if (!ns) return undefined;
892
+ try {
893
+ await ns.revoke(permissionId);
894
+ await doFetch();
895
+ } catch (err) {
896
+ throw toPermissionError(err);
897
+ }
898
+ }, [resolveNs, doFetch]);
578
899
 
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
- }
900
+ const update = useCallback(async (permissionId, body) => {
901
+ const ns = resolveNs();
902
+ if (!ns) return null;
903
+ try {
904
+ // body (the patch) is snake_case verbatim.
905
+ const row = await ns.update(permissionId, body);
906
+ await doFetch();
907
+ return row;
908
+ } catch (err) {
909
+ throw toPermissionError(err);
910
+ }
911
+ }, [resolveNs, doFetch]);
596
912
 
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
- );
913
+ if (!ready) {
914
+ // Mutation no-ops are safe: a widget that does
915
+ // `await grant({...})` against an unbound hook resolves to null
916
+ // rather than throwing. Same shape as the populated case so the
917
+ // caller can branch on `tableId/recordId` once, at the top.
918
+ return _NOOP_PERMISSIONS_RESULT;
617
919
  }
618
- return ctx.renderer;
619
- }
620
920
 
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);
921
+ return { permissions, loading, error, grant, revoke, update, refetch };
635
922
  }
636
923
 
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.
924
+ /* ============================================================================
925
+ * FILES CLIENT ctx.files (@colixsystems/files-client)
646
926
  *
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
- }
927
+ * Files, folders, shares. Covers: useFile.
928
+ * ==========================================================================*/
654
929
 
655
930
  /**
656
- * Structured error thrown by `usePayments` callbacks. Carries a stable
657
- * `code` so widgets can branch without parsing message strings.
931
+ * Stateful file-asset resolver hook. Returns { url, file, loading, error,
932
+ * refetch }.
658
933
  *
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
934
+ * The host injects a `@colixsystems/files-client` instance at `ctx.files`,
935
+ * FLATTENED so file ops are top-level. We call `ctx.files.get(fileId)`,
936
+ * which resolves to `{ url, ...meta }` the returned file already carries
937
+ * an absolute URL so the widget can drop it straight into an `<Image
938
+ * source>` without knowing where the API lives. A missing/soft-deleted
939
+ * asset surfaces as `{ url: null, error: <DatastoreError NOT_FOUND> }`.
940
+ *
941
+ * When `fileId` is falsy the hook collapses to { url: null, file: null,
942
+ * loading: false, error: null, refetch } without a network round-trip,
943
+ * so a widget rendering before the author has bound an asset stays
944
+ * loop-free.
666
945
  */
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;
946
+ export function useFile(fileId) {
947
+ const ctx = useWidgetContextOrThrow("useFile");
948
+ if (!ctx.files || typeof ctx.files.get !== "function") {
949
+ throw new Error("useFile: host did not inject a files client");
673
950
  }
674
- }
951
+ const ready = Boolean(fileId);
952
+ const [file, setFile] = useState(null);
953
+ const [loading, setLoading] = useState(ready);
954
+ const [error, setError] = useState(null);
675
955
 
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
- }
956
+ const fileIdRef = useRef(fileId);
957
+ const getRef = useRef(ctx.files.get);
958
+ fileIdRef.current = fileId;
959
+ getRef.current = ctx.files.get;
699
960
 
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;
961
+ const runRef = useRef(0);
737
962
 
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
- );
963
+ const doFetch = useCallback(async () => {
964
+ const myRun = ++runRef.current;
965
+ const id = fileIdRef.current;
966
+ if (!id) {
967
+ setLoading(false);
968
+ setError(null);
969
+ setFile(null);
970
+ return;
751
971
  }
972
+ setLoading(true);
973
+ setError(null);
752
974
  try {
753
- return await getRef.current(paymentId);
975
+ const f = await getRef.current(id);
976
+ if (runRef.current !== myRun) return;
977
+ setFile(f || null);
978
+ setLoading(false);
754
979
  } catch (err) {
755
- throw toPaymentError(err);
980
+ if (runRef.current !== myRun) return;
981
+ setError(toDatastoreError(err));
982
+ setLoading(false);
756
983
  }
757
984
  }, []);
758
- return { requestPayment, getPayment };
985
+
986
+ useEffect(() => {
987
+ doFetch();
988
+ // eslint-disable-next-line react-hooks/exhaustive-deps
989
+ }, [fileId]);
990
+
991
+ const refetch = useCallback(async () => {
992
+ await doFetch();
993
+ }, [doFetch]);
994
+
995
+ const url =
996
+ file && typeof file.url === "string" && file.url.length > 0
997
+ ? file.url
998
+ : null;
999
+ return { url, file, loading, error, refetch };
759
1000
  }
760
1001
 
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
- */
1002
+ /* ============================================================================
1003
+ * DIRECTORY CLIENT ctx.directory (@colixsystems/directory-client)
1004
+ *
1005
+ * Me, users, groups, invites. Covers:
1006
+ * useDirectory, useUsers, useGroups.
1007
+ * ==========================================================================*/
1008
+
767
1009
  /**
768
1010
  * REQ-USERMGMT / REQ-ACL-SYS M3 — structured error thrown by `useUsers` /
769
1011
  * `useGroups` callbacks. Carries a stable `code` so widgets can branch
@@ -814,14 +1056,103 @@ function toDirectoryError(err) {
814
1056
  return new DirectoryError(code, message, { cause: err });
815
1057
  }
816
1058
 
1059
+ /**
1060
+ * Stateful user-directory query hook. Returns { users, loading, error,
1061
+ * refetch }.
1062
+ *
1063
+ * The host injects a `@colixsystems/directory-client` instance at
1064
+ * `ctx.directory`. We call `ctx.directory.users.list(query)`, which resolves
1065
+ * to the `{ data, meta }` list envelope VERBATIM; we unwrap `res.data`
1066
+ * (default `[]`) into the `users` slot. Rows are snake_case (`id`, `name`,
1067
+ * `role`, …) — the privacy-reduced directory projection the backend hands to
1068
+ * non-Studio (Player) callers. Use it to build a chat people-list, an
1069
+ * @-mention picker, or to resolve an author id to a display name. Mutating
1070
+ * users is NOT part of this surface; the directory is read-only here (see
1071
+ * useUsers for administration).
1072
+ *
1073
+ * `query` is an optional `{ q?, role?, is_active?, limit?, offset? }`
1074
+ * object passed through verbatim. The hook re-fetches whenever
1075
+ * `JSON.stringify(query)` changes and exposes `refetch` for on-demand
1076
+ * reloads (e.g. a chat roster refresh).
1077
+ *
1078
+ * Requires the `directory.read:users` scope in the widget manifest's
1079
+ * `requestedScopes`.
1080
+ */
1081
+ export function useDirectory(query) {
1082
+ const ctx = useWidgetContextOrThrow("useDirectory");
1083
+ if (
1084
+ !ctx.directory ||
1085
+ !ctx.directory.users ||
1086
+ typeof ctx.directory.users.list !== "function"
1087
+ ) {
1088
+ throw new Error(
1089
+ "useDirectory: host did not inject a directory client (ctx.directory.users.list)",
1090
+ );
1091
+ }
1092
+ const [users, setUsers] = useState([]);
1093
+ const [loading, setLoading] = useState(true);
1094
+ const [error, setError] = useState(null);
1095
+
1096
+ // Same ref discipline as useDatastoreQuery: the host rebuilds the
1097
+ // WidgetContext value every render, so we capture the live query +
1098
+ // client in refs to keep `refetch` a stable identity.
1099
+ const queryRef = useRef(query);
1100
+ const usersNsRef = useRef(ctx.directory.users);
1101
+ queryRef.current = query;
1102
+ usersNsRef.current = ctx.directory.users;
1103
+
1104
+ const runRef = useRef(0);
1105
+
1106
+ const doFetch = useCallback(async () => {
1107
+ const myRun = ++runRef.current;
1108
+ setLoading(true);
1109
+ setError(null);
1110
+ try {
1111
+ const res = await usersNsRef.current.list(queryRef.current);
1112
+ // Directory list returns the { data, meta } envelope verbatim.
1113
+ const rows = res && Array.isArray(res.data) ? res.data : [];
1114
+ if (runRef.current !== myRun) return;
1115
+ setUsers(rows);
1116
+ setLoading(false);
1117
+ } catch (err) {
1118
+ if (runRef.current !== myRun) return;
1119
+ setError(toDatastoreError(err));
1120
+ setLoading(false);
1121
+ }
1122
+ }, []);
1123
+
1124
+ const queryKey = (() => {
1125
+ try {
1126
+ return JSON.stringify(query);
1127
+ } catch (_e) {
1128
+ return null;
1129
+ }
1130
+ })();
1131
+ useEffect(() => {
1132
+ doFetch();
1133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1134
+ }, [queryKey]);
1135
+
1136
+ const refetch = useCallback(async () => {
1137
+ await doFetch();
1138
+ }, [doFetch]);
1139
+
1140
+ return { users, loading, error, refetch };
1141
+ }
1142
+
817
1143
  /**
818
1144
  * REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook.
819
1145
  *
820
1146
  * 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
1147
+ * reactivate, remove }`. Reads through the injected
1148
+ * `@colixsystems/directory-client` at `ctx.directory.users.{list, get,
1149
+ * invite, deactivate, reactivate}` `list` resolves to the `{ data, meta }`
1150
+ * envelope VERBATIM, so we unwrap `res.data` (default `[]`). User rows are
1151
+ * snake_case (`id`, `name`, `email`, `role`, `is_active`, …) and bodies
1152
+ * (e.g. `{ email, name, group_ids? }`) pass through verbatim. The list
1153
+ * refetches whenever `JSON.stringify(query)` changes; the imperative methods
1154
+ * reject with a `DirectoryError`. Reads require the `users.read:*` scope;
1155
+ * mutations additionally require `users.write:*`. The host's signed
825
1156
  * `X-Widget-Scopes` header + a tenant-scoped SystemAcl `users.read` /
826
1157
  * `users.write` capability grant gate the underlying endpoint
827
1158
  * (REQ-ACL-SYS M3 §4.3) — a widget that declares the scopes but whose
@@ -829,17 +1160,23 @@ function toDirectoryError(err) {
829
1160
  */
830
1161
  export function useUsers(query) {
831
1162
  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");
1163
+ if (
1164
+ !ctx.directory ||
1165
+ !ctx.directory.users ||
1166
+ typeof ctx.directory.users.list !== "function"
1167
+ ) {
1168
+ throw new Error(
1169
+ "useUsers: host did not inject a directory client (ctx.directory.users)",
1170
+ );
834
1171
  }
835
1172
  const [users, setUsers] = useState([]);
836
1173
  const [loading, setLoading] = useState(true);
837
1174
  const [error, setError] = useState(null);
838
1175
 
839
1176
  const queryRef = useRef(query);
840
- const usersRef = useRef(ctx.users);
1177
+ const usersRef = useRef(ctx.directory.users);
841
1178
  queryRef.current = query;
842
- usersRef.current = ctx.users;
1179
+ usersRef.current = ctx.directory.users;
843
1180
 
844
1181
  const runRef = useRef(0);
845
1182
 
@@ -848,9 +1185,11 @@ export function useUsers(query) {
848
1185
  setLoading(true);
849
1186
  setError(null);
850
1187
  try {
851
- const rows = await usersRef.current.listUsers(queryRef.current);
1188
+ const res = await usersRef.current.list(queryRef.current);
1189
+ // Directory users.list returns the { data, meta } envelope verbatim.
1190
+ const rows = res && Array.isArray(res.data) ? res.data : [];
852
1191
  if (runRef.current !== myRun) return;
853
- setUsers(Array.isArray(rows) ? rows : []);
1192
+ setUsers(rows);
854
1193
  setLoading(false);
855
1194
  } catch (err) {
856
1195
  if (runRef.current !== myRun) return;
@@ -911,22 +1250,33 @@ export function useUsers(query) {
911
1250
  * REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration hook.
912
1251
  *
913
1252
  * 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`.
1253
+ * removeMember }`. Reads through the injected
1254
+ * `@colixsystems/directory-client` at `ctx.directory.groups.{list, create,
1255
+ * remove, addMember, removeMember, listMine}` — `list` resolves to the
1256
+ * `{ data, meta }` envelope VERBATIM, so we unwrap `res.data` (default `[]`).
1257
+ * Group rows are snake_case and bodies pass through verbatim. Reads require
1258
+ * `groups.read:*`; mutations require `groups.write:*`. Same X-Widget-Scopes +
1259
+ * SystemAcl gating as `useUsers`.
916
1260
  */
917
1261
  export function useGroups(query) {
918
1262
  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");
1263
+ if (
1264
+ !ctx.directory ||
1265
+ !ctx.directory.groups ||
1266
+ typeof ctx.directory.groups.list !== "function"
1267
+ ) {
1268
+ throw new Error(
1269
+ "useGroups: host did not inject a directory client (ctx.directory.groups)",
1270
+ );
921
1271
  }
922
1272
  const [groups, setGroups] = useState([]);
923
1273
  const [loading, setLoading] = useState(true);
924
1274
  const [error, setError] = useState(null);
925
1275
 
926
1276
  const queryRef = useRef(query);
927
- const groupsRef = useRef(ctx.groups);
1277
+ const groupsRef = useRef(ctx.directory.groups);
928
1278
  queryRef.current = query;
929
- groupsRef.current = ctx.groups;
1279
+ groupsRef.current = ctx.directory.groups;
930
1280
 
931
1281
  const runRef = useRef(0);
932
1282
 
@@ -935,9 +1285,11 @@ export function useGroups(query) {
935
1285
  setLoading(true);
936
1286
  setError(null);
937
1287
  try {
938
- const rows = await groupsRef.current.listGroups(queryRef.current);
1288
+ const res = await groupsRef.current.list(queryRef.current);
1289
+ // Directory groups.list returns the { data, meta } envelope verbatim.
1290
+ const rows = res && Array.isArray(res.data) ? res.data : [];
939
1291
  if (runRef.current !== myRun) return;
940
- setGroups(Array.isArray(rows) ? rows : []);
1292
+ setGroups(rows);
941
1293
  setLoading(false);
942
1294
  } catch (err) {
943
1295
  if (runRef.current !== myRun) return;
@@ -994,33 +1346,35 @@ export function useGroups(query) {
994
1346
  return { groups, loading, error, refetch, create, remove, addMember, removeMember };
995
1347
  }
996
1348
 
1349
+ /* ============================================================================
1350
+ * PAYMENTS CLIENT — ctx.payments (@colixsystems/payments-client)
1351
+ *
1352
+ * requestPayment, getPayment. Covers: usePayments.
1353
+ * ==========================================================================*/
1354
+
997
1355
  /**
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`.
1356
+ * Structured error thrown by `usePayments` callbacks. Carries a stable
1357
+ * `code` so widgets can branch without parsing message strings.
1003
1358
  *
1004
1359
  * `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).
1360
+ * - "AUTH_REQUIRED" no signed-in app user
1361
+ * - "PAYMENTS_SCOPE_NOT_GRANTED"— widget lacks payments.charge:appUser
1362
+ * - "INVALID_AMOUNT" / "AMOUNT_TOO_LARGE" / "VALIDATION" bad request
1363
+ * - "CONNECT_NOT_READY" / "PAYMENTS_DISABLED" — provider not ready
1364
+ * - "DECLINED" the charge was declined
1365
+ * - "INTERNAL" — anything else
1011
1366
  */
1012
- export class PermissionError extends Error {
1367
+ export class PaymentError extends Error {
1013
1368
  constructor(code, message, opts) {
1014
1369
  super(message);
1015
- this.name = "PermissionError";
1370
+ this.name = "PaymentError";
1016
1371
  this.code = code;
1017
- if (opts && opts.status !== undefined) this.status = opts.status;
1018
1372
  if (opts && opts.cause) this.cause = opts.cause;
1019
1373
  }
1020
1374
  }
1021
1375
 
1022
- function toPermissionError(err) {
1023
- if (err instanceof PermissionError) return err;
1376
+ function toPaymentError(err) {
1377
+ if (err instanceof PaymentError) return err;
1024
1378
  const status =
1025
1379
  err && err.response && typeof err.response.status === "number"
1026
1380
  ? err.response.status
@@ -1030,219 +1384,76 @@ function toPermissionError(err) {
1030
1384
  const bodyMessage =
1031
1385
  err && err.response && err.response.data && err.response.data.error;
1032
1386
  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
- }
1387
+ if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
1388
+ else if (status === 401) code = "AUTH_REQUIRED";
1389
+ else if (status === 402) code = "DECLINED";
1390
+ else if (status === 403) code = "FORBIDDEN";
1391
+ else if (status === 400) code = "VALIDATION";
1043
1392
  const message =
1044
1393
  (typeof bodyMessage === "string" && bodyMessage) ||
1045
1394
  (err && typeof err.message === "string"
1046
1395
  ? err.message
1047
- : "Record permission call failed");
1048
- return new PermissionError(code, message, { status, cause: err });
1396
+ : "Payment request failed");
1397
+ return new PaymentError(code, message, { cause: err });
1049
1398
  }
1050
1399
 
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
1400
  /**
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.
1401
+ * Incoming app-user payments (REQ-BILL-07-WIDGETPAY). Returns
1402
+ * `{ requestPayment, getPayment }`.
1081
1403
  *
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.
1404
+ * requestPayment({ amountCents, currency?, description, metadata? })
1405
+ * Promise<{ id, status, checkoutUrl?, ... }>. The host either
1406
+ * auto-confirms (mock provider, `status: "PAID"`, no redirect) or
1407
+ * returns a hosted-Checkout `checkoutUrl` the widget should open
1408
+ * (Stripe provider, `status: "PENDING"`). Rejects with a
1409
+ * `PaymentError`.
1410
+ * getPayment(paymentId) → Promise<payment> — poll the terminal status.
1087
1411
  *
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" }`.
1412
+ * Requires the `payments.charge:appUser` scope in the manifest's
1413
+ * `requestedScopes`. The charge settles to the workspace owner; the app
1414
+ * user confirms the amount in hosted Checkout. No card data touches the
1415
+ * widget never collect card fields yourself.
1094
1416
  */
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
- ) {
1417
+ export function usePayments() {
1418
+ const ctx = useWidgetContextOrThrow("usePayments");
1419
+ if (!ctx.payments || typeof ctx.payments.requestPayment !== "function") {
1104
1420
  throw new Error(
1105
- "useRecordPermissions: host did not inject a recordPermissions client",
1421
+ "usePayments: host did not inject a payments client. The widget must " +
1422
+ "declare the payments.charge:appUser scope and be installed in a " +
1423
+ "workspace whose host supports payments.",
1106
1424
  );
1107
1425
  }
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);
1426
+ const requestRef = useRef(ctx.payments.requestPayment);
1427
+ const getRef = useRef(
1428
+ typeof ctx.payments.getPayment === "function"
1429
+ ? ctx.payments.getPayment
1430
+ : null,
1431
+ );
1432
+ requestRef.current = ctx.payments.requestPayment;
1433
+ getRef.current =
1434
+ typeof ctx.payments.getPayment === "function"
1435
+ ? ctx.payments.getPayment
1436
+ : null;
1125
1437
 
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);
1438
+ const requestPayment = useCallback(async (args) => {
1141
1439
  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);
1440
+ return await requestRef.current(args);
1146
1441
  } catch (err) {
1147
- if (runRef.current !== myRun) return;
1148
- setError(toPermissionError(err));
1149
- setLoading(false);
1442
+ throw toPaymentError(err);
1150
1443
  }
1151
1444
  }, []);
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);
1445
+ const getPayment = useCallback(async (paymentId) => {
1446
+ if (!getRef.current) {
1447
+ throw new PaymentError(
1448
+ "INTERNAL",
1449
+ "getPayment is not supported by this host.",
1450
+ );
1196
1451
  }
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
1452
  try {
1204
- const row = await clientRef.current.update(t, r, permissionId, body);
1205
- await doFetch();
1206
- return row;
1453
+ return await getRef.current(paymentId);
1207
1454
  } catch (err) {
1208
- throw toPermissionError(err);
1455
+ throw toPaymentError(err);
1209
1456
  }
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 };
1457
+ }, []);
1458
+ return { requestPayment, getPayment };
1248
1459
  }