@softr/sdk 0.0.14 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/client.d.ts +1 -3
- package/dist/client.js +1 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/timezone.d.ts +4 -0
- package/dist/{client/timezone.js → timezone.js} +1 -1
- package/package.json +12 -5
- package/skills/softr-sdk/SKILL.md +121 -0
- package/skills/softr-sdk/auth/SKILL.md +171 -0
- package/skills/softr-sdk/current-user/SKILL.md +118 -0
- package/skills/softr-sdk/reading-records/SKILL.md +135 -0
- package/skills/softr-sdk/reading-records/references/query-builder.md +144 -0
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +160 -0
- package/dist/client/timezone.d.ts +0 -4
package/README.md
CHANGED
package/dist/client.d.ts
CHANGED
package/dist/client.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -5,4 +5,5 @@ import { Permissions } from "./auth/permissions.js";
|
|
|
5
5
|
import { SoftrContext } from "./auth/context.js";
|
|
6
6
|
import { AccessBehaviour, AccessCheck, AccessError, AccessReason, RouteVisibility, VisibilityHandle, defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
|
|
7
7
|
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
|
|
8
|
-
|
|
8
|
+
import { useUserTimezone } from "./timezone.js";
|
|
9
|
+
export { type AccessBehaviour, type AccessCheck, type AccessError, type AccessReason, type AggregationFunction, type ChartDataOptions, type ChartDataResult, type CreateOptions, type DatasourcePermissions, type GetOptions, type ListOptions, type ListResult, type MetricOptions, type MetricResult, type Permissions, type RouteVisibility, type SoftrContext, type SoftrMe, type SoftrPermissions, type SoftrRecord, type SoftrUser, type SoftrUserGroupId, type SoftrUserGroups, type UpdateOptions, type User, type VisibilityHandle, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, q, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { q } from "./core/query.js";
|
|
2
2
|
import { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
|
|
3
3
|
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
|
|
4
|
+
import { useUserTimezone } from "./timezone.js";
|
|
4
5
|
|
|
5
|
-
export { defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, q, softrAuthMiddleware, useCurrentUser, usePermissions };
|
|
6
|
+
export { defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, q, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softr/sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js" },
|
|
9
9
|
"./vite": { "types": "./dist/vite.d.ts", "import": "./dist/vite.js" }
|
|
10
10
|
},
|
|
11
|
-
"files": ["dist"],
|
|
11
|
+
"files": ["dist", "skills", "!skills/_artifacts"],
|
|
12
12
|
"publishConfig": { "access": "public" },
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "tsdown",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"zod": "^4.4.3"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
+
"@tanstack/intent": "^0.3.6",
|
|
26
27
|
"@tanstack/react-router": "^1.168.0",
|
|
27
28
|
"@tanstack/react-start": "^1.168.0",
|
|
28
29
|
"@types/babel__traverse": "^7.28.0",
|
|
@@ -32,10 +33,10 @@
|
|
|
32
33
|
"vite": "^8.0.0"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
|
-
"react": "^18.0.0 || ^19.0.0",
|
|
36
|
-
"vite": "^8.0.0",
|
|
37
36
|
"@tanstack/react-router": "^1.168.0",
|
|
38
|
-
"@tanstack/react-start": "^1.168.0"
|
|
37
|
+
"@tanstack/react-start": "^1.168.0",
|
|
38
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
39
|
+
"vite": "^8.0.0"
|
|
39
40
|
},
|
|
40
41
|
"peerDependenciesMeta": {
|
|
41
42
|
"react": {
|
|
@@ -44,5 +45,11 @@
|
|
|
44
45
|
"vite": {
|
|
45
46
|
"optional": true
|
|
46
47
|
}
|
|
48
|
+
},
|
|
49
|
+
"keywords": ["tanstack-intent"],
|
|
50
|
+
"intent": {
|
|
51
|
+
"version": 1,
|
|
52
|
+
"repo": "@softr/sdk",
|
|
53
|
+
"docs": "https://www.npmjs.com/package/@softr/sdk"
|
|
47
54
|
}
|
|
48
55
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: softr-sdk
|
|
3
|
+
description: >-
|
|
4
|
+
Entry point for @softr/sdk, the layer a Softr app uses to reach Softr: the
|
|
5
|
+
four entrypoints (@softr/sdk, /server, /client, /vite) and the client-server
|
|
6
|
+
boundary between them, softrAuthMiddleware, getSoftrContext,
|
|
7
|
+
initialSoftrContext, SoftrContext, and which sub-skill to load for route
|
|
8
|
+
visibility, the current user, reading records, or auth. Load this before any
|
|
9
|
+
other Softr SDK skill.
|
|
10
|
+
metadata:
|
|
11
|
+
type: core
|
|
12
|
+
library: softr-sdk
|
|
13
|
+
library_version: 0.0.16
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Softr SDK core
|
|
17
|
+
|
|
18
|
+
`@softr/sdk` is how a Softr application talks to Softr. The visitor's identity, who may see a route, records in a datasource, signing in: all of it goes through this package. Everything else is an ordinary TanStack Start app. Routes, server functions, loaders and middleware are Start's, not Softr's.
|
|
19
|
+
|
|
20
|
+
> **CRITICAL**: The SDK is split across four entrypoints and the split is enforced. `ds` and `auth` exist only on `@softr/sdk/server` and only run inside a `createServerFn` handler. Importing them anywhere else fails the build.
|
|
21
|
+
> **CRITICAL**: Every route file must declare its audience with `defineVisibility`. There is no default and nothing is inherited from a sibling. A route file without a declaration is a lint error.
|
|
22
|
+
> **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register the middleware and resolve the session. Do not add a second registration.
|
|
23
|
+
|
|
24
|
+
Load this skill, then load **one** sub-skill for the task at hand. Add a second only when the work genuinely crosses that boundary. A page that lists records behind a group restriction needs `reading-records` plus `routes-and-visibility`.
|
|
25
|
+
|
|
26
|
+
## Entrypoints
|
|
27
|
+
|
|
28
|
+
| Import | Runs | Holds |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| `@softr/sdk` | Both | `defineVisibility`, `q`, `useCurrentUser`, `usePermissions`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
|
|
31
|
+
| `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration` |
|
|
32
|
+
| `@softr/sdk/client` | Browser only | Nothing yet, reserved for browser-only utilities |
|
|
33
|
+
| `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
|
|
34
|
+
|
|
35
|
+
## Sub-Skills
|
|
36
|
+
|
|
37
|
+
| Task | Sub-Skill |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| Add a route, or change who can see one | [routes-and-visibility/SKILL.md](./routes-and-visibility/SKILL.md) |
|
|
40
|
+
| Read the signed-in visitor or what they may do | [current-user/SKILL.md](./current-user/SKILL.md) |
|
|
41
|
+
| Fetch records from a datasource | [reading-records/SKILL.md](./reading-records/SKILL.md) |
|
|
42
|
+
| Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
|
|
43
|
+
|
|
44
|
+
## Quick Decision Tree
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
Adding a page, or changing who may see one?
|
|
48
|
+
load softr-sdk/routes-and-visibility
|
|
49
|
+
|
|
50
|
+
Showing the visitor's name, or hiding UI they cannot use?
|
|
51
|
+
load softr-sdk/current-user
|
|
52
|
+
|
|
53
|
+
Putting records on a page, in a table, a list or a detail view?
|
|
54
|
+
load softr-sdk/reading-records
|
|
55
|
+
|
|
56
|
+
Wiring a login, signup, or sign-out?
|
|
57
|
+
load softr-sdk/auth
|
|
58
|
+
|
|
59
|
+
Writing a server function, loader, or middleware in general?
|
|
60
|
+
not a Softr concern. Use the TanStack Start skills.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## The wiring
|
|
64
|
+
|
|
65
|
+
Already present in the app. Read it to understand where the visitor comes from. Do not recreate it.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// src/start.ts: resolves the visitor once per request
|
|
69
|
+
import { createCsrfMiddleware, createStart } from '@tanstack/react-start'
|
|
70
|
+
import { softrAuthMiddleware } from '@softr/sdk'
|
|
71
|
+
|
|
72
|
+
export const startInstance = createStart(() => ({
|
|
73
|
+
requestMiddleware: [
|
|
74
|
+
createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn' }),
|
|
75
|
+
softrAuthMiddleware(),
|
|
76
|
+
],
|
|
77
|
+
}))
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
// src/routes/__root.tsx: publishes the visitor as route context
|
|
82
|
+
import { createRootRouteWithContext } from '@tanstack/react-router'
|
|
83
|
+
import { getSoftrContext } from '@softr/sdk'
|
|
84
|
+
import type { SoftrContext } from '@softr/sdk'
|
|
85
|
+
|
|
86
|
+
export const Route = createRootRouteWithContext<SoftrContext>()({
|
|
87
|
+
beforeLoad: async () => await getSoftrContext(),
|
|
88
|
+
shellComponent: RootDocument,
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
// src/router.tsx: the context before any request has resolved one
|
|
94
|
+
import { createRouter } from '@tanstack/react-router'
|
|
95
|
+
import { initialSoftrContext } from '@softr/sdk'
|
|
96
|
+
import { routeTree } from './routeTree.gen'
|
|
97
|
+
|
|
98
|
+
export function getRouter() {
|
|
99
|
+
return createRouter({ routeTree, context: initialSoftrContext() })
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`SoftrContext` is what all three produce, and what every guard and every server function reads:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
type SoftrContext = {
|
|
107
|
+
user: User | null // null means signed out
|
|
108
|
+
permissions: { datasource: Record<string, DatasourcePermissions> }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type User = {
|
|
112
|
+
id: string
|
|
113
|
+
email: string
|
|
114
|
+
name: string
|
|
115
|
+
userGroupIds: Array<SoftrUserGroupId> // plain string unless you declare your ids
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Version
|
|
120
|
+
|
|
121
|
+
Targets `@softr/sdk` v0.0.15.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: auth
|
|
3
|
+
description: >-
|
|
4
|
+
Sign a Softr visitor in, out, or up from a server function: auth.signIn,
|
|
5
|
+
auth.signOut, auth.signUp and auth.confirmSignUp from @softr/sdk/server, the
|
|
6
|
+
Result shape they answer with ({ success, data } | { success, error }) and its
|
|
7
|
+
codes invalidCredentials, tooManyAttempts, emailTaken, invalidDetails and
|
|
8
|
+
invalidCode, the two-step sign-up, and redirecting after a successful sign-in.
|
|
9
|
+
The blank template already ships working login and signup pages.
|
|
10
|
+
metadata:
|
|
11
|
+
type: sub-skill
|
|
12
|
+
library: softr-sdk
|
|
13
|
+
library_version: 0.0.16
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
This skill builds on softr-sdk. Read it first for the entrypoints and the client/server boundary.
|
|
17
|
+
|
|
18
|
+
# Auth
|
|
19
|
+
|
|
20
|
+
`auth` is what changes who the visitor is. It exposes four calls and nothing else:
|
|
21
|
+
|
|
22
|
+
| Call | Does |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| `auth.signIn({ email, password })` | Signs a visitor in |
|
|
25
|
+
| `auth.signOut()` | Signs the visitor out |
|
|
26
|
+
| `auth.signUp({ email, name? })` | Emails a verification code, creates no account |
|
|
27
|
+
| `auth.confirmSignUp({ email, password, code, name? })` | Creates the account and signs in |
|
|
28
|
+
|
|
29
|
+
**The blank template already ships a working login page and signup page.** Read `src/routes/_auth/login.tsx` before writing anything new. Most tasks are an edit to those, not a new flow.
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
`auth` is server-only, so every call sits inside a `createServerFn` handler:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
37
|
+
import { redirect } from '@tanstack/react-router'
|
|
38
|
+
import { auth } from '@softr/sdk/server'
|
|
39
|
+
import { z } from 'zod'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Pass a zod schema straight to `.validator`. Start accepts it directly and types `data` from it:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
const Credentials = z.object({ email: z.string(), password: z.string() })
|
|
46
|
+
|
|
47
|
+
const signIn = createServerFn({ method: 'POST' })
|
|
48
|
+
.validator(Credentials)
|
|
49
|
+
.handler(async ({ data }) => { /* data is { email, password } */ })
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Keep the schema to the shape the call needs. Softr is what decides whether an email exists, whether a password is right, and whether a code is still valid, and it answers with the codes below. A second set of rules in the app is just a second place to be wrong.
|
|
53
|
+
|
|
54
|
+
A form that posts `FormData` instead validates with a function, since a zod object schema does not accept `FormData`:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
.validator((fields: FormData) => Credentials.parse(Object.fromEntries(fields)))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`signIn`, `signUp` and `confirmSignUp` answer with a `Result` rather than throwing on something the visitor can fix. `signOut` returns nothing.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
type Result<T> =
|
|
64
|
+
| { success: true; data: T }
|
|
65
|
+
| { success: false; error: { code: AuthErrorCode; message: string } }
|
|
66
|
+
|
|
67
|
+
type AuthErrorCode =
|
|
68
|
+
| 'invalidCredentials' // signIn: wrong email or password
|
|
69
|
+
| 'tooManyAttempts' // signIn: rate limited
|
|
70
|
+
| 'emailTaken' // signUp or confirmSignUp: that email already has an account
|
|
71
|
+
| 'invalidDetails' // signUp: details Softr rejected
|
|
72
|
+
| 'invalidCode' // confirmSignUp: wrong or expired verification code
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Each code comes from the call named beside it. Email and password is the sign-in method these calls cover.
|
|
76
|
+
|
|
77
|
+
## Core Patterns
|
|
78
|
+
|
|
79
|
+
### Sign in
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const signIn = createServerFn({ method: 'POST' })
|
|
83
|
+
.validator(Credentials)
|
|
84
|
+
.handler(async ({ data }) => {
|
|
85
|
+
const result = await auth.signIn(data)
|
|
86
|
+
|
|
87
|
+
if (!result.success) {
|
|
88
|
+
return {
|
|
89
|
+
message:
|
|
90
|
+
result.error.code === 'tooManyAttempts'
|
|
91
|
+
? 'Too many attempts. Please try again in a few minutes.'
|
|
92
|
+
: 'Please ensure your email and password are correct.',
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
throw redirect({ to: '/' })
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Two exits: return a message for the form to render, or `throw redirect` on success. Do not put `error.message` in front of the visitor. Write your own wording, as above.
|
|
101
|
+
|
|
102
|
+
Call it from the component with `useServerFn`:
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
import { useServerFn } from '@tanstack/react-start'
|
|
106
|
+
|
|
107
|
+
const submit = useServerFn(signIn)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A server function that throws `redirect` needs the hook. Call the imported function directly and the throw never reaches the router, so a successful sign-in leaves the visitor on the form.
|
|
111
|
+
|
|
112
|
+
A login page is a `NON_LOGGED_IN_USERS` route, which is what keeps an already-signed-in visitor off it. See [routes-and-visibility](../routes-and-visibility/SKILL.md).
|
|
113
|
+
|
|
114
|
+
### Sign out
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const signOut = createServerFn({ method: 'POST' }).handler(async () => {
|
|
118
|
+
await auth.signOut()
|
|
119
|
+
throw redirect({ to: '/login' })
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
It returns nothing and has no failure the visitor can act on, so send them somewhere and let the redirect re-resolve the context.
|
|
124
|
+
|
|
125
|
+
### Sign up, in two steps
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const Details = z.object({ email: z.string(), name: z.string() })
|
|
129
|
+
const Confirmation = Details.extend({ password: z.string(), code: z.string() })
|
|
130
|
+
|
|
131
|
+
// Step 1: emails a code. No account exists yet.
|
|
132
|
+
const startSignUp = createServerFn({ method: 'POST' })
|
|
133
|
+
.validator(Details)
|
|
134
|
+
.handler(async ({ data }) => {
|
|
135
|
+
const result = await auth.signUp(data)
|
|
136
|
+
if (!result.success) {
|
|
137
|
+
return {
|
|
138
|
+
message:
|
|
139
|
+
result.error.code === 'emailTaken'
|
|
140
|
+
? 'That email already has an account.'
|
|
141
|
+
: 'Please check the details and try again.',
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { message: null } // now show the code field
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// Step 2: creates the account and signs the visitor in.
|
|
148
|
+
const finishSignUp = createServerFn({ method: 'POST' })
|
|
149
|
+
.validator(Confirmation)
|
|
150
|
+
.handler(async ({ data }) => {
|
|
151
|
+
const result = await auth.confirmSignUp(data)
|
|
152
|
+
if (!result.success) {
|
|
153
|
+
return {
|
|
154
|
+
message:
|
|
155
|
+
result.error.code === 'emailTaken'
|
|
156
|
+
? 'That email already has an account. Please sign in instead.'
|
|
157
|
+
: 'That code is not valid. Please try again.',
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
throw redirect({ to: '/' })
|
|
162
|
+
})
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The UI has to carry email and password into step two, plus the name if step one collected one. `name` is optional to `confirmSignUp`, the rest is not. It is the only call that creates the account.
|
|
166
|
+
|
|
167
|
+
## References
|
|
168
|
+
|
|
169
|
+
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor these calls establish
|
|
170
|
+
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `NON_LOGGED_IN_USERS` and the `alreadySignedIn` redirect that keeps a signed-in visitor off a login page
|
|
171
|
+
- `src/routes/_auth/login.tsx` and `signup.tsx` in your own app: the implementations it was scaffolded with
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: current-user
|
|
3
|
+
description: >-
|
|
4
|
+
Read the signed-in Softr visitor and what they may do: useCurrentUser and the
|
|
5
|
+
User type, usePermissions and DatasourcePermissions, context.user in a route's
|
|
6
|
+
beforeLoad, and context.user inside a server function handler, where Start
|
|
7
|
+
types it from the app's registered request middleware. Covers the null guest,
|
|
8
|
+
SoftrUserGroups module augmentation for narrowing group ids, and why
|
|
9
|
+
permissions are a rendering hint rather than an authorization check.
|
|
10
|
+
metadata:
|
|
11
|
+
type: sub-skill
|
|
12
|
+
library: softr-sdk
|
|
13
|
+
library_version: 0.0.16
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
This skill builds on softr-sdk. Read it first for the entrypoints and the wiring that puts a visitor on route context.
|
|
17
|
+
|
|
18
|
+
# The current user
|
|
19
|
+
|
|
20
|
+
One visitor is resolved once per request and published three ways: as a hook in components, as route context in `beforeLoad`, and on the `context` argument inside a server function. All three read the same `SoftrContext`. Which one you reach for depends only on where the code runs.
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
Nothing to install or register, since the app is already wired. Import from the root entrypoint:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { useCurrentUser, usePermissions } from '@softr/sdk'
|
|
28
|
+
import type { User } from '@softr/sdk'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
type User = {
|
|
33
|
+
id: string
|
|
34
|
+
email: string
|
|
35
|
+
name: string
|
|
36
|
+
userGroupIds: Array<SoftrUserGroupId> // plain string unless you declare your ids
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Core Patterns
|
|
41
|
+
|
|
42
|
+
### In a component: `useCurrentUser()`
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { useCurrentUser } from '@softr/sdk'
|
|
46
|
+
|
|
47
|
+
function Header() {
|
|
48
|
+
const user = useCurrentUser()
|
|
49
|
+
return <p>{user ? `Hello, ${user.name}` : 'Please sign in'}</p>
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It returns `User | null` and never suspends or fetches. It selects off root route context, so it is available anywhere below the root, on the server render and in the browser alike.
|
|
54
|
+
|
|
55
|
+
### In a route: `context.user`
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
export const Route = createFileRoute('/_app/invoices')({
|
|
59
|
+
beforeLoad: visibility.guard(),
|
|
60
|
+
loader: ({ context }) => fetchInvoicesFor({ data: { userId: context.user!.id } }),
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A `guard()` in `beforeLoad` guarantees a visitor at runtime, but it does not change what the compiler sees. `context.user` is still `User | null` further down the route. `visibility.isAuthorized(context)` is the check that narrows it, so prefer that over `!`. See [routes-and-visibility](../routes-and-visibility/SKILL.md).
|
|
65
|
+
|
|
66
|
+
### In a server function: `context`
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
70
|
+
|
|
71
|
+
const getMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
72
|
+
if (!context.user) throw new Error('Not signed in')
|
|
73
|
+
|
|
74
|
+
return await ds.list('ds_invoices', {
|
|
75
|
+
select: q.select({ owner: 'fld_owner', amount: 'fld_amount' }),
|
|
76
|
+
where: q.text('owner').is(context.user.id),
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`context.user` and `context.permissions` are 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.
|
|
82
|
+
|
|
83
|
+
### Hiding UI the visitor cannot use
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { usePermissions } from '@softr/sdk'
|
|
87
|
+
|
|
88
|
+
function NewInvoiceButton() {
|
|
89
|
+
const { create } = usePermissions('ds_invoices')
|
|
90
|
+
return create ? <button>New invoice</button> : null
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`usePermissions(datasourceId)` returns `{ read, create, update, delete }` for one datasource. It hides a control. It does not protect what the control does.
|
|
95
|
+
|
|
96
|
+
Pass an id the visitor's session has no entry for and it throws while rendering, so a typo takes the page down rather than returning four falses.
|
|
97
|
+
|
|
98
|
+
### Narrowing group ids to your own
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// src/softr.d.ts
|
|
102
|
+
declare module '@softr/sdk' {
|
|
103
|
+
interface SoftrUserGroups {
|
|
104
|
+
ids:
|
|
105
|
+
| 'b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d'
|
|
106
|
+
| 'f9e8d7c6-5b4a-4938-8271-605f4e3d2c1b'
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export {}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
With this declaration, `user.userGroupIds` and every `userGroupIds` the SDK accepts narrow to those ids, and a typo stops compiling. Without it they are plain `string`.
|
|
113
|
+
|
|
114
|
+
## References
|
|
115
|
+
|
|
116
|
+
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `isAuthorized()` narrows `context.user` while applying the route's rule
|
|
117
|
+
- [reading-records/SKILL.md](../reading-records/SKILL.md): filtering a query by the visitor, and who really authorizes the read
|
|
118
|
+
- [auth/SKILL.md](../auth/SKILL.md): what changes the value `useCurrentUser` returns
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reading-records
|
|
3
|
+
description: >-
|
|
4
|
+
Fetch records from a Softr datasource inside a server function: ds.list and
|
|
5
|
+
ds.get from @softr/sdk/server, the q query builder (q.text, q.number,
|
|
6
|
+
q.boolean, q.date, q.array, q.and, q.or, q.asc, q.desc), q.select field
|
|
7
|
+
aliasing, filtering, sorting, the 20-record default and 100-record cap, and
|
|
8
|
+
nextOffset pagination. Reads only. Mutations, workflows, integrations, metrics
|
|
9
|
+
and chart data are not covered.
|
|
10
|
+
metadata:
|
|
11
|
+
type: sub-skill
|
|
12
|
+
library: softr-sdk
|
|
13
|
+
library_version: 0.0.16
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
This skill builds on softr-sdk. Read it first for the entrypoints and the client/server boundary.
|
|
17
|
+
|
|
18
|
+
# Reading records
|
|
19
|
+
|
|
20
|
+
`ds` reads records out of a Softr datasource. It lives on `@softr/sdk/server` and runs on the server only, so every read happens inside a `createServerFn` handler or a server-side route hook, never in a component.
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
26
|
+
import { ds } from '@softr/sdk/server'
|
|
27
|
+
import { q } from '@softr/sdk'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`ds` is server-only and lives on `@softr/sdk/server`. `q` builds queries and nothing more, so it ships on the root entrypoint and can be imported anywhere.
|
|
31
|
+
|
|
32
|
+
A record comes back as `{ id: string; fields: Record<string, unknown> }`.
|
|
33
|
+
|
|
34
|
+
## Core Patterns
|
|
35
|
+
|
|
36
|
+
### List records
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const listPublishedPosts = createServerFn().handler(async () => {
|
|
40
|
+
return await ds.list('ds_posts', {
|
|
41
|
+
select: q.select({
|
|
42
|
+
title: 'fld_title',
|
|
43
|
+
author: 'fld_author',
|
|
44
|
+
published: 'fld_published',
|
|
45
|
+
publishedAt: 'fld_published_at',
|
|
46
|
+
}),
|
|
47
|
+
where: q.boolean('published').is(true),
|
|
48
|
+
orderBy: q.desc('publishedAt'),
|
|
49
|
+
limit: 50,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Returns `{ records, total, nextOffset }`. `ds.list('ds_posts')` on its own returns the first 20 records with their raw field ids. `limit` defaults to 20 and is clamped to 100. A larger number is silently reduced, so more than a hundred records means paginating.
|
|
55
|
+
|
|
56
|
+
**Filtering or sorting requires a `select`, and it has to cover every field you name.** `where` and `orderBy` speak aliases, so without a map to translate through they throw at request time. That is why `published` is in the map above even though the table never displays it.
|
|
57
|
+
|
|
58
|
+
### Name the fields with `select`
|
|
59
|
+
|
|
60
|
+
Softr field ids are opaque (`fld_a1b2c3`). `q.select` maps your own names onto them, and those names are what the rest of the call speaks:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
const select = q.select({ title: 'fld_a1b2c3', views: 'fld_d4e5f6' })
|
|
64
|
+
|
|
65
|
+
await ds.list('ds_posts', {
|
|
66
|
+
select,
|
|
67
|
+
where: q.number('views').gt(100), // alias, not fld_d4e5f6
|
|
68
|
+
orderBy: q.asc('title'), // alias here too
|
|
69
|
+
})
|
|
70
|
+
// records[0].fields is { title: ..., views: ... }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Once `select` is present it is the whole vocabulary. The returned record carries exactly those keys, and any field you filter or sort on must appear in the map.
|
|
74
|
+
|
|
75
|
+
### Get one record
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const post = await ds.get('ds_posts', { id: postId })
|
|
79
|
+
if (post === null) throw notFound()
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
A record that does not exist comes back as `null`, not an error.
|
|
83
|
+
|
|
84
|
+
The type also accepts `{ where }` instead of `{ id }`, but there is no way to pass a `select` alongside it, so the filter cannot be translated and the call throws. Fetch one record by id, or use `ds.list` with a `limit` of 1 when you need to find it by a field.
|
|
85
|
+
|
|
86
|
+
### Filter with `q`
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
where: q.and(
|
|
90
|
+
q.text('status').is('active'),
|
|
91
|
+
q.number('amount').gte(100),
|
|
92
|
+
q.or(
|
|
93
|
+
q.text('owner').is(user.id),
|
|
94
|
+
q.array('collaborators').hasAllOf([user.id]),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Five field types, `q.text`, `q.number`, `q.boolean`, `q.date` and `q.array`, combined with `q.and` and `q.or`. The full operator matrix is in [references/query-builder.md](./references/query-builder.md).
|
|
100
|
+
|
|
101
|
+
### Paginate
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const first = await ds.list('ds_posts', { limit: 50 })
|
|
105
|
+
if (first.nextOffset !== null) {
|
|
106
|
+
const second = await ds.list('ds_posts', { limit: 50, offset: first.nextOffset })
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`nextOffset` is `null` when there are no more pages. `total` is the count of all matching records, not the count returned.
|
|
111
|
+
|
|
112
|
+
### Scope the read to the visitor
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const listMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
116
|
+
if (!context.user) throw new Error('Not signed in')
|
|
117
|
+
|
|
118
|
+
return await ds.list('ds_invoices', {
|
|
119
|
+
select: q.select({ owner: 'fld_owner', amount: 'fld_amount' }),
|
|
120
|
+
where: q.text('owner').is(context.user.id),
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
|
|
126
|
+
|
|
127
|
+
A server function is its own endpoint, reachable whether or not the visitor ever matched the route that displays the data. A route's visibility rule decides who sees a page. The server function decides for itself who gets the records.
|
|
128
|
+
|
|
129
|
+
A read the visitor is not allowed to make throws rather than coming back empty, and it throws a plain `Error` carrying the status. 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.
|
|
130
|
+
|
|
131
|
+
## References
|
|
132
|
+
|
|
133
|
+
- [references/query-builder.md](./references/query-builder.md): every `q` field type and operator
|
|
134
|
+
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context` to scope a query
|
|
135
|
+
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): deciding who sees the page the records land on
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# `q`, the query builder
|
|
2
|
+
|
|
3
|
+
Every filter, sort and field map passed to `ds` is built with `q`, imported from `@softr/sdk`. Filters and sorts built any other way are rejected at runtime.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { q } from '@softr/sdk'
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Shape
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
await ds.list(datasourceId, {
|
|
13
|
+
select: q.select({ alias: 'fld_id', ... }), // optional field map
|
|
14
|
+
where: <one filter, or q.and/q.or of filters>,
|
|
15
|
+
orderBy: q.asc('alias') | [q.desc('a'), q.asc('b')],
|
|
16
|
+
limit: number, // default 20, clamped down to 100
|
|
17
|
+
offset: number | null,
|
|
18
|
+
})
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`where` takes exactly one value. For more than one condition, wrap them in `q.and(...)` or `q.or(...)`.
|
|
22
|
+
|
|
23
|
+
## Field types and operators
|
|
24
|
+
|
|
25
|
+
Pick the builder that matches the field's type. The type travels with the operator and decides how the value is compared.
|
|
26
|
+
|
|
27
|
+
### `q.text(field)`
|
|
28
|
+
|
|
29
|
+
| Call | Meaning |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `.is(value: string)` | Exactly equal |
|
|
32
|
+
| `.isNot(value: string)` | Not equal |
|
|
33
|
+
| `.contains(value: string)` | Substring match |
|
|
34
|
+
| `.startsWith(value: string)` | Prefix match |
|
|
35
|
+
| `.endsWith(value: string)` | Suffix match |
|
|
36
|
+
| `.isOneOf(values: string[])` | Equal to any of |
|
|
37
|
+
| `.isNoneOf(values: string[])` | Equal to none of |
|
|
38
|
+
| `.hasAllOf(values: string[])` | Contains all of |
|
|
39
|
+
| `.isEmpty()` | No value |
|
|
40
|
+
| `.isNotEmpty()` | Has a value |
|
|
41
|
+
|
|
42
|
+
### `q.number(field)`
|
|
43
|
+
|
|
44
|
+
| Call | Meaning |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `.is(value: number)` | Equal |
|
|
47
|
+
| `.isNot(value: number)` | Not equal |
|
|
48
|
+
| `.gt(value: number)` | Greater than |
|
|
49
|
+
| `.gte(value: number)` | Greater than or equal |
|
|
50
|
+
| `.lt(value: number)` | Less than |
|
|
51
|
+
| `.lte(value: number)` | Less than or equal |
|
|
52
|
+
| `.between(start: number, end: number)` | Inclusive range |
|
|
53
|
+
| `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
|
|
54
|
+
|
|
55
|
+
Takes JavaScript numbers.
|
|
56
|
+
|
|
57
|
+
### `q.boolean(field)`
|
|
58
|
+
|
|
59
|
+
| Call | Meaning |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `.is(value: boolean)` | Equal |
|
|
62
|
+
| `.isNot(value: boolean)` | Not equal |
|
|
63
|
+
| `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
|
|
64
|
+
|
|
65
|
+
### `q.date(field)`
|
|
66
|
+
|
|
67
|
+
| Call | Meaning |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `.is(value: string)` | On that date |
|
|
70
|
+
| `.isNot(value: string)` | Not on that date |
|
|
71
|
+
| `.gt(value: string)` | After |
|
|
72
|
+
| `.gte(value: string)` | On or after |
|
|
73
|
+
| `.lt(value: string)` | Before |
|
|
74
|
+
| `.lte(value: string)` | On or before |
|
|
75
|
+
| `.between(start: string, end: string)` | Inclusive range |
|
|
76
|
+
| `.isNotBetween(start: string, end: string)` | Outside the range |
|
|
77
|
+
| `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
|
|
78
|
+
|
|
79
|
+
Every value is an ISO date string, either `'2026-01-31'` or a full timestamp. A `Date` instance is not accepted. Call `.toISOString()` first.
|
|
80
|
+
|
|
81
|
+
### `q.array(field)`
|
|
82
|
+
|
|
83
|
+
For multi-value fields: linked records, multi-selects, collaborators.
|
|
84
|
+
|
|
85
|
+
| Call | Meaning |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `.is(values: string[])` | Exactly this set |
|
|
88
|
+
| `.isOneOf(values: string[])` | Contains at least one of |
|
|
89
|
+
| `.isNoneOf(values: string[])` | Contains none of |
|
|
90
|
+
| `.hasAllOf(values: string[])` | Contains all of |
|
|
91
|
+
| `.isEmpty()` / `.isNotEmpty()` | Empty / non-empty |
|
|
92
|
+
|
|
93
|
+
## Combining
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
q.and(...filters) // all must match
|
|
97
|
+
q.or(...filters) // any may match
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Both take filters or nested groups:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
where: q.and(
|
|
104
|
+
q.text('status').is('active'),
|
|
105
|
+
q.or(
|
|
106
|
+
q.number('amount').gte(1000),
|
|
107
|
+
q.array('tags').hasAllOf(['priority']),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Nesting goes two levels, a group inside a group. A third level throws `Cannot nest filter groups more than three levels deep.` Flatten by distributing the outer condition rather than nesting further.
|
|
113
|
+
|
|
114
|
+
## Sorting
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
orderBy: q.asc('title')
|
|
118
|
+
orderBy: [q.desc('publishedAt'), q.asc('title')]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Sorts are applied in array order.
|
|
122
|
+
|
|
123
|
+
## Field maps
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const select = q.select({ title: 'fld_a1b2c3', views: 'fld_d4e5f6' })
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`q.select` maps names you choose onto Softr field ids. Two rules follow from that:
|
|
130
|
+
|
|
131
|
+
1. The returned record carries exactly the keys in the map. A field left out is not returned, and no error is raised.
|
|
132
|
+
2. `where` and `orderBy` name aliases, and every one of them must be in the map. A name that is not throws `Could not find an alias for subject "..."` at request time, and so does a `where` or `orderBy` passed with no `select` at all.
|
|
133
|
+
|
|
134
|
+
So `select` must list every field the query touches, not just the ones being displayed.
|
|
135
|
+
|
|
136
|
+
## Errors
|
|
137
|
+
|
|
138
|
+
| Message | Cause |
|
|
139
|
+
| --- | --- |
|
|
140
|
+
| `Filters cannot be constructed manually. Use the 'q' helper.` | A `where` value built as a plain object |
|
|
141
|
+
| `orderBy conditions cannot be constructed manually. Use the 'q' helper.` | An `orderBy` value not from `q.asc` / `q.desc` |
|
|
142
|
+
| `Cannot nest filter groups more than three levels deep.` | A group nested one level too far |
|
|
143
|
+
| `Could not find an alias for subject "..."` | A sort field missing from `select`, or an `orderBy` with no `select` at all |
|
|
144
|
+
| `Could not find an alias for subject "undefined"` | The same thing on a filter, which does not report the field name |
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: routes-and-visibility
|
|
3
|
+
description: >-
|
|
4
|
+
Add a route file to a Softr app and declare who may see it: defineVisibility
|
|
5
|
+
with ALL_USERS, LOGGED_IN_USERS or NON_LOGGED_IN_USERS, userGroupIds, and the
|
|
6
|
+
four ways to enforce a rule (guard, assert, isAuthorized, check). Covers
|
|
7
|
+
per-reason overrides, the redirect and throw strategies, rendering an access
|
|
8
|
+
error with isAccessError, isUnauthenticatedError, isForbiddenError and
|
|
9
|
+
isAlreadySignedInError, guarding a layout, and the lint rule that requires a
|
|
10
|
+
declaration alongside the build rules that reject a duplicate, computed or
|
|
11
|
+
root-level one.
|
|
12
|
+
metadata:
|
|
13
|
+
type: sub-skill
|
|
14
|
+
library: softr-sdk
|
|
15
|
+
library_version: 0.0.16
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
This skill builds on softr-sdk. Read it first for the entrypoints and the wiring that puts a visitor on route context.
|
|
19
|
+
|
|
20
|
+
# Route visibility
|
|
21
|
+
|
|
22
|
+
Every route file in a Softr app declares who may see it. The build reads that declaration out of the source text without executing it and publishes it to Softr Studio, which shows the app owner every route and its audience. That is why the rules below are strict: a rule the build cannot read is a rule Studio cannot show.
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
Every route file, without exception, looks like this:
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
30
|
+
import { defineVisibility } from '@softr/sdk'
|
|
31
|
+
|
|
32
|
+
const visibility = defineVisibility({ userGroup: 'LOGGED_IN_USERS' })
|
|
33
|
+
|
|
34
|
+
export const Route = createFileRoute('/_app/billing')({
|
|
35
|
+
beforeLoad: visibility.guard(),
|
|
36
|
+
component: Billing,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
function Billing() {
|
|
40
|
+
return <h1>Billing</h1>
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
One `defineVisibility` call per file, at module scope, with an inline object literal as its argument.
|
|
45
|
+
|
|
46
|
+
Three rules about that shape, because the build reads the file rather than running it:
|
|
47
|
+
|
|
48
|
+
- The call must initialise a top-level `const`.
|
|
49
|
+
- The import must be a direct named import from `@softr/sdk`.
|
|
50
|
+
- Never declare on `__root.tsx`. The root cannot carry an audience, so the build rejects a declaration there rather than shipping an app that only looks gated. Put the rule on a layout below the root instead.
|
|
51
|
+
|
|
52
|
+
The four rules:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
defineVisibility({ userGroup: 'ALL_USERS' }) // everyone, signed in or not
|
|
56
|
+
defineVisibility({ userGroup: 'NON_LOGGED_IN_USERS' }) // signed-out only: login, signup
|
|
57
|
+
defineVisibility({ userGroup: 'LOGGED_IN_USERS' }) // any signed-in visitor
|
|
58
|
+
defineVisibility({ // those groups only
|
|
59
|
+
userGroup: 'LOGGED_IN_USERS',
|
|
60
|
+
userGroupIds: ['b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d'],
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Absent or empty `userGroupIds` means **any** signed-in visitor, not "nobody".
|
|
65
|
+
|
|
66
|
+
## Core Patterns
|
|
67
|
+
|
|
68
|
+
### Guard a whole section with one layout route
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
// src/routes/_app.tsx: guards every route under /_app
|
|
72
|
+
import { Outlet, createFileRoute } from '@tanstack/react-router'
|
|
73
|
+
import { defineVisibility } from '@softr/sdk'
|
|
74
|
+
|
|
75
|
+
const visibility = defineVisibility({ userGroup: 'LOGGED_IN_USERS' })
|
|
76
|
+
|
|
77
|
+
export const Route = createFileRoute('/_app')({
|
|
78
|
+
beforeLoad: visibility.guard(),
|
|
79
|
+
component: () => <Outlet />,
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The whole match chain runs, so a layout guards everything beneath it. Sibling routes do not guard each other. `/admin/index.tsx` does nothing for `/admin/users.tsx` unless an `admin.tsx` layout exists. Routes below a guarded layout still declare their own rule, because the declaration is what Studio reads.
|
|
84
|
+
|
|
85
|
+
A thrown error renders in place of the guarded route's component. Guard the layout that draws your nav and chrome, and the error screen replaces the chrome along with it. Put the guard on a layout inside the shell rather than on the shell itself.
|
|
86
|
+
|
|
87
|
+
### Change what a denied visitor gets
|
|
88
|
+
|
|
89
|
+
`guard()` takes per-reason overrides. A bare string is shorthand for a redirect.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
beforeLoad: visibility.guard({ unauthenticated: '/marketing/sign-up' }),
|
|
93
|
+
beforeLoad: visibility.guard({ forbidden: { strategy: 'throw' } }),
|
|
94
|
+
beforeLoad: visibility.guard({
|
|
95
|
+
unauthenticated: { strategy: 'redirect', to: '/marketing/sign-up' },
|
|
96
|
+
forbidden: { strategy: 'throw' },
|
|
97
|
+
}),
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The long redirect form and the bare string do the same thing.
|
|
101
|
+
|
|
102
|
+
The defaults, when no override is given:
|
|
103
|
+
|
|
104
|
+
| Reason | Condition | Default |
|
|
105
|
+
| --- | --- | --- |
|
|
106
|
+
| `unauthenticated` | Route needs a visitor, none present | Redirect to `/login` |
|
|
107
|
+
| `forbidden` | Signed in, in none of the named groups | Throw, renders in place |
|
|
108
|
+
| `alreadySignedIn` | Signed-in visitor on a signed-out-only route | Redirect to `/` |
|
|
109
|
+
|
|
110
|
+
`ALL_USERS` admits everyone, so it never redirects and never throws. Only `strategy: 'throw'` reaches an `errorComponent`. A redirect navigates away and the route never renders.
|
|
111
|
+
|
|
112
|
+
### Render the error in place
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
116
|
+
import { defineVisibility, isAccessError, isForbiddenError } from '@softr/sdk'
|
|
117
|
+
|
|
118
|
+
const visibility = defineVisibility({
|
|
119
|
+
userGroup: 'LOGGED_IN_USERS',
|
|
120
|
+
userGroupIds: ['b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d'],
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
export const Route = createFileRoute('/_app/billing')({
|
|
124
|
+
beforeLoad: visibility.guard(),
|
|
125
|
+
component: Billing,
|
|
126
|
+
errorComponent: ({ error }: { error: unknown }) => {
|
|
127
|
+
if (isForbiddenError(error)) return <UpgradePrompt />
|
|
128
|
+
if (isAccessError(error)) return <NoAccess />
|
|
129
|
+
return <SomethingWentWrong />
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The error carries a `type` discriminant and its `reason`, nothing else. It never names the groups the route wanted, which would hand that list to the one visitor who was just turned away.
|
|
135
|
+
|
|
136
|
+
Use the predicates: `isAccessError` for any of the three, and `isUnauthenticatedError`, `isForbiddenError`, `isAlreadySignedInError` for one each. The error is plain data rather than an `Error` subclass, so `instanceof` never matches and there is no `message` to read. That is also why the parameter is annotated `error: unknown`. The router types it as `Error`, and the wider annotation is what lets the predicates narrow it. Leave it as it is.
|
|
137
|
+
|
|
138
|
+
### Run the check and then keep going
|
|
139
|
+
|
|
140
|
+
`guard()` covers almost everything. When a route needs its own logic afterwards, `assert()` applies the same rule and throws the same access error, using the default behaviour for each reason. It takes no overrides.
|
|
141
|
+
|
|
142
|
+
`assert()` guarantees the visitor at runtime but does not narrow `context.user` for the compiler. `isAuthorized()` is the one that narrows. It is a type predicate, so inside its `true` branch `context.user` is `User` under `LOGGED_IN_USERS` and `null` under `NON_LOGGED_IN_USERS`.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { redirect } from '@tanstack/react-router'
|
|
146
|
+
|
|
147
|
+
beforeLoad: ({ context }) => {
|
|
148
|
+
if (visibility.isAuthorized(context)) {
|
|
149
|
+
if (context.user.email.endsWith('@example.com')) throw redirect({ to: '/internal' })
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Reach for `assert()` when the route wants the error thrown for it, and `check(context)` when it needs to branch on why. `check` returns `{ ok: true } | { ok: false, reason }` and throws nothing. All of them are synchronous and make no request.
|
|
155
|
+
|
|
156
|
+
## References
|
|
157
|
+
|
|
158
|
+
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor a guard has just admitted
|
|
159
|
+
- [reading-records/SKILL.md](../reading-records/SKILL.md): a guard covers the page, not the server function behind it
|
|
160
|
+
- [auth/SKILL.md](../auth/SKILL.md): `NON_LOGGED_IN_USERS` and the sign-in flow it fronts
|