@colixsystems/widget-sdk 0.88.0 → 0.90.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 +32 -2
- package/dist/contract.cjs +39 -3
- package/dist/contract.js +39 -3
- package/dist/index.d.ts +11 -6
- package/dist/linter.cjs +50 -5
- package/dist/linter.js +50 -5
- package/dist/manifest.cjs +6 -5
- package/dist/manifest.js +6 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -61,7 +61,33 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
61
61
|
|
|
62
62
|
## Status
|
|
63
63
|
|
|
64
|
-
`v0.
|
|
64
|
+
`v0.90.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**.
|
|
65
|
+
|
|
66
|
+
### What's new in 0.89.0 (contract unchanged)
|
|
67
|
+
|
|
68
|
+
**New linter rule `write-not-gated-on-user` — a widget that writes must decide what a signed-OUT visitor sees (sc-4985).**
|
|
69
|
+
|
|
70
|
+
- **`write-not-gated-on-user` (severity `warning`, non-blocking).** A widget that
|
|
71
|
+
writes with `useDatastoreMutation` but carries no identity guard is flagged. A
|
|
72
|
+
write needs a signed-in app user, so an anonymous visitor handed a live
|
|
73
|
+
"Save" / "Book" / "Delete" button can only ever tap it and fail. Author fix:
|
|
74
|
+
read `useUser()` and branch **before** rendering the control — when `!user.id`
|
|
75
|
+
keep the affordance as a visibly-inactive signpost with a translated "sign in"
|
|
76
|
+
line and **no press handler** (a widget cannot open the login surface; that is
|
|
77
|
+
a built-in Button's `sign-in` action, wired by the page author), and when the
|
|
78
|
+
user is signed in but not permitted, leave the control out entirely.
|
|
79
|
+
- **Reading `useUser().id` as a VALUE does not satisfy it.** The canonical
|
|
80
|
+
USER-column write pattern (`create({ [memberField]: user.id })`) calls
|
|
81
|
+
`useUser()` without ever branching on it — the case most easily mistaken for a
|
|
82
|
+
gate — so the rule requires an operator after `.id` (a negation, a ternary,
|
|
83
|
+
`&&`, a comparison) or a `groupIds` / `roles` check.
|
|
84
|
+
- **Why a warning.** It is conservative on purpose: an unrelated `.id`
|
|
85
|
+
comparison elsewhere in the source silences it. A rule that occasionally stays
|
|
86
|
+
quiet is far cheaper than one that cries wolf on correct code, and gating is an
|
|
87
|
+
affordance decision — the server remains the only authority, so the `catch`
|
|
88
|
+
stays either way.
|
|
89
|
+
|
|
90
|
+
`CONTRACT` is unchanged (no new field), and no export changed signature.
|
|
65
91
|
|
|
66
92
|
### What's new in 0.88.0 (contract 1.62.0)
|
|
67
93
|
|
|
@@ -146,6 +172,10 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
146
172
|
- **The colour maths ignores alpha, on purpose.** `hexChannels` reads the R/G/B pair and skips any alpha, so contrast, readable text and the derived accent tints reason about the opaque colour. None of them can composite without knowing the backdrop, which a token table does not have — so transparency lives in the VALUE your widget renders, not in the decision about whether that colour reads as light or dark.
|
|
147
173
|
- **`CONTRACT.version` → `1.61.0`** (additive: `themeTokens.spacingScale` + `widgetStyles` and their bounds, `themeComponents.card.universalFields`, and the `normaliseWidgetStyles` / `deriveSurfaceTokens` host exports). No author-facing export changed signature, and a theme that sets none of it resolves exactly as before.
|
|
148
174
|
|
|
175
|
+
### What's new in 0.90.0 (contract 1.63.0)
|
|
176
|
+
|
|
177
|
+
**A manifest action declares `triggerTypes` — a set — and the script learns which one fired (sc-4915).** An action could carry exactly one trigger, so a widget that needed the same work done on create *and* on delete had to ship the script twice: two `actions` entries, two operator bindings, two run histories, and the usual drift between the copies. `triggerTypes` replaces `triggerType`: a non-empty array of unique values from `schedule`, `record_created`, `record_updated`, `record_deleted`, freely combined (`scheduleCron` is required iff the array contains `schedule`). The pre-0.90.0 scalar `triggerType` is still read and normalised into the array, so a widget already published against it keeps validating and nothing needs republishing. What makes the combination useful is the other half: the script's `triggerType` global now names the trigger that **actually fired this run** — including `"manual"` (an operator's Run now) and `"app"` (a button press) — instead of echoing the row's configuration, so one script can branch on whether its record was created or deleted. `CONTRACT.version` → `1.63.0`. Additive for every existing manifest.
|
|
178
|
+
|
|
149
179
|
### What's new in 0.85.1 (contract 1.60.1)
|
|
150
180
|
|
|
151
181
|
**`useWidgetEvent(name)` returns the emitter FUNCTION — the declared contract said otherwise (sc-4753).** `CONTRACT.hooks`'s entry for the hook declared `returnShape: { emit }`, so every surface derived from it — chiefly the Widget Builder Agent's hooks table — told authors the hook resolves to an object. It never did: `useWidgetEvent("slotChosen")` hands back the callable you invoke directly (`emitSlot({ courtId })`), exactly as the typings and the Developer guide have always documented. A widget written against the declared shape destructured a function, got `undefined`, and threw the moment a user interacted — a cross-widget wire that rendered perfectly and only failed on click. The declaration is now a bare callable and the publish-time render harness models the same shape, so a wrong destructure is caught instead of waved through. `CONTRACT.version` → `1.60.1`. Documentation-only correction: no export, signature, or runtime behaviour changed — a widget already calling the result is unaffected.
|
|
@@ -593,7 +623,7 @@ Two additive features land in this version.
|
|
|
593
623
|
|
|
594
624
|
**A widget may declare server-side actions in its manifest.**
|
|
595
625
|
|
|
596
|
-
- **`WidgetManifest.actions` is now part of the public contract.** An optional array; each entry is `{ key, name, description?,
|
|
626
|
+
- **`WidgetManifest.actions` is now part of the public contract.** An optional array; each entry is `{ key, name, description?, triggerTypes, scheduleCron?, timeoutMs?, scriptSource }`. `triggerTypes` is a non-empty array of unique values from `schedule`, `record_created`, `record_updated`, `record_deleted` — combine them so one script serves several events; `scheduleCron` is required when it contains `schedule`. The `scriptSource` (≤ 200 KiB) runs in the **shared isolated-vm action runner** — against `datastore` / `fetch` / `connectors` / `console` / `record` / `tenantId` (the runner surface, **not** the React/SDK widget surface, so SDK imports and hooks are unavailable there and the component linter does not scan it). `connectors.call(slug, { method, path, query, body, headers })` resolves a tenant-configured REST connector by slug and returns `{ status, headers, body }` (auth + SSRF handled by the platform; an unknown slug / SSRF / timeout throws a catchable Error). Actions never run in the rendered app, so they have **no effect on Player ↔ export parity**.
|
|
597
627
|
- **Operators enable actions per tenant** from the Properties Panel. An enabled action materialises a tenant `Action` row **DISABLED** until the operator binds an integration API key (and, for `record_*` triggers, a target table) in the Actions admin page — those bindings are tenant-local, so `triggerTableId` / `apiKeyId` must **not** appear in the manifest (the validator and linter reject them).
|
|
598
628
|
- **New contract fields** `CONTRACT.actionTriggerTypes`, `CONTRACT.actionScriptGlobals`, `CONTRACT.actionScriptMaxBytes` expose the grammar so the Developer page, the AI agent prompt, and `validateManifest` derive it from one source. `validateManifest` now structurally validates `actions`; the marketplace linter rejects malformed / oversized declarations.
|
|
599
629
|
- **New manifest category `ADMINISTRATION`** for app-administration widgets such as User Management. Added to `CONTRACT.manifestCategories`, `validateManifest`, the `WidgetCategory` type, the marketplace category list, and the master-DB `WidgetCategory` enum.
|
package/dist/contract.cjs
CHANGED
|
@@ -1373,6 +1373,34 @@ const ACTION_SCRIPT_GLOBALS = [
|
|
|
1373
1373
|
// Mirrors action.service.js SCRIPT_MAX_BYTES.
|
|
1374
1374
|
const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
|
|
1375
1375
|
|
|
1376
|
+
/**
|
|
1377
|
+
* sc-4915 — the trigger set a manifest action declares, canonically ordered.
|
|
1378
|
+
*
|
|
1379
|
+
* `triggerTypes` is the current shape; the pre-sc-4915 scalar `triggerType` is
|
|
1380
|
+
* still read so a widget published before the change keeps validating. Returns
|
|
1381
|
+
* null when the declaration is empty, holds an unknown value, or repeats one —
|
|
1382
|
+
* every caller reports that as a manifest error. ONE reader, so the SDK
|
|
1383
|
+
* validator, the CLI linter and the backend cannot disagree about what a
|
|
1384
|
+
* manifest declared.
|
|
1385
|
+
*/
|
|
1386
|
+
function normaliseActionTriggerTypes(action) {
|
|
1387
|
+
const a = action !== null && typeof action === "object" ? action : {};
|
|
1388
|
+
const raw = Array.isArray(a.triggerTypes)
|
|
1389
|
+
? a.triggerTypes
|
|
1390
|
+
: a.triggerTypes === undefined && a.triggerType !== undefined
|
|
1391
|
+
? [a.triggerType]
|
|
1392
|
+
: null;
|
|
1393
|
+
if (raw === null || raw.length === 0) return null;
|
|
1394
|
+
const seen = new Set();
|
|
1395
|
+
for (const t of raw) {
|
|
1396
|
+
if (typeof t !== "string" || !ACTION_TRIGGER_TYPES.includes(t)) return null;
|
|
1397
|
+
if (seen.has(t)) return null;
|
|
1398
|
+
seen.add(t);
|
|
1399
|
+
}
|
|
1400
|
+
return ACTION_TRIGGER_TYPES.filter((t) => seen.has(t));
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
|
|
1376
1404
|
// Reverse-DNS-ish manifest id, e.g. "com.acme.charts.barchart". Two or
|
|
1377
1405
|
// more labels, lowercase alnum + hyphen, label starts with a letter. The
|
|
1378
1406
|
// analyzer + the SDK validator both read this from the contract so a
|
|
@@ -1501,9 +1529,9 @@ const MANIFEST_SCHEMA = {
|
|
|
1501
1529
|
type: "object[]",
|
|
1502
1530
|
required: false,
|
|
1503
1531
|
description:
|
|
1504
|
-
"Optional. Server-side actions the widget declares. Each runs in the shared isolated-vm action runner (cron- or record-triggered) — NEVER in the rendered app. Operators enable them per tenant from the Properties Panel; the action materialises DISABLED until they bind an integration API key (and, for record_* triggers, a target table) in the Actions admin page. Each entry: { key (stable, unique within the manifest), name, description?,
|
|
1532
|
+
"Optional. Server-side actions the widget declares. Each runs in the shared isolated-vm action runner (cron- or record-triggered) — NEVER in the rendered app. Operators enable them per tenant from the Properties Panel; the action materialises DISABLED until they bind an integration API key (and, for record_* triggers, a target table) in the Actions admin page. Each entry: { key (stable, unique within the manifest), name, description?, triggerTypes (a non-empty array of unique values from " +
|
|
1505
1533
|
ACTION_TRIGGER_TYPES.join(", ") +
|
|
1506
|
-
"), scheduleCron? (required iff
|
|
1534
|
+
"; combine them so one script serves several events — the script's `triggerType` global names the event that actually fired), scheduleCron? (required iff triggerTypes contains 'schedule'; node-cron syntax), timeoutMs? (100–300000), scriptSource (≤200 KiB; runs against " +
|
|
1507
1535
|
ACTION_SCRIPT_GLOBALS.join(", ") +
|
|
1508
1536
|
" — NOT the React surface, so SDK imports/hooks are unavailable) }. Do NOT include triggerTableId or apiKeyId — those are tenant-local and bound after install.",
|
|
1509
1537
|
default: [],
|
|
@@ -2787,7 +2815,14 @@ const CONTRACT = deepFreeze({
|
|
|
2787
2815
|
// Naming one widget is strictly more specific than restyling a scope, and
|
|
2788
2816
|
// the Properties Panel stays the final word. Additive throughout: a theme
|
|
2789
2817
|
// that sets none of it resolves exactly as before.
|
|
2790
|
-
|
|
2818
|
+
// 1.63.0: additive (sc-4915) — a manifest action declares `triggerTypes`, a
|
|
2819
|
+
// non-empty ARRAY, so one script can serve a create, an update and a
|
|
2820
|
+
// delete instead of being copied into three actions. The pre-sc-4915
|
|
2821
|
+
// scalar `triggerType` is still read and normalised, so an already
|
|
2822
|
+
// published widget keeps validating. The script's `triggerType` global
|
|
2823
|
+
// now names the trigger that actually FIRED the run ('manual' and 'app'
|
|
2824
|
+
// included), which is what makes a multi-trigger script able to branch.
|
|
2825
|
+
version: "1.63.0",
|
|
2791
2826
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2792
2827
|
hooks: HOOKS,
|
|
2793
2828
|
primitives: PRIMITIVES,
|
|
@@ -3073,6 +3108,7 @@ module.exports = {
|
|
|
3073
3108
|
CONTRACT,
|
|
3074
3109
|
clampSpacingScale,
|
|
3075
3110
|
scaleSpacing,
|
|
3111
|
+
normaliseActionTriggerTypes,
|
|
3076
3112
|
isHookAllowed,
|
|
3077
3113
|
requiredContextKeys,
|
|
3078
3114
|
isHexColor,
|
package/dist/contract.js
CHANGED
|
@@ -1373,6 +1373,34 @@ const ACTION_SCRIPT_GLOBALS = [
|
|
|
1373
1373
|
// Mirrors action.service.js SCRIPT_MAX_BYTES.
|
|
1374
1374
|
const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
|
|
1375
1375
|
|
|
1376
|
+
/**
|
|
1377
|
+
* sc-4915 — the trigger set a manifest action declares, canonically ordered.
|
|
1378
|
+
*
|
|
1379
|
+
* `triggerTypes` is the current shape; the pre-sc-4915 scalar `triggerType` is
|
|
1380
|
+
* still read so a widget published before the change keeps validating. Returns
|
|
1381
|
+
* null when the declaration is empty, holds an unknown value, or repeats one —
|
|
1382
|
+
* every caller reports that as a manifest error. ONE reader, so the SDK
|
|
1383
|
+
* validator, the CLI linter and the backend cannot disagree about what a
|
|
1384
|
+
* manifest declared.
|
|
1385
|
+
*/
|
|
1386
|
+
function normaliseActionTriggerTypes(action) {
|
|
1387
|
+
const a = action !== null && typeof action === "object" ? action : {};
|
|
1388
|
+
const raw = Array.isArray(a.triggerTypes)
|
|
1389
|
+
? a.triggerTypes
|
|
1390
|
+
: a.triggerTypes === undefined && a.triggerType !== undefined
|
|
1391
|
+
? [a.triggerType]
|
|
1392
|
+
: null;
|
|
1393
|
+
if (raw === null || raw.length === 0) return null;
|
|
1394
|
+
const seen = new Set();
|
|
1395
|
+
for (const t of raw) {
|
|
1396
|
+
if (typeof t !== "string" || !ACTION_TRIGGER_TYPES.includes(t)) return null;
|
|
1397
|
+
if (seen.has(t)) return null;
|
|
1398
|
+
seen.add(t);
|
|
1399
|
+
}
|
|
1400
|
+
return ACTION_TRIGGER_TYPES.filter((t) => seen.has(t));
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
|
|
1376
1404
|
// Reverse-DNS-ish manifest id, e.g. "com.acme.charts.barchart". Two or
|
|
1377
1405
|
// more labels, lowercase alnum + hyphen, label starts with a letter. The
|
|
1378
1406
|
// analyzer + the SDK validator both read this from the contract so a
|
|
@@ -1501,9 +1529,9 @@ const MANIFEST_SCHEMA = {
|
|
|
1501
1529
|
type: "object[]",
|
|
1502
1530
|
required: false,
|
|
1503
1531
|
description:
|
|
1504
|
-
"Optional. Server-side actions the widget declares. Each runs in the shared isolated-vm action runner (cron- or record-triggered) — NEVER in the rendered app. Operators enable them per tenant from the Properties Panel; the action materialises DISABLED until they bind an integration API key (and, for record_* triggers, a target table) in the Actions admin page. Each entry: { key (stable, unique within the manifest), name, description?,
|
|
1532
|
+
"Optional. Server-side actions the widget declares. Each runs in the shared isolated-vm action runner (cron- or record-triggered) — NEVER in the rendered app. Operators enable them per tenant from the Properties Panel; the action materialises DISABLED until they bind an integration API key (and, for record_* triggers, a target table) in the Actions admin page. Each entry: { key (stable, unique within the manifest), name, description?, triggerTypes (a non-empty array of unique values from " +
|
|
1505
1533
|
ACTION_TRIGGER_TYPES.join(", ") +
|
|
1506
|
-
"), scheduleCron? (required iff
|
|
1534
|
+
"; combine them so one script serves several events — the script's `triggerType` global names the event that actually fired), scheduleCron? (required iff triggerTypes contains 'schedule'; node-cron syntax), timeoutMs? (100–300000), scriptSource (≤200 KiB; runs against " +
|
|
1507
1535
|
ACTION_SCRIPT_GLOBALS.join(", ") +
|
|
1508
1536
|
" — NOT the React surface, so SDK imports/hooks are unavailable) }. Do NOT include triggerTableId or apiKeyId — those are tenant-local and bound after install.",
|
|
1509
1537
|
default: [],
|
|
@@ -2787,7 +2815,14 @@ const CONTRACT = deepFreeze({
|
|
|
2787
2815
|
// Naming one widget is strictly more specific than restyling a scope, and
|
|
2788
2816
|
// the Properties Panel stays the final word. Additive throughout: a theme
|
|
2789
2817
|
// that sets none of it resolves exactly as before.
|
|
2790
|
-
|
|
2818
|
+
// 1.63.0: additive (sc-4915) — a manifest action declares `triggerTypes`, a
|
|
2819
|
+
// non-empty ARRAY, so one script can serve a create, an update and a
|
|
2820
|
+
// delete instead of being copied into three actions. The pre-sc-4915
|
|
2821
|
+
// scalar `triggerType` is still read and normalised, so an already
|
|
2822
|
+
// published widget keeps validating. The script's `triggerType` global
|
|
2823
|
+
// now names the trigger that actually FIRED the run ('manual' and 'app'
|
|
2824
|
+
// included), which is what makes a multi-trigger script able to branch.
|
|
2825
|
+
version: "1.63.0",
|
|
2791
2826
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2792
2827
|
hooks: HOOKS,
|
|
2793
2828
|
primitives: PRIMITIVES,
|
|
@@ -3073,6 +3108,7 @@ export {
|
|
|
3073
3108
|
CONTRACT,
|
|
3074
3109
|
clampSpacingScale,
|
|
3075
3110
|
scaleSpacing,
|
|
3111
|
+
normaliseActionTriggerTypes,
|
|
3076
3112
|
isHookAllowed,
|
|
3077
3113
|
requiredContextKeys,
|
|
3078
3114
|
isHexColor,
|
package/dist/index.d.ts
CHANGED
|
@@ -223,18 +223,23 @@ export interface WidgetManifestAction {
|
|
|
223
223
|
key: string;
|
|
224
224
|
name: string;
|
|
225
225
|
description?: string;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
/**
|
|
227
|
+
* sc-4915 — a non-empty set of unique triggers. Combine them so one script
|
|
228
|
+
* serves several events; the script's `triggerType` global names the one
|
|
229
|
+
* that actually fired.
|
|
230
|
+
*/
|
|
231
|
+
triggerTypes: Array<
|
|
232
|
+
"schedule" | "record_created" | "record_updated" | "record_deleted"
|
|
233
|
+
>;
|
|
234
|
+
/** Required iff `triggerTypes` contains `"schedule"`. node-cron syntax. */
|
|
232
235
|
scheduleCron?: string;
|
|
233
236
|
/** 100–300000. Defaults to 30000 on materialise. */
|
|
234
237
|
timeoutMs?: number;
|
|
235
238
|
/**
|
|
236
239
|
* Runs against `datastore`, `fetch`, `console`, `record`, `tenantId`,
|
|
237
240
|
* `triggerType`, `triggerTableId` — NOT the React/SDK surface. ≤ 200 KiB.
|
|
241
|
+
* `triggerType` is the trigger that fired THIS run — one of the declared
|
|
242
|
+
* `triggerTypes`, or `"manual"` / `"app"` for an operator or button run.
|
|
238
243
|
*/
|
|
239
244
|
scriptSource: string;
|
|
240
245
|
}
|
package/dist/linter.cjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
"use strict";
|
|
12
12
|
|
|
13
|
-
const { CONTRACT } = require("./contract.cjs");
|
|
13
|
+
const { CONTRACT, normaliseActionTriggerTypes } = require("./contract.cjs");
|
|
14
14
|
const { LUCIDE_ICON_NAMES, LUCIDE_VERSION } = require("./lucideIconNames.cjs");
|
|
15
15
|
|
|
16
16
|
function _ruleForIdentifier(identifier, reason) {
|
|
@@ -579,10 +579,11 @@ function _manifestActionRules(manifest) {
|
|
|
579
579
|
if (typeof a.name !== "string" || a.name.length === 0) {
|
|
580
580
|
push("manifest.actions[].name must be a non-empty string");
|
|
581
581
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
582
|
+
const triggerTypes = normaliseActionTriggerTypes(a);
|
|
583
|
+
if (triggerTypes === null) {
|
|
584
|
+
push(`manifest.actions[].triggerTypes must be a non-empty array of unique values from ${[...validTriggers].join(", ")}`);
|
|
585
|
+
} else if (triggerTypes.includes("schedule") && (typeof a.scheduleCron !== "string" || !a.scheduleCron)) {
|
|
586
|
+
push("manifest.actions[].scheduleCron is required when triggerTypes contains 'schedule'");
|
|
586
587
|
}
|
|
587
588
|
if (typeof a.scriptSource !== "string" || a.scriptSource.length === 0) {
|
|
588
589
|
push("manifest.actions[].scriptSource must be a non-empty string");
|
|
@@ -789,6 +790,49 @@ const CURRENCY_LABEL_RES = [
|
|
|
789
790
|
/(?:\d|\})\s*\b(?:SEK|NOK|DKK|EUR|GBP|USD|CHF|PLN|CZK|HUF|JPY|INR)\b/,
|
|
790
791
|
];
|
|
791
792
|
|
|
793
|
+
// sc-4985 — soft warning: a widget that writes must decide what a signed-OUT
|
|
794
|
+
// visitor sees. A write needs a signed-in app user, so an anonymous visitor
|
|
795
|
+
// handed a live "Save" / "Book" / "Delete" button can only tap it and fail —
|
|
796
|
+
// the failure the gate exists to spare them. Satisfied by any identity guard:
|
|
797
|
+
// a negated or compared `.id`, or a `groupIds` / `roles` check. Reading
|
|
798
|
+
// `useUser().id` purely as a VALUE (the USER-column write pattern) is NOT a
|
|
799
|
+
// guard, which is why an operator has to follow it.
|
|
800
|
+
//
|
|
801
|
+
// Conservative on purpose: an unrelated `.id` comparison elsewhere in the
|
|
802
|
+
// source silences the rule. A warning that occasionally stays quiet is far
|
|
803
|
+
// cheaper than one that cries wolf on correct code.
|
|
804
|
+
const _IDENTITY_GUARD_RES = [
|
|
805
|
+
// `!user.id` / `!user?.id`
|
|
806
|
+
/![\s(]*\w+\??\.id\b/,
|
|
807
|
+
// `user.id ?` / `&&` / `||` / `===` / `!==` / `==` / `!=`
|
|
808
|
+
/\w+\??\.id\s*(\?[^.]|&&|\|\||===|!==|==|!=)/,
|
|
809
|
+
// any group / role check the brief asked for
|
|
810
|
+
/\bgroupIds\b/,
|
|
811
|
+
/\broles\b/,
|
|
812
|
+
];
|
|
813
|
+
|
|
814
|
+
function _writeGatedOnUserRules(source) {
|
|
815
|
+
const code = _stripNonCode(source);
|
|
816
|
+
const call = /\buseDatastoreMutation\s*\(/.exec(code);
|
|
817
|
+
if (!call) return [];
|
|
818
|
+
if (_IDENTITY_GUARD_RES.some((re) => re.test(code))) return [];
|
|
819
|
+
const line = code.slice(0, call.index).split(/\r?\n/).length;
|
|
820
|
+
return [
|
|
821
|
+
{
|
|
822
|
+
rule: "write-not-gated-on-user",
|
|
823
|
+
severity: "warning",
|
|
824
|
+
// Kept under ~210 chars: a finding is truncated at 300 downstream, and
|
|
825
|
+
// the fix instruction is the half worth keeping.
|
|
826
|
+
label:
|
|
827
|
+
`writes with useDatastoreMutation() but never checks who is signed ` +
|
|
828
|
+
`in - read useUser(), and when !user.id render the action inactive ` +
|
|
829
|
+
`with a "sign in" line instead of a live button that can only fail.`,
|
|
830
|
+
line,
|
|
831
|
+
snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
|
|
832
|
+
},
|
|
833
|
+
];
|
|
834
|
+
}
|
|
835
|
+
|
|
792
836
|
function _hardcodedCurrencyLabelRules(source) {
|
|
793
837
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
794
838
|
if (!code.includes(REQUEST_PAYMENT_CALL)) return [];
|
|
@@ -1039,6 +1083,7 @@ function lintSource(source, options) {
|
|
|
1039
1083
|
// sc-4913 — soft warning: a measured width that includes the widget's own
|
|
1040
1084
|
// padding wraps the last grid column into an empty one.
|
|
1041
1085
|
findings.push(..._measuredPaddingRules(source));
|
|
1086
|
+
findings.push(..._writeGatedOnUserRules(source));
|
|
1042
1087
|
// sc-4650 — soft warning: every payment refusal reported as "try again".
|
|
1043
1088
|
findings.push(..._paymentCurrencyRules(source));
|
|
1044
1089
|
findings.push(..._hardcodedCurrencyLabelRules(source));
|
package/dist/linter.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// The banned-identifier list is derived from `CONTRACT.bannedApis` so the
|
|
7
7
|
// system prompt, the linter, and the runtime allowlist agree.
|
|
8
8
|
|
|
9
|
-
import { CONTRACT } from "./contract.js";
|
|
9
|
+
import { CONTRACT, normaliseActionTriggerTypes } from "./contract.js";
|
|
10
10
|
import { LUCIDE_ICON_NAMES, LUCIDE_VERSION } from "./lucideIconNames.js";
|
|
11
11
|
|
|
12
12
|
// Per-identifier match rule. Most banned identifiers compile to a
|
|
@@ -674,16 +674,17 @@ function _manifestActionRules(manifest) {
|
|
|
674
674
|
if (typeof a.name !== "string" || a.name.length === 0) {
|
|
675
675
|
push("manifest.actions[].name must be a non-empty string");
|
|
676
676
|
}
|
|
677
|
-
|
|
677
|
+
const triggerTypes = normaliseActionTriggerTypes(a);
|
|
678
|
+
if (triggerTypes === null) {
|
|
678
679
|
push(
|
|
679
|
-
`manifest.actions[].
|
|
680
|
+
`manifest.actions[].triggerTypes must be a non-empty array of unique values from ${[...validTriggers].join(", ")}`,
|
|
680
681
|
);
|
|
681
682
|
} else if (
|
|
682
|
-
|
|
683
|
+
triggerTypes.includes("schedule") &&
|
|
683
684
|
(typeof a.scheduleCron !== "string" || !a.scheduleCron)
|
|
684
685
|
) {
|
|
685
686
|
push(
|
|
686
|
-
"manifest.actions[].scheduleCron is required when
|
|
687
|
+
"manifest.actions[].scheduleCron is required when triggerTypes contains 'schedule'",
|
|
687
688
|
);
|
|
688
689
|
}
|
|
689
690
|
if (typeof a.scriptSource !== "string" || a.scriptSource.length === 0) {
|
|
@@ -921,6 +922,49 @@ const CURRENCY_LABEL_RES = [
|
|
|
921
922
|
/(?:\d|\})\s*\b(?:SEK|NOK|DKK|EUR|GBP|USD|CHF|PLN|CZK|HUF|JPY|INR)\b/,
|
|
922
923
|
];
|
|
923
924
|
|
|
925
|
+
// sc-4985 — soft warning: a widget that writes must decide what a signed-OUT
|
|
926
|
+
// visitor sees. A write needs a signed-in app user, so an anonymous visitor
|
|
927
|
+
// handed a live "Save" / "Book" / "Delete" button can only tap it and fail —
|
|
928
|
+
// the failure the gate exists to spare them. Satisfied by any identity guard:
|
|
929
|
+
// a negated or compared `.id`, or a `groupIds` / `roles` check. Reading
|
|
930
|
+
// `useUser().id` purely as a VALUE (the USER-column write pattern) is NOT a
|
|
931
|
+
// guard, which is why an operator has to follow it.
|
|
932
|
+
//
|
|
933
|
+
// Conservative on purpose: an unrelated `.id` comparison elsewhere in the
|
|
934
|
+
// source silences the rule. A warning that occasionally stays quiet is far
|
|
935
|
+
// cheaper than one that cries wolf on correct code.
|
|
936
|
+
const _IDENTITY_GUARD_RES = [
|
|
937
|
+
// `!user.id` / `!user?.id`
|
|
938
|
+
/![\s(]*\w+\??\.id\b/,
|
|
939
|
+
// `user.id ?` / `&&` / `||` / `===` / `!==` / `==` / `!=`
|
|
940
|
+
/\w+\??\.id\s*(\?[^.]|&&|\|\||===|!==|==|!=)/,
|
|
941
|
+
// any group / role check the brief asked for
|
|
942
|
+
/\bgroupIds\b/,
|
|
943
|
+
/\broles\b/,
|
|
944
|
+
];
|
|
945
|
+
|
|
946
|
+
function _writeGatedOnUserRules(source) {
|
|
947
|
+
const code = _stripNonCode(source);
|
|
948
|
+
const call = /\buseDatastoreMutation\s*\(/.exec(code);
|
|
949
|
+
if (!call) return [];
|
|
950
|
+
if (_IDENTITY_GUARD_RES.some((re) => re.test(code))) return [];
|
|
951
|
+
const line = code.slice(0, call.index).split(/\r?\n/).length;
|
|
952
|
+
return [
|
|
953
|
+
{
|
|
954
|
+
rule: "write-not-gated-on-user",
|
|
955
|
+
severity: "warning",
|
|
956
|
+
// Kept under ~210 chars: a finding is truncated at 300 downstream, and
|
|
957
|
+
// the fix instruction is the half worth keeping.
|
|
958
|
+
label:
|
|
959
|
+
`writes with useDatastoreMutation() but never checks who is signed ` +
|
|
960
|
+
`in - read useUser(), and when !user.id render the action inactive ` +
|
|
961
|
+
`with a "sign in" line instead of a live button that can only fail.`,
|
|
962
|
+
line,
|
|
963
|
+
snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
|
|
964
|
+
},
|
|
965
|
+
];
|
|
966
|
+
}
|
|
967
|
+
|
|
924
968
|
function _hardcodedCurrencyLabelRules(source) {
|
|
925
969
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
926
970
|
if (!code.includes(REQUEST_PAYMENT_CALL)) return [];
|
|
@@ -1201,6 +1245,7 @@ export function lintSource(source, options) {
|
|
|
1201
1245
|
// sc-4913 — soft warning: a measured width that includes the widget's own
|
|
1202
1246
|
// padding wraps the last grid column into an empty one.
|
|
1203
1247
|
findings.push(..._measuredPaddingRules(source));
|
|
1248
|
+
findings.push(..._writeGatedOnUserRules(source));
|
|
1204
1249
|
// sc-4650 — soft warning: every payment refusal reported as "try again".
|
|
1205
1250
|
findings.push(..._paymentCurrencyRules(source));
|
|
1206
1251
|
findings.push(..._hardcodedCurrencyLabelRules(source));
|
package/dist/manifest.cjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// re-exports the same functions defined here so there is exactly one
|
|
5
5
|
// implementation.
|
|
6
6
|
|
|
7
|
-
const { CONTRACT } = require("./contract.cjs");
|
|
7
|
+
const { CONTRACT, normaliseActionTriggerTypes } = require("./contract.cjs");
|
|
8
8
|
|
|
9
9
|
const PAYLOAD_VALUE_TYPES = CONTRACT.payloadValueTypes;
|
|
10
10
|
|
|
@@ -152,15 +152,16 @@ function validateManifestActions(actions, errors) {
|
|
|
152
152
|
seenKeys.add(a.key);
|
|
153
153
|
}
|
|
154
154
|
pushIf(errors, isNonEmptyString(a.name), "manifest.actions[].name must be a non-empty string");
|
|
155
|
-
|
|
155
|
+
const triggerTypes = normaliseActionTriggerTypes(a);
|
|
156
|
+
if (triggerTypes === null) {
|
|
156
157
|
errors.push(
|
|
157
|
-
`manifest.actions[].
|
|
158
|
+
`manifest.actions[].triggerTypes must be a non-empty array of unique values from ${[...VALID_ACTION_TRIGGERS].join(", ")}`,
|
|
158
159
|
);
|
|
159
|
-
} else if (
|
|
160
|
+
} else if (triggerTypes.includes("schedule")) {
|
|
160
161
|
pushIf(
|
|
161
162
|
errors,
|
|
162
163
|
isNonEmptyString(a.scheduleCron),
|
|
163
|
-
"manifest.actions[].scheduleCron is required when
|
|
164
|
+
"manifest.actions[].scheduleCron is required when triggerTypes contains 'schedule'",
|
|
164
165
|
);
|
|
165
166
|
}
|
|
166
167
|
if (!isNonEmptyString(a.scriptSource)) {
|
package/dist/manifest.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// re-exports the same functions defined here so there is exactly one
|
|
5
5
|
// implementation.
|
|
6
6
|
|
|
7
|
-
import { CONTRACT } from "./contract.js";
|
|
7
|
+
import { CONTRACT, normaliseActionTriggerTypes } from "./contract.js";
|
|
8
8
|
|
|
9
9
|
const PAYLOAD_VALUE_TYPES = CONTRACT.payloadValueTypes;
|
|
10
10
|
|
|
@@ -152,15 +152,16 @@ function validateManifestActions(actions, errors) {
|
|
|
152
152
|
seenKeys.add(a.key);
|
|
153
153
|
}
|
|
154
154
|
pushIf(errors, isNonEmptyString(a.name), "manifest.actions[].name must be a non-empty string");
|
|
155
|
-
|
|
155
|
+
const triggerTypes = normaliseActionTriggerTypes(a);
|
|
156
|
+
if (triggerTypes === null) {
|
|
156
157
|
errors.push(
|
|
157
|
-
`manifest.actions[].
|
|
158
|
+
`manifest.actions[].triggerTypes must be a non-empty array of unique values from ${[...VALID_ACTION_TRIGGERS].join(", ")}`,
|
|
158
159
|
);
|
|
159
|
-
} else if (
|
|
160
|
+
} else if (triggerTypes.includes("schedule")) {
|
|
160
161
|
pushIf(
|
|
161
162
|
errors,
|
|
162
163
|
isNonEmptyString(a.scheduleCron),
|
|
163
|
-
"manifest.actions[].scheduleCron is required when
|
|
164
|
+
"manifest.actions[].scheduleCron is required when triggerTypes contains 'schedule'",
|
|
164
165
|
);
|
|
165
166
|
}
|
|
166
167
|
if (!isNonEmptyString(a.scriptSource)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.90.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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
],
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "node scripts/build.js",
|
|
51
|
-
"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-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js"
|
|
51
|
+
"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-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|