@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 +41 -3
- package/dist/cli.js +89 -6
- package/dist/contract.cjs +21 -9
- package/dist/contract.js +21 -9
- package/dist/devserver.js +350 -0
- package/dist/linter.cjs +29 -1
- package/dist/linter.js +29 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
34
34
|
| **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
|
|
35
35
|
| **FILES** (`ctx.files`) | `useFile(id)` | `{ url, file, loading, error, refetch }` | `ctx.files.get` — no scope |
|
|
36
36
|
| **DIRECTORY** (`ctx.directory`) | `useDirectory(query?)` | `{ users, loading, error, refetch }` | `directory.users.list` — `directory.read:users` |
|
|
37
|
-
| **DIRECTORY** | `useUsers(query?)` | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` | `directory.users.*` — `users.read:*` (
|
|
37
|
+
| **DIRECTORY** | `useUsers(query?)` | `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` | `directory.users.*` — `users.read:*` (edits also `users.write:*`; `remove()` also `users.delete:*`) |
|
|
38
38
|
| **DIRECTORY** | `useGroups(query?)` | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
|
|
39
39
|
| **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
|
|
40
40
|
| **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser` |
|
|
@@ -47,7 +47,11 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
47
47
|
|
|
48
48
|
## Status
|
|
49
49
|
|
|
50
|
-
`v0.
|
|
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
|
-
|
|
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
|
|
2
|
+
// CLI entry for `appstudio-widget`.
|
|
3
|
+
// appstudio-widget lint <path> — validate a widget source
|
|
4
|
+
// appstudio-widget dev <entry.jsx> [--port N] [--manifest path]
|
|
5
|
+
// — REQ-WSDK-DEVKIT local dev server
|
|
3
6
|
|
|
4
7
|
import { readFileSync } from "node:fs";
|
|
5
8
|
import { resolve } from "node:path";
|
|
6
9
|
import { argv, exit, stderr, stdout } from "node:process";
|
|
7
10
|
import { lintSource } from "./linter.js";
|
|
11
|
+
import { startDevServer } from "./devserver.js";
|
|
8
12
|
|
|
9
13
|
function usage() {
|
|
10
|
-
stderr.write(
|
|
14
|
+
stderr.write(
|
|
15
|
+
"Usage:\n" +
|
|
16
|
+
" appstudio-widget lint <path>\n" +
|
|
17
|
+
" appstudio-widget dev <entry.jsx> [--port <n>] [--manifest <path>]\n",
|
|
18
|
+
);
|
|
11
19
|
exit(2);
|
|
12
20
|
}
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
// Minimal flag parser: pulls `--key value` / `--key=value` out of the args and
|
|
23
|
+
// returns the remaining positionals. No external dependency.
|
|
24
|
+
function parseFlags(args) {
|
|
25
|
+
const flags = {};
|
|
26
|
+
const positionals = [];
|
|
27
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
28
|
+
const a = args[i];
|
|
29
|
+
if (a.startsWith("--")) {
|
|
30
|
+
const eq = a.indexOf("=");
|
|
31
|
+
if (eq !== -1) {
|
|
32
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
33
|
+
} else {
|
|
34
|
+
const next = args[i + 1];
|
|
35
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
36
|
+
flags[a.slice(2)] = next;
|
|
37
|
+
i += 1;
|
|
38
|
+
} else {
|
|
39
|
+
flags[a.slice(2)] = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
positionals.push(a);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { flags, positionals };
|
|
47
|
+
}
|
|
17
48
|
|
|
18
|
-
|
|
49
|
+
function runLint(rest) {
|
|
19
50
|
if (rest.length === 0) usage();
|
|
20
51
|
const filePath = resolve(rest[0]);
|
|
21
52
|
let source;
|
|
@@ -35,6 +66,58 @@ if (cmd === "lint") {
|
|
|
35
66
|
stderr.write(` [${f.rule}] line ${f.line}: ${f.label}\n ${f.snippet}\n`);
|
|
36
67
|
}
|
|
37
68
|
exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function runDev(rest) {
|
|
72
|
+
const { flags, positionals } = parseFlags(rest);
|
|
73
|
+
if (positionals.length === 0) usage();
|
|
74
|
+
const entry = positionals[0];
|
|
75
|
+
const port = flags.port ? Number(flags.port) : 4400;
|
|
76
|
+
if (Number.isNaN(port)) {
|
|
77
|
+
stderr.write(`Invalid --port: ${flags.port}\n`);
|
|
78
|
+
exit(2);
|
|
79
|
+
}
|
|
80
|
+
const manifest = typeof flags.manifest === "string" ? flags.manifest : null;
|
|
81
|
+
|
|
82
|
+
let handle;
|
|
83
|
+
try {
|
|
84
|
+
handle = await startDevServer({
|
|
85
|
+
entry,
|
|
86
|
+
port,
|
|
87
|
+
manifest,
|
|
88
|
+
log: (line) => stdout.write(` ${line}\n`),
|
|
89
|
+
});
|
|
90
|
+
} catch (err) {
|
|
91
|
+
stderr.write(`${err.message}\n`);
|
|
92
|
+
exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
stdout.write(
|
|
96
|
+
`\nappstudio-widget dev — serving ${resolve(entry)}\n` +
|
|
97
|
+
` bundle: ${handle.url}/widget.mjs\n` +
|
|
98
|
+
` manifest: ${handle.url}/manifest.json\n` +
|
|
99
|
+
` reload: ${handle.url}/__dev/events (SSE)\n` +
|
|
100
|
+
(handle.manifestId ? ` id: ${handle.manifestId}\n` : "") +
|
|
101
|
+
`\nIn the Studio Builder (dev mode), open the "Dev widgets" panel and add:\n` +
|
|
102
|
+
` ${handle.url}\n` +
|
|
103
|
+
`Edits to the entry hot-reload the widget on the canvas. Ctrl+C to stop.\n\n`,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const shutdown = () => {
|
|
107
|
+
handle.close().finally(() => exit(0));
|
|
108
|
+
};
|
|
109
|
+
process.on("SIGINT", shutdown);
|
|
110
|
+
process.on("SIGTERM", shutdown);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const args = argv.slice(2);
|
|
114
|
+
if (args.length === 0) usage();
|
|
115
|
+
const [cmd, ...rest] = args;
|
|
116
|
+
|
|
117
|
+
if (cmd === "lint") {
|
|
118
|
+
runLint(rest);
|
|
119
|
+
} else if (cmd === "dev") {
|
|
120
|
+
runDev(rest);
|
|
38
121
|
} else {
|
|
39
122
|
stderr.write(`Unknown command "${cmd}"\n`);
|
|
40
123
|
usage();
|
package/dist/contract.cjs
CHANGED
|
@@ -359,12 +359,14 @@ const HOOKS = [
|
|
|
359
359
|
},
|
|
360
360
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
361
361
|
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
362
|
-
// Reads need `users.read:*` scope; mutations
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
362
|
+
// Reads need `users.read:*` scope; edit-style mutations (invite /
|
|
363
|
+
// deactivate / reactivate) additionally need `users.write:*`, and the
|
|
364
|
+
// destructive `remove` needs `users.delete:*` (SC-902). The `invite`
|
|
365
|
+
// call accepts `{ email, name, groupIds? }` and returns the resulting
|
|
366
|
+
// AppUserInvite row (the email is sent by the host). Mutating users from
|
|
367
|
+
// a widget requires the corresponding SystemAcl capability grant in the
|
|
368
|
+
// tenant (`users.write` for edits, `users.delete` for removal); widgets
|
|
369
|
+
// that only call read methods need only `users.read:*`.
|
|
368
370
|
{
|
|
369
371
|
name: "useUsers",
|
|
370
372
|
signature: "useUsers(query?)",
|
|
@@ -374,7 +376,9 @@ const HOOKS = [
|
|
|
374
376
|
"{ users, loading, error, refetch, invite, deactivate, reactivate, remove }. " +
|
|
375
377
|
"list returns the { data, meta } envelope verbatim — the hook unwraps " +
|
|
376
378
|
"res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
|
|
377
|
-
"scope; mutations
|
|
379
|
+
"scope; edit-style mutations (invite/deactivate/reactivate) need " +
|
|
380
|
+
"users.write:*, and the destructive remove() additionally needs " +
|
|
381
|
+
"users.delete:* (SC-902). The `invite` call accepts " +
|
|
378
382
|
"{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
|
|
379
383
|
"(the email is sent by the host).",
|
|
380
384
|
returnShape: {
|
|
@@ -892,7 +896,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
892
896
|
"groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
|
|
893
897
|
"invites: { list(), revoke(id), resend(id) }, " +
|
|
894
898
|
"bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
|
|
895
|
-
"users backs useDirectory() + useUsers(); groups backs useGroups(); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write
|
|
899
|
+
"users backs useDirectory() + useUsers(); groups backs useGroups(); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
|
|
896
900
|
required: true,
|
|
897
901
|
fields: { users: "object", groups: "object", bankid: "object" },
|
|
898
902
|
},
|
|
@@ -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
|
-
|
|
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
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
362
|
+
// Reads need `users.read:*` scope; edit-style mutations (invite /
|
|
363
|
+
// deactivate / reactivate) additionally need `users.write:*`, and the
|
|
364
|
+
// destructive `remove` needs `users.delete:*` (SC-902). The `invite`
|
|
365
|
+
// call accepts `{ email, name, groupIds? }` and returns the resulting
|
|
366
|
+
// AppUserInvite row (the email is sent by the host). Mutating users from
|
|
367
|
+
// a widget requires the corresponding SystemAcl capability grant in the
|
|
368
|
+
// tenant (`users.write` for edits, `users.delete` for removal); widgets
|
|
369
|
+
// that only call read methods need only `users.read:*`.
|
|
368
370
|
{
|
|
369
371
|
name: "useUsers",
|
|
370
372
|
signature: "useUsers(query?)",
|
|
@@ -374,7 +376,9 @@ const HOOKS = [
|
|
|
374
376
|
"{ users, loading, error, refetch, invite, deactivate, reactivate, remove }. " +
|
|
375
377
|
"list returns the { data, meta } envelope verbatim — the hook unwraps " +
|
|
376
378
|
"res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
|
|
377
|
-
"scope; mutations
|
|
379
|
+
"scope; edit-style mutations (invite/deactivate/reactivate) need " +
|
|
380
|
+
"users.write:*, and the destructive remove() additionally needs " +
|
|
381
|
+
"users.delete:* (SC-902). The `invite` call accepts " +
|
|
378
382
|
"{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
|
|
379
383
|
"(the email is sent by the host).",
|
|
380
384
|
returnShape: {
|
|
@@ -892,7 +896,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
892
896
|
"groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
|
|
893
897
|
"invites: { list(), revoke(id), resend(id) }, " +
|
|
894
898
|
"bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
|
|
895
|
-
"users backs useDirectory() + useUsers(); groups backs useGroups(); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write
|
|
899
|
+
"users backs useDirectory() + useUsers(); groups backs useGroups(); bankid backs useBankIdLink() (REQ-BANKID-AUTH — self-service account linking, JWT-gated, no widget scope). List methods return the { data, meta } envelope verbatim (hooks unwrap res.data); rows/bodies are snake_case. Reads gated by directory.read:users / users.read:* / groups.read:*; mutations by users.write:* / groups.write:* (destructive user removal by users.delete:*).",
|
|
896
900
|
required: true,
|
|
897
901
|
fields: { users: "object", groups: "object", bankid: "object" },
|
|
898
902
|
},
|
|
@@ -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
|
-
|
|
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"
|
|
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"
|
|
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.
|
|
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",
|