@colixsystems/widget-sdk 0.35.0 → 0.36.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,11 @@ 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.36.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.36.0
53
+
54
+ **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
55
 
52
56
  ### What's new in 0.35.0
53
57
 
@@ -375,7 +379,9 @@ The matching manifest declares the scopes:
375
379
  ```js
376
380
  {
377
381
  // ...
378
- requestedScopes: ["users.read:*", "users.write:*", "groups.read:*", "groups.write:*"],
382
+ // `remove(u.id)` above needs `users.delete:*` (SC-902) — the destructive
383
+ // delete is gated separately from the edit-style `users.write:*`.
384
+ requestedScopes: ["users.read:*", "users.write:*", "users.delete:*", "groups.read:*", "groups.write:*"],
379
385
  }
380
386
  ```
381
387
 
@@ -448,6 +454,38 @@ import { lintSource } from "@colixsystems/widget-sdk/linter";
448
454
  const report = lintSource(source);
449
455
  ```
450
456
 
457
+ ## Local dev loop (`appstudio-widget dev`)
458
+
459
+ Author a marketplace widget with live reload instead of the publish → submit →
460
+ review → install round-trip:
461
+
462
+ ```sh
463
+ appstudio-widget dev path/to/widget.jsx --port 4400
464
+ ```
465
+
466
+ This transpiles the entry (the **same** Sucrase JSX pass the publish path runs)
467
+ and serves it over `http://127.0.0.1:4400`:
468
+
469
+ | Route | Purpose |
470
+ | ----- | ------- |
471
+ | `GET /widget.mjs` | the transpiled entry — an ES module the Studio loads exactly like a published `widget.mjs` |
472
+ | `GET /manifest.json` | the manifest (from `--manifest`, else a sibling `manifest.json`); only consulted for a bare-component bundle |
473
+ | `GET /__dev/events` | Server-Sent-Events stream that emits `reload` on every source change |
474
+ | `GET /__dev/health` | `{ ok, manifestId }` |
475
+
476
+ Then, in the Studio Builder (dev mode only), open the **Dev widgets** panel in
477
+ the palette and paste `http://127.0.0.1:4400`. The widget loads through the
478
+ real runtime loader (same import-rewrite, host-shim, and export-shape gates a
479
+ published bundle hits) and hot-reloads onto the canvas on every save.
480
+
481
+ - **v1 serves a single-file entry.** Split-impl widgets (`widget.web.jsx` +
482
+ `widget.native.jsx` with shared `./relative.jsx` helpers) need a real build
483
+ step; the dev server reports relative imports rather than serving a module
484
+ the browser can't resolve.
485
+ - `dev` lazily requires the optional `sucrase` dependency. In this monorepo it
486
+ is already present; in a standalone widget project run `npm i -D sucrase`.
487
+ - Lint findings print to the terminal on each change but never block serving.
488
+
451
489
  ## Why no TypeScript dependency?
452
490
 
453
491
  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
  },
