@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/README.md +78 -3
- package/dist/clipboard.js +88 -0
- package/dist/clipboard.native.js +64 -0
- package/dist/contract.cjs +318 -11
- package/dist/contract.js +280 -9
- package/dist/datetimepicker.js +102 -0
- package/dist/hooks.js +233 -1
- package/dist/icon.js +29 -0
- package/dist/index.d.ts +126 -0
- package/dist/index.js +10 -0
- package/dist/index.native.js +8 -0
- package/dist/linter.cjs +243 -9
- package/dist/linter.js +309 -10
- package/dist/primitives.js +8 -0
- package/dist/primitives.native.js +9 -0
- package/dist/property-schema.js +7 -0
- package/dist/toast.js +73 -0
- package/dist/toast.native.js +46 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,7 +6,35 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
6
6
|
|
|
7
7
|
## Status
|
|
8
8
|
|
|
9
|
-
`v0.
|
|
9
|
+
`v0.15.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
10
|
+
|
|
11
|
+
### What's new in 0.15.0
|
|
12
|
+
|
|
13
|
+
REQ-WSDK-PLATFORM — the "split-implementation + vetted package list" pivot. See [`docs/design/req-widget-sdk-cross-platform-primitives.md`](../../docs/design/req-widget-sdk-cross-platform-primitives.md) for the full design and rationale.
|
|
14
|
+
|
|
15
|
+
- **`CONTRACT.vettedImports` (new).** A curated allowlist of bare specifiers a widget may import — `react`, `@colixsystems/widget-sdk`, `react-native`, `axios`, `date-fns`, `react-native-svg`, `lucide-react-native`, `react-native-maps`, `leaflet`, `react-leaflet`, `expo-av`, `@react-native-community/datetimepicker`, `expo-clipboard`, `expo-haptics`. Each entry carries `platforms` (one or both of `"web"` / `"native"`) and a `category` so the linter and the marketplace listing can render honest platform badges. `CONTRACT.allowedBareImports` (the existing field) is now derived from `vettedImports` and stays a plain `string[]` for back-compat.
|
|
16
|
+
- **`fetch` and `XMLHttpRequest` come off `CONTRACT.bannedApis`.** Widgets may call third-party APIs directly. Calls to the host's own `/api/*` surface will 401 because the JWT token is never shared with widget code; the linter emits a soft `no-host-api-url` warning when it sees host-URL substrings so authors learn the rule statically. Use SDK hooks (`useDatastoreQuery`, `useUsers`, `useFile`, …) for workspace data; use `axios` / `fetch` for third-party APIs.
|
|
17
|
+
- **`import-not-vetted` linter rule (new).** Every bare `import` specifier is validated against `CONTRACT.vettedImports`. Relative imports inside the bundle (`./shared.js`) are allowed so split-impl widgets can share helpers; `../` and absolute paths are rejected.
|
|
18
|
+
- **`import-platform-mismatch` linter rule (new).** A single-source widget that imports a native-only package while `manifest.supportedPlatforms` includes `"web"` fails the lint. The author either drops the platform from the manifest OR ships a `widget.web.jsx` + `widget.native.jsx` pair where the platform-specific import lives in the file that targets its platform.
|
|
19
|
+
- **Lint findings carry `severity`.** `"error"` (default) blocks publish; `"warning"` (currently only `no-host-api-url`) surfaces to reviewers without blocking. The `lintSource(...)` return shape stays `{ ok, findings }` — `ok` is true iff no error-severity findings exist.
|
|
20
|
+
- **Four Tier A SDK additions:**
|
|
21
|
+
- `<Icon>` primitive — `<Icon name="check" size={16} color={theme.colors.primary} />`. Wraps `lucide-react-native`; works on both platforms.
|
|
22
|
+
- `<DateTimePicker>` primitive — `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" />`. Wraps `@react-native-community/datetimepicker` and normalizes the value to ISO 8601 strings (the datastore wire format).
|
|
23
|
+
- `useClipboard()` hook — `{ copy, paste, hasContent }`. Web via `navigator.clipboard`; native via `expo-clipboard`. Rejections are a structured `ClipboardError` with `.code` in `PERMISSION_DENIED | INTERNAL`.
|
|
24
|
+
- `useToast()` hook — `{ showToast }`. The host installs a workspace-themed renderer at `WidgetContext.toast.showToast`; if omitted, the web variant dispatches an `appstudio:widget-toast` CustomEvent and native logs to the console.
|
|
25
|
+
- **`CONTRACT.version` → `1.5.0`** (additive: two new contract fields — `vettedImports`, `hostApiUrlPatterns` — two banned APIs removed, two primitives + two hooks added, one optional `widgetContextShape.toast` slot). No existing export changed signature.
|
|
26
|
+
|
|
27
|
+
### What was in 0.14.1
|
|
28
|
+
|
|
29
|
+
- **`groupRef` property type (REQ-USERMGMT M4 / §4.8).** Authors can now declare `{ type: 'groupRef', label: 'Group' }` in their `propertySchema` to render a Group picker in the Studio Properties Panel. The widget receives a bare `AppUserGroup` UUID — REQ-GEN-07 compliant, so tenant-copy walks the value transparently. Used by the built-in `appstudio.user-management` widget for its `defaultGroupId` prop and available to third-party widgets that need to anchor behaviour on a specific group.
|
|
30
|
+
- **Patch bump** — additive enumeration entry, no exported function signature changed. `CONTRACT.version` stays `1.4.0`.
|
|
31
|
+
|
|
32
|
+
### What's new in 0.14.0
|
|
33
|
+
|
|
34
|
+
- **`useUsers()` + `useGroups()` — AppUser administration hooks (REQ-USERMGMT / REQ-ACL-SYS M3).** A widget can now invite, deactivate, reactivate, and remove members, and create / delete groups + add / remove members, from a published-app surface. Returns `{ users | groups, loading, error, refetch, ... }` plus imperative mutation methods. Reads gated by `users.read:*` / `groups.read:*`; mutations by `users.write:*` / `groups.write:*`. Rejections surface as a structured `DirectoryError` (new named export) with `code` ∈ `FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY`. The host signs an `X-Widget-Scopes` header against `JWT_SECRET` so an APP_USER cannot forge a scope set, and the backend additionally gates the request behind a SystemAcl `users.*` / `groups.*` capability grant (REQ-ACL-SYS M1 + M3).
|
|
35
|
+
- **Managing app users from a widget — see the section below.**
|
|
36
|
+
- **New linter rule `no-scope-mismatch-useUsers` / `no-scope-mismatch-useGroups`.** Calling `useUsers().invite()` / `.deactivate()` / `.reactivate()` / `.remove()` without `users.write:*` in the manifest's `requestedScopes` fails the lint; calling `useGroups()` mutation methods without `groups.write:*` fails the lint. Keeps the manifest honest at submission time.
|
|
37
|
+
- **`CONTRACT.version` → `1.4.0`** (additive: two new hooks, two new context slices, six new scope verbs, one new error class). No existing export changed signature.
|
|
10
38
|
|
|
11
39
|
### What's new in 0.13.0
|
|
12
40
|
|
|
@@ -89,11 +117,58 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
89
117
|
|
|
90
118
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
91
119
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
92
|
-
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreMutation`, `useDirectory`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer` — hooks that read from the host-provided `WidgetContext
|
|
120
|
+
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (`id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useFile(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case).
|
|
93
121
|
- `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
|
|
94
|
-
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet` — re-exported from `react-native
|
|
122
|
+
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`, `DateTimePicker` wraps `@react-native-community/datetimepicker`). The web build aliases `react-native` to `react-native-web` so widgets render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
|
|
95
123
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
96
124
|
|
|
125
|
+
## Managing app users from a widget
|
|
126
|
+
|
|
127
|
+
REQ-USERMGMT / REQ-ACL-SYS M3 added two hooks that let a widget invite, deactivate, reactivate, and remove members, plus create / delete groups and add / remove their members. The hooks are gated by two layers: the widget's manifest must declare the scope (so the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability in the tenant (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller does not hold the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that to the end-user as a "you do not have permission to do that" message.
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
import { Text, View, Pressable, useUsers, useGroups } from "@colixsystems/widget-sdk";
|
|
131
|
+
|
|
132
|
+
export default function MemberManager() {
|
|
133
|
+
const { users, loading, invite, deactivate, remove } = useUsers({ q: "" });
|
|
134
|
+
const { groups, addMember } = useGroups();
|
|
135
|
+
if (loading) return <Text>Loading…</Text>;
|
|
136
|
+
return (
|
|
137
|
+
<View>
|
|
138
|
+
{users.map((u) => (
|
|
139
|
+
<View key={u.id}>
|
|
140
|
+
<Text>{u.name} — {u.isActive ? "active" : "inactive"}</Text>
|
|
141
|
+
<Pressable onPress={() => deactivate(u.id)}><Text>Deactivate</Text></Pressable>
|
|
142
|
+
<Pressable onPress={() => remove(u.id)}><Text>Remove</Text></Pressable>
|
|
143
|
+
</View>
|
|
144
|
+
))}
|
|
145
|
+
<Pressable
|
|
146
|
+
onPress={async () => {
|
|
147
|
+
try {
|
|
148
|
+
await invite({ email: "a@b.com", name: "New User", groupIds: [groups[0]?.id].filter(Boolean) });
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// err.code is one of FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY
|
|
151
|
+
}
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
<Text>Invite</Text>
|
|
155
|
+
</Pressable>
|
|
156
|
+
</View>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The matching manifest declares the scopes:
|
|
162
|
+
|
|
163
|
+
```js
|
|
164
|
+
{
|
|
165
|
+
// ...
|
|
166
|
+
requestedScopes: ["users.read:*", "users.write:*", "groups.read:*", "groups.write:*"],
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The host rejects calls whose scope is not declared in the manifest (the SDK linter catches this statically too). Declaring a write scope is also a consent prompt the Studio admin sees at install time — the wider the scope set, the more careful the admin is about granting the install.
|
|
171
|
+
|
|
97
172
|
## Linter
|
|
98
173
|
|
|
99
174
|
```sh
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// REQ-WSDK-PLATFORM §6 — `useClipboard()` hook (web implementation).
|
|
2
|
+
//
|
|
3
|
+
// Wraps the browser's `navigator.clipboard` API. Returns a stable object:
|
|
4
|
+
//
|
|
5
|
+
// const { copy, paste, hasContent } = useClipboard();
|
|
6
|
+
//
|
|
7
|
+
// - `copy(text)` → Promise<void>. Writes a string. Rejects with a
|
|
8
|
+
// `ClipboardError` on failure (most common code:
|
|
9
|
+
// "PERMISSION_DENIED" — the page lacks the
|
|
10
|
+
// clipboard-write permission, often because the
|
|
11
|
+
// call wasn't triggered by a user gesture).
|
|
12
|
+
// - `paste()` → Promise<string>. Reads the current clipboard
|
|
13
|
+
// text. Returns "" when empty, rejects with
|
|
14
|
+
// `ClipboardError` (code "PERMISSION_DENIED" on
|
|
15
|
+
// sites that haven't asked for clipboard-read
|
|
16
|
+
// permission).
|
|
17
|
+
// - `hasContent()` → Promise<boolean>. Best-effort: reads the
|
|
18
|
+
// clipboard and reports whether it had a
|
|
19
|
+
// non-empty string. Same permission caveats as
|
|
20
|
+
// `paste()`.
|
|
21
|
+
//
|
|
22
|
+
// The native build uses ./clipboard.native.js (Metro picks via the
|
|
23
|
+
// package.json `react-native` resolution path through index.native.js).
|
|
24
|
+
|
|
25
|
+
export class ClipboardError extends Error {
|
|
26
|
+
constructor(code, message, opts) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "ClipboardError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _isPermissionError(err) {
|
|
35
|
+
if (!err) return false;
|
|
36
|
+
if (err.name === "NotAllowedError" || err.name === "SecurityError") return true;
|
|
37
|
+
const msg = typeof err.message === "string" ? err.message.toLowerCase() : "";
|
|
38
|
+
return msg.includes("permission") || msg.includes("not allowed");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _wrap(err, label) {
|
|
42
|
+
if (err instanceof ClipboardError) return err;
|
|
43
|
+
const code = _isPermissionError(err) ? "PERMISSION_DENIED" : "INTERNAL";
|
|
44
|
+
return new ClipboardError(code, `${label}: ${err && err.message ? err.message : err}`, {
|
|
45
|
+
cause: err,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useClipboard() {
|
|
50
|
+
// Each method is a fresh function but the returned identity is stable
|
|
51
|
+
// per call site — callers passing one into useEffect dep arrays should
|
|
52
|
+
// pin the method, not the whole object.
|
|
53
|
+
return {
|
|
54
|
+
async copy(text) {
|
|
55
|
+
const value = text == null ? "" : String(text);
|
|
56
|
+
try {
|
|
57
|
+
if (typeof navigator === "undefined" || !navigator.clipboard) {
|
|
58
|
+
throw new ClipboardError("INTERNAL", "Clipboard API unavailable.");
|
|
59
|
+
}
|
|
60
|
+
await navigator.clipboard.writeText(value);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
throw _wrap(err, "clipboard.copy");
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
async paste() {
|
|
66
|
+
try {
|
|
67
|
+
if (typeof navigator === "undefined" || !navigator.clipboard) {
|
|
68
|
+
throw new ClipboardError("INTERNAL", "Clipboard API unavailable.");
|
|
69
|
+
}
|
|
70
|
+
const text = await navigator.clipboard.readText();
|
|
71
|
+
return typeof text === "string" ? text : "";
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw _wrap(err, "clipboard.paste");
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
async hasContent() {
|
|
77
|
+
try {
|
|
78
|
+
if (typeof navigator === "undefined" || !navigator.clipboard) return false;
|
|
79
|
+
const text = await navigator.clipboard.readText();
|
|
80
|
+
return typeof text === "string" && text.length > 0;
|
|
81
|
+
} catch {
|
|
82
|
+
// Permission-denied reads return false instead of throwing —
|
|
83
|
+
// hasContent() is the "best-effort" probe, not a write gate.
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// REQ-WSDK-PLATFORM §6 — `useClipboard()` hook (native implementation).
|
|
2
|
+
//
|
|
3
|
+
// Wraps expo-clipboard. Same return shape as the web counterpart
|
|
4
|
+
// (./clipboard.js) so widget code is byte-for-byte identical across
|
|
5
|
+
// platforms. The two implementations only differ in how `copy` / `paste`
|
|
6
|
+
// reach the system clipboard.
|
|
7
|
+
//
|
|
8
|
+
// Errors are normalized to a structured `ClipboardError` with a stable
|
|
9
|
+
// `.code`. Native clipboard reads cannot fail on permission grounds
|
|
10
|
+
// (Android doesn't prompt; iOS reveals a paste banner but still
|
|
11
|
+
// resolves), so "PERMISSION_DENIED" is web-only.
|
|
12
|
+
|
|
13
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
14
|
+
import * as Clipboard from "expo-clipboard";
|
|
15
|
+
|
|
16
|
+
export class ClipboardError extends Error {
|
|
17
|
+
constructor(code, message, opts) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "ClipboardError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function _wrap(err, label) {
|
|
26
|
+
if (err instanceof ClipboardError) return err;
|
|
27
|
+
return new ClipboardError(
|
|
28
|
+
"INTERNAL",
|
|
29
|
+
`${label}: ${err && err.message ? err.message : err}`,
|
|
30
|
+
{ cause: err },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useClipboard() {
|
|
35
|
+
return {
|
|
36
|
+
async copy(text) {
|
|
37
|
+
const value = text == null ? "" : String(text);
|
|
38
|
+
try {
|
|
39
|
+
await Clipboard.setStringAsync(value);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw _wrap(err, "clipboard.copy");
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
async paste() {
|
|
45
|
+
try {
|
|
46
|
+
const text = await Clipboard.getStringAsync();
|
|
47
|
+
return typeof text === "string" ? text : "";
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw _wrap(err, "clipboard.paste");
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
async hasContent() {
|
|
53
|
+
try {
|
|
54
|
+
if (typeof Clipboard.hasStringAsync === "function") {
|
|
55
|
+
return !!(await Clipboard.hasStringAsync());
|
|
56
|
+
}
|
|
57
|
+
const text = await Clipboard.getStringAsync();
|
|
58
|
+
return typeof text === "string" && text.length > 0;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/contract.cjs
CHANGED
|
@@ -170,6 +170,112 @@ const HOOKS = [
|
|
|
170
170
|
requiredContextSlice: ["payments.requestPayment"],
|
|
171
171
|
scopes: ["payments.charge:appUser"],
|
|
172
172
|
},
|
|
173
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
174
|
+
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
175
|
+
// Reads need `users.read:*` scope; mutations additionally need
|
|
176
|
+
// `users.write:*`. The `invite` call accepts `{ email, name, groupIds? }`
|
|
177
|
+
// and returns the resulting AppUserInvite row (the email is sent by the
|
|
178
|
+
// host). Mutating users from a widget requires the corresponding
|
|
179
|
+
// SystemAcl `users.write` capability grant in the tenant; widgets that
|
|
180
|
+
// only call read methods need only `users.read:*`.
|
|
181
|
+
{
|
|
182
|
+
name: "useUsers",
|
|
183
|
+
signature: "useUsers(query?)",
|
|
184
|
+
description:
|
|
185
|
+
"AppUser administration. Returns { users, loading, error, refetch, invite, " +
|
|
186
|
+
"deactivate, reactivate, remove }. Reads need users.read:* scope; mutations " +
|
|
187
|
+
"need users.write:*. The `invite` call accepts { email, name, groupIds? } " +
|
|
188
|
+
"and returns the resulting AppUserInvite row (the email is sent by the host).",
|
|
189
|
+
returnShape: {
|
|
190
|
+
users: "Array<{ id, name, email?, role, isActive }>",
|
|
191
|
+
loading: "boolean",
|
|
192
|
+
error: "DirectoryError | null",
|
|
193
|
+
refetch: "() => Promise<void>",
|
|
194
|
+
invite:
|
|
195
|
+
"({ email, name, groupIds? }) => Promise<Invite> // rejects with DirectoryError",
|
|
196
|
+
deactivate: "(userId) => Promise<User> // rejects with DirectoryError",
|
|
197
|
+
reactivate: "(userId) => Promise<User> // rejects with DirectoryError",
|
|
198
|
+
remove: "(userId) => Promise<void> // rejects with DirectoryError",
|
|
199
|
+
},
|
|
200
|
+
requiredContextSlice: [
|
|
201
|
+
"users.listUsers",
|
|
202
|
+
"users.invite",
|
|
203
|
+
"users.deactivate",
|
|
204
|
+
"users.reactivate",
|
|
205
|
+
"users.remove",
|
|
206
|
+
],
|
|
207
|
+
scopes: ["users.read:*"],
|
|
208
|
+
},
|
|
209
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration. Returns
|
|
210
|
+
// `{ groups, loading, error, refetch, create, remove, addMember, removeMember }`.
|
|
211
|
+
// Reads need `groups.read:*`; mutations need `groups.write:*`. Removing a
|
|
212
|
+
// member requires the corresponding `users.write` capability since it
|
|
213
|
+
// affects the user's effective access.
|
|
214
|
+
{
|
|
215
|
+
name: "useGroups",
|
|
216
|
+
signature: "useGroups(query?)",
|
|
217
|
+
description:
|
|
218
|
+
"AppUserGroup administration. Returns { groups, loading, error, refetch, " +
|
|
219
|
+
"create, remove, addMember, removeMember }. Reads need groups.read:*; " +
|
|
220
|
+
"mutations need groups.write:*.",
|
|
221
|
+
returnShape: {
|
|
222
|
+
groups: "Array<{ id, name, memberCount }>",
|
|
223
|
+
loading: "boolean",
|
|
224
|
+
error: "DirectoryError | null",
|
|
225
|
+
refetch: "() => Promise<void>",
|
|
226
|
+
create:
|
|
227
|
+
"({ name }) => Promise<Group> // rejects with DirectoryError",
|
|
228
|
+
remove: "(groupId) => Promise<void> // rejects with DirectoryError",
|
|
229
|
+
addMember:
|
|
230
|
+
"(groupId, userId) => Promise<void> // rejects with DirectoryError",
|
|
231
|
+
removeMember:
|
|
232
|
+
"(groupId, userId) => Promise<void> // rejects with DirectoryError",
|
|
233
|
+
},
|
|
234
|
+
requiredContextSlice: [
|
|
235
|
+
"groups.listGroups",
|
|
236
|
+
"groups.create",
|
|
237
|
+
"groups.remove",
|
|
238
|
+
"groups.addMember",
|
|
239
|
+
"groups.removeMember",
|
|
240
|
+
],
|
|
241
|
+
scopes: ["groups.read:*"],
|
|
242
|
+
},
|
|
243
|
+
// REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
|
|
244
|
+
{
|
|
245
|
+
name: "useClipboard",
|
|
246
|
+
signature: "useClipboard()",
|
|
247
|
+
description:
|
|
248
|
+
"Cross-platform clipboard access. Returns { copy, paste, hasContent }. " +
|
|
249
|
+
"All methods return Promises; rejections surface a structured ClipboardError " +
|
|
250
|
+
"with .code in PERMISSION_DENIED | INTERNAL. On web the browser may " +
|
|
251
|
+
"require a user gesture for read access — surface the error to the " +
|
|
252
|
+
"user as a 'try again after clicking' prompt when code === PERMISSION_DENIED.",
|
|
253
|
+
returnShape: {
|
|
254
|
+
copy:
|
|
255
|
+
"(text: string) => Promise<void> // rejects with ClipboardError",
|
|
256
|
+
paste:
|
|
257
|
+
"() => Promise<string> // rejects with ClipboardError",
|
|
258
|
+
hasContent: "() => Promise<boolean> // best-effort, never throws",
|
|
259
|
+
},
|
|
260
|
+
requiredContextSlice: [],
|
|
261
|
+
scopes: null,
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: "useToast",
|
|
265
|
+
signature: "useToast()",
|
|
266
|
+
description:
|
|
267
|
+
"Surfaces a short auto-dismissing notification. Returns { showToast }. " +
|
|
268
|
+
"showToast({ kind: 'success' | 'error' | 'info' | 'warning', message }) " +
|
|
269
|
+
"asks the host to render a workspace-themed toast. If the host hasn't " +
|
|
270
|
+
"wired a renderer, the web variant dispatches an 'appstudio:widget-toast' " +
|
|
271
|
+
"CustomEvent on window; native logs to the console. The widget never " +
|
|
272
|
+
"owns the toast UI — that's the host's responsibility.",
|
|
273
|
+
returnShape: {
|
|
274
|
+
showToast: "({ kind, message }) => void",
|
|
275
|
+
},
|
|
276
|
+
requiredContextSlice: ["toast.showToast"],
|
|
277
|
+
scopes: null,
|
|
278
|
+
},
|
|
173
279
|
];
|
|
174
280
|
|
|
175
281
|
// REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
|
|
@@ -264,6 +370,22 @@ const PRIMITIVES = [
|
|
|
264
370
|
rnComponent: "Linking",
|
|
265
371
|
docsUrl: "https://reactnative.dev/docs/linking",
|
|
266
372
|
},
|
|
373
|
+
// REQ-WSDK-PLATFORM §6 — Tier A SDK primitive.
|
|
374
|
+
{
|
|
375
|
+
name: "Icon",
|
|
376
|
+
description:
|
|
377
|
+
'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.',
|
|
378
|
+
rnComponent: "lucide-react-native",
|
|
379
|
+
docsUrl: "https://lucide.dev/icons",
|
|
380
|
+
},
|
|
381
|
+
// REQ-WSDK-PLATFORM §6 — Tier A SDK primitive.
|
|
382
|
+
{
|
|
383
|
+
name: "DateTimePicker",
|
|
384
|
+
description:
|
|
385
|
+
'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.',
|
|
386
|
+
rnComponent: "@react-native-community/datetimepicker",
|
|
387
|
+
docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
|
|
388
|
+
},
|
|
267
389
|
];
|
|
268
390
|
|
|
269
391
|
const CATEGORIES = [
|
|
@@ -433,6 +555,38 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
433
555
|
required: true,
|
|
434
556
|
fields: { requestPayment: "function", getPayment: "function" },
|
|
435
557
|
},
|
|
558
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration facade backing
|
|
559
|
+
// useUsers(). Reads gated by `users.read:*`; mutations by `users.write:*`.
|
|
560
|
+
// The host signs an `X-Widget-Scopes` header against JWT_SECRET so an
|
|
561
|
+
// APP_USER cannot forge scope claims, and the request is additionally
|
|
562
|
+
// gated by a SystemAcl `users.read` / `users.write` capability grant.
|
|
563
|
+
users: {
|
|
564
|
+
description:
|
|
565
|
+
"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:*.",
|
|
566
|
+
required: true,
|
|
567
|
+
fields: {
|
|
568
|
+
listUsers: "function",
|
|
569
|
+
invite: "function",
|
|
570
|
+
deactivate: "function",
|
|
571
|
+
reactivate: "function",
|
|
572
|
+
remove: "function",
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration facade
|
|
576
|
+
// backing useGroups(). Reads gated by `groups.read:*`; mutations by
|
|
577
|
+
// `groups.write:*`. Same X-Widget-Scopes + SystemAcl gating as users.
|
|
578
|
+
groups: {
|
|
579
|
+
description:
|
|
580
|
+
"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:*.",
|
|
581
|
+
required: true,
|
|
582
|
+
fields: {
|
|
583
|
+
listGroups: "function",
|
|
584
|
+
create: "function",
|
|
585
|
+
remove: "function",
|
|
586
|
+
addMember: "function",
|
|
587
|
+
removeMember: "function",
|
|
588
|
+
},
|
|
589
|
+
},
|
|
436
590
|
i18n: {
|
|
437
591
|
description: "{ t(key, fallback?), locale }.",
|
|
438
592
|
required: true,
|
|
@@ -449,6 +603,17 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
449
603
|
error: "function",
|
|
450
604
|
},
|
|
451
605
|
},
|
|
606
|
+
// REQ-WSDK-PLATFORM §6 — backs useToast(). Mirror of contract.js.
|
|
607
|
+
toast: {
|
|
608
|
+
description:
|
|
609
|
+
"Optional host toast slot. { showToast({ kind, message }): void }. " +
|
|
610
|
+
"The host populates this to render workspace-themed notifications " +
|
|
611
|
+
"from any widget that calls useToast(). When omitted the SDK falls " +
|
|
612
|
+
"back to dispatching an 'appstudio:widget-toast' CustomEvent on web " +
|
|
613
|
+
"and console.log on native.",
|
|
614
|
+
required: false,
|
|
615
|
+
fields: { showToast: "function" },
|
|
616
|
+
},
|
|
452
617
|
};
|
|
453
618
|
|
|
454
619
|
const BUNDLE_EXPORT_CONTRACT = [
|
|
@@ -471,6 +636,15 @@ const BUNDLE_EXPORT_CONTRACT = [
|
|
|
471
636
|
},
|
|
472
637
|
];
|
|
473
638
|
|
|
639
|
+
// REQ-WSDK-PLATFORM (docs/design/req-widget-sdk-cross-platform-primitives.md
|
|
640
|
+
// §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. Widgets may call
|
|
641
|
+
// third-party APIs directly. Same-origin requests to the host's own
|
|
642
|
+
// `/api/*` surface are rejected at runtime by the WidgetContextProvider's
|
|
643
|
+
// network gate (`no host-api access from widgets`) — the JWT token is
|
|
644
|
+
// never shared with widget code, so the call would 401 anyway; the runtime
|
|
645
|
+
// gate makes the failure mode "blocked" instead of "401 noise". A soft
|
|
646
|
+
// linter warning (`no-host-api-url`) flags obvious host-URL substrings at
|
|
647
|
+
// submission so authors learn the rule statically.
|
|
474
648
|
const BANNED_APIS = [
|
|
475
649
|
{ identifier: "eval", reason: "Arbitrary code evaluation." },
|
|
476
650
|
{
|
|
@@ -499,23 +673,144 @@ const BANNED_APIS = [
|
|
|
499
673
|
reason: "Same reason as localStorage.",
|
|
500
674
|
},
|
|
501
675
|
{
|
|
502
|
-
identifier: "
|
|
503
|
-
reason:
|
|
504
|
-
|
|
676
|
+
identifier: "import(",
|
|
677
|
+
reason: "Dynamic import bypasses the loader's allowlist.",
|
|
678
|
+
},
|
|
679
|
+
{ identifier: "globalThis", reason: "Host environment escape." },
|
|
680
|
+
];
|
|
681
|
+
|
|
682
|
+
// REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
|
|
683
|
+
// specifier the widget bundle may import as a bare module specifier. The
|
|
684
|
+
// linter validates every `import ... from "<spec>"` line against this
|
|
685
|
+
// list; specifiers not on the list fail the lint. The compiler reads it
|
|
686
|
+
// to know which packages to add to the exported Expo app's package.json.
|
|
687
|
+
//
|
|
688
|
+
// `platforms` is one or both of "web" / "native". A widget that imports a
|
|
689
|
+
// native-only package implicitly drops "web" from the package's effective
|
|
690
|
+
// `supportedPlatforms` (and vice versa) — the marketplace upload pipeline
|
|
691
|
+
// surfaces the derived set so the listing shows honest badges.
|
|
692
|
+
//
|
|
693
|
+
// Adding a package is a CONTRACT change. Review burden: confirm the
|
|
694
|
+
// package does what it claims, has a credible maintainer, and the
|
|
695
|
+
// import shape (named exports, default export) is stable. After that,
|
|
696
|
+
// every widget using the package inherits the review — the marketplace
|
|
697
|
+
// reviewer only spot-checks usage, not the package source.
|
|
698
|
+
const VETTED_IMPORTS = [
|
|
699
|
+
{
|
|
700
|
+
specifier: "react",
|
|
701
|
+
platforms: ["web", "native"],
|
|
702
|
+
category: "core",
|
|
703
|
+
description: "React. Hooks, JSX, lifecycle. Unchanged.",
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
specifier: "@colixsystems/widget-sdk",
|
|
707
|
+
platforms: ["web", "native"],
|
|
708
|
+
category: "core",
|
|
709
|
+
description:
|
|
710
|
+
"The AppStudio widget SDK — primitives, hooks, manifest helpers. Unchanged.",
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
specifier: "react-native",
|
|
714
|
+
platforms: ["web", "native"],
|
|
715
|
+
category: "primitive",
|
|
716
|
+
description:
|
|
717
|
+
"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.",
|
|
505
718
|
},
|
|
506
719
|
{
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
720
|
+
specifier: "axios",
|
|
721
|
+
platforms: ["web", "native"],
|
|
722
|
+
category: "network",
|
|
723
|
+
description:
|
|
724
|
+
"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.",
|
|
510
725
|
},
|
|
511
726
|
{
|
|
512
|
-
|
|
513
|
-
|
|
727
|
+
specifier: "date-fns",
|
|
728
|
+
platforms: ["web", "native"],
|
|
729
|
+
category: "utility",
|
|
730
|
+
description: "Pure-JS date math. Works on both platforms unchanged.",
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
specifier: "react-native-svg",
|
|
734
|
+
platforms: ["web", "native"],
|
|
735
|
+
category: "drawing",
|
|
736
|
+
description:
|
|
737
|
+
"Cross-platform SVG drawing primitives. Used by the built-in Chart widget; works on both platforms.",
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
specifier: "lucide-react-native",
|
|
741
|
+
platforms: ["web", "native"],
|
|
742
|
+
category: "iconography",
|
|
743
|
+
description:
|
|
744
|
+
"Lucide icon set as React components. Used by the built-in Icon widget; works on both platforms.",
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
specifier: "react-native-maps",
|
|
748
|
+
platforms: ["native"],
|
|
749
|
+
category: "geo",
|
|
750
|
+
description:
|
|
751
|
+
"Native map view + markers. Native-only; pair with leaflet/react-leaflet in widget.web.jsx for a web variant.",
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
specifier: "leaflet",
|
|
755
|
+
platforms: ["web"],
|
|
756
|
+
category: "geo",
|
|
757
|
+
description:
|
|
758
|
+
"Web-only mapping library. Use alongside react-leaflet in widget.web.jsx as the web counterpart to react-native-maps.",
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
specifier: "react-leaflet",
|
|
762
|
+
platforms: ["web"],
|
|
763
|
+
category: "geo",
|
|
764
|
+
description: "React bindings for leaflet. Web-only.",
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
specifier: "expo-av",
|
|
768
|
+
platforms: ["native"],
|
|
769
|
+
category: "media",
|
|
770
|
+
description:
|
|
771
|
+
"Native audio + video playback. Native-only; pair with browser <audio>/<video> in widget.web.jsx.",
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
specifier: "@react-native-community/datetimepicker",
|
|
775
|
+
platforms: ["native"],
|
|
776
|
+
category: "input",
|
|
777
|
+
description:
|
|
778
|
+
"Native date/time picker. The SDK's <DateTimePicker> primitive already wraps this; reach for it directly only if you need RN-specific options.",
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
specifier: "expo-clipboard",
|
|
782
|
+
platforms: ["native"],
|
|
783
|
+
category: "system",
|
|
784
|
+
description:
|
|
785
|
+
"Native clipboard. The SDK's useClipboard() hook already wraps this; reach for it directly only if you need RN-specific options.",
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
specifier: "expo-haptics",
|
|
789
|
+
platforms: ["native"],
|
|
790
|
+
category: "system",
|
|
791
|
+
description:
|
|
792
|
+
"Native haptic feedback. Pair with navigator.vibrate in widget.web.jsx.",
|
|
514
793
|
},
|
|
515
|
-
{ identifier: "globalThis", reason: "Host environment escape." },
|
|
516
794
|
];
|
|
517
795
|
|
|
518
|
-
|
|
796
|
+
// Back-compat shape — every existing consumer (widgetLoader, the static
|
|
797
|
+
// analyzer's DEPENDENCY_ALLOWLIST, the AI prompt's "Allowed imports" line)
|
|
798
|
+
// reads a plain `string[]` from `CONTRACT.allowedBareImports`. Derive it
|
|
799
|
+
// from the rich list so a single edit in VETTED_IMPORTS propagates to
|
|
800
|
+
// every surface.
|
|
801
|
+
const ALLOWED_BARE_IMPORTS = VETTED_IMPORTS.map((v) => v.specifier);
|
|
802
|
+
|
|
803
|
+
// REQ-WSDK-PLATFORM §3.5: host-API URL patterns the linter scans for as a
|
|
804
|
+
// soft warning. None of these block the lint by themselves — they prompt
|
|
805
|
+
// the marketplace reviewer to spot-check, and they help authors learn the
|
|
806
|
+
// rule statically. Strings appear as literal substring matches (the URL
|
|
807
|
+
// is reachable in widget code by composing it at runtime, so this is
|
|
808
|
+
// best-effort).
|
|
809
|
+
const HOST_API_URL_PATTERNS = [
|
|
810
|
+
"/api/v1",
|
|
811
|
+
"/uploads/",
|
|
812
|
+
"Authorization: Bearer",
|
|
813
|
+
];
|
|
519
814
|
|
|
520
815
|
function deepFreeze(value) {
|
|
521
816
|
if (value === null || typeof value !== "object") return value;
|
|
@@ -525,7 +820,17 @@ function deepFreeze(value) {
|
|
|
525
820
|
}
|
|
526
821
|
|
|
527
822
|
const CONTRACT = deepFreeze({
|
|
528
|
-
|
|
823
|
+
// REQ-WSDK-PLATFORM bump:
|
|
824
|
+
// - `vettedImports` is a new field (rich allowlist with platforms +
|
|
825
|
+
// category + description).
|
|
826
|
+
// - `hostApiUrlPatterns` is a new field (soft-warning substrings).
|
|
827
|
+
// - `bannedApis` no longer lists `fetch` / `XMLHttpRequest`.
|
|
828
|
+
// - `allowedBareImports` is now derived from `vettedImports` (same
|
|
829
|
+
// shape; same contents grow with each vetted addition).
|
|
830
|
+
// Permissive-direction change: minor bump on the contract's own
|
|
831
|
+
// versioning (per CLAUDE.md §4, pre-1.0 minor is the breaking channel —
|
|
832
|
+
// the package.json version bumps accordingly).
|
|
833
|
+
version: "1.5.0",
|
|
529
834
|
hooks: HOOKS,
|
|
530
835
|
primitives: PRIMITIVES,
|
|
531
836
|
manifestSchema: MANIFEST_SCHEMA,
|
|
@@ -535,7 +840,9 @@ const CONTRACT = deepFreeze({
|
|
|
535
840
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
536
841
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
537
842
|
bannedApis: BANNED_APIS,
|
|
843
|
+
vettedImports: VETTED_IMPORTS,
|
|
538
844
|
allowedBareImports: ALLOWED_BARE_IMPORTS,
|
|
845
|
+
hostApiUrlPatterns: HOST_API_URL_PATTERNS,
|
|
539
846
|
});
|
|
540
847
|
|
|
541
848
|
function isHookAllowed(name) {
|