@colixsystems/widget-sdk 0.173.1 → 0.174.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
@@ -44,7 +44,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
44
44
  | **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
45
45
  | **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
46
46
  | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
47
- | **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, fetching, error, refetch }` — a refetch of the same record keeps `data` while `fetching` | `records(table).get` — `datastore.read:<table>` |
47
+ | **DATASTORE** | `useDatastoreRecord(table, id, options)` (optional: options) | `{ data, loading, fetching, error, refetch }` — a refetch of the same record keeps `data` while `fetching`; `{ includeCapabilities: true }` adds `data._capabilities` | `records(table).get` — `datastore.read:<table>` |
48
48
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
49
49
  | **DATASTORE** | `useBoundColumns(tableId, shape, props)` | `{ columns, resolved, missing, loading, error }` | `schema(tableId)` (built on `useDatastoreSchema`) — `datastore.read:<table>`. Resolves author-bound column NAMES from `props` by exact name → case-insensitive name → first unclaimed column matching `shape[key].dataType`, so a column an author renamed after install still resolves instead of `record[props.titleField]` reading `undefined`. `columns` holds the resolved NAME (`record[columns.titleField]`); `resolved` holds the full `Column`; `missing` lists non-`optional` keys that never resolved. Falsy `tableId` collapses to `{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null }`. |
50
50
  | **DATASTORE** | `useInterpretDraft(tableId)` | `{ interpret, interpreting, error, result, available }` | `interpret(tableId, body)` — `datastore.read:<table>`. Turns ONE sentence a user typed ("walk at 11 am tomorrow") into DRAFT column values so a form can prefill itself. IMPERATIVE: call `interpret(text, { fields?, timeZone? })` from an event handler, never on mount. It DRAFTS and writes nothing — show the values for review, then submit through `useDatastoreMutation().create`. Resolves to `{ values, unresolved }`; `values` is keyed by column NAME (the shape `create()` takes) and `unresolved` names the fields the sentence did not state. Only text / number / boolean / date / datetime / array columns are drafted — `FILE`, `RELATION`, `USER` and `USER_GROUP` carry ids and are never guessed. Fails closed to an empty draft. **Every call spends the workspace's AI credits** and is rate-limited per actor, so call it once per user action (never on mount or in a render loop); once the workspace runs out the call is refused with a generic 429 — an app user is deliberately **not** told the workspace's billing state, since they have never heard of an AI credit and cannot buy one. Never surface a raw error to the person filling the form: say drafting is unavailable and keep every field editable by hand. `available` is false where the host brokers no interpreter. |
@@ -73,7 +73,22 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
73
73
 
74
74
  ## Status
75
75
 
76
- `v0.173.1` — 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**.
76
+ `v0.174.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**.
77
+
78
+ ### What's new in 0.174.0 (contract 1.135.0)
79
+
80
+ **Show a row's Edit / Delete only to someone allowed to use it: `includeCapabilities`.** A list gated on `useCanWrite(tableId)` offered Edit and Delete on every row, because the table-level answer only says whether the user may *create*. On a table where each user owns the rows they created, that is true for every signed-in user, so the server refused every edit and delete on someone else's row. Ask the read for each row's own answer instead:
81
+
82
+ ```jsx
83
+ const { data: rows, refetch } = useDatastoreQuery(tableId, { limit: 50, includeCapabilities: true });
84
+ const { delete: remove } = useDatastoreMutation(tableId);
85
+ // inside rows.map((row) => …)
86
+ {row._capabilities?.can_delete && (
87
+ <Pressable onPress={() => remove(row.id).then(refetch)}><Text>Delete</Text></Pressable>
88
+ )}
89
+ ```
90
+
91
+ Every row arrives with `_capabilities: { can_read, can_write, can_delete, can_grant }`, the same answer `update` / `delete` enforce, computed for the whole page in one lookup. `useDatastoreRecord(tableId, recordId, { includeCapabilities: true })` does the same for one record. Without the option, rows are unchanged. The new linter warning `row-action-not-gated` flags a widget that calls `update` or `delete` with no per-row check (`_capabilities`, or `useCanWrite(tableId, { recordId })` on a single-record page).
77
92
 
