@colixsystems/widget-sdk 0.87.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,67 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
61
61
 
62
62
  ## Status
63
63
 
64
- `v0.87.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.
91
+
92
+ ### What's new in 0.88.0 (contract 1.62.0)
93
+
94
+ **A refused datastore / directory / permission call now reaches the widget as its real reason (sc-4986).**
95
+
96
+ - **The reason was being thrown away.** Each `@colixsystems/*-client` throws typed
97
+ errors carrying `.code` / `.status` / `.details` (the parsed envelope) and **no
98
+ `.response`** — but `toDatastoreError`, `toDirectoryError` and
99
+ `toPermissionError` read `err.response.*` only. Every typed client rejection
100
+ fell through every branch and arrived as `code: "INTERNAL"`, so a 403 the
101
+ workspace owner has to lift was indistinguishable from a dropped socket, and
102
+ `DatastoreError.fieldErrors` never populated at all. `toPaymentError` was fixed
103
+ for exactly this in 0.83.0; these three were not.
104
+ - **All three mappers now read both shapes**, preferring the envelope's own
105
+ `message` (the canonical `{ statusCode, message, code }` field — the old code
106
+ read a `.error` key the envelope has never carried). The documented `code`
107
+ vocabularies are unchanged, so a widget already branching on
108
+ `code === "FORBIDDEN"` starts working rather than having to change.
109
+ - **`DatastoreError` / `DirectoryError` / `PermissionError` gain `retryable`**
110
+ (and `status`). `retryable === false` for a refusal only the caller, the record
111
+ or the workspace can clear — 403 / 404 / 400 / 422 / 409 — and `true` for a
112
+ timeout, a rate limit, a 5xx or a dropped socket. Branch on it instead of
113
+ offering a blanket "try again". This is deliberately *not* the payments rule:
114
+ a 402 `DECLINED` card IS worth another attempt, so that contract differs.
115
+ - **`fieldErrors` works again** — a 400/422 carrying
116
+ `errors: [{ field, code, message }]` becomes the flat `{ field: message }` map
117
+ the type has always advertised, so a form can mark the offending input.
118
+ - **New soft lint rule `datastore-error-not-branched`** (severity `warning`,
119
+ never blocks a publish): a widget that writes with `useDatastoreMutation` but
120
+ never reads `retryable`, branches on `code ===`, or renders the error's own
121
+ `.message` is flagged, so the AI widget agent's repair loop closes the gap.
122
+ - `CONTRACT.version` → `1.62.0`: the three hooks' `returnShape` entries now name
123
+ the `{ code, message, retryable }` triple. No export or signature changed —
124
+ additive fields on three error classes.
65
125
 
66
126
  ### What's new in 0.87.0 (contract 1.61.1)
67
127
 
package/dist/contract.cjs CHANGED
@@ -714,7 +714,7 @@ const HOOKS = [
714
714
  signedAt: "string | null",
715
715
  verdict: "{ valid, checks, content_status, ... } | null",
716
716
  loading: "boolean",
717
- error: "PermissionError | null",
717
+ error: "PermissionError | null // { code, message, retryable }",
718
718
  initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
719
719
  refresh: "() => Promise<void>",
720
720
  cancel: "() => Promise<void>",
@@ -824,9 +824,12 @@ const HOOKS = [
824
824
  name: "useDatastoreMutation",
825
825
  signature: "useDatastoreMutation(tableId)",
826
826
  returnShape: {
827
- create: "(record) => Promise<Record> // rejects with DatastoreError",
828
- update: "(id, partial) => Promise<Record> // rejects with DatastoreError",
829
- delete: "(id) => Promise<void> // rejects with DatastoreError",
827
+ create:
828
+ "(record) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
829
+ update:
830
+ "(id, partial) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
831
+ delete:
832
+ "(id) => Promise<void> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
830
833
  },
831
834
  requiredContextSlice: ["datastore.records"],
832
835
  scopes: ["datastore.write:*"],
@@ -942,7 +945,7 @@ const HOOKS = [
942
945
  returnShape: {
943
946
  users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
944
947
  loading: "boolean",
945
- error: "DirectoryError | null",
948
+ error: "DirectoryError | null // { code, message, retryable }",
946
949
  refetch: "() => Promise<void>",
947
950
  invite:
948
951
  "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
@@ -971,7 +974,7 @@ const HOOKS = [
971
974
  returnShape: {
972
975
  groups: "Array<{ id, name, member_count }> // snake_case rows; unwrapped from { data, meta }",
973
976
  loading: "boolean",
974
- error: "DirectoryError | null",
977
+ error: "DirectoryError | null // { code, message, retryable }",
975
978
  refetch: "() => Promise<void>",
976
979
  create:
977
980
  "({ name }) => Promise<Group> // rejects with DirectoryError",
@@ -1010,7 +1013,7 @@ const HOOKS = [
1010
1013
  message: "string | null",
1011
1014
  loading: "boolean",
1012
1015
  statusLoading: "boolean",
1013
- error: "DirectoryError | null",
1016
+ error: "DirectoryError | null // { code, message, retryable }",
1014
1017
  startLink: "() => Promise<{ order_ref, qr, auto_start_token, status }>",
1015
1018
  refresh: "() => Promise<void>",
1016
1019
  cancel: "() => Promise<void>",
@@ -1092,7 +1095,7 @@ const HOOKS = [
1092
1095
  permissions:
1093
1096
  "Array<{ id, user_id, group_id, can_read, can_write, can_delete, can_grant }> // snake_case rows; unwrapped from { data, meta }",
1094
1097
  loading: "boolean",
1095
- error: "PermissionError | null",
1098
+ error: "PermissionError | null // { code, message, retryable }",
1096
1099
  grant:
1097
1100
  "({ user_id?, group_id?, can_read?, can_write?, can_delete?, can_grant? }) => Promise<RecordPermission> // rejects with PermissionError",
1098
1101
  revoke:
@@ -2784,7 +2787,7 @@ const CONTRACT = deepFreeze({
2784
2787
  // Naming one widget is strictly more specific than restyling a scope, and
2785
2788
  // the Properties Panel stays the final word. Additive throughout: a theme
2786
2789
  // that sets none of it resolves exactly as before.
2787
- version: "1.61.1",
2790
+ version: "1.62.0",
2788
2791
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2789
2792
  hooks: HOOKS,
2790
2793
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -714,7 +714,7 @@ const HOOKS = [
714
714
  signedAt: "string | null",
715
715
  verdict: "{ valid, checks, content_status, ... } | null",
716
716
  loading: "boolean",
717
- error: "PermissionError | null",
717
+ error: "PermissionError | null // { code, message, retryable }",
718
718
  initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
719
719
  refresh: "() => Promise<void>",
720
720
  cancel: "() => Promise<void>",
@@ -824,9 +824,12 @@ const HOOKS = [
824
824
  name: "useDatastoreMutation",
825
825
  signature: "useDatastoreMutation(tableId)",
826
826
  returnShape: {
827
- create: "(record) => Promise<Record> // rejects with DatastoreError",
828
- update: "(id, partial) => Promise<Record> // rejects with DatastoreError",
829
- delete: "(id) => Promise<void> // rejects with DatastoreError",
827
+ create:
828
+ "(record) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
829
+ update:
830
+ "(id, partial) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
831
+ delete:
832
+ "(id) => Promise<void> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
830
833
  },
831
834
  requiredContextSlice: ["datastore.records"],
832
835
  scopes: ["datastore.write:*"],
@@ -942,7 +945,7 @@ const HOOKS = [
942
945
  returnShape: {
943
946
  users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
944
947
  loading: "boolean",
945
- error: "DirectoryError | null",
948
+ error: "DirectoryError | null // { code, message, retryable }",
946
949
  refetch: "() => Promise<void>",
947
950
  invite:
948
951
  "({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
@@ -971,7 +974,7 @@ const HOOKS = [
971
974
  returnShape: {
972
975
  groups: "Array<{ id, name, member_count }> // snake_case rows; unwrapped from { data, meta }",
973
976
  loading: "boolean",
974
- error: "DirectoryError | null",
977
+ error: "DirectoryError | null // { code, message, retryable }",
975
978
  refetch: "() => Promise<void>",
976
979
  create:
977
980
  "({ name }) => Promise<Group> // rejects with DirectoryError",
@@ -1010,7 +1013,7 @@ const HOOKS = [
1010
1013
  message: "string | null",
1011
1014
  loading: "boolean",
1012
1015
  statusLoading: "boolean",
1013
- error: "DirectoryError | null",
1016
+ error: "DirectoryError | null // { code, message, retryable }",
1014
1017
  startLink: "() => Promise<{ order_ref, qr, auto_start_token, status }>",
1015
1018
  refresh: "() => Promise<void>",
1016
1019
  cancel: "() => Promise<void>",
@@ -1092,7 +1095,7 @@ const HOOKS = [
1092
1095
  permissions:
1093
1096
  "Array<{ id, user_id, group_id, can_read, can_write, can_delete, can_grant }> // snake_case rows; unwrapped from { data, meta }",
1094
1097
  loading: "boolean",
1095
- error: "PermissionError | null",
1098
+ error: "PermissionError | null // { code, message, retryable }",
1096
1099
  grant:
1097
1100
  "({ user_id?, group_id?, can_read?, can_write?, can_delete?, can_grant? }) => Promise<RecordPermission> // rejects with PermissionError",
1098
1101
  revoke:
@@ -2784,7 +2787,7 @@ const CONTRACT = deepFreeze({
2784
2787
  // Naming one widget is strictly more specific than restyling a scope, and
2785
2788
  // the Properties Panel stays the final word. Additive throughout: a theme
2786
2789
  // that sets none of it resolves exactly as before.
2787
- version: "1.61.1",
2790
+ version: "1.62.0",
2788
2791
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2789
2792
  hooks: HOOKS,
2790
2793
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -941,11 +941,29 @@ export function useGeolocation(options) {
941
941
  * error message, populated when the datastore returned a structured
942
942
  * 400/422 payload with `errors: [{ field, code }, ...]`.
943
943
  */
