softr-vibe-coding 2.8.1 → 2.9.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/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to this skill are documented here. Versions follow [Semantic
4
4
 
5
5
  Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
6
6
 
7
+ ## [2.9.0] - 2026-09-29
8
+ - Add runtime facts verified live 2026-09-18
9
+
10
+ ## [2.8.2] - 2026-09-10
11
+ - Corrections on evidence: Softr never strips the trailing newline; the array-argument rejection was client-side stringification, not an empty server schema (2.8.2)
12
+
7
13
  ## [2.8.1] - 2026-09-10
8
14
  - Verifying a push: errors:null is not proof — fetch-back byte-compare, deployed==disk pre-check, two-block swaps (2.8.1)
9
15
  - Inventory + keyboard-picker snippet reconciled with the shipped projects-table shape
package/README.md CHANGED
@@ -169,7 +169,7 @@ Create a contact form that creates records in our Airtable Contacts table
169
169
  softr-vibe-coding/
170
170
  ├── SKILL.md # Main skill
171
171
  │ # Workflow, code structure, visual baseline,
172
- │ # components, settings, 21 hard constraints
172
+ │ # components, settings, 27 hard constraints
173
173
  │
174
174
  ├── ui-ux-guidelines.md # Design reference
175
175
  │ # 26 sections: hierarchy, color, typography,
@@ -191,7 +191,10 @@ softr-vibe-coding/
191
191
  │ │ # Softr DB schema + record tools incl. deletes,
192
192
  │ │ # app management/scaffolding, Workflows suite
193
193
  │ │ # (26 tools, 418-node catalog), per-application
194
- │ │ # MCP servers, auth, permissions
194
+ │ │ # MCP servers, auth, permissions; what the server
195
+ │ │ # enforces on block data endpoints, "Preview as"
196
+ │ │ # role testing, search-replace on 100KB+ blocks
197
+ │ │ # (Sep 18 2026)
195
198
  │ ├── advanced-integrations.md # Shadow DOM CSS isolation
196
199
  │ │ # Leaflet, Mapbox, TinyMCE, Quill, FullCalendar
197
200
  │ ├── native-chrome-styling.md # Restyle Softr's native shell (header, footer,
@@ -239,9 +242,14 @@ softr-vibe-coding/
239
242
  ├── overview.md # Comparison matrix, selection guide
240
243
  ├── shared-patterns.md # Index → multi-datasource, reading, writing, fields
241
244
  ├── multi-datasource.md # Several data sources in ONE block: datasource.define(),
242
- │ # the from: parameter, getting the datasource UUIDs
245
+ │ # the from: parameter, getting the datasource UUIDs,
246
+ │ # select: as a module-scope identifier, the union-of-
247
+ │ # selects read payload (a conditional select is not
248
+ │ # privacy), Actions per table (Sep 18 2026)
243
249
  ├── reading.md # useRecords, filtering, sorting, pagination,
244
- │ # metrics, charts, current user
250
+ │ # metrics, charts, current user; no detail-page
251
+ │ # auto-scoping, useRecords ignores enabled:false,
252
+ │ # server-side linked-record filters (Sep 18 2026)
245
253
  ├── writing.md # Mutations, sequential write queues, uploads,
246
254
  │ # linked record format, cross-table writes
247
255
  ├── fields.md # getFieldValue(), field type shapes, record
package/SKILL.md CHANGED
@@ -69,6 +69,10 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
69
69
 
70
70
  6. **Self-validate before delivering.** Before presenting the code as complete, verify. (Data-hook items apply only to data-connected blocks; static marketing blocks swap in the checklist deltas from [references/static-blocks.md](references/static-blocks.md#workflow-deltas).)
71
71
  - Every data hook is called with an **inline options object literal** — `useRecords({ ... })` written through a variable or wrapper function fails to compile (verified live 2026-08-25). Share `q.select` mappings between hooks, never whole options objects
72
+ - Multi-datasource block: every `select:` / `fields:` value is a **plain module-scope identifier** — no ternary, no inline `q.select({...})` inside the hook options (the query returns `fields: {}`; Hard Constraint 24)
73
+ - Detail page: the record is fetched with `useRecord({ select, recordId, enabled: !!recordId })` using `useCurrentRecordId()`, and the code checks `data.id === recordId` before rendering — never `useRecords({ count: 1 })`, which returns the table's FIRST row (Hard Constraint 25)
74
+ - No list query relies on `enabled: false` — `useRecords` fetches anyway; conditional list queries live in a child component mounted only when needed, or carry a match-nothing `where` (Hard Constraint 26)
75
+ - No field is "hidden" from some viewers by a conditional / second `select` on the same connection — the browser receives the union of every read select on that connection (Hard Constraint 23)
72
76
  - All imports use named imports (no `import React from 'react'`)
73
77
  - `export default function Block()` is present
74
78
  - Container + content wrappers present (`<div className="container py-0"><div className="content">`) — OR a deliberate full-bleed layout recorded in the `// BLOCK PLACEMENT:` comment (see "Block Placement & Page Spacing")
@@ -89,7 +93,7 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
89
93
  - Static block: no hardcoded user-visible copy — every string/image/link is an editable setting (see [references/editable-settings.md](references/editable-settings.md#granularity-doctrine-settings-first-static-blocks))
90
94
  - Array-setting rows keyed by **index**, never by a builder-editable field value
91
95
  - Media settings that may start empty (`src: ""`) gated with a conditional render or placeholder — never an unconditional `<img src={setting.src}>`
92
- - **Deploying through the MCP:** `errors: null` on a push is not proof — fetch the block's `sourceCode` back and byte-compare it to the file you sent (trailing newline normalised, nothing else), and prove deployed == disk *before* editing so a Studio-side change is never overwritten. Protocol in [references/softr-mcp.md → Verifying a push](references/softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof)
96
+ - **Deploying through the MCP:** `errors: null` on a push is not proof — fetch the block's `sourceCode` back and byte-compare it to the file you sent, trailing newline included (Softr stores exactly what it receives; the one-byte drift we once blamed on it was a chunked read on our side), and prove deployed == disk *before* editing so a Studio-side change is never overwritten. Protocol in [references/softr-mcp.md → Verifying a push](references/softr-mcp.md#verifying-a-push--the-deployed-source-is-the-only-proof)
93
97
 
94
98
  ## What to Clarify
95
99
 
@@ -556,7 +560,7 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
556
560
  restore worked. Softr's default for a `genericActions` ADD_RECORD is `ALL_USERS`, i.e. writable by
557
561
  logged-OUT visitors, and the MCP call that re-tightens it (`set_vibe_coding_block_action_visibility`)
558
562
  can itself fail with no fallback (see the array-argument quirk in
559
- [references/softr-mcp.md](references/softr-mcp.md#the-array-argument-serialization-quirk-and-why-it-is-a-security-issue)).
563
+ [references/softr-mcp.md](references/softr-mcp.md#the-array-argument-rejection-and-why-it-is-a-security-issue)).
560
564
  A push that returns `errors: null` can still have left public write access on the block.
561
565
  **If any action is still `ALL_USERS`, report it WITH its severity and let the builder decide.**
562
566
  Check the page's own VIEW permission first (`get_page_permissions`): a page gated to logged-in
@@ -574,6 +578,31 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
574
578
  are shared, and promise nothing beyond them (the class strings usually differ in layout and padding,
575
579
  and do not need to match). The same rule governs repeated page chrome -- see **Block Placement &
576
580
  Page Spacing**.
581
+ 23. **One connection = one read payload; a conditional select is not privacy** -- the records
582
+ endpoint is per block + connection and returns the UNION of every field named by any READ
583
+ `q.select` on that connection, to every viewer. A second or ternary select "only for admins"
584
+ hides nothing. Put a private field on a **second connection of the same table** (allowed) read
585
+ only by a hook non-privileged browsers never run, or in a group-gated block. Page VIEW permission
586
+ is enforced on these endpoints, but on a page any logged-in user may view, every connected
587
+ datasource is readable by any logged-in user who crafts the request -- Source conditions are the
588
+ only server-side ROW gate. Verified live 2026-09-18. See
589
+ [datasources/multi-datasource.md](datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects).
590
+ 24. **Multi-datasource: `select:` is a plain module-scope identifier** -- a ternary
591
+ (`select: a ? X : Y`) cannot be attributed to a connection and the query returns `fields: {}`, no
592
+ error. Treat an inline `q.select({...})` inside hook options the same way: hoist it. Verified
593
+ live 2026-09-18.
594
+ 25. **No detail-page auto-scoping** -- the runtime sends `pageContext: null`;
595
+ `useRecords({ count: 1 })` returns the table's FIRST row, not the URL's record. Fetch detail
596
+ records with `useRecord({ select, recordId, enabled: !!recordId })` (`useCurrentRecordId()` does
597
+ return the URL's `recordId`) and verify `data.id === recordId` -- a null id falls back to a list
598
+ call. Verified live 2026-09-18. See [datasources/reading.md](datasources/reading.md#userecord----fetch-a-single-record).
599
+ 26. **`useRecords` ignores `enabled: false`** -- literal or variable, it fetches anyway. `useRecord`
600
+ honours it. Make a list query conditional by mounting it in a child component only when needed,
601
+ or with a match-nothing `where`. Verified live 2026-09-18.
602
+ 27. **Mutation Actions register per TABLE, not per connection** -- several `useRecordUpdate` hooks on
603
+ one table merge into ONE UPDATE_RECORD action (field list = the union), filed under the table's
604
+ FIRST connection even when a hook points at a second one. Point writes at the first connection.
605
+ Verified live 2026-09-18. See [datasources/writing.md](datasources/writing.md#actions-register-per-table-not-per-hook-or-connection).
577
606
 
578
607
  ## Style Conventions
579
608
 
@@ -60,7 +60,7 @@ You'll see exactly which field is an object. Add `getFieldValue()` around it.
60
60
  | Date Range | `{ from: string, to: string }` |
61
61
  | Rating, Duration | `string or number or null` |
62
62
  | Select | `{ label: string, id: string }` |
63
- | Linked Record (via useRecord/useRecords) | `{ label: string, id: string }` |
63
+ | Linked Record (via useRecord/useRecords) | `{ label: string, id: string }` — usually an array of these, but a link can arrive as a **single object** (verified live 2026-09-18); normalise with `Array.isArray(v) ? v : (v ? [v] : [])` |
64
64
  | Linked Record (via useLinkedRecords) | `{ id: string, title: string }` -- different! |
65
65
  | User, Created By, Updated By | `{ avatarUrl, id, name, email }` |
66
66
  | Attachment | `{ filename, id, type, url }` |
@@ -64,6 +64,97 @@ var ds = datasource.define({ people: "74d2cbfd-…" });
64
64
  The error text is explicit, so this one fails fast rather than silently — but it's an easy
65
65
  reflex to hoist "magic strings" into named constants, and that reflex is wrong here.
66
66
 
67
+ ## `select:` must be a plain module-scope identifier
68
+
69
+ *Verified live 2026-09-18 (Softr Database; probe block + network capture in a draft preview).*
70
+
71
+ With more than one connection, Softr has to attribute every `q.select` to the connection it is
72
+ used with. The observed behaviour says it does that statically, from the identifier you pass as
73
+ `select:` (the mechanism is inferred; the outcome below is what was captured). An expression
74
+ breaks the attribution:
75
+
76
+ ```jsx
77
+ // WRONG — compiles, runs, and the query returns records with `fields: {}`. No error.
78
+ var order = useRecord({ from: ds.orders, select: isAdmin ? adminSelect : publicSelect, recordId: id });
79
+
80
+ // CORRECT — one module-scope identifier per hook
81
+ var orderSelect = q.select({ title: "FIELD_ID1", status: "FIELD_ID2" });
82
+ var order = useRecord({ from: ds.orders, select: orderSelect, recordId: id });
83
+ ```
84
+
85
+ Treat an inline `q.select({...})` written inside the hook options the same way: hoist it to
86
+ module scope and pass the identifier (the pattern at the top of this file already does). The
87
+ same goes for a mutation hook's `fields:`.
88
+
89
+ In a **single-datasource** block the same ternary *works* — there is nothing to attribute — but
90
+ it behaves as a **union** of both branches, not a choice between them. Which is the next rule.
91
+
92
+ ## One connection = one read payload (the union of its selects)
93
+
94
+ *Verified live 2026-09-18.*
95
+
96
+ The records endpoint is per block + connection —
97
+ `/blocks/<blockId>/datasources/<dataSourceId>/records` — and it returns the **UNION of every field
98
+ named by any READ `q.select` attributed to that connection**. Two selects on one connection do
99
+ NOT produce two payloads: every read hook on that connection gets all the fields, for every
100
+ viewer.
101
+
102
+ **So "request the private field only for admins" is not privacy.** A second select, or a ternary
103
+ between a public and an admin select, still ships the private field to every browser that loads
104
+ the block — it is simply not rendered. Anyone can read it in the network tab.
105
+
106
+ What does *not* join the union: a mutation hook's `fields:` select. Write-only fields stay out of
107
+ the read payload.
108
+
109
+ **Remedy.** Connect the **same table a second time** — Softr allows it, and the second connection
110
+ gets its own `dataSourceId` — and read the private field only through that connection, from a
111
+ hook that non-privileged browsers never run:
112
+
113
+ ```jsx
114
+ var ds = datasource.define({
115
+ orders: "11111111-…", // everyone: public fields only
116
+ ordersAdmin: "22222222-…", // same table, second connection: the private fields
117
+ });
118
+
119
+ var orderSelect = q.select({ title: "FIELD_ID1", status: "FIELD_ID2" });
120
+ var orderAdminSelect = q.select({ internalNotes: "FIELD_ID9" });
121
+
122
+ // Mounted by Block() ONLY when the viewer is an admin — so a non-admin browser never
123
+ // issues the request. (`useRecord` honours `enabled: false`; `useRecords` does NOT —
124
+ // see reading.md — which is why the gate is the mount, not an option.)
125
+ function AdminNotes({ recordId }) {
126
+ var admin = useRecord({ from: ds.ordersAdmin, select: orderAdminSelect, recordId: recordId, enabled: !!recordId });
127
+ // …
128
+ }
129
+ ```
130
+
131
+ Or put the private field in a separate block whose visibility is group-gated.
132
+
133
+ Know what this buys you. Not *rendering* the hook keeps the field out of ordinary browsers, but
134
+ the endpoint still exists: page VIEW permission is enforced on it (a viewer who cannot view the
135
+ page gets a 403), yet on a page any logged-in user may view, **every connected datasource is
136
+ readable by any logged-in user who crafts the request**. A connection's **Source conditions are
137
+ the only server-side ROW gate**; the only server-side gate on the *field* is a page or block the
138
+ viewer cannot see. See [../references/softr-mcp.md](../references/softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints).
139
+
140
+ Alias → field attribution is per connection, so two selects on different connections may reuse
141
+ an alias name (`customer` on both) without colliding — including in `where` filters.
142
+
143
+ ## Mutation Actions register per TABLE, not per connection
144
+
145
+ *Verified live 2026-09-18.*
146
+
147
+ The second connection above is for **reads**. Actions are filed per table:
148
+
149
+ - Several `useRecordUpdate` hooks on one table merge into **ONE `UPDATE_RECORD` action** whose
150
+ field list is the union of their `fields:` selects.
151
+ - A hook pointed at the *second* connection of a table (`from: ds.ordersAdmin`) was still filed
152
+ under the **first** connection's `dataSourceId`.
153
+
154
+ So **point every write at the table's first connection**, and expect one action per table and
155
+ operation in `get_vibe_coding_block_settings` / the Actions tab — that is the row you re-tighten
156
+ after each push. Details in [writing.md](writing.md#actions-register-per-table-not-per-hook-or-connection).
157
+
67
158
  ## Getting the datasource ids — ask for CODE, never for a value
68
159
 
69
160
  The id is a plain **UUID**. It is *not* the underlying table id (`tbl…` in Airtable), and not
@@ -5,11 +5,11 @@ Fetching, filtering, sorting, pagination, metrics, charts, and current user.
5
5
  ## Table of Contents
6
6
 
7
7
  - [Query Builder](#query-builder)
8
- - [useRecords -- Fetch a Paginated List](#userecords----fetch-a-paginated-list)
9
- - [useRecord -- Fetch a Single Record](#userecord----fetch-a-single-record)
8
+ - [useRecords -- Fetch a Paginated List](#userecords----fetch-a-paginated-list) — incl. [`enabled: false` is ignored](#userecords-ignores-enabled-false)
9
+ - [useRecord -- Fetch a Single Record](#userecord----fetch-a-single-record) — the detail-page pattern; no auto-scoping
10
10
  - [useLinkedRecords -- Fetch Linked/Related Options](#uselinkedrecords----fetch-linkedrelated-options)
11
11
  - [useFieldOptions -- Fetch Single/Multi-Select Choices](#usefieldoptions----fetch-singlemulti-select-choices)
12
- - [Filtering](#filtering)
12
+ - [Filtering](#filtering) — incl. [server-side linked-record filters](#filtering-by-a-linked-record-server-side)
13
13
  - [Sorting](#sorting)
14
14
  - [Current User](#current-user)
15
15
  - [Metrics](#metrics)
@@ -29,6 +29,15 @@ var select = q.select({
29
29
  });
30
30
  ```
31
31
 
32
+ **Declare every `q.select` at module scope and pass it by identifier** (verified live 2026-09-18).
33
+ In a multi-datasource block a `select:` that is a ternary (`select: a ? X : Y`) cannot be
34
+ attributed to a connection and the query returns `fields: {}` with no error; treat an inline
35
+ `q.select({...})` inside hook options the same way and hoist it. And a connection's read payload
36
+ is the **union** of every read select on it — a second or conditional select never narrows what
37
+ the browser receives, so it is not a privacy tool. Both rules, with the remedy, in
38
+ [multi-datasource.md](multi-datasource.md#select-must-be-a-plain-module-scope-identifier).
39
+ (The short inline `q.select` snippets below are single-datasource illustrations.)
40
+
32
41
  ## useRecords -- Fetch a Paginated List
33
42
 
34
43
  ```jsx
@@ -39,7 +48,7 @@ var result = useRecords({
39
48
  count: 6, // records per page (default 6, max 100)
40
49
  where: q.text("name").contains("Alice"), // optional filter
41
50
  orderBy: q.desc("createdAt"), // optional sort
42
- enabled: true, // optional, defer loading
51
+ enabled: true, // accepted, but `false` is IGNORED — see below
43
52
  });
44
53
 
45
54
  var data = result.data;
@@ -66,6 +75,33 @@ objects.
66
75
 
67
76
  A block can connect to **several data sources** and call `useRecords` once per source — declare them with `datasource.define()` and pass `from:` on every hook. See [multi-datasource.md](multi-datasource.md). (This replaces the old one-table-per-block limit; blocks no longer need an invisible helper block just to read a second table.)
68
77
 
78
+ ### `useRecords` ignores `enabled: false`
79
+
80
+ *Verified live 2026-09-18 (network capture).* `useRecords({ ..., enabled: false })` **fetches
81
+ anyway** — with a literal `false` and with a variable alike. `useRecord` is different: it honours
82
+ `enabled: false` and issues no request. (`useLinkedRecords`, `useMetric` and `useChartData` were
83
+ not probed — don't assume either behaviour for them.)
84
+
85
+ So `enabled` cannot make a list query conditional, and it cannot keep a query away from viewers
86
+ who should not run it. Two things that do work:
87
+
88
+ ```jsx
89
+ // 1. Gate by MOUNT — put the hook in a child component and render it only when needed.
90
+ function CommentsSection({ orderId }) {
91
+ var comments = useRecords({ from: ds.comments, select: commentSelect, count: 50,
92
+ where: q.array("order").hasAllOf([orderId]) });
93
+ // …
94
+ }
95
+ // in Block(): {canSeeComments && <CommentsSection orderId={recordId} />}
96
+
97
+ // 2. Keep the hook mounted but give it a match-nothing `where` until it should load.
98
+ var rows = useRecords({ select: select, count: 50,
99
+ where: q.text("email").is(email || "__no_match__") });
100
+ ```
101
+
102
+ Option 1 is the only one that sends no request at all; option 2 still calls the endpoint and gets
103
+ zero rows back. Remember the child must be defined at **module scope** (SKILL.md Self-validate).
104
+
69
105
  ### Loading All Records (Auto-Pagination)
70
106
 
71
107
  ```jsx
@@ -85,14 +121,45 @@ useEffect(function() {
85
121
  ```jsx
86
122
  import { useRecord, useCurrentRecordId, q } from "@/lib/datasource";
87
123
 
88
- var recordId = useCurrentRecordId(); // resolves from URL context, can be null
124
+ var detailSelect = q.select({ title: "FIELD_ID1", description: "FIELD_ID2" });
125
+
126
+ var recordId = useCurrentRecordId(); // the URL's `recordId` param — can be null
89
127
  var result = useRecord({
90
- select: q.select({ title: "FIELD_ID1", description: "FIELD_ID2" }),
128
+ select: detailSelect,
91
129
  recordId: recordId,
130
+ enabled: !!recordId, // honoured by useRecord: no id → no request
92
131
  });
132
+
133
+ var record = result.data && result.data.id === recordId ? result.data : null; // trust only a matching id
93
134
  ```
94
135
 
95
- **`recordId` can be omitted when Softr Studio supplies the record context.** `useRecord({ select })` with no `recordId` loads the record the block is bound to via its data-source binding in Studio — verified by deployed block, July 2026 (an Airtable-backed stats block rendered live values this way). Keep `useCurrentRecordId()` + explicit `recordId` as the pattern for URL-driven detail pages (`/page?recordId=...`). When editing an existing **working** block that already omits `recordId`, leave the call shape as-is: adding an explicit `recordId` from `useCurrentRecordId()` can change behavior on pages whose URL carries no `recordId` param. Corollary for reviews: a recordId-less `useRecord` is NOT by itself a defect — check whether the block is deployed and loading data before flagging it.
136
+ **There is NO detail-page auto-scoping (verified live 2026-09-18, Softr Database, network
137
+ capture).** The runtime sends `pageContext: null` with the block's data requests — nothing tells
138
+ the server which record the page is "about". Consequences:
139
+
140
+ - `useRecords({ count: 1 })` on a detail page returns the **FIRST row of the table**, not the
141
+ URL's record. It looks right on the first record you test and wrong on every other.
142
+ - `useCurrentRecordId()` **does** return the URL's `recordId`, and
143
+ `useRecord({ from, select, recordId })` fetches exactly that record (it hits `/records/<id>`).
144
+ That is the detail-page pattern — the only one.
145
+ - `useRecord` with a **null / missing id falls back to a list call** and hands back whatever that
146
+ returns. So always pass `enabled: !!recordId` (`useRecord` honours `enabled: false` — no
147
+ request is made) and verify `data.id === recordId` before rendering or, worse, writing.
148
+
149
+ **A recordId-less `useRecord` — what the older note here meant, and its limits.** This file used
150
+ to say that `useRecord({ select })` with no `recordId` "loads the record the block is bound to
151
+ via its data-source binding in Studio" (seen on one deployed Airtable-backed stats block, July
152
+ 2026, which rendered live values that way). Read that in the light of the capture above: no
153
+ record context is sent, and a null-id `useRecord` falls back to a list call — so the likeliest
154
+ explanation of the July block is that it was showing the list fallback's row, which is the
155
+ "right" record only when the connection's Source conditions/sort leave exactly that row first.
156
+ (That reading is an inference: the Airtable block was not re-probed, and the 2026-09-18 capture
157
+ was on Softr Database.) Either way it is not a binding you can rely on, and never the way to
158
+ build a detail page. The review corollary
159
+ survives in a narrower form: a recordId-less `useRecord` in a **working, deployed** block is not
160
+ by itself proof of a defect — check what it actually loads (and for which viewers) before
161
+ flagging it, and when editing such a block, know that adding an explicit `recordId` changes what
162
+ it loads on pages whose URL carries no `recordId` param.
96
163
 
97
164
  ## useLinkedRecords -- Fetch Linked/Related Options
98
165
 
@@ -189,6 +256,39 @@ where: q.and(
189
256
  )
190
257
  ```
191
258
 
259
+ ### Filtering by a linked record (server-side)
260
+
261
+ *Verified live 2026-09-18 (Softr Database, network capture).* A linked-record field filters on
262
+ the server with the array builder and the linked record's id — no need to load the whole child
263
+ table and filter client-side:
264
+
265
+ ```jsx
266
+ var commentSelect = q.select({ order: "LINK_FIELD_ID", body: "FIELD_ID2" });
267
+
268
+ var comments = useRecords({
269
+ from: ds.comments,
270
+ select: commentSelect,
271
+ count: 50,
272
+ where: q.array("order").hasAllOf([orderId]), // "order" = the link field's ALIAS
273
+ });
274
+ ```
275
+
276
+ On the wire the alias is resolved to the field id:
277
+ `{ subject: <fieldId>, type: "ARRAY", operator: "HAS_ALL_OF", value: [orderId] }`. Alias → field
278
+ attribution is **per datasource**, so two selects on different connections may use the same alias
279
+ name for different fields and each filter still resolves against its own connection.
280
+
281
+ `orderId` must be a real id when the hook runs — `useRecords` cannot be switched off with
282
+ `enabled: false` ([above](#userecords-ignores-enabled-false)), so mount this query in a child
283
+ component that only renders once the parent record has loaded.
284
+
285
+ **Reading the link back:** a linked field can arrive as a **single `{ id, label }` object**, not
286
+ only as an array of them. Normalise before you `.map()` or compare ids:
287
+
288
+ ```jsx
289
+ var links = Array.isArray(v) ? v : (v ? [v] : []);
290
+ ```
291
+
192
292
  ## Sorting
193
293
 
194
294
  ```jsx
@@ -31,6 +31,29 @@ implication: do the Actions-tab tightening pass only AFTER the last redeploy of
31
31
  re-check every tightened block after any future redeploy. Hit across a 15-block production
32
32
  deployment; treat it as standing platform behavior, not a one-off.
33
33
 
34
+ ### Actions register per TABLE, not per hook or connection
35
+
36
+ *Verified live 2026-09-18 (Softr Database; probe block + network capture in a draft preview).*
37
+
38
+ - **Several `useRecordUpdate` hooks on one table merge into ONE `UPDATE_RECORD` action** whose
39
+ field list is the UNION of all their `fields:` selects. Splitting a table's writes across hooks
40
+ ("one hook for the status, one for the admin-only fields") does not produce separately
41
+ permissionable actions — there is one action, and one visibility setting, per table and operation.
42
+ (What follows from that, deduced rather than separately tested: two user groups needing
43
+ different write rights on the same table cannot be expressed inside one block — use a second,
44
+ group-gated block, or a Softr Workflow that does the privileged write.)
45
+ - **When the same table is connected twice** (the private-field pattern in
46
+ [multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)),
47
+ a mutation hook pointed at the SECOND connection was still filed under the FIRST connection's
48
+ `dataSourceId`. **Point writes at the table's first connection** and keep the second one
49
+ read-only, so the code says what the platform does.
50
+ - A mutation hook's `fields:` select does **not** join the connection's read union — write-only
51
+ fields are not shipped to the browser by the records endpoint.
52
+
53
+ Practical upshot for the post-push permission pass (see
54
+ [softr-mcp.md](../references/softr-mcp.md#the-array-argument-rejection-and-why-it-is-a-security-issue)):
55
+ expect one row per table + operation, and re-tighten that row.
56
+
34
57
  The `enabled` boolean on a mutation hook is a combined signal — it's `true` only when BOTH conditions are met:
35
58
 
36
59
  1. **The Action was successfully derived from the code** (parser side). Causes of failure here:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softr-vibe-coding",
3
- "version": "2.8.1",
3
+ "version": "2.9.0",
4
4
  "description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
5
5
  "bin": {
6
6
  "softr-vibe-coding": "./bin/cli.js"
@@ -19,6 +19,10 @@ Run through this catalog before delivering any block. Every row is a violation o
19
19
  | Omitting `from:` on a hook when the block has more than one datasource | Throws at runtime. `from:` is optional ONLY when exactly one source is connected — then hooks default to it. Applies to `useRecords`, `useRecord`, `useLinkedRecords`, `useFieldOptions`, `useMetric`, `useChartData`, `useRecordCreate`, `useRecordUpdate`, `useRecordDelete`. NOT to `useUpload` / `useCurrentRecordId`, which are app-level. `useProxyFetch` has the same multi-datasource requirement but takes the alias as its **argument** — `useProxyFetch(ds.store)` — not as `from:` |
20
20
  | Hoisting datasource ids into constants: `datasource.define({ people: PEOPLE_DS_ID })` | Fails to compile — *"datasource.define() object values must be string literals."* Softr statically analyses the call, same as `q.select()`. Keep the UUIDs **inline**: `datasource.define({ people: "74d2cbfd-…" })`. Fails fast with an explicit message, but hoisting magic strings is a strong reflex — resist it here |
21
21
  | Asking Studio's AI chat "what are the datasource IDs?" and pasting the answer | **It fabricates them.** Verified July 2026: asked three times for the same three connected tables, it gave three different UUID sets, once reusing a previously-mentioned table's uuid for a different table — all confidently worded, none hedged. Ask it to **write code** instead (*"write a datasource.define call covering every connected source, plus one useRecords per source, code only"*) — scaffolding is bound to the real connections. Then RUN it: real rows under each heading proves each alias maps where you think. A wrong uuid fails safe (matches nothing → error); a *swapped pair* of valid uuids does not |
22
+ | "Hiding" a private field from non-admins with a second `q.select`, or a ternary between a public and an admin select, on the SAME connection | **Not privacy.** The records endpoint is per block + connection (`/blocks/<id>/datasources/<dsId>/records`) and returns the UNION of every field named by any READ `q.select` on that connection — every viewer's browser receives the private field; it is merely not rendered (verified live 2026-09-18, network capture). A mutation hook's `fields:` select does not join the union. Fix: connect the **same table a second time** (allowed — it gets its own dataSourceId), read the private field only from that connection in a hook that non-privileged browsers never run (a child component mounted only for admins), or move it to a group-gated block. Server-side, page VIEW permission gates the endpoint and Source conditions gate ROWS; nothing else does. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects) |
23
+ | `select: isAdmin ? adminSelect : publicSelect` (or an inline `q.select({...})` in the hook options) in a **multi-datasource** block | The select cannot be attributed to a connection and the query returns records with `fields: {}` — no compile error, no runtime error, just empty fields (verified live 2026-09-18). `select:` / `fields:` must be a **plain module-scope identifier**. In a single-datasource block the ternary "works", but as a union of both branches (row above) — so it is never the tool it looks like. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#select-must-be-a-plain-module-scope-identifier) |
24
+ | `useRecords({ select, count: 1 })` on a detail page, expecting the page's record — or a `useRecord` with no / null `recordId` | There is **no detail-page auto-scoping**: the runtime sends `pageContext: null`, so `count: 1` returns the table's FIRST row, and a null-id `useRecord` falls back to a list call (verified live 2026-09-18). It passes a test on the first record and fails on every other. Use `useRecord({ from, select, recordId, enabled: !!recordId })` with `recordId = useCurrentRecordId()` (which does return the URL's `recordId`; the call hits `/records/<id>`), and verify `data.id === recordId` before rendering or writing. See [datasources/reading.md](../datasources/reading.md#userecord----fetch-a-single-record) |
25
+ | `useRecords({ ..., enabled: false })` / `enabled: someFlag` to defer or withhold a list query | **`useRecords` ignores `enabled: false`** — literal or variable, it fetches anyway (verified live 2026-09-18). `useRecord` honours it. To make a list query conditional, mount the hook in a **child component rendered only when needed** (the only option that sends no request), or give it a match-nothing `where`. Never rely on `enabled` to keep a table away from viewers who should not load it. See [datasources/reading.md](../datasources/reading.md#userecords-ignores-enabled-false) |
22
26
 
23
27
  ## Mutations
24
28
 
@@ -40,6 +44,7 @@ Run through this catalog before delivering any block. Every row is a violation o
40
44
  | Tightening Actions-tab permissions before the block's final redeploy | Every code recompile **resets the auto-registered Actions to default permissions** (verified live 2026-08-25). Tighten permissions after the LAST redeploy, and re-check after any future one |
41
45
  | Assuming a comment-only edit is "safe" and leaves Action permissions alone | There is no cosmetic-edit exemption. Any save recompiles, and every recompile rebuilds the Actions at default visibility — a `search_replace` changing nothing but a code comment resets them exactly like a rewrite (verified live 2026-09-09, on two blocks at once). Re-check after EVERY push, including cosmetic ones |
42
46
  | Treating the permission-restore call as done because you issued it | Read the permissions back with `get_vibe_coding_block_settings` and confirm each one changed. `set_vibe_coding_block_action_visibility` can fail outright on the array-argument serialization quirk, and it has **no fallback** — the default for a `genericActions` ADD_RECORD is `ALL_USERS`, so a routine push silently leaves the block publicly writable while returning `errors: null`. Verified live 2026-09-09: four ADD_RECORD actions left open across two blocks. Report the list with its severity — check the page's VIEW permission with `get_page_permissions`, since a logged-in-gated page makes this housekeeping while a public page makes it a real hole — and let the builder decide whether it holds their release. A human sets them on the block's Actions tab |
47
+ | Splitting one table's writes across several `useRecordUpdate` hooks (or across two connections of the same table) to get separately-permissioned Actions | Actions register per **TABLE**: the hooks merge into ONE UPDATE_RECORD action whose field list is the union, and a hook pointed at a second connection of the table is still filed under the FIRST connection's dataSourceId (verified live 2026-09-18). Point writes at the table's first connection; expect one action per table + operation when re-tightening permissions. See [datasources/writing.md](../datasources/writing.md#actions-register-per-table-not-per-hook-or-connection) |
43
48
  | Treating Studio's Actions tab as a separately-managed configuration to keep in sync with code | Actions auto-derive from your `useRecordCreate`/`useRecordUpdate`/`useRecordDelete` + `q.select` on every save. The Actions tab is a read-only inspector; there is no manual delete control. To change an Action, change the code |
44
49
  | One alias in a write-side `q.select` referencing a renamed / non-existent Airtable column | Softr's Action parser silently rejects the **entire** create/update Action — not just the bad alias. Symptoms: Studio's Actions tab shows "No actions used in this block yet", `createRecord.enabled` / `updateRecord.enabled` stays `false`, `.mutate()` calls dispatch but resolve immediately to "not yet ready". Every OTHER field in the same `q.select()` is also lost, even the ones that map cleanly. Diagnostic: bisect the `q.select` — strip down to a known-good minimal set, confirm the Action appears in Studio, then add fields back in halves until it drops out. The culprit is in the last half added. Once narrowed to a single field, grep its name against the freshest Airtable schema export to catch the rename / trailing-space / case-mismatch. Verified 2026-05-21: a `"Photos"` column on Wigs was renamed to `"Before Photos"`, the helper that wrote `photos: "Photos"` had its entire Action disabled even though 11 other fields in the same `q.select` were fine. See [datasources/airtable.md](../datasources/airtable.md#maintainability-gotcha) |
45
50
 
@@ -82,11 +82,12 @@ useRecords({
82
82
  ## Single Record (detail pages)
83
83
 
84
84
  ```jsx
85
- var recordId = useCurrentRecordId();
86
- var result = useRecord({ recordId: recordId, select: select });
85
+ var recordId = useCurrentRecordId(); // the URL's recordId — can be null
86
+ var result = useRecord({ recordId: recordId, select: select, enabled: !!recordId });
87
+ var record = result.data && result.data.id === recordId ? result.data : null;
87
88
  ```
88
89
 
89
- `recordId` may be omitted when the block's Studio data binding supplies the record context (verified by deployed block, July 2026) — see [reading.md](../datasources/reading.md#userecord----fetch-a-single-record).
90
+ There is **no detail-page auto-scoping** (verified live 2026-09-18): `useRecords({ count: 1 })` returns the table's FIRST row, and a null-id `useRecord` falls back to a list call — hence `enabled: !!recordId` (honoured by `useRecord`; **ignored by `useRecords`**) and the `data.id` check. The older "recordId may be omitted when Studio supplies the record context" note is qualified in [reading.md](../datasources/reading.md#userecord----fetch-a-single-record).
90
91
 
91
92
  ## Current User
92
93
 
@@ -13,10 +13,10 @@ The official Softr MCP server (`https://mcp.softr.io/mcp`) gives an AI assistant
13
13
  - [What it covers](#what-it-covers)
14
14
  - [Connection and auth](#connection-and-auth)
15
15
  - [Permissions model](#permissions-model)
16
- - [Vibe coding block tools](#vibe-coding-block-tools)
16
+ - [Vibe coding block tools](#vibe-coding-block-tools) — incl. [what the server enforces on a block's data endpoints](#what-the-server-enforces-on-a-blocks-data-endpoints)
17
17
  - [Adopting Studio-AI-generated code](#adopting-studio-ai-generated-code)
18
18
  - [Vibe coding gotchas (official)](#vibe-coding-gotchas-official)
19
- - [Application management tools](#application-management-tools)
19
+ - [Application management tools](#application-management-tools) — incl. [testing as any user via "Preview as"](#testing-as-any-app-user-without-logins--the-preview-as-switcher)
20
20
  - [Browsing integrations (external data sources)](#browsing-integrations-external-data-sources)
21
21
  - [Softr Database tools](#softr-database-tools)
22
22
  - [Workflows](#workflows)
@@ -96,6 +96,23 @@ not name, so **each block keeps its own pair and the swap step disappears entire
96
96
  2026-09-09 across a report block deployed to two pages). It is also the safer option on large files:
97
97
  retransmitting ~100KB verbatim to change one class string is its own corruption risk.
98
98
 
99
+ **Search-replace on a 100KB+ block — the working recipe (verified live 2026-09-18).** Sent a real
100
+ array of `{ search, replace }` objects (not a JSON string — see
101
+ [the array-argument rejection](#the-array-argument-rejection-and-why-it-is-a-security-issue)), the
102
+ tool patches large blocks reliably, and nothing but the fragments passes through the model's
103
+ context. Keep the local mirror in step mechanically rather than by hand:
104
+
105
+ 1. Prove deployed == disk first ([below](#verifying-a-push--the-deployed-source-is-the-only-proof)).
106
+ 2. Write the ops once, as data. Send them to the tool, and apply the **identical** ops to the local
107
+ mirror with a script that asserts each `search` occurs exactly once before replacing it.
108
+ 3. Several rounds of ops are fine — **byte-verify once at the end**: fetch `sourceCode`, compare to
109
+ the mirror, and a mismatch means an op landed differently on one side.
110
+
111
+ One encoding trap: JSON `\uXXXX` escapes inside the ops are **decoded to the real characters** on
112
+ Softr's side (`"—"` is stored as `—`). The mirror must therefore hold raw UTF-8 — apply the
113
+ ops to it *after* JSON-decoding them, never as the escaped text, or the final byte comparison
114
+ fails on every non-ASCII character.
115
+
99
116
  **Reach for the full replace when the change is structural** — reordering JSX, moving logic between
100
117
  components, adding a hook — where being sure of "the exact current text" of a dozen scattered fragments
101
118
  is harder than being sure of the whole file. Also use it when the local file is the source of truth and
@@ -121,14 +138,19 @@ unverified until you have pulled the source back down and compared it.
121
138
  --log-level=error --outfile=/dev/null`) plus eslint with `@babel/eslint-parser`. The bugs that
122
139
  actually bite Softr blocks are semantic — `useRecordUpdate({ select: … })` instead of `fields:`,
123
140
  an invented identifier — and the push is the first thing that reports them.
124
- 3. Push the **entire** file.
141
+ 3. Push the **entire** file — or, for a targeted patch on a large block, send search-replace ops
142
+ and apply the identical ops to the mirror
143
+ ([recipe above](#which-edit-tool-full-replace-vs-targeted-search-replace)).
125
144
  4. **Fetch it back and compare again.** Identical, or you are not done: diff, fix, re-push.
126
145
 
127
- **Tolerate exactly one difference: the trailing newline.** Softr sometimes strips the file's final
128
- `\n` on save and sometimes keeps it — stripped on every push on 2026-09-09, kept on every push on
129
- 2026-09-10, same app, same tools. A comparison that demands byte equality will report a phantom
130
- mismatch on some days; one that ignores *all* whitespace will miss the dropped-blank-line case above.
131
- Compare with the trailing newline normalised and nothing else.
146
+ **Compare byte for byte, trailing newline included.** Softr stores exactly what it receives: across
147
+ 58 push→fetch pairs between 2026-08-26 and 2026-09-10 (14 blocks, 16–161 KB each) the fetched
148
+ `sourceCode` was byte- and MD5-identical to the text sent, including two pushes sent *without* a
149
+ final newline and stored without one. The "deployed block is one byte shorter" we chased on
150
+ 2026-09-09 was our own read: an agent that reads a large file in chunks can drop the final
151
+ newline (or a blank line at a chunk boundary) before transmission. A comparison that normalises
152
+ the trailing newline hides exactly that class of error — so do not normalise anything; a mismatch
153
+ means re-send, whatever the byte.
132
154
 
133
155
  **One file, two blocks, two datasource pairs.** When the same source is deployed to two pages, the
134
156
  local file holds ONE page's `datasource.define()` pair. Push it as-is to that block; for the other,
@@ -137,7 +159,7 @@ expectation (disk for the first, disk-with-swap for the second). Never save the
137
159
  the local mirror — the mirror records which page it belongs to, and the block's header comment
138
160
  records the other page's pair. Search-replace would avoid the swap altogether
139
161
  ([above](#which-edit-tool-full-replace-vs-targeted-search-replace)) — when the client can send its
140
- array argument ([below](#the-array-argument-serialization-quirk-and-why-it-is-a-security-issue)).
162
+ array argument ([below](#the-array-argument-rejection-and-why-it-is-a-security-issue)).
141
163
 
142
164
  **Do not read a 100KB block into a model's context to push it.** The full-replace tool takes the
143
165
  whole file as a string parameter, so the source has to pass through whatever is making the call. A
@@ -152,35 +174,40 @@ default permissions (see the next section for why that can be a security problem
152
174
  the restoration). If page-level visibility is the access control in your app, record that decision
153
175
  so nobody chases the reset after every round; if it is not, re-tighten and read back.
154
176
 
155
- ### The array-argument serialization quirk, and why it is a security issue
177
+ ### The array-argument rejection, and why it is a security issue
156
178
 
157
- **Several tools on this server take an array argument, and some MCP clients serialize it as a JSON
158
- *string* instead.** The API then rejects it with a Jackson error before it reaches any business logic:
179
+ **Several workspace-server tools take an array argument, and a call that sends it as a JSON *string*
180
+ is rejected** by Jackson before it reaches any business logic:
159
181
 
160
182
  ```
161
183
  Cannot deserialize value of type `java.util.ArrayList<java.util.Map<String,Object>>`
162
184
  from String value (token `JsonToken.VALUE_STRING`)
163
185
  ```
164
186
 
165
- Root cause: the server advertises an **empty schema** for its tools (`{"type":"object"}`, no property
166
- definitions), so a client has no type information to serialize against. Confirmed 2026-09-09 on:
167
-
168
187
  | Tool | Array argument | Fallback if it fails |
169
188
  |---|---|---|
170
189
  | `update_vibe_coding_block_code_search_replace` | `operations` | Use `update_vibe_coding_block_code` (full replace) |
171
190
  | `set_vibe_coding_block_action_visibility` | `updates` | **NONE — a human must fix it in Studio** |
172
191
 
173
- It is intermittent, and that is the trap: on 2026-09-09 both tools accepted the array early in a
174
- session and rejected it an hour later, same shapes, same session. Do not conclude from one success
175
- that the path is reliable for the rest of your work.
176
-
177
- **Why the second row is a security problem, not an inconvenience.** Every code push resets the block's
178
- auto-registered Actions to Softr's defaults, and the default for a `genericActions` **ADD_RECORD is
179
- `ALL_USERS`** — writable by logged-OUT visitors. The documented remedy is to re-tighten with
180
- `set_vibe_coding_block_action_visibility`. When that call is the one that fails, a routine cosmetic push
181
- silently leaves public write access on the block, and nothing in the push result says so: the push
182
- itself returns `errors: null, warnings: null`. Verified live 2026-09-09 — one push left four ADD_RECORD
183
- actions open across two blocks.
192
+ **Where the string comes from — corrected 2026-09-10.** The first write-up of this (2026-09-09)
193
+ blamed the server for advertising an empty schema. The transcripts say otherwise: the workspace
194
+ server's schema declares both parameters as `type: array`, all 13 rejected calls had sent a JSON
195
+ string, and all 84 successful calls to the same two tools had sent a real array — same day, same
196
+ shapes, different payload type. The stringification happened on the client side, most likely on
197
+ calls made while the tool definitions had not been loaded into the model's context (deferred
198
+ schemas), so there was no type to serialise against. **Load the tool's schema before calling it,
199
+ and pass arrays as arrays.** (Empty schemas are real on Softr's *per-application* MCP servers —
200
+ every tool there is advertised as `{"type":"object"}` with a name-only description — but the
201
+ workspace server is not affected.)
202
+
203
+ **Why the second row is a security problem, not an inconvenience.** Every code push resets the
204
+ block's auto-registered Actions to Softr's defaults, and the default for a `genericActions`
205
+ **ADD_RECORD is `ALL_USERS`** — writable by logged-OUT visitors — while UPDATE_RECORD and
206
+ DELETE_RECORD default to `LOGGED_IN_USERS` in the same response. The documented remedy is to
207
+ re-tighten with `set_vibe_coding_block_action_visibility`. When that call is the one that fails, a
208
+ routine cosmetic push silently leaves public write access on the block, and nothing in the push
209
+ result says so: the push itself returns `errors: null, warnings: null`. Verified live 2026-09-09 —
210
+ one push left four ADD_RECORD actions open across two blocks.
184
211
 
185
212
  **So treat permission restoration as a step that must be VERIFIED, never assumed:**
186
213
 
@@ -203,10 +230,15 @@ actions open across two blocks.
203
230
  block the publish.** It is not your app, and the person whose app it is needs the finding and the
204
231
  severity, not a veto.
205
232
 
206
- One caveat worth stating: page visibility and action permissions are *separate* gates, and whether
207
- Softr enforces page VIEW on the action endpoint itself is unverified here. The reason to treat a
208
- gated page as low-severity is the practical difficulty and low blast radius, not a proof that the
209
- action is unreachable. Say that plainly rather than implying the action is safe.
233
+ One caveat worth stating: page visibility and action permissions are *separate* gates. Page VIEW
234
+ **is** enforced on the block's datasource **records** endpoint (verified live 2026-09-18 — a
235
+ viewer who cannot view the page gets a 403 whose message names "block/action visibility rules";
236
+ see [below](#what-the-server-enforces-on-a-blocks-data-endpoints)). The *action* (write) endpoint
237
+ was not exercised separately; the message wording suggests the same gate covers it, but that part
238
+ is inference. So the reason to treat a gated page as low-severity is still the practical
239
+ difficulty and low blast radius — and note that "gated to logged-in users" keeps out anonymous
240
+ visitors only: any logged-in user can view that page, and therefore reach its endpoints. Say that
241
+ plainly rather than implying the action is safe.
210
242
 
211
243
  **Calibration matters.** This guidance read "do not publish" in v2.5.1 and immediately fired at
212
244
  maximum severity on a logged-in-gated app where the real exposure was junk records. A warning that
@@ -220,6 +252,34 @@ reverts the code along with the permissions, undoing the change you just pushed.
220
252
 
221
253
  Both edit paths recompile, so both reset Action permissions either way (Hard Constraint 21).
222
254
 
255
+ ### What the server enforces on a block's data endpoints
256
+
257
+ *Verified live 2026-09-18 (Softr Database; draft preview, "Preview as" different users, requests
258
+ captured from the app iframe).* A block's data lives behind per-connection endpoints —
259
+ `/blocks/<blockId>/datasources/<dataSourceId>/records` for lists, `/records/<id>` for one record —
260
+ and these are the gates that actually exist on them:
261
+
262
+ | Gate | Enforced server-side? |
263
+ |---|---|
264
+ | **Page VIEW permission** | **Yes.** A viewer who cannot view the page gets **403** ("block/action visibility rules…") from the block's datasource endpoint — crafting the request by hand does not get around it |
265
+ | **The connection's Source conditions** (Source tab / `set_vibe_coding_block_data_source_record_filters`) | **Yes — and they are the only server-side ROW gate** |
266
+ | A `where` filter in the block's code | No — it is a request parameter the caller controls |
267
+ | Which fields the block *renders*, a second / conditional `q.select`, `enabled: false` on `useRecords` | No — the endpoint returns the union of the connection's read selects to anyone allowed to call it (see [multi-datasource.md](../datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)) |
268
+
269
+ The consequence to design around: **on a page any logged-in user may view, every datasource
270
+ connected to its blocks is readable by any logged-in user who crafts the request** — all rows the
271
+ Source conditions allow, all fields the block's read selects name. A per-record access check in
272
+ React ("is this viewer a party to this record?") shapes the UI; it is not access control. When rows
273
+ must be private per user, put it in the Source conditions (e.g. a logged-in-user condition) or on a
274
+ page only the right group can view. When a field must be private, a second connection of the table
275
+ keeps it out of every ordinary browser's payload — but not away from a crafted request by someone
276
+ who may view the page; for that it has to live on a page (or in a group-gated block) the viewer
277
+ cannot see. (The verified 403 case was page VIEW; the message's "block/action visibility rules"
278
+ wording suggests block visibility is checked the same way, which is inference.)
279
+
280
+ This is also what makes the open-`ADD_RECORD` finding above severity-dependent on the page's VIEW
281
+ permission rather than uniformly critical.
282
+
223
283
  ## Adopting Studio-AI-generated code
224
284
 
225
285
  When you pull a Studio-AI-generated block via `get_vibe_coding_block_code` to adopt into a project repo as source of truth: its output renders fine but ships with predictable defects. **Functional patterns in Studio output are platform-support evidence** (it surfaces undocumented capabilities before the docs do — see SKILL.md's "Platform truth sources"); **its code hygiene is not a pattern to imitate.** Cleanup pass before committing:
@@ -260,6 +320,37 @@ Combined with the database tools (`create_database` / `create_table` / `create_f
260
320
 
261
321
  > **preview_app links are auth tokens.** Per the server's own instructions, a preview link **signs its opener in as the user who requested it** and lasts about a day. Give it only to that user, and mint a fresh one with another `preview_app` call rather than re-sending an old link. Never paste a preview link into a shared channel.
262
322
 
323
+ ### Testing as any app user without logins — the "Preview as" switcher
324
+
325
+ *Verified live 2026-09-18.* The `preview_app` link does not open the app directly: it opens a
326
+ **toolbar shell** with a **"Preview as" user switcher**, and runs the draft app in an **iframe**
327
+ whose URL carries `?autoUser=true`. That is a complete role-testing rig — every user group, no
328
+ passwords, no test accounts to create:
329
+
330
+ - The switcher is a **Choices.js** select listing the app's users. Picking one raises a
331
+ confirmation modal ("Ok, I understand"); after confirming, the app runs as that user, and the
332
+ choice persists per browser.
333
+ - **With the Browser pane visible**, click through it like a person would.
334
+ - **With the pane hidden, drive it by script** in the shell page: dispatch `mouseover` then
335
+ `mousedown` on `.choices__item--choice[data-value="<email>"]` (Choices.js acts on
336
+ `mousedown`, not `click`), then click
337
+ `#userConfirmationModal button.submit-btn`.
338
+ - **To see what a block really sends and receives**, set `iframe.src` to the page under test and
339
+ wrap `iframe.contentWindow.fetch` immediately afterwards, recording each request body and
340
+ response. This is how the union-of-selects, `pageContext: null` and `enabled: false` findings in
341
+ [../datasources/](../datasources/) were established — read the wire, not the rendered UI.
342
+ - Blocks render in shadow roots inside that iframe: read the DOM through
343
+ `iframe.contentDocument` and each block host's `shadowRoot`, not `document.querySelector`.
344
+
345
+ > **The preview is wired to the LIVE datasource.** Anything clicked there — a Save, a status
346
+ > change, a form submit — writes real records, as the previewed user. Keep preview sessions to
347
+ > read-only checks unless the record is a marked test record, and never run a write path "just to
348
+ > see" against client data.
349
+
350
+ Limits: a page gated to a user group nobody belongs to cannot be previewed until someone is in the
351
+ group, and these selectors are Softr's shell internals — observed, not documented, so re-inspect
352
+ the shell if a selector stops matching rather than assuming the feature is gone.
353
+
263
354
  ## Browsing integrations (external data sources)
264
355
 
265
356
  An integration is an external data source connected once per workspace (the builder says "integrations", the tools say "data sources" — same thing). Five read-only tools drill down from workspace to fields; each level needs an ID from the level above: