@colixsystems/widget-sdk 0.34.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 +49 -3
- package/dist/cli.js +89 -6
- package/dist/contract.cjs +75 -10
- package/dist/contract.js +75 -10
- package/dist/devserver.js +350 -0
- package/dist/hooks.js +187 -0
- package/dist/index.js +2 -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,19 @@ 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.
|
|
55
|
+
|
|
56
|
+
### What's new in 0.35.0
|
|
57
|
+
|
|
58
|
+
**Folder-ACL + signer-roster hooks (REQ-FSH / REQ-SIGN).** Two admin/MANAGE-gated hooks reading the existing `ctx.filestore`:
|
|
59
|
+
- `useFileRoster(fileId, { limit?, offset?, enabled? })` → `{ roster, total, signedCount, loading, error, refetch }` — the signer roster for one file: the folder audience (every member of an open project folder, or the granted users/groups of a restricted one), each annotated `signed` with `signer_name` / `signed_at`. Reads `ctx.filestore.signatures.roster` (new `@colixsystems/filestore-client` **0.5.0** method); the host 403s a non-manager, so use `error` to hide the panel for non-admins.
|
|
60
|
+
- `useFolderPermissions(folderId, { enabled? })` → `{ grants, loading, busy, error, refetch, grant, revoke, setVisibility }` — manage a folder's permissions (the Filestore ACL): `grant(subjectType, subjectId, 'VIEW'|'DOWNLOAD'|'MANAGE')`, `revoke(shareId)`, `setVisibility('INHERIT'|'RESTRICTED')`. Pair the user/group picker with `useUsers` / `useGroups`.
|
|
61
|
+
|
|
62
|
+
Also: `useFileSignatures(fileIds)` is now **self-scoped** (the caller's own signatures only) — the backend tightened; the hook's shape is unchanged. **`CONTRACT.version` → `1.25.0`** (additive: two new hooks; the underlying `filestore-client` → `0.5.0`, shares folder-scoped).
|
|
51
63
|
|
|
52
64
|
### What's new in 0.34.0
|
|
53
65
|
|
|
@@ -367,7 +379,9 @@ The matching manifest declares the scopes:
|
|
|
367
379
|
```js
|
|
368
380
|
{
|
|
369
381
|
// ...
|
|
370
|
-
|
|
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:*"],
|
|
371
385
|
}
|
|
372
386
|
```
|
|
373
387
|
|
|
@@ -440,6 +454,38 @@ import { lintSource } from "@colixsystems/widget-sdk/linter";
|
|
|
440
454
|
const report = lintSource(source);
|
|
441
455
|
```
|
|
442
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
|
+
|
|
443
489
|
## Why no TypeScript dependency?
|
|
444
490
|
|
|
445
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
|
@@ -236,6 +236,51 @@ const HOOKS = [
|
|
|
236
236
|
requiredContextSlice: ["filestore.signatures"],
|
|
237
237
|
scopes: ["files.read:*"],
|
|
238
238
|
},
|
|
239
|
+
{
|
|
240
|
+
name: "useFileRoster",
|
|
241
|
+
signature: "useFileRoster(fileId, { limit?, offset?, enabled? }?)",
|
|
242
|
+
description:
|
|
243
|
+
"MANAGE-gated signer roster for one Filestore file: the people expected " +
|
|
244
|
+
"to be able to sign it (the file's folder audience) each annotated " +
|
|
245
|
+
"signed/not-signed, plus a distinct signed_count. Reads " +
|
|
246
|
+
"ctx.filestore.signatures.roster and unwraps { data, meta }. The host 403s " +
|
|
247
|
+
"a caller who can see the file but does not MANAGE its folder — surface " +
|
|
248
|
+
"that as 'not an admin here' via `error`.",
|
|
249
|
+
returnShape: {
|
|
250
|
+
roster:
|
|
251
|
+
"{ app_user_id, name, signed, signature_id, signer_name, signed_at }[]",
|
|
252
|
+
total: "number",
|
|
253
|
+
signedCount: "number",
|
|
254
|
+
loading: "boolean",
|
|
255
|
+
error: "Error | null",
|
|
256
|
+
refetch: "() => Promise<void>",
|
|
257
|
+
},
|
|
258
|
+
requiredContextSlice: ["filestore.signatures"],
|
|
259
|
+
scopes: ["files.read:*"],
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: "useFolderPermissions",
|
|
263
|
+
signature: "useFolderPermissions(folderId, { enabled? }?)",
|
|
264
|
+
description:
|
|
265
|
+
"Manage a folder's permissions (the Filestore ACL — folder-scoped). Lists " +
|
|
266
|
+
"the folder's grants and exposes grant(subjectType, subjectId, permission) " +
|
|
267
|
+
"/ revoke(shareId) / setVisibility('INHERIT' | 'RESTRICTED') — all MANAGE " +
|
|
268
|
+
"actions the host enforces. Reads ctx.filestore.shares + ctx.filestore." +
|
|
269
|
+
"folders; pair the user/group picker with useUsers / useGroups.",
|
|
270
|
+
returnShape: {
|
|
271
|
+
grants:
|
|
272
|
+
"{ id, folder_id, subject_type, subject_id, permission, created_at }[]",
|
|
273
|
+
loading: "boolean",
|
|
274
|
+
busy: "boolean",
|
|
275
|
+
error: "Error | null",
|
|
276
|
+
refetch: "() => Promise<void>",
|
|
277
|
+
grant: "(subjectType, subjectId, permission) => Promise<share>",
|
|
278
|
+
revoke: "(shareId) => Promise<void>",
|
|
279
|
+
setVisibility: "(visibility) => Promise<folder>",
|
|
280
|
+
},
|
|
281
|
+
requiredContextSlice: ["filestore.shares", "filestore.folders"],
|
|
282
|
+
scopes: ["files.read:*", "files.write:*"],
|
|
283
|
+
},
|
|
239
284
|
{
|
|
240
285
|
name: "useDatastoreQuery",
|
|
241
286
|
signature: "useDatastoreQuery(tableId, options?)",
|
|
@@ -314,12 +359,14 @@ const HOOKS = [
|
|
|
314
359
|
},
|
|
315
360
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
316
361
|
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
317
|
-
// Reads need `users.read:*` scope; mutations
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
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:*`.
|
|
323
370
|
{
|
|
324
371
|
name: "useUsers",
|
|
325
372
|
signature: "useUsers(query?)",
|
|
@@ -329,7 +376,9 @@ const HOOKS = [
|
|
|
329
376
|
"{ users, loading, error, refetch, invite, deactivate, reactivate, remove }. " +
|
|
330
377
|
"list returns the { data, meta } envelope verbatim — the hook unwraps " +
|
|
331
378
|
"res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
|
|
332
|
-
"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 " +
|
|
333
382
|
"{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
|
|
334
383
|
"(the email is sent by the host).",
|
|
335
384
|
returnShape: {
|
|
@@ -847,7 +896,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
847
896
|
"groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
|
|
848
897
|
"invites: { list(), revoke(id), resend(id) }, " +
|
|
849
898
|
"bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
|
|
850
|
-
"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:*).",
|
|
851
900
|
required: true,
|
|
852
901
|
fields: { users: "object", groups: "object", bankid: "object" },
|
|
853
902
|
},
|
|
@@ -867,7 +916,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
867
916
|
"signatures: { initiate(fileId), status(id), cancel(id), verify(id) }, objectUrl(token), fetchObject(token) }. " +
|
|
868
917
|
"Backs useFilestoreFiles() + useFilestoreFolders() + useFileSignature(). Optional — a host without an end-user filestore omits it and those hooks throw a clear error.",
|
|
869
918
|
required: false,
|
|
870
|
-
fields: { files: "object", folders: "object", signatures: "object" },
|
|
919
|
+
fields: { files: "object", folders: "object", shares: "object", signatures: "object" },
|
|
871
920
|
},
|
|
872
921
|
renderer: {
|
|
873
922
|
description:
|
|
@@ -1394,7 +1443,23 @@ const CONTRACT = deepFreeze({
|
|
|
1394
1443
|
// (animated-QR poll). Self-service + JWT-gated — no requestedScopes entry.
|
|
1395
1444
|
// No existing hook, primitive, or field changed shape — minor bump on the
|
|
1396
1445
|
// pre-1.0 channel.
|
|
1397
|
-
|
|
1446
|
+
//
|
|
1447
|
+
// 1.25.0: additive (REQ-FSH / REQ-SIGN) — new `useFileRoster(fileId)`
|
|
1448
|
+
// (MANAGE-gated signer roster: the folder audience annotated
|
|
1449
|
+
// signed/not-signed) and `useFolderPermissions(folderId)` (manage the
|
|
1450
|
+
// folder ACL: list grants + grant/revoke a VIEW/DOWNLOAD/MANAGE tier +
|
|
1451
|
+
// setVisibility). Both read the existing `ctx.filestore`; the underlying
|
|
1452
|
+
// `filestore-client` 0.5.0 made shares folder-scoped + added
|
|
1453
|
+
// `signatures.roster`. No existing hook changed.
|
|
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",
|
|
1398
1463
|
hooks: HOOKS,
|
|
1399
1464
|
primitives: PRIMITIVES,
|
|
1400
1465
|
manifestSchema: MANIFEST_SCHEMA,
|
package/dist/contract.js
CHANGED
|
@@ -236,6 +236,51 @@ const HOOKS = [
|
|
|
236
236
|
requiredContextSlice: ["filestore.signatures"],
|
|
237
237
|
scopes: ["files.read:*"],
|
|
238
238
|
},
|
|
239
|
+
{
|
|
240
|
+
name: "useFileRoster",
|
|
241
|
+
signature: "useFileRoster(fileId, { limit?, offset?, enabled? }?)",
|
|
242
|
+
description:
|
|
243
|
+
"MANAGE-gated signer roster for one Filestore file: the people expected " +
|
|
244
|
+
"to be able to sign it (the file's folder audience) each annotated " +
|
|
245
|
+
"signed/not-signed, plus a distinct signed_count. Reads " +
|
|
246
|
+
"ctx.filestore.signatures.roster and unwraps { data, meta }. The host 403s " +
|
|
247
|
+
"a caller who can see the file but does not MANAGE its folder — surface " +
|
|
248
|
+
"that as 'not an admin here' via `error`.",
|
|
249
|
+
returnShape: {
|
|
250
|
+
roster:
|
|
251
|
+
"{ app_user_id, name, signed, signature_id, signer_name, signed_at }[]",
|
|
252
|
+
total: "number",
|
|
253
|
+
signedCount: "number",
|
|
254
|
+
loading: "boolean",
|
|
255
|
+
error: "Error | null",
|
|
256
|
+
refetch: "() => Promise<void>",
|
|
257
|
+
},
|
|
258
|
+
requiredContextSlice: ["filestore.signatures"],
|
|
259
|
+
scopes: ["files.read:*"],
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: "useFolderPermissions",
|
|
263
|
+
signature: "useFolderPermissions(folderId, { enabled? }?)",
|
|
264
|
+
description:
|
|
265
|
+
"Manage a folder's permissions (the Filestore ACL — folder-scoped). Lists " +
|
|
266
|
+
"the folder's grants and exposes grant(subjectType, subjectId, permission) " +
|
|
267
|
+
"/ revoke(shareId) / setVisibility('INHERIT' | 'RESTRICTED') — all MANAGE " +
|
|
268
|
+
"actions the host enforces. Reads ctx.filestore.shares + ctx.filestore." +
|
|
269
|
+
"folders; pair the user/group picker with useUsers / useGroups.",
|
|
270
|
+
returnShape: {
|
|
271
|
+
grants:
|
|
272
|
+
"{ id, folder_id, subject_type, subject_id, permission, created_at }[]",
|
|
273
|
+
loading: "boolean",
|
|
274
|
+
busy: "boolean",
|
|
275
|
+
error: "Error | null",
|
|
276
|
+
refetch: "() => Promise<void>",
|
|
277
|
+
grant: "(subjectType, subjectId, permission) => Promise<share>",
|
|
278
|
+
revoke: "(shareId) => Promise<void>",
|
|
279
|
+
setVisibility: "(visibility) => Promise<folder>",
|
|
280
|
+
},
|
|
281
|
+
requiredContextSlice: ["filestore.shares", "filestore.folders"],
|
|
282
|
+
scopes: ["files.read:*", "files.write:*"],
|
|
283
|
+
},
|
|
239
284
|
{
|
|
240
285
|
name: "useDatastoreQuery",
|
|
241
286
|
signature: "useDatastoreQuery(tableId, options?)",
|
|
@@ -314,12 +359,14 @@ const HOOKS = [
|
|
|
314
359
|
},
|
|
315
360
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
316
361
|
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
317
|
-
// Reads need `users.read:*` scope; mutations
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
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:*`.
|
|
323
370
|
{
|
|
324
371
|
name: "useUsers",
|
|
325
372
|
signature: "useUsers(query?)",
|
|
@@ -329,7 +376,9 @@ const HOOKS = [
|
|
|
329
376
|
"{ users, loading, error, refetch, invite, deactivate, reactivate, remove }. " +
|
|
330
377
|
"list returns the { data, meta } envelope verbatim — the hook unwraps " +
|
|
331
378
|
"res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
|
|
332
|
-
"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 " +
|
|
333
382
|
"{ email, name, group_ids? } and returns the resulting AppUserInvite row " +
|
|
334
383
|
"(the email is sent by the host).",
|
|
335
384
|
returnShape: {
|
|
@@ -847,7 +896,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
847
896
|
"groups: { list(query?) -> Promise<{ data, meta }>, create(body), remove(id), addMember(groupId, userId), removeMember(groupId, userId), listMine() }, " +
|
|
848
897
|
"invites: { list(), revoke(id), resend(id) }, " +
|
|
849
898
|
"bankid: { status() -> { linked, available }, startLink() -> { order_ref, qr, ... }, collect(orderRef), cancel(orderRef), unlink() } }. " +
|
|
850
|
-
"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:*).",
|
|
851
900
|
required: true,
|
|
852
901
|
fields: { users: "object", groups: "object", bankid: "object" },
|
|
853
902
|
},
|
|
@@ -867,7 +916,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
867
916
|
"signatures: { initiate(fileId), status(id), cancel(id), verify(id) }, objectUrl(token), fetchObject(token) }. " +
|
|
868
917
|
"Backs useFilestoreFiles() + useFilestoreFolders() + useFileSignature(). Optional — a host without an end-user filestore omits it and those hooks throw a clear error.",
|
|
869
918
|
required: false,
|
|
870
|
-
fields: { files: "object", folders: "object", signatures: "object" },
|
|
919
|
+
fields: { files: "object", folders: "object", shares: "object", signatures: "object" },
|
|
871
920
|
},
|
|
872
921
|
renderer: {
|
|
873
922
|
description:
|
|
@@ -1394,7 +1443,23 @@ const CONTRACT = deepFreeze({
|
|
|
1394
1443
|
// (animated-QR poll). Self-service + JWT-gated — no requestedScopes entry.
|
|
1395
1444
|
// No existing hook, primitive, or field changed shape — minor bump on the
|
|
1396
1445
|
// pre-1.0 channel.
|
|
1397
|
-
|
|
1446
|
+
//
|
|
1447
|
+
// 1.25.0: additive (REQ-FSH / REQ-SIGN) — new `useFileRoster(fileId)`
|
|
1448
|
+
// (MANAGE-gated signer roster: the folder audience annotated
|
|
1449
|
+
// signed/not-signed) and `useFolderPermissions(folderId)` (manage the
|
|
1450
|
+
// folder ACL: list grants + grant/revoke a VIEW/DOWNLOAD/MANAGE tier +
|
|
1451
|
+
// setVisibility). Both read the existing `ctx.filestore`; the underlying
|
|
1452
|
+
// `filestore-client` 0.5.0 made shares folder-scoped + added
|
|
1453
|
+
// `signatures.roster`. No existing hook changed.
|
|
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",
|
|
1398
1463
|
hooks: HOOKS,
|
|
1399
1464
|
primitives: PRIMITIVES,
|
|
1400
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/hooks.js
CHANGED
|
@@ -1503,6 +1503,193 @@ export function useFileSignatures(fileIds) {
|
|
|
1503
1503
|
return { signaturesByFileId, loading, error, refetch };
|
|
1504
1504
|
}
|
|
1505
1505
|
|
|
1506
|
+
/**
|
|
1507
|
+
* REQ-SIGN: MANAGE-gated signer roster for one file — the people expected to be
|
|
1508
|
+
* able to sign it (the file's folder audience) each annotated signed/not-signed.
|
|
1509
|
+
* Reads ctx.filestore.signatures.roster. The host backend 403s a caller who can
|
|
1510
|
+
* see the file but doesn't manage its folder, so `error` (a ForbiddenError) is
|
|
1511
|
+
* the "you're not an admin here" signal the widget hides on. Returns
|
|
1512
|
+
* { roster, total, signedCount, loading, error, refetch }. `roster` items are
|
|
1513
|
+
* { app_user_id, name, signed, signature_id, signer_name, signed_at }.
|
|
1514
|
+
*/
|
|
1515
|
+
export function useFileRoster(fileId, options = {}) {
|
|
1516
|
+
const ctx = useWidgetContextOrThrow("useFileRoster");
|
|
1517
|
+
if (
|
|
1518
|
+
!ctx.filestore ||
|
|
1519
|
+
!ctx.filestore.signatures ||
|
|
1520
|
+
typeof ctx.filestore.signatures.roster !== "function"
|
|
1521
|
+
) {
|
|
1522
|
+
throw new Error(
|
|
1523
|
+
"useFileRoster: host did not inject a filestore client (ctx.filestore.signatures.roster)",
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
const { limit = 50, offset = 0, enabled = true } = options;
|
|
1527
|
+
const [roster, setRoster] = useState([]);
|
|
1528
|
+
const [total, setTotal] = useState(0);
|
|
1529
|
+
const [signedCount, setSignedCount] = useState(0);
|
|
1530
|
+
const [loading, setLoading] = useState(Boolean(fileId) && enabled);
|
|
1531
|
+
const [error, setError] = useState(null);
|
|
1532
|
+
|
|
1533
|
+
const sigRef = useRef(ctx.filestore.signatures);
|
|
1534
|
+
sigRef.current = ctx.filestore.signatures;
|
|
1535
|
+
const argsRef = useRef({ fileId, limit, offset, enabled });
|
|
1536
|
+
argsRef.current = { fileId, limit, offset, enabled };
|
|
1537
|
+
const runRef = useRef(0);
|
|
1538
|
+
|
|
1539
|
+
const doFetch = useCallback(async () => {
|
|
1540
|
+
const myRun = ++runRef.current;
|
|
1541
|
+
const { fileId: fid, limit: lim, offset: off, enabled: en } = argsRef.current;
|
|
1542
|
+
if (!fid || !en) {
|
|
1543
|
+
setLoading(false);
|
|
1544
|
+
setError(null);
|
|
1545
|
+
setRoster([]);
|
|
1546
|
+
setTotal(0);
|
|
1547
|
+
setSignedCount(0);
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
setLoading(true);
|
|
1551
|
+
setError(null);
|
|
1552
|
+
try {
|
|
1553
|
+
const res = await sigRef.current.roster(fid, { limit: lim, offset: off });
|
|
1554
|
+
if (runRef.current !== myRun) return;
|
|
1555
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
1556
|
+
const meta = (res && res.meta) || {};
|
|
1557
|
+
setRoster(rows);
|
|
1558
|
+
setTotal(typeof meta.total === "number" ? meta.total : rows.length);
|
|
1559
|
+
setSignedCount(typeof meta.signed_count === "number" ? meta.signed_count : 0);
|
|
1560
|
+
setLoading(false);
|
|
1561
|
+
} catch (err) {
|
|
1562
|
+
if (runRef.current !== myRun) return;
|
|
1563
|
+
setError(err);
|
|
1564
|
+
setLoading(false);
|
|
1565
|
+
}
|
|
1566
|
+
}, []);
|
|
1567
|
+
|
|
1568
|
+
useEffect(() => {
|
|
1569
|
+
doFetch();
|
|
1570
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1571
|
+
}, [fileId, limit, offset, enabled]);
|
|
1572
|
+
|
|
1573
|
+
const refetch = useCallback(async () => {
|
|
1574
|
+
await doFetch();
|
|
1575
|
+
}, [doFetch]);
|
|
1576
|
+
|
|
1577
|
+
return { roster, total, signedCount, loading, error, refetch };
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* REQ-FSH: folder-permission management for one folder (the Filestore ACL).
|
|
1582
|
+
* Reads ctx.filestore.shares + ctx.filestore.folders. Lists the folder's grants
|
|
1583
|
+
* and exposes grant / revoke / setVisibility mutations — all MANAGE actions the
|
|
1584
|
+
* host backend enforces (a non-manager's mutation rejects). Pair the user/group
|
|
1585
|
+
* picker with useUsers / useGroups. Returns { grants, loading, busy, error,
|
|
1586
|
+
* refetch, grant, revoke, setVisibility }.
|
|
1587
|
+
*/
|
|
1588
|
+
export function useFolderPermissions(folderId, options = {}) {
|
|
1589
|
+
const ctx = useWidgetContextOrThrow("useFolderPermissions");
|
|
1590
|
+
if (
|
|
1591
|
+
!ctx.filestore ||
|
|
1592
|
+
!ctx.filestore.shares ||
|
|
1593
|
+
typeof ctx.filestore.shares.list !== "function" ||
|
|
1594
|
+
!ctx.filestore.folders ||
|
|
1595
|
+
typeof ctx.filestore.folders.update !== "function"
|
|
1596
|
+
) {
|
|
1597
|
+
throw new Error(
|
|
1598
|
+
"useFolderPermissions: host did not inject a filestore client (ctx.filestore.shares / ctx.filestore.folders)",
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
const { enabled = true } = options;
|
|
1602
|
+
const [grants, setGrants] = useState([]);
|
|
1603
|
+
const [loading, setLoading] = useState(Boolean(folderId) && enabled);
|
|
1604
|
+
const [busy, setBusy] = useState(false);
|
|
1605
|
+
const [error, setError] = useState(null);
|
|
1606
|
+
|
|
1607
|
+
const sharesRef = useRef(ctx.filestore.shares);
|
|
1608
|
+
sharesRef.current = ctx.filestore.shares;
|
|
1609
|
+
const foldersRef = useRef(ctx.filestore.folders);
|
|
1610
|
+
foldersRef.current = ctx.filestore.folders;
|
|
1611
|
+
const idRef = useRef({ folderId, enabled });
|
|
1612
|
+
idRef.current = { folderId, enabled };
|
|
1613
|
+
const runRef = useRef(0);
|
|
1614
|
+
|
|
1615
|
+
const doFetch = useCallback(async () => {
|
|
1616
|
+
const myRun = ++runRef.current;
|
|
1617
|
+
const { folderId: fid, enabled: en } = idRef.current;
|
|
1618
|
+
if (!fid || !en) {
|
|
1619
|
+
setLoading(false);
|
|
1620
|
+
setError(null);
|
|
1621
|
+
setGrants([]);
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
setLoading(true);
|
|
1625
|
+
setError(null);
|
|
1626
|
+
try {
|
|
1627
|
+
const res = await sharesRef.current.list({ folder_id: fid });
|
|
1628
|
+
if (runRef.current !== myRun) return;
|
|
1629
|
+
setGrants(res && Array.isArray(res.data) ? res.data : []);
|
|
1630
|
+
setLoading(false);
|
|
1631
|
+
} catch (err) {
|
|
1632
|
+
if (runRef.current !== myRun) return;
|
|
1633
|
+
setError(err);
|
|
1634
|
+
setLoading(false);
|
|
1635
|
+
}
|
|
1636
|
+
}, []);
|
|
1637
|
+
|
|
1638
|
+
useEffect(() => {
|
|
1639
|
+
doFetch();
|
|
1640
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1641
|
+
}, [folderId, enabled]);
|
|
1642
|
+
|
|
1643
|
+
const refetch = useCallback(async () => {
|
|
1644
|
+
await doFetch();
|
|
1645
|
+
}, [doFetch]);
|
|
1646
|
+
|
|
1647
|
+
const grant = useCallback(
|
|
1648
|
+
async (subjectType, subjectId, permission) => {
|
|
1649
|
+
const { folderId: fid } = idRef.current;
|
|
1650
|
+
setBusy(true);
|
|
1651
|
+
try {
|
|
1652
|
+
const row = await sharesRef.current.create({
|
|
1653
|
+
folder_id: fid,
|
|
1654
|
+
subject_type: subjectType,
|
|
1655
|
+
subject_id: subjectId,
|
|
1656
|
+
permission,
|
|
1657
|
+
});
|
|
1658
|
+
await doFetch();
|
|
1659
|
+
return row;
|
|
1660
|
+
} finally {
|
|
1661
|
+
setBusy(false);
|
|
1662
|
+
}
|
|
1663
|
+
},
|
|
1664
|
+
[doFetch],
|
|
1665
|
+
);
|
|
1666
|
+
|
|
1667
|
+
const revoke = useCallback(
|
|
1668
|
+
async (shareId) => {
|
|
1669
|
+
setBusy(true);
|
|
1670
|
+
try {
|
|
1671
|
+
await sharesRef.current.remove(shareId);
|
|
1672
|
+
await doFetch();
|
|
1673
|
+
} finally {
|
|
1674
|
+
setBusy(false);
|
|
1675
|
+
}
|
|
1676
|
+
},
|
|
1677
|
+
[doFetch],
|
|
1678
|
+
);
|
|
1679
|
+
|
|
1680
|
+
const setVisibility = useCallback(async (visibility) => {
|
|
1681
|
+
const { folderId: fid } = idRef.current;
|
|
1682
|
+
setBusy(true);
|
|
1683
|
+
try {
|
|
1684
|
+
return await foldersRef.current.update(fid, { visibility });
|
|
1685
|
+
} finally {
|
|
1686
|
+
setBusy(false);
|
|
1687
|
+
}
|
|
1688
|
+
}, []);
|
|
1689
|
+
|
|
1690
|
+
return { grants, loading, busy, error, refetch, grant, revoke, setVisibility };
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1506
1693
|
/* ============================================================================
|
|
1507
1694
|
* DIRECTORY CLIENT — ctx.directory (@colixsystems/directory-client)
|
|
1508
1695
|
*
|
package/dist/index.js
CHANGED
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",
|