@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 +14 -2
- package/dist/contract.cjs +18 -1
- package/dist/contract.js +18 -1
- package/dist/linter.cjs +129 -30
- package/dist/linter.js +129 -35
- package/package.json +1 -1
- package/src/contract.js +18 -1
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.
|
|
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`, `
|
|
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
|
-
|
|
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
|
-
|
|
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: /(
|
|
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: /(
|
|
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:
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
2334
|
-
//
|
|
2335
|
-
|
|
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
|
|
13
|
-
// whole-word match
|
|
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: /(
|
|
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: /(
|
|
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:
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
2397
|
-
//
|
|
2398
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|