@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 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:*` (mutations also `users.write:*`) |
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.35.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**.
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
- requestedScopes: ["users.read:*", "users.write:*", "groups.read:*", "groups.write:*"],
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 lint <path>`.
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("Usage: appstudio-widget lint <path>\n");
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
- const args = argv.slice(2);
15
- if (args.length === 0) usage();
16
- const [cmd, ...rest] = args;
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
- if (cmd === "lint") {
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 additionally need
363
- // `users.write:*`. The `invite` call accepts `{ email, name, groupIds? }`
364
- // and returns the resulting AppUserInvite row (the email is sent by the
365
- // host). Mutating users from a widget requires the corresponding
366
- // SystemAcl `users.write` capability grant in the tenant; widgets that
367
- // only call read methods need only `users.read:*`.
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 need users.write:*. The `invite` call accepts " +
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
- version: "1.25.0",
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 additionally need
363
- // `users.write:*`. The `invite` call accepts `{ email, name, groupIds? }`
364
- // and returns the resulting AppUserInvite row (the email is sent by the
365
- // host). Mutating users from a widget requires the corresponding
366
- // SystemAcl `users.write` capability grant in the tenant; widgets that
367
- // only call read methods need only `users.read:*`.
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 need users.write:*. The `invite` call accepts " +
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
- version: "1.25.0",
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", "remove"];
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 line = lines[i];
540
+ const codeLine = codeLines[i];
364
541
  for (const rule of RULES) {
365
- if (rule.pattern.test(line)) {
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: line.trim().slice(0, 200),
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", "remove"];
256
- const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
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({ rule: "manifest-action", severity: "error", label, line: 0, snippet: "" });
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(`manifest.actions[].triggerType must be one of ${[...validTriggers].join(", ")}`);
398
- } else if (a.triggerType === "schedule" && (typeof a.scheduleCron !== "string" || !a.scheduleCron)) {
399
- push("manifest.actions[].scheduleCron is required when triggerType is 'schedule'");
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("manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install");
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 line = lines[i];
628
+ const codeLine = codeLines[i];
438
629
  for (const rule of RULES) {
439
- if (rule.pattern.test(line)) {
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: line.trim().slice(0, 200),
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.35.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",