@softr/sdk 0.0.97-alpha.2 → 0.0.98-anonsession.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.
Files changed (42) hide show
  1. package/dist/action.d.ts +1 -45
  2. package/dist/action.js +1 -37
  3. package/dist/auth/context.d.ts +4 -1
  4. package/dist/auth/isomorphic.d.ts +1 -1
  5. package/dist/auth/isomorphic.js +2 -1
  6. package/dist/auth/methods/authenticator.js +3 -12
  7. package/dist/auth/methods/code.js +1 -4
  8. package/dist/auth/methods/emailCode2fa.js +1 -4
  9. package/dist/auth/methods/magicLink.js +1 -4
  10. package/dist/auth/methods/password.js +1 -4
  11. package/dist/auth/methods/passwordLink.js +1 -4
  12. package/dist/auth/methods/signup.js +1 -4
  13. package/dist/auth/methods/social.js +1 -4
  14. package/dist/auth/permissions.d.ts +0 -9
  15. package/dist/auth/platform.js +12 -27
  16. package/dist/default-error-overlay.d.ts +2 -2
  17. package/dist/index.d.ts +2 -3
  18. package/dist/index.js +2 -2
  19. package/dist/package.js +1 -1
  20. package/dist/pwa.d.ts +2 -2
  21. package/dist/server.d.ts +1 -2
  22. package/dist/server.js +1 -2
  23. package/package.json +1 -1
  24. package/skills/softr-sdk/SKILL.md +4 -10
  25. package/skills/softr-sdk/ai/SKILL.md +3 -1
  26. package/skills/softr-sdk/auth/SKILL.md +1 -1
  27. package/skills/softr-sdk/charts-and-metrics/SKILL.md +21 -4
  28. package/skills/softr-sdk/current-user/SKILL.md +16 -3
  29. package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
  30. package/skills/softr-sdk/formatting/SKILL.md +1 -1
  31. package/skills/softr-sdk/integrations/SKILL.md +1 -3
  32. package/skills/softr-sdk/mcp/SKILL.md +1 -1
  33. package/skills/softr-sdk/pwa/SKILL.md +1 -1
  34. package/skills/softr-sdk/record-mutations/SKILL.md +41 -4
  35. package/skills/softr-sdk/record-queries/SKILL.md +132 -36
  36. package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
  37. package/skills/softr-sdk/routes-and-visibility/SKILL.md +2 -2
  38. package/skills/softr-sdk/workflows/SKILL.md +8 -10
  39. package/dist/auth/permissions.js +0 -13
  40. package/dist/core/email.d.ts +0 -48
  41. package/dist/core/email.js +0 -100
  42. package/skills/softr-sdk/email/SKILL.md +0 -136
@@ -4,12 +4,12 @@ description: >-
4
4
  Read the signed-in Softr visitor: useCurrentUser and the User type,
5
5
  context.user in a route's beforeLoad, and context.user inside a server
6
6
  function handler, where Start types it from the app's registered request
7
- middleware. Covers the null guest and the generated SoftrUserGroups that
8
- narrows group ids to the app's own.
7
+ middleware. Covers the null guest, the generated SoftrUserGroups that narrows
8
+ group ids to the app's own, and reading the visitor's role on the server.
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.97-alpha.2
12
+ library_version: 0.0.98-anonsession.0
13
13
  ---
14
14
 
15
15
  # The current user