@@ -1447,7 +1451,15 @@ const CONTRACT = deepFreeze({
1447
1451
  // setVisibility). Both read the existing `ctx.filestore`; the underlying
1448
1452
  // `filestore-client` 0.5.0 made shares folder-scoped + added
1449
1453
  // `signatures.roster`. No existing hook changed.
1450
- version: "1.25.0",
1454
+ //
1455
+ // 1.26.0: additive (SC-902) — new `users.delete:*` scope verb. The
1456
+ // destructive `useUsers().remove()` now requires `users.delete:*` in
1457
+ // requestedScopes (backed by the SystemAcl `users.delete` capability
1458
+ // on DELETE /app/users/:id), separated from the edit-style
1459
+ // `users.write:*`. New linter rule `scope-required-for-user-delete`
1460
+ // flags a widget that calls `.remove()` without declaring the scope.
1461
+ // No hook signature changed; `useUsers().remove` is unchanged.
1462
+ version: "1.26.0",
1451
1463
  hooks: HOOKS,
1452
1464
  primitives: PRIMITIVES,
1453
1465
  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
  },
@@ -1447,7 +1451,15 @@ const CONTRACT = deepFreeze({
1447
1451
  // setVisibility). Both read the existing `ctx.filestore`; the underlying
1448
1452
  // `filestore-client` 0.5.0 made shares folder-scoped + added
1449
1453
  // `signatures.roster`. No existing hook changed.
1450
- version: "1.25.0",
1454
+ //
1455
+ // 1.26.0: additive (SC-902) — new `users.delete:*` scope verb. The
1456
+ // destructive `useUsers().remove()` now requires `users.delete:*` in
1457
+ // requestedScopes (backed by the SystemAcl `users.delete` capability
1458
+ // on DELETE /app/users/:id), separated from the edit-style
1459
+ // `users.write:*`. New linter rule `scope-required-for-user-delete`
1460
+ // flags a widget that calls `.remove()` without declaring the scope.
1461
+ // No hook signature changed; `useUsers().remove` is unchanged.
1462
+ version: "1.26.0",
1451
1463
  hooks: HOOKS,
1452
1464
  primitives: PRIMITIVES,
1453
1465
  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
@@ -196,7 +196,12 @@ function _hostApiUrlRules(source) {
196
196
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation. See
197
197
  // linter.js for the rationale comment. The two files must stay in
198
198
  // lockstep (the contract test asserts behaviour-equivalence).
199
- const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate", "remove"];
199
+ const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate"];
200
+ // SC-902 — destructive user removal is gated on the dedicated
201
+ // `users.delete` capability, NOT `users.write`. A widget that calls
202
+ // useUsers().remove() must declare `users.delete:*` so the static contract
203
+ // matches the backend gate on DELETE /api/v1/app/users/:userId.
204
+ const USER_DELETE_METHODS = ["remove"];
200
205
  const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
201
206
  const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
202
207
 
@@ -238,6 +243,7 @@ function _scopeRules(source, manifest) {
238
243
 
239
244
  const lines = source.split(/\r?\n/);
240
245
  let firedUsersWrite = false;
246
+ let firedUsersDelete = false;
241
247
  let firedGroupsWrite = false;
242
248
  for (let i = 0; i < lines.length; i++) {
243
249
  const line = lines[i];
@@ -262,6 +268,28 @@ function _scopeRules(source, manifest) {
262
268
  }
263
269
  }
264
270
  }
271
+ // SC-902 — destructive removal needs `users.delete:*`, separately from
272
+ // the edit-style write scope above.
273
+ if (usesUsersHook && !firedUsersDelete) {
274
+ for (const m of USER_DELETE_METHODS) {
275
+ const re = new RegExp(`\\.${m}\\s*\\(`);
276
+ if (re.test(line)) {
277
+ if (
278
+ !declared.has("users.delete:*") &&
279
+ !declared.has("users.delete")
280
+ ) {
281
+ findings.push({
282
+ rule: "scope-required-for-user-delete",
283
+ label: `useUsers().${m}() requires \`users.delete:*\` in manifest.requestedScopes`,
284
+ line: i + 1,
285
+ snippet: line.trim().slice(0, 200),
286
+ });
287
+ }
288
+ firedUsersDelete = true;
289
+ break;
290
+ }
291
+ }
292
+ }
265
293
  if (usesGroupsHook && !firedGroupsWrite) {
266
294
  for (const m of GROUP_MUTATION_METHODS) {
267
295
  const re = new RegExp(`\\.${m}\\s*\\(`);
package/dist/linter.js CHANGED
@@ -252,7 +252,12 @@ function _hostApiUrlRules(source) {
252
252
  // AST-free; an author whose code happened to spell `.invite(` for an
253
253
  // unrelated reason can opt out with a `// @appstudio-skip-scope-check`
254
254
  // trailing comment on the offending line.
255
- const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate", "remove"];
255
+ const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate"];
256
+ // SC-902 — destructive user removal is gated on the dedicated
257
+ // `users.delete` capability, NOT `users.write`. A widget that calls
258
+ // useUsers().remove() must declare `users.delete:*` so the static contract
259
+ // matches the backend gate on DELETE /api/v1/app/users/:userId.
260
+ const USER_DELETE_METHODS = ["remove"];
256
261
  const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
257
262
  const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
258
263
 
@@ -294,6 +299,7 @@ function _scopeRules(source, manifest) {
294
299
 
295
300
  const lines = source.split(/\r?\n/);
296
301
  let firedUsersWrite = false;
302
+ let firedUsersDelete = false;
297
303
  let firedGroupsWrite = false;
298
304
  for (let i = 0; i < lines.length; i++) {
299
305
  const line = lines[i];
@@ -318,6 +324,28 @@ function _scopeRules(source, manifest) {
318
324
  }
319
325
  }
320
326
  }
327
+ // SC-902 — destructive removal needs `users.delete:*`, separately from
328
+ // the edit-style write scope above.
329
+ if (usesUsersHook && !firedUsersDelete) {
330
+ for (const m of USER_DELETE_METHODS) {
331
+ const re = new RegExp(`\\.${m}\\s*\\(`);
332
+ if (re.test(line)) {
333
+ if (
334
+ !declared.has("users.delete:*") &&
335
+ !declared.has("users.delete")
336
+ ) {
337
+ findings.push({
338
+ rule: "scope-required-for-user-delete",
339
+ label: `useUsers().${m}() requires \`users.delete:*\` in manifest.requestedScopes`,
340
+ line: i + 1,
341
+ snippet: line.trim().slice(0, 200),
342
+ });
343
+ }
344
+ firedUsersDelete = true;
345
+ break;
346
+ }
347
+ }
348
+ }
321
349
  if (usesGroupsHook && !firedGroupsWrite) {
322
350
  for (const m of GROUP_MUTATION_METHODS) {
323
351
  const re = new RegExp(`\\.${m}\\s*\\(`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.35.0",
3
+ "version": "0.36.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__/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",