@colixsystems/widget-sdk 0.271.0 → 0.272.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,7 +86,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
86
86
 
87
87
  ## Status
88
88
 
89
- `v0.271.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**.
89
+ `v0.272.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**.
90
+
91
+ ### What's new in 0.272.0 (contract 1.222.0)
92
+
93
+ **Breaking for authors: more routes to the page's global object are banned.** `self`, `top`, `parent`, `frames` and `opener` are other names for `window`, `frameElement` leads to the host document, and a function's `.constructor` is `Function()`. The linter now refuses them (`no-self`, `no-top`, `no-parent`, `no-frames`, `no-opener`, `no-frameelement`, `no-constructor-access`), refuses `eval` and `Function` as any reference, not only as a call (`const e = eval`, `(0, eval)(…)`), and refuses a `\u` escape that spells a letter, digit, `_` or `$` in code (`no-identifier-escape`), which would hide a name from the scan. A widget version that uses them fails lint at submission; versions already published keep running.
94
+
95
+ - **`self` / `top` / `parent` / `frames` / `opener`** are refused wherever they read a variable: `top[k]`, `{ ...self }`, `typeof parent`, `c ? frames : x`. That includes a local variable with one of these names, so rename it (`const [rootTop, setRootTop] = useState(null)`, `const parentId = …`). A property (`insets.top`, `node.parent`), an object key (`{ top: 0 }`, `const { top: topInset } = insets`) and a word in JSX text after another word on the same line (`Back to top`) are fine. JSX text that is only the word (`<Text>top</Text>`) or starts a line is refused; render it as `{"top"}` or through `t()`.
96
+ - **`.constructor`** is refused as a property read (`x.constructor`, `x?.constructor`), a destructured key (`const { constructor } = fn`) and the quoted name (`x["constructor"]`, `Reflect.get(p, "constructor")`). A class component's own `constructor(props)` method is fine.
97
+ - **`Function`** stays allowed after `instanceof` (`v instanceof Function`); prefer `typeof v === "function"`. To test for a plain object, compare `Object.getPrototypeOf(x) === Object.prototype`.
90
98
 
91
99
  ### What's new in 0.271.0 (contract 1.221.0)
92
100
 
@@ -3493,6 +3501,10 @@ joined — it then reads back as `{ id, name, invited: true }`, with no `email`
3493
3501
  behind it. `status: 'open'` is every invite that is neither accepted nor revoked
3494
3502
  (`pending`, `not_sent` or `expired`); a re-invite keeps the id, so without it earlier invites match too.
3495
3503
 
3504
+ Only a signed-in member viewer sees a `USER` cell's `email`. A visitor who is not
3505
+ signed in, and an anonymous account, read a member as `{ id, name }`, so render
3506
+ `name` and treat `email` as optional.
3507
+
3496
3508
  **The gate is `users.write`, not `users.read`.** A pending invite exposes the email of
3497
3509
  someone who is not a member yet, so the backend gates the entire invite surface —
3498
3510
  listing included — on the `users.write` capability plus a signed `users.write:*`
@@ -3776,7 +3788,7 @@ npx appstudio-widget lint path/to/widget.jsx
3776
3788
  npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.js
3777
3789
  ```
3778
3790
 
3779
- Scans for banned patterns (`eval`, `new Function`, dynamic `import()`, direct imports of host stores, raw axios) and for the styling rules below. **Only error-severity findings change the exit code**; warnings print and exit 0.
3791
+ Scans for banned patterns (every `CONTRACT.bannedApis` entry — `eval`, `Function`, dynamic `import()`, `window` and its other names `self` / `top` / `parent` / `frames`, `.constructor`, … — and direct imports of host stores) and for the styling rules below. **Only error-severity findings change the exit code**; warnings print and exit 0.
3780
3792
 
3781
3793
  ```js
