@colixsystems/widget-sdk 0.3.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 CHANGED
@@ -6,11 +6,34 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
6
6
 
7
7
  ## Status
8
8
 
9
- `v0.3.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**.
9
+ `v0.4.1` — 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
10
 
11
- ### What's new in 0.3.0
11
+ ### What's new in 0.4.1
12
12
 
13
- Additive: `WidgetManifest` now carries an optional `datastoreTemplate` field. When a tenant installs a widget that declares one, the table set is seeded into their workspace alongside the install. Tables follow the existing built-in template semantics (auto-suffixed naming, REQ-ACL-05 creator-grants, REQ-TEMPLATES-ACL public grants, REQ-ACL-RELINHERIT cross-relation inheritance) and persist when the widget is later uninstalled. See `WidgetDatastoreTemplate` in `src/index.d.ts` for the structural constraints — at most 8 tables per widget, 24 columns per table, RELATION columns address siblings by `suffix`.
13
+ - **`useDatastoreQuery` returns a stable `refetch` identity.** The hook no longer rebinds the underlying callback when the host's `WidgetContext` value (a fresh object identity on every render in Studio + PageRenderer) changes. Widgets that put `refetch` in a `useEffect` dep array no longer loop.
14
+
15
+ ### What's new in 0.4.0
16
+
17
+ - **`CONTRACT` is now a named export.** A frozen object literal describing the SDK surface every consumer (LLM system prompt, static analyzer, host builder, contract tests) derives from instead of declaring its own copy. See `docs/design/ai-widget-contract.md` for the full design and `src/contract.js` for the source. The contract carries:
18
+
19
+ - `hooks` — name, signature, return shape, required `WidgetContext` slices, manifest scopes.
20
+ - `primitives` — `Text` / `View` / `Pressable` / `Image` / `ScrollView` props.
21
+ - `manifestSchema` — the authoritative `WidgetManifest` field list (with `id` and `minAppStudioVersion`, not the legacy `manifestId` / `minAppStudioVer`).
22
+ - `themeTokens` — the default `useTheme()` payload.
23
+ - `widgetContextShape` — what the host must populate.
24
+ - `bundleExportContract` — the two default-export shapes the loader accepts.
25
+ - `bannedApis` — the global allowlist gate.
26
+ - `allowedBareImports` — `react`, `@colixsystems/widget-sdk`.
27
+
28
+ - **`useDatastoreQuery` is now stateful.** Returns `{ data, loading, error, refetch }`. Previously the hook returned the raw `list(...)` promise; widgets that called `.map(...)` synchronously on the result threw on first render. Migration: replace `const rows = useDatastoreQuery(...)` with `const { data, loading, error } = useDatastoreQuery(...)`. `data` is always an array (empty when the table is unbound or loading).
29
+
30
+ - **`DatastoreError` is now a named export.** Mutations throw a structured `DatastoreError` with `.code` (`VALIDATION` / `CONSTRAINT_VIOLATION` / `FORBIDDEN` / `NOT_FOUND` / `INTERNAL`) and an optional `.fieldErrors` map populated from the host's 422 payload. Widgets can branch on `err.code` without parsing axios messages.
31
+
32
+ - **`useI18n().t` now honours `fallback`.** `t(key, fallback)` returns `fallback` when the host's translation table has no entry for `key`.
33
+
34
+ ### What was in 0.3.0
35
+
36
+ Additive: `WidgetManifest` carries an optional `datastoreTemplate` field. When a tenant installs a widget that declares one, the table set is seeded into their workspace alongside the install. Tables follow the existing built-in template semantics (auto-suffixed naming, REQ-ACL-05 creator-grants, REQ-TEMPLATES-ACL public grants, REQ-ACL-RELINHERIT cross-relation inheritance) and persist when the widget is later uninstalled. See `WidgetDatastoreTemplate` in `src/index.d.ts` for the structural constraints — at most 8 tables per widget, 24 columns per table, RELATION columns address siblings by `suffix`.
14
37
 
15
38
  ## Public API
16
39
 
@@ -0,0 +1,10 @@
1
+ // Re-export of the default theme tokens from the canonical contract data.
2
+ // Exists so the frontend's `widgetTheme.js` can keep its existing
3
+ // `DEFAULT_THEME_TOKENS` named export without reaching into the contract
4
+ // path itself. The values are the same Object.freeze'd reference as
5
+ // CONTRACT.themeTokens — assertion #6 in the contract test relies on
6
+ // referential identity.
7
+
8
+ import { CONTRACT } from "./contract.js";
9
+
10
+ export const DEFAULT_THEME_TOKENS = CONTRACT.themeTokens;
@@ -0,0 +1,404 @@
1
+ // CommonJS-flavoured copy of the contract data so backend services (CJS
2
+ // runtime) can `require()` it directly without dynamic import gymnastics.
3
+ //
4
+ // The ESM `contract.js` re-exports the SAME object as the `CONTRACT` named
5
+ // export. The contract test asserts the two are identical (deep-equal) so
6
+ // drift between this file and the ESM source-of-truth is caught in CI.
7
+ //
8
+ // Keep edits in lockstep between `contract.cjs` and `contract.js`. The
9
+ // SDK build script copies both into `dist/`.
10
+
11
+ const DEFAULT_THEME_TOKENS = Object.freeze({
12
+ colors: Object.freeze({
13
+ primary: "#ff6b5b",
14
+ onPrimary: "#ffffff",
15
+ surface: "#ffffff",
16
+ onSurface: "#111827",
17
+ surfaceMuted: "#f8fafc",
18
+ onSurfaceMuted: "#475569",
19
+ border: "#e2e8f0",
20
+ danger: "#dc2626",
21
+ success: "#059669",
22
+ warning: "#d97706",
23
+ info: "#0284c7",
24
+ }),
25
+ spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
26
+ radii: Object.freeze({ sm: 4, md: 8, lg: 16, pill: 9999 }),
27
+ typography: Object.freeze({
28
+ fontFamily:
29
+ 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
30
+ sizes: Object.freeze({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 }),
31
+ }),
32
+ });
33
+
34
+ const HOOKS = [
35
+ {
36
+ name: "useTheme",
37
+ signature: "useTheme()",
38
+ returnShape: {
39
+ colors:
40
+ "{ primary, onPrimary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }",
41
+ spacing: "{ xs, sm, md, lg, xl }",
42
+ radii: "{ sm, md, lg, pill }",
43
+ typography: "{ fontFamily, sizes: { xs, sm, md, lg, xl, xxl } }",
44
+ },
45
+ requiredContextSlice: ["workspace.theme"],
46
+ scopes: null,
47
+ },
48
+ {
49
+ name: "useI18n",
50
+ signature: "useI18n()",
51
+ returnShape: {
52
+ t: "(key: string, fallback?: string) => string",
53
+ locale: "string",
54
+ },
55
+ requiredContextSlice: ["i18n.t", "i18n.locale"],
56
+ scopes: null,
57
+ },
58
+ {
59
+ name: "useDatastoreQuery",
60
+ signature: "useDatastoreQuery(tableId, options?)",
61
+ returnShape: {
62
+ data: "Record[]",
63
+ loading: "boolean",
64
+ error: "DatastoreError | null",
65
+ refetch: "() => Promise<void>",
66
+ },
67
+ requiredContextSlice: ["datastore.records"],
68
+ scopes: ["datastore.read:*"],
69
+ },
70
+ {
71
+ name: "useDatastoreMutation",
72
+ signature: "useDatastoreMutation(tableId)",
73
+ returnShape: {
74
+ create: "(record) => Promise<Record> // rejects with DatastoreError",
75
+ update: "(id, partial) => Promise<Record> // rejects with DatastoreError",
76
+ delete: "(id) => Promise<void> // rejects with DatastoreError",
77
+ },
78
+ requiredContextSlice: ["datastore.records"],
79
+ scopes: ["datastore.write:*"],
80
+ },
81
+ {
82
+ name: "useWidgetEvent",
83
+ signature: "useWidgetEvent(eventName)",
84
+ returnShape: { emit: "(payload?) => void" },
85
+ requiredContextSlice: ["events.emit"],
86
+ scopes: null,
87
+ },
88
+ ];
89
+
90
+ const PRIMITIVES = [
91
+ {
92
+ name: "Text",
93
+ description:
94
+ "Text node. Web: <span>. Native: react-native Text. Use `style` for typography.",
95
+ props: {
96
+ children: { type: "ReactNode", description: "Text content." },
97
+ style: { type: "CSSObject | StyleObject", description: "Inline style." },
98
+ },
99
+ },
100
+ {
101
+ name: "View",
102
+ description:
103
+ "Block container. Web: <div>. Native: react-native View. Use for layout boxes.",
104
+ props: {
105
+ children: { type: "ReactNode" },
106
+ style: { type: "CSSObject | StyleObject" },
107
+ },
108
+ },
109
+ {
110
+ name: "Pressable",
111
+ description:
112
+ "Clickable wrapper. Use instead of raw <button> so cross-platform styling stays consistent.",
113
+ props: {
114
+ children: { type: "ReactNode" },
115
+ style: { type: "CSSObject | StyleObject" },
116
+ onPress: {
117
+ type: "(event) => void",
118
+ description: "Fires on click/tap. Web preventDefault is auto-applied.",
119
+ },
120
+ },
121
+ },
122
+ {
123
+ name: "Image",
124
+ description:
125
+ "Image. Pass an object with a `uri` (or a bare string on web). Falls back to `alt`.",
126
+ props: {
127
+ source: {
128
+ type: "{ uri: string } | string",
129
+ required: true,
130
+ description: "Source object or bare URL string.",
131
+ },
132
+ alt: { type: "string", description: "Alt text for accessibility." },
133
+ style: { type: "CSSObject | StyleObject" },
134
+ },
135
+ },
136
+ {
137
+ name: "ScrollView",
138
+ description:
139
+ "Scrollable container. Pass `horizontal` to scroll on the x-axis.",
140
+ props: {
141
+ children: { type: "ReactNode" },
142
+ horizontal: { type: "boolean" },
143
+ style: { type: "CSSObject | StyleObject" },
144
+ },
145
+ },
146
+ ];
147
+
148
+ const CATEGORIES = [
149
+ "INPUT",
150
+ "DISPLAY",
151
+ "LAYOUT",
152
+ "DATA",
153
+ "MEDIA",
154
+ "COMMUNICATION",
155
+ "CUSTOM",
156
+ ];
157
+ const PLATFORMS = ["web", "native"];
158
+
159
+ // Reverse-DNS-ish manifest id, e.g. "com.acme.charts.barchart". Two or
160
+ // more labels, lowercase alnum + hyphen, label starts with a letter. The
161
+ // analyzer + the SDK validator both read this from the contract so a
162
+ // rename / relaxation can only happen in one place.
163
+ const MANIFEST_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
164
+
165
+ const MANIFEST_SCHEMA = {
166
+ id: {
167
+ type: "string",
168
+ required: true,
169
+ description: "Reverse-DNS identifier, e.g. com.acme.hello.",
170
+ example: "com.acme.hello",
171
+ pattern: MANIFEST_ID_PATTERN,
172
+ },
173
+ name: {
174
+ type: "string",
175
+ required: true,
176
+ description: "Human-readable widget name.",
177
+ },
178
+ version: {
179
+ type: "string",
180
+ required: true,
181
+ description: "Semver. Patch bump per publish unless author overrides.",
182
+ example: "1.0.0",
183
+ },
184
+ category: {
185
+ type: "enum",
186
+ required: true,
187
+ description:
188
+ "One of " + CATEGORIES.join(", ") + ". Drives the palette section.",
189
+ values: CATEGORIES,
190
+ },
191
+ icon: {
192
+ type: "string",
193
+ required: true,
194
+ description: "Icon identifier, e.g. lucide:sparkles.",
195
+ default: "lucide:sparkles",
196
+ },
197
+ description: {
198
+ type: "string",
199
+ required: true,
200
+ description: "1-2 sentence storefront description.",
201
+ },
202
+ author: {
203
+ type: "object",
204
+ required: true,
205
+ description: "{ name: string, url?: string, email?: string }",
206
+ },
207
+ supportedPlatforms: {
208
+ type: "string[]",
209
+ required: true,
210
+ description: "Subset of " + PLATFORMS.join(", ") + ".",
211
+ default: ["web"],
212
+ },
213
+ minAppStudioVersion: {
214
+ type: "string",
215
+ required: true,
216
+ description: "Semver range, e.g. >=2.4.0.",
217
+ default: ">=0.1.0",
218
+ },
219
+ requestedScopes: {
220
+ type: "string[]",
221
+ required: true,
222
+ description:
223
+ "Permission scopes; use [] unless the widget reads/writes data.",
224
+ default: [],
225
+ },
226
+ propertySchema: {
227
+ type: "object",
228
+ required: true,
229
+ description: "Map of prop name to property definition.",
230
+ default: {},
231
+ },
232
+ events: {
233
+ type: "object[]",
234
+ required: true,
235
+ description: "Declared events. Each entry { name, payloadSchema? }.",
236
+ default: [],
237
+ },
238
+ };
239
+
240
+ const WIDGET_CONTEXT_SHAPE = {
241
+ props: {
242
+ description:
243
+ "Resolved property values, shape per manifest.propertySchema.",
244
+ required: true,
245
+ fields: {},
246
+ },
247
+ widget: {
248
+ description:
249
+ "Identity of the currently rendered widget instance ({ id, version }).",
250
+ required: true,
251
+ fields: { id: "manifest.id", version: "manifest.version" },
252
+ },
253
+ user: {
254
+ description: "Signed-in user ({ id, email, displayName }).",
255
+ required: true,
256
+ fields: { id: "string", email: "string", displayName: "string" },
257
+ },
258
+ workspace: {
259
+ description:
260
+ "Workspace identity + resolved theme ({ id, slug, theme, locale }).",
261
+ required: true,
262
+ fields: {
263
+ id: "string",
264
+ slug: "string",
265
+ theme: "ThemeTokens",
266
+ locale: "string",
267
+ },
268
+ },
269
+ navigation: {
270
+ description: "{ push(path), replace(path), back() }.",
271
+ required: true,
272
+ fields: { push: "function", replace: "function", back: "function" },
273
+ },
274
+ datastore: {
275
+ description:
276
+ "{ records(table) -> { list(query?), get(id), create(values), update(id, values), delete(id) } }.",
277
+ required: true,
278
+ fields: { records: "function" },
279
+ },
280
+ events: {
281
+ description: "{ emit(name, payload) }.",
282
+ required: true,
283
+ fields: { emit: "function" },
284
+ },
285
+ i18n: {
286
+ description: "{ t(key, fallback?), locale }.",
287
+ required: true,
288
+ fields: { t: "function", locale: "string" },
289
+ },
290
+ logger: {
291
+ description:
292
+ "{ debug, info, warn, error } — host-routed; never use console.*.",
293
+ required: true,
294
+ fields: {
295
+ debug: "function",
296
+ info: "function",
297
+ warn: "function",
298
+ error: "function",
299
+ },
300
+ },
301
+ };
302
+
303
+ const BUNDLE_EXPORT_CONTRACT = [
304
+ {
305
+ name: "wrapped",
306
+ description:
307
+ "default export = defineWidget({ manifest, component }) → resolves to { manifest, component, _kind: 'widget' }. Public marketplace widgets.",
308
+ predicate:
309
+ "typeof mod.default === 'object' && typeof mod.default.component === 'function' && typeof mod.default.manifest === 'object'",
310
+ manifestSource: "mod.default.manifest",
311
+ audience: "public-marketplace",
312
+ },
313
+ {
314
+ name: "bare-component",
315
+ description:
316
+ "default export = function MyWidget(props) {...} → host pairs it with the installation's pinnedVersion.manifestJson. AI-agent widgets.",
317
+ predicate: "typeof mod.default === 'function'",
318
+ manifestSource: "installation.pinnedVersion.manifestJson",
319
+ audience: "ai-agent",
320
+ },
321
+ ];
322
+
323
+ const BANNED_APIS = [
324
+ { identifier: "eval", reason: "Arbitrary code evaluation." },
325
+ {
326
+ identifier: "Function",
327
+ reason: "Function() constructor evaluates strings.",
328
+ },
329
+ {
330
+ identifier: "new Function",
331
+ reason: "Function() constructor evaluates strings.",
332
+ },
333
+ { identifier: "window", reason: "Host environment escape." },
334
+ {
335
+ identifier: "document",
336
+ reason: "Host environment escape; use SDK primitives.",
337
+ },
338
+ {
339
+ identifier: "process",
340
+ reason: "Node global; unavailable in the browser, banned for parity.",
341
+ },
342
+ {
343
+ identifier: "localStorage",
344
+ reason: "Bypasses host data model; not portable to native.",
345
+ },
346
+ {
347
+ identifier: "sessionStorage",
348
+ reason: "Same reason as localStorage.",
349
+ },
350
+ {
351
+ identifier: "fetch",
352
+ reason:
353
+ "Direct network calls bypass the datastore client + tenant auth.",
354
+ },
355
+ {
356
+ identifier: "XMLHttpRequest",
357
+ reason:
358
+ "Direct network calls bypass the datastore client + tenant auth.",
359
+ },
360
+ {
361
+ identifier: "import(",
362
+ reason: "Dynamic import bypasses the loader's allowlist.",
363
+ },
364
+ { identifier: "globalThis", reason: "Host environment escape." },
365
+ ];
366
+
367
+ const ALLOWED_BARE_IMPORTS = ["react", "@colixsystems/widget-sdk"];
368
+
369
+ function deepFreeze(value) {
370
+ if (value === null || typeof value !== "object") return value;
371
+ if (Object.isFrozen(value)) return value;
372
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
373
+ return Object.freeze(value);
374
+ }
375
+
376
+ const CONTRACT = deepFreeze({
377
+ version: "1.0.0",
378
+ hooks: HOOKS,
379
+ primitives: PRIMITIVES,
380
+ manifestSchema: MANIFEST_SCHEMA,
381
+ manifestCategories: CATEGORIES,
382
+ manifestPlatforms: PLATFORMS,
383
+ themeTokens: DEFAULT_THEME_TOKENS,
384
+ widgetContextShape: WIDGET_CONTEXT_SHAPE,
385
+ bundleExportContract: BUNDLE_EXPORT_CONTRACT,
386
+ bannedApis: BANNED_APIS,
387
+ allowedBareImports: ALLOWED_BARE_IMPORTS,
388
+ });
389
+
390
+ function isHookAllowed(name) {
391
+ return CONTRACT.hooks.some((h) => h.name === name);
392
+ }
393
+
394
+ function requiredContextKeys() {
395
+ const keys = new Set();
396
+ for (const hook of CONTRACT.hooks) {
397
+ for (const dotPath of hook.requiredContextSlice) {
398
+ keys.add(dotPath.split(".")[0]);
399
+ }
400
+ }
401
+ return [...keys];
402
+ }
403
+
404
+ module.exports = { CONTRACT, isHookAllowed, requiredContextKeys };
@@ -0,0 +1,17 @@
1
+ // Single-source-of-truth contract for the AI Widget Agent and every layer
2
+ // that derives from it. See docs/design/ai-widget-contract.md for the full
3
+ // rationale.
4
+ //
5
+ // The canonical data lives in `contract.cjs` so backend services (CJS
6
+ // runtime) can `require()` it without dynamic import gymnastics. This file
7
+ // is the ESM mirror — both modules export the exact same frozen
8
+ // `CONTRACT` object, and the contract test asserts they stay in lockstep.
9
+
10
+ import { createRequire } from "module";
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const cjs = require("./contract.cjs");
14
+
15
+ export const CONTRACT = cjs.CONTRACT;
16
+ export const isHookAllowed = cjs.isHookAllowed;
17
+ export const requiredContextKeys = cjs.requiredContextKeys;
package/dist/hooks.js CHANGED
@@ -1,15 +1,58 @@
1
- // Widget hooks per docs/architecture/widget-marketplace.md §3.1.
2
- // Each hook reads from the host-provided WidgetContext via React context, then
3
- // delegates to the host implementation. Bodies are intentionally slim the
4
- // hosts (Studio, Player, exported app) own the runtime semantics.
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, { createContext, useContext, useCallback } from "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
- // v0.1 scope: returns the host's datastore-backed query result.
33
- // The host owns suspense/caching; this hook is a passthrough to ctx.datastore.
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
- // The host-injected datastore is expected to expose a `useRecords(table, query)` hook;
40
- // v0.1 stub returns the raw promise interface and lets the host wire React Query.
41
- return ctx.datastore.records(table).list(query);
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
- // v0.1 scope: returns { create, update, delete } from the injected datastore client.
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) => ns.create(values),
53
- update: (id, values) => ns.update(id, values),
54
- delete: (id) => ns.delete(id),
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
- // v0.1 scope: emits a named event through ctx.events.emit. Page-level event
59
- // bindings (subscribed by the host) decide what happens next.
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
- // v0.1 scope: returns ThemeTokens. Host owns theme resolution per workspace.
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
- // v0.1 scope: returns { locale, t }. Host owns translation tables;
72
- // the SDK does not bundle a translation engine.
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
- return ctx.i18n;
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
@@ -275,9 +275,28 @@ export function useTheme(): ThemeTokens;
275
275
 
276
276
  export function useI18n(): {
277
277
  locale: string;
278
- t(key: string, vars?: Record<string, unknown>): string;
278
+ t(key: string, fallback?: string): string;
279
279
  };
280
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
+
281
300
  export function WidgetContextProvider(props: {
282
301
  value: WidgetContext;
283
302
  children?: ReactNode;
@@ -301,3 +320,69 @@ export function lintSource(source: string): {
301
320
  ok: boolean;
302
321
  findings: LintFinding[];
303
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";
@@ -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
- const RULES = [
7
- {
8
- id: "no-eval",
9
- label: "eval() is forbidden",
10
- pattern: /\beval\s*\(/,
11
- },
12
- {
13
- id: "no-new-function",
14
- label: "new Function() is forbidden",
15
- pattern: /\bnew\s+Function\s*\(/,
16
- },
17
- {
18
- id: "no-function-constructor",
19
- label: "Function() constructor is forbidden",
20
- // Bare Function( call not preceded by identifier/property char.
21
- pattern: /(^|[^A-Za-z0-9_$.])Function\s*\(/,
22
- },
23
- {
24
- id: "no-dynamic-import",
25
- label: "dynamic import() is forbidden",
26
- pattern: /(^|[^A-Za-z0-9_$.])import\s*\(/,
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: "widgets must not import axios directly; use the injected datastore client",
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: [{ rule: "input", label: "source must be a string", line: 0, snippet: "" }],
95
+ findings: [
96
+ { rule: "input", label: "source must be a string", line: 0, snippet: "" },
97
+ ],
50
98
  };
51
99
  }
52
100
  const findings = [];
@@ -0,0 +1,144 @@
1
+ // CommonJS mirror of manifest.js. Lets backend services (CJS) call
2
+ // `validateManifest(...)` without a dynamic-import detour. The two files
3
+ // share their constants by mutual import via createRequire — manifest.js
4
+ // re-exports the same functions defined here so there is exactly one
5
+ // implementation.
6
+
7
+ const VALID_CATEGORIES = new Set([
8
+ "input", "display", "layout", "data", "media", "communication", "custom",
9
+ "INPUT", "DISPLAY", "LAYOUT", "DATA", "MEDIA", "COMMUNICATION", "CUSTOM",
10
+ ]);
11
+ const VALID_PLATFORMS = new Set(["web", "native"]);
12
+
13
+ function canonicalCategory(c) {
14
+ if (typeof c !== "string") return null;
15
+ const upper = c.toUpperCase();
16
+ return [
17
+ "INPUT",
18
+ "DISPLAY",
19
+ "LAYOUT",
20
+ "DATA",
21
+ "MEDIA",
22
+ "COMMUNICATION",
23
+ "CUSTOM",
24
+ ].includes(upper)
25
+ ? upper
26
+ : null;
27
+ }
28
+
29
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
30
+ const SEMVER_RANGE_RE = /^[\^~>=<]*\s*\d+\.\d+\.\d+/;
31
+ const ID_RE = /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/;
32
+
33
+ function isNonEmptyString(v) {
34
+ return typeof v === "string" && v.length > 0;
35
+ }
36
+
37
+ function pushIf(errors, cond, msg) {
38
+ if (!cond) errors.push(msg);
39
+ }
40
+
41
+ function validateManifest(m) {
42
+ const errors = [];
43
+ if (m === null || typeof m !== "object") {
44
+ return { ok: false, errors: ["manifest must be an object"] };
45
+ }
46
+ const manifest = m;
47
+
48
+ pushIf(errors, isNonEmptyString(manifest.id), "manifest.id must be a non-empty string");
49
+ if (isNonEmptyString(manifest.id)) {
50
+ pushIf(
51
+ errors,
52
+ ID_RE.test(manifest.id),
53
+ "manifest.id must be reverse-DNS, e.g. com.acme.charts.barchart",
54
+ );
55
+ }
56
+
57
+ pushIf(errors, isNonEmptyString(manifest.name), "manifest.name must be a non-empty string");
58
+
59
+ pushIf(errors, isNonEmptyString(manifest.version), "manifest.version must be a non-empty string");
60
+ if (isNonEmptyString(manifest.version)) {
61
+ pushIf(errors, SEMVER_RE.test(manifest.version), "manifest.version must be a valid semver");
62
+ }
63
+
64
+ pushIf(
65
+ errors,
66
+ typeof manifest.category === "string" && VALID_CATEGORIES.has(manifest.category),
67
+ `manifest.category must be one of ${[...VALID_CATEGORIES].join(", ")}`,
68
+ );
69
+
70
+ pushIf(errors, isNonEmptyString(manifest.icon), "manifest.icon must be a non-empty string");
71
+ pushIf(errors, isNonEmptyString(manifest.description), "manifest.description must be a non-empty string");
72
+
73
+ if (manifest.author === null || typeof manifest.author !== "object") {
74
+ errors.push("manifest.author must be an object with a name");
75
+ } else {
76
+ const author = manifest.author;
77
+ pushIf(errors, isNonEmptyString(author.name), "manifest.author.name must be a non-empty string");
78
+ if (author.url !== undefined) {
79
+ pushIf(errors, isNonEmptyString(author.url), "manifest.author.url must be a string");
80
+ }
81
+ if (author.email !== undefined) {
82
+ pushIf(errors, isNonEmptyString(author.email), "manifest.author.email must be a string");
83
+ }
84
+ }
85
+
86
+ if (!Array.isArray(manifest.supportedPlatforms) || manifest.supportedPlatforms.length === 0) {
87
+ errors.push("manifest.supportedPlatforms must be a non-empty array");
88
+ } else {
89
+ for (const p of manifest.supportedPlatforms) {
90
+ if (!VALID_PLATFORMS.has(p)) {
91
+ errors.push(`manifest.supportedPlatforms contains invalid value "${p}"`);
92
+ }
93
+ }
94
+ }
95
+
96
+ pushIf(
97
+ errors,
98
+ isNonEmptyString(manifest.minAppStudioVersion),
99
+ "manifest.minAppStudioVersion must be a non-empty string",
100
+ );
101
+ if (isNonEmptyString(manifest.minAppStudioVersion)) {
102
+ pushIf(
103
+ errors,
104
+ SEMVER_RANGE_RE.test(manifest.minAppStudioVersion),
105
+ "manifest.minAppStudioVersion must be a semver range, e.g. >=2.4.0",
106
+ );
107
+ }
108
+
109
+ if (!Array.isArray(manifest.requestedScopes)) {
110
+ errors.push("manifest.requestedScopes must be an array (use [] for none)");
111
+ } else {
112
+ for (const s of manifest.requestedScopes) {
113
+ if (!isNonEmptyString(s)) {
114
+ errors.push("manifest.requestedScopes entries must be non-empty strings");
115
+ break;
116
+ }
117
+ }
118
+ }
119
+
120
+ if (manifest.propertySchema === null || typeof manifest.propertySchema !== "object") {
121
+ errors.push("manifest.propertySchema must be an object");
122
+ }
123
+
124
+ if (!Array.isArray(manifest.events)) {
125
+ errors.push("manifest.events must be an array (use [] for none)");
126
+ } else {
127
+ for (const e of manifest.events) {
128
+ if (e === null || typeof e !== "object") {
129
+ errors.push("manifest.events entries must be objects");
130
+ break;
131
+ }
132
+ if (!isNonEmptyString(e.name)) {
133
+ errors.push("manifest.events[].name must be a non-empty string");
134
+ }
135
+ }
136
+ }
137
+
138
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
139
+ }
140
+
141
+ module.exports = {
142
+ validateManifest,
143
+ canonicalCategory,
144
+ };
package/dist/manifest.js CHANGED
@@ -1,133 +1,15 @@
1
1
  // Shape validation for WidgetManifest per docs/architecture/widget-marketplace.md §2.1.
2
2
  // Pure JS — no third-party deps. Banned-identifier scanning lives in linter.js.
3
+ //
4
+ // The implementation lives in `manifest.cjs` so backend services (CJS
5
+ // runtime) can `require` the same validator the SDK ships to ESM
6
+ // consumers. This file is a thin re-export so ESM imports keep working
7
+ // unchanged.
3
8
 
4
- // REQ-MKT canonical category values are uppercase (matching the Prisma
5
- // `WidgetCategory` enum used to persist them server-side). We accept both
6
- // cases on input — lowercase is the form spelled out in design doc §2.2
7
- // and the TS types — and canonicalize internally via `canonicalCategory`.
8
- const VALID_CATEGORIES = new Set([
9
- "input", "display", "layout", "data", "media", "communication", "custom",
10
- "INPUT", "DISPLAY", "LAYOUT", "DATA", "MEDIA", "COMMUNICATION", "CUSTOM",
11
- ]);
12
- const VALID_PLATFORMS = new Set(["web", "native"]);
9
+ import { createRequire } from "module";
13
10
 
14
- /**
15
- * Normalize a manifest category to its canonical (uppercase) form.
16
- * Returns `null` if the input is not a recognised category.
17
- * @param {unknown} c
18
- * @returns {string | null}
19
- */
20
- export function canonicalCategory(c) {
21
- if (typeof c !== "string") return null;
22
- const upper = c.toUpperCase();
23
- return ["INPUT", "DISPLAY", "LAYOUT", "DATA", "MEDIA", "COMMUNICATION", "CUSTOM"].includes(upper) ? upper : null;
24
- }
25
- const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
26
- const SEMVER_RANGE_RE = /^[\^~>=<]*\s*\d+\.\d+\.\d+/;
27
- const ID_RE = /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/;
11
+ const require = createRequire(import.meta.url);
12
+ const cjs = require("./manifest.cjs");
28
13
 
29
- function isNonEmptyString(v) {
30
- return typeof v === "string" && v.length > 0;
31
- }
32
-
33
- function pushIf(errors, cond, msg) {
34
- if (!cond) errors.push(msg);
35
- }
36
-
37
- /**
38
- * Validates a WidgetManifest shape.
39
- * @param {unknown} m
40
- * @returns {{ ok: true } | { ok: false, errors: string[] }}
41
- */
42
- export function validateManifest(m) {
43
- const errors = [];
44
-
45
- if (m === null || typeof m !== "object") {
46
- return { ok: false, errors: ["manifest must be an object"] };
47
- }
48
-
49
- const manifest = /** @type {Record<string, unknown>} */ (m);
50
-
51
- pushIf(errors, isNonEmptyString(manifest.id), "manifest.id must be a non-empty string");
52
- if (isNonEmptyString(manifest.id)) {
53
- pushIf(errors, ID_RE.test(/** @type {string} */ (manifest.id)),
54
- "manifest.id must be reverse-DNS, e.g. com.acme.charts.barchart");
55
- }
56
-
57
- pushIf(errors, isNonEmptyString(manifest.name), "manifest.name must be a non-empty string");
58
-
59
- pushIf(errors, isNonEmptyString(manifest.version), "manifest.version must be a non-empty string");
60
- if (isNonEmptyString(manifest.version)) {
61
- pushIf(errors, SEMVER_RE.test(/** @type {string} */ (manifest.version)),
62
- "manifest.version must be a valid semver");
63
- }
64
-
65
- pushIf(errors,
66
- typeof manifest.category === "string" && VALID_CATEGORIES.has(manifest.category),
67
- `manifest.category must be one of ${[...VALID_CATEGORIES].join(", ")}`);
68
-
69
- pushIf(errors, isNonEmptyString(manifest.icon), "manifest.icon must be a non-empty string");
70
- pushIf(errors, isNonEmptyString(manifest.description), "manifest.description must be a non-empty string");
71
-
72
- if (manifest.author === null || typeof manifest.author !== "object") {
73
- errors.push("manifest.author must be an object with a name");
74
- } else {
75
- const author = /** @type {Record<string, unknown>} */ (manifest.author);
76
- pushIf(errors, isNonEmptyString(author.name), "manifest.author.name must be a non-empty string");
77
- if (author.url !== undefined) {
78
- pushIf(errors, isNonEmptyString(author.url), "manifest.author.url must be a string");
79
- }
80
- if (author.email !== undefined) {
81
- pushIf(errors, isNonEmptyString(author.email), "manifest.author.email must be a string");
82
- }
83
- }
84
-
85
- if (!Array.isArray(manifest.supportedPlatforms) || manifest.supportedPlatforms.length === 0) {
86
- errors.push("manifest.supportedPlatforms must be a non-empty array");
87
- } else {
88
- for (const p of manifest.supportedPlatforms) {
89
- if (!VALID_PLATFORMS.has(p)) {
90
- errors.push(`manifest.supportedPlatforms contains invalid value "${p}"`);
91
- }
92
- }
93
- }
94
-
95
- pushIf(errors, isNonEmptyString(manifest.minAppStudioVersion),
96
- "manifest.minAppStudioVersion must be a non-empty string");
97
- if (isNonEmptyString(manifest.minAppStudioVersion)) {
98
- pushIf(errors, SEMVER_RANGE_RE.test(/** @type {string} */ (manifest.minAppStudioVersion)),
99
- "manifest.minAppStudioVersion must be a semver range, e.g. >=2.4.0");
100
- }
101
-
102
- if (!Array.isArray(manifest.requestedScopes)) {
103
- errors.push("manifest.requestedScopes must be an array (use [] for none)");
104
- } else {
105
- for (const s of manifest.requestedScopes) {
106
- if (!isNonEmptyString(s)) {
107
- errors.push("manifest.requestedScopes entries must be non-empty strings");
108
- break;
109
- }
110
- }
111
- }
112
-
113
- if (manifest.propertySchema === null || typeof manifest.propertySchema !== "object") {
114
- errors.push("manifest.propertySchema must be an object");
115
- }
116
-
117
- if (!Array.isArray(manifest.events)) {
118
- errors.push("manifest.events must be an array (use [] for none)");
119
- } else {
120
- for (const e of manifest.events) {
121
- if (e === null || typeof e !== "object") {
122
- errors.push("manifest.events entries must be objects");
123
- break;
124
- }
125
- const ev = /** @type {Record<string, unknown>} */ (e);
126
- if (!isNonEmptyString(ev.name)) {
127
- errors.push("manifest.events[].name must be a non-empty string");
128
- }
129
- }
130
- }
131
-
132
- return errors.length === 0 ? { ok: true } : { ok: false, errors };
133
- }
14
+ export const validateManifest = cjs.validateManifest;
15
+ export const canonicalCategory = cjs.canonicalCategory;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,6 +17,12 @@
17
17
  "./linter": {
18
18
  "import": "./dist/linter.js",
19
19
  "default": "./dist/linter.js"
20
+ },
21
+ "./contract": {
22
+ "types": "./dist/index.d.ts",
23
+ "require": "./dist/contract.cjs",
24
+ "import": "./dist/contract.js",
25
+ "default": "./dist/contract.cjs"
20
26
  }
21
27
  },
22
28
  "bin": {
@@ -29,7 +35,7 @@
29
35
  ],
30
36
  "scripts": {
31
37
  "build": "node scripts/build.js",
32
- "test": "node --test src"
38
+ "test": "node --test src/__tests__/contract.test.js"
33
39
  },
34
40
  "engines": {
35
41
  "node": ">=18"