softr-vibe-coding 2.8.2 → 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 +3 -0
- package/README.md +12 -4
- package/SKILL.md +30 -1
- package/datasources/fields.md +1 -1
- package/datasources/multi-datasource.md +91 -0
- package/datasources/reading.md +107 -7
- package/datasources/writing.md +23 -0
- package/package.json +1 -1
- package/references/anti-patterns.md +5 -0
- package/references/quick-reference.md +4 -3
- package/references/softr-mcp.md +91 -8
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,9 @@ 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
|
+
|
|
7
10
|
## [2.8.2] - 2026-09-10
|
|
8
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)
|
|
9
12
|
|
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,
|
|
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")
|
|
@@ -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-
|
|
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
|
|
package/datasources/fields.md
CHANGED
|
@@ -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
|
package/datasources/reading.md
CHANGED
|
@@ -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, //
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
package/datasources/writing.md
CHANGED
|
@@ -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.
|
|
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
|
-
`
|
|
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
|
|
package/references/softr-mcp.md
CHANGED
|
@@ -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,7 +138,9 @@ 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
146
|
**Compare byte for byte, trailing newline included.** Softr stores exactly what it receives: across
|
|
@@ -140,7 +159,7 @@ expectation (disk for the first, disk-with-swap for the second). Never save the
|
|
|
140
159
|
the local mirror — the mirror records which page it belongs to, and the block's header comment
|
|
141
160
|
records the other page's pair. Search-replace would avoid the swap altogether
|
|
142
161
|
([above](#which-edit-tool-full-replace-vs-targeted-search-replace)) — when the client can send its
|
|
143
|
-
array argument ([below](#the-array-argument-
|
|
162
|
+
array argument ([below](#the-array-argument-rejection-and-why-it-is-a-security-issue)).
|
|
144
163
|
|
|
145
164
|
**Do not read a 100KB block into a model's context to push it.** The full-replace tool takes the
|
|
146
165
|
whole file as a string parameter, so the source has to pass through whatever is making the call. A
|
|
@@ -211,10 +230,15 @@ one push left four ADD_RECORD actions open across two blocks.
|
|
|
211
230
|
block the publish.** It is not your app, and the person whose app it is needs the finding and the
|
|
212
231
|
severity, not a veto.
|
|
213
232
|
|
|
214
|
-
One caveat worth stating: page visibility and action permissions are *separate* gates
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
218
242
|
|
|
219
243
|
**Calibration matters.** This guidance read "do not publish" in v2.5.1 and immediately fired at
|
|
220
244
|
maximum severity on a logged-in-gated app where the real exposure was junk records. A warning that
|
|
@@ -228,6 +252,34 @@ reverts the code along with the permissions, undoing the change you just pushed.
|
|
|
228
252
|
|
|
229
253
|
Both edit paths recompile, so both reset Action permissions either way (Hard Constraint 21).
|
|
230
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
|
+
|
|
231
283
|
## Adopting Studio-AI-generated code
|
|
232
284
|
|
|
233
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:
|
|
@@ -268,6 +320,37 @@ Combined with the database tools (`create_database` / `create_table` / `create_f
|
|
|
268
320
|
|
|
269
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.
|
|
270
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
|
+
|
|
271
354
|
## Browsing integrations (external data sources)
|
|
272
355
|
|
|
273
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:
|