@colixsystems/widget-sdk 0.271.0 → 0.273.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 +18 -2
- package/dist/contract.cjs +28 -1
- package/dist/contract.js +28 -1
- package/dist/linter.cjs +160 -30
- package/dist/linter.js +160 -35
- package/package.json +1 -1
- package/src/contract.js +28 -1
package/README.md
CHANGED
|
@@ -86,7 +86,19 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
86
86
|
|
|
87
87
|
## Status
|
|
88
88
|
|
|
89
|
-
`v0.
|
|
89
|
+
`v0.273.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.273.0 (contract 1.223.0)
|
|
92
|
+
|
|
93
|
+
**A widget never mints or sets its own permission header.** The host attaches each widget's `X-Widget-Scopes` header itself, from what that widget was granted. Source that calls `/widgets/scope-token` or sets `X-Widget-Scopes` (any casing) is now a blocking `no-scope-header-mint` finding, listed in the new `CONTRACT.scopeMintPatterns`. Comments are not scanned. Declare the scopes your hooks need in `manifest.requestedScopes`, as before; a widget that does that is unaffected.
|
|
94
|
+
|
|
95
|
+
### What's new in 0.272.0 (contract 1.222.0)
|
|
96
|
+
|
|
97
|
+
**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.
|
|
98
|
+
|
|
99
|
+
- **`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()`.
|
|
100
|
+
- **`.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.
|
|
101
|
+
- **`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
102
|
|
|
91
103
|
### What's new in 0.271.0 (contract 1.221.0)
|
|
92
104
|
|
|
@@ -3493,6 +3505,10 @@ joined — it then reads back as `{ id, name, invited: true }`, with no `email`
|
|
|
3493
3505
|
behind it. `status: 'open'` is every invite that is neither accepted nor revoked
|
|
3494
3506
|
(`pending`, `not_sent` or `expired`); a re-invite keeps the id, so without it earlier invites match too.
|
|
3495
3507
|
|
|
3508
|
+
Only a signed-in member viewer sees a `USER` cell's `email`. A visitor who is not
|
|
3509
|
+
signed in, and an anonymous account, read a member as `{ id, name }`, so render
|
|
3510
|
+
`name` and treat `email` as optional.
|
|
3511
|
+
|
|
3496
3512
|
**The gate is `users.write`, not `users.read`.** A pending invite exposes the email of
|
|
3497
3513
|
someone who is not a member yet, so the backend gates the entire invite surface —
|
|
3498
3514
|
listing included — on the `users.write` capability plus a signed `users.write:*`
|
|
@@ -3776,7 +3792,7 @@ npx appstudio-widget lint path/to/widget.jsx
|
|
|
3776
3792
|
npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.js
|
|
3777
3793
|
```
|
|
3778
3794
|
|
|
3779
|
-
Scans for banned patterns (`eval`, `
|
|
3795
|
+
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
3796
|
|
|
3781
3797
|
```js
|
|
3782
3798
|
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
|
|
@@ -4942,6 +4956,12 @@ const TRANSLATION_API_HOSTS = [
|
|
|
4942
4956
|
"lingvanex.com",
|
|
4943
4957
|
];
|
|
4944
4958
|
|
|
4959
|
+
// sc-12906: the host mints a widget's `X-Widget-Scopes` header, never the
|
|
4960
|
+
// widget. Bundles share the host's JS realm, so a widget calling the mint
|
|
4961
|
+
// itself, or forging the header, could borrow another widget's scopes. Matched
|
|
4962
|
+
// case-insensitively against code (comments are blanked before matching).
|
|
4963
|
+
const SCOPE_MINT_PATTERNS = ["/widgets/scope-token", "x-widget-scopes"];
|
|
4964
|
+
|
|
4945
4965
|
// sc-4686 — the currency a price renders in when the HOST does not supply the
|
|
4946
4966
|
// workspace's own (a host predating the workspace.currency slice, or a caller
|
|
4947
4967
|
// that forgot to wire it). A rendering fallback so a price is never blank or
|
|
@@ -6542,7 +6562,13 @@ const CONTRACT = deepFreeze({
|
|
|
6542
6562
|
// useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
|
|
6543
6563
|
// 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
|
|
6544
6564
|
// `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
|
|
6545
|
-
|
|
6565
|
+
// 1.222.0: stricter (sc-14479) — `self`, `top`, `parent`, `frames`,
|
|
6566
|
+
// `opener`, `frameElement` and `.constructor` join bannedApis; `eval` and
|
|
6567
|
+
// `Function` are refused as any reference, not only as a call.
|
|
6568
|
+
// 1.223.0: stricter (sc-12906) — `scopeMintPatterns`; a widget that calls
|
|
6569
|
+
// the scope-header mint or sets `X-Widget-Scopes` is refused
|
|
6570
|
+
// (`no-scope-header-mint`).
|
|
6571
|
+
version: "1.223.0",
|
|
6546
6572
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6547
6573
|
hooks: HOOKS,
|
|
6548
6574
|
primitives: PRIMITIVES,
|
|
@@ -6587,6 +6613,7 @@ const CONTRACT = deepFreeze({
|
|
|
6587
6613
|
currencyFormats: CURRENCY_FORMATS,
|
|
6588
6614
|
currencyFormatFallback: CURRENCY_FORMAT_FALLBACK,
|
|
6589
6615
|
translationApiHosts: TRANSLATION_API_HOSTS,
|
|
6616
|
+
scopeMintPatterns: SCOPE_MINT_PATTERNS,
|
|
6590
6617
|
});
|
|
6591
6618
|
|
|
6592
6619
|
function isHookAllowed(name) {
|
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
|
|
@@ -4939,6 +4953,12 @@ const TRANSLATION_API_HOSTS = [
|
|
|
4939
4953
|
"lingvanex.com",
|
|
4940
4954
|
];
|
|
4941
4955
|
|
|
4956
|
+
// sc-12906: the host mints a widget's `X-Widget-Scopes` header, never the
|
|
4957
|
+
// widget. Bundles share the host's JS realm, so a widget calling the mint
|
|
4958
|
+
// itself, or forging the header, could borrow another widget's scopes. Matched
|
|
4959
|
+
// case-insensitively against code (comments are blanked before matching).
|
|
4960
|
+
const SCOPE_MINT_PATTERNS = ["/widgets/scope-token", "x-widget-scopes"];
|
|
4961
|
+
|
|
4942
4962
|
// sc-4686 — the currency a price renders in when the HOST does not supply the
|
|
4943
4963
|
// workspace's own (a host predating the workspace.currency slice, or a caller
|
|
4944
4964
|
// that forgot to wire it). A rendering fallback so a price is never blank or
|
|
@@ -6539,7 +6559,13 @@ const CONTRACT = deepFreeze({
|
|
|
6539
6559
|
// useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
|
|
6540
6560
|
// 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
|
|
6541
6561
|
// `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
|
|
6542
|
-
|
|
6562
|
+
// 1.222.0: stricter (sc-14479) — `self`, `top`, `parent`, `frames`,
|
|
6563
|
+
// `opener`, `frameElement` and `.constructor` join bannedApis; `eval` and
|
|
6564
|
+
// `Function` are refused as any reference, not only as a call.
|
|
6565
|
+
// 1.223.0: stricter (sc-12906) — `scopeMintPatterns`; a widget that calls
|
|
6566
|
+
// the scope-header mint or sets `X-Widget-Scopes` is refused
|
|
6567
|
+
// (`no-scope-header-mint`).
|
|
6568
|
+
version: "1.223.0",
|
|
6543
6569
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6544
6570
|
hooks: HOOKS,
|
|
6545
6571
|
primitives: PRIMITIVES,
|
|
@@ -6584,6 +6610,7 @@ const CONTRACT = deepFreeze({
|
|
|
6584
6610
|
currencyFormats: CURRENCY_FORMATS,
|
|
6585
6611
|
currencyFormatFallback: CURRENCY_FORMAT_FALLBACK,
|
|
6586
6612
|
translationApiHosts: TRANSLATION_API_HOSTS,
|
|
6613
|
+
scopeMintPatterns: SCOPE_MINT_PATTERNS,
|
|
6587
6614
|
});
|
|
6588
6615
|
|
|
6589
6616
|
function isHookAllowed(name) {
|
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
|
|
|
@@ -647,6 +763,36 @@ function _translationApiRules(source) {
|
|
|
647
763
|
return findings;
|
|
648
764
|
}
|
|
649
765
|
|
|
766
|
+
// sc-12906 — no-scope-header-mint.
|
|
767
|
+
//
|
|
768
|
+
// The host mints each widget's `X-Widget-Scopes` header from the widget's own
|
|
769
|
+
// installation. A bundle runs in the host's JS realm, so one that calls the
|
|
770
|
+
// mint itself, or sets the header, could borrow another widget's scopes. The
|
|
771
|
+
// server refuses a built-in nobody placed; this catches the attempt at publish.
|
|
772
|
+
// An error with no opt-out: no correct widget touches either.
|
|
773
|
+
function _scopeMintRules(source) {
|
|
774
|
+
const findings = [];
|
|
775
|
+
const needles = CONTRACT.scopeMintPatterns || [];
|
|
776
|
+
const lines = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
|
|
777
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
778
|
+
const lowered = lines[i].toLowerCase();
|
|
779
|
+
const needle = needles.find((n) => lowered.includes(n));
|
|
780
|
+
if (!needle) continue;
|
|
781
|
+
findings.push({
|
|
782
|
+
rule: "no-scope-header-mint",
|
|
783
|
+
severity: "error",
|
|
784
|
+
label:
|
|
785
|
+
`source references "${needle}" — the host attaches each widget's ` +
|
|
786
|
+
`permission header itself. Never call that endpoint or set that ` +
|
|
787
|
+
`header; declare the scopes your hooks need in ` +
|
|
788
|
+
`manifest.requestedScopes.`,
|
|
789
|
+
line: i + 1,
|
|
790
|
+
snippet: lines[i].trim().slice(0, 200),
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
return findings;
|
|
794
|
+
}
|
|
795
|
+
|
|
650
796
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation. See
|
|
651
797
|
// linter.js for the rationale comment. The two files must stay in
|
|
652
798
|
// lockstep (the contract test asserts behaviour-equivalence).
|
|
@@ -2330,26 +2476,9 @@ function lintSource(source, options) {
|
|
|
2330
2476
|
}
|
|
2331
2477
|
const findings = [];
|
|
2332
2478
|
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
|
-
}
|
|
2479
|
+
// Comments and string text are masked, so a banned identifier fires only on
|
|
2480
|
+
// code, never on the same word in prose or string data.
|
|
2481
|
+
findings.push(..._bannedApiFindings(source, lines));
|
|
2353
2482
|
findings.push(
|
|
2354
2483
|
..._importRules(source, options && options.manifest).map((f) => ({
|
|
2355
2484
|
...f,
|
|
@@ -2361,6 +2490,7 @@ function lintSource(source, options) {
|
|
|
2361
2490
|
// sc-6970 — soft warning: HTML markup spliced into a content string.
|
|
2362
2491
|
findings.push(..._htmlInContentRules(source));
|
|
2363
2492
|
findings.push(..._translationApiRules(source));
|
|
2493
|
+
findings.push(..._scopeMintRules(source));
|
|
2364
2494
|
findings.push(..._handBuiltPageUrlRules(source));
|
|
2365
2495
|
findings.push(..._lucideIconRules(source));
|
|
2366
2496
|
findings.push(..._bundledAssetRules(source, options && options.assetFiles));
|
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
|
|
|
@@ -584,6 +695,36 @@ function _translationApiRules(source) {
|
|
|
584
695
|
return findings;
|
|
585
696
|
}
|
|
586
697
|
|
|
698
|
+
// sc-12906 — no-scope-header-mint.
|
|
699
|
+
//
|
|
700
|
+
// The host mints each widget's `X-Widget-Scopes` header from the widget's own
|
|
701
|
+
// installation. A bundle runs in the host's JS realm, so one that calls the
|
|
702
|
+
// mint itself, or sets the header, could borrow another widget's scopes. The
|
|
703
|
+
// server refuses a built-in nobody placed; this catches the attempt at publish.
|
|
704
|
+
// An error with no opt-out: no correct widget touches either.
|
|
705
|
+
function _scopeMintRules(source) {
|
|
706
|
+
const findings = [];
|
|
707
|
+
const needles = CONTRACT.scopeMintPatterns || [];
|
|
708
|
+
const lines = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
|
|
709
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
710
|
+
const lowered = lines[i].toLowerCase();
|
|
711
|
+
const needle = needles.find((n) => lowered.includes(n));
|
|
712
|
+
if (!needle) continue;
|
|
713
|
+
findings.push({
|
|
714
|
+
rule: "no-scope-header-mint",
|
|
715
|
+
severity: "error",
|
|
716
|
+
label:
|
|
717
|
+
`source references "${needle}" — the host attaches each widget's ` +
|
|
718
|
+
`permission header itself. Never call that endpoint or set that ` +
|
|
719
|
+
`header; declare the scopes your hooks need in ` +
|
|
720
|
+
`manifest.requestedScopes.`,
|
|
721
|
+
line: i + 1,
|
|
722
|
+
snippet: lines[i].trim().slice(0, 200),
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
return findings;
|
|
726
|
+
}
|
|
727
|
+
|
|
587
728
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation.
|
|
588
729
|
//
|
|
589
730
|
// A widget that calls `useUsers().invite()` / `.deactivate()` /
|
|
@@ -2393,26 +2534,9 @@ export function lintSource(source, options) {
|
|
|
2393
2534
|
}
|
|
2394
2535
|
const findings = [];
|
|
2395
2536
|
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
|
-
}
|
|
2537
|
+
// Comments and string text are masked, so a banned identifier fires only on
|
|
2538
|
+
// code, never on the same word in prose or string data.
|
|
2539
|
+
findings.push(..._bannedApiFindings(source, lines));
|
|
2416
2540
|
// REQ-WSDK-PLATFORM §3.4: import-specifier validation.
|
|
2417
2541
|
findings.push(
|
|
2418
2542
|
..._importRules(source, options && options.manifest).map((f) => ({
|
|
@@ -2426,6 +2550,7 @@ export function lintSource(source, options) {
|
|
|
2426
2550
|
// sc-6970 — soft warning: HTML markup spliced into a content string.
|
|
2427
2551
|
findings.push(..._htmlInContentRules(source));
|
|
2428
2552
|
findings.push(..._translationApiRules(source));
|
|
2553
|
+
findings.push(..._scopeMintRules(source));
|
|
2429
2554
|
findings.push(..._handBuiltPageUrlRules(source));
|
|
2430
2555
|
findings.push(..._lucideIconRules(source));
|
|
2431
2556
|
findings.push(..._bundledAssetRules(source, options && options.assetFiles));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.273.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
|
|
@@ -4939,6 +4953,12 @@ const TRANSLATION_API_HOSTS = [
|
|
|
4939
4953
|
"lingvanex.com",
|
|
4940
4954
|
];
|
|
4941
4955
|
|
|
4956
|
+
// sc-12906: the host mints a widget's `X-Widget-Scopes` header, never the
|
|
4957
|
+
// widget. Bundles share the host's JS realm, so a widget calling the mint
|
|
4958
|
+
// itself, or forging the header, could borrow another widget's scopes. Matched
|
|
4959
|
+
// case-insensitively against code (comments are blanked before matching).
|
|
4960
|
+
const SCOPE_MINT_PATTERNS = ["/widgets/scope-token", "x-widget-scopes"];
|
|
4961
|
+
|
|
4942
4962
|
// sc-4686 — the currency a price renders in when the HOST does not supply the
|
|
4943
4963
|
// workspace's own (a host predating the workspace.currency slice, or a caller
|
|
4944
4964
|
// that forgot to wire it). A rendering fallback so a price is never blank or
|
|
@@ -6539,7 +6559,13 @@ const CONTRACT = deepFreeze({
|
|
|
6539
6559
|
// useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
|
|
6540
6560
|
// 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
|
|
6541
6561
|
// `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
|
|
6542
|
-
|
|
6562
|
+
// 1.222.0: stricter (sc-14479) — `self`, `top`, `parent`, `frames`,
|
|
6563
|
+
// `opener`, `frameElement` and `.constructor` join bannedApis; `eval` and
|
|
6564
|
+
// `Function` are refused as any reference, not only as a call.
|
|
6565
|
+
// 1.223.0: stricter (sc-12906) — `scopeMintPatterns`; a widget that calls
|
|
6566
|
+
// the scope-header mint or sets `X-Widget-Scopes` is refused
|
|
6567
|
+
// (`no-scope-header-mint`).
|
|
6568
|
+
version: "1.223.0",
|
|
6543
6569
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6544
6570
|
hooks: HOOKS,
|
|
6545
6571
|
primitives: PRIMITIVES,
|
|
@@ -6584,6 +6610,7 @@ const CONTRACT = deepFreeze({
|
|
|
6584
6610
|
currencyFormats: CURRENCY_FORMATS,
|
|
6585
6611
|
currencyFormatFallback: CURRENCY_FORMAT_FALLBACK,
|
|
6586
6612
|
translationApiHosts: TRANSLATION_API_HOSTS,
|
|
6613
|
+
scopeMintPatterns: SCOPE_MINT_PATTERNS,
|
|
6587
6614
|
});
|
|
6588
6615
|
|
|
6589
6616
|
function isHookAllowed(name) {
|