78
93
  ### What's new in 0.173.1 (contract 1.134.1)
79
94
 
@@ -2168,7 +2183,7 @@ export default function OpenTasks({ tableId, titleField, statusField }) {
2168
2183
  }
2169
2184
  ```
2170
2185
 
2171
- `useCanWrite` answers "is this table's ACL open to me" — it is a FLOOR, not the whole rule. A widget whose own logic is more specific ("only the assignee may edit this row") still hand-checks that in addition, typically with `options.recordId` passed to a second `useCanWrite` call or a plain `user.id === record[assigneeField]` comparison.
2186
+ `useCanWrite(tableId)` answers "may I create a row here". It says nothing about changing an EXISTING row: gate a list's Edit / Delete on each row's `_capabilities` (read with `includeCapabilities: true`, see *What's new in 0.174.0*), and a single-record page on `useCanWrite(tableId, { recordId })` or `useDatastoreRecord(…, { includeCapabilities: true })`. Never call `useCanWrite` with a `recordId` per list row — that is one request per row. Either answer is a FLOOR, not the whole rule: a widget whose own logic is more specific ("only the assignee may edit this row") still hand-checks that in addition (`user.id === record[assigneeField]`).
2172
2187
 
2173
2188
  ## Cross-platform widgets (single-file vs split)
2174
2189
 
@@ -2239,6 +2254,8 @@ Pass `--manifest` to enable `style-field-unread` and the three editability rules
2239
2254
 
2240
2255
  `flex-basis-percent` flags a literal percentage `flexBasis` (sc-7274) — `"100%"`, `"50%"`, or the ternary a responsive cell writes. `flex-basis` sizes the MAIN axis, so in a column parent it asks for the parent's HEIGHT: under an ancestor that makes that height definite (a Grid cell stretches its child on the web Player) the child fills the container and, since a `View` never shrinks, overflows over everything below it. Say full-width with `width: "100%"` — correct in a column AND in a wrap row — and keep a NUMBER for a wrap threshold. A warning, because the percentage is right in a row and a text scan cannot see the parent.
2241
2256
 
2257
+ `row-action-not-gated` flags a widget that calls `update` or `delete` from `useDatastoreMutation` without a per-row permission check (sc-9132): no `_capabilities` read and no `useCanWrite(tableId, { recordId })`. The table-level `useCanWrite(tableId)` only answers "may create", so a list gated on it shows Edit / Delete on rows the server will refuse. A warning.
2258
+
2242
2259
  `no-html-in-content` flags an HTML tag inside a **string** (sc-6970) — `"<strong>"`, `"<br>"`, `"<p>…</p>"`. There is no HTML renderer on either host, so the tag reaches the reader as literal text; author formatted text with `<MarkdownInput>` and render it with `<RichText>` instead. Only string and template content is scanned, so your own `<View>` / `<Text>` JSX can never trip it. A warning for a human author, blocking for the AI widget agent.
2243
2260
 
2244
2261
  ## Local dev loop (`appstudio-widget dev`)
package/dist/contract.cjs CHANGED
@@ -1018,11 +1018,14 @@ const HOOKS = [
1018
1018
  },
1019
1019
  {
1020
1020
  name: "useDatastoreRecord",
1021
- signature: "useDatastoreRecord(tableId, recordId)",
1021
+ signature: "useDatastoreRecord(tableId, recordId, options)",
1022
+ optionalArgs: ["options"],
1022
1023
  description:
1023
1024
  "Reads one row. Re-reading the same record (refetch, pull-to-refresh) " +
1024
1025
  "keeps it in `data` with loading false while `fetching` is true; a " +
1025
- "different table or record id resets to loading.",
1026
+ "different table or record id resets to loading. " +
1027
+ "`{ includeCapabilities: true }` adds `_capabilities` to the row, as " +
1028
+ "for useDatastoreQuery.",
1026
1029
  returnShape: {
1027
1030
  data: "Record | null",
1028
1031
  loading: "boolean",
@@ -1371,7 +1374,12 @@ const HOOKS = [
1371
1374
  "until the first result for the bound table lands; a changed query on " +
1372
1375
  "that table, refetch() and pull-to-refresh keep the rows already in " +
1373
1376
  "`data` with loading false, while `fetching` is true. Binding a " +
1374
- "different table resets to loading. Show a spinner on `loading` only.",
1377
+ "different table resets to loading. Show a spinner on `loading` only. " +
1378
+ "Pass `includeCapabilities: true` in options to stamp each row with " +
1379
+ "`_capabilities` { can_read, can_write, can_delete, can_grant } — what " +
1380
+ "the signed-in user may do to THAT row, the answer update/delete " +
1381
+ "enforce, computed for the whole page in one lookup. Gate a row's " +
1382
+ "Edit on `row._capabilities?.can_write` and Delete on `can_delete`.",
1375
1383
  returnShape: {
1376
1384
  data: "Record[]",
1377
1385
  loading: "boolean",
@@ -1886,7 +1894,8 @@ const HOOKS = [
1886
1894
  "domain-specific write rules: a widget whose rule is more specific than the table ACL (e.g. 'only the assigned user may edit this row') must " +
1887
1895
  "still hand-check that in addition to useCanWrite (user.id === record[assigneeField]). It only answers 'signed in AND permitted' — it does " +
1888
1896
  "NOT distinguish 'not signed in' from 'signed in but forbidden' (both resolve canWrite:false); pair it with useUser() when the UI needs to " +
1889
- "tell those apart. myPermissions is a newer client method: a host that has not injected it is NOT a hard error — this hook collapses to " +
1897
+ "tell those apart. For a LIST, gate each row's edit/delete on useDatastoreQuery's `includeCapabilities` answer instead — a " +
1898
+ "{ recordId } check per row is one request per row, and the table-level answer only says whether they may CREATE. myPermissions is a newer client method: a host that has not injected it is NOT a hard error — this hook collapses to " +
1890
1899
  "{ canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. Same collapse when tableId is falsy.",
1891
1900
  returnShape: {
1892
1901
  canWrite: "boolean",
@@ -4671,7 +4680,11 @@ const CONTRACT = deepFreeze({
4671
4680
  // a `ListRow`'s lone `View` child spans the row; a selected row's bar
4672
4681
  // no longer shifts its content. Other rows render as before.
4673
4682
  // 1.134.1: docs (sc-9122) — an emitted event can run a bound action.
4674
- version: "1.134.1",
4683
+ // 1.135.0: additive (sc-9132) — `includeCapabilities` on useDatastoreQuery
4684
+ // options and a new `options` arg on useDatastoreRecord stamp each row
4685
+ // with `_capabilities`, so a widget shows Edit/Delete only on rows the
4686
+ // user may change. New linter warning `row-action-not-gated`.
4687
+ version: "1.135.0",
4675
4688
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4676
4689
  hooks: HOOKS,
4677
4690
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1018,11 +1018,14 @@ const HOOKS = [
1018
1018
  },
1019
1019
  {
1020
1020
  name: "useDatastoreRecord",
1021
- signature: "useDatastoreRecord(tableId, recordId)",
1021
+ signature: "useDatastoreRecord(tableId, recordId, options)",
1022
+ optionalArgs: ["options"],
1022
1023
  description:
1023
1024
  "Reads one row. Re-reading the same record (refetch, pull-to-refresh) " +
1024
1025
  "keeps it in `data` with loading false while `fetching` is true; a " +
1025
- "different table or record id resets to loading.",
1026
+ "different table or record id resets to loading. " +
1027
+ "`{ includeCapabilities: true }` adds `_capabilities` to the row, as " +
1028
+ "for useDatastoreQuery.",
1026
1029
  returnShape: {
1027
1030
  data: "Record | null",
1028
1031
  loading: "boolean",
@@ -1371,7 +1374,12 @@ const HOOKS = [
1371
1374
  "until the first result for the bound table lands; a changed query on " +
1372
1375
  "that table, refetch() and pull-to-refresh keep the rows already in " +
1373
1376
  "`data` with loading false, while `fetching` is true. Binding a " +
1374
- "different table resets to loading. Show a spinner on `loading` only.",
1377
+ "different table resets to loading. Show a spinner on `loading` only. " +
1378
+ "Pass `includeCapabilities: true` in options to stamp each row with " +
1379
+ "`_capabilities` { can_read, can_write, can_delete, can_grant } — what " +
1380
+ "the signed-in user may do to THAT row, the answer update/delete " +
1381
+ "enforce, computed for the whole page in one lookup. Gate a row's " +
1382
+ "Edit on `row._capabilities?.can_write` and Delete on `can_delete`.",
1375
1383
  returnShape: {
1376
1384
  data: "Record[]",
1377
1385
  loading: "boolean",
@@ -1886,7 +1894,8 @@ const HOOKS = [
1886
1894
  "domain-specific write rules: a widget whose rule is more specific than the table ACL (e.g. 'only the assigned user may edit this row') must " +
1887
1895
  "still hand-check that in addition to useCanWrite (user.id === record[assigneeField]). It only answers 'signed in AND permitted' — it does " +
1888
1896
  "NOT distinguish 'not signed in' from 'signed in but forbidden' (both resolve canWrite:false); pair it with useUser() when the UI needs to " +
1889
- "tell those apart. myPermissions is a newer client method: a host that has not injected it is NOT a hard error — this hook collapses to " +
1897
+ "tell those apart. For a LIST, gate each row's edit/delete on useDatastoreQuery's `includeCapabilities` answer instead — a " +
1898
+ "{ recordId } check per row is one request per row, and the table-level answer only says whether they may CREATE. myPermissions is a newer client method: a host that has not injected it is NOT a hard error — this hook collapses to " +
1890
1899
  "{ canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. Same collapse when tableId is falsy.",
1891
1900
  returnShape: {
1892
1901
  canWrite: "boolean",
@@ -4667,7 +4676,11 @@ const CONTRACT = deepFreeze({
4667
4676
  // a `ListRow`'s lone `View` child spans the row; a selected row's bar
4668
4677
  // no longer shifts its content. Other rows render as before.
4669
4678
  // 1.134.1: docs (sc-9122) — an emitted event can run a bound action.
4670
- version: "1.134.1",
4679
+ // 1.135.0: additive (sc-9132) — `includeCapabilities` on useDatastoreQuery
4680
+ // options and a new `options` arg on useDatastoreRecord stamp each row
4681
+ // with `_capabilities`, so a widget shows Edit/Delete only on rows the
4682
+ // user may change. New linter warning `row-action-not-gated`.
4683
+ version: "1.135.0",
4671
4684
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4672
4685
  hooks: HOOKS,
4673
4686
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -2185,7 +2185,8 @@ function serializeQuery(query) {
2185
2185
  }
2186
2186
 
2187
2187
  const queryResultKey = (table, queryKey) => JSON.stringify([table, queryKey]);
2188
- const recordResultKey = (table, recordId) => JSON.stringify([table, recordId]);
2188
+ const recordResultKey = (table, recordId, includeCapabilities = false) =>
2189
+ JSON.stringify([table, recordId, includeCapabilities]);
2189
2190
 
2190
2191
  /**
2191
2192
  * Stateful datastore query hook. Returns { data, loading, fetching, error,
@@ -2202,6 +2203,10 @@ const recordResultKey = (table, recordId) => JSON.stringify([table, recordId]);
2202
2203
  * re-fetch when [table, JSON.stringify(query)] changes. `refetch` re-runs the
2203
2204
  * same call on demand.
2204
2205
  *
2206
+ * sc-9132 — `query.includeCapabilities: true` stamps each row with
2207
+ * `_capabilities` ({ can_read, can_write, can_delete, can_grant }), computed
2208
+ * for the whole page in one server lookup. Gate a row's Edit / Delete on it.
2209
+ *
2205
2210
  * sc-8595 — stale-while-revalidate. `loading` is true only until the first
2206
2211
  * result for the bound TABLE lands. A changed query on that table, `refetch()`
2207
2212
  * and a page pull-to-refresh keep the rows already in `data` on screen with
@@ -2362,8 +2367,12 @@ export function useDatastoreQuery(table, query) {
2362
2367
  * fetching: false, error: null, refetch } so the widget can render its empty state
2363
2368
  * without throwing. A 404 surfaces as a DatastoreError with
2364
2369
  * `code: "NOT_FOUND"`.
2370
+ *
2371
+ * sc-9132 — pass `{ includeCapabilities: true }` to have the row carry
2372
+ * `_capabilities` ({ can_read, can_write, can_delete, can_grant }): what the
2373
+ * signed-in user may do to THIS record, the same answer update/delete enforce.
2365
2374
  */
2366
- export function useDatastoreRecord(table, recordId) {
2375
+ export function useDatastoreRecord(table, recordId, options) {
2367
2376
  const ctx = useWidgetContextOrThrow("useDatastoreRecord");
2368
2377
  if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
2369
2378
  throw new Error(
@@ -2378,11 +2387,14 @@ export function useDatastoreRecord(table, recordId) {
2378
2387
  // Same ref discipline as useDatastoreQuery — `ctx` is a fresh object
2379
2388
  // identity on every host render, so we hold the live inputs in refs
2380
2389
  // to keep `refetch` a stable callback.
2390
+ const includeCapabilities = options?.includeCapabilities === true;
2381
2391
  const tableRef = useRef(table);
2382
2392
  const recordIdRef = useRef(recordId);
2393
+ const includeCapabilitiesRef = useRef(includeCapabilities);
2383
2394
  const recordsRef = useRef(ctx.datastore.records);
2384
2395
  tableRef.current = table;
2385
2396
  recordIdRef.current = recordId;
2397
+ includeCapabilitiesRef.current = includeCapabilities;
2386
2398
  recordsRef.current = ctx.datastore.records;
2387
2399
 
2388
2400
  const runRef = useRef(0);
@@ -2396,11 +2408,13 @@ export function useDatastoreRecord(table, recordId) {
2396
2408
  setSettled(NO_RECORD_RESULT);
2397
2409
  return;
2398
2410
  }
2399
- const key = recordResultKey(t, id);
2411
+ const key = recordResultKey(t, id, includeCapabilitiesRef.current);
2400
2412
  setInFlight(true);
2401
2413
  try {
2402
2414
  const ns = recordsRef.current(t);
2403
- const row = await ns.get(id);
2415
+ const row = includeCapabilitiesRef.current
2416
+ ? await ns.get(id, { includeCapabilities: true })
2417
+ : await ns.get(id);
2404
2418
  if (runRef.current !== myRun) return;
2405
2419
  setSettled({ key, data: row || null, error: null, loaded: true });
2406
2420
  setInFlight(false);
@@ -2419,7 +2433,7 @@ export function useDatastoreRecord(table, recordId) {
2419
2433
  useEffect(() => {
2420
2434
  doFetch();
2421
2435
  // eslint-disable-next-line react-hooks/exhaustive-deps
2422
- }, [table, recordId]);
2436
+ }, [table, recordId, includeCapabilities]);
2423
2437
 
2424
2438
  const refetch = useCallback(async () => {
2425
2439
  await doFetch();
@@ -2428,7 +2442,8 @@ export function useDatastoreRecord(table, recordId) {
2428
2442
  // sc-1179 — auto-subscribe to the page-level refresh tick.
2429
2443
  useRefresh(refetch);
2430
2444
 
2431
- const currentKey = table && recordId ? recordResultKey(table, recordId) : null;
2445
+ const currentKey =
2446
+ table && recordId ? recordResultKey(table, recordId, includeCapabilities) : null;
2432
2447
  const busy = currentKey !== null && (settled.key !== currentKey || inFlight);
2433
2448
  const hasRecord = currentKey !== null && settled.key === currentKey && settled.loaded;
2434
2449
  return {
package/dist/index.d.ts CHANGED
@@ -822,6 +822,23 @@ export interface Query {
822
822
  sort?: Array<{ field: string; dir: "asc" | "desc" }>;
823
823
  limit?: number;
824
824
  cursor?: string;
825
+ /**
826
+ * Stamp each row with `_capabilities` — what the signed-in user may do to
827
+ * that row. Gate a row's Edit / Delete on it.
828
+ */
829
+ includeCapabilities?: boolean;
830
+ }
831
+
832
+ /**
833
+ * What the signed-in user may do to one record: the same answer the update /
834
+ * delete endpoints enforce. Present as `row._capabilities` only when the read
835
+ * passed `includeCapabilities: true`.
836
+ */
837
+ export interface RecordCapabilities {
838
+ can_read: boolean;
839
+ can_write: boolean;
840
+ can_delete: boolean;
841
+ can_grant: boolean;
825
842
  }
826
843
 
827
844
  export interface QueryResult<T> {
@@ -1065,6 +1082,7 @@ export function useWidgetStyle(): Record<string, unknown>;
1065
1082
  export function useDatastoreRecord(
1066
1083
  tableId: string | null | undefined,
1067
1084
  recordId: string | null | undefined,
1085
+ options?: { includeCapabilities?: boolean },
1068
1086
  ): {
1069
1087
  data: unknown | null;
1070
1088
  loading: boolean;
package/dist/linter.cjs CHANGED
@@ -1189,6 +1189,45 @@ function _handRolledWriteGateRules(source) {
1189
1189
  ];
1190
1190
  }
1191
1191
 
1192
+ // sc-9132 — soft warning: a row's Edit / Delete shown whatever that row's
1193
+ // permissions say. Table-level `useCanWrite(tableId)` only answers "may
1194
+ // create", which on a creator-owned table is true for every signed-in user,
1195
+ // so every row offered actions the server then refused.
1196
+ function _rowActionGatingRules(source) {
1197
+ const code = _stripNonCode(source);
1198
+ const call = _rowMutationCall(code);
1199
+ if (!call) return [];
1200
+ if (/\b_capabilities\b/.test(code)) return [];
1201
+ if (/\buseCanWrite\s*\([^)]*\brecordId\b/.test(code)) return [];
1202
+ const line = code.slice(0, call.index).split(/\r?\n/).length;
1203
+ return [
1204
+ {
1205
+ rule: "row-action-not-gated",
1206
+ severity: "warning",
1207
+ label:
1208
+ `edits or deletes rows without checking that row's permission - read ` +
1209
+ `with { includeCapabilities: true } and show Edit/Delete only when ` +
1210
+ `row._capabilities.can_write / can_delete is true.`,
1211
+ line,
1212
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
1213
+ },
1214
+ ];
1215
+ }
1216
+
1217
+ // The useDatastoreMutation() call whose update or delete the widget uses,
1218
+ // either as `m.update(` / `m.delete(` or destructured `{ update, delete }`.
1219
+ function _rowMutationCall(code) {
1220
+ const hookCall = /(?:\{([^}]*)\}|\b(\w+))\s*=\s*useDatastoreMutation\s*\(/g;
1221
+ for (const match of code.matchAll(hookCall)) {
1222
+ const [, destructured, name] = match;
1223
+ const usesRowVerb = destructured
1224
+ ? /\b(update|delete)\b/.test(destructured)
1225
+ : new RegExp(`\\b${name}\\s*\\.\\s*(update|delete)\\s*\\(`).test(code);
1226
+ if (usesRowVerb) return match;
1227
+ }
1228
+ return null;
1229
+ }
1230
+
1192
1231
  function _imagePercentHeightRules(source) {
1193
1232
  const findings = [];
1194
1233
  const code = _stripNonCode(source, { keepStrings: true });
@@ -1839,6 +1878,7 @@ function lintSource(source, options) {
1839
1878
  // sc-5206 — soft warnings steering a widget toward the two new hooks.
1840
1879
  findings.push(..._rawUseMemoIntoDatastoreQueryRules(source));
1841
1880
  findings.push(..._handRolledWriteGateRules(source));
1881
+ findings.push(..._rowActionGatingRules(source));
1842
1882
  findings.push(
1843
1883
  ..._scopeRules(source, options && options.manifest).map((f) => ({
1844
1884
  ...f,
package/dist/linter.js CHANGED
@@ -1210,6 +1210,45 @@ function _handRolledWriteGateRules(source) {
1210
1210
  ];
1211
1211
  }
1212
1212
 
1213
+ // sc-9132 — soft warning: a row's Edit / Delete shown whatever that row's
1214
+ // permissions say. Table-level `useCanWrite(tableId)` only answers "may
1215
+ // create", which on a creator-owned table is true for every signed-in user,
1216
+ // so every row offered actions the server then refused.
1217
+ function _rowActionGatingRules(source) {
1218
+ const code = _stripNonCode(source);
1219
+ const call = _rowMutationCall(code);
1220
+ if (!call) return [];
1221
+ if (/\b_capabilities\b/.test(code)) return [];
1222
+ if (/\buseCanWrite\s*\([^)]*\brecordId\b/.test(code)) return [];
1223
+ const line = code.slice(0, call.index).split(/\r?\n/).length;
1224
+ return [
1225
+ {
1226
+ rule: "row-action-not-gated",
1227
+ severity: "warning",
1228
+ label:
1229
+ `edits or deletes rows without checking that row's permission - read ` +
1230
+ `with { includeCapabilities: true } and show Edit/Delete only when ` +
1231
+ `row._capabilities.can_write / can_delete is true.`,
1232
+ line,
1233
+ snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
1234
+ },
1235
+ ];
1236
+ }
1237
+
1238
+ // The useDatastoreMutation() call whose update or delete the widget uses,
1239
+ // either as `m.update(` / `m.delete(` or destructured `{ update, delete }`.
1240
+ function _rowMutationCall(code) {
1241
+ const hookCall = /(?:\{([^}]*)\}|\b(\w+))\s*=\s*useDatastoreMutation\s*\(/g;
1242
+ for (const match of code.matchAll(hookCall)) {
1243
+ const [, destructured, name] = match;
1244
+ const usesRowVerb = destructured
1245
+ ? /\b(update|delete)\b/.test(destructured)
1246
+ : new RegExp(`\\b${name}\\s*\\.\\s*(update|delete)\\s*\\(`).test(code);
1247
+ if (usesRowVerb) return match;
1248
+ }
1249
+ return null;
1250
+ }
1251
+
1213
1252
  function _imagePercentHeightRules(source) {
1214
1253
  const findings = [];
1215
1254
  // Comments are blanked (string contents kept) so a commented-out example —
@@ -1915,6 +1954,7 @@ export function lintSource(source, options) {
1915
1954
  // sc-5206 — soft warnings steering a widget toward the two new hooks.
1916
1955
  findings.push(..._rawUseMemoIntoDatastoreQueryRules(source));
1917
1956
  findings.push(..._handRolledWriteGateRules(source));
1957
+ findings.push(..._rowActionGatingRules(source));
1918
1958
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
1919
1959
  // line-by-line scan so banned-identifier findings stay first in the
1920
1960
  // output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.173.1",
3
+ "version": "0.174.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",