@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/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
@@ -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, vars?: Record<string, unknown>): 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";
@@ -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 = [];