@colixsystems/widget-sdk 0.13.0 → 0.15.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/contract.js CHANGED
@@ -171,6 +171,112 @@ const HOOKS = [
171
171
  requiredContextSlice: ["payments.requestPayment"],
172
172
  scopes: ["payments.charge:appUser"],
173
173
  },
174
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
175
+ // `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
176
+ // Reads need `users.read:*` scope; mutations additionally need
177
+ // `users.write:*`. The `invite` call accepts `{ email, name, groupIds? }`
178
+ // and returns the resulting AppUserInvite row (the email is sent by the
179
+ // host). Mutating users from a widget requires the corresponding
180
+ // SystemAcl `users.write` capability grant in the tenant; widgets that
181
+ // only call read methods need only `users.read:*`.
182
+ {
183
+ name: "useUsers",
184
+ signature: "useUsers(query?)",
185
+ description:
186
+ "AppUser administration. Returns { users, loading, error, refetch, invite, " +
187
+ "deactivate, reactivate, remove }. Reads need users.read:* scope; mutations " +
188
+ "need users.write:*. The `invite` call accepts { email, name, groupIds? } " +
189
+ "and returns the resulting AppUserInvite row (the email is sent by the host).",
190
+ returnShape: {
191
+ users: "Array<{ id, name, email?, role, isActive }>",
192
+ loading: "boolean",
193
+ error: "DirectoryError | null",
194
+ refetch: "() => Promise<void>",
195
+ invite:
196
+ "({ email, name, groupIds? }) => Promise<Invite> // rejects with DirectoryError",
197
+ deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
198
+ reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
199
+ remove: "(userId) => Promise<void> // rejects with DirectoryError",
200
+ },
201
+ requiredContextSlice: [
202
+ "users.listUsers",
203
+ "users.invite",
204
+ "users.deactivate",
205
+ "users.reactivate",
206
+ "users.remove",
207
+ ],
208
+ scopes: ["users.read:*"],
209
+ },
210
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
211
+ // `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
212
+ // Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
213
+ // member requires the corresponding `users.write` capability since it
214
+ // affects the user's effective access.
215
+ {
216
+ name: "useGroups",
217
+ signature: "useGroups(query?)",
218
+ description:
219
+ "AppUserGroup administration. Returns { groups, loading, error, refetch, " +
220
+ "create, remove, addMember, removeMember }. Reads need groups.read:*; " +
221
+ "mutations need groups.write:*.",
222
+ returnShape: {
223
+ groups: "Array<{ id, name, memberCount }>",
224
+ loading: "boolean",
225
+ error: "DirectoryError | null",
226
+ refetch: "() => Promise<void>",
227
+ create:
228
+ "({ name }) => Promise<Group> // rejects with DirectoryError",
229
+ remove: "(groupId) => Promise<void> // rejects with DirectoryError",
230
+ addMember:
231
+ "(groupId, userId) => Promise<void> // rejects with DirectoryError",
232
+ removeMember:
233
+ "(groupId, userId) => Promise<void> // rejects with DirectoryError",
234
+ },
235
+ requiredContextSlice: [
236
+ "groups.listGroups",
237
+ "groups.create",
238
+ "groups.remove",
239
+ "groups.addMember",
240
+ "groups.removeMember",
241
+ ],
242
+ scopes: ["groups.read:*"],
243
+ },
244
+ // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks.
245
+ {
246
+ name: "useClipboard",
247
+ signature: "useClipboard()",
248
+ description:
249
+ "Cross-platform clipboard access. Returns { copy, paste, hasContent }. " +
250
+ "All methods return Promises; rejections surface a structured ClipboardError " +
251
+ "with .code in PERMISSION_DENIED | INTERNAL. On web the browser may " +
252
+ "require a user gesture for read access — surface the error to the " +
253
+ "user as a 'try again after clicking' prompt when code === PERMISSION_DENIED.",
254
+ returnShape: {
255
+ copy:
256
+ "(text: string) => Promise<void> // rejects with ClipboardError",
257
+ paste:
258
+ "() => Promise<string> // rejects with ClipboardError",
259
+ hasContent: "() => Promise<boolean> // best-effort, never throws",
260
+ },
261
+ requiredContextSlice: [],
262
+ scopes: null,
263
+ },
264
+ {
265
+ name: "useToast",
266
+ signature: "useToast()",
267
+ description:
268
+ "Surfaces a short auto-dismissing notification. Returns { showToast }. " +
269
+ "showToast({ kind: 'success' | 'error' | 'info' | 'warning', message }) " +
270
+ "asks the host to render a workspace-themed toast. If the host hasn't " +
271
+ "wired a renderer, the web variant dispatches an 'appstudio:widget-toast' " +
272
+ "CustomEvent on window; native logs to the console. The widget never " +
273
+ "owns the toast UI — that's the host's responsibility.",
274
+ returnShape: {
275
+ showToast: "({ kind, message }) => void",
276
+ },
277
+ requiredContextSlice: ["toast.showToast"],
278
+ scopes: null,
279
+ },
174
280
  ];