@@ -92,6 +92,19 @@ const getMyInvoices = createServerFn().handler(async ({ context }) => {
92
92
 
93
93
  `context.user` is typed. Start derives a handler's context from the app's registered request middleware as well as the server function's own, so there is nothing to import and nothing to cast.
94
94
 
95
+ ### The visitor's role, in a server function
96
+
97
+ A server function that answers by role reads the role from `context.user.userGroupIds`, never from an argument. Use it to pick which query and which scope the role reads, as [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records) shows:
98
+
99
+ ```ts
100
+ if (!context.user) throw new Error('Not signed in')
101
+ if (context.user.userGroupIds.includes('00000000-0000-0000-0000-00000000000a')) {
102
+ // read with the staff query and the staff scope
103
+ }
104
+ ```
105
+
106
+ `context.user.id` is the visitor's account id, not their row in the Users table. A link to Users holds the row id, so a filter on such a link takes the row that `auth.currentUserRow` finds.
107
+
95
108
  ### Group ids are the app's own
96
109
 
97
110
  For every group's name and id, read `docs/user-groups.md`. It lists every group the app has, the predefined ones included. Copy an id from there; never derive one from a group's name.
@@ -7,7 +7,7 @@ description: >-
7
7
  metadata:
8
8
  type: sub-skill
9
9
  library: softr-sdk
10
- library_version: 0.0.97-alpha.2
10
+ library_version: 0.0.98-anonsession.0
11
11
  ---
12
12
 
13
13
  **Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
@@ -8,7 +8,7 @@ description: >-
8
8
  metadata:
9
9
  type: sub-skill
10
10
  library: softr-sdk
11
- library_version: 0.0.97-alpha.2
11
+ library_version: 0.0.98-anonsession.0
12
12
  ---
13
13
 
14
14
  # Dates, times and numbers
@@ -7,7 +7,7 @@ description: >-
7
7
  metadata:
8
8
  type: sub-skill
9
9
  library: softr-sdk
10
- library_version: 0.0.97-alpha.2
10
+ library_version: 0.0.98-anonsession.0
11
11
  ---
12
12
 
13
13
  **Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -16,8 +16,6 @@ metadata:
16
16
 
17
17
  `integration.createFetcher` calls a third-party API through Softr's authenticated proxy. It adds the connected account's credentials automatically — you never set API keys or auth headers yourself. **Every call acts as that one account, whoever the visitor is**, so take what a call reaches, such as a customer or a channel, from the session or a record the server read, never from the arguments the browser sends.
18
18
 
19
- An email the app sends is the `email` sub-skill, with nothing to connect. Send through a connected Gmail or Outlook only when the user wants the email to come from their own mailbox.
20
-
21
19
  ## Setup
22
20
 
23
21
  ```ts
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.97-alpha.2
12
+ library_version: 0.0.98-anonsession.0
13
13
  ---
14
14
 
15
15
  **Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.97-alpha.2
12
+ library_version: 0.0.98-anonsession.0
13
13
  ---
14
14
 
15
15
  # Installing the app
@@ -2,13 +2,14 @@
2
2
  name: record-mutations
3
3
  description: >-
4
4
  Create, update and delete records with defineMutation, ds.create, ds.update
5
- and ds.delete: the payload keyed by your own field names and what each field
6
- type accepts. Use when submitting a form, saving an edit or deleting a record,
7
- or when a write fails or changes nothing. Reads are record-queries.
5
+ and ds.delete: the payload keyed by your own field names, what each field type
6
+ accepts, and keeping an update or a delete to the records the visitor may
7
+ read. Use when submitting a form, saving an edit or deleting a record, or when
8
+ a write fails or changes nothing. Reads are record-queries.
8
9
  metadata:
9
10
  type: sub-skill
10
11
  library: softr-sdk
11
- library_version: 0.0.97-alpha.2
12
+ library_version: 0.0.98-anonsession.0
12
13
  ---
13
14
 
14
15
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -111,6 +112,42 @@ const removeProject = createServerFn({ method: 'POST' })
111
112
 
112
113
  Resolves to nothing. A record that cannot be deleted throws.
113
114
 
115
+ ### Change only the records the visitor may read
116
+
117
+ When a role reads only some records of a table, it updates and deletes only those records. `permissions` decides who may run the mutation, not which record. The id comes from the browser, so it can name any record. Before the write, read the record through the same query and scope as every read of the table (see [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records)). Refuse the write when that read answers `null`, with the same error as a missing record, so the visitor cannot find out which ids exist. Keep the field that the scope filters on, such as the owner, out of the update's `fields`. If it is in `fields`, a visitor can move a record to another person.
118
+
119
+ Below, `bookings` is the query every read of bookings uses, and `usersQuery` is the app's query on the Users table. `updateBooking` declares `status` only, not `client`.
120
+
121
+ ```ts
122
+ import { auth, ds } from '@softr/sdk/server'
123
+
124
+ const cancelBooking = createServerFn({ method: 'POST' })
125
+ .validator(z.object({ id: z.string() }))
126
+ .handler(async ({ context, data }) => {
127
+ const me = await auth.currentUserRow(context.user, usersQuery, 'email')
128
+ if (me === null) throw new Error('Booking not found')
129
+ const booking = await ds.get(bookings, { id: data.id }).where((q) => q.client.isOneOf([me.id]))
130
+ if (booking === null) throw new Error('Booking not found')
131
+ await ds.update(updateBooking, { id: booking.id, data: { status: { id: 'cancelled' } } })
132
+ })
133
+ ```
134
+
135
+ A delete takes the same read before `ds.delete`. A create into a table that takes its parent's scope, such as a message in a chat, reads the parent through its scope before `ds.create` (see [record-queries](../record-queries/SKILL.md#a-table-with-no-link-to-users-takes-its-parents-scope)).
136
+
137
+ ### Only the roles that change a table
138
+
139
+ Often fewer roles change a table than read it. A mutation's `customGroups` names only the roles that may change the table, also when more roles read it. In a barber app, every client reads the free hours, but only barbers open or close them. So the slot update names the barbers' group only, and a barber still changes only their own slots, through the read above:
140
+
141
+ ```ts
142
+ const updateSlot = defineMutation({
143
+ id: 'updateSlot',
144
+ datasourceId: '00000000-0000-0000-0000-000000000004',
145
+ operation: 'update',
146
+ fields: { open: 'Wd5nKp2xRb8Tc3' },
147
+ permissions: { userGroupTypes: ['LOGGED_IN_USERS'], customGroups: ['00000000-0000-0000-0000-00000000000b'] },
148
+ })
149
+ ```
150
+
114
151
  ### Edit an existing record
115
152
 
116
153
  An edit form needs both halves: a `defineQuery` to load the record and a `defineMutation` to save it. They are separate declarations over the same table, so give both the fields the form touches, and read the loaded values off the query's names.
@@ -2,14 +2,15 @@
2
2
  name: record-queries
3
3
  description: >-
4
4
  Fetch records with defineQuery, ds.list and ds.get: where and orderBy, field
5
- names of your own choosing, pagination and its limits, and scoping a read to
6
- the signed-in visitor. Use to put records on a page or fill a dropdown, or
7
- when a read comes back empty, unsorted or missing a field. Writes are
8
- record-mutations; totals and chart rows are charts-and-metrics.
5
+ names of your own choosing, pagination and its limits, and keeping each
6
+ visitor to their own records and to the fields their role may see. Use to put
7
+ records on a page or fill a dropdown, to write any server function that reads
8
+ a table, or when a read comes back empty, unsorted or missing a field. Writes
9
+ are record-mutations; totals and chart rows are charts-and-metrics.
9
10
  metadata:
10
11
  type: sub-skill
11
12
  library: softr-sdk
12
- library_version: 0.0.97-alpha.2
13
+ library_version: 0.0.98-anonsession.0
13
14
  ---
14
15
 
15
16
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. The same holds for any helper that calls `ds`: declare it inside the handler. A function at module level that calls `ds` keeps the server-only import in the browser bundle, and every page stops loading until it moves. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -125,37 +126,6 @@ if (first.nextOffset !== null) {
125
126
 
126
127
  `nextOffset` is `null` when there are no more pages. `total` is the count of all matching records, not the count returned.
127
128
 
128
- ### Scope the read to the visitor
129
-
130
- ```ts
131
- const myInvoices = defineQuery({
132
- id: 'myInvoices',
133
- datasourceId: '00000000-0000-0000-0000-000000000001',
134
- fields: { owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' },
135
- permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
136
- })
137
-
138
- const listMyInvoices = createServerFn().handler(async ({ context }) => {
139
- if (!context.user) throw new Error('Not signed in')
140
- const userId = context.user.id
141
- return await ds.list(myInvoices).where((q) => q.owner.is(userId))
142
- })
143
- ```
144
-
145
- When the owner is a link to the Users table, the row to match is the visitor's own row there, not `context.user.id`. `auth.currentUserRow(context.user, usersQuery, 'email')` finds it by email, or answers `null` when they are signed out or have no row:
146
-
147
- ```ts
148
- import { auth, ds } from '@softr/sdk/server'
149
-
150
- const listMyDeals = createServerFn().handler(async ({ context }) => {
151
- const me = await auth.currentUserRow(context.user, usersQuery, 'email')
152
- if (me === null) return { records: [], nextOffset: null }
153
- return await ds.list(deals).where((q) => q.owner.isOneOf([me.id]))
154
- })
155
- ```
156
-
157
- The SDK enforces `permissions`, so a visitor outside the declared audience never reaches the records. Which of those records belong to one visitor is a separate question, and the `where` above answers it: a group says nothing about who owns a row, so a per-visitor read filters on `context.user`. See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
158
-
159
129
  ### Populate a control from a linked table
160
130
 
161
131
  A `LINKED_RECORD` field on a record answers with `{ id, label }` — the label is the linked record's primary field, and the id is what a write or a `where` filter takes. To fill a filter dropdown or form select with **all** options from the linked table (not just the values already referenced by existing records), query the linked table's own datasource:
@@ -194,6 +164,8 @@ const loadProductStatuses = createServerFn()
194
164
  .handler(({ data }) => ds.options(productStatuses, { label: 'name', ...data }))
195
165
  ```
196
166
 
167
+ `ds.options` takes no `where`, so use it only over a table with no scope. A picker over a scoped table reads with `ds.list` and the scope: see [A picker over a scoped table](#a-picker-over-a-scoped-table).
168
+
197
169
  Skip this request when the options are predefined and fixed — hardcode them instead.
198
170
 
199
171
  A `SELECT` field's options are its choices, and the builder can add and rename them in Studio after the app is built. When a dropdown should follow those changes, read them with `ds.choices`. When the app means a fixed choice, such as a "Done" tab, use its id directly. A mistyped id is not caught at compile time, and a write with an id the field lacks adds it as a new choice. `ds.choices` takes any query or mutation that declares the field and answers `[{ id, label, color }]` as the field holds them now. Each `id` is what a `where` and a write take, for a filter dropdown and a form select alike:
@@ -205,6 +177,130 @@ const loadCategories = createServerFn().handler(() => ds.choices(products, 'cate
205
177
 
206
178
  A read the datasource turns down throws rather than coming back empty, and it throws a plain `Error` carrying the status, followed by the reason the datasource gives when it sends one. There is no typed predicate to narrow it with. Handle it where you would handle any other failed fetch: an `errorComponent` on the route, or a check in the handler.
207
179
 
180
+ ## Keep each visitor to their own records
181
+
182
+ `permissions` decides who may run a query. It does not decide which rows they get, and it does not hide a field the query declares. A visitor can call every server function with any arguments, so one read that skips a rule opens the table to them. The examples below use `bookings`, a query over a table where a client sees only their own bookings, and `usersQuery`, the app's query on the Users table.
183
+
184
+ ### One scope per table, in every read
185
+
186
+ A privacy rule from the builder's prompt, such as "clients see only their own bookings", belongs to the table, not to the page where it came up. Every server function that reads the table applies the same `where`: the list, the get by id, a search, a picker, a count and a chart. Keep these server functions beside the table's queries, so the copies of the filter stay together and a new read is written next to them. A get by id takes the same `where`, so an id outside the scope reads as `null`.
187
+
188
+ ```ts
189
+ // Bookings are private: a client reads only the bookings linked to their own Users row.
190
+ // Every read of bookings applies this filter.
191
+ const getMyBooking = createServerFn()
192
+ .validator(z.object({ id: z.string() }))
193
+ .handler(async ({ context, data }) => {
194
+ const me = await auth.currentUserRow(context.user, usersQuery, 'email')
195
+ if (me === null) return null
196
+ return await ds.get(bookings, { id: data.id }).where((q) => q.client.isOneOf([me.id]))
197
+ })
198
+ ```
199
+
200
+ The filter matches the field that holds the owner. A link to the Users table holds the visitor's row there, not `context.user.id`. `auth.currentUserRow(context.user, usersQuery, 'email')` finds that row by email, or answers `null` when they are signed out or have no row. A `USER` field holds the account itself, so it filters on `context.user.id` after a check for a signed-out visitor. See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
201
+
202
+ ### A picker over a scoped table
203
+
204
+ `ds.options` takes no `where`, so a picker over a scoped table reads with `ds.list` and the scope, and maps the records to the `{ id, label }` the picker takes. Here `title` is the field of `bookings` the picker shows:
205
+
206
+ ```ts
207
+ const loadMyBookings = createServerFn()
208
+ .validator(z.object({ term: z.string(), offset: z.number().nullable() }))
209
+ .handler(async ({ context, data }) => {
210
+ const me = await auth.currentUserRow(context.user, usersQuery, 'email')
211
+ if (me === null) return { options: [], nextOffset: null }
212
+ const term = data.term.trim()
213
+ let list = ds.list(bookings).where((q) => q.client.isOneOf([me.id]))
214
+ if (term !== '') list = list.where((q) => q.title.contains(term))
215
+ const page = await list.offset(data.offset).limit(20)
216
+ return {
217
+ options: page.records.map((record) => ({ id: record.id, label: record.fields.title ?? '' })),
218
+ nextOffset: page.nextOffset,
219
+ }
220
+ })
221
+ ```
222
+
223
+ ### Server functions you add use the same scope
224
+
225
+ A server function you write for a new page or widget applies the table's scope too. When it needs other fields, it may declare its own query over the table, but every query over a scoped table takes the same scope. A query read without it is a second path to the same rows.
226
+
227
+ ```ts
228
+ const nextBookings = defineQuery({
229
+ id: 'nextBookings',
230
+ datasourceId: '00000000-0000-0000-0000-000000000002',
231
+ fields: { date: 'Xp2dLh6vKz9Fa3', client: 'Pc8vNm3kTq6Wz2' },
232
+ permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
233
+ })
234
+
235
+ // Wrong: read with no scope. Every client sees every booking.
236
+ const loadNext = createServerFn().handler(() => ds.list(nextBookings).limit(3))
237
+
238
+ // Right: the same scope as every other read of bookings.
239
+ const loadNext = createServerFn().handler(async ({ context }) => {
240
+ const me = await auth.currentUserRow(context.user, usersQuery, 'email')
241
+ if (me === null) return { records: [], total: 0, nextOffset: null }
242
+ return await ds
243
+ .list(nextBookings)
244
+ .where((q) => q.client.isOneOf([me.id]))
245
+ .orderBy((q) => q.date.asc)
246
+ .limit(3)
247
+ })
248
+ ```
249
+
250
+ The scope filters on `client`, so `nextBookings` declares it, also when the page does not show it.
251
+
252
+ ### A table with no link to Users takes its parent's scope
253
+
254
+ Some tables have no link to Users. Chat messages belong to a chat, and the browser sends the chat id. If a server function reads the messages by that id alone, any chat id reads its messages. The same goes for a game room read by its code. Such a table takes its parent's scope: read the parent through its own scope first. Read or post the children only when that read answers, and filter them on the parent that it answered. Here `chats` has a `members` link to Users, and `messages` has a `chat` link to chats:
255
+
256
+ ```ts
257
+ const listMessages = createServerFn()
258
+ .validator(z.object({ chatId: z.string() }))
259
+ .handler(async ({ context, data }) => {
260
+ const me = await auth.currentUserRow(context.user, usersQuery, 'email')
261
+ if (me === null) throw new Error('Chat not found')
262
+ const chat = await ds.get(chats, { id: data.chatId }).where((q) => q.members.isOneOf([me.id]))
263
+ if (chat === null) throw new Error('Chat not found')
264
+ return await ds
265
+ .list(messages)
266
+ .where((q) => q.chat.isOneOf([chat.id]))
267
+ .orderBy((q) => q.sentAt.asc)
268
+ .limit(100)
269
+ })
270
+ ```
271
+
272
+ A server function that posts a message reads the chat the same way before `ds.create`.
273
+
274
+ ### Other people's records show only what the role needs
275
+
276
+ A read returns every field its query declares, and everything a server function returns reaches the browser, also when the page does not show it. A list of other people, such as a directory or the members of a chat, declares only the fields its role needs, so no email or phone unless the role needs them. A `USER` field reads back with the person's email, so a list that must not show emails does not declare one either. When roles need different fields, declare one query per role, each with its own `fields` and `permissions`, and pick the query on the server by the visitor's groups:
277
+
278
+ ```ts
279
+ const peopleForMembers = defineQuery({
280
+ id: 'peopleForMembers',
281
+ datasourceId: '00000000-0000-0000-0000-000000000003',
282
+ fields: { name: 'Hn4tRw8kPz2Mc5', jobTitle: 'Lb7yQe3vXa9Ns1' },
283
+ permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
284
+ })
285
+
286
+ const peopleForStaff = defineQuery({
287
+ id: 'peopleForStaff',
288
+ datasourceId: '00000000-0000-0000-0000-000000000003',
289
+ fields: { name: 'Hn4tRw8kPz2Mc5', jobTitle: 'Lb7yQe3vXa9Ns1', email: 'Td2kWm6pFs8Jq4' },
290
+ permissions: { userGroupTypes: ['LOGGED_IN_USERS'], customGroups: ['00000000-0000-0000-0000-00000000000a'] },
291
+ })
292
+
293
+ const listPeople = createServerFn().handler(async ({ context }) => {
294
+ if (!context.user) throw new Error('Not signed in')
295
+ if (context.user.userGroupIds.includes('00000000-0000-0000-0000-00000000000a')) {
296
+ return await ds.list(peopleForStaff).limit(100)
297
+ }
298
+ return await ds.list(peopleForMembers).limit(100)
299
+ })
300
+ ```
301
+
302
+ A role that may change only some rows changes only the rows it can read: see [record-mutations](../record-mutations/SKILL.md). A count or a chart over the table takes the same scope: see [charts-and-metrics](../charts-and-metrics/SKILL.md).
303
+
208
304
  ## Typing
209
305
 
210
306
  Queries you write are type safe and a type error is shown when there's a mistake. The records a read returns are typed the same way, so never validate them again with a zod schema of your own. A schema guesses each field's shape, and one value it misses, such as a person with no email, throws for the whole read and the page stops loading. Show each field with a fallback instead: `record.fields.owner?.name ?? '—'`. For an in depth reference on what fields every datasource has, read `docs/datasources/README.md` first — it lists every registered datasource with a link to its file. Follow those links to get field ids, types, and constraints. Do not guess filenames from table names.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.97-alpha.2
12
+ library_version: 0.0.98-anonsession.0
13
13
  ---
14
14
 
15
15
  **Boundary.** `Audience` and `defineVisibility` come from `@softr/sdk` and work anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.97-alpha.2
12
+ library_version: 0.0.98-anonsession.0
13
13
  ---
14
14
 
15
15
  **Boundary.** `definePage`, `pageHead`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
@@ -73,7 +73,7 @@ Everyone is both audiences, as in the first line above. A set names each audienc
73
73
 
74
74
  Take every id in `customGroups` from `docs/user-groups.md`, which lists each group the app has with its name and id. An id the app does not have is a type error.
75
75
 
76
- **`visibility` guards the page, not the server functions it calls.** A call can reach them without the page and with any arguments, so the queries and mutations behind a page carry its groups in their own `permissions` and reach only the rows the visitor's role allows.
76
+ **`visibility` guards the page, not the server functions it calls.** A call can reach them without the page and with any arguments, so the queries and mutations behind a page carry its groups in their own `permissions`. `permissions` decide who may run them, not which rows they reach: each server function filters the rows itself, as [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records) shows.
77
77
 
78
78
  ## Core Patterns
79
79
 
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.97-alpha.2
12
+ library_version: 0.0.98-anonsession.0
13
13
  ---
14
14
 
15
15
  **Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -18,8 +18,6 @@ metadata:
18
18
 
19
19
  `workflow.trigger` fires a Softr workflow and waits for it to finish. It resolves to `void`.
20
20
 
21
- An email the app sends itself, when a form is submitted or a button is pressed, needs no workflow: load the `email` sub-skill. Fire a workflow for what the workflow's own steps do, such as updating a CRM or posting to a connected service.
22
-
23
21
  ## Setup
24
22
 
25
23
  ```ts
@@ -33,15 +31,15 @@ import { workflow } from '@softr/sdk/server'
33
31
  Every trigger needs a workflow action: which workflow, which trigger step, who may run it, and any data that is always sent. The workflow must have a **Run custom workflow** trigger — no other trigger type can be fired from app code.
34
32
 
35
33
  ```ts
36
- const syncNewLead = defineWorkflow({
37
- id: 'syncNewLead',
34
+ const sendWelcomeEmail = defineWorkflow({
35
+ id: 'sendWelcomeEmail',
38
36
  workflowId: 'c9d8e7f6-a5b4-4c3d-8e2f-1a0b9c8d7e6f',
39
37
  triggerId: '926da496-749f-4006-999c-197d8a556122',
40
38
  permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
41
39
  body: {
42
40
  email: '{LOGGED_IN_USER:EMAIL}', // resolved server-side from the signed-in user
43
41
  name: '{LOGGED_IN_USER:NAME}',
44
- source: 'website', // any hardcoded value — always sent, never overridden at runtime
42
+ plan: 'enterprise', // any hardcoded value — always sent, never overridden at runtime
45
43
  },
46
44
  })
47
45
  ```
@@ -72,7 +70,7 @@ These placeholders are resolved server-side when the workflow runs:
72
70
  const runWorkflow = createServerFn({ method: 'POST' })
73
71
  .validator(z.object({ message: z.string() }))
74
72
  .handler(async ({ data }) => {
75
- await workflow.trigger(syncNewLead, { message: data.message })
73
+ await workflow.trigger(sendWelcomeEmail, { message: data.message })
76
74
  })
77
75
  ```
78
76
 
@@ -84,7 +82,7 @@ const runWorkflow = createServerFn({ method: 'POST' })
84
82
 
85
83
  ```ts
86
84
  const run = createServerFn({ method: 'POST' }).handler(async () => {
87
- await workflow.trigger(syncNewLead)
85
+ await workflow.trigger(sendWelcomeEmail)
88
86
  })
89
87
  ```
90
88
 
@@ -96,11 +94,11 @@ Omit the second argument when the action's `body` already carries everything the
96
94
 
97
95
  ```ts
98
96
  // Wrong: calling from a click handler
99
- <button onClick={() => workflow.trigger(syncNewLead)}>Run</button>
97
+ <button onClick={() => workflow.trigger(sendWelcomeEmail)}>Run</button>
100
98
 
101
99
  // Correct: server function, called from the button
102
100
  const run = createServerFn({ method: 'POST' }).handler(() =>
103
- workflow.trigger(syncNewLead),
101
+ workflow.trigger(sendWelcomeEmail),
104
102
  )
105
103
  // …
106
104
  const submit = useServerFn(run)
@@ -1,13 +0,0 @@
1
- import { z } from "zod";
2
-
3
- //#region src/auth/permissions.ts
4
- const datasourcePermissionsSchema = z.object({
5
- read: z.boolean(),
6
- create: z.boolean(),
7
- update: z.boolean(),
8
- delete: z.boolean()
9
- });
10
- const permissionsSchema = z.object({ datasources: z.record(z.string(), datasourcePermissionsSchema) });
11
-
12
- //#endregion
13
- export { permissionsSchema };
@@ -1,48 +0,0 @@
1
- import { EmailAction } from "../action.js";
2
-
3
- //#region src/core/email.d.ts
4
- declare const EMAIL_ERROR_CODES: readonly ["NOT_FOUND", "INVALID_INPUT", "PLAN_RESTRICTED", "CREDITS_EXHAUSTED", "RATE_LIMITED", "DAILY_LIMIT_REACHED", "SENDER_UNAVAILABLE", "PROVIDER_REJECTED", "UNAVAILABLE", "TIMEOUT"];
5
- type EmailErrorCode = (typeof EMAIL_ERROR_CODES)[number];
6
- interface EmailSendInit {
7
- /** Ignored when the action declares its own. */
8
- to?: ReadonlyArray<string>;
9
- /** Ignored when the action declares its own `cc` or `to`. */
10
- cc?: ReadonlyArray<string>;
11
- /** Ignored when the action declares its own `bcc` or `to`. */
12
- bcc?: ReadonlyArray<string>;
13
- /** Ignored when the action declares its own. */
14
- replyTo?: string;
15
- subject: string;
16
- /** Plain text or HTML. */
17
- body: string;
18
- }
19
- interface EmailSent {
20
- /** Null when the mail provider did not report one; the email was still sent. */
21
- messageId: string | null;
22
- }
23
- /**
24
- * Thrown when Softr did not send the email. `code` says why:
25
- *
26
- * - `NOT_FOUND`: no such action, or the caller is not in its audience.
27
- * - `INVALID_INPUT`: the call's inputs were refused; the message says which.
28
- * - `PLAN_RESTRICTED`: the workspace's plan does not include sending this email.
29
- * - `CREDITS_EXHAUSTED`: the workspace has no workflow credits left.
30
- * - `RATE_LIMITED`: the app sent too many emails lately; try again later.
31
- * - `DAILY_LIMIT_REACHED`: Softr's own address sent the workspace's emails for today.
32
- * - `SENDER_UNAVAILABLE`: the chosen sender is missing or not confirmed, or the action leaves `to` open and would send from Softr's address.
33
- * - `PROVIDER_REJECTED`: the mail provider refused the email.
34
- * - `UNAVAILABLE`: Softr could not send it right now, or could not be reached.
35
- * - `TIMEOUT`: no answer in time. It may still have been sent.
36
- */
37
- declare class EmailError extends Error {
38
- readonly status: number;
39
- readonly code: EmailErrorCode;
40
- constructor(status: number, code: EmailErrorCode, message: string);
41
- }
42
- /** Sends one email through the action. Never retries by itself. */
43
- declare function sendEmail(action: EmailAction, init: EmailSendInit): Promise<EmailSent>;
44
- declare const email: {
45
- send: typeof sendEmail;
46
- };
47
- //#endregion
48
- export { EmailError, EmailErrorCode, EmailSendInit, EmailSent, email };
@@ -1,100 +0,0 @@
1
- import { assert } from "../assertions.js";
2
- import { softrApiUrl } from "../softr-api.js";
3
- import { fetch } from "../fetch.js";
4
- import { sessionConfig } from "../auth/config.js";
5
- import { readSessionToken } from "../auth/session.js";
6
-
7
- //#region src/core/email.ts
8
- /** Longer than Softr waits for the send itself, so its own timeout answers first. */
9
- const TIMEOUT_MS = 12e4;
10
- const EMAIL_ERROR_CODES = [
11
- "NOT_FOUND",
12
- "INVALID_INPUT",
13
- "PLAN_RESTRICTED",
14
- "CREDITS_EXHAUSTED",
15
- "RATE_LIMITED",
16
- "DAILY_LIMIT_REACHED",
17
- "SENDER_UNAVAILABLE",
18
- "PROVIDER_REJECTED",
19
- "UNAVAILABLE",
20
- "TIMEOUT"
21
- ];
22
- /**
23
- * Thrown when Softr did not send the email. `code` says why:
24
- *
25
- * - `NOT_FOUND`: no such action, or the caller is not in its audience.
26
- * - `INVALID_INPUT`: the call's inputs were refused; the message says which.
27
- * - `PLAN_RESTRICTED`: the workspace's plan does not include sending this email.
28
- * - `CREDITS_EXHAUSTED`: the workspace has no workflow credits left.
29
- * - `RATE_LIMITED`: the app sent too many emails lately; try again later.
30
- * - `DAILY_LIMIT_REACHED`: Softr's own address sent the workspace's emails for today.
31
- * - `SENDER_UNAVAILABLE`: the chosen sender is missing or not confirmed, or the action leaves `to` open and would send from Softr's address.
32
- * - `PROVIDER_REJECTED`: the mail provider refused the email.
33
- * - `UNAVAILABLE`: Softr could not send it right now, or could not be reached.
34
- * - `TIMEOUT`: no answer in time. It may still have been sent.
35
- */
36
- var EmailError = class extends Error {
37
- status;
38
- code;
39
- constructor(status, code, message) {
40
- super(message);
41
- this.name = "EmailError";
42
- this.status = status;
43
- this.code = code;
44
- }
45
- };
46
- /** Sends one email through the action. Never retries by itself. */
47
- async function sendEmail(action, init) {
48
- const { to, cc, bcc, replyTo, subject, body } = init;
49
- const sessionToken = readSessionToken();
50
- let res;
51
- try {
52
- res = await fetch(`${softrApiUrl("SOFTR_PLATFORM_API_URL")}/v1/vibe/applications/${sessionConfig().applicationId}/actions/${action.id}/email`, {
53
- method: "POST",
54
- headers: {
55
- ...sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {},
56
- "Content-Type": "application/json"
57
- },
58
- body: JSON.stringify({ input: {
59
- to,
60
- cc,
61
- bcc,
62
- replyTo,
63
- subject,
64
- body
65
- } }),
66
- signal: AbortSignal.timeout(TIMEOUT_MS)
67
- });
68
- } catch (error) {
69
- if (error instanceof DOMException && error.name === "TimeoutError") throw new EmailError(504, "TIMEOUT", `Email "${action.id}" got no answer in time. It may still have been sent.`);
70
- throw new EmailError(502, "UNAVAILABLE", `Email "${action.id}" could not reach Softr: ${error instanceof Error ? error.message : String(error)}`);
71
- }
72
- if (res.ok) {
73
- const sent = await res.json();
74
- assert(typeof sent === "object" && sent !== null && "messageId" in sent && (typeof sent.messageId === "string" || sent.messageId === null), "the send answered without a messageId");
75
- return { messageId: sent.messageId };
76
- }
77
- const { code, message } = await readFailure(res, `Email "${action.id}" failed with HTTP ${res.status}`);
78
- throw new EmailError(res.status, code, message);
79
- }
80
- /** The code and message of a send Softr refused, read from its answer; `fallbackMessage` when it sent none. */
81
- async function readFailure(res, fallbackMessage) {
82
- const failure = await res.json().catch(() => null);
83
- const known = typeof failure === "object" && failure !== null ? failure : {};
84
- return {
85
- code: ("code" in known ? EMAIL_ERROR_CODES.find((errorCode) => errorCode === known.code) : void 0) ?? codeOfStatus(res.status),
86
- message: "message" in known && typeof known.message === "string" && known.message !== "" ? known.message : fallbackMessage
87
- };
88
- }
89
- function codeOfStatus(status) {
90
- switch (status) {
91
- case 400: return "INVALID_INPUT";
92
- case 404: return "NOT_FOUND";
93
- case 429: return "RATE_LIMITED";
94
- default: return "UNAVAILABLE";
95
- }
96
- }
97
- const email = { send: sendEmail };
98
-
99
- //#endregion
100
- export { EmailError, email };