@colixsystems/widget-sdk 0.173.1 → 0.175.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. |
@@ -60,7 +60,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
60
60
  | **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
61
61
  | **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| all` (endpoint default `all`). |
62
62
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
63
- | **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId })` (optional: folderId) | `{ exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.exportPdf` — `files.write:*`. `exportToPdf(html, { fileName?, folderId? })` renders the HTML to a PDF server-side and saves it as a file (`application/pdf`); same server-side renderer on web + native. |
63
+ | **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId })` (optional: spaceType, folderId) | `{ openPdf, opening, canOpenPdf, exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.renderPdf` + `ctx.device.pdfViewer`, `ctx.filestore.files.exportPdf` — `files.write:*`. `openPdf(html, { fileName? })` renders the HTML to a PDF server-side and OPENS it (web: PDF tab; native: print / share sheet) without saving. `exportToPdf(html, { fileName?, folderId? })` renders it and saves it as a file (`application/pdf`). Same server-side renderer on web + native. |
64
64
  | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. Charges are accepted ONLY in the currency the workspace sells in — omit `currency` and the platform applies it (a disagreeing literal is a publish-blocking `payment-currency` finding). |
65
65
  | **NOTIFICATIONS** (`ctx.notifications`) | `useSendNotification()` | `{ send, sending, error }` | `ctx.notifications.send` — `notifications.send:appUser`. `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace; call from an event handler (never render); rejects with `NotificationError`. |
66
66
  | **IDENTIFICATION** (`ctx.identification`) | `useIdentification({ provider, purpose, pollIntervalMs })` (optional: every key, and the whole argument) | `{ available, status, qr, autoStartToken, message, identity, identificationId, start, refresh, cancel, reset, … }` | `ctx.identification.*` — no scope (the visitor is deliberately NOT signed in). Gate the UI on `available`; `start()` opens the order and the hook polls to completion. `identity` carries `personal_number_masked` + a stable `subject_hash` — never a raw personal number. |
@@ -73,7 +73,28 @@ 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.175.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.175.0 (contract 1.136.0)
79
+
80
+ **Open a PDF for the user to view, print or share, without saving it (sc-9135).** `usePdfExport()` gains `openPdf(html, { fileName? })`, `opening` and `canOpenPdf`. The HTML is rendered server-side by the same headless-Chromium pipeline as `exportToPdf`, and the host opens the result: a new browser tab with the PDF viewer on the web Player, and the OS print and share sheet in the native export (`expo-print`). Nothing is stored, so a widget that only opens PDFs calls `usePdfExport()` with no arguments. Call `openPdf` directly from the press handler, because the web host opens its tab inside that press and a tab opened after an `await` is refused by the popup blocker. `canOpenPdf` is false on a host with no PDF viewer, where `openPdf` rejects with `err.code` `UNSUPPORTED`. The wire errors arrive on `err.status`: 401 not signed in, 413 over the size cap, 429 too many renders, 503 renderer busy. The HTML runs no scripts and loads only `https:` / `data:` images and `https:` fonts. On native, closing the print sheet resolves `openPdf` like closing the web tab does. Needs `@colixsystems/filestore-client@0.11.0` (`files.renderPdf`). The host slice is the new `device.pdfViewer`.
81
+
82
+ **Breaking for authors: `createObjectURL` is now a banned API** (lint rule `no-createobjecturl`). Widgets used it to open a hand-built plain-text blob as a stand-in "print version": it only works in a browser, and on the web it opens widget-built bytes on the host's origin. A widget version that calls it fails lint at submission; versions already published keep running. Open a generated document with `openPdf`, upload a file with `useFilestoreUpload`, and preview a picked photo from the `uri` `useCamera` returns.
83
+
84
+ ### What's new in 0.174.0 (contract 1.135.0)
85
+
86
+ **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:
87
+
88
+ ```jsx
89
+ const { data: rows, refetch } = useDatastoreQuery(tableId, { limit: 50, includeCapabilities: true });
90
+ const { delete: remove } = useDatastoreMutation(tableId);
91
+ // inside rows.map((row) => …)
92
+ {row._capabilities?.can_delete && (
93
+ <Pressable onPress={() => remove(row.id).then(refetch)}><Text>Delete</Text></Pressable>
94
+ )}
95
+ ```
96
+
97
+ 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
98
 
78
99
  ### What's new in 0.173.1 (contract 1.134.1)
79
100
 
@@ -2168,7 +2189,7 @@ export default function OpenTasks({ tableId, titleField, statusField }) {
2168
2189
  }
2169
2190
  ```
2170
2191
 
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.
2192
+ `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
2193
 
2173
2194
  ## Cross-platform widgets (single-file vs split)
2174
2195
 
@@ -2239,6 +2260,8 @@ Pass `--manifest` to enable `style-field-unread` and the three editability rules
2239
2260
 
2240
2261
  `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
2262
 
2263
+ `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.
2264
+
2242
2265
  `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
2266
 
2244
2267
  ## 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",
@@ -1176,9 +1179,22 @@ const HOOKS = [
1176
1179
  {
1177
1180
  name: "usePdfExport",
1178
1181
  signature: "usePdfExport({ spaceType, folderId })",
1179
- optionalArgs: ["folderId"],
1180
- description:
1181
- "Render an HTML string to a PDF server-side and SAVE it as a file in " +
1182
+ optionalArgs: ["spaceType", "folderId"],
1183
+ description:
1184
+ "Render an HTML string to a PDF server-side, then either OPEN it or SAVE " +
1185
+ "it. `openPdf(html, { fileName? })` is THE way to give the user a PDF " +
1186
+ "to view, print, download or share: it renders the HTML and opens the " +
1187
+ "result — the browser's PDF viewer in a new tab on the web Player, the " +
1188
+ "OS print / share sheet in the native export — and SAVES NOTHING, so " +
1189
+ "a widget that only opens PDFs calls `usePdfExport()` with no " +
1190
+ "arguments (`spaceType` names where exportToPdf saves). Call " +
1191
+ "it straight from the press handler (the web tab opens before the " +
1192
+ "render so popup blockers allow it). It resolves once the document is " +
1193
+ "handed to the viewer and rejects with `err.code` UNSUPPORTED when the " +
1194
+ "host has no PDF viewer (check `canOpenPdf`), or the wire error " +
1195
+ "(`err.status` 401 not signed in, 413 over the size cap, 429 too many " +
1196
+ "renders, 503 renderer busy — try again). The rest of this entry is " +
1197
+ "the SAVE path. `exportToPdf` renders and SAVES it as a file in " +
1182
1198
  "the end-user's Filestore space. The widget passes the SPACE " +
1183
1199
  "(`{ spaceType, folderId? }`); the hook resolves owner_id from the host " +
1184
1200
  "context and POSTs JSON through ctx.filestore.files.exportPdf. " +
@@ -1191,6 +1207,9 @@ const HOOKS = [
1191
1207
  "service (headless Chromium) — the SAME server-side pipeline on the web " +
1192
1208
  "Player and the native export, so no browser-only PDF library is used.",
1193
1209
  returnShape: {
1210
+ openPdf: "(html, { fileName? }) => Promise<void>",
1211
+ opening: "boolean",
1212
+ canOpenPdf: "boolean",
1194
1213
  exportToPdf: "(html, { fileName?, folderId? }) => Promise<FilestoreFile>",
1195
1214
  exporting: "boolean",
1196
1215
  error: "Error | null",
@@ -1371,7 +1390,12 @@ const HOOKS = [
1371
1390
  "until the first result for the bound table lands; a changed query on " +
1372
1391
  "that table, refetch() and pull-to-refresh keep the rows already in " +
1373
1392
  "`data` with loading false, while `fetching` is true. Binding a " +
1374
- "different table resets to loading. Show a spinner on `loading` only.",
1393
+ "different table resets to loading. Show a spinner on `loading` only. " +
1394
+ "Pass `includeCapabilities: true` in options to stamp each row with " +
1395
+ "`_capabilities` { can_read, can_write, can_delete, can_grant } — what " +
1396
+ "the signed-in user may do to THAT row, the answer update/delete " +
1397
+ "enforce, computed for the whole page in one lookup. Gate a row's " +
1398
+ "Edit on `row._capabilities?.can_write` and Delete on `can_delete`.",
1375
1399
  returnShape: {
1376
1400
  data: "Record[]",
1377
1401
  loading: "boolean",
@@ -1886,7 +1910,8 @@ const HOOKS = [
1886
1910
  "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
1911
  "still hand-check that in addition to useCanWrite (user.id === record[assigneeField]). It only answers 'signed in AND permitted' — it does " +
1888
1912
  "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 " +
1913
+ "tell those apart. For a LIST, gate each row's edit/delete on useDatastoreQuery's `includeCapabilities` answer instead — a " +
1914
+ "{ 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
1915
  "{ canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. Same collapse when tableId is falsy.",
1891
1916
  returnShape: {
1892
1917
  canWrite: "boolean",
@@ -2949,10 +2974,14 @@ const WIDGET_CONTEXT_SHAPE = {
2949
2974
  "speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
2950
2975
  "camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> }, " +
2951
2976
  "imageEditor: { isSupported() -> boolean, edit(uri, actions, options?) -> Promise<asset> }, " +
2952
- "barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> } }. " +
2953
- "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor() and useBarcodeScanner(). The web Player brokers them via " +
2954
- "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas and BarcodeDetector; the Expo export via " +
2955
- "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator and expo-camera. " +
2977
+ "barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }, " +
2978
+ "pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> } }. " +
2979
+ "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner() and usePdfExport().openPdf. The web Player brokers them via " +
2980
+ "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector and a new browser tab; the Expo export via " +
2981
+ "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera and expo-print. " +
2982
+ "pdfViewer.open must be CALLED synchronously from the user's press: the web broker opens its tab before awaiting `load` " +
2983
+ "(the server render), which is what keeps a popup blocker from refusing it; the Expo export writes the bytes to the " +
2984
+ "cache and hands them to the OS print sheet. " +
2956
2985
  "imageEditor.edit applies resize / crop / rotate / flip in order and rejects with an ImageEditorError " +
2957
2986
  "(.code UNSUPPORTED | INVALID_ACTION | DECODE_FAILED | ENCODE_FAILED | INTERNAL). " +
2958
2987
  "getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL). " +
@@ -2980,6 +3009,7 @@ const WIDGET_CONTEXT_SHAPE = {
2980
3009
  camera: "object",
2981
3010
  imageEditor: "object",
2982
3011
  barcode: "object",
3012
+ pdfViewer: "object",
2983
3013
  },
2984
3014
  },
2985
3015
  };
@@ -3044,6 +3074,11 @@ const BANNED_APIS = [
3044
3074
  reason: "Dynamic import bypasses the loader's allowlist.",
3045
3075
  },
3046
3076
  { identifier: "globalThis", reason: "Host environment escape." },
3077
+ {
3078
+ identifier: "createObjectURL",
3079
+ reason:
3080
+ "Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
3081
+ },
3047
3082
  ];
3048
3083
 
3049
3084
  // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
@@ -4671,7 +4706,17 @@ const CONTRACT = deepFreeze({
4671
4706
  // a `ListRow`'s lone `View` child spans the row; a selected row's bar
4672
4707
  // no longer shifts its content. Other rows render as before.
4673
4708
  // 1.134.1: docs (sc-9122) — an emitted event can run a bound action.
4674
- version: "1.134.1",
4709
+ // 1.135.0: additive (sc-9132) — `includeCapabilities` on useDatastoreQuery
4710
+ // options and a new `options` arg on useDatastoreRecord stamp each row
4711
+ // with `_capabilities`, so a widget shows Edit/Delete only on rows the
4712
+ // user may change. New linter warning `row-action-not-gated`.
4713
+ // 1.136.0: additive (sc-9135) — `usePdfExport()` gains `openPdf(html,
4714
+ // { fileName? })`, `opening` and `canOpenPdf`: render server-side and
4715
+ // OPEN the PDF (web: browser viewer tab; native: expo-print sheet)
4716
+ // without saving it, through the new `device.pdfViewer` host slice. And
4717
+ // `createObjectURL` joins `bannedApis`: widgets reached for it to fake a
4718
+ // "print version" as a plain-text blob that never worked on native.
4719
+ version: "1.136.0",
4675
4720
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4676
4721
  hooks: HOOKS,
4677
4722
  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",
@@ -1176,9 +1179,22 @@ const HOOKS = [
1176
1179
  {
1177
1180
  name: "usePdfExport",
1178
1181
  signature: "usePdfExport({ spaceType, folderId })",
1179
- optionalArgs: ["folderId"],
1180
- description:
1181
- "Render an HTML string to a PDF server-side and SAVE it as a file in " +
1182
+ optionalArgs: ["spaceType", "folderId"],
1183
+ description:
1184
+ "Render an HTML string to a PDF server-side, then either OPEN it or SAVE " +
1185
+ "it. `openPdf(html, { fileName? })` is THE way to give the user a PDF " +
1186
+ "to view, print, download or share: it renders the HTML and opens the " +
1187
+ "result — the browser's PDF viewer in a new tab on the web Player, the " +
1188
+ "OS print / share sheet in the native export — and SAVES NOTHING, so " +
1189
+ "a widget that only opens PDFs calls `usePdfExport()` with no " +
1190
+ "arguments (`spaceType` names where exportToPdf saves). Call " +
1191
+ "it straight from the press handler (the web tab opens before the " +
1192
+ "render so popup blockers allow it). It resolves once the document is " +
1193
+ "handed to the viewer and rejects with `err.code` UNSUPPORTED when the " +
1194
+ "host has no PDF viewer (check `canOpenPdf`), or the wire error " +
1195
+ "(`err.status` 401 not signed in, 413 over the size cap, 429 too many " +
1196
+ "renders, 503 renderer busy — try again). The rest of this entry is " +
1197
+ "the SAVE path. `exportToPdf` renders and SAVES it as a file in " +
1182
1198
  "the end-user's Filestore space. The widget passes the SPACE " +
1183
1199
  "(`{ spaceType, folderId? }`); the hook resolves owner_id from the host " +
1184
1200
  "context and POSTs JSON through ctx.filestore.files.exportPdf. " +
@@ -1191,6 +1207,9 @@ const HOOKS = [
1191
1207
  "service (headless Chromium) — the SAME server-side pipeline on the web " +
1192
1208
  "Player and the native export, so no browser-only PDF library is used.",
1193
1209
  returnShape: {
1210
+ openPdf: "(html, { fileName? }) => Promise<void>",
1211
+ opening: "boolean",
1212
+ canOpenPdf: "boolean",
1194
1213
  exportToPdf: "(html, { fileName?, folderId? }) => Promise<FilestoreFile>",
1195
1214
  exporting: "boolean",
1196
1215
  error: "Error | null",
@@ -1371,7 +1390,12 @@ const HOOKS = [
1371
1390
  "until the first result for the bound table lands; a changed query on " +
1372
1391
  "that table, refetch() and pull-to-refresh keep the rows already in " +
1373
1392
  "`data` with loading false, while `fetching` is true. Binding a " +
1374
- "different table resets to loading. Show a spinner on `loading` only.",
1393
+ "different table resets to loading. Show a spinner on `loading` only. " +
1394
+ "Pass `includeCapabilities: true` in options to stamp each row with " +
1395
+ "`_capabilities` { can_read, can_write, can_delete, can_grant } — what " +
1396
+ "the signed-in user may do to THAT row, the answer update/delete " +
1397
+ "enforce, computed for the whole page in one lookup. Gate a row's " +
1398
+ "Edit on `row._capabilities?.can_write` and Delete on `can_delete`.",
1375
1399
  returnShape: {
1376
1400
  data: "Record[]",
1377
1401
  loading: "boolean",
@@ -1886,7 +1910,8 @@ const HOOKS = [
1886
1910
  "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
1911
  "still hand-check that in addition to useCanWrite (user.id === record[assigneeField]). It only answers 'signed in AND permitted' — it does " +
1888
1912
  "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 " +
1913
+ "tell those apart. For a LIST, gate each row's edit/delete on useDatastoreQuery's `includeCapabilities` answer instead — a " +
1914
+ "{ 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
1915
  "{ canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. Same collapse when tableId is falsy.",
1891
1916
  returnShape: {
1892
1917
  canWrite: "boolean",
@@ -2945,10 +2970,14 @@ const WIDGET_CONTEXT_SHAPE = {
2945
2970
  "speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
2946
2971
  "camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> }, " +
2947
2972
  "imageEditor: { isSupported() -> boolean, edit(uri, actions, options?) -> Promise<asset> }, " +
2948
- "barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> } }. " +
2949
- "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor() and useBarcodeScanner(). The web Player brokers them via " +
2950
- "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas and BarcodeDetector; the Expo export via " +
2951
- "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator and expo-camera. " +
2973
+ "barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }, " +
2974
+ "pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> } }. " +
2975
+ "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner() and usePdfExport().openPdf. The web Player brokers them via " +
2976
+ "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector and a new browser tab; the Expo export via " +
2977
+ "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera and expo-print. " +
2978
+ "pdfViewer.open must be CALLED synchronously from the user's press: the web broker opens its tab before awaiting `load` " +
2979
+ "(the server render), which is what keeps a popup blocker from refusing it; the Expo export writes the bytes to the " +
2980
+ "cache and hands them to the OS print sheet. " +
2952
2981
  "imageEditor.edit applies resize / crop / rotate / flip in order and rejects with an ImageEditorError " +
2953
2982
  "(.code UNSUPPORTED | INVALID_ACTION | DECODE_FAILED | ENCODE_FAILED | INTERNAL). " +
2954
2983
  "getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL). " +
@@ -2976,6 +3005,7 @@ const WIDGET_CONTEXT_SHAPE = {
2976
3005
  camera: "object",
2977
3006
  imageEditor: "object",
2978
3007
  barcode: "object",
3008
+ pdfViewer: "object",
2979
3009
  },
2980
3010
  },
2981
3011
  };
@@ -3040,6 +3070,11 @@ const BANNED_APIS = [
3040
3070
  reason: "Dynamic import bypasses the loader's allowlist.",
3041
3071
  },
3042
3072
  { identifier: "globalThis", reason: "Host environment escape." },
3073
+ {
3074
+ identifier: "createObjectURL",
3075
+ reason:
3076
+ "Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
3077
+ },
3043
3078
  ];
3044
3079
 
3045
3080
  // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
@@ -4667,7 +4702,17 @@ const CONTRACT = deepFreeze({
4667
4702
  // a `ListRow`'s lone `View` child spans the row; a selected row's bar
4668
4703
  // no longer shifts its content. Other rows render as before.
4669
4704
  // 1.134.1: docs (sc-9122) — an emitted event can run a bound action.
4670
- version: "1.134.1",
4705
+ // 1.135.0: additive (sc-9132) — `includeCapabilities` on useDatastoreQuery
4706
+ // options and a new `options` arg on useDatastoreRecord stamp each row
4707
+ // with `_capabilities`, so a widget shows Edit/Delete only on rows the
4708
+ // user may change. New linter warning `row-action-not-gated`.
4709
+ // 1.136.0: additive (sc-9135) — `usePdfExport()` gains `openPdf(html,
4710
+ // { fileName? })`, `opening` and `canOpenPdf`: render server-side and
4711
+ // OPEN the PDF (web: browser viewer tab; native: expo-print sheet)
4712
+ // without saving it, through the new `device.pdfViewer` host slice. And
4713
+ // `createObjectURL` joins `bannedApis`: widgets reached for it to fake a
4714
+ // "print version" as a plain-text blob that never worked on native.
4715
+ version: "1.136.0",
4671
4716
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4672
4717
  hooks: HOOKS,
4673
4718
  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 {
@@ -4134,9 +4149,18 @@ export function useFilestoreUpload(options) {
4134
4149
  }
4135
4150
 
4136
4151
  /**
4137
- * sc-2314 — render an HTML string to a PDF server-side and SAVE it into the
4138
- * end-user's Filestore space. Returns `{ exportToPdf, exporting, error,
4139
- * lastExported }`. The widget passes the SPACE (`{ spaceType, folderId? }`);
4152
+ * sc-2314 / sc-9135 — render an HTML string to a PDF server-side, then OPEN it
4153
+ * or SAVE it. Returns `{ openPdf, opening, canOpenPdf, exportToPdf, exporting,
4154
+ * error, lastExported }`.
4155
+ *
4156
+ * `openPdf(html, { fileName? })` renders without saving and hands the bytes to
4157
+ * the host's `device.pdfViewer`: a browser PDF tab on web, the OS print sheet
4158
+ * on native. It is how a widget offers "print / view / download as PDF".
4159
+ * `canOpenPdf` is false on a host with no viewer, where openPdf rejects with
4160
+ * `err.code` UNSUPPORTED.
4161
+ *
4162
+ * `exportToPdf` SAVES into the end-user's Filestore space. The widget passes
4163
+ * the SPACE (`{ spaceType, folderId? }`);
4140
4164
  * the hook resolves `owner_id` the same way the upload hook does (tenant for
4141
4165
  * PROJECT, app user for PERSONAL) and posts JSON to
4142
4166
  * `ctx.filestore.files.exportPdf`.
@@ -4174,6 +4198,45 @@ export function usePdfExport(options) {
4174
4198
 
4175
4199
  const filesRef = useRef(ctx.filestore.files);
4176
4200
  filesRef.current = ctx.filestore.files;
4201
+ const viewerRef = useRef(ctx.device && ctx.device.pdfViewer);
4202
+ viewerRef.current = ctx.device && ctx.device.pdfViewer;
4203
+ const [opening, setOpening] = useState(false);
4204
+ const openingCountRef = useRef(0);
4205
+
4206
+ const canOpenPdf =
4207
+ _canOpenPdf(viewerRef.current) &&
4208
+ typeof ctx.filestore.files.renderPdf === "function";
4209
+
4210
+ // Deliberately no `await` before viewer.open: the web broker must open its
4211
+ // tab inside the press that called us, or a popup blocker refuses it.
4212
+ const openPdf = useCallback(async (html, overrides) => {
4213
+ if (typeof html !== "string" || html.trim().length === 0) {
4214
+ throw new Error("usePdfExport: html is required");
4215
+ }
4216
+ const viewer = viewerRef.current;
4217
+ const files = filesRef.current;
4218
+ if (!_canOpenPdf(viewer) || typeof files.renderPdf !== "function") {
4219
+ const err = new Error("This app cannot open PDFs here.");
4220
+ err.code = "UNSUPPORTED";
4221
+ setError(err);
4222
+ throw err;
4223
+ }
4224
+ setError(null);
4225
+ openingCountRef.current += 1;
4226
+ setOpening(true);
4227
+ try {
4228
+ await viewer.open({
4229
+ fileName: _pdfFileName(overrides && overrides.fileName),
4230
+ load: () => files.renderPdf({ html }),
4231
+ });
4232
+ } catch (err) {
4233
+ setError(err);
4234
+ throw err;
4235
+ } finally {
4236
+ openingCountRef.current -= 1;
4237
+ if (openingCountRef.current === 0) setOpening(false);
4238
+ }
4239
+ }, []);
4177
4240
 
4178
4241
  const exportToPdf = useCallback(
4179
4242
  async (html, overrides) => {
@@ -4213,7 +4276,36 @@ export function usePdfExport(options) {
4213
4276
  [ownerId, defaultFolderId, spaceType],
4214
4277
  );
4215
4278
 
4216
- return { exportToPdf, exporting, error, lastExported };
4279
+ return {
4280
+ openPdf,
4281
+ opening,
4282
+ canOpenPdf,
4283
+ exportToPdf,
4284
+ exporting,
4285
+ error,
4286
+ lastExported,
4287
+ };
4288
+ }
4289
+
4290
+ function _canOpenPdf(viewer) {
4291
+ return Boolean(
4292
+ viewer &&
4293
+ typeof viewer.open === "function" &&
4294
+ (typeof viewer.isSupported !== "function" || viewer.isSupported()),
4295
+ );
4296
+ }
4297
+
4298
+ // The name the viewer offers when the user saves or shares the document.
4299
+ function _pdfFileName(fileName) {
4300
+ const base =
4301
+ typeof fileName === "string" && fileName.trim()
4302
+ ? Array.from(fileName.trim(), (ch) =>
4303
+ ch < " " || ch === "/" || ch === "\\" ? "_" : ch,
4304
+ )
4305
+ .join("")
4306
+ .slice(0, 120)
4307
+ : "document";
4308
+ return /\.pdf$/i.test(base) ? base : `${base}.pdf`;
4217
4309
  }
4218
4310
 
4219
4311
  /**
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.175.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",