3782
3794
  import {
package/dist/contract.cjs CHANGED
@@ -4540,6 +4540,20 @@ const BANNED_APIS = [
4540
4540
  reason:
4541
4541
  "Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
4542
4542
  },
4543
+ ...["self", "top", "parent", "frames", "opener"].map((identifier) => ({
4544
+ identifier,
4545
+ reason:
4546
+ "Another name for window. Refused where it reads a variable, so rename a local one; `insets.top` and `{ top: 0 }` are fine.",
4547
+ })),
4548
+ {
4549
+ identifier: "frameElement",
4550
+ reason: "The element framing the page, a route to the host document.",
4551
+ },
4552
+ {
4553
+ identifier: ".constructor",
4554
+ reason:
4555
+ "Reaches Function(), which evaluates strings. Refused as a property read or quoted name; a class's own constructor() method is fine.",
4556
+ },
4543
4557
  ];
4544
4558
 
4545
4559
  // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
@@ -6542,7 +6556,10 @@ const CONTRACT = deepFreeze({
6542
6556
  // useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
6543
6557
  // 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
6544
6558
  // `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
6545
- version: "1.221.0",
6559
+ // 1.222.0: stricter (sc-14479) — `self`, `top`, `parent`, `frames`,
6560
+ // `opener`, `frameElement` and `.constructor` join bannedApis; `eval` and
6561
+ // `Function` are refused as any reference, not only as a call.
6562
+ version: "1.222.0",
6546
6563
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6547
6564
  hooks: HOOKS,
6548
6565
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -4537,6 +4537,20 @@ const BANNED_APIS = [
4537
4537
  reason:
4538
4538
  "Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
4539
4539
  },
4540
+ ...["self", "top", "parent", "frames", "opener"].map((identifier) => ({
4541
+ identifier,
4542
+ reason:
4543
+ "Another name for window. Refused where it reads a variable, so rename a local one; `insets.top` and `{ top: 0 }` are fine.",
4544
+ })),
4545
+ {
4546
+ identifier: "frameElement",
4547
+ reason: "The element framing the page, a route to the host document.",
4548
+ },
4549
+ {
4550
+ identifier: ".constructor",
4551
+ reason:
4552
+ "Reaches Function(), which evaluates strings. Refused as a property read or quoted name; a class's own constructor() method is fine.",
4553
+ },
4540
4554
  ];
4541
4555
 
4542
4556
  // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
@@ -6539,7 +6553,10 @@ const CONTRACT = deepFreeze({
6539
6553
  // useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
6540
6554
  // 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
6541
6555
  // `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
6542
- version: "1.221.0",
6556
+ // 1.222.0: stricter (sc-14479) — `self`, `top`, `parent`, `frames`,
6557
+ // `opener`, `frameElement` and `.constructor` join bannedApis; `eval` and
6558
+ // `Function` are refused as any reference, not only as a call.
6559
+ version: "1.222.0",
6543
6560
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6544
6561
  hooks: HOOKS,
6545
6562
  primitives: PRIMITIVES,
package/dist/linter.cjs CHANGED
@@ -13,42 +13,151 @@
13
13
  const { CONTRACT, normaliseActionTriggerTypes } = require("./contract.cjs");
14
14
  const { LUCIDE_ICON_NAMES, LUCIDE_VERSION } = require("./lucideIconNames.cjs");
15
15
 
16
+ // Per-identifier match rule, run over the whole masked source. Most banned
17
+ // identifiers compile to a whole-word match.
16
18
  function _ruleForIdentifier(identifier, reason) {
17
19
  const id = identifier;
18
20
  if (id === "Function") {
21
+ // Any reference, so an alias (`const F = Function`) is refused too.
19
22
  return {
20
23
  id: "no-function-constructor",
21
24
  label: `${reason} (banned: Function() constructor)`,
22
- pattern: /(^|[^A-Za-z0-9_$.])Function\s*\(/,
25
+ pattern: /(?<![\w$])Function(?![\w$])/g,
26
+ accept: (code, index) =>
27
+ !_isMemberName(code, index) && _wordBefore(code, index) !== "instanceof",
23
28
  };
24
29
  }
25
30
  if (id === "new Function") {
26
31
  return {
27
32
  id: "no-new-function",
28
33
  label: `${reason} (banned: new Function())`,
29
- pattern: /\bnew\s+Function\s*\(/,
34
+ pattern: /\bnew\s+Function\s*\(/g,
30
35
  };
31
36
  }
32
37
  if (id === "import(") {
33
38
  return {
34
39
  id: "no-dynamic-import",
35
40
  label: `${reason} (banned: dynamic import())`,
36
- pattern: /(^|[^A-Za-z0-9_$.])import\s*\(/,
41
+ pattern: /(?<![\w$.])import\s*\(/g,
37
42
  };
38
43
  }
39
44
  if (id === "eval") {
45
+ // Any reference, so indirect `(0, eval)(…)` is refused too.
40
46
  return {
41
47
  id: "no-eval",
42
48
  label: `${reason} (banned: eval)`,
43
- pattern: /\beval\s*\(/,
49
+ pattern: /(?<![\w$])eval(?![\w$])/g,
50
+ };
51
+ }
52
+ if (id === ".constructor") {
53
+ return {
54
+ id: "no-constructor-access",
55
+ label: `${reason} (banned: .constructor)`,
56
+ // Also `{ constructor: F } = fn`; a class's `constructor(` passes.
57
+ pattern: /\.\s*constructor(?![\w$])|(?<![\w$.])constructor(?![\w$])(?!\s*\()/g,
58
+ stringPattern: /(["'`])constructor\1/g,
44
59
  };
45
60
  }
46
61
  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
- };
62
+ const rule = { id: `no-${id.toLowerCase()}`, label: `${reason} (banned: ${id})` };
63
+ if (_GLOBAL_OBJECT_ALIASES.has(id)) {
64
+ // Also a common property name, so only a read of the variable is refused.
65
+ return {
66
+ ...rule,
67
+ pattern: new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`, "g"),
68
+ accept: (code, index) => _readsVariable(code, index, id.length),
69
+ };
70
+ }
71
+ return { ...rule, pattern: new RegExp(`\\b${escaped}\\b`, "g") };
72
+ }
73
+
74
+ // Other names for the page's global object that are also everyday property
75
+ // names (`insets.top`, `node.parent`).
76
+ const _GLOBAL_OBJECT_ALIASES = new Set(["self", "top", "parent", "frames", "opener"]);
77
+
78
+ // Read-leading keywords `_EXPRESSION_LEAD_WORDS` leaves out.
79
+ const _READ_LEAD_WORDS = new Set(["new", "extends", "default"]);
80
+
81
+ // Index of the last non-space character before `index`, and whether a
82
+ // newline lies between.
83
+ function _tokenBefore(code, index) {
84
+ let at = index - 1;
85
+ let newline = false;
86
+ for (; at >= 0 && /\s/.test(code[at]); at -= 1) {
87
+ if (code[at] === "\n") newline = true;
88
+ }
89
+ return { at, newline };
90
+ }
91
+
92
+ function _wordBefore(code, index) {
93
+ const { at } = _tokenBefore(code, index);
94
+ let start = at + 1;
95
+ while (start > 0 && /[\w$]/.test(code[start - 1])) start -= 1;
96
+ return code.slice(start, at + 1);
97
+ }
98
+
99
+ // `x.name` / `x?.name`, but not a spread (`...name`) and not the decimal point
100
+ // of an integer (`1.` + newline starts a new statement).
101
+ function _isMemberName(code, index) {
102
+ const { at } = _tokenBefore(code, index);
103
+ if (code[at] !== ".") return false;
104
+ if (code[at - 1] === ".") return false;
105
+ return !/(^|[^\w$.])\d[\d_]*$/.test(code.slice(Math.max(0, at - 64), at));
106
+ }
107
+
108
+ // Whether the name at `index` reads its variable. A member name, an object
109
+ // key, a write, and a word after a declaration keyword or JSX text on the
110
+ // same line do not.
111
+ function _readsVariable(code, index, length) {
112
+ if (_isMemberName(code, index)) return false;
113
+ const { at: before, newline } = _tokenBefore(code, index);
114
+ let after = index + length;
115
+ while (after < code.length && /\s/.test(code[after])) after += 1;
116
+ const prev = code[before] || "";
117
+ if (code[after] === ":" && (prev === "{" || prev === ",")) return false;
118
+ if (code[after] === "=" && !/[=>]/.test(code[after + 1] || "")) return false;
119
+ if (newline || !/[\w$]/.test(prev)) return true;
120
+ const word = _wordBefore(code, index);
121
+ return _EXPRESSION_LEAD_WORDS.has(word) || _READ_LEAD_WORDS.has(word);
122
+ }
123
+
124
+ // A `\u` escape spelling an ASCII identifier character (`\u0077indow`).
125
+ const _IDENTIFIER_ESCAPE_RE = /\\u(?:\{0*([0-9a-fA-F]{1,6})\}|([0-9a-fA-F]{4}))/g;
126
+
127
+ function _escapesAsciiIdentifier(code, index) {
128
+ // A fresh regex: matchAll clones the shared one with its lastIndex.
129
+ const at = new RegExp(_IDENTIFIER_ESCAPE_RE.source, "y");
130
+ at.lastIndex = index;
131
+ const m = at.exec(code);
132
+ const ch = String.fromCodePoint(parseInt(m[1] || m[2], 16));
133
+ return /^[\w$]$/.test(ch);
134
+ }
135
+
136
+ // One finding per rule per line, in line order, then rule order. Masking keeps
137
+ // every index, so a match's line is the source's line.
138
+ function _bannedApiFindings(source, lines) {
139
+ const masks = [_stripNonCode(source), _stripNonCode(source, { keepStrings: true })];
140
+ const hits = [];
141
+ RULES.forEach((rule, order) => {
142
+ const ruleLines = new Set();
143
+ [rule.pattern, rule.stringPattern].forEach((pattern, maskIndex) => {
144
+ if (!pattern) return;
145
+ const code = masks[maskIndex];
146
+ const lineAt = _lineCounter(code);
147
+ for (const match of code.matchAll(pattern)) {
148
+ if (!rule.accept || rule.accept(code, match.index)) ruleLines.add(lineAt(match.index));
149
+ }
150
+ });
151
+ for (const line of ruleLines) hits.push({ rule, order, line });
152
+ });
153
+ hits.sort((a, b) => a.line - b.line || a.order - b.order);
154
+ return hits.map(({ rule, line }) => ({
155
+ rule: rule.id,
156
+ severity: "error",
157
+ label: rule.label,
158
+ line,
159
+ snippet: lines[line - 1].trim().slice(0, 200),
160
+ }));
52
161
  }
53
162
 
54
163
  const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
@@ -209,7 +318,14 @@ const EXTRA_RULES = [
209
318
  {
210
319
  id: "no-auth-store-import",
211
320
  label: "widgets must not import the host's auth store",
212
- pattern: /useAuthStore/,
321
+ pattern: /useAuthStore/g,
322
+ },
323
+ {
324
+ id: "no-identifier-escape",
325
+ label:
326
+ "a \\u escape spelling a letter, digit, _ or $ hides a name from the banned-API scan; write the name plainly",
327
+ pattern: _IDENTIFIER_ESCAPE_RE,
328
+ accept: _escapesAsciiIdentifier,
213
329
  },
214
330
  ];
215
331
 
@@ -2330,26 +2446,9 @@ function lintSource(source, options) {
2330
2446
  }
2331
2447
  const findings = [];
2332
2448
  const lines = source.split(/\r?\n/);
2333
- // Scan code with comments + string/template text blanked out so a banned
2334
- // identifier only fires on an actual code reference, not on the same word
2335
- // appearing in prose or string data. `codeLines` lines up 1:1 with `lines`
2336
- // (masking preserves newlines), so the reported snippet still comes from
2337
- // the original source.
2338
- const codeLines = _stripNonCode(source).split(/\r?\n/);
2339
- for (let i = 0; i < lines.length; i++) {
2340
- const codeLine = codeLines[i];
2341
- for (const rule of RULES) {
2342
- if (rule.pattern.test(codeLine)) {
2343
- findings.push({
2344
- rule: rule.id,
2345
- severity: "error",
2346
- label: rule.label,
2347
- line: i + 1,
2348
- snippet: lines[i].trim().slice(0, 200),
2349
- });
2350
- }
2351
- }
2352
- }
2449
+ // Comments and string text are masked, so a banned identifier fires only on
2450
+ // code, never on the same word in prose or string data.
2451
+ findings.push(..._bannedApiFindings(source, lines));
2353
2452
  findings.push(
2354
2453
  ..._importRules(source, options && options.manifest).map((f) => ({
2355
2454
  ...f,
package/dist/linter.js CHANGED
@@ -9,47 +9,151 @@
9
9
  import { CONTRACT, normaliseActionTriggerTypes } from "./contract.js";
10
10
  import { LUCIDE_ICON_NAMES, LUCIDE_VERSION } from "./lucideIconNames.js";
11
11
 
12
- // Per-identifier match rule. Most banned identifiers compile to a
13
- // whole-word match; a few have special syntax (`Function(`, `new Function`,
14
- // `import(`) that needs a tighter regex.
12
+ // Per-identifier match rule, run over the whole masked source. Most banned
13
+ // identifiers compile to a whole-word match.
15
14
  function _ruleForIdentifier(identifier, reason) {
16
15
  const id = identifier;
17
16
  if (id === "Function") {
17
+ // Any reference, so an alias (`const F = Function`) is refused too.
18
18
  return {
19
19
  id: "no-function-constructor",
20
20
  label: `${reason} (banned: Function() constructor)`,
21
- pattern: /(^|[^A-Za-z0-9_$.])Function\s*\(/,
21
+ pattern: /(?<![\w$])Function(?![\w$])/g,
22
+ accept: (code, index) =>
23
+ !_isMemberName(code, index) && _wordBefore(code, index) !== "instanceof",
22
24
  };
23
25
  }
24
26
  if (id === "new Function") {
25
27
  return {
26
28
  id: "no-new-function",
27
29
  label: `${reason} (banned: new Function())`,
28
- pattern: /\bnew\s+Function\s*\(/,
30
+ pattern: /\bnew\s+Function\s*\(/g,
29
31
  };
30
32
  }
31
33
  if (id === "import(") {
32
34
  return {
33
35
  id: "no-dynamic-import",
34
36
  label: `${reason} (banned: dynamic import())`,
35
- pattern: /(^|[^A-Za-z0-9_$.])import\s*\(/,
37
+ pattern: /(?<![\w$.])import\s*\(/g,
36
38
  };
37
39
  }
38
40
  if (id === "eval") {
41
+ // Any reference, so indirect `(0, eval)(…)` is refused too.
39
42
  return {
40
43
  id: "no-eval",
41
44
  label: `${reason} (banned: eval)`,
42
- pattern: /\beval\s*\(/,
45
+ pattern: /(?<![\w$])eval(?![\w$])/g,
46
+ };
47
+ }
48
+ if (id === ".constructor") {
49
+ return {
50
+ id: "no-constructor-access",
51
+ label: `${reason} (banned: .constructor)`,
52
+ // Also `{ constructor: F } = fn`; a class's `constructor(` passes.
53
+ pattern: /\.\s*constructor(?![\w$])|(?<![\w$.])constructor(?![\w$])(?!\s*\()/g,
54
+ stringPattern: /(["'`])constructor\1/g,
43
55
  };
44
56
  }
45
- // Default: word-boundary anchored. Used for the simple identifier
46
- // forms (`window`, `document`, `process`, ...).
47
57
  const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
- return {
49
- id: `no-${id.toLowerCase()}`,
50
- label: `${reason} (banned: ${id})`,
51
- pattern: new RegExp(`\\b${escaped}\\b`),
52
- };
58
+ const rule = { id: `no-${id.toLowerCase()}`, label: `${reason} (banned: ${id})` };
59
+ if (_GLOBAL_OBJECT_ALIASES.has(id)) {
60
+ // Also a common property name, so only a read of the variable is refused.
61
+ return {
62
+ ...rule,
63
+ pattern: new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`, "g"),
64
+ accept: (code, index) => _readsVariable(code, index, id.length),
65
+ };
66
+ }
67
+ return { ...rule, pattern: new RegExp(`\\b${escaped}\\b`, "g") };
68
+ }
69
+
70
+ // Other names for the page's global object that are also everyday property
71
+ // names (`insets.top`, `node.parent`).
72
+ const _GLOBAL_OBJECT_ALIASES = new Set(["self", "top", "parent", "frames", "opener"]);
73
+
74
+ // Read-leading keywords `_EXPRESSION_LEAD_WORDS` leaves out.
75
+ const _READ_LEAD_WORDS = new Set(["new", "extends", "default"]);
76
+
77
+ // Index of the last non-space character before `index`, and whether a
78
+ // newline lies between.
79
+ function _tokenBefore(code, index) {
80
+ let at = index - 1;
81
+ let newline = false;
82
+ for (; at >= 0 && /\s/.test(code[at]); at -= 1) {
83
+ if (code[at] === "\n") newline = true;
84
+ }
85
+ return { at, newline };
86
+ }
87
+
88
+ function _wordBefore(code, index) {
89
+ const { at } = _tokenBefore(code, index);
90
+ let start = at + 1;
91
+ while (start > 0 && /[\w$]/.test(code[start - 1])) start -= 1;
92
+ return code.slice(start, at + 1);
93
+ }
94
+
95
+ // `x.name` / `x?.name`, but not a spread (`...name`) and not the decimal point
96
+ // of an integer (`1.` + newline starts a new statement).
97
+ function _isMemberName(code, index) {
98
+ const { at } = _tokenBefore(code, index);
99
+ if (code[at] !== ".") return false;
100
+ if (code[at - 1] === ".") return false;
101
+ return !/(^|[^\w$.])\d[\d_]*$/.test(code.slice(Math.max(0, at - 64), at));
102
+ }
103
+
104
+ // Whether the name at `index` reads its variable. A member name, an object
105
+ // key, a write, and a word after a declaration keyword or JSX text on the
106
+ // same line do not.
107
+ function _readsVariable(code, index, length) {
108
+ if (_isMemberName(code, index)) return false;
109
+ const { at: before, newline } = _tokenBefore(code, index);
110
+ let after = index + length;
111
+ while (after < code.length && /\s/.test(code[after])) after += 1;
112
+ const prev = code[before] || "";
113
+ if (code[after] === ":" && (prev === "{" || prev === ",")) return false;
114
+ if (code[after] === "=" && !/[=>]/.test(code[after + 1] || "")) return false;
115
+ if (newline || !/[\w$]/.test(prev)) return true;
116
+ const word = _wordBefore(code, index);
117
+ return _EXPRESSION_LEAD_WORDS.has(word) || _READ_LEAD_WORDS.has(word);
118
+ }
119
+
120
+ // A `\u` escape spelling an ASCII identifier character (`\u0077indow`).
121
+ const _IDENTIFIER_ESCAPE_RE = /\\u(?:\{0*([0-9a-fA-F]{1,6})\}|([0-9a-fA-F]{4}))/g;
122
+
123
+ function _escapesAsciiIdentifier(code, index) {
124
+ // A fresh regex: matchAll clones the shared one with its lastIndex.
125
+ const at = new RegExp(_IDENTIFIER_ESCAPE_RE.source, "y");
126
+ at.lastIndex = index;
127
+ const m = at.exec(code);
128
+ const ch = String.fromCodePoint(parseInt(m[1] || m[2], 16));
129
+ return /^[\w$]$/.test(ch);
130
+ }
131
+
132
+ // One finding per rule per line, in line order, then rule order. Masking keeps
133
+ // every index, so a match's line is the source's line.
134
+ function _bannedApiFindings(source, lines) {
135
+ const masks = [_stripNonCode(source), _stripNonCode(source, { keepStrings: true })];
136
+ const hits = [];
137
+ RULES.forEach((rule, order) => {
138
+ const ruleLines = new Set();
139
+ [rule.pattern, rule.stringPattern].forEach((pattern, maskIndex) => {
140
+ if (!pattern) return;
141
+ const code = masks[maskIndex];
142
+ const lineAt = _lineCounter(code);
143
+ for (const match of code.matchAll(pattern)) {
144
+ if (!rule.accept || rule.accept(code, match.index)) ruleLines.add(lineAt(match.index));
145
+ }
146
+ });
147
+ for (const line of ruleLines) hits.push({ rule, order, line });
148
+ });
149
+ hits.sort((a, b) => a.line - b.line || a.order - b.order);
150
+ return hits.map(({ rule, line }) => ({
151
+ rule: rule.id,
152
+ severity: "error",
153
+ label: rule.label,
154
+ line,
155
+ snippet: lines[line - 1].trim().slice(0, 200),
156
+ }));
53
157
  }
54
158
 
55
159
  const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
@@ -73,7 +177,14 @@ const EXTRA_RULES = [
73
177
  {
74
178
  id: "no-auth-store-import",
75
179
  label: "widgets must not import the host's auth store",
76
- pattern: /useAuthStore/,
180
+ pattern: /useAuthStore/g,
181
+ },
182
+ {
183
+ id: "no-identifier-escape",
184
+ label:
185
+ "a \\u escape spelling a letter, digit, _ or $ hides a name from the banned-API scan; write the name plainly",
186
+ pattern: _IDENTIFIER_ESCAPE_RE,
187
+ accept: _escapesAsciiIdentifier,
77
188
  },
78
189
  ];
79
190
 
@@ -2393,26 +2504,9 @@ export function lintSource(source, options) {
2393
2504
  }
2394
2505
  const findings = [];
2395
2506
  const lines = source.split(/\r?\n/);
2396
- // Scan code with comments + string/template text blanked out so a banned
2397
- // identifier only fires on an actual code reference, not on the same word
2398
- // appearing in prose or string data. `codeLines` lines up 1:1 with `lines`
2399
- // (masking preserves newlines), so the reported snippet still comes from
2400
- // the original source.
2401
- const codeLines = _stripNonCode(source).split(/\r?\n/);
2402
- for (let i = 0; i < lines.length; i++) {
2403
- const codeLine = codeLines[i];
2404
- for (const rule of RULES) {
2405
- if (rule.pattern.test(codeLine)) {
2406
- findings.push({
2407
- rule: rule.id,
2408
- severity: "error",
2409
- label: rule.label,
2410
- line: i + 1,
2411
- snippet: lines[i].trim().slice(0, 200),
2412
- });
2413
- }
2414
- }
2415
- }
2507
+ // Comments and string text are masked, so a banned identifier fires only on
2508
+ // code, never on the same word in prose or string data.
2509
+ findings.push(..._bannedApiFindings(source, lines));
2416
2510
  // REQ-WSDK-PLATFORM §3.4: import-specifier validation.
2417
2511
  findings.push(
2418
2512
  ..._importRules(source, options && options.manifest).map((f) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.271.0",
3
+ "version": "0.272.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -4537,6 +4537,20 @@ const BANNED_APIS = [
4537
4537
  reason:
4538
4538
  "Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
4539
4539
  },
4540
+ ...["self", "top", "parent", "frames", "opener"].map((identifier) => ({
4541
+ identifier,
4542
+ reason:
4543
+ "Another name for window. Refused where it reads a variable, so rename a local one; `insets.top` and `{ top: 0 }` are fine.",
4544
+ })),
4545
+ {
4546
+ identifier: "frameElement",
4547
+ reason: "The element framing the page, a route to the host document.",
4548
+ },
4549
+ {
4550
+ identifier: ".constructor",
4551
+ reason:
4552
+ "Reaches Function(), which evaluates strings. Refused as a property read or quoted name; a class's own constructor() method is fine.",
4553
+ },
4540
4554
  ];
4541
4555
 
4542
4556
  // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
@@ -6539,7 +6553,10 @@ const CONTRACT = deepFreeze({
6539
6553
  // useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
6540
6554
  // 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
6541
6555
  // `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
6542
- version: "1.221.0",
6556
+ // 1.222.0: stricter (sc-14479) — `self`, `top`, `parent`, `frames`,
6557
+ // `opener`, `frameElement` and `.constructor` join bannedApis; `eval` and
6558
+ // `Function` are refused as any reference, not only as a call.
6559
+ version: "1.222.0",
6543
6560
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6544
6561
  hooks: HOOKS,
6545
6562
  primitives: PRIMITIVES,