@colixsystems/widget-sdk 0.2.0 → 0.4.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/README.md +28 -1
- package/dist/_theme-tokens.js +10 -0
- package/dist/contract.cjs +404 -0
- package/dist/contract.js +17 -0
- package/dist/hooks.js +242 -20
- package/dist/index.d.ts +159 -1
- package/dist/index.js +3 -1
- package/dist/index.native.js +3 -1
- package/dist/linter.js +72 -24
- package/dist/manifest.cjs +144 -0
- package/dist/manifest.js +10 -128
- package/package.json +8 -2
package/dist/hooks.js
CHANGED
|
@@ -1,15 +1,58 @@
|
|
|
1
|
-
// Widget hooks per docs/architecture/widget-marketplace.md §3.1
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Widget hooks per docs/architecture/widget-marketplace.md §3.1 and the
|
|
2
|
+
// CONTRACT in ./contract.js (the single source of truth — when a hook's
|
|
3
|
+
// signature or return shape changes, update both this file and the
|
|
4
|
+
// CONTRACT entry in lockstep).
|
|
5
|
+
//
|
|
6
|
+
// Each hook reads from the host-provided WidgetContext via React context,
|
|
7
|
+
// then delegates to the host implementation. The hosts (Studio, Player,
|
|
8
|
+
// exported app) own the runtime semantics — this file is the SDK surface
|
|
9
|
+
// widgets call.
|
|
5
10
|
//
|
|
6
11
|
// This file avoids JSX so it can ship as a plain .js without a transform.
|
|
7
12
|
|
|
8
|
-
import React, {
|
|
13
|
+
import React, {
|
|
14
|
+
createContext,
|
|
15
|
+
useContext,
|
|
16
|
+
useCallback,
|
|
17
|
+
useEffect,
|
|
18
|
+
useRef,
|
|
19
|
+
useState,
|
|
20
|
+
} from "react";
|
|
9
21
|
|
|
10
22
|
/** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
|
|
11
23
|
const HostWidgetContext = createContext(null);
|
|
12
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Structured error thrown by `useDatastoreMutation` callbacks (and surfaced
|
|
27
|
+
* by `useDatastoreQuery` in its `error` slot). Carries a stable `code` so
|
|
28
|
+
* widgets can branch on the error class without parsing axios message
|
|
29
|
+
* strings.
|
|
30
|
+
*
|
|
31
|
+
* `code` is one of:
|
|
32
|
+
* - "VALIDATION" — 400 / 422 from the datastore
|
|
33
|
+
* - "CONSTRAINT_VIOLATION" — 409 from the datastore
|
|
34
|
+
* - "FORBIDDEN" — 403 from the datastore
|
|
35
|
+
* - "NOT_FOUND" — 404 from the datastore
|
|
36
|
+
* - "INTERNAL" — anything else (network, 5xx, axios timeout)
|
|
37
|
+
*
|
|
38
|
+
* `fieldErrors` (when present) is a flat map of field name to per-field
|
|
39
|
+
* error message, populated when the datastore returned a structured
|
|
40
|
+
* 400/422 payload with `errors: [{ field, code }, ...]`.
|
|
41
|
+
*/
|
|
42
|
+
export class DatastoreError extends Error {
|
|
43
|
+
constructor(code, message, opts) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "DatastoreError";
|
|
46
|
+
this.code = code;
|
|
47
|
+
if (opts && opts.fieldErrors && typeof opts.fieldErrors === "object") {
|
|
48
|
+
this.fieldErrors = opts.fieldErrors;
|
|
49
|
+
}
|
|
50
|
+
if (opts && opts.cause) {
|
|
51
|
+
this.cause = opts.cause;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
13
56
|
/**
|
|
14
57
|
* Wraps children with the host-provided WidgetContext.
|
|
15
58
|
* The host (Studio/Player/native shell) builds the value and renders this provider.
|
|
@@ -29,19 +72,147 @@ function useWidgetContextOrThrow(hookName) {
|
|
|
29
72
|
return ctx;
|
|
30
73
|
}
|
|
31
74
|
|
|
32
|
-
//
|
|
33
|
-
|
|
75
|
+
// --------------------------------------------------------------- helpers
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Coerce an arbitrary thrown value (axios error, plain Error, string) into
|
|
79
|
+
* a DatastoreError with a stable `.code`. Reads `error.response.status` if
|
|
80
|
+
* present (axios shape) and falls back to inspecting the message string.
|
|
81
|
+
*/
|
|
82
|
+
function toDatastoreError(err) {
|
|
83
|
+
if (err instanceof DatastoreError) return err;
|
|
84
|
+
const status =
|
|
85
|
+
err && err.response && typeof err.response.status === "number"
|
|
86
|
+
? err.response.status
|
|
87
|
+
: null;
|
|
88
|
+
const bodyMessage =
|
|
89
|
+
err && err.response && err.response.data && typeof err.response.data.error === "string"
|
|
90
|
+
? err.response.data.error
|
|
91
|
+
: null;
|
|
92
|
+
const fallbackMessage =
|
|
93
|
+
bodyMessage ||
|
|
94
|
+
(err && typeof err.message === "string" ? err.message : "Datastore call failed");
|
|
95
|
+
let code = "INTERNAL";
|
|
96
|
+
if (status === 400 || status === 422) code = "VALIDATION";
|
|
97
|
+
else if (status === 409) code = "CONSTRAINT_VIOLATION";
|
|
98
|
+
else if (status === 403) code = "FORBIDDEN";
|
|
99
|
+
else if (status === 404) code = "NOT_FOUND";
|
|
100
|
+
// Surface a structured fieldErrors map when the server emitted one
|
|
101
|
+
// (record.controller.js shapes 422 bodies as `{ errors: [{ field, code }] }`).
|
|
102
|
+
let fieldErrors;
|
|
103
|
+
if (err && err.response && err.response.data && Array.isArray(err.response.data.errors)) {
|
|
104
|
+
const map = {};
|
|
105
|
+
for (const entry of err.response.data.errors) {
|
|
106
|
+
if (entry && typeof entry.field === "string") {
|
|
107
|
+
map[entry.field] = entry.message || entry.code || "Invalid value";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (Object.keys(map).length > 0) fieldErrors = map;
|
|
111
|
+
}
|
|
112
|
+
return new DatastoreError(code, fallbackMessage, {
|
|
113
|
+
cause: err,
|
|
114
|
+
fieldErrors,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --------------------------------------------------------------- hooks
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Stateful datastore query hook. Returns { data, loading, error, refetch }.
|
|
122
|
+
*
|
|
123
|
+
* The host's datastore client exposes `records(table).list(query)` which
|
|
124
|
+
* returns a Promise<Record[]>. We hold the result in component state and
|
|
125
|
+
* re-fetch when [table, JSON.stringify(query)] changes. `refetch` re-runs
|
|
126
|
+
* the same call on demand.
|
|
127
|
+
*
|
|
128
|
+
* When `table` is falsy (e.g. the user hasn't bound a `tableRef` property
|
|
129
|
+
* yet), the hook resolves to { data: [], loading: false, error: null,
|
|
130
|
+
* refetch } so the widget can render its empty state without throwing.
|
|
131
|
+
*/
|
|
34
132
|
export function useDatastoreQuery(table, query) {
|
|
35
133
|
const ctx = useWidgetContextOrThrow("useDatastoreQuery");
|
|
36
134
|
if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
|
|
37
135
|
throw new Error("useDatastoreQuery: host did not inject a datastore client");
|
|
38
136
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
137
|
+
const [data, setData] = useState([]);
|
|
138
|
+
const [loading, setLoading] = useState(Boolean(table));
|
|
139
|
+
const [error, setError] = useState(null);
|
|
140
|
+
|
|
141
|
+
// Capture the latest table + query in refs so refetch() — a stable
|
|
142
|
+
// callback identity — always reads the current arguments without having
|
|
143
|
+
// to be re-bound on every render.
|
|
144
|
+
//
|
|
145
|
+
// We hold ctx.datastore.records in a ref for the same reason: the
|
|
146
|
+
// Studio / Player / PageRenderer rebuild the WidgetContext value
|
|
147
|
+
// inside their render closure on every render, so `ctx` is a fresh
|
|
148
|
+
// object identity every render. If `doFetch` listed `[ctx]` in its
|
|
149
|
+
// dep array, the callback identity (and therefore the `refetch`
|
|
150
|
+
// surface) would change every render and any widget that put
|
|
151
|
+
// `refetch` in a useEffect dep array would loop forever. The effect
|
|
152
|
+
// that drives the actual initial fetch keeps `[table, queryKey]` so
|
|
153
|
+
// it only re-runs when the bound table or the query payload changes.
|
|
154
|
+
const tableRef = useRef(table);
|
|
155
|
+
const queryRef = useRef(query);
|
|
156
|
+
const recordsRef = useRef(ctx.datastore.records);
|
|
157
|
+
tableRef.current = table;
|
|
158
|
+
queryRef.current = query;
|
|
159
|
+
recordsRef.current = ctx.datastore.records;
|
|
160
|
+
|
|
161
|
+
const runRef = useRef(0);
|
|
162
|
+
|
|
163
|
+
const doFetch = useCallback(async () => {
|
|
164
|
+
const myRun = ++runRef.current;
|
|
165
|
+
const t = tableRef.current;
|
|
166
|
+
if (!t) {
|
|
167
|
+
// No table bound yet — collapse to a stable empty result without
|
|
168
|
+
// a network round-trip.
|
|
169
|
+
setLoading(false);
|
|
170
|
+
setError(null);
|
|
171
|
+
setData([]);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
setLoading(true);
|
|
175
|
+
setError(null);
|
|
176
|
+
try {
|
|
177
|
+
const ns = recordsRef.current(t);
|
|
178
|
+
const rows = await ns.list(queryRef.current);
|
|
179
|
+
// Discard the result if a newer fetch has started since we kicked off.
|
|
180
|
+
if (runRef.current !== myRun) return;
|
|
181
|
+
setData(Array.isArray(rows) ? rows : []);
|
|
182
|
+
setLoading(false);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (runRef.current !== myRun) return;
|
|
185
|
+
setError(toDatastoreError(err));
|
|
186
|
+
setLoading(false);
|
|
187
|
+
}
|
|
188
|
+
}, []);
|
|
189
|
+
|
|
190
|
+
// Re-run on mount and whenever [table, JSON.stringify(query)] changes.
|
|
191
|
+
const queryKey = (() => {
|
|
192
|
+
try {
|
|
193
|
+
return JSON.stringify(query);
|
|
194
|
+
} catch (_e) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
doFetch();
|
|
200
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
201
|
+
}, [table, queryKey]);
|
|
202
|
+
|
|
203
|
+
const refetch = useCallback(async () => {
|
|
204
|
+
await doFetch();
|
|
205
|
+
}, [doFetch]);
|
|
206
|
+
|
|
207
|
+
return { data, loading, error, refetch };
|
|
42
208
|
}
|
|
43
209
|
|
|
44
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Datastore mutation hook. Returns { create, update, delete }, each method
|
|
212
|
+
* returning a Promise. Rejected promises throw a DatastoreError carrying a
|
|
213
|
+
* stable `.code` (`VALIDATION`, `CONSTRAINT_VIOLATION`, `FORBIDDEN`,
|
|
214
|
+
* `NOT_FOUND`, `INTERNAL`) plus an optional `fieldErrors` map.
|
|
215
|
+
*/
|
|
45
216
|
export function useDatastoreMutation(table) {
|
|
46
217
|
const ctx = useWidgetContextOrThrow("useDatastoreMutation");
|
|
47
218
|
if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
|
|
@@ -49,28 +220,79 @@ export function useDatastoreMutation(table) {
|
|
|
49
220
|
}
|
|
50
221
|
const ns = ctx.datastore.records(table);
|
|
51
222
|
return {
|
|
52
|
-
create: (values) =>
|
|
53
|
-
|
|
54
|
-
|
|
223
|
+
create: async (values) => {
|
|
224
|
+
try {
|
|
225
|
+
return await ns.create(values);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
throw toDatastoreError(err);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
update: async (id, values) => {
|
|
231
|
+
try {
|
|
232
|
+
return await ns.update(id, values);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
throw toDatastoreError(err);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
delete: async (id) => {
|
|
238
|
+
try {
|
|
239
|
+
return await ns.delete(id);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
throw toDatastoreError(err);
|
|
242
|
+
}
|
|
243
|
+
},
|
|
55
244
|
};
|
|
56
245
|
}
|
|
57
246
|
|
|
58
|
-
|
|
59
|
-
|
|
247
|
+
/**
|
|
248
|
+
* Emit a named widget event through ctx.events.emit. Page-level event
|
|
249
|
+
* bindings (subscribed by the host) decide what happens next.
|
|
250
|
+
*/
|
|
60
251
|
export function useWidgetEvent(name) {
|
|
61
252
|
const ctx = useWidgetContextOrThrow("useWidgetEvent");
|
|
62
253
|
return useCallback((payload) => ctx.events.emit(name, payload), [ctx, name]);
|
|
63
254
|
}
|
|
64
255
|
|
|
65
|
-
|
|
256
|
+
/**
|
|
257
|
+
* Returns the host-provided theme tokens. The host guarantees every field
|
|
258
|
+
* documented in CONTRACT.themeTokens is present (defaults merged with
|
|
259
|
+
* tenant overrides), so widgets read `theme.colors.primary` without
|
|
260
|
+
* optional chaining.
|
|
261
|
+
*/
|
|
66
262
|
export function useTheme() {
|
|
67
263
|
const ctx = useWidgetContextOrThrow("useTheme");
|
|
68
264
|
return ctx.workspace.theme;
|
|
69
265
|
}
|
|
70
266
|
|
|
71
|
-
|
|
72
|
-
|
|
267
|
+
/**
|
|
268
|
+
* Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
|
|
269
|
+
* the host's translation table and falls back to `fallback ?? key` when
|
|
270
|
+
* the key is missing. The host's i18n.t may or may not honour the two-arg
|
|
271
|
+
* form; we degrade gracefully either way.
|
|
272
|
+
*/
|
|
73
273
|
export function useI18n() {
|
|
74
274
|
const ctx = useWidgetContextOrThrow("useI18n");
|
|
75
|
-
|
|
275
|
+
const i18n = ctx.i18n || {};
|
|
276
|
+
const locale = typeof i18n.locale === "string" ? i18n.locale : "en";
|
|
277
|
+
const hostT = typeof i18n.t === "function" ? i18n.t : null;
|
|
278
|
+
const t = useCallback(
|
|
279
|
+
(key, fallback) => {
|
|
280
|
+
if (typeof key !== "string" || !key) {
|
|
281
|
+
return typeof fallback === "string" ? fallback : "";
|
|
282
|
+
}
|
|
283
|
+
if (hostT) {
|
|
284
|
+
// Try the two-arg form first; if the host's `t` ignores `fallback`
|
|
285
|
+
// and returns the bare key on a miss, swap in the fallback.
|
|
286
|
+
const out = hostT(key, fallback);
|
|
287
|
+
if (typeof out === "string" && out.length > 0 && out !== key) {
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
// Host returned the key (unresolved) or nothing usable.
|
|
291
|
+
return typeof fallback === "string" ? fallback : key;
|
|
292
|
+
}
|
|
293
|
+
return typeof fallback === "string" ? fallback : key;
|
|
294
|
+
},
|
|
295
|
+
[hostT]
|
|
296
|
+
);
|
|
297
|
+
return { t, locale };
|
|
76
298
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -54,6 +54,71 @@ export interface WidgetEventDescriptor {
|
|
|
54
54
|
payloadSchema?: WidgetPropertySchema;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Optional datastore template a widget can ship in its manifest. When the
|
|
59
|
+
* tenant installs the widget, every declared table is created in their
|
|
60
|
+
* workspace alongside the WidgetInstallation row, named
|
|
61
|
+
* `<widgetDisplayName>_<suffix>` (auto-suffixed `_2`/`_3`/... on name
|
|
62
|
+
* collisions). The tables persist when the widget is uninstalled — the
|
|
63
|
+
* tenant may have authored records into them.
|
|
64
|
+
*
|
|
65
|
+
* The shape mirrors the built-in template registry on the backend, with
|
|
66
|
+
* two limits a third-party widget must satisfy (enforced at submission
|
|
67
|
+
* time by the static analyzer):
|
|
68
|
+
*
|
|
69
|
+
* - At most 8 tables per widget. Templates exist to seed schema, not
|
|
70
|
+
* to be a full database design tool.
|
|
71
|
+
* - At most 24 columns per table. Same reason.
|
|
72
|
+
*
|
|
73
|
+
* RELATION columns reference siblings within the same template via
|
|
74
|
+
* `targetSuffix` — that suffix MUST belong to a table declared earlier
|
|
75
|
+
* in the array. There is intentionally no way to reference a table
|
|
76
|
+
* outside the widget's own template.
|
|
77
|
+
*/
|
|
78
|
+
export interface WidgetDatastoreTemplateColumn {
|
|
79
|
+
name: string;
|
|
80
|
+
dataType:
|
|
81
|
+
| "STRING"
|
|
82
|
+
| "TEXT"
|
|
83
|
+
| "NUMBER"
|
|
84
|
+
| "FLOAT"
|
|
85
|
+
| "BOOL"
|
|
86
|
+
| "DATE"
|
|
87
|
+
| "FILE"
|
|
88
|
+
| "STRING_ARRAY"
|
|
89
|
+
| "INT_ARRAY"
|
|
90
|
+
| "RELATION"
|
|
91
|
+
| "USER"
|
|
92
|
+
| "USER_GROUP";
|
|
93
|
+
required?: boolean;
|
|
94
|
+
/** For RELATION columns only — points at a sibling table's `suffix`. */
|
|
95
|
+
targetSuffix?: string;
|
|
96
|
+
/** For RELATION columns only. */
|
|
97
|
+
relationType?: "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_MANY";
|
|
98
|
+
/** REQ-ACL-RELINHERIT: opt this RELATION column into row-level ACL inheritance. */
|
|
99
|
+
inheritAcl?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface WidgetDatastoreTemplateTable {
|
|
103
|
+
/** Stable identifier within the template; appears in the created table name. */
|
|
104
|
+
suffix: string;
|
|
105
|
+
/** REQ-ACL-05: when true, the row creator gets full RWD+G on the new record. */
|
|
106
|
+
grantCreatorPermissions?: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* REQ-TEMPLATES-ACL: optional table-level public grant. Omitted means
|
|
109
|
+
* the table starts purely per-record (record-level grants are the only
|
|
110
|
+
* access path). canRead opens reads to everyone (including anonymous);
|
|
111
|
+
* canWrite only lets authenticated APP_USERs insert; canDelete remains
|
|
112
|
+
* studio-owner only regardless.
|
|
113
|
+
*/
|
|
114
|
+
publicGrant?: { canRead?: boolean; canWrite?: boolean; canDelete?: boolean };
|
|
115
|
+
columns: WidgetDatastoreTemplateColumn[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface WidgetDatastoreTemplate {
|
|
119
|
+
tables: WidgetDatastoreTemplateTable[];
|
|
120
|
+
}
|
|
121
|
+
|
|
57
122
|
export interface WidgetManifest {
|
|
58
123
|
id: string;
|
|
59
124
|
name: string;
|
|
@@ -67,6 +132,14 @@ export interface WidgetManifest {
|
|
|
67
132
|
requestedScopes: WidgetScope[];
|
|
68
133
|
propertySchema: WidgetPropertySchema;
|
|
69
134
|
events: WidgetEventDescriptor[];
|
|
135
|
+
/**
|
|
136
|
+
* Optional datastore template seeded into the tenant's workspace when
|
|
137
|
+
* the widget is installed. The author wires the resulting tables into
|
|
138
|
+
* the widget's `tableRef` properties via the Properties Panel — the
|
|
139
|
+
* SDK does not auto-bind them. See `WidgetDatastoreTemplate` for the
|
|
140
|
+
* structural constraints enforced at submission time.
|
|
141
|
+
*/
|
|
142
|
+
datastoreTemplate?: WidgetDatastoreTemplate;
|
|
70
143
|
}
|
|
71
144
|
|
|
72
145
|
export interface ThemeTokens {
|
|
@@ -202,9 +275,28 @@ export function useTheme(): ThemeTokens;
|
|
|
202
275
|
|
|
203
276
|
export function useI18n(): {
|
|
204
277
|
locale: string;
|
|
205
|
-
t(key: string,
|
|
278
|
+
t(key: string, fallback?: string): string;
|
|
206
279
|
};
|
|
207
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Error class thrown by useDatastoreMutation callbacks (and surfaced by
|
|
283
|
+
* useDatastoreQuery in its `error` slot). The `code` is a stable
|
|
284
|
+
* categorisation widgets can branch on.
|
|
285
|
+
*/
|
|
286
|
+
export class DatastoreError extends Error {
|
|
287
|
+
code:
|
|
288
|
+
| "VALIDATION"
|
|
289
|
+
| "CONSTRAINT_VIOLATION"
|
|
290
|
+
| "FORBIDDEN"
|
|
291
|
+
| "NOT_FOUND"
|
|
292
|
+
| "INTERNAL";
|
|
293
|
+
fieldErrors?: Record<string, string>;
|
|
294
|
+
constructor(code: DatastoreError["code"], message: string, opts?: {
|
|
295
|
+
fieldErrors?: Record<string, string>;
|
|
296
|
+
cause?: unknown;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
208
300
|
export function WidgetContextProvider(props: {
|
|
209
301
|
value: WidgetContext;
|
|
210
302
|
children?: ReactNode;
|
|
@@ -228,3 +320,69 @@ export function lintSource(source: string): {
|
|
|
228
320
|
ok: boolean;
|
|
229
321
|
findings: LintFinding[];
|
|
230
322
|
};
|
|
323
|
+
|
|
324
|
+
// --------------------------------------------------------------- CONTRACT
|
|
325
|
+
//
|
|
326
|
+
// Single-source-of-truth contract artefact. See docs/design/ai-widget-contract.md.
|
|
327
|
+
// The runtime value is `Object.freeze`d; the type below describes the
|
|
328
|
+
// public shape consumers can read.
|
|
329
|
+
|
|
330
|
+
export interface ContractHookEntry {
|
|
331
|
+
name: string;
|
|
332
|
+
signature: string;
|
|
333
|
+
returnShape: Record<string, string>;
|
|
334
|
+
requiredContextSlice: string[];
|
|
335
|
+
scopes: string[] | null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export interface ContractPrimitiveEntry {
|
|
339
|
+
name: string;
|
|
340
|
+
description: string;
|
|
341
|
+
props: Record<
|
|
342
|
+
string,
|
|
343
|
+
{ type: string; required?: boolean; description?: string }
|
|
344
|
+
>;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface ContractManifestField {
|
|
348
|
+
type: string;
|
|
349
|
+
required: boolean;
|
|
350
|
+
description?: string;
|
|
351
|
+
default?: unknown;
|
|
352
|
+
values?: readonly string[];
|
|
353
|
+
example?: unknown;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export interface ContractBundleShape {
|
|
357
|
+
name: string;
|
|
358
|
+
description: string;
|
|
359
|
+
predicate: string;
|
|
360
|
+
manifestSource: string;
|
|
361
|
+
audience: string;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export interface ContractBannedApi {
|
|
365
|
+
identifier: string;
|
|
366
|
+
reason: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export interface AiWidgetContract {
|
|
370
|
+
readonly version: string;
|
|
371
|
+
readonly hooks: ReadonlyArray<ContractHookEntry>;
|
|
372
|
+
readonly primitives: ReadonlyArray<ContractPrimitiveEntry>;
|
|
373
|
+
readonly manifestSchema: Readonly<Record<string, ContractManifestField>>;
|
|
374
|
+
readonly manifestCategories: ReadonlyArray<string>;
|
|
375
|
+
readonly manifestPlatforms: ReadonlyArray<string>;
|
|
376
|
+
readonly themeTokens: ThemeTokens;
|
|
377
|
+
readonly widgetContextShape: Readonly<
|
|
378
|
+
Record<string, { description: string; required: boolean; fields: Record<string, unknown> }>
|
|
379
|
+
>;
|
|
380
|
+
readonly bundleExportContract: ReadonlyArray<ContractBundleShape>;
|
|
381
|
+
readonly bannedApis: ReadonlyArray<ContractBannedApi>;
|
|
382
|
+
readonly allowedBareImports: ReadonlyArray<string>;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export const CONTRACT: AiWidgetContract;
|
|
386
|
+
|
|
387
|
+
export function isHookAllowed(name: string): boolean;
|
|
388
|
+
export function requiredContextKeys(): string[];
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { validateManifest, canonicalCategory } from "./manifest.js";
|
|
|
7
7
|
export { validatePropertySchema, validateProps } from "./property-schema.js";
|
|
8
8
|
export {
|
|
9
9
|
WidgetContextProvider,
|
|
10
|
+
DatastoreError,
|
|
10
11
|
useDatastoreQuery,
|
|
11
12
|
useDatastoreMutation,
|
|
12
13
|
useWidgetEvent,
|
|
@@ -14,4 +15,5 @@ export {
|
|
|
14
15
|
useI18n,
|
|
15
16
|
} from "./hooks.js";
|
|
16
17
|
export { Text, View, Pressable, Image, ScrollView } from "./primitives.js";
|
|
17
|
-
export { lintSource } from "./linter.js";
|
|
18
|
+
export { lintSource, bannedIdentifiers } from "./linter.js";
|
|
19
|
+
export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
|
package/dist/index.native.js
CHANGED
|
@@ -7,6 +7,7 @@ export { validateManifest, canonicalCategory } from "./manifest.js";
|
|
|
7
7
|
export { validatePropertySchema, validateProps } from "./property-schema.js";
|
|
8
8
|
export {
|
|
9
9
|
WidgetContextProvider,
|
|
10
|
+
DatastoreError,
|
|
10
11
|
useDatastoreQuery,
|
|
11
12
|
useDatastoreMutation,
|
|
12
13
|
useWidgetEvent,
|
|
@@ -14,4 +15,5 @@ export {
|
|
|
14
15
|
useI18n,
|
|
15
16
|
} from "./hooks.js";
|
|
16
17
|
export { Text, View, Pressable, Image, ScrollView } from "./primitives.native.js";
|
|
17
|
-
export { lintSource } from "./linter.js";
|
|
18
|
+
export { lintSource, bannedIdentifiers } from "./linter.js";
|
|
19
|
+
export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
|
package/dist/linter.js
CHANGED
|
@@ -2,29 +2,62 @@
|
|
|
2
2
|
// Scans widget source for banned patterns. Pure text scanning — no AST parsing
|
|
3
3
|
// dep — which is sufficient to gate v0.1 submissions and gives clear pointers
|
|
4
4
|
// to humans during review.
|
|
5
|
+
//
|
|
6
|
+
// The banned-identifier list is derived from `CONTRACT.bannedApis` so the
|
|
7
|
+
// system prompt, the linter, and the runtime allowlist agree.
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
9
|
+
import { CONTRACT } from "./contract.js";
|
|
10
|
+
|
|
11
|
+
// Per-identifier match rule. Most banned identifiers compile to a
|
|
12
|
+
// whole-word match; a few have special syntax (`Function(`, `new Function`,
|
|
13
|
+
// `import(`) that needs a tighter regex.
|
|
14
|
+
function _ruleForIdentifier(identifier, reason) {
|
|
15
|
+
const id = identifier;
|
|
16
|
+
if (id === "Function") {
|
|
17
|
+
return {
|
|
18
|
+
id: "no-function-constructor",
|
|
19
|
+
label: `${reason} (banned: Function() constructor)`,
|
|
20
|
+
pattern: /(^|[^A-Za-z0-9_$.])Function\s*\(/,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (id === "new Function") {
|
|
24
|
+
return {
|
|
25
|
+
id: "no-new-function",
|
|
26
|
+
label: `${reason} (banned: new Function())`,
|
|
27
|
+
pattern: /\bnew\s+Function\s*\(/,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (id === "import(") {
|
|
31
|
+
return {
|
|
32
|
+
id: "no-dynamic-import",
|
|
33
|
+
label: `${reason} (banned: dynamic import())`,
|
|
34
|
+
pattern: /(^|[^A-Za-z0-9_$.])import\s*\(/,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (id === "eval") {
|
|
38
|
+
return {
|
|
39
|
+
id: "no-eval",
|
|
40
|
+
label: `${reason} (banned: eval)`,
|
|
41
|
+
pattern: /\beval\s*\(/,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// Default: word-boundary anchored. Used for the simple identifier
|
|
45
|
+
// forms (`window`, `document`, `process`, ...).
|
|
46
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
47
|
+
return {
|
|
48
|
+
id: `no-${id.toLowerCase()}`,
|
|
49
|
+
label: `${reason} (banned: ${id})`,
|
|
50
|
+
pattern: new RegExp(`\\b${escaped}\\b`),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
|
|
55
|
+
_ruleForIdentifier(b.identifier, b.reason),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Extra rules that don't map 1:1 to a banned identifier in the contract:
|
|
59
|
+
// host-internal imports that widgets must never touch.
|
|
60
|
+
const EXTRA_RULES = [
|
|
28
61
|
{
|
|
29
62
|
id: "no-auth-store-import",
|
|
30
63
|
label: "widgets must not import the host's auth store",
|
|
@@ -32,11 +65,24 @@ const RULES = [
|
|
|
32
65
|
},
|
|
33
66
|
{
|
|
34
67
|
id: "no-axios-import",
|
|
35
|
-
label:
|
|
68
|
+
label:
|
|
69
|
+
"widgets must not import axios directly; use the injected datastore client",
|
|
36
70
|
pattern: /from\s+['"]axios['"]|require\s*\(\s*['"]axios['"]\s*\)/,
|
|
37
71
|
},
|
|
38
72
|
];
|
|
39
73
|
|
|
74
|
+
const RULES = [...CONTRACT_RULES, ...EXTRA_RULES];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns the contract-derived list of banned identifiers. Exposed so the
|
|
78
|
+
* SDK contract test (and any host build step that wants to surface the
|
|
79
|
+
* list to humans) can read the canonical set without re-parsing the
|
|
80
|
+
* source.
|
|
81
|
+
*/
|
|
82
|
+
export function bannedIdentifiers() {
|
|
83
|
+
return CONTRACT.bannedApis.map((b) => b.identifier);
|
|
84
|
+
}
|
|
85
|
+
|
|
40
86
|
/**
|
|
41
87
|
* Lint a JavaScript source string.
|
|
42
88
|
* @param {string} source
|
|
@@ -46,7 +92,9 @@ export function lintSource(source) {
|
|
|
46
92
|
if (typeof source !== "string") {
|
|
47
93
|
return {
|
|
48
94
|
ok: false,
|
|
49
|
-
findings: [
|
|
95
|
+
findings: [
|
|
96
|
+
{ rule: "input", label: "source must be a string", line: 0, snippet: "" },
|
|
97
|
+
],
|
|
50
98
|
};
|
|
51
99
|
}
|
|
52
100
|
const findings = [];
|