944
+ // sc-4986 — the datastore refusals retrying the same call can never clear.
945
+ // The default for a hand-constructed error; the mappers pass the status-derived
946
+ // answer explicitly (see isRetryableDomainStatus).
947
+ const NON_RETRYABLE_DATASTORE_CODES = new Set([
948
+ "CONSTRAINT_VIOLATION",
949
+ "FORBIDDEN",
950
+ "NOT_FOUND",
951
+ "VALIDATION",
952
+ ]);
953
+
944
954
  export class DatastoreError extends Error {
945
955
  constructor(code, message, opts) {
946
956
  super(message);
947
957
  this.name = "DatastoreError";
948
958
  this.code = code;
959
+ // sc-4986 — the HTTP status behind the code, when there was one. null for a
960
+ // transport failure or a locally-raised wiring error.
961
+ this.status =
962
+ opts && typeof opts.status === "number" ? opts.status : null;
963
+ this.retryable =
964
+ opts && opts.retryable !== undefined
965
+ ? Boolean(opts.retryable)
966
+ : !NON_RETRYABLE_DATASTORE_CODES.has(code);
949
967
  if (opts && opts.fieldErrors && typeof opts.fieldErrors === "object") {
950
968
  this.fieldErrors = opts.fieldErrors;
951
969
  }
@@ -960,50 +978,113 @@ export class DatastoreError extends Error {
960
978
  * a DatastoreError with a stable `.code`. Reads `error.response.status` if
961
979
  * present (axios shape) and falls back to inspecting the message string.
962
980
  */
981
+ /* ===========================================================================
982
+ * sc-4986 — reading a refusal's real reason off a rejected domain-client call
983
+ * ==========================================================================*/
984
+
985
+ /**
986
+ * The one envelope read the three data-domain mappers share.
987
+ *
988
+ * A rejected call arrives in one of TWO shapes and both have to be read, which
989
+ * is the whole bug this exists to fix:
990
+ *
991
+ * - the `@colixsystems/*-client` packages throw their own typed errors
992
+ * (`ForbiddenError`, `ValidationError`, …) carrying `.code` / `.status` /
993
+ * `.details` — the parsed envelope — and **no `.response`**;
994
+ * - the older host path throws an axios-shaped error with `.response`.
995
+ *
996
+ * Reading only `.response` (what these mappers used to do) meant every typed
997
+ * client rejection fell through every branch and became `INTERNAL`, so a 403
998
+ * was indistinguishable from a dropped socket.
999
+ *
1000
+ * `status` is the signal the code is derived from, never the client's own
1001
+ * `.code`: an axios transport failure carries `code: "ECONNABORTED"`, which is
1002
+ * not a domain code and must never be mistaken for one. The typed client errors
1003
+ * always set a numeric `.status`, and axios keeps its status on `.response`, so
1004
+ * the two reads together cover both shapes.
1005
+ */
1006
+ function readDomainErrorEnvelope(err) {
1007
+ const response = (err && err.response) || null;
1008
+ const body = (response && response.data) || (err && err.details) || null;
1009
+ const status =
1010
+ response && typeof response.status === "number"
1011
+ ? response.status
1012
+ : err && typeof err.status === "number" && err.status
1013
+ ? err.status
1014
+ : null;
1015
+ // `message` is the canonical envelope's field (REQ-GEN-05, errorResponse.ts
1016
+ // emits `{ statusCode, message, ...extras }`); `error` is the ad-hoc key a
1017
+ // few older surfaces still emit. The envelope has never carried BOTH.
1018
+ const envelopeMessage = body && (body.message || body.error);
1019
+ return {
1020
+ status,
1021
+ code: body && typeof body.code === "string" && body.code ? body.code : null,
1022
+ message: typeof envelopeMessage === "string" ? envelopeMessage : null,
1023
+ fieldErrors: readEnvelopeFieldErrors(body),
1024
+ };
1025
+ }
1026
+
1027
+ /**
1028
+ * A flat `field -> message` map from an envelope's `errors: [{ field, code,
1029
+ * message }]` array (the shape `errorResponse(res, 4xx, msg, { errors })`
1030
+ * emits). Returns undefined when the body carries none, so the caller can leave
1031
+ * the property off the error entirely.
1032
+ */
1033
+ function readEnvelopeFieldErrors(body) {
1034
+ if (!body || !Array.isArray(body.errors)) return undefined;
1035
+ const map = {};
1036
+ for (const entry of body.errors) {
1037
+ if (entry && typeof entry.field === "string") {
1038
+ map[entry.field] = entry.message || entry.code || "Invalid value";
1039
+ }
1040
+ }
1041
+ return Object.keys(map).length > 0 ? map : undefined;
1042
+ }
1043
+
1044
+ /**
1045
+ * Whether retrying the SAME data call could plausibly succeed.
1046
+ *
1047
+ * A timeout, a rate limit, a 5xx or a dropped socket is worth another attempt;
1048
+ * a refusal (403), a missing row (404), a bad body (400/422) or a conflict
1049
+ * (409) never clears until the caller, the record or the workspace changes.
1050
+ * An absent status is a transport failure, so it counts as transient.
1051
+ *
1052
+ * Deliberately NOT shared with `NON_RETRYABLE_PAYMENT_CODES`: a payment's 402
1053
+ * DECLINED IS worth another attempt with a different card, so the payments
1054
+ * contract genuinely differs here (CLAUDE.md §3 — the divergence is the point,
1055
+ * not an accident).
1056
+ */
1057
+ function isRetryableDomainStatus(status) {
1058
+ if (status === null) return true;
1059
+ if (status === 408 || status === 429) return true;
1060
+ return status >= 500;
1061
+ }
1062
+
963
1063
  function toDatastoreError(err) {
1064
+ // An already-mapped SDK error passes straight through. NOTE: the datastore
1065
+ // CLIENT exports its own class of the same name, so a client rejection does
1066
+ // NOT match here — it is handled by the envelope read below, which is exactly
1067
+ // what used to be missing (sc-4986).
964
1068
  if (err instanceof DatastoreError) return err;
965
- const status =
966
- err && err.response && typeof err.response.status === "number"
967
- ? err.response.status
968
- : null;
969
- const bodyMessage =
970
- err &&
971
- err.response &&
972
- err.response.data &&
973
- typeof err.response.data.error === "string"
974
- ? err.response.data.error
975
- : null;
976
- const fallbackMessage =
977
- bodyMessage ||
978
- (err && typeof err.message === "string"
979
- ? err.message
980
- : "Datastore call failed");
1069
+ const { status, message, fieldErrors } = readDomainErrorEnvelope(err);
981
1070
  let code = "INTERNAL";
982
1071
  if (status === 400 || status === 422) code = "VALIDATION";
983
1072
  else if (status === 409) code = "CONSTRAINT_VIOLATION";
984
1073
  else if (status === 403) code = "FORBIDDEN";
985
1074
  else if (status === 404) code = "NOT_FOUND";
986
- // Surface a structured fieldErrors map when the server emitted one
987
- // (record.controller.js shapes 422 bodies as `{ errors: [{ field, code }] }`).
988
- let fieldErrors;
989
- if (
990
- err &&
991
- err.response &&
992
- err.response.data &&
993
- Array.isArray(err.response.data.errors)
994
- ) {
995
- const map = {};
996
- for (const entry of err.response.data.errors) {
997
- if (entry && typeof entry.field === "string") {
998
- map[entry.field] = entry.message || entry.code || "Invalid value";
999
- }
1000
- }
1001
- if (Object.keys(map).length > 0) fieldErrors = map;
1002
- }
1003
- return new DatastoreError(code, fallbackMessage, {
1004
- cause: err,
1005
- fieldErrors,
1006
- });
1075
+ return new DatastoreError(
1076
+ code,
1077
+ message ||
1078
+ (err && typeof err.message === "string"
1079
+ ? err.message
1080
+ : "Datastore call failed"),
1081
+ {
1082
+ cause: err,
1083
+ status,
1084
+ retryable: isRetryableDomainStatus(status),
1085
+ fieldErrors,
1086
+ },
1087
+ );
1007
1088
  }
1008
1089
 
1009
1090
  // sc-1579 — consecutive renders with a different serialized query before
@@ -1422,43 +1503,54 @@ export function useDatastoreMutation(table) {
1422
1503
  * - "CONFLICT" — 409 (e.g. trying to edit a template-derived row).
1423
1504
  * - "INTERNAL" — anything else (network, 5xx).
1424
1505
  */
1506
+ // sc-4986 — the permission refusals retrying the same call can never clear.
1507
+ // This mapper's code set is OPEN (the server's own code wins), so the status is
1508
+ // the primary signal and this set only backstops a hand-constructed error.
1509
+ const NON_RETRYABLE_PERMISSION_CODES = new Set([
1510
+ "CONFLICT",
1511
+ "FORBIDDEN",
1512
+ "NOT_FOUND",
1513
+ "TEMPLATE_DERIVED",
1514
+ "VALIDATION",
1515
+ ]);
1516
+
1425
1517
  export class PermissionError extends Error {
1426
1518
  constructor(code, message, opts) {
1427
1519
  super(message);
1428
1520
  this.name = "PermissionError";
1429
1521
  this.code = code;
1430
1522
  if (opts && opts.status !== undefined) this.status = opts.status;
1523
+ // sc-4986 — lets a widget tell "this will never work" from "try again".
1524
+ this.retryable =
1525
+ opts && opts.retryable !== undefined
1526
+ ? Boolean(opts.retryable)
1527
+ : !NON_RETRYABLE_PERMISSION_CODES.has(code);
1431
1528
  if (opts && opts.cause) this.cause = opts.cause;
1432
1529
  }
1433
1530
  }
1434
1531
 
1435
1532
  function toPermissionError(err) {
1533
+ // See the note in toDatastoreError — a client rejection is read from the
1534
+ // envelope below rather than matching this instanceof.
1436
1535
  if (err instanceof PermissionError) return err;
1437
- const status =
1438
- err && err.response && typeof err.response.status === "number"
1439
- ? err.response.status
1440
- : null;
1441
- const bodyCode =
1442
- err && err.response && err.response.data && err.response.data.code;
1443
- const bodyMessage =
1444
- err && err.response && err.response.data && err.response.data.error;
1536
+ const { status, code: envelopeCode, message } = readDomainErrorEnvelope(err);
1445
1537
  let code = "INTERNAL";
1446
1538
  if (status === 403) code = "FORBIDDEN";
1447
1539
  else if (status === 404) code = "NOT_FOUND";
1448
1540
  else if (status === 409) code = "CONFLICT";
1449
1541
  else if (status === 400 || status === 422) code = "VALIDATION";
1450
- if (typeof bodyCode === "string" && bodyCode) {
1451
- // Preserve the server's stable code over the status-derived one when
1452
- // the server volunteered it (e.g. TEMPLATE_DERIVED on edit/delete of
1453
- // an inherit/template row).
1454
- code = bodyCode;
1455
- }
1456
- const message =
1457
- (typeof bodyMessage === "string" && bodyMessage) ||
1458
- (err && typeof err.message === "string"
1459
- ? err.message
1460
- : "Record permission call failed");
1461
- return new PermissionError(code, message, { status, cause: err });
1542
+ // Preserve the server's stable code over the status-derived one when the
1543
+ // server volunteered it (e.g. TEMPLATE_DERIVED on edit/delete of an
1544
+ // inherit/template row) this mapper's documented code set is open.
1545
+ if (envelopeCode) code = envelopeCode;
1546
+ return new PermissionError(
1547
+ code,
1548
+ message ||
1549
+ (err && typeof err.message === "string"
1550
+ ? err.message
1551
+ : "Record permission call failed"),
1552
+ { cause: err, status, retryable: isRetryableDomainStatus(status) },
1553
+ );
1462
1554
  }
1463
1555
 
1464
1556
  const _NOOP_PERMISSIONS_RESULT = Object.freeze({
@@ -2727,41 +2819,53 @@ export function useFolderPermissions(folderId, options = {}) {
2727
2819
  * is invite-only and the email is not on the list.
2728
2820
  * - "INTERNAL" — anything else (network, 5xx).
2729
2821
  */
2822
+ // sc-4986 — the directory refusals retrying the same call can never clear.
2823
+ const NON_RETRYABLE_DIRECTORY_CODES = new Set([
2824
+ "FORBIDDEN",
2825
+ "INVITE_ONLY",
2826
+ "NOT_FOUND",
2827
+ "VALIDATION",
2828
+ ]);
2829
+
2730
2830
  export class DirectoryError extends Error {
2731
2831
  constructor(code, message, opts) {
2732
2832
  super(message);
2733
2833
  this.name = "DirectoryError";
2734
2834
  this.code = code;
2835
+ // sc-4986 — the HTTP status behind the code, when there was one.
2836
+ this.status =
2837
+ opts && typeof opts.status === "number" ? opts.status : null;
2838
+ this.retryable =
2839
+ opts && opts.retryable !== undefined
2840
+ ? Boolean(opts.retryable)
2841
+ : !NON_RETRYABLE_DIRECTORY_CODES.has(code);
2735
2842
  if (opts && opts.cause) this.cause = opts.cause;
2736
2843
  }
2737
2844
  }
2738
2845
 
2739
2846
  function toDirectoryError(err) {
2847
+ // See the note in toDatastoreError: the directory CLIENT has a same-named
2848
+ // class, so a client rejection is read from the envelope below, not here.
2740
2849
  if (err instanceof DirectoryError) return err;
2741
- const status =
2742
- err && err.response && typeof err.response.status === "number"
2743
- ? err.response.status
2744
- : null;
2745
- const bodyCode =
2746
- err && err.response && err.response.data && err.response.data.code;
2747
- const bodyMessage =
2748
- err && err.response && err.response.data && err.response.data.error;
2850
+ const { status, code: envelopeCode, message } = readDomainErrorEnvelope(err);
2749
2851
  let code = "INTERNAL";
2750
- if (bodyCode === "INVITE_ONLY") code = "INVITE_ONLY";
2852
+ if (envelopeCode === "INVITE_ONLY") code = "INVITE_ONLY";
2751
2853
  else if (status === 403) code = "FORBIDDEN";
2752
2854
  else if (status === 404) code = "NOT_FOUND";
2753
2855
  else if (status === 400 || status === 422) code = "VALIDATION";
2754
2856
  else if (status === 409) {
2755
2857
  // 409 is invite-only on the invite endpoints; treat the rest as
2756
2858
  // validation conflicts (duplicate email, etc.).
2757
- code = bodyCode === "INVITE_ONLY" ? "INVITE_ONLY" : "VALIDATION";
2859
+ code = envelopeCode === "INVITE_ONLY" ? "INVITE_ONLY" : "VALIDATION";
2758
2860
  }
2759
- const message =
2760
- (typeof bodyMessage === "string" && bodyMessage) ||
2761
- (err && typeof err.message === "string"
2762
- ? err.message
2763
- : "Directory call failed");
2764
- return new DirectoryError(code, message, { cause: err });
2861
+ return new DirectoryError(
2862
+ code,
2863
+ message ||
2864
+ (err && typeof err.message === "string"
2865
+ ? err.message
2866
+ : "Directory call failed"),
2867
+ { cause: err, status, retryable: isRetryableDomainStatus(status) },
2868
+ );
2765
2869
  }
2766
2870
 
2767
2871
  /**
package/dist/index.d.ts CHANGED
@@ -1275,11 +1275,22 @@ export class DatastoreError extends Error {
1275
1275
  | "NOT_FOUND"
1276
1276
  | "INTERNAL";
1277
1277
  fieldErrors?: Record<string, string>;
1278
+ /** sc-4986 — the HTTP status behind the code; null for a transport failure. */
1279
+ status: number | null;
1280
+ /**
1281
+ * sc-4986 — whether retrying the SAME call could plausibly succeed. False
1282
+ * for a refusal only the caller, the record or the workspace can clear
1283
+ * (403/404/400/422/409); true for a timeout, a rate limit, a 5xx or a
1284
+ * dropped socket. Branch on this instead of offering a blanket retry.
1285
+ */
1286
+ retryable: boolean;
1278
1287
  constructor(
1279
1288
  code: DatastoreError["code"],
1280
1289
  message: string,
1281
1290
  opts?: {
1282
1291
  fieldErrors?: Record<string, string>;
1292
+ status?: number | null;
1293
+ retryable?: boolean;
1283
1294
  cause?: unknown;
1284
1295
  },
1285
1296
  );
@@ -1297,10 +1308,18 @@ export class DirectoryError extends Error {
1297
1308
  | "NOT_FOUND"
1298
1309
  | "INVITE_ONLY"
1299
1310
  | "INTERNAL";
1311
+ /** sc-4986 — the HTTP status behind the code; null for a transport failure. */
1312
+ status: number | null;
1313
+ /** sc-4986 — whether retrying the SAME call could plausibly succeed. */
1314
+ retryable: boolean;
1300
1315
  constructor(
1301
1316
  code: DirectoryError["code"],
1302
1317
  message: string,
1303
- opts?: { cause?: unknown },
1318
+ opts?: {
1319
+ status?: number | null;
1320
+ retryable?: boolean;
1321
+ cause?: unknown;
1322
+ },
1304
1323
  );
1305
1324
  }
1306
1325
 
@@ -1620,10 +1639,16 @@ export class PermissionError extends Error {
1620
1639
  | "INTERNAL"
1621
1640
  | string;
1622
1641
  status?: number;
1642
+ /** sc-4986 — whether retrying the SAME call could plausibly succeed. */
1643
+ retryable: boolean;
1623
1644
  constructor(
1624
1645
  code: PermissionError["code"],
1625
1646
  message: string,
1626
- opts?: { status?: number; cause?: unknown },
1647
+ opts?: {
1648
+ status?: number | null;
1649
+ retryable?: boolean;
1650
+ cause?: unknown;
1651
+ },
1627
1652
  );
1628
1653
  }
1629
1654
 
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 [];
@@ -845,6 +888,37 @@ function _paymentErrorHandlingRules(source) {
845
888
  ];
846
889
  }
847
890
 
891
+ // sc-4986 — soft warning, the datastore twin of `payment-error-not-branched`.
892
+ // A write can be refused for reasons a retry never clears (the table's grants,
893
+ // a validation failure, a row that is gone), and the server sends a user-safe
894
+ // sentence saying which. A widget that collapses every rejection into one
895
+ // generic "something went wrong" throws that sentence away and leaves the user
896
+ // pressing the button again. Reading `DatastoreError.retryable`, branching on
897
+ // `code ===`, or rendering the error's own `.message` all satisfy it; strings
898
+ // and comments are blanked so prose about errors never does.
899
+ function _datastoreErrorHandlingRules(source) {
900
+ const code = _stripNonCode(source);
901
+ const call = /\buseDatastoreMutation\s*\(/.exec(code);
902
+ if (!call) return [];
903
+ if (/\bretryable\b/.test(code)) return [];
904
+ if (/\bcode\s*===/.test(code)) return [];
905
+ if (/\.message\b/.test(code)) return [];
906
+ const line = code.slice(0, call.index).split(/\r?\n/).length;
907
+ return [
908
+ {
909
+ rule: "datastore-error-not-branched",
910
+ severity: "warning",
911
+ label:
912
+ `writes with useDatastoreMutation() but never reports why a write ` +
913
+ `failed — read DatastoreError.retryable (or err.message) and show the ` +
914
+ `server's reason, so a refusal the user cannot clear is not offered ` +
915
+ `as "try again".`,
916
+ line,
917
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
918
+ },
919
+ ];
920
+ }
921
+
848
922
  function _imagePercentHeightRules(source) {
849
923
  const findings = [];
850
924
  const code = _stripNonCode(source, { keepStrings: true });
@@ -1008,10 +1082,12 @@ function lintSource(source, options) {
1008
1082
  // sc-4913 — soft warning: a measured width that includes the widget's own
1009
1083
  // padding wraps the last grid column into an empty one.
1010
1084
  findings.push(..._measuredPaddingRules(source));
1085
+ findings.push(..._writeGatedOnUserRules(source));
1011
1086
  // sc-4650 — soft warning: every payment refusal reported as "try again".
1012
1087
  findings.push(..._paymentCurrencyRules(source));
1013
1088
  findings.push(..._hardcodedCurrencyLabelRules(source));
1014
1089
  findings.push(..._paymentErrorHandlingRules(source));
1090
+ findings.push(..._datastoreErrorHandlingRules(source));
1015
1091
  findings.push(
1016
1092
  ..._scopeRules(source, options && options.manifest).map((f) => ({
1017
1093
  ...f,
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 [];
@@ -977,6 +1020,37 @@ function _paymentErrorHandlingRules(source) {
977
1020
  ];
978
1021
  }
979
1022
 
1023
+ // sc-4986 — soft warning, the datastore twin of `payment-error-not-branched`.
1024
+ // A write can be refused for reasons a retry never clears (the table's grants,
1025
+ // a validation failure, a row that is gone), and the server sends a user-safe
1026
+ // sentence saying which. A widget that collapses every rejection into one
1027
+ // generic "something went wrong" throws that sentence away and leaves the user
1028
+ // pressing the button again. Reading `DatastoreError.retryable`, branching on
1029
+ // `code ===`, or rendering the error's own `.message` all satisfy it; strings
1030
+ // and comments are blanked so prose about errors never does.
1031
+ function _datastoreErrorHandlingRules(source) {
1032
+ const code = _stripNonCode(source);
1033
+ const call = /\buseDatastoreMutation\s*\(/.exec(code);
1034
+ if (!call) return [];
1035
+ if (/\bretryable\b/.test(code)) return [];
1036
+ if (/\bcode\s*===/.test(code)) return [];
1037
+ if (/\.message\b/.test(code)) return [];
1038
+ const line = code.slice(0, call.index).split(/\r?\n/).length;
1039
+ return [
1040
+ {
1041
+ rule: "datastore-error-not-branched",
1042
+ severity: "warning",
1043
+ label:
1044
+ `writes with useDatastoreMutation() but never reports why a write ` +
1045
+ `failed — read DatastoreError.retryable (or err.message) and show the ` +
1046
+ `server's reason, so a refusal the user cannot clear is not offered ` +
1047
+ `as "try again".`,
1048
+ line,
1049
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
1050
+ },
1051
+ ];
1052
+ }
1053
+
980
1054
  function _imagePercentHeightRules(source) {
981
1055
  const findings = [];
982
1056
  // Comments are blanked (string contents kept) so a commented-out example —
@@ -1170,10 +1244,12 @@ export function lintSource(source, options) {
1170
1244
  // sc-4913 — soft warning: a measured width that includes the widget's own
1171
1245
  // padding wraps the last grid column into an empty one.
1172
1246
  findings.push(..._measuredPaddingRules(source));
1247
+ findings.push(..._writeGatedOnUserRules(source));
1173
1248
  // sc-4650 — soft warning: every payment refusal reported as "try again".
1174
1249
  findings.push(..._paymentCurrencyRules(source));
1175
1250
  findings.push(..._hardcodedCurrencyLabelRules(source));
1176
1251
  findings.push(..._paymentErrorHandlingRules(source));
1252
+ findings.push(..._datastoreErrorHandlingRules(source));
1177
1253
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
1178
1254
  // line-by-line scan so banned-identifier findings stay first in the
1179
1255
  // output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.87.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"
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"