@softr/sdk 0.0.63 → 0.0.64-alpha.1
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/dist/auth/isomorphic.d.ts +63 -4
- package/dist/auth/isomorphic.js +63 -5
- package/dist/auth/methods/passwordLink.d.ts +47 -0
- package/dist/auth/methods/passwordLink.js +86 -0
- package/dist/auth/methods/settings.d.ts +11 -0
- package/dist/auth/methods/settings.js +17 -0
- package/dist/auth/methods/signup.js +1 -0
- package/dist/auth/methods/social.js +1 -0
- package/dist/auth/methods/utils.d.ts +1 -1
- package/dist/auth/platform.d.ts +46 -0
- package/dist/auth/platform.js +54 -0
- package/dist/auth/server.d.ts +6 -0
- package/dist/auth/server.js +7 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +2 -8
- package/dist/mcp.js +8 -8
- package/dist/package.js +1 -1
- package/dist/softr-config.d.ts +1 -0
- package/dist/softr-config.js +26 -5
- package/dist/visibility/route.d.ts +34 -6
- package/dist/visibility/route.js +55 -13
- package/package.json +33 -10
- package/schema/softr.schema.json +42 -1
- package/skills/softr-sdk/SKILL.md +4 -3
- package/skills/softr-sdk/auth/SKILL.md +131 -31
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
- package/skills/softr-sdk/formatting/SKILL.md +4 -4
- package/skills/softr-sdk/integrations/SKILL.md +1 -1
- package/skills/softr-sdk/mcp/SKILL.md +7 -14
- package/skills/softr-sdk/pwa/SKILL.md +3 -3
- package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
- package/skills/softr-sdk/record-queries/SKILL.md +1 -1
- package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +24 -12
- package/skills/softr-sdk/workflows/SKILL.md +1 -1
|
@@ -7,16 +7,19 @@ description: >-
|
|
|
7
7
|
sign-in (verifyAuthenticator, startAuthenticatorSetup,
|
|
8
8
|
confirmAuthenticatorSetup, skipAuthenticatorSetup) and the ones that change an
|
|
9
9
|
authenticator from a settings page (sendAuthenticatorResetCode,
|
|
10
|
-
resetAuthenticator, removeAuthenticator)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
and
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
resetAuthenticator, removeAuthenticator), the emailed password links
|
|
11
|
+
(requestPasswordReset, checkPasswordLink, setPasswordWithLink), and
|
|
12
|
+
getAuthSettings, AuthSettingsProvider and useAuthSettings for which sign-in
|
|
13
|
+
and sign-up methods the app has enabled. Covers the Result shape they answer
|
|
14
|
+
with and its codes invalidCredentials, tooManyAttempts, emailTaken,
|
|
15
|
+
invalidDetails, invalidCode, notAuthenticated, emailDomainNotAllowed and
|
|
16
|
+
linkExpired, the two-step sign-up, why a successful sign-in does not always
|
|
17
|
+
mean the visitor is signed in, and toReturnPath for where to land after
|
|
18
|
+
signing in.
|
|
16
19
|
metadata:
|
|
17
20
|
type: sub-skill
|
|
18
21
|
library: softr-sdk
|
|
19
|
-
library_version: 0.0.
|
|
22
|
+
library_version: 0.0.64-alpha.1
|
|
20
23
|
---
|
|
21
24
|
|
|
22
25
|
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
|
|
@@ -25,6 +28,12 @@ metadata:
|
|
|
25
28
|
|
|
26
29
|
`auth` is what changes who the visitor is.
|
|
27
30
|
|
|
31
|
+
Reading how the app signs people in:
|
|
32
|
+
|
|
33
|
+
| Call | Does |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `auth.settings()` | Returns the sign-in and sign-up settings the builder chose in Studio |
|
|
36
|
+
|
|
28
37
|
Signing in:
|
|
29
38
|
|
|
30
39
|
| Call | Does |
|
|
@@ -42,6 +51,14 @@ Signing up:
|
|
|
42
51
|
| `auth.signUp({ email, name? })` | Emails a verification code, creates no account |
|
|
43
52
|
| `auth.confirmSignUp({ email, code, name?, password? })` | Creates the account and signs the visitor in |
|
|
44
53
|
|
|
54
|
+
Setting a password from an emailed link, for a password reset or an invitation:
|
|
55
|
+
|
|
56
|
+
| Call | Does |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `auth.requestPasswordReset({ email })` | Emails a link to set a new password |
|
|
59
|
+
| `auth.checkPasswordLink({ token })` | Returns the `email` the link was sent to, and leaves the link usable |
|
|
60
|
+
| `auth.setPasswordWithLink({ token, password })` | Sets the password and signs the visitor in |
|
|
61
|
+
|
|
45
62
|
Signing out:
|
|
46
63
|
|
|
47
64
|
| Call | Does |
|
|
@@ -65,8 +82,6 @@ Changing an authenticator from a settings page:
|
|
|
65
82
|
| `auth.resetAuthenticator({ code })` | Unpairs the app, so a new one can be enrolled |
|
|
66
83
|
| `auth.removeAuthenticator({ code })` | Turns the authenticator off |
|
|
67
84
|
|
|
68
|
-
**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.
|
|
69
|
-
|
|
70
85
|
## Setup
|
|
71
86
|
|
|
72
87
|
`auth` is server-only, so every call sits inside a `createServerFn` handler:
|
|
@@ -107,29 +122,73 @@ type AuthErrorCode =
|
|
|
107
122
|
| 'invalidCredentials' // signIn, signInWithGoogle, signInWithMagicLink: wrong password, or a token Softr would not take
|
|
108
123
|
| 'tooManyAttempts' // any call: rate limited
|
|
109
124
|
| 'emailTaken' // signUp, confirmSignUp: that email already has an account
|
|
110
|
-
| 'invalidDetails' // signUp, sendSignInCode: details Softr rejected
|
|
125
|
+
| 'invalidDetails' // signUp, sendSignInCode, setPasswordWithLink: details Softr rejected
|
|
111
126
|
| 'invalidCode' // any call that takes a code: wrong or expired
|
|
112
127
|
| 'notAuthenticated' // a call that needs a signed-in visitor, made by one who is not
|
|
128
|
+
| 'emailDomainNotAllowed' // signUp, signInWithGoogle: the app lets only some email domains sign up, and this is not one
|
|
129
|
+
| 'linkExpired' // checkPasswordLink, setPasswordWithLink: the link is used, expired or no longer valid
|
|
113
130
|
```
|
|
114
131
|
|
|
115
132
|
Branch on `error.code` with a `switch` and write your own wording. Never put `error.message` in front of the visitor.
|
|
116
133
|
|
|
117
134
|
`code` is typed as every code the SDK produces, not the ones a given call can answer, so a `switch` needs a `default` even when the cases above it are complete. Give it a message rather than leaving it out.
|
|
118
135
|
|
|
136
|
+
## Render only the methods the app has enabled
|
|
137
|
+
|
|
138
|
+
`getAuthSettings` from `@softr/sdk` loads which ways in the app offers. Call it in the loader of a route that sits above every page needing it, and wrap that route's children in `AuthSettingsProvider`. If the auth pages share a layout route, that route is the usual place:
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
import { AuthSettingsProvider, getAuthSettings } from '@softr/sdk'
|
|
142
|
+
|
|
143
|
+
// in the route options
|
|
144
|
+
loader: () => getAuthSettings(),
|
|
145
|
+
staleTime: 60_000,
|
|
146
|
+
|
|
147
|
+
// in the route's component
|
|
148
|
+
const authSettings = Route.useLoaderData()
|
|
149
|
+
return (
|
|
150
|
+
<AuthSettingsProvider value={authSettings}>
|
|
151
|
+
<Outlet />
|
|
152
|
+
</AuthSettingsProvider>
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Any page or component below it reads them with `useAuthSettings()`, which needs the provider above it:
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
const options = useAuthSettings()
|
|
160
|
+
return options.password && <PasswordForm />
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Each field names the `auth` call it unlocks:
|
|
164
|
+
|
|
165
|
+
| Field | When on, offer |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| `password` | the password form, `auth.signIn` |
|
|
168
|
+
| `emailCode` | sign-in with a code, `auth.sendSignInCode` |
|
|
169
|
+
| `magicLink` | the route that calls `auth.signInWithMagicLink` |
|
|
170
|
+
| `google` | a Google button started with `google.clientId`. `null` means no Google. Never hardcode a client id. |
|
|
171
|
+
| `signUp` | sign-up, `auth.signUp` |
|
|
172
|
+
| `strongPasswords` | the strong-password rules, on every form that sets a new password. Check the password against them before submitting. |
|
|
173
|
+
|
|
174
|
+
When the builder turns sign-in off, every field is off.
|
|
175
|
+
|
|
176
|
+
Render nothing for a field that is off. A page for a flow that is off answers 404 from its loader. Throw `notFound()` there, never from the component: a component that renders nothing answers 200 with an empty page. `auth.settings()` returns the full settings, for a server function that needs more than these.
|
|
177
|
+
|
|
119
178
|
## A successful sign-in is not always a session
|
|
120
179
|
|
|
121
|
-
`signIn`, `signInWithCode`, `signInWithGoogle`, `signInWithMagicLink` and `confirmSignUp` all answer the same two ways:
|
|
180
|
+
`signIn`, `signInWithCode`, `signInWithGoogle`, `signInWithMagicLink`, `setPasswordWithLink` and `confirmSignUp` all answer the same two ways:
|
|
122
181
|
|
|
123
182
|
```ts
|
|
124
183
|
{ status: 'signedIn', user }
|
|
125
184
|
{ status: 'authenticatorRequired', step: 'verify' | 'setup' | 'setupOptional' }
|
|
126
185
|
```
|
|
127
186
|
|
|
128
|
-
`signedIn` is a session.
|
|
187
|
+
`signedIn` is a session. Send the visitor to the return target, see [Landing after sign-in](#landing-after-sign-in).
|
|
129
188
|
|
|
130
189
|
`authenticatorRequired` is not. The app asks for an authenticator app as well, and the visitor has no session until they produce one. Redirecting into the app here sends them to a page they cannot see, and a guarded page bounces them back to the login form with nothing on screen explaining why.
|
|
131
190
|
|
|
132
|
-
|
|
191
|
+
Show the second factor as the next step of the page that signed them in, not on a page of its own. Pick what it shows by `step`:
|
|
133
192
|
|
|
134
193
|
| `step` | Show them | Finish with |
|
|
135
194
|
| --- | --- | --- |
|
|
@@ -141,6 +200,27 @@ Those calls take only what the visitor typed. The sign-in they finish is the one
|
|
|
141
200
|
|
|
142
201
|
An app with no authenticator app configured never answers `authenticatorRequired`. The type still has both arms either way, so `result.data.user` does not exist until you have narrowed on `status`.
|
|
143
202
|
|
|
203
|
+
## Landing after sign-in
|
|
204
|
+
|
|
205
|
+
A guard that turns a signed-out visitor away sends them to the page `softr.json` names for that, usually the login page. The page they wanted rides along in the search param named for the login page. `toReturnPath` from `@softr/sdk` reads it back:
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
import { useLocation, useNavigate } from '@tanstack/react-router'
|
|
209
|
+
import { toReturnPath } from '@softr/sdk'
|
|
210
|
+
|
|
211
|
+
const location = useLocation()
|
|
212
|
+
const navigate = useNavigate()
|
|
213
|
+
|
|
214
|
+
// once the visitor is signed in
|
|
215
|
+
await navigate(toReturnPath(location))
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
A loader that signs the visitor in throws it instead: `throw redirect(toReturnPath(location))`, with the loader's own `location`.
|
|
219
|
+
|
|
220
|
+
It goes to the page they wanted when that is a path on this app. Otherwise it goes where `auth.guard.alreadyAuthenticated` in `softr.json` points, or to `/` when that setting throws. It reloads the page, so the app sees the visitor as signed in. Pass its answer as is. Never read the param yourself: anyone can put a link to another site in it.
|
|
221
|
+
|
|
222
|
+
Every page that ends a sign-in lands there: login, sign-up, the magic link, a password link and the second-factor step. A link from one auth page to another keeps the search param, or the target is lost on the way.
|
|
223
|
+
|
|
144
224
|
## Core Patterns
|
|
145
225
|
|
|
146
226
|
### Sign in
|
|
@@ -162,24 +242,27 @@ const signIn = createServerFn({ method: 'POST' })
|
|
|
162
242
|
}
|
|
163
243
|
}
|
|
164
244
|
|
|
165
|
-
|
|
166
|
-
throw redirect({ to: '/verify', search: { step: result.data.step } })
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
throw redirect({ to: '/' })
|
|
245
|
+
return { message: null, outcome: result.data }
|
|
170
246
|
})
|
|
171
247
|
```
|
|
172
248
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
Call it from the component with `useServerFn`:
|
|
249
|
+
The component takes it from there:
|
|
176
250
|
|
|
177
251
|
```tsx
|
|
178
252
|
import { useServerFn } from '@tanstack/react-start'
|
|
179
253
|
|
|
180
254
|
const submit = useServerFn(signIn)
|
|
255
|
+
|
|
256
|
+
const answer = await submit({ data: { email, password } })
|
|
257
|
+
if (answer.message !== null) return setMessage(answer.message)
|
|
258
|
+
if (answer.outcome.status === 'authenticatorRequired') {
|
|
259
|
+
return setStep({ name: 'secondFactor', opensOn: answer.outcome.step })
|
|
260
|
+
}
|
|
261
|
+
await navigate({ href: target })
|
|
181
262
|
```
|
|
182
263
|
|
|
264
|
+
Three exits: a message for the form to render, the second-factor step, the return target.
|
|
265
|
+
|
|
183
266
|
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.
|
|
184
267
|
|
|
185
268
|
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).
|
|
@@ -221,6 +304,25 @@ const result = await auth.signInWithMagicLink({ token })
|
|
|
221
304
|
|
|
222
305
|
The token keeps working after it is used, so anyone holding the link can sign in as that visitor. Never log it, and redirect away from the URL that carried it.
|
|
223
306
|
|
|
307
|
+
### Set a password from an emailed link
|
|
308
|
+
|
|
309
|
+
A password reset and an invitation both email a link with a token. The same two calls redeem either.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
// The forgot-password form. Succeeds whether or not the address has an account.
|
|
313
|
+
const sent = await auth.requestPasswordReset({ email })
|
|
314
|
+
|
|
315
|
+
// The loader of the page the link opens. Show an expired message on linkExpired, before any form.
|
|
316
|
+
const link = await auth.checkPasswordLink({ token })
|
|
317
|
+
|
|
318
|
+
// The form's submit. Same two-armed result as signIn.
|
|
319
|
+
const result = await auth.setPasswordWithLink({ token, password })
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Say "check your email" after `requestPasswordReset` either way, and never branch the UI on it.
|
|
323
|
+
|
|
324
|
+
`setPasswordWithLink` uses up the link on any success, `authenticatorRequired` included, and signs the visitor out everywhere else.
|
|
325
|
+
|
|
224
326
|
### Sign out
|
|
225
327
|
|
|
226
328
|
```ts
|
|
@@ -249,6 +351,8 @@ const startSignUp = createServerFn({ method: 'POST' })
|
|
|
249
351
|
return { message: 'That email already has an account.' }
|
|
250
352
|
case 'invalidDetails':
|
|
251
353
|
return { message: 'Please check the details and try again.' }
|
|
354
|
+
case 'emailDomainNotAllowed':
|
|
355
|
+
return { message: 'Sign-up is open only to some email domains, and yours is not one.' }
|
|
252
356
|
case 'tooManyAttempts':
|
|
253
357
|
return { message: 'Too many attempts. Please try again in a few minutes.' }
|
|
254
358
|
default:
|
|
@@ -276,11 +380,7 @@ const finishSignUp = createServerFn({ method: 'POST' })
|
|
|
276
380
|
}
|
|
277
381
|
}
|
|
278
382
|
|
|
279
|
-
|
|
280
|
-
throw redirect({ to: '/verify', search: { step: result.data.step } })
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
throw redirect({ to: '/' })
|
|
383
|
+
return { message: null, outcome: result.data } // handled as sign-in's is
|
|
284
384
|
})
|
|
285
385
|
```
|
|
286
386
|
|
|
@@ -307,7 +407,7 @@ const verify = createServerFn({ method: 'POST' })
|
|
|
307
407
|
return { message: 'Could not check that code. Please try again.' }
|
|
308
408
|
}
|
|
309
409
|
}
|
|
310
|
-
|
|
410
|
+
return { message: null } // signed in: the component goes to the target
|
|
311
411
|
})
|
|
312
412
|
```
|
|
313
413
|
|
|
@@ -329,11 +429,12 @@ Neither of those enrols anybody. `confirmAuthenticatorSetup` does, once it accep
|
|
|
329
429
|
```ts
|
|
330
430
|
const done = await auth.confirmAuthenticatorSetup({ code })
|
|
331
431
|
if (done.success) {
|
|
332
|
-
done.data.backupCodes // returned once. Render them now, nothing fetches them again.
|
|
333
|
-
throw redirect({ to: '/' })
|
|
432
|
+
return { backupCodes: done.data.backupCodes } // returned once. Render them now, nothing fetches them again.
|
|
334
433
|
}
|
|
335
434
|
```
|
|
336
435
|
|
|
436
|
+
The visitor is signed in while the codes are on screen, often on a page only signed-out visitors may see. Invalidating the router or navigating re-runs that page's guard, which sends them away and the codes are gone. Do neither until they click "Continue", then go to the target.
|
|
437
|
+
|
|
337
438
|
Call `startAuthenticatorSetup` again to hand back a fresh QR code, for a visitor who lost the first one. Only the newest one enrols them.
|
|
338
439
|
|
|
339
440
|
`skipAuthenticatorSetup` finishes the sign-in without enrolling, and only on `setupOptional`. Later sign-ins stop asking.
|
|
@@ -353,5 +454,4 @@ After a reset, enrol the new app with `startAuthenticatorSetup` and `confirmAuth
|
|
|
353
454
|
## References
|
|
354
455
|
|
|
355
456
|
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor these calls establish
|
|
356
|
-
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `NON_LOGGED_IN_USERS` and the `alreadyAuthenticated` redirect that keeps a signed-in visitor off a login page
|
|
357
|
-
- `src/routes/_auth/login.tsx` and `signup.tsx` in your own app: the implementations it was scaffolded with
|
|
457
|
+
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `NON_LOGGED_IN_USERS` and the `alreadyAuthenticated` redirect that keeps a signed-in visitor off a login page
|
|
@@ -12,7 +12,7 @@ description: >-
|
|
|
12
12
|
metadata:
|
|
13
13
|
type: sub-skill
|
|
14
14
|
library: softr-sdk
|
|
15
|
-
library_version: 0.0.
|
|
15
|
+
library_version: 0.0.64-alpha.1
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
**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. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.64-alpha.1
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
**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.
|
|
@@ -4,13 +4,13 @@ description: >-
|
|
|
4
4
|
Show dates, times and numbers in the app's format without hydration
|
|
5
5
|
mismatches: FormattedDate in JSX, useFormatting with formatDate,
|
|
6
6
|
formatDateTime and formatNumber in code, FormattingScope and the timeZone
|
|
7
|
-
option for a zone or locale the user names, parts for a partial date, and
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
option for a zone or locale the user names, parts for a partial date, and why
|
|
8
|
+
toLocaleDateString, toLocaleString and Intl formatters with the runtime's
|
|
9
|
+
defaults must never run in render.
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.64-alpha.1
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
# Dates, times and numbers
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.64-alpha.1
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
**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.
|
|
@@ -5,11 +5,11 @@ description: >-
|
|
|
5
5
|
@softr/sdk, registered in src/start.ts ahead of softrAuthMiddleware, turns the
|
|
6
6
|
actions you list into tools an assistant can call as the signed-in visitor.
|
|
7
7
|
The list is explicit — an action left out is invisible over MCP. The app's own
|
|
8
|
-
login must
|
|
8
|
+
login must land on toReturnPath, or the approval step dead-ends.
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.64-alpha.1
|
|
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.
|
|
@@ -59,18 +59,11 @@ Reads first. Add a write only when the user asked for one, and name it back to t
|
|
|
59
59
|
|
|
60
60
|
## The login round trip
|
|
61
61
|
|
|
62
|
-
Connecting starts with an approval page the app serves. Most people hit it signed out, and that page cannot sign anyone in. It sends them to the
|
|
62
|
+
Connecting starts with an approval page the app serves. Most people hit it signed out, and that page cannot sign anyone in. It sends them to the login page named in `softr.json` (`auth.routes.login.path`), with the approval page in the search param named there (`auth.routes.login.redirectParam`).
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
softrMcpMiddleware({ actions: [listCafes], loginPath: '/sign-in' })
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
**The app's login has to honour that param.** Carry it through every exit that ends a sign-in, the second-factor step included, and send the visitor there instead of the home page. Drop it anywhere along the way and the approval dies quietly: they land on the home page, the assistant never gets its answer, nothing reports an error.
|
|
69
|
-
|
|
70
|
-
Validate it before following it. A path on this app and nothing else. Ask the URL parser rather than checking prefixes: resolve the value against a placeholder origin and reject it unless the origin survives. Browsers read `\` as `/` and drop tabs and newlines, so a value that does not start with `//` can still leave the app. The whole flow starts with a link someone was sent, so this is the one redirect an attacker can reliably aim.
|
|
71
|
-
|
|
72
|
-
The blank template does this in `src/routes/_auth/login.tsx`, `src/routes/verify.tsx` and `src/lib/after-auth.ts`. Read those before writing your own.
|
|
64
|
+
**The app's login has to send them back.** Every exit that ends a sign-in, the second-factor step included, goes to `toReturnPath(location)` instead of the home page. See [auth/SKILL.md](../auth/SKILL.md). Lose the search param anywhere along the way and the approval dies quietly: they land on the home page, the assistant never gets its answer, nothing reports an error.
|
|
73
65
|
|
|
66
|
+
`toReturnPath` only ever goes to a path on this app. Never follow the param without it. The whole flow starts with a link someone was sent, so this is the one redirect an attacker can reliably aim.
|
|
74
67
|
## Failure modes
|
|
75
68
|
|
|
76
69
|
**Registering with no actions.** `softrMcpMiddleware()` is valid and serves an endpoint with nothing on it. The assistant connects, lists tools, finds only `whoami`. Pass the actions.
|
|
@@ -79,9 +72,9 @@ The blank template does this in `src/routes/_auth/login.tsx`, `src/routes/verify
|
|
|
79
72
|
|
|
80
73
|
**Handing over the app's URL as the MCP URL.** The origin is where the app's pages are. Without `/_softr/mcp` on the end the assistant has nothing to talk to, and the owner is left comparing a URL that looks right against a connection that never works.
|
|
81
74
|
|
|
82
|
-
**A login that drops
|
|
75
|
+
**A login that drops the return target.** Sign-in works, approval doesn't. The most common break, and silent — test the connect flow signed out, not only signed in.
|
|
83
76
|
|
|
84
|
-
**Following
|
|
77
|
+
**Following the param without `toReturnPath`.** Anything that is not a path on this app is someone else's page, and `/\evil.test` is one of them. A prefix check misses it.
|
|
85
78
|
|
|
86
79
|
## References
|
|
87
80
|
|
|
@@ -4,12 +4,12 @@ description: >-
|
|
|
4
4
|
Let visitors install the app on their home screen: SoftrPwaHead for the
|
|
5
5
|
manifest and icon links, usePwaInstall to decide whether to show an install
|
|
6
6
|
button and which platform's steps to show, the PwaInstall and PwaPlatform
|
|
7
|
-
types, and how to check that the app is installable. The manifest and icon
|
|
8
|
-
|
|
7
|
+
types, and how to check that the app is installable. The manifest and icon are
|
|
8
|
+
already served; never write them.
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.64-alpha.1
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
# Installing the app
|
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
metadata:
|
|
12
12
|
type: sub-skill
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.
|
|
14
|
+
library_version: 0.0.64-alpha.1
|
|
15
15
|
---
|
|
16
16
|
|
|
17
17
|
**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.
|
|
@@ -13,7 +13,7 @@ description: >-
|
|
|
13
13
|
metadata:
|
|
14
14
|
type: sub-skill
|
|
15
15
|
library: softr-sdk
|
|
16
|
-
library_version: 0.0.
|
|
16
|
+
library_version: 0.0.64-alpha.1
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
**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.
|
|
@@ -13,7 +13,7 @@ description: >-
|
|
|
13
13
|
metadata:
|
|
14
14
|
type: sub-skill
|
|
15
15
|
library: softr-sdk
|
|
16
|
-
library_version: 0.0.
|
|
16
|
+
library_version: 0.0.64-alpha.1
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
**Boundary.** `defineVisibility`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
|
|
@@ -115,6 +115,12 @@ Every app declares where a denied visitor goes in `softr.json`, at the app root,
|
|
|
115
115
|
{
|
|
116
116
|
"$schema": "./node_modules/@softr/sdk/schema/softr.schema.json",
|
|
117
117
|
"auth": {
|
|
118
|
+
"routes": {
|
|
119
|
+
"login": { "path": "/login", "redirectParam": "redirect" },
|
|
120
|
+
"resetPassword": { "path": "/reset-password", "tokenParam": "token" },
|
|
121
|
+
"acceptInvitation": { "path": "/accept-invitation", "tokenParam": "token" },
|
|
122
|
+
"magicLink": { "path": "/magic-authentication", "tokenParam": "magic-token" }
|
|
123
|
+
},
|
|
118
124
|
"guard": {
|
|
119
125
|
"unauthenticated": "/login",
|
|
120
126
|
"alreadyAuthenticated": "/",
|
|
@@ -124,13 +130,17 @@ Every app declares where a denied visitor goes in `softr.json`, at the app root,
|
|
|
124
130
|
}
|
|
125
131
|
```
|
|
126
132
|
|
|
127
|
-
|
|
133
|
+
Every key is required, because nothing fills in one the file leaves out:
|
|
128
134
|
|
|
129
|
-
| Reason | Condition |
|
|
130
|
-
| --- | --- |
|
|
131
|
-
| `unauthenticated` | Route needs a visitor, none present |
|
|
132
|
-
| `forbidden` | Signed in, in none of the named groups |
|
|
133
|
-
| `alreadyAuthenticated` | Signed-in visitor on a signed-out-only route |
|
|
135
|
+
| Reason | Condition | Goes to |
|
|
136
|
+
| --- | --- | --- |
|
|
137
|
+
| `unauthenticated` | Route needs a visitor, none present | `guard.unauthenticated` |
|
|
138
|
+
| `forbidden` | Signed in, in none of the named groups | `guard.forbidden` |
|
|
139
|
+
| `alreadyAuthenticated` | Signed-in visitor on a signed-out-only route | `guard.alreadyAuthenticated` |
|
|
140
|
+
|
|
141
|
+
`guard.unauthenticated` is where signed-out visitors go. It is usually the same as `routes.login.path`, but it can be another page, such as a public landing page. Every `unauthenticated` redirect, an override's included, carries the page they wanted under `routes.login.redirectParam`. The login page reads it back with `toReturnPath`. See [auth/SKILL.md](../auth/SKILL.md).
|
|
142
|
+
|
|
143
|
+
`auth.routes` names the app's sign-in pages. To move one, rename its route file and edit its `path` in `auth.routes` to match. When `guard.unauthenticated` points at the login page, edit it too.
|
|
134
144
|
|
|
135
145
|
That file covers every route at once; a route's own overrides win over it. A rule naming both audiences with no `customGroups` admits everyone, so it never redirects and never throws. Only `strategy: 'throw'` reaches an `errorComponent`. A redirect navigates away and the route never renders.
|
|
136
146
|
|
|
@@ -162,20 +172,22 @@ Use the predicates: `isAccessError` for any of the three, and `isUnauthenticated
|
|
|
162
172
|
|
|
163
173
|
### Run the check and then keep going
|
|
164
174
|
|
|
165
|
-
`guardRoute` covers almost everything. When a route needs its own logic afterwards, `assertRouteAccess(visibility,
|
|
175
|
+
`guardRoute` covers almost everything. When a route needs its own logic afterwards, `assertRouteAccess(visibility, opts)` applies the same rule to the whole `beforeLoad` argument and throws the same access error, using the default behaviour for each reason. It takes no overrides.
|
|
166
176
|
|
|
167
|
-
It also narrows `context.user` for the rest of the handler, where the set names one audience and no other: `User` under `['LOGGED_IN_USERS']`, `null` under `['NON_LOGGED_IN_USERS']`. A set naming both admits either kind of visitor, so it narrows to neither. `guardRoute` cannot, because it hands `beforeLoad` a function rather than running inside one.
|
|
177
|
+
It also narrows `opts.context.user` for the rest of the handler, where the set names one audience and no other: `User` under `['LOGGED_IN_USERS']`, `null` under `['NON_LOGGED_IN_USERS']`. A set naming both admits either kind of visitor, so it narrows to neither. `guardRoute` cannot, because it hands `beforeLoad` a function rather than running inside one.
|
|
168
178
|
|
|
169
179
|
```ts
|
|
170
180
|
import { redirect } from '@tanstack/react-router'
|
|
171
181
|
import { assertRouteAccess } from '@softr/sdk'
|
|
172
182
|
|
|
173
|
-
beforeLoad: (
|
|
174
|
-
assertRouteAccess(visibility,
|
|
175
|
-
if (context.user.email.endsWith('@example.com')) throw redirect({ to: '/internal' })
|
|
183
|
+
beforeLoad: (opts) => {
|
|
184
|
+
assertRouteAccess(visibility, opts)
|
|
185
|
+
if (opts.context.user.email.endsWith('@example.com')) throw redirect({ to: '/internal' })
|
|
176
186
|
},
|
|
177
187
|
```
|
|
178
188
|
|
|
189
|
+
Take `opts` whole. A `context` destructured in the parameter list does not narrow.
|
|
190
|
+
|
|
179
191
|
Reach for `assertRouteAccess` when the route wants the error thrown for it, `isAuthorized(visibility, context)` when it wants the same narrowing without a throw, and `checkAccess(visibility, context)` when it needs to branch on why. `checkAccess` returns `{ ok: true } | { ok: false, reason }` and throws nothing. All of them are synchronous and make no request.
|
|
180
192
|
|
|
181
193
|
`guardRoute` and `assertRouteAccess` throw router redirects, so they belong in a route and nowhere else. That is what `Route` in their names marks. `isAuthorized` and `checkAccess` throw nothing and answer anywhere, in a loader, a component or a server function.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.64-alpha.1
|
|
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.
|