@colixsystems/widget-sdk 0.13.0 → 0.15.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 +78 -3
- package/dist/clipboard.js +88 -0
- package/dist/clipboard.native.js +64 -0
- package/dist/contract.cjs +318 -11
- package/dist/contract.js +280 -9
- package/dist/datetimepicker.js +102 -0
- package/dist/hooks.js +233 -1
- package/dist/icon.js +29 -0
- package/dist/index.d.ts +126 -0
- package/dist/index.js +10 -0
- package/dist/index.native.js +8 -0
- package/dist/linter.cjs +243 -9
- package/dist/linter.js +309 -10
- package/dist/primitives.js +8 -0
- package/dist/primitives.native.js +9 -0
- package/dist/property-schema.js +7 -0
- package/dist/toast.js +73 -0
- package/dist/toast.native.js +46 -0
- package/package.json +2 -2
package/dist/linter.cjs
CHANGED
|
@@ -54,18 +54,15 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
|
|
|
54
54
|
_ruleForIdentifier(b.identifier, b.reason),
|
|
55
55
|
);
|
|
56
56
|
|
|
57
|
+
// REQ-WSDK-PLATFORM: `no-axios-import` is GONE. axios is on the vetted
|
|
58
|
+
// import list now (`CONTRACT.vettedImports`). See linter.js for the
|
|
59
|
+
// source-of-truth comment.
|
|
57
60
|
const EXTRA_RULES = [
|
|
58
61
|
{
|
|
59
62
|
id: "no-auth-store-import",
|
|
60
63
|
label: "widgets must not import the host's auth store",
|
|
61
64
|
pattern: /useAuthStore/,
|
|
62
65
|
},
|
|
63
|
-
{
|
|
64
|
-
id: "no-axios-import",
|
|
65
|
-
label:
|
|
66
|
-
"widgets must not import axios directly; use the injected datastore client",
|
|
67
|
-
pattern: /from\s+['"]axios['"]|require\s*\(\s*['"]axios['"]\s*\)/,
|
|
68
|
-
},
|
|
69
66
|
];
|
|
70
67
|
|
|
71
68
|
const RULES = [...CONTRACT_RULES, ...EXTRA_RULES];
|
|
@@ -74,12 +71,234 @@ function bannedIdentifiers() {
|
|
|
74
71
|
return CONTRACT.bannedApis.map((b) => b.identifier);
|
|
75
72
|
}
|
|
76
73
|
|
|
77
|
-
|
|
74
|
+
// REQ-WSDK-PLATFORM §3.4, §8: import-specifier validation. See linter.js
|
|
75
|
+
// for the source-of-truth comment.
|
|
76
|
+
const IMPORT_SPECIFIER_RE =
|
|
77
|
+
/(?:^|[\s;])(?:import(?:\s+[^"';]+from)?|require)\s*\(?\s*['"]([^'"]+)['"]/g;
|
|
78
|
+
|
|
79
|
+
function _classifySpecifier(spec) {
|
|
80
|
+
if (
|
|
81
|
+
spec.startsWith("./") ||
|
|
82
|
+
(spec.startsWith(".") && !spec.startsWith(".."))
|
|
83
|
+
) {
|
|
84
|
+
return "relative-ok";
|
|
85
|
+
}
|
|
86
|
+
if (spec.startsWith("../")) return "relative-escape";
|
|
87
|
+
if (
|
|
88
|
+
spec.startsWith("/") ||
|
|
89
|
+
spec.startsWith("http:") ||
|
|
90
|
+
spec.startsWith("https:") ||
|
|
91
|
+
spec.startsWith("blob:") ||
|
|
92
|
+
spec.startsWith("data:")
|
|
93
|
+
) {
|
|
94
|
+
return "absolute";
|
|
95
|
+
}
|
|
96
|
+
return "bare";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function _importRules(source, manifest) {
|
|
100
|
+
const findings = [];
|
|
101
|
+
const allowed = new Map(
|
|
102
|
+
CONTRACT.vettedImports.map((v) => [v.specifier, v]),
|
|
103
|
+
);
|
|
104
|
+
const declaredPlatforms =
|
|
105
|
+
manifest &&
|
|
106
|
+
Array.isArray(manifest.supportedPlatforms) &&
|
|
107
|
+
manifest.supportedPlatforms.length > 0
|
|
108
|
+
? new Set(manifest.supportedPlatforms)
|
|
109
|
+
: null;
|
|
110
|
+
|
|
111
|
+
const lines = source.split(/\r?\n/);
|
|
112
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
113
|
+
const line = lines[i];
|
|
114
|
+
IMPORT_SPECIFIER_RE.lastIndex = 0;
|
|
115
|
+
let m;
|
|
116
|
+
while ((m = IMPORT_SPECIFIER_RE.exec(line))) {
|
|
117
|
+
const spec = m[1];
|
|
118
|
+
const kind = _classifySpecifier(spec);
|
|
119
|
+
if (kind === "relative-ok") continue;
|
|
120
|
+
if (kind === "relative-escape") {
|
|
121
|
+
findings.push({
|
|
122
|
+
rule: "no-parent-relative-import",
|
|
123
|
+
label: `relative import "${spec}" escapes the bundle (\`../\`) — only sibling-file imports (\`./foo.js\`) are allowed`,
|
|
124
|
+
line: i + 1,
|
|
125
|
+
snippet: line.trim().slice(0, 200),
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (kind === "absolute") {
|
|
130
|
+
findings.push({
|
|
131
|
+
rule: "no-absolute-import",
|
|
132
|
+
label: `absolute import "${spec}" is not allowed — use a vetted bare specifier or a sibling-file (\`./foo.js\`) import`,
|
|
133
|
+
line: i + 1,
|
|
134
|
+
snippet: line.trim().slice(0, 200),
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const entry = allowed.get(spec);
|
|
139
|
+
if (!entry) {
|
|
140
|
+
findings.push({
|
|
141
|
+
rule: "import-not-vetted",
|
|
142
|
+
label:
|
|
143
|
+
`import "${spec}" is not on the vetted package list — see ` +
|
|
144
|
+
`CONTRACT.vettedImports for the current set or open a request to add it`,
|
|
145
|
+
line: i + 1,
|
|
146
|
+
snippet: line.trim().slice(0, 200),
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (declaredPlatforms) {
|
|
151
|
+
for (const wanted of declaredPlatforms) {
|
|
152
|
+
if (!entry.platforms.includes(wanted)) {
|
|
153
|
+
findings.push({
|
|
154
|
+
rule: "import-platform-mismatch",
|
|
155
|
+
label:
|
|
156
|
+
`"${spec}" supports [${entry.platforms.join(", ")}] but the ` +
|
|
157
|
+
`manifest claims supportedPlatforms includes "${wanted}". ` +
|
|
158
|
+
`Move this import into widget.${wanted === "web" ? "native" : "web"}.jsx, ` +
|
|
159
|
+
`or drop "${wanted}" from manifest.supportedPlatforms.`,
|
|
160
|
+
line: i + 1,
|
|
161
|
+
snippet: line.trim().slice(0, 200),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return findings;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function _hostApiUrlRules(source) {
|
|
172
|
+
const findings = [];
|
|
173
|
+
const patterns = CONTRACT.hostApiUrlPatterns || [];
|
|
174
|
+
const lines = source.split(/\r?\n/);
|
|
175
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
176
|
+
const line = lines[i];
|
|
177
|
+
for (const needle of patterns) {
|
|
178
|
+
if (line.includes(needle)) {
|
|
179
|
+
findings.push({
|
|
180
|
+
rule: "no-host-api-url",
|
|
181
|
+
severity: "warning",
|
|
182
|
+
label:
|
|
183
|
+
`source contains "${needle}" — calls to the AppStudio host API ` +
|
|
184
|
+
`are blocked at runtime (widgets get no JWT token). Use SDK ` +
|
|
185
|
+
`hooks for workspace data; \`axios\`/\`fetch\` for third-party APIs.`,
|
|
186
|
+
line: i + 1,
|
|
187
|
+
snippet: line.trim().slice(0, 200),
|
|
188
|
+
});
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return findings;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation. See
|
|
197
|
+
// linter.js for the rationale comment. The two files must stay in
|
|
198
|
+
// lockstep (the contract test asserts behaviour-equivalence).
|
|
199
|
+
const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate", "remove"];
|
|
200
|
+
const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
|
|
201
|
+
const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
|
|
202
|
+
|
|
203
|
+
function _scopeRules(source, manifest) {
|
|
204
|
+
const findings = [];
|
|
205
|
+
if (!manifest || typeof manifest !== "object") return findings;
|
|
206
|
+
const declared = new Set(
|
|
207
|
+
Array.isArray(manifest.requestedScopes)
|
|
208
|
+
? manifest.requestedScopes.filter((s) => typeof s === "string")
|
|
209
|
+
: [],
|
|
210
|
+
);
|
|
211
|
+
const usesUsersHook = /\buseUsers\s*\(/.test(source);
|
|
212
|
+
const usesGroupsHook = /\buseGroups\s*\(/.test(source);
|
|
213
|
+
|
|
214
|
+
if (usesUsersHook) {
|
|
215
|
+
const reads = declared.has("users.read:*") || declared.has("users.read");
|
|
216
|
+
if (!reads) {
|
|
217
|
+
findings.push({
|
|
218
|
+
rule: "scope-required-for-useUsers",
|
|
219
|
+
label:
|
|
220
|
+
"useUsers() requires `users.read:*` in manifest.requestedScopes",
|
|
221
|
+
line: 0,
|
|
222
|
+
snippet: "",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (usesGroupsHook) {
|
|
227
|
+
const reads = declared.has("groups.read:*") || declared.has("groups.read");
|
|
228
|
+
if (!reads) {
|
|
229
|
+
findings.push({
|
|
230
|
+
rule: "scope-required-for-useGroups",
|
|
231
|
+
label:
|
|
232
|
+
"useGroups() requires `groups.read:*` in manifest.requestedScopes",
|
|
233
|
+
line: 0,
|
|
234
|
+
snippet: "",
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const lines = source.split(/\r?\n/);
|
|
240
|
+
let firedUsersWrite = false;
|
|
241
|
+
let firedGroupsWrite = false;
|
|
242
|
+
for (let i = 0; i < lines.length; i++) {
|
|
243
|
+
const line = lines[i];
|
|
244
|
+
if (SKIP_COMMENT.test(line)) continue;
|
|
245
|
+
if (usesUsersHook && !firedUsersWrite) {
|
|
246
|
+
for (const m of USER_MUTATION_METHODS) {
|
|
247
|
+
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
248
|
+
if (re.test(line)) {
|
|
249
|
+
if (
|
|
250
|
+
!declared.has("users.write:*") &&
|
|
251
|
+
!declared.has("users.write")
|
|
252
|
+
) {
|
|
253
|
+
findings.push({
|
|
254
|
+
rule: "scope-required-for-user-mutation",
|
|
255
|
+
label: `useUsers().${m}() requires \`users.write:*\` in manifest.requestedScopes`,
|
|
256
|
+
line: i + 1,
|
|
257
|
+
snippet: line.trim().slice(0, 200),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
firedUsersWrite = true;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (usesGroupsHook && !firedGroupsWrite) {
|
|
266
|
+
for (const m of GROUP_MUTATION_METHODS) {
|
|
267
|
+
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
268
|
+
if (re.test(line)) {
|
|
269
|
+
if (m === "remove" && usesUsersHook) continue;
|
|
270
|
+
if (
|
|
271
|
+
!declared.has("groups.write:*") &&
|
|
272
|
+
!declared.has("groups.write")
|
|
273
|
+
) {
|
|
274
|
+
findings.push({
|
|
275
|
+
rule: "scope-required-for-group-mutation",
|
|
276
|
+
label: `useGroups().${m}() requires \`groups.write:*\` in manifest.requestedScopes`,
|
|
277
|
+
line: i + 1,
|
|
278
|
+
snippet: line.trim().slice(0, 200),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
firedGroupsWrite = true;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return findings;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function lintSource(source, options) {
|
|
78
291
|
if (typeof source !== "string") {
|
|
79
292
|
return {
|
|
80
293
|
ok: false,
|
|
81
294
|
findings: [
|
|
82
|
-
{
|
|
295
|
+
{
|
|
296
|
+
rule: "input",
|
|
297
|
+
severity: "error",
|
|
298
|
+
label: "source must be a string",
|
|
299
|
+
line: 0,
|
|
300
|
+
snippet: "",
|
|
301
|
+
},
|
|
83
302
|
],
|
|
84
303
|
};
|
|
85
304
|
}
|
|
@@ -91,6 +310,7 @@ function lintSource(source) {
|
|
|
91
310
|
if (rule.pattern.test(line)) {
|
|
92
311
|
findings.push({
|
|
93
312
|
rule: rule.id,
|
|
313
|
+
severity: "error",
|
|
94
314
|
label: rule.label,
|
|
95
315
|
line: i + 1,
|
|
96
316
|
snippet: line.trim().slice(0, 200),
|
|
@@ -98,7 +318,21 @@ function lintSource(source) {
|
|
|
98
318
|
}
|
|
99
319
|
}
|
|
100
320
|
}
|
|
101
|
-
|
|
321
|
+
findings.push(
|
|
322
|
+
..._importRules(source, options && options.manifest).map((f) => ({
|
|
323
|
+
...f,
|
|
324
|
+
severity: f.severity || "error",
|
|
325
|
+
})),
|
|
326
|
+
);
|
|
327
|
+
findings.push(..._hostApiUrlRules(source));
|
|
328
|
+
findings.push(
|
|
329
|
+
..._scopeRules(source, options && options.manifest).map((f) => ({
|
|
330
|
+
...f,
|
|
331
|
+
severity: f.severity || "error",
|
|
332
|
+
})),
|
|
333
|
+
);
|
|
334
|
+
const hasErrors = findings.some((f) => f.severity !== "warning");
|
|
335
|
+
return { ok: !hasErrors, findings };
|
|
102
336
|
}
|
|
103
337
|
|
|
104
338
|
module.exports = { lintSource, bannedIdentifiers };
|
package/dist/linter.js
CHANGED
|
@@ -57,18 +57,19 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
|
|
|
57
57
|
|
|
58
58
|
// Extra rules that don't map 1:1 to a banned identifier in the contract:
|
|
59
59
|
// host-internal imports that widgets must never touch.
|
|
60
|
+
//
|
|
61
|
+
// REQ-WSDK-PLATFORM: `no-axios-import` is GONE. axios is on the vetted
|
|
62
|
+
// import list now (`CONTRACT.vettedImports`) — widgets may call third-party
|
|
63
|
+
// APIs directly. Calls to the host's own /api/* surface are blocked at
|
|
64
|
+
// runtime by the WidgetContextProvider's network gate; the soft
|
|
65
|
+
// `no-host-api-url` rule below flags obvious host-URL substrings so
|
|
66
|
+
// authors learn the rule statically.
|
|
60
67
|
const EXTRA_RULES = [
|
|
61
68
|
{
|
|
62
69
|
id: "no-auth-store-import",
|
|
63
70
|
label: "widgets must not import the host's auth store",
|
|
64
71
|
pattern: /useAuthStore/,
|
|
65
72
|
},
|
|
66
|
-
{
|
|
67
|
-
id: "no-axios-import",
|
|
68
|
-
label:
|
|
69
|
-
"widgets must not import axios directly; use the injected datastore client",
|
|
70
|
-
pattern: /from\s+['"]axios['"]|require\s*\(\s*['"]axios['"]\s*\)/,
|
|
71
|
-
},
|
|
72
73
|
];
|
|
73
74
|
|
|
74
75
|
const RULES = [...CONTRACT_RULES, ...EXTRA_RULES];
|
|
@@ -83,17 +84,295 @@ export function bannedIdentifiers() {
|
|
|
83
84
|
return CONTRACT.bannedApis.map((b) => b.identifier);
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
// REQ-WSDK-PLATFORM §3.4, §8: scan the source for every bare import
|
|
88
|
+
// specifier and validate it against `CONTRACT.vettedImports`.
|
|
89
|
+
//
|
|
90
|
+
// Recognised forms (string-level, no AST):
|
|
91
|
+
// import X from "spec"
|
|
92
|
+
// import { … } from "spec"
|
|
93
|
+
// import * as X from "spec"
|
|
94
|
+
// import "spec"
|
|
95
|
+
// const X = require("spec") // CJS — for completeness
|
|
96
|
+
//
|
|
97
|
+
// Relative imports within the bundle (`./foo.js`, `./shared/util.js`) are
|
|
98
|
+
// allowed — REQ-WSDK-PLATFORM §4.2 lets a split-impl widget share helpers
|
|
99
|
+
// between `widget.web.jsx` and `widget.native.jsx`. Parent-directory
|
|
100
|
+
// (`../`) and absolute (`/`, `http(s):`) paths are rejected to keep the
|
|
101
|
+
// bundle scope sealed.
|
|
102
|
+
const IMPORT_SPECIFIER_RE =
|
|
103
|
+
/(?:^|[\s;])(?:import(?:\s+[^"';]+from)?|require)\s*\(?\s*['"]([^'"]+)['"]/g;
|
|
104
|
+
|
|
105
|
+
function _classifySpecifier(spec) {
|
|
106
|
+
if (
|
|
107
|
+
spec.startsWith("./") ||
|
|
108
|
+
(spec.startsWith(".") && !spec.startsWith(".."))
|
|
109
|
+
) {
|
|
110
|
+
return "relative-ok";
|
|
111
|
+
}
|
|
112
|
+
if (spec.startsWith("../")) return "relative-escape";
|
|
113
|
+
if (
|
|
114
|
+
spec.startsWith("/") ||
|
|
115
|
+
spec.startsWith("http:") ||
|
|
116
|
+
spec.startsWith("https:") ||
|
|
117
|
+
spec.startsWith("blob:") ||
|
|
118
|
+
spec.startsWith("data:")
|
|
119
|
+
) {
|
|
120
|
+
return "absolute";
|
|
121
|
+
}
|
|
122
|
+
return "bare";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function _importRules(source, manifest) {
|
|
126
|
+
const findings = [];
|
|
127
|
+
const allowed = new Map(
|
|
128
|
+
CONTRACT.vettedImports.map((v) => [v.specifier, v]),
|
|
129
|
+
);
|
|
130
|
+
// Track declared `supportedPlatforms` so a widget that claims "web only"
|
|
131
|
+
// doesn't import a native-only package (and vice versa) without the
|
|
132
|
+
// marketplace listing being honest about which platforms ship.
|
|
133
|
+
const declaredPlatforms =
|
|
134
|
+
manifest &&
|
|
135
|
+
Array.isArray(manifest.supportedPlatforms) &&
|
|
136
|
+
manifest.supportedPlatforms.length > 0
|
|
137
|
+
? new Set(manifest.supportedPlatforms)
|
|
138
|
+
: null;
|
|
139
|
+
|
|
140
|
+
const lines = source.split(/\r?\n/);
|
|
141
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
142
|
+
const line = lines[i];
|
|
143
|
+
// Reset lastIndex — the regex is /g and shared across lines.
|
|
144
|
+
IMPORT_SPECIFIER_RE.lastIndex = 0;
|
|
145
|
+
let m;
|
|
146
|
+
while ((m = IMPORT_SPECIFIER_RE.exec(line))) {
|
|
147
|
+
const spec = m[1];
|
|
148
|
+
const kind = _classifySpecifier(spec);
|
|
149
|
+
if (kind === "relative-ok") continue;
|
|
150
|
+
if (kind === "relative-escape") {
|
|
151
|
+
findings.push({
|
|
152
|
+
rule: "no-parent-relative-import",
|
|
153
|
+
label: `relative import "${spec}" escapes the bundle (\`../\`) — only sibling-file imports (\`./foo.js\`) are allowed`,
|
|
154
|
+
line: i + 1,
|
|
155
|
+
snippet: line.trim().slice(0, 200),
|
|
156
|
+
});
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (kind === "absolute") {
|
|
160
|
+
findings.push({
|
|
161
|
+
rule: "no-absolute-import",
|
|
162
|
+
label: `absolute import "${spec}" is not allowed — use a vetted bare specifier or a sibling-file (\`./foo.js\`) import`,
|
|
163
|
+
line: i + 1,
|
|
164
|
+
snippet: line.trim().slice(0, 200),
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// Bare specifier — validate against the vetted list.
|
|
169
|
+
const entry = allowed.get(spec);
|
|
170
|
+
if (!entry) {
|
|
171
|
+
findings.push({
|
|
172
|
+
rule: "import-not-vetted",
|
|
173
|
+
label:
|
|
174
|
+
`import "${spec}" is not on the vetted package list — see ` +
|
|
175
|
+
`CONTRACT.vettedImports for the current set or open a request to add it`,
|
|
176
|
+
line: i + 1,
|
|
177
|
+
snippet: line.trim().slice(0, 200),
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
// If the manifest declares supportedPlatforms, every imported
|
|
182
|
+
// specifier must support every declared platform. A widget that
|
|
183
|
+
// imports react-native-maps (native-only) cannot honestly claim
|
|
184
|
+
// supportedPlatforms: ["web", "native"] from a single source file —
|
|
185
|
+
// it must either drop "web" from the manifest, OR move that import
|
|
186
|
+
// into widget.native.jsx so the web bundle doesn't see it.
|
|
187
|
+
if (declaredPlatforms) {
|
|
188
|
+
for (const wanted of declaredPlatforms) {
|
|
189
|
+
if (!entry.platforms.includes(wanted)) {
|
|
190
|
+
findings.push({
|
|
191
|
+
rule: "import-platform-mismatch",
|
|
192
|
+
label:
|
|
193
|
+
`"${spec}" supports [${entry.platforms.join(", ")}] but the ` +
|
|
194
|
+
`manifest claims supportedPlatforms includes "${wanted}". ` +
|
|
195
|
+
`Move this import into widget.${wanted === "web" ? "native" : "web"}.jsx, ` +
|
|
196
|
+
`or drop "${wanted}" from manifest.supportedPlatforms.`,
|
|
197
|
+
line: i + 1,
|
|
198
|
+
snippet: line.trim().slice(0, 200),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return findings;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// REQ-WSDK-PLATFORM §3.5: soft warning when source contains host-API URL
|
|
209
|
+
// substrings. NOT a hard block — false positives are possible (a widget
|
|
210
|
+
// that happens to call a third-party API also located at `/api`). The
|
|
211
|
+
// marketplace review queue surfaces the warning for a human pass.
|
|
212
|
+
function _hostApiUrlRules(source) {
|
|
213
|
+
const findings = [];
|
|
214
|
+
const patterns = CONTRACT.hostApiUrlPatterns || [];
|
|
215
|
+
const lines = source.split(/\r?\n/);
|
|
216
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
217
|
+
const line = lines[i];
|
|
218
|
+
for (const needle of patterns) {
|
|
219
|
+
if (line.includes(needle)) {
|
|
220
|
+
findings.push({
|
|
221
|
+
rule: "no-host-api-url",
|
|
222
|
+
severity: "warning",
|
|
223
|
+
label:
|
|
224
|
+
`source contains "${needle}" — calls to the AppStudio host API ` +
|
|
225
|
+
`are blocked at runtime (widgets get no JWT token). Use SDK ` +
|
|
226
|
+
`hooks for workspace data; \`axios\`/\`fetch\` for third-party APIs.`,
|
|
227
|
+
line: i + 1,
|
|
228
|
+
snippet: line.trim().slice(0, 200),
|
|
229
|
+
});
|
|
230
|
+
// Only fire once per line — repeated needle hits on the same line
|
|
231
|
+
// are noise.
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return findings;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation.
|
|
240
|
+
//
|
|
241
|
+
// A widget that calls `useUsers().invite()` / `.deactivate()` /
|
|
242
|
+
// `.reactivate()` / `.remove()` MUST declare `users.write:*` in its
|
|
243
|
+
// manifest's `requestedScopes`; similarly `useGroups()` mutation methods
|
|
244
|
+
// require `groups.write:*`. The rule is enforced statically so a manifest
|
|
245
|
+
// that drifts from the source (e.g. an author forgot to add the scope
|
|
246
|
+
// after wiring up the mutation) is rejected at submission time.
|
|
247
|
+
//
|
|
248
|
+
// The check is pattern-based: scan the source for `.invite(`, `.deactivate(`
|
|
249
|
+
// etc. and only fire when the source ALSO contains `useUsers(` / `useGroups(`
|
|
250
|
+
// nearby (i.e. the destructured callee likely originated from the hook).
|
|
251
|
+
// False positives are accepted in exchange for keeping the linter
|
|
252
|
+
// AST-free; an author whose code happened to spell `.invite(` for an
|
|
253
|
+
// unrelated reason can opt out with a `// @appstudio-skip-scope-check`
|
|
254
|
+
// trailing comment on the offending line.
|
|
255
|
+
const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate", "remove"];
|
|
256
|
+
const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
|
|
257
|
+
const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
|
|
258
|
+
|
|
259
|
+
function _scopeRules(source, manifest) {
|
|
260
|
+
const findings = [];
|
|
261
|
+
if (!manifest || typeof manifest !== "object") return findings;
|
|
262
|
+
const declared = new Set(
|
|
263
|
+
Array.isArray(manifest.requestedScopes)
|
|
264
|
+
? manifest.requestedScopes.filter((s) => typeof s === "string")
|
|
265
|
+
: [],
|
|
266
|
+
);
|
|
267
|
+
const usesUsersHook = /\buseUsers\s*\(/.test(source);
|
|
268
|
+
const usesGroupsHook = /\buseGroups\s*\(/.test(source);
|
|
269
|
+
|
|
270
|
+
if (usesUsersHook) {
|
|
271
|
+
const reads = declared.has("users.read:*") || declared.has("users.read");
|
|
272
|
+
if (!reads) {
|
|
273
|
+
findings.push({
|
|
274
|
+
rule: "scope-required-for-useUsers",
|
|
275
|
+
label:
|
|
276
|
+
"useUsers() requires `users.read:*` in manifest.requestedScopes",
|
|
277
|
+
line: 0,
|
|
278
|
+
snippet: "",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (usesGroupsHook) {
|
|
283
|
+
const reads = declared.has("groups.read:*") || declared.has("groups.read");
|
|
284
|
+
if (!reads) {
|
|
285
|
+
findings.push({
|
|
286
|
+
rule: "scope-required-for-useGroups",
|
|
287
|
+
label:
|
|
288
|
+
"useGroups() requires `groups.read:*` in manifest.requestedScopes",
|
|
289
|
+
line: 0,
|
|
290
|
+
snippet: "",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const lines = source.split(/\r?\n/);
|
|
296
|
+
let firedUsersWrite = false;
|
|
297
|
+
let firedGroupsWrite = false;
|
|
298
|
+
for (let i = 0; i < lines.length; i++) {
|
|
299
|
+
const line = lines[i];
|
|
300
|
+
if (SKIP_COMMENT.test(line)) continue;
|
|
301
|
+
if (usesUsersHook && !firedUsersWrite) {
|
|
302
|
+
for (const m of USER_MUTATION_METHODS) {
|
|
303
|
+
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
304
|
+
if (re.test(line)) {
|
|
305
|
+
if (
|
|
306
|
+
!declared.has("users.write:*") &&
|
|
307
|
+
!declared.has("users.write")
|
|
308
|
+
) {
|
|
309
|
+
findings.push({
|
|
310
|
+
rule: "scope-required-for-user-mutation",
|
|
311
|
+
label: `useUsers().${m}() requires \`users.write:*\` in manifest.requestedScopes`,
|
|
312
|
+
line: i + 1,
|
|
313
|
+
snippet: line.trim().slice(0, 200),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
firedUsersWrite = true;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (usesGroupsHook && !firedGroupsWrite) {
|
|
322
|
+
for (const m of GROUP_MUTATION_METHODS) {
|
|
323
|
+
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
324
|
+
if (re.test(line)) {
|
|
325
|
+
// `.remove(` is also a useUsers method; only blame groups
|
|
326
|
+
// when the source also uses the groups hook AND the source
|
|
327
|
+
// doesn't ALSO use the users hook (an ambiguous .remove(
|
|
328
|
+
// is best left to the user-mutation rule above).
|
|
329
|
+
if (m === "remove" && usesUsersHook) continue;
|
|
330
|
+
if (
|
|
331
|
+
!declared.has("groups.write:*") &&
|
|
332
|
+
!declared.has("groups.write")
|
|
333
|
+
) {
|
|
334
|
+
findings.push({
|
|
335
|
+
rule: "scope-required-for-group-mutation",
|
|
336
|
+
label: `useGroups().${m}() requires \`groups.write:*\` in manifest.requestedScopes`,
|
|
337
|
+
line: i + 1,
|
|
338
|
+
snippet: line.trim().slice(0, 200),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
firedGroupsWrite = true;
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return findings;
|
|
348
|
+
}
|
|
349
|
+
|
|
86
350
|
/**
|
|
87
351
|
* Lint a JavaScript source string.
|
|
352
|
+
*
|
|
353
|
+
* REQ-WSDK-PLATFORM update: findings can now carry a `severity` field
|
|
354
|
+
* (`"error"` | `"warning"`). The `ok` flag is true iff NO `severity:
|
|
355
|
+
* "error"` finding exists — warnings (currently just `no-host-api-url`)
|
|
356
|
+
* surface to the reviewer but do not block publish. Findings without an
|
|
357
|
+
* explicit `severity` are treated as errors for back-compat with existing
|
|
358
|
+
* rules.
|
|
359
|
+
*
|
|
88
360
|
* @param {string} source
|
|
89
|
-
* @
|
|
361
|
+
* @param {{ manifest?: { requestedScopes?: string[], supportedPlatforms?: string[] } }} [options]
|
|
362
|
+
* @returns {{ ok: boolean, findings: Array<{ rule: string, severity?: "error" | "warning", label: string, line: number, snippet: string }> }}
|
|
90
363
|
*/
|
|
91
|
-
export function lintSource(source) {
|
|
364
|
+
export function lintSource(source, options) {
|
|
92
365
|
if (typeof source !== "string") {
|
|
93
366
|
return {
|
|
94
367
|
ok: false,
|
|
95
368
|
findings: [
|
|
96
|
-
{
|
|
369
|
+
{
|
|
370
|
+
rule: "input",
|
|
371
|
+
severity: "error",
|
|
372
|
+
label: "source must be a string",
|
|
373
|
+
line: 0,
|
|
374
|
+
snippet: "",
|
|
375
|
+
},
|
|
97
376
|
],
|
|
98
377
|
};
|
|
99
378
|
}
|
|
@@ -105,6 +384,7 @@ export function lintSource(source) {
|
|
|
105
384
|
if (rule.pattern.test(line)) {
|
|
106
385
|
findings.push({
|
|
107
386
|
rule: rule.id,
|
|
387
|
+
severity: "error",
|
|
108
388
|
label: rule.label,
|
|
109
389
|
line: i + 1,
|
|
110
390
|
snippet: line.trim().slice(0, 200),
|
|
@@ -112,5 +392,24 @@ export function lintSource(source) {
|
|
|
112
392
|
}
|
|
113
393
|
}
|
|
114
394
|
}
|
|
115
|
-
|
|
395
|
+
// REQ-WSDK-PLATFORM §3.4: import-specifier validation.
|
|
396
|
+
findings.push(
|
|
397
|
+
..._importRules(source, options && options.manifest).map((f) => ({
|
|
398
|
+
...f,
|
|
399
|
+
severity: f.severity || "error",
|
|
400
|
+
})),
|
|
401
|
+
);
|
|
402
|
+
// REQ-WSDK-PLATFORM §3.5: soft host-API URL warning (does not block).
|
|
403
|
+
findings.push(..._hostApiUrlRules(source));
|
|
404
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
|
|
405
|
+
// line-by-line scan so banned-identifier findings stay first in the
|
|
406
|
+
// output.
|
|
407
|
+
findings.push(
|
|
408
|
+
..._scopeRules(source, options && options.manifest).map((f) => ({
|
|
409
|
+
...f,
|
|
410
|
+
severity: f.severity || "error",
|
|
411
|
+
})),
|
|
412
|
+
);
|
|
413
|
+
const hasErrors = findings.some((f) => f.severity !== "warning");
|
|
414
|
+
return { ok: !hasErrors, findings };
|
|
116
415
|
}
|
package/dist/primitives.js
CHANGED
|
@@ -60,3 +60,11 @@ export const StyleSheet = ReactNative.StyleSheet;
|
|
|
60
60
|
// the OS hands it off to the system handler. `canOpenURL` reports whether
|
|
61
61
|
// the URL scheme is registered.
|
|
62
62
|
export const Linking = ReactNative.Linking;
|
|
63
|
+
// REQ-WSDK-PLATFORM §6 — `<Icon>` wraps lucide-react-native. Same source
|
|
64
|
+
// runs on both platforms; see ./icon.js.
|
|
65
|
+
export { Icon } from "./icon.js";
|
|
66
|
+
// REQ-WSDK-PLATFORM §6 — `<DateTimePicker>` wraps
|
|
67
|
+
// @react-native-community/datetimepicker and normalizes its value to
|
|
68
|
+
// ISO 8601 strings. Same source runs on both platforms; see
|
|
69
|
+
// ./datetimepicker.js.
|
|
70
|
+
export { DateTimePicker } from "./datetimepicker.js";
|
|
@@ -20,3 +20,12 @@ export {
|
|
|
20
20
|
StyleSheet,
|
|
21
21
|
Linking,
|
|
22
22
|
} from "react-native";
|
|
23
|
+
|
|
24
|
+
// REQ-WSDK-PLATFORM §6 — `<Icon>` is implemented in one file that runs on
|
|
25
|
+
// both platforms (lucide-react-native ships a working build for both).
|
|
26
|
+
export { Icon } from "./icon.js";
|
|
27
|
+
// REQ-WSDK-PLATFORM §6 — `<DateTimePicker>` ditto. The underlying
|
|
28
|
+
// @react-native-community/datetimepicker handles its own
|
|
29
|
+
// per-platform rendering; the SDK wrapper just normalizes the value
|
|
30
|
+
// to ISO strings.
|
|
31
|
+
export { DateTimePicker } from "./datetimepicker.js";
|
package/dist/property-schema.js
CHANGED
|
@@ -6,6 +6,12 @@ const VALID_TYPES = new Set([
|
|
|
6
6
|
"color", "icon", "image",
|
|
7
7
|
"select", "multiselect",
|
|
8
8
|
"tableRef", "columnRef", "recordBinding",
|
|
9
|
+
// REQ-USERMGMT M4 / §4.8: `groupRef` is a Group picker that emits a bare
|
|
10
|
+
// AppUserGroup UUID into the page JSON. Renders via `GroupSelector` in
|
|
11
|
+
// the Studio Properties Panel. REQ-GEN-07 compliance: no typed UUIDs —
|
|
12
|
+
// value is the raw uuid string so the tenant-copy walker can remap it
|
|
13
|
+
// through `_remapJson` without per-prop hooks.
|
|
14
|
+
"groupRef",
|
|
9
15
|
"expression", "eventBinding",
|
|
10
16
|
"object", "array",
|
|
11
17
|
]);
|
|
@@ -87,6 +93,7 @@ function coerceLeaf(def, value, path, errors) {
|
|
|
87
93
|
case "tableRef":
|
|
88
94
|
case "columnRef":
|
|
89
95
|
case "recordBinding":
|
|
96
|
+
case "groupRef":
|
|
90
97
|
case "expression":
|
|
91
98
|
case "eventBinding":
|
|
92
99
|
if (typeof value !== "string") errors.push(`${path}: expected string`);
|