@colixsystems/widget-sdk 0.35.0 → 0.37.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 +45 -3
- package/dist/cli.js +89 -6
- package/dist/contract.cjs +48 -9
- package/dist/contract.js +48 -9
- package/dist/devserver.js +350 -0
- package/dist/linter.cjs +181 -4
- package/dist/linter.js +210 -19
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
34
34
|
| **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
|
|
35
35
|
| **FILES** (`ctx.files`) | `useFile(id)` | `{ url, file, loading, error, refetch }` | `ctx.files.get` — no scope |
|
|
36
36
|
| **DIRECTORY** (`ctx.directory`) | `useDirectory(query?)` | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
|
|
37
|
-
| **DIRECTORY** | `useUsers(query?)` | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` | `directory.users.*` — `users.read:*` (
|
|
37
|
+
| **DIRECTORY** | `useUsers(query?)` | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` | `directory.users.*` — `users.read:*` (edits also `users.write:*`; `remove()` also `users.delete:*`) |
|
|
38
38
|
| **DIRECTORY** | `useGroups(query?)` | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
|
|
39
39
|
| **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
|
|
40
40
|
| **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser` |
|
|
@@ -47,7 +47,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
47
47
|
|
|
48
48
|
## Status
|
|
49
49
|
|
|
50
|
-
`v0.
|
|
50
|
+
`v0.37.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**.
|
|
51
|
+
|
|
52
|
+
### What's new in 0.37.0
|
|
53
|
+
|
|
54
|
+
**`react/jsx-runtime` + `react/jsx-dev-runtime` are now vetted imports.** A widget bundle compiled with React's *automatic* JSX runtime (Vite/esbuild's default) emits `import { jsx, jsxs, Fragment } from "react/jsx-runtime"` — code the author never writes by hand. The runtime already treated these as host-provided (the web loader shims both, the AI-agent sandbox stubs them, and the Developer guide documents them as externalized), but the linter's vetted list did not list them, so such a bundle failed publish static analysis with `import-not-vetted` on `react/jsx-runtime`. Both are now on `CONTRACT.vettedImports` as `core` subpaths of the already-vetted `react`. **`CONTRACT.version` → `1.27.0`** (additive: two vetted core subpaths). No existing entry changed shape.
|
|
55
|
+
|
|
56
|
+
### What's new in 0.36.0
|
|
57
|
+
|
|
58
|
+
**Explicit `users.delete:*` scope for destructive user removal (SC-902).** `useUsers().remove(userId)` now requires `users.delete:*` in the manifest's `requestedScopes` — separated from the edit-style `users.write:*`. The backend gates `DELETE /app/users/:id` on a dedicated SystemAcl `users.delete` capability, so an operator can authorise (or withhold) permanent removal independently of inviting / deactivating. New linter rule **`scope-required-for-user-delete`** flags a widget that calls `.remove()` without declaring `users.delete:*` (`.invite()` / `.deactivate()` / `.reactivate()` still map to `users.write:*`). The built-in **User Management** widget adds the scope. **`CONTRACT.version` → `1.26.0`** (additive: one new scope verb + one linter rule). No hook signature changed.
|
|
51
59
|
|
|
52
60
|
### What's new in 0.35.0
|
|
53
61
|
|
|
@@ -375,7 +383,9 @@ The matching manifest declares the scopes:
|
|
|
375
383
|
```js
|
|
376
384
|
{
|
|
377
385
|
// ...
|
|
378
|
-
|
|
386
|
+
// `remove(u.id)` above needs `users.delete:*` (SC-902) — the destructive
|
|
387
|
+
// delete is gated separately from the edit-style `users.write:*`.
|
|
388
|
+
requestedScopes: ["users.read:*", "users.write:*", "users.delete:*", "groups.read:*", "groups.write:*"],
|
|
379
389
|
}
|
|
380
390
|
```
|
|
381
391
|
|
|
@@ -448,6 +458,38 @@ import { lintSource } from "@colixsystems/widget-sdk/linter";
|
|
|
448
458
|
const report = lintSource(source);
|
|
449
459
|
```
|
|
450
460
|
|
|
461
|
+
## Local dev loop (`appstudio-widget dev`)
|
|
462
|
+
|
|
463
|
+
Author a marketplace widget with live reload instead of the publish → submit →
|
|
464
|
+
review → install round-trip:
|
|
465
|
+
|
|
466
|
+
```sh
|
|
467
|
+
appstudio-widget dev path/to/widget.jsx --port 4400
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
This transpiles the entry (the **same** Sucrase JSX pass the publish path runs)
|
|
471
|
+
and serves it over `http://127.0.0.1:4400`:
|
|
472
|
+
|
|
473
|
+
| Route | Purpose |
|
|
474
|
+
| ----- | ------- |
|
|
475
|
+
| `GET /widget.mjs` | the transpiled entry — an ES module the Studio loads exactly like a published `widget.mjs` |
|
|
476
|
+
| `GET /manifest.json` | the manifest (from `--manifest`, else a sibling `manifest.json`); only consulted for a bare-component bundle |
|
|
477
|
+
| `GET /__dev/events` | Server-Sent-Events stream that emits `reload` on every source change |
|
|
478
|
+
| `GET /__dev/health` | `{ ok, manifestId }` |
|
|
479
|
+
|
|
480
|
+
Then, in the Studio Builder (dev mode only), open the **Dev widgets** panel in
|
|
481
|
+
the palette and paste `http://127.0.0.1:4400`. The widget loads through the
|
|
482
|
+
real runtime loader (same import-rewrite, host-shim, and export-shape gates a
|
|
483
|
+
published bundle hits) and hot-reloads onto the canvas on every save.
|
|
484
|
+
|
|
485
|
+
- **v1 serves a single-file entry.** Split-impl widgets (`widget.web.jsx` +
|
|
486
|
+
`widget.native.jsx` with shared `./relative.jsx` helpers) need a real build
|
|
487
|
+
step; the dev server reports relative imports rather than serving a module
|
|
488
|
+
the browser can't resolve.
|
|
489
|
+
- `dev` lazily requires the optional `sucrase` dependency. In this monorepo it
|
|
490
|
+
is already present; in a standalone widget project run `npm i -D sucrase`.
|
|
491
|
+
- Lint findings print to the terminal on each change but never block serving.
|
|
492
|
+
|
|
451
493
|
## Why no TypeScript dependency?
|
|
452
494
|
|
|
453
495
|
The package is plain ESM JavaScript with hand-written `.d.ts` ambient types. Consumers using TypeScript get full IntelliSense; consumers using JavaScript pay nothing.
|
package/dist/cli.js
CHANGED
|
@@ -1,21 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// CLI entry for `appstudio-widget
|
|
2
|
+
// CLI entry for `appstudio-widget`.
|
|
3
|
+
// appstudio-widget lint <path> — validate a widget source
|
|
4
|
+
// appstudio-widget dev <entry.jsx> [--port N] [--manifest path]
|
|
5
|
+
// — REQ-WSDK-DEVKIT local dev server
|
|
3
6
|
|
|
4
7
|
import { readFileSync } from "node:fs";
|
|
5
8
|
import { resolve } from "node:path";
|
|
6
9
|
import { argv, exit, stderr, stdout } from "node:process";
|
|
7
10
|
import { lintSource } from "./linter.js";
|
|
11
|
+
import { startDevServer } from "./devserver.js";
|
|
8
12
|
|
|
9
13
|
function usage() {
|
|
10
|
-
stderr.write(
|
|
14
|
+
stderr.write(
|
|
15
|
+
"Usage:\n" +
|
|
16
|
+
" appstudio-widget lint <path>\n" +
|
|
17
|
+
" appstudio-widget dev <entry.jsx> [--port <n>] [--manifest <path>]\n",
|
|
18
|
+
);
|
|
11
19
|
exit(2);
|
|
12
20
|
}
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
// Minimal flag parser: pulls `--key value` / `--key=value` out of the args and
|
|
23
|
+
// returns the remaining positionals. No external dependency.
|
|
24
|
+
function parseFlags(args) {
|
|
25
|
+
const flags = {};
|
|
26
|
+
const positionals = [];
|
|
27
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
28
|
+
const a = args[i];
|
|
29
|
+
if (a.startsWith("--")) {
|
|
30
|
+
const eq = a.indexOf("=");
|
|
31
|
+
if (eq !== -1) {
|
|
32
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
33
|
+
} else {
|
|
34
|
+
const next = args[i + 1];
|
|
35
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
36
|
+
flags[a.slice(2)] = next;
|
|
37
|
+
i += 1;
|
|
38
|
+
} else {
|
|
39
|
+
flags[a.slice(2)] = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
positionals.push(a);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { flags, positionals };
|
|
47
|
+
}
|
|
17
48
|
|
|
18
|
-
|
|
49
|
+
function runLint(rest) {
|
|
19
50
|
if (rest.length === 0) usage();
|
|
20
51
|
const filePath = resolve(rest[0]);
|
|
21
52
|
let source;
|
|
@@ -35,6 +66,58 @@ if (cmd === "lint") {
|
|
|
35
66
|
stderr.write(` [${f.rule}] line ${f.line}: ${f.label}\n ${f.snippet}\n`);
|
|
36
67
|
}
|
|
37
68
|
exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function runDev(rest) {
|
|
72
|
+
const { flags, positionals } = parseFlags(rest);
|
|
73
|
+
if (positionals.length === 0) usage();
|
|
74
|
+
const entry = positionals[0];
|
|
75
|
+
const port = flags.port ? Number(flags.port) : 4400;
|
|
76
|
+
if (Number.isNaN(port)) {
|
|
77
|
+
stderr.write(`Invalid --port: ${flags.port}\n`);
|
|
78
|
+
exit(2);
|
|
79
|
+
}
|
|
80
|
+
const manifest = typeof flags.manifest === "string" ? flags.manifest : null;
|
|
81
|
+
|
|
82
|
+
let handle;
|
|
83
|
+
try {
|
|
84
|
+
handle = await startDevServer({
|
|
85
|
+
entry,
|
|
86
|
+
port,
|
|
87
|
+
manifest,
|
|
88
|
+
log: (line) => stdout.write(` ${line}\n`),
|
|
89
|
+
});
|
|
90
|
+
} catch (err) {
|
|
91
|
+
stderr.write(`${err.message}\n`);
|
|
92
|
+
exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
stdout.write(
|
|
96
|
+
`\nappstudio-widget dev — serving ${resolve(entry)}\n` +
|
|
97
|
+
` bundle: ${handle.url}/widget.mjs\n` +
|
|
98
|
+
` manifest: ${handle.url}/manifest.json\n` +
|
|
99
|
+
` reload: ${handle.url}/__dev/events (SSE)\n` +
|
|
100
|
+
(handle.manifestId ? ` id: ${handle.manifestId}\n` : "") +
|
|
101
|
+
`\nIn the Studio Builder (dev mode), open the "Dev widgets" panel and add:\n` +
|
|
102
|
+
` ${handle.url}\n` +
|
|
103
|
+
`Edits to the entry hot-reload the widget on the canvas. Ctrl+C to stop.\n\n`,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const shutdown = () => {
|
|
107
|
+
handle.close().finally(() => exit(0));
|
|
108
|
+
};
|
|
109
|
+
process.on("SIGINT", shutdown);
|
|
110
|
+
process.on("SIGTERM", shutdown);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const args = argv.slice(2);
|
|
114
|
+
if (args.length === 0) usage();
|
|
115
|
+
const [cmd, ...rest] = args;
|
|
116
|
+
|
|
117
|
+
if (cmd === "lint") {
|
|
118
|
+
runLint(rest);
|
|
119
|
+
} else if (cmd === "dev") {
|
|
120
|
+
runDev(rest);
|
|
38
121
|
} else {
|
|
39
122
|
stderr.write(`Unknown command "${cmd}"\n`);
|
|
40
123
|
usage();
|
package/dist/contract.cjs
CHANGED
|
@@ -359,12 +359,14 @@ const HOOKS = [
|
|
|
359
359
|
},
|
|
360
360
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
361
361
|
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
362
|
-
// Reads need `users.read:*` scope; mutations
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
362
|
+
// Reads need `users.read:*` scope; edit-style mutations (invite /
|
|
363
|
+
// deactivate / reactivate) additionally need `users.write:*`, and the
|
|
364
|
+
// destructive `remove` needs `users.delete:*` (SC-902). The `invite`
|
|
365
|
+
// call accepts `{ email, name, groupIds? }` and returns the resulting
|
|
366
|
+
// AppUserInvite row (the email is sent by the host). Mutating users from
|
|
367
|
+
// a widget requires the corresponding SystemAcl capability grant in the
|
|
368
|
+
// tenant (`users.write` for edits, `users.delete` for removal); widgets
|
|
369
|
+
// that only call read methods need only `users.read:*`.
|
|
368
370
|
{
|
|
369
371
|
name: "useUsers",
|
|
370
372
|
signature: "useUsers(query?)",
|
|
@@ -374,7 +376,9 @@ const HOOKS = [
|
|
|
374
376
|
"{ users, loading, error, refetch, invite, deactivate, reactivate, remove }. " +
|
|
375
377
|
"list returns the { data, meta } envelope verbatim — the hook unwraps " +
|
|
376
378
|
"res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
|
|
377
|
-
"scope; mutations
|
|
379
|
+
"scope; edit-style mutations (invite/deactivate/reactivate) need " +
|
|
380
|
+
"users.write:*, and the destructive remove() additionally needs " +
|
|
381
|
+
"users.delete:* (SC-902). The `invite` call accepts " +
|
|
378
382
|
"{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
|
|
379
383
|
"(the email is sent by the host).",
|
|
380
384
|
returnShape: {
|
|
@@ -892,7 +896,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
892
896
|
"groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
|
|
893
897
|
"invites: { list(), revoke(id), resend(id) }, " +
|
|
894
898
|
"bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
|
|
895
|
-
"users backs useDirectory() + useUsers(); groups backs useGroups(); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write
|
|
899
|
+
"users backs useDirectory() + useUsers(); groups backs useGroups(); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
|
|
896
900
|
required: true,
|
|
897
901
|
fields: { users: "object", groups: "object", bankid: "object" },
|
|
898
902
|
},
|
|
@@ -1053,6 +1057,20 @@ const VETTED_IMPORTS = [
|
|
|
1053
1057
|
category: "core",
|
|
1054
1058
|
description: "React. Hooks, JSX, lifecycle. Unchanged.",
|
|
1055
1059
|
},
|
|
1060
|
+
{
|
|
1061
|
+
specifier: "react/jsx-runtime",
|
|
1062
|
+
platforms: ["web", "native"],
|
|
1063
|
+
category: "core",
|
|
1064
|
+
description:
|
|
1065
|
+
"React's automatic JSX runtime (jsx/jsxs/Fragment). Not hand-written — the JSX transform injects this import when a bundle is compiled with the automatic runtime. Host-provided on both platforms (the web loader shims it; Metro resolves the real subpath of react), so it is externalized exactly like react.",
|
|
1066
|
+
},
|
|
1067
|
+
{
|
|
1068
|
+
specifier: "react/jsx-dev-runtime",
|
|
1069
|
+
platforms: ["web", "native"],
|
|
1070
|
+
category: "core",
|
|
1071
|
+
description:
|
|
1072
|
+
"React's automatic JSX dev runtime (jsxDEV/Fragment). The development-mode counterpart to react/jsx-runtime, injected by the JSX transform in dev builds. Host-provided on both platforms, externalized like react.",
|
|
1073
|
+
},
|
|
1056
1074
|
{
|
|
1057
1075
|
specifier: "@colixsystems/widget-sdk",
|
|
1058
1076
|
platforms: ["web", "native"],
|
|
@@ -1447,7 +1465,28 @@ const CONTRACT = deepFreeze({
|
|
|
1447
1465
|
// setVisibility). Both read the existing `ctx.filestore`; the underlying
|
|
1448
1466
|
// `filestore-client` 0.5.0 made shares folder-scoped + added
|
|
1449
1467
|
// `signatures.roster`. No existing hook changed.
|
|
1450
|
-
|
|
1468
|
+
//
|
|
1469
|
+
// 1.26.0: additive (SC-902) — new `users.delete:*` scope verb. The
|
|
1470
|
+
// destructive `useUsers().remove()` now requires `users.delete:*` in
|
|
1471
|
+
// requestedScopes (backed by the SystemAcl `users.delete` capability
|
|
1472
|
+
// on DELETE /app/users/:id), separated from the edit-style
|
|
1473
|
+
// `users.write:*`. New linter rule `scope-required-for-user-delete`
|
|
1474
|
+
// flags a widget that calls `.remove()` without declaring the scope.
|
|
1475
|
+
// No hook signature changed; `useUsers().remove` is unchanged.
|
|
1476
|
+
//
|
|
1477
|
+
// 1.27.0: additive — the vetted import allowlist gains `react/jsx-runtime`
|
|
1478
|
+
// and `react/jsx-dev-runtime`. These are subpaths of the already-vetted
|
|
1479
|
+
// `react`, injected automatically by the JSX transform when a bundle is
|
|
1480
|
+
// compiled with the automatic JSX runtime (Vite/esbuild's default). The
|
|
1481
|
+
// runtime already treats them as host-provided (the web loader shims both
|
|
1482
|
+
// in widgetLoader.js, the AI-agent sandbox stubs them, and the Developer
|
|
1483
|
+
// guide documents them as externalized), but the linter's vetted list did
|
|
1484
|
+
// not list them — so a first-party/marketplace bundle built with the
|
|
1485
|
+
// automatic runtime failed publish static analysis with `import-not-vetted`
|
|
1486
|
+
// on `react/jsx-runtime`. Adding them converges the linter onto the same
|
|
1487
|
+
// contract every other surface already honoured (CLAUDE.md §3). No
|
|
1488
|
+
// existing entry changed shape — minor bump on the pre-1.0 channel.
|
|
1489
|
+
version: "1.27.0",
|
|
1451
1490
|
hooks: HOOKS,
|
|
1452
1491
|
primitives: PRIMITIVES,
|
|
1453
1492
|
manifestSchema: MANIFEST_SCHEMA,
|
package/dist/contract.js
CHANGED
|
@@ -359,12 +359,14 @@ const HOOKS = [
|
|
|
359
359
|
},
|
|
360
360
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
361
361
|
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
362
|
-
// Reads need `users.read:*` scope; mutations
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
362
|
+
// Reads need `users.read:*` scope; edit-style mutations (invite /
|
|
363
|
+
// deactivate / reactivate) additionally need `users.write:*`, and the
|
|
364
|
+
// destructive `remove` needs `users.delete:*` (SC-902). The `invite`
|
|
365
|
+
// call accepts `{ email, name, groupIds? }` and returns the resulting
|
|
366
|
+
// AppUserInvite row (the email is sent by the host). Mutating users from
|
|
367
|
+
// a widget requires the corresponding SystemAcl capability grant in the
|
|
368
|
+
// tenant (`users.write` for edits, `users.delete` for removal); widgets
|
|
369
|
+
// that only call read methods need only `users.read:*`.
|
|
368
370
|
{
|
|
369
371
|
name: "useUsers",
|
|
370
372
|
signature: "useUsers(query?)",
|
|
@@ -374,7 +376,9 @@ const HOOKS = [
|
|
|
374
376
|
"{ users, loading, error, refetch, invite, deactivate, reactivate, remove }. " +
|
|
375
377
|
"list returns the { data, meta } envelope verbatim — the hook unwraps " +
|
|
376
378
|
"res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
|
|
377
|
-
"scope; mutations
|
|
379
|
+
"scope; edit-style mutations (invite/deactivate/reactivate) need " +
|
|
380
|
+
"users.write:*, and the destructive remove() additionally needs " +
|
|
381
|
+
"users.delete:* (SC-902). The `invite` call accepts " +
|
|
378
382
|
"{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
|
|
379
383
|
"(the email is sent by the host).",
|
|
380
384
|
returnShape: {
|
|
@@ -892,7 +896,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
892
896
|
"groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
|
|
893
897
|
"invites: { list(), revoke(id), resend(id) }, " +
|
|
894
898
|
"bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
|
|
895
|
-
"users backs useDirectory() + useUsers(); groups backs useGroups(); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write
|
|
899
|
+
"users backs useDirectory() + useUsers(); groups backs useGroups(); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
|
|
896
900
|
required: true,
|
|
897
901
|
fields: { users: "object", groups: "object", bankid: "object" },
|
|
898
902
|
},
|
|
@@ -1053,6 +1057,20 @@ const VETTED_IMPORTS = [
|
|
|
1053
1057
|
category: "core",
|
|
1054
1058
|
description: "React. Hooks, JSX, lifecycle. Unchanged.",
|
|
1055
1059
|
},
|
|
1060
|
+
{
|
|
1061
|
+
specifier: "react/jsx-runtime",
|
|
1062
|
+
platforms: ["web", "native"],
|
|
1063
|
+
category: "core",
|
|
1064
|
+
description:
|
|
1065
|
+
"React's automatic JSX runtime (jsx/jsxs/Fragment). Not hand-written — the JSX transform injects this import when a bundle is compiled with the automatic runtime. Host-provided on both platforms (the web loader shims it; Metro resolves the real subpath of react), so it is externalized exactly like react.",
|
|
1066
|
+
},
|
|
1067
|
+
{
|
|
1068
|
+
specifier: "react/jsx-dev-runtime",
|
|
1069
|
+
platforms: ["web", "native"],
|
|
1070
|
+
category: "core",
|
|
1071
|
+
description:
|
|
1072
|
+
"React's automatic JSX dev runtime (jsxDEV/Fragment). The development-mode counterpart to react/jsx-runtime, injected by the JSX transform in dev builds. Host-provided on both platforms, externalized like react.",
|
|
1073
|
+
},
|
|
1056
1074
|
{
|
|
1057
1075
|
specifier: "@colixsystems/widget-sdk",
|
|
1058
1076
|
platforms: ["web", "native"],
|
|
@@ -1447,7 +1465,28 @@ const CONTRACT = deepFreeze({
|
|
|
1447
1465
|
// setVisibility). Both read the existing `ctx.filestore`; the underlying
|
|
1448
1466
|
// `filestore-client` 0.5.0 made shares folder-scoped + added
|
|
1449
1467
|
// `signatures.roster`. No existing hook changed.
|
|
1450
|
-
|
|
1468
|
+
//
|
|
1469
|
+
// 1.26.0: additive (SC-902) — new `users.delete:*` scope verb. The
|
|
1470
|
+
// destructive `useUsers().remove()` now requires `users.delete:*` in
|
|
1471
|
+
// requestedScopes (backed by the SystemAcl `users.delete` capability
|
|
1472
|
+
// on DELETE /app/users/:id), separated from the edit-style
|
|
1473
|
+
// `users.write:*`. New linter rule `scope-required-for-user-delete`
|
|
1474
|
+
// flags a widget that calls `.remove()` without declaring the scope.
|
|
1475
|
+
// No hook signature changed; `useUsers().remove` is unchanged.
|
|
1476
|
+
//
|
|
1477
|
+
// 1.27.0: additive — the vetted import allowlist gains `react/jsx-runtime`
|
|
1478
|
+
// and `react/jsx-dev-runtime`. These are subpaths of the already-vetted
|
|
1479
|
+
// `react`, injected automatically by the JSX transform when a bundle is
|
|
1480
|
+
// compiled with the automatic JSX runtime (Vite/esbuild's default). The
|
|
1481
|
+
// runtime already treats them as host-provided (the web loader shims both
|
|
1482
|
+
// in widgetLoader.js, the AI-agent sandbox stubs them, and the Developer
|
|
1483
|
+
// guide documents them as externalized), but the linter's vetted list did
|
|
1484
|
+
// not list them — so a first-party/marketplace bundle built with the
|
|
1485
|
+
// automatic runtime failed publish static analysis with `import-not-vetted`
|
|
1486
|
+
// on `react/jsx-runtime`. Adding them converges the linter onto the same
|
|
1487
|
+
// contract every other surface already honoured (CLAUDE.md §3). No
|
|
1488
|
+
// existing entry changed shape — minor bump on the pre-1.0 channel.
|
|
1489
|
+
version: "1.27.0",
|
|
1451
1490
|
hooks: HOOKS,
|
|
1452
1491
|
primitives: PRIMITIVES,
|
|
1453
1492
|
manifestSchema: MANIFEST_SCHEMA,
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// REQ-WSDK-DEVKIT: local widget dev server backing `appstudio-widget dev`.
|
|
2
|
+
//
|
|
3
|
+
// Goal: a tight author loop for marketplace-widget development that mirrors the
|
|
4
|
+
// built-in widgets' Vite-HMR experience WITHOUT the publish → submit → review →
|
|
5
|
+
// install round-trip. The Studio Builder loads the widget this server serves
|
|
6
|
+
// through the EXACT runtime loader path it uses for published bundles (fetch
|
|
7
|
+
// text → rewrite bare imports to host shims → blob `import()` → register), so a
|
|
8
|
+
// widget that renders under `dev` will publish unchanged.
|
|
9
|
+
//
|
|
10
|
+
// What this module owns:
|
|
11
|
+
// * transpile the single-file entry JSX→JS with the SAME Sucrase pass the
|
|
12
|
+
// publish path runs (`transforms:["jsx"]`, automatic runtime, production),
|
|
13
|
+
// so the served `/widget.mjs` is byte-for-byte the shape the loader expects;
|
|
14
|
+
// * serve it (plus a best-effort `/manifest.json`) over HTTP with permissive
|
|
15
|
+
// CORS for the Vite dev origin;
|
|
16
|
+
// * a Server-Sent-Events channel (`/__dev/events`) that emits `reload` on
|
|
17
|
+
// every source change so the Builder re-fetches + re-registers + re-renders;
|
|
18
|
+
// * re-lint (reusing `lintSource`) on every change, printing findings without
|
|
19
|
+
// blocking — the author iterates against the real runtime.
|
|
20
|
+
//
|
|
21
|
+
// Pure Node (node:http / node:fs / node:path) plus a lazily-imported `sucrase`
|
|
22
|
+
// (an optional dependency) — the published SDK's runtime + types carry no extra
|
|
23
|
+
// weight; only the dev command pulls the transform in.
|
|
24
|
+
|
|
25
|
+
import { createServer } from "node:http";
|
|
26
|
+
import { readFileSync, existsSync, watch } from "node:fs";
|
|
27
|
+
import { resolve, dirname, join, sep } from "node:path";
|
|
28
|
+
import { lintSource } from "./linter.js";
|
|
29
|
+
|
|
30
|
+
// Bare-import / relative-import detection. The loader rewrites bare specifiers
|
|
31
|
+
// in the ENTRY to client-side blob shims, so they resolve fine. Relative
|
|
32
|
+
// imports do NOT — a sibling fetched over HTTP can't be handed the loader's
|
|
33
|
+
// blob shims for its own bare imports — so v1 refuses them with guidance
|
|
34
|
+
// rather than serving a module the browser silently fails to resolve.
|
|
35
|
+
const RELATIVE_IMPORT_RE =
|
|
36
|
+
/(?:^|\n)\s*(?:import|export)[^;\n]*?from\s*['"](\.\.?\/[^'"]+)['"]/g;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Lazily resolve `sucrase`'s `transform`. Kept out of the module's static
|
|
40
|
+
* imports so the published SDK (runtime + types) never forces the dependency;
|
|
41
|
+
* only `appstudio-widget dev` reaches this path. Throws a friendly install
|
|
42
|
+
* hint when the package isn't present.
|
|
43
|
+
*
|
|
44
|
+
* @returns {Promise<(code: string, opts: object) => { code: string }>}
|
|
45
|
+
*/
|
|
46
|
+
export async function loadTransform() {
|
|
47
|
+
try {
|
|
48
|
+
const mod = await import("sucrase");
|
|
49
|
+
return mod.transform;
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"`appstudio-widget dev` needs the optional `sucrase` dependency.\n" +
|
|
53
|
+
"Install it in your widget project:\n" +
|
|
54
|
+
" npm install --save-dev sucrase",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Transpile a widget source string JSX→JS using the SAME options the publish
|
|
61
|
+
* path uses, so the dev bundle and the published bundle are transpiled
|
|
62
|
+
* identically (automatic jsx-runtime form the loader shims).
|
|
63
|
+
*
|
|
64
|
+
* @param {(code: string, opts: object) => { code: string }} transform
|
|
65
|
+
* @param {string} source
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function transpile(transform, source) {
|
|
69
|
+
return transform(source, {
|
|
70
|
+
transforms: ["jsx"],
|
|
71
|
+
jsxRuntime: "automatic",
|
|
72
|
+
production: true,
|
|
73
|
+
}).code;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Find relative (`./` / `../`) import specifiers in a source string. Used to
|
|
78
|
+
* reject split-impl bundles in v1 with a clear message instead of serving a
|
|
79
|
+
* module the browser can't resolve.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} source
|
|
82
|
+
* @returns {string[]} unique relative specifiers
|
|
83
|
+
*/
|
|
84
|
+
export function findRelativeImports(source) {
|
|
85
|
+
const out = new Set();
|
|
86
|
+
let m;
|
|
87
|
+
RELATIVE_IMPORT_RE.lastIndex = 0;
|
|
88
|
+
while ((m = RELATIVE_IMPORT_RE.exec(source))) out.add(m[1]);
|
|
89
|
+
return Array.from(out);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Best-effort manifest resolution for bare-component bundles (the loader's
|
|
94
|
+
* shape B, where the manifest does NOT ride in the bundle). Marketplace
|
|
95
|
+
* widgets authored with `defineWidget({ manifest, component })` (shape A)
|
|
96
|
+
* carry their manifest inside the bundle and don't need this — so a missing
|
|
97
|
+
* manifest file is not an error here, just `null`.
|
|
98
|
+
*
|
|
99
|
+
* Resolution order: explicit `--manifest` path, then a sibling
|
|
100
|
+
* `appstudio.manifest.json` / `manifest.json` next to the entry.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} entryPath absolute path to the widget entry
|
|
103
|
+
* @param {string|null} manifestPath explicit `--manifest` path (or null)
|
|
104
|
+
* @returns {object|null} parsed manifest, or null when none found
|
|
105
|
+
*/
|
|
106
|
+
export function resolveManifest(entryPath, manifestPath) {
|
|
107
|
+
const candidates = [];
|
|
108
|
+
if (manifestPath) candidates.push(resolve(manifestPath));
|
|
109
|
+
const dir = dirname(entryPath);
|
|
110
|
+
candidates.push(join(dir, "appstudio.manifest.json"));
|
|
111
|
+
candidates.push(join(dir, "manifest.json"));
|
|
112
|
+
for (const p of candidates) {
|
|
113
|
+
if (existsSync(p)) {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
116
|
+
} catch (err) {
|
|
117
|
+
throw new Error(`Could not parse manifest at ${p}: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function setCors(res) {
|
|
125
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
126
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
127
|
+
res.setHeader("Access-Control-Allow-Headers", "*");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create the dev HTTP server (without listening). Exposed separately from
|
|
132
|
+
* `startDevServer` so tests can inject a `transform` and listen on an
|
|
133
|
+
* ephemeral port. The returned object carries the `http.Server`, a
|
|
134
|
+
* `broadcastReload()` the watcher calls, and a `manifestId` getter for the
|
|
135
|
+
* health route + CLI banner.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} opts
|
|
138
|
+
* @param {string} opts.entryPath absolute path to the widget entry
|
|
139
|
+
* @param {string|null} [opts.manifestPath] explicit `--manifest` path
|
|
140
|
+
* @param {(code: string, opts: object) => { code: string }} opts.transform
|
|
141
|
+
* @param {(msg: string) => void} [opts.onLint] receives lint summary lines
|
|
142
|
+
*/
|
|
143
|
+
export function createDevServer({ entryPath, manifestPath = null, transform, onLint }) {
|
|
144
|
+
if (!transform) throw new Error("createDevServer: transform is required");
|
|
145
|
+
const entryDir = dirname(entryPath);
|
|
146
|
+
const sseClients = new Set();
|
|
147
|
+
let manifest = null;
|
|
148
|
+
try {
|
|
149
|
+
manifest = resolveManifest(entryPath, manifestPath);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
// A malformed manifest file shouldn't crash the server — surface it and
|
|
152
|
+
// serve shape-A bundles (manifest in the bundle) regardless.
|
|
153
|
+
if (onLint) onLint(`manifest: ${err.message}`);
|
|
154
|
+
}
|
|
155
|
+
const manifestId = manifest?.id || null;
|
|
156
|
+
|
|
157
|
+
function readEntry() {
|
|
158
|
+
return readFileSync(entryPath, "utf8");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function serveWidget(res) {
|
|
162
|
+
let source;
|
|
163
|
+
try {
|
|
164
|
+
source = readEntry();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
167
|
+
res.end(`Could not read entry ${entryPath}: ${err.message}`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const relatives = findRelativeImports(source);
|
|
171
|
+
if (relatives.length > 0) {
|
|
172
|
+
const msg =
|
|
173
|
+
`Entry imports relative modules (${relatives.join(", ")}). ` +
|
|
174
|
+
"v1 of `appstudio-widget dev` serves a single-file entry only — " +
|
|
175
|
+
"bundle split-impl widgets with a real build step (esbuild/rollup) " +
|
|
176
|
+
"and load the built widget.mjs, or inline the helpers into the entry.";
|
|
177
|
+
res.writeHead(422, { "Content-Type": "text/plain" });
|
|
178
|
+
res.end(msg);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
let code;
|
|
182
|
+
try {
|
|
183
|
+
code = transpile(transform, source);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
// Surface the transpile error as the module body so the browser console
|
|
186
|
+
// (and the Builder dev panel) shows the real syntax error, not an opaque
|
|
187
|
+
// "failed to fetch dynamically imported module".
|
|
188
|
+
const safe = JSON.stringify(String(err.message || err));
|
|
189
|
+
res.writeHead(200, { "Content-Type": "text/javascript" });
|
|
190
|
+
res.end(`throw new Error(${safe});`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
res.writeHead(200, { "Content-Type": "text/javascript" });
|
|
194
|
+
res.end(code);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function serveManifest(res) {
|
|
198
|
+
// Re-read so an edited sibling manifest.json is picked up live.
|
|
199
|
+
let m = null;
|
|
200
|
+
try {
|
|
201
|
+
m = resolveManifest(entryPath, manifestPath);
|
|
202
|
+
} catch {
|
|
203
|
+
m = null;
|
|
204
|
+
}
|
|
205
|
+
if (!m) {
|
|
206
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
207
|
+
res.end(JSON.stringify({ code: "MANIFEST_NOT_FOUND" }));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
211
|
+
res.end(JSON.stringify(m));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function serveEvents(req, res) {
|
|
215
|
+
res.writeHead(200, {
|
|
216
|
+
"Content-Type": "text/event-stream",
|
|
217
|
+
"Cache-Control": "no-cache",
|
|
218
|
+
Connection: "keep-alive",
|
|
219
|
+
"Access-Control-Allow-Origin": "*",
|
|
220
|
+
});
|
|
221
|
+
res.write(": connected\n\n");
|
|
222
|
+
sseClients.add(res);
|
|
223
|
+
req.on("close", () => sseClients.delete(res));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const server = createServer((req, res) => {
|
|
227
|
+
setCors(res);
|
|
228
|
+
if (req.method === "OPTIONS") {
|
|
229
|
+
res.writeHead(204);
|
|
230
|
+
res.end();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const url = (req.url || "/").split("?")[0];
|
|
234
|
+
if (url === "/widget.mjs") return serveWidget(res);
|
|
235
|
+
if (url === "/manifest.json") return serveManifest(res);
|
|
236
|
+
if (url === "/__dev/events") return serveEvents(req, res);
|
|
237
|
+
if (url === "/__dev/health") {
|
|
238
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
239
|
+
res.end(JSON.stringify({ ok: true, manifestId }));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
243
|
+
res.end("Not found");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
function broadcastReload() {
|
|
247
|
+
for (const res of sseClients) {
|
|
248
|
+
try {
|
|
249
|
+
res.write("event: reload\ndata: {}\n\n");
|
|
250
|
+
} catch {
|
|
251
|
+
sseClients.delete(res);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function lintEntry() {
|
|
257
|
+
let source;
|
|
258
|
+
try {
|
|
259
|
+
source = readEntry();
|
|
260
|
+
} catch {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const { ok, findings } = lintSource(source);
|
|
264
|
+
if (onLint) {
|
|
265
|
+
if (ok) onLint("lint: clean");
|
|
266
|
+
else {
|
|
267
|
+
onLint(`lint: ${findings.length} finding(s)`);
|
|
268
|
+
for (const f of findings) onLint(` [${f.rule}] line ${f.line}: ${f.label}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
server,
|
|
275
|
+
broadcastReload,
|
|
276
|
+
lintEntry,
|
|
277
|
+
entryDir,
|
|
278
|
+
get manifestId() {
|
|
279
|
+
return manifest?.id || null;
|
|
280
|
+
},
|
|
281
|
+
sseClientCount() {
|
|
282
|
+
return sseClients.size;
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolve sucrase, create the dev server, start listening, watch the entry's
|
|
289
|
+
* directory, and wire change → re-lint + reload broadcast. Returns the running
|
|
290
|
+
* dev-server handle. The caller (the CLI) owns logging the banner.
|
|
291
|
+
*
|
|
292
|
+
* @param {object} opts
|
|
293
|
+
* @param {string} opts.entry path to the widget entry (relative or absolute)
|
|
294
|
+
* @param {number} [opts.port]
|
|
295
|
+
* @param {string|null} [opts.manifest] explicit manifest path
|
|
296
|
+
* @param {(line: string) => void} [opts.log]
|
|
297
|
+
* @returns {Promise<{ server: import("node:http").Server, port: number, url: string, manifestId: string|null, close: () => Promise<void> }>}
|
|
298
|
+
*/
|
|
299
|
+
export async function startDevServer({ entry, port = 4400, manifest = null, log = () => {} }) {
|
|
300
|
+
const entryPath = resolve(entry);
|
|
301
|
+
if (!existsSync(entryPath)) {
|
|
302
|
+
throw new Error(`Entry not found: ${entryPath}`);
|
|
303
|
+
}
|
|
304
|
+
const transform = await loadTransform();
|
|
305
|
+
const dev = createDevServer({
|
|
306
|
+
entryPath,
|
|
307
|
+
manifestPath: manifest,
|
|
308
|
+
transform,
|
|
309
|
+
onLint: log,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await new Promise((res, rej) => {
|
|
313
|
+
dev.server.once("error", rej);
|
|
314
|
+
dev.server.listen(port, "127.0.0.1", res);
|
|
315
|
+
});
|
|
316
|
+
const actualPort = dev.server.address().port;
|
|
317
|
+
// Advertise 127.0.0.1, not "localhost": the server binds the IPv4 loopback,
|
|
318
|
+
// but "localhost" resolves to IPv6 (::1) first on Windows, so a browser/CLI
|
|
319
|
+
// hitting http://localhost:<port> would fail to connect. 127.0.0.1 is
|
|
320
|
+
// unambiguous, loopback-only (not LAN-exposed), and works cross-platform.
|
|
321
|
+
const url = `http://127.0.0.1:${actualPort}`;
|
|
322
|
+
|
|
323
|
+
// Watch the entry's directory (non-recursive — reliable cross-platform).
|
|
324
|
+
// Debounce bursts (editors emit multiple events per save) before linting +
|
|
325
|
+
// broadcasting a single reload.
|
|
326
|
+
let timer = null;
|
|
327
|
+
const watcher = watch(dev.entryDir, (_event, filename) => {
|
|
328
|
+
if (filename && !/\.(jsx?|json)$/.test(String(filename))) return;
|
|
329
|
+
if (timer) clearTimeout(timer);
|
|
330
|
+
timer = setTimeout(() => {
|
|
331
|
+
timer = null;
|
|
332
|
+
dev.lintEntry();
|
|
333
|
+
dev.broadcastReload();
|
|
334
|
+
log(`reload → ${dev.sseClientCount()} client(s)`);
|
|
335
|
+
}, 60);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Guard against the watched path not existing under some sandboxes.
|
|
339
|
+
watcher.on("error", (err) => log(`watch error: ${err.message}`));
|
|
340
|
+
|
|
341
|
+
// Initial lint pass so the author sees findings immediately.
|
|
342
|
+
dev.lintEntry();
|
|
343
|
+
|
|
344
|
+
async function close() {
|
|
345
|
+
watcher.close();
|
|
346
|
+
await new Promise((res) => dev.server.close(res));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return { server: dev.server, port: actualPort, url, manifestId: dev.manifestId, close };
|
|
350
|
+
}
|
package/dist/linter.cjs
CHANGED
|
@@ -54,6 +54,149 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
|
|
|
54
54
|
_ruleForIdentifier(b.identifier, b.reason),
|
|
55
55
|
);
|
|
56
56
|
|
|
57
|
+
// Replace the *content* of comments and string / template literals with
|
|
58
|
+
// spaces so the banned-identifier scan only ever sees executable code. A
|
|
59
|
+
// banned host-escape identifier (`window`, `document`, `eval`, `process`, …)
|
|
60
|
+
// is only dangerous as a real identifier reference — never as prose in a
|
|
61
|
+
// `//` comment or as character data inside a string — so matching the bare
|
|
62
|
+
// word there is a false positive that blocks an otherwise-clean widget (a
|
|
63
|
+
// comment that reads "the hour window the grid renders" must not trip
|
|
64
|
+
// `no-window`).
|
|
65
|
+
//
|
|
66
|
+
// Newlines are preserved verbatim so reported line numbers still line up
|
|
67
|
+
// with the original source. Template-literal `${ … }` expression holes are
|
|
68
|
+
// left intact: real code lives there and must still be scanned (`${window}`
|
|
69
|
+
// is a genuine escape). Backslash escapes inside strings/templates are
|
|
70
|
+
// consumed so an escaped quote (`"\""`) doesn't end the literal early.
|
|
71
|
+
function _stripNonCode(source) {
|
|
72
|
+
let out = "";
|
|
73
|
+
const n = source.length;
|
|
74
|
+
let mode = "code"; // code | line | block | sq | dq | tmpl
|
|
75
|
+
// Brace depth, plus a stack of the depths at which an enclosing template
|
|
76
|
+
// literal resumes — lets a `${ … }` hole (which may itself contain `{}`,
|
|
77
|
+
// strings, or nested templates) be told apart from the literal text.
|
|
78
|
+
let braceDepth = 0;
|
|
79
|
+
const tmplStack = [];
|
|
80
|
+
const keep = (ch) => {
|
|
81
|
+
out += ch;
|
|
82
|
+
};
|
|
83
|
+
const blank = (ch) => {
|
|
84
|
+
out += ch === "\n" || ch === "\r" ? ch : " ";
|
|
85
|
+
};
|
|
86
|
+
let i = 0;
|
|
87
|
+
while (i < n) {
|
|
88
|
+
const ch = source[i];
|
|
89
|
+
const nx = source[i + 1];
|
|
90
|
+
if (mode === "code") {
|
|
91
|
+
if (ch === "/" && nx === "/") {
|
|
92
|
+
mode = "line";
|
|
93
|
+
blank(ch);
|
|
94
|
+
blank(nx);
|
|
95
|
+
i += 2;
|
|
96
|
+
} else if (ch === "/" && nx === "*") {
|
|
97
|
+
mode = "block";
|
|
98
|
+
blank(ch);
|
|
99
|
+
blank(nx);
|
|
100
|
+
i += 2;
|
|
101
|
+
} else if (ch === "'") {
|
|
102
|
+
mode = "sq";
|
|
103
|
+
blank(ch);
|
|
104
|
+
i += 1;
|
|
105
|
+
} else if (ch === '"') {
|
|
106
|
+
mode = "dq";
|
|
107
|
+
blank(ch);
|
|
108
|
+
i += 1;
|
|
109
|
+
} else if (ch === "`") {
|
|
110
|
+
mode = "tmpl";
|
|
111
|
+
blank(ch);
|
|
112
|
+
i += 1;
|
|
113
|
+
} else if (ch === "{") {
|
|
114
|
+
braceDepth += 1;
|
|
115
|
+
keep(ch);
|
|
116
|
+
i += 1;
|
|
117
|
+
} else if (ch === "}") {
|
|
118
|
+
braceDepth -= 1;
|
|
119
|
+
if (
|
|
120
|
+
tmplStack.length > 0 &&
|
|
121
|
+
tmplStack[tmplStack.length - 1] === braceDepth
|
|
122
|
+
) {
|
|
123
|
+
tmplStack.pop();
|
|
124
|
+
mode = "tmpl";
|
|
125
|
+
blank(ch);
|
|
126
|
+
} else {
|
|
127
|
+
keep(ch);
|
|
128
|
+
}
|
|
129
|
+
i += 1;
|
|
130
|
+
} else {
|
|
131
|
+
keep(ch);
|
|
132
|
+
i += 1;
|
|
133
|
+
}
|
|
134
|
+
} else if (mode === "line") {
|
|
135
|
+
if (ch === "\n") {
|
|
136
|
+
mode = "code";
|
|
137
|
+
keep(ch);
|
|
138
|
+
} else {
|
|
139
|
+
blank(ch);
|
|
140
|
+
}
|
|
141
|
+
i += 1;
|
|
142
|
+
} else if (mode === "block") {
|
|
143
|
+
if (ch === "*" && nx === "/") {
|
|
144
|
+
mode = "code";
|
|
145
|
+
blank(ch);
|
|
146
|
+
blank(nx);
|
|
147
|
+
i += 2;
|
|
148
|
+
} else {
|
|
149
|
+
blank(ch);
|
|
150
|
+
i += 1;
|
|
151
|
+
}
|
|
152
|
+
} else if (mode === "sq" || mode === "dq") {
|
|
153
|
+
const quote = mode === "sq" ? "'" : '"';
|
|
154
|
+
if (ch === "\\") {
|
|
155
|
+
blank(ch);
|
|
156
|
+
if (i + 1 < n) blank(nx);
|
|
157
|
+
i += 2;
|
|
158
|
+
} else if (ch === quote) {
|
|
159
|
+
mode = "code";
|
|
160
|
+
blank(ch);
|
|
161
|
+
i += 1;
|
|
162
|
+
} else if (ch === "\n") {
|
|
163
|
+
// A bare newline terminates an unterminated string in JS; bail back
|
|
164
|
+
// to code so malformed input can't blank the rest of the file.
|
|
165
|
+
mode = "code";
|
|
166
|
+
keep(ch);
|
|
167
|
+
i += 1;
|
|
168
|
+
} else {
|
|
169
|
+
blank(ch);
|
|
170
|
+
i += 1;
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
// mode === "tmpl"
|
|
174
|
+
if (ch === "\\") {
|
|
175
|
+
blank(ch);
|
|
176
|
+
if (i + 1 < n) blank(nx);
|
|
177
|
+
i += 2;
|
|
178
|
+
} else if (ch === "`") {
|
|
179
|
+
mode = "code";
|
|
180
|
+
blank(ch);
|
|
181
|
+
i += 1;
|
|
182
|
+
} else if (ch === "$" && nx === "{") {
|
|
183
|
+
// Enter an expression hole. Remember the brace depth the template
|
|
184
|
+
// resumes at, then count the `{` so its matching `}` is recognised.
|
|
185
|
+
tmplStack.push(braceDepth);
|
|
186
|
+
braceDepth += 1;
|
|
187
|
+
mode = "code";
|
|
188
|
+
keep(ch);
|
|
189
|
+
keep(nx);
|
|
190
|
+
i += 2;
|
|
191
|
+
} else {
|
|
192
|
+
blank(ch);
|
|
193
|
+
i += 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
57
200
|
// REQ-WSDK-PLATFORM: `no-axios-import` is GONE. axios is on the vetted
|
|
58
201
|
// import list now (`CONTRACT.vettedImports`). See linter.js for the
|
|
59
202
|
// source-of-truth comment.
|
|
@@ -196,7 +339,12 @@ function _hostApiUrlRules(source) {
|
|
|
196
339
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation. See
|
|
197
340
|
// linter.js for the rationale comment. The two files must stay in
|
|
198
341
|
// lockstep (the contract test asserts behaviour-equivalence).
|
|
199
|
-
const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate"
|
|
342
|
+
const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate"];
|
|
343
|
+
// SC-902 — destructive user removal is gated on the dedicated
|
|
344
|
+
// `users.delete` capability, NOT `users.write`. A widget that calls
|
|
345
|
+
// useUsers().remove() must declare `users.delete:*` so the static contract
|
|
346
|
+
// matches the backend gate on DELETE /api/v1/app/users/:userId.
|
|
347
|
+
const USER_DELETE_METHODS = ["remove"];
|
|
200
348
|
const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
|
|
201
349
|
const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
|
|
202
350
|
|
|
@@ -238,6 +386,7 @@ function _scopeRules(source, manifest) {
|
|
|
238
386
|
|
|
239
387
|
const lines = source.split(/\r?\n/);
|
|
240
388
|
let firedUsersWrite = false;
|
|
389
|
+
let firedUsersDelete = false;
|
|
241
390
|
let firedGroupsWrite = false;
|
|
242
391
|
for (let i = 0; i < lines.length; i++) {
|
|
243
392
|
const line = lines[i];
|
|
@@ -262,6 +411,28 @@ function _scopeRules(source, manifest) {
|
|
|
262
411
|
}
|
|
263
412
|
}
|
|
264
413
|
}
|
|
414
|
+
// SC-902 — destructive removal needs `users.delete:*`, separately from
|
|
415
|
+
// the edit-style write scope above.
|
|
416
|
+
if (usesUsersHook && !firedUsersDelete) {
|
|
417
|
+
for (const m of USER_DELETE_METHODS) {
|
|
418
|
+
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
419
|
+
if (re.test(line)) {
|
|
420
|
+
if (
|
|
421
|
+
!declared.has("users.delete:*") &&
|
|
422
|
+
!declared.has("users.delete")
|
|
423
|
+
) {
|
|
424
|
+
findings.push({
|
|
425
|
+
rule: "scope-required-for-user-delete",
|
|
426
|
+
label: `useUsers().${m}() requires \`users.delete:*\` in manifest.requestedScopes`,
|
|
427
|
+
line: i + 1,
|
|
428
|
+
snippet: line.trim().slice(0, 200),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
firedUsersDelete = true;
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
265
436
|
if (usesGroupsHook && !firedGroupsWrite) {
|
|
266
437
|
for (const m of GROUP_MUTATION_METHODS) {
|
|
267
438
|
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
@@ -359,16 +530,22 @@ function lintSource(source, options) {
|
|
|
359
530
|
}
|
|
360
531
|
const findings = [];
|
|
361
532
|
const lines = source.split(/\r?\n/);
|
|
533
|
+
// Scan code with comments + string/template text blanked out so a banned
|
|
534
|
+
// identifier only fires on an actual code reference, not on the same word
|
|
535
|
+
// appearing in prose or string data. `codeLines` lines up 1:1 with `lines`
|
|
536
|
+
// (masking preserves newlines), so the reported snippet still comes from
|
|
537
|
+
// the original source.
|
|
538
|
+
const codeLines = _stripNonCode(source).split(/\r?\n/);
|
|
362
539
|
for (let i = 0; i < lines.length; i++) {
|
|
363
|
-
const
|
|
540
|
+
const codeLine = codeLines[i];
|
|
364
541
|
for (const rule of RULES) {
|
|
365
|
-
if (rule.pattern.test(
|
|
542
|
+
if (rule.pattern.test(codeLine)) {
|
|
366
543
|
findings.push({
|
|
367
544
|
rule: rule.id,
|
|
368
545
|
severity: "error",
|
|
369
546
|
label: rule.label,
|
|
370
547
|
line: i + 1,
|
|
371
|
-
snippet:
|
|
548
|
+
snippet: lines[i].trim().slice(0, 200),
|
|
372
549
|
});
|
|
373
550
|
}
|
|
374
551
|
}
|
package/dist/linter.js
CHANGED
|
@@ -55,6 +55,149 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
|
|
|
55
55
|
_ruleForIdentifier(b.identifier, b.reason),
|
|
56
56
|
);
|
|
57
57
|
|
|
58
|
+
// Replace the *content* of comments and string / template literals with
|
|
59
|
+
// spaces so the banned-identifier scan only ever sees executable code. A
|
|
60
|
+
// banned host-escape identifier (`window`, `document`, `eval`, `process`, …)
|
|
61
|
+
// is only dangerous as a real identifier reference — never as prose in a
|
|
62
|
+
// `//` comment or as character data inside a string — so matching the bare
|
|
63
|
+
// word there is a false positive that blocks an otherwise-clean widget (a
|
|
64
|
+
// comment that reads "the hour window the grid renders" must not trip
|
|
65
|
+
// `no-window`).
|
|
66
|
+
//
|
|
67
|
+
// Newlines are preserved verbatim so reported line numbers still line up
|
|
68
|
+
// with the original source. Template-literal `${ … }` expression holes are
|
|
69
|
+
// left intact: real code lives there and must still be scanned (`${window}`
|
|
70
|
+
// is a genuine escape). Backslash escapes inside strings/templates are
|
|
71
|
+
// consumed so an escaped quote (`"\""`) doesn't end the literal early.
|
|
72
|
+
function _stripNonCode(source) {
|
|
73
|
+
let out = "";
|
|
74
|
+
const n = source.length;
|
|
75
|
+
let mode = "code"; // code | line | block | sq | dq | tmpl
|
|
76
|
+
// Brace depth, plus a stack of the depths at which an enclosing template
|
|
77
|
+
// literal resumes — lets a `${ … }` hole (which may itself contain `{}`,
|
|
78
|
+
// strings, or nested templates) be told apart from the literal text.
|
|
79
|
+
let braceDepth = 0;
|
|
80
|
+
const tmplStack = [];
|
|
81
|
+
const keep = (ch) => {
|
|
82
|
+
out += ch;
|
|
83
|
+
};
|
|
84
|
+
const blank = (ch) => {
|
|
85
|
+
out += ch === "\n" || ch === "\r" ? ch : " ";
|
|
86
|
+
};
|
|
87
|
+
let i = 0;
|
|
88
|
+
while (i < n) {
|
|
89
|
+
const ch = source[i];
|
|
90
|
+
const nx = source[i + 1];
|
|
91
|
+
if (mode === "code") {
|
|
92
|
+
if (ch === "/" && nx === "/") {
|
|
93
|
+
mode = "line";
|
|
94
|
+
blank(ch);
|
|
95
|
+
blank(nx);
|
|
96
|
+
i += 2;
|
|
97
|
+
} else if (ch === "/" && nx === "*") {
|
|
98
|
+
mode = "block";
|
|
99
|
+
blank(ch);
|
|
100
|
+
blank(nx);
|
|
101
|
+
i += 2;
|
|
102
|
+
} else if (ch === "'") {
|
|
103
|
+
mode = "sq";
|
|
104
|
+
blank(ch);
|
|
105
|
+
i += 1;
|
|
106
|
+
} else if (ch === '"') {
|
|
107
|
+
mode = "dq";
|
|
108
|
+
blank(ch);
|
|
109
|
+
i += 1;
|
|
110
|
+
} else if (ch === "`") {
|
|
111
|
+
mode = "tmpl";
|
|
112
|
+
blank(ch);
|
|
113
|
+
i += 1;
|
|
114
|
+
} else if (ch === "{") {
|
|
115
|
+
braceDepth += 1;
|
|
116
|
+
keep(ch);
|
|
117
|
+
i += 1;
|
|
118
|
+
} else if (ch === "}") {
|
|
119
|
+
braceDepth -= 1;
|
|
120
|
+
if (
|
|
121
|
+
tmplStack.length > 0 &&
|
|
122
|
+
tmplStack[tmplStack.length - 1] === braceDepth
|
|
123
|
+
) {
|
|
124
|
+
tmplStack.pop();
|
|
125
|
+
mode = "tmpl";
|
|
126
|
+
blank(ch);
|
|
127
|
+
} else {
|
|
128
|
+
keep(ch);
|
|
129
|
+
}
|
|
130
|
+
i += 1;
|
|
131
|
+
} else {
|
|
132
|
+
keep(ch);
|
|
133
|
+
i += 1;
|
|
134
|
+
}
|
|
135
|
+
} else if (mode === "line") {
|
|
136
|
+
if (ch === "\n") {
|
|
137
|
+
mode = "code";
|
|
138
|
+
keep(ch);
|
|
139
|
+
} else {
|
|
140
|
+
blank(ch);
|
|
141
|
+
}
|
|
142
|
+
i += 1;
|
|
143
|
+
} else if (mode === "block") {
|
|
144
|
+
if (ch === "*" && nx === "/") {
|
|
145
|
+
mode = "code";
|
|
146
|
+
blank(ch);
|
|
147
|
+
blank(nx);
|
|
148
|
+
i += 2;
|
|
149
|
+
} else {
|
|
150
|
+
blank(ch);
|
|
151
|
+
i += 1;
|
|
152
|
+
}
|
|
153
|
+
} else if (mode === "sq" || mode === "dq") {
|
|
154
|
+
const quote = mode === "sq" ? "'" : '"';
|
|
155
|
+
if (ch === "\\") {
|
|
156
|
+
blank(ch);
|
|
157
|
+
if (i + 1 < n) blank(nx);
|
|
158
|
+
i += 2;
|
|
159
|
+
} else if (ch === quote) {
|
|
160
|
+
mode = "code";
|
|
161
|
+
blank(ch);
|
|
162
|
+
i += 1;
|
|
163
|
+
} else if (ch === "\n") {
|
|
164
|
+
// A bare newline terminates an unterminated string in JS; bail back
|
|
165
|
+
// to code so malformed input can't blank the rest of the file.
|
|
166
|
+
mode = "code";
|
|
167
|
+
keep(ch);
|
|
168
|
+
i += 1;
|
|
169
|
+
} else {
|
|
170
|
+
blank(ch);
|
|
171
|
+
i += 1;
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
// mode === "tmpl"
|
|
175
|
+
if (ch === "\\") {
|
|
176
|
+
blank(ch);
|
|
177
|
+
if (i + 1 < n) blank(nx);
|
|
178
|
+
i += 2;
|
|
179
|
+
} else if (ch === "`") {
|
|
180
|
+
mode = "code";
|
|
181
|
+
blank(ch);
|
|
182
|
+
i += 1;
|
|
183
|
+
} else if (ch === "$" && nx === "{") {
|
|
184
|
+
// Enter an expression hole. Remember the brace depth the template
|
|
185
|
+
// resumes at, then count the `{` so its matching `}` is recognised.
|
|
186
|
+
tmplStack.push(braceDepth);
|
|
187
|
+
braceDepth += 1;
|
|
188
|
+
mode = "code";
|
|
189
|
+
keep(ch);
|
|
190
|
+
keep(nx);
|
|
191
|
+
i += 2;
|
|
192
|
+
} else {
|
|
193
|
+
blank(ch);
|
|
194
|
+
i += 1;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
58
201
|
// Extra rules that don't map 1:1 to a banned identifier in the contract:
|
|
59
202
|
// host-internal imports that widgets must never touch.
|
|
60
203
|
//
|
|
@@ -124,9 +267,7 @@ function _classifySpecifier(spec) {
|
|
|
124
267
|
|
|
125
268
|
function _importRules(source, manifest) {
|
|
126
269
|
const findings = [];
|
|
127
|
-
const allowed = new Map(
|
|
128
|
-
CONTRACT.vettedImports.map((v) => [v.specifier, v]),
|
|
129
|
-
);
|
|
270
|
+
const allowed = new Map(CONTRACT.vettedImports.map((v) => [v.specifier, v]));
|
|
130
271
|
// Track declared `supportedPlatforms` so a widget that claims "web only"
|
|
131
272
|
// doesn't import a native-only package (and vice versa) without the
|
|
132
273
|
// marketplace listing being honest about which platforms ship.
|
|
@@ -252,8 +393,18 @@ function _hostApiUrlRules(source) {
|
|
|
252
393
|
// AST-free; an author whose code happened to spell `.invite(` for an
|
|
253
394
|
// unrelated reason can opt out with a `// @appstudio-skip-scope-check`
|
|
254
395
|
// trailing comment on the offending line.
|
|
255
|
-
const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate"
|
|
256
|
-
|
|
396
|
+
const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate"];
|
|
397
|
+
// SC-902 — destructive user removal is gated on the dedicated
|
|
398
|
+
// `users.delete` capability, NOT `users.write`. A widget that calls
|
|
399
|
+
// useUsers().remove() must declare `users.delete:*` so the static contract
|
|
400
|
+
// matches the backend gate on DELETE /api/v1/app/users/:userId.
|
|
401
|
+
const USER_DELETE_METHODS = ["remove"];
|
|
402
|
+
const GROUP_MUTATION_METHODS = [
|
|
403
|
+
"create",
|
|
404
|
+
"remove",
|
|
405
|
+
"addMember",
|
|
406
|
+
"removeMember",
|
|
407
|
+
];
|
|
257
408
|
const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
|
|
258
409
|
|
|
259
410
|
function _scopeRules(source, manifest) {
|
|
@@ -272,8 +423,7 @@ function _scopeRules(source, manifest) {
|
|
|
272
423
|
if (!reads) {
|
|
273
424
|
findings.push({
|
|
274
425
|
rule: "scope-required-for-useUsers",
|
|
275
|
-
label:
|
|
276
|
-
"useUsers() requires `users.read:*` in manifest.requestedScopes",
|
|
426
|
+
label: "useUsers() requires `users.read:*` in manifest.requestedScopes",
|
|
277
427
|
line: 0,
|
|
278
428
|
snippet: "",
|
|
279
429
|
});
|
|
@@ -294,6 +444,7 @@ function _scopeRules(source, manifest) {
|
|
|
294
444
|
|
|
295
445
|
const lines = source.split(/\r?\n/);
|
|
296
446
|
let firedUsersWrite = false;
|
|
447
|
+
let firedUsersDelete = false;
|
|
297
448
|
let firedGroupsWrite = false;
|
|
298
449
|
for (let i = 0; i < lines.length; i++) {
|
|
299
450
|
const line = lines[i];
|
|
@@ -302,10 +453,7 @@ function _scopeRules(source, manifest) {
|
|
|
302
453
|
for (const m of USER_MUTATION_METHODS) {
|
|
303
454
|
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
304
455
|
if (re.test(line)) {
|
|
305
|
-
if (
|
|
306
|
-
!declared.has("users.write:*") &&
|
|
307
|
-
!declared.has("users.write")
|
|
308
|
-
) {
|
|
456
|
+
if (!declared.has("users.write:*") && !declared.has("users.write")) {
|
|
309
457
|
findings.push({
|
|
310
458
|
rule: "scope-required-for-user-mutation",
|
|
311
459
|
label: `useUsers().${m}() requires \`users.write:*\` in manifest.requestedScopes`,
|
|
@@ -318,6 +466,28 @@ function _scopeRules(source, manifest) {
|
|
|
318
466
|
}
|
|
319
467
|
}
|
|
320
468
|
}
|
|
469
|
+
// SC-902 — destructive removal needs `users.delete:*`, separately from
|
|
470
|
+
// the edit-style write scope above.
|
|
471
|
+
if (usesUsersHook && !firedUsersDelete) {
|
|
472
|
+
for (const m of USER_DELETE_METHODS) {
|
|
473
|
+
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
474
|
+
if (re.test(line)) {
|
|
475
|
+
if (
|
|
476
|
+
!declared.has("users.delete:*") &&
|
|
477
|
+
!declared.has("users.delete")
|
|
478
|
+
) {
|
|
479
|
+
findings.push({
|
|
480
|
+
rule: "scope-required-for-user-delete",
|
|
481
|
+
label: `useUsers().${m}() requires \`users.delete:*\` in manifest.requestedScopes`,
|
|
482
|
+
line: i + 1,
|
|
483
|
+
snippet: line.trim().slice(0, 200),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
firedUsersDelete = true;
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
321
491
|
if (usesGroupsHook && !firedGroupsWrite) {
|
|
322
492
|
for (const m of GROUP_MUTATION_METHODS) {
|
|
323
493
|
const re = new RegExp(`\\.${m}\\s*\\(`);
|
|
@@ -372,7 +542,13 @@ function _manifestActionRules(manifest) {
|
|
|
372
542
|
const validTriggers = new Set(CONTRACT.actionTriggerTypes);
|
|
373
543
|
const maxBytes = CONTRACT.actionScriptMaxBytes;
|
|
374
544
|
const push = (label) =>
|
|
375
|
-
findings.push({
|
|
545
|
+
findings.push({
|
|
546
|
+
rule: "manifest-action",
|
|
547
|
+
severity: "error",
|
|
548
|
+
label,
|
|
549
|
+
line: 0,
|
|
550
|
+
snippet: "",
|
|
551
|
+
});
|
|
376
552
|
if (!Array.isArray(manifest.actions)) {
|
|
377
553
|
push("manifest.actions must be an array (omit it or use [] for none)");
|
|
378
554
|
return findings;
|
|
@@ -394,9 +570,16 @@ function _manifestActionRules(manifest) {
|
|
|
394
570
|
push("manifest.actions[].name must be a non-empty string");
|
|
395
571
|
}
|
|
396
572
|
if (!validTriggers.has(a.triggerType)) {
|
|
397
|
-
push(
|
|
398
|
-
|
|
399
|
-
|
|
573
|
+
push(
|
|
574
|
+
`manifest.actions[].triggerType must be one of ${[...validTriggers].join(", ")}`,
|
|
575
|
+
);
|
|
576
|
+
} else if (
|
|
577
|
+
a.triggerType === "schedule" &&
|
|
578
|
+
(typeof a.scheduleCron !== "string" || !a.scheduleCron)
|
|
579
|
+
) {
|
|
580
|
+
push(
|
|
581
|
+
"manifest.actions[].scheduleCron is required when triggerType is 'schedule'",
|
|
582
|
+
);
|
|
400
583
|
}
|
|
401
584
|
if (typeof a.scriptSource !== "string" || a.scriptSource.length === 0) {
|
|
402
585
|
push("manifest.actions[].scriptSource must be a non-empty string");
|
|
@@ -410,7 +593,9 @@ function _manifestActionRules(manifest) {
|
|
|
410
593
|
}
|
|
411
594
|
}
|
|
412
595
|
if (a.triggerTableId !== undefined || a.apiKeyId !== undefined) {
|
|
413
|
-
push(
|
|
596
|
+
push(
|
|
597
|
+
"manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install",
|
|
598
|
+
);
|
|
414
599
|
}
|
|
415
600
|
}
|
|
416
601
|
return findings;
|
|
@@ -433,16 +618,22 @@ export function lintSource(source, options) {
|
|
|
433
618
|
}
|
|
434
619
|
const findings = [];
|
|
435
620
|
const lines = source.split(/\r?\n/);
|
|
621
|
+
// Scan code with comments + string/template text blanked out so a banned
|
|
622
|
+
// identifier only fires on an actual code reference, not on the same word
|
|
623
|
+
// appearing in prose or string data. `codeLines` lines up 1:1 with `lines`
|
|
624
|
+
// (masking preserves newlines), so the reported snippet still comes from
|
|
625
|
+
// the original source.
|
|
626
|
+
const codeLines = _stripNonCode(source).split(/\r?\n/);
|
|
436
627
|
for (let i = 0; i < lines.length; i++) {
|
|
437
|
-
const
|
|
628
|
+
const codeLine = codeLines[i];
|
|
438
629
|
for (const rule of RULES) {
|
|
439
|
-
if (rule.pattern.test(
|
|
630
|
+
if (rule.pattern.test(codeLine)) {
|
|
440
631
|
findings.push({
|
|
441
632
|
rule: rule.id,
|
|
442
633
|
severity: "error",
|
|
443
634
|
label: rule.label,
|
|
444
635
|
line: i + 1,
|
|
445
|
-
snippet:
|
|
636
|
+
snippet: lines[i].trim().slice(0, 200),
|
|
446
637
|
});
|
|
447
638
|
}
|
|
448
639
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "node scripts/build.js",
|
|
38
|
-
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js"
|
|
38
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js"
|
|
39
39
|
},
|
|
40
40
|
"engines": {
|
|
41
41
|
"node": ">=18"
|
|
@@ -65,6 +65,9 @@
|
|
|
65
65
|
"optional": true
|
|
66
66
|
}
|
|
67
67
|
},
|
|
68
|
+
"optionalDependencies": {
|
|
69
|
+
"sucrase": "^3.35.0"
|
|
70
|
+
},
|
|
68
71
|
"keywords": [
|
|
69
72
|
"appstudio",
|
|
70
73
|
"widget",
|