175
281
 
176
282
  // REQ-WSDK-RN-WEB: see contract.cjs for the source-of-truth comment.
@@ -259,6 +365,22 @@ const PRIMITIVES = [
259
365
  rnComponent: "Linking",
260
366
  docsUrl: "https://reactnative.dev/docs/linking",
261
367
  },
368
+ // REQ-WSDK-PLATFORM §6 — Tier A SDK primitive.
369
+ {
370
+ name: "Icon",
371
+ description:
372
+ 'Lucide icon. `<Icon name="check" size={16} color="..." />`. Unknown names render the Square fallback so the canvas always shows something visible. Names are the lucide icon ids (`https://lucide.dev/icons`). Works on both web and native.',
373
+ rnComponent: "lucide-react-native",
374
+ docsUrl: "https://lucide.dev/icons",
375
+ },
376
+ // REQ-WSDK-PLATFORM §6 — Tier A SDK primitive.
377
+ {
378
+ name: "DateTimePicker",
379
+ description:
380
+ 'Cross-platform date / time / datetime picker. `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" />`. The value prop and the onChange callback both speak ISO 8601 strings (the datastore wire format) — authors never round-trip through `new Date()`. Web renders the browser\'s native input via react-native-web; native uses @react-native-community/datetimepicker.',
381
+ rnComponent: "@react-native-community/datetimepicker",
382
+ docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
383
+ },
262
384
  ];
263
385
 
264
386
  const CATEGORIES = [
@@ -427,6 +549,38 @@ const WIDGET_CONTEXT_SHAPE = {
427
549
  required: true,
428
550
  fields: { requestPayment: "function", getPayment: "function" },
429
551
  },
552
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration facade backing
553
+ // useUsers(). Reads gated by `users.read:*`; mutations by `users.write:*`.
554
+ // The host signs an `X-Widget-Scopes` header against JWT_SECRET so an
555
+ // APP_USER cannot forge scope claims, and the request is additionally
556
+ // gated by a SystemAcl `users.read` / `users.write` capability grant.
557
+ users: {
558
+ description:
559
+ "AppUser administration. { listUsers(query?) -> Promise<User[]>, invite({ email, name, groupIds? }) -> Promise<Invite>, deactivate(userId) -> Promise<User>, reactivate(userId) -> Promise<User>, remove(userId) -> Promise<void> }. Backs useUsers(); reads require users.read:*, mutations require users.write:*.",
560
+ required: true,
561
+ fields: {
562
+ listUsers: "function",
563
+ invite: "function",
564
+ deactivate: "function",
565
+ reactivate: "function",
566
+ remove: "function",
567
+ },
568
+ },
569
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration facade
570
+ // backing useGroups(). Reads gated by `groups.read:*`; mutations by
571
+ // `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as users.
572
+ groups: {
573
+ description:
574
+ "AppUserGroup administration. { listGroups(query?) -> Promise<Group[]>, create({ name }) -> Promise<Group>, remove(groupId) -> Promise<void>, addMember(groupId, userId) -> Promise<void>, removeMember(groupId, userId) -> Promise<void> }. Backs useGroups(); reads require groups.read:*, mutations require groups.write:*.",
575
+ required: true,
576
+ fields: {
577
+ listGroups: "function",
578
+ create: "function",
579
+ remove: "function",
580
+ addMember: "function",
581
+ removeMember: "function",
582
+ },
583
+ },
430
584
  i18n: {
431
585
  description: "{ t(key, fallback?), locale }.",
432
586
  required: true,
@@ -443,6 +597,20 @@ const WIDGET_CONTEXT_SHAPE = {
443
597
  error: "function",
444
598
  },
445
599
  },
600
+ // REQ-WSDK-PLATFORM §6 — backs useToast(). The host installs its own
601
+ // workspace-themed toast renderer here; if omitted, the SDK's useToast
602
+ // hook falls back to a CustomEvent on web / console.log on native so
603
+ // widget code still runs without a host integration.
604
+ toast: {
605
+ description:
606
+ "Optional host toast slot. { showToast({ kind, message }): void }. " +
607
+ "The host populates this to render workspace-themed notifications " +
608
+ "from any widget that calls useToast(). When omitted the SDK falls " +
609
+ "back to dispatching an 'appstudio:widget-toast' CustomEvent on web " +
610
+ "and console.log on native.",
611
+ required: false,
612
+ fields: { showToast: "function" },
613
+ },
446
614
  };
