@colixsystems/widget-sdk 0.88.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.88.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**.
64
+ `v0.89.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
 
package/dist/linter.cjs CHANGED
@@ -789,6 +789,49 @@ const CURRENCY_LABEL_RES = [
789
789
  /(?:\d|\})\s*\b(?:SEK|NOK|DKK|EUR|GBP|USD|CHF|PLN|CZK|HUF|JPY|INR)\b/,
790
790
  ];
791
791
 
792
+ // sc-4985 — soft warning: a widget that writes must decide what a signed-OUT
793
+ // visitor sees. A write needs a signed-in app user, so an anonymous visitor
794
+ // handed a live "Save" / "Book" / "Delete" button can only tap it and fail —
795
+ // the failure the gate exists to spare them. Satisfied by any identity guard:
796
+ // a negated or compared `.id`, or a `groupIds` / `roles` check. Reading
797
+ // `useUser().id` purely as a VALUE (the USER-column write pattern) is NOT a
798
+ // guard, which is why an operator has to follow it.
799
+ //
800
+ // Conservative on purpose: an unrelated `.id` comparison elsewhere in the
801
+ // source silences the rule. A warning that occasionally stays quiet is far
802
+ // cheaper than one that cries wolf on correct code.
803
+ const _IDENTITY_GUARD_RES = [
804
+ // `!user.id` / `!user?.id`
805
+ /![\s(]*\w+\??\.id\b/,
806
+ // `user.id ?` / `&&` / `||` / `===` / `!==` / `==` / `!=`
807
+ /\w+\??\.id\s*(\?[^.]|&&|\|\||===|!==|==|!=)/,
808
+ // any group / role check the brief asked for
809
+ /\bgroupIds\b/,
810
+ /\broles\b/,
811
+ ];
812
+
813
+ function _writeGatedOnUserRules(source) {
814
+ const code = _stripNonCode(source);
815
+ const call = /\buseDatastoreMutation\s*\(/.exec(code);
816
+ if (!call) return [];
817
+ if (_IDENTITY_GUARD_RES.some((re) => re.test(code))) return [];
818
+ const line = code.slice(0, call.index).split(/\r?\n/).length;
819
+ return [
820
+ {
821
+ rule: "write-not-gated-on-user",
822
+ severity: "warning",
823
+ // Kept under ~210 chars: a finding is truncated at 300 downstream, and
824
+ // the fix instruction is the half worth keeping.
825
+ label:
826
+ `writes with useDatastoreMutation() but never checks who is signed ` +
827
+ `in - read useUser(), and when !user.id render the action inactive ` +
828
+ `with a "sign in" line instead of a live button that can only fail.`,
829
+ line,
830
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
831
+ },
832
+ ];
833
+ }
834
+
792
835
  function _hardcodedCurrencyLabelRules(source) {
793
836
  const code = _stripNonCode(source, { keepStrings: true });
794
837
  if (!code.includes(REQUEST_PAYMENT_CALL)) return [];
@@ -1039,6 +1082,7 @@ function lintSource(source, options) {
1039
1082
  // sc-4913 — soft warning: a measured width that includes the widget's own
1040
1083
  // padding wraps the last grid column into an empty one.
1041
1084
  findings.push(..._measuredPaddingRules(source));
1085
+ findings.push(..._writeGatedOnUserRules(source));
1042
1086
  // sc-4650 — soft warning: every payment refusal reported as "try again".
1043
1087
  findings.push(..._paymentCurrencyRules(source));
1044
1088
  findings.push(..._hardcodedCurrencyLabelRules(source));
package/dist/linter.js CHANGED
@@ -921,6 +921,49 @@ const CURRENCY_LABEL_RES = [
921
921
  /(?:\d|\})\s*\b(?:SEK|NOK|DKK|EUR|GBP|USD|CHF|PLN|CZK|HUF|JPY|INR)\b/,
922
922
  ];
923
923
 
924
+ // sc-4985 — soft warning: a widget that writes must decide what a signed-OUT
925
+ // visitor sees. A write needs a signed-in app user, so an anonymous visitor
926
+ // handed a live "Save" / "Book" / "Delete" button can only tap it and fail —
927
+ // the failure the gate exists to spare them. Satisfied by any identity guard:
928
+ // a negated or compared `.id`, or a `groupIds` / `roles` check. Reading
929
+ // `useUser().id` purely as a VALUE (the USER-column write pattern) is NOT a
930
+ // guard, which is why an operator has to follow it.
931
+ //
932
+ // Conservative on purpose: an unrelated `.id` comparison elsewhere in the
933
+ // source silences the rule. A warning that occasionally stays quiet is far
934
+ // cheaper than one that cries wolf on correct code.
935
+ const _IDENTITY_GUARD_RES = [
936
+ // `!user.id` / `!user?.id`
937
+ /![\s(]*\w+\??\.id\b/,
938
+ // `user.id ?` / `&&` / `||` / `===` / `!==` / `==` / `!=`
939
+ /\w+\??\.id\s*(\?[^.]|&&|\|\||===|!==|==|!=)/,
940
+ // any group / role check the brief asked for
941
+ /\bgroupIds\b/,
942
+ /\broles\b/,
943
+ ];
944
+
945
+ function _writeGatedOnUserRules(source) {
946
+ const code = _stripNonCode(source);
947
+ const call = /\buseDatastoreMutation\s*\(/.exec(code);
948
+ if (!call) return [];
949
+ if (_IDENTITY_GUARD_RES.some((re) => re.test(code))) return [];
950
+ const line = code.slice(0, call.index).split(/\r?\n/).length;
951
+ return [
952
+ {
953
+ rule: "write-not-gated-on-user",
954
+ severity: "warning",
955
+ // Kept under ~210 chars: a finding is truncated at 300 downstream, and
956
+ // the fix instruction is the half worth keeping.
957
+ label:
958
+ `writes with useDatastoreMutation() but never checks who is signed ` +
959
+ `in - read useUser(), and when !user.id render the action inactive ` +
960
+ `with a "sign in" line instead of a live button that can only fail.`,
961
+ line,
962
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
963
+ },
964
+ ];
965
+ }
966
+
924
967
  function _hardcodedCurrencyLabelRules(source) {
925
968
  const code = _stripNonCode(source, { keepStrings: true });
926
969
  if (!code.includes(REQUEST_PAYMENT_CALL)) return [];
@@ -1201,6 +1244,7 @@ export function lintSource(source, options) {
1201
1244
  // sc-4913 — soft warning: a measured width that includes the widget's own
1202
1245
  // padding wraps the last grid column into an empty one.
1203
1246
  findings.push(..._measuredPaddingRules(source));
1247
+ findings.push(..._writeGatedOnUserRules(source));
1204
1248
  // sc-4650 — soft warning: every payment refusal reported as "try again".
1205
1249
  findings.push(..._paymentCurrencyRules(source));
1206
1250
  findings.push(..._hardcodedCurrencyLabelRules(source));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.88.0",
3
+ "version": "0.89.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"