447
615
 
448
616
  const BUNDLE_EXPORT_CONTRACT = [
@@ -465,6 +633,9 @@ const BUNDLE_EXPORT_CONTRACT = [
465
633
  },
466
634
  ];
467
635
 
636
+ // REQ-WSDK-PLATFORM (docs/design/req-widget-sdk-cross-platform-primitives.md
637
+ // §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. See contract.cjs
638
+ // for the source-of-truth comment.
468
639
  const BANNED_APIS = [
469
640
  { identifier: "eval", reason: "Arbitrary code evaluation." },
470
641
  {
@@ -493,21 +664,119 @@ const BANNED_APIS = [
493
664
  reason: "Same reason as localStorage.",
494
665
  },
495
666
  {
496
- identifier: "fetch",
497
- reason: "Direct network calls bypass the datastore client + tenant auth.",
667
+ identifier: "import(",
668
+ reason: "Dynamic import bypasses the loader's allowlist.",
498
669
  },
670
+ { identifier: "globalThis", reason: "Host environment escape." },
671
+ ];
672
+
673
+ // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. See contract.cjs
674
+ // for the source-of-truth comment.
675
+ const VETTED_IMPORTS = [
499
676
  {
500
- identifier: "XMLHttpRequest",
501
- reason: "Direct network calls bypass the datastore client + tenant auth.",
677
+ specifier: "react",
678
+ platforms: ["web", "native"],
679
+ category: "core",
680
+ description: "React. Hooks, JSX, lifecycle. Unchanged.",
502
681
  },
503
682
  {
504
- identifier: "import(",
505
- reason: "Dynamic import bypasses the loader's allowlist.",
683
+ specifier: "@colixsystems/widget-sdk",
684
+ platforms: ["web", "native"],
685
+ category: "core",
686
+ description:
687
+ "The AppStudio widget SDK — primitives, hooks, manifest helpers. Unchanged.",
688
+ },
689
+ {
690
+ specifier: "react-native",
691
+ platforms: ["web", "native"],
692
+ category: "primitive",
693
+ description:
694
+ "Direct RN imports for APIs the SDK hasn't re-exported yet. On web the host bundler aliases this to react-native-web; on native Metro resolves the real library.",
695
+ },
696
+ {
697
+ specifier: "axios",
698
+ platforms: ["web", "native"],
699
+ category: "network",
700
+ description:
701
+ "HTTP client for third-party APIs. Calls to the host's /api/* surface are blocked at runtime — widgets get no JWT token, so use SDK hooks for workspace data.",
702
+ },
703
+ {
704
+ specifier: "date-fns",
705
+ platforms: ["web", "native"],
706
+ category: "utility",
707
+ description: "Pure-JS date math. Works on both platforms unchanged.",
708
+ },
709
+ {
710
+ specifier: "react-native-svg",
711
+ platforms: ["web", "native"],
712
+ category: "drawing",
713
+ description:
714
+ "Cross-platform SVG drawing primitives. Used by the built-in Chart widget; works on both platforms.",
715
+ },
716
+ {
717
+ specifier: "lucide-react-native",
718
+ platforms: ["web", "native"],
719
+ category: "iconography",
720
+ description:
721
+ "Lucide icon set as React components. Used by the built-in Icon widget; works on both platforms.",
722
+ },
723
+ {
724
+ specifier: "react-native-maps",
725
+ platforms: ["native"],
726
+ category: "geo",
727
+ description:
728
+ "Native map view + markers. Native-only; pair with leaflet/react-leaflet in widget.web.jsx for a web variant.",
729
+ },
730
+ {
731
+ specifier: "leaflet",
732
+ platforms: ["web"],
733
+ category: "geo",
734
+ description:
735
+ "Web-only mapping library. Use alongside react-leaflet in widget.web.jsx as the web counterpart to react-native-maps.",
736
+ },
737
+ {
738
+ specifier: "react-leaflet",
739
+ platforms: ["web"],
740
+ category: "geo",
741
+ description: "React bindings for leaflet. Web-only.",
742
+ },
743
+ {
744
+ specifier: "expo-av",
745
+ platforms: ["native"],
746
+ category: "media",
747
+ description:
748
+ "Native audio + video playback. Native-only; pair with browser <audio>/<video> in widget.web.jsx.",
749
+ },
750
+ {
751
+ specifier: "@react-native-community/datetimepicker",
752
+ platforms: ["native"],
753
+ category: "input",
754
+ description:
755
+ "Native date/time picker. The SDK's <DateTimePicker> primitive already wraps this; reach for it directly only if you need RN-specific options.",
756
+ },
757
+ {
758
+ specifier: "expo-clipboard",
759
+ platforms: ["native"],
760
+ category: "system",
761
+ description:
762
+ "Native clipboard. The SDK's useClipboard() hook already wraps this; reach for it directly only if you need RN-specific options.",
763
+ },
764
+ {
765
+ specifier: "expo-haptics",
766
+ platforms: ["native"],
767
+ category: "system",
768
+ description:
769
+ "Native haptic feedback. Pair with navigator.vibrate in widget.web.jsx.",
506
770
  },
507
- { identifier: "globalThis", reason: "Host environment escape." },
508
771
  ];
509
772
 
510
- const ALLOWED_BARE_IMPORTS = ["react", "@colixsystems/widget-sdk"];
773
+ const ALLOWED_BARE_IMPORTS = VETTED_IMPORTS.map((v) => v.specifier);
774
+
775
+ const HOST_API_URL_PATTERNS = [
776
+ "/api/v1",
777
+ "/uploads/",
778
+ "Authorization: Bearer",
779
+ ];
511
780
 
512
781
  function deepFreeze(value) {
513
782
  if (value === null || typeof value !== "object") return value;
@@ -517,7 +786,7 @@ function deepFreeze(value) {
517
786
  }
518
787
 
519
788
  const CONTRACT = deepFreeze({
520
- version: "1.2.0",
789
+ version: "1.5.0",
521
790
  hooks: HOOKS,
522
791
  primitives: PRIMITIVES,
523
792
  manifestSchema: MANIFEST_SCHEMA,
@@ -527,7 +796,9 @@ const CONTRACT = deepFreeze({
527
796
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
528
797
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
529
798
  bannedApis: BANNED_APIS,
799
+ vettedImports: VETTED_IMPORTS,
530
800
  allowedBareImports: ALLOWED_BARE_IMPORTS,
801
+ hostApiUrlPatterns: HOST_API_URL_PATTERNS,
531
802
  });
532
803
 
533
804
  function isHookAllowed(name) {
@@ -0,0 +1,102 @@
1
+ // REQ-WSDK-PLATFORM §6 — `<DateTimePicker>` SDK primitive.
2
+ //
3
+ // Cross-platform date / time / datetime picker. Wraps
4
+ // `@react-native-community/datetimepicker` (works on both web and native:
5
+ // on web it renders the browser's native `<input type="date|time">`
6
+ // surface through react-native-web's mapping). The wire format is ISO
7
+ // 8601 strings — the same format the datastore speaks, so widget authors
8
+ // never round-trip through `new Date()`.
9
+ //
10
+ // Props:
11
+ // value: string | null — ISO 8601 (`2026-05-28` for date mode,
12
+ // `2026-05-28T14:30:00.000Z` for datetime).
13
+ // `null` defaults to "now".
14
+ // onChange: (iso: string) => void
15
+ // mode: "date" | "time" | "datetime" — default "date"
16
+ // minimumDate / maximumDate: string | null — ISO bounds
17
+ // disabled: boolean
18
+ //
19
+ // The author writes:
20
+ // const [day, setDay] = useState(null);
21
+ // <DateTimePicker value={day} onChange={setDay} mode="date" />
22
+ //
23
+ // …and `day` ends up as an ISO string suitable for storing directly into
24
+ // a DATE column. The previous pattern of importing the RN library
25
+ // directly and managing `Date` objects in widget state is gone — the
26
+ // primitive normalizes both ends.
27
+
28
+ import React, { useMemo } from "react";
29
+ // eslint-disable-next-line no-restricted-syntax
30
+ import RNDateTimePicker from "@react-native-community/datetimepicker";
31
+
32
+ function _parseToDate(value) {
33
+ if (value == null || value === "") return new Date();
34
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? new Date() : value;
35
+ if (typeof value === "string") {
36
+ const d = new Date(value);
37
+ return Number.isNaN(d.getTime()) ? new Date() : d;
38
+ }
39
+ return new Date();
40
+ }
41
+
42
+ function _formatToIso(date, mode) {
43
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
44
+ if (mode === "date") {
45
+ // Local-date ISO (yyyy-mm-dd) — calendar dates should be timezone-free
46
+ // so a "May 28" picked in Stockholm doesn't read as "May 27" in NYC.
47
+ const y = date.getFullYear();
48
+ const m = String(date.getMonth() + 1).padStart(2, "0");
49
+ const d = String(date.getDate()).padStart(2, "0");
50
+ return `${y}-${m}-${d}`;
51
+ }
52
+ if (mode === "time") {
53
+ // Local-time ISO (hh:mm) — time-of-day is timezone-free for the same
54
+ // reason. Authors who want a full datetime get mode="datetime".
55
+ const h = String(date.getHours()).padStart(2, "0");
56
+ const mm = String(date.getMinutes()).padStart(2, "0");
57
+ return `${h}:${mm}`;
58
+ }
59
+ // datetime — full UTC ISO so the wire format round-trips through the
60
+ // datastore's DATE column unchanged.
61
+ return date.toISOString();
62
+ }
63
+
64
+ export function DateTimePicker({
65
+ value,
66
+ onChange,
67
+ mode,
68
+ minimumDate,
69
+ maximumDate,
70
+ disabled,
71
+ }) {
72
+ const effectiveMode = mode === "time" || mode === "datetime" ? mode : "date";
73
+ const dateValue = useMemo(() => _parseToDate(value), [value]);
74
+ const min = useMemo(
75
+ () => (minimumDate ? _parseToDate(minimumDate) : undefined),
76
+ [minimumDate],
77
+ );
78
+ const max = useMemo(
79
+ () => (maximumDate ? _parseToDate(maximumDate) : undefined),
80
+ [maximumDate],
81
+ );
82
+
83
+ const handleChange = (_event, picked) => {
84
+ if (typeof onChange !== "function") return;
85
+ if (!(picked instanceof Date)) return;
86
+ const iso = _formatToIso(picked, effectiveMode);
87
+ if (iso != null) onChange(iso);
88
+ };
89
+
90
+ // The RN library's `mode` accepts "date" / "time"; for "datetime" we
91
+ // ask for "datetime" on iOS / Android and let the picker's
92
+ // implementation handle it. react-native-web's mapping interprets
93
+ // "datetime" as `<input type="datetime-local">`.
94
+ return React.createElement(RNDateTimePicker, {
95
+ value: dateValue,
96
+ mode: effectiveMode,
97
+ minimumDate: min,
98
+ maximumDate: max,
99
+ disabled: !!disabled,
100
+ onChange: handleChange,
101
+ });
102
+ }