@softr/sdk 0.0.64 → 0.0.65

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 (43) hide show
  1. package/dist/auth/isomorphic.d.ts +60 -1
  2. package/dist/auth/isomorphic.js +63 -5
  3. package/dist/auth/methods/passwordLink.d.ts +47 -0
  4. package/dist/auth/methods/passwordLink.js +86 -0
  5. package/dist/auth/methods/settings.d.ts +11 -0
  6. package/dist/auth/methods/settings.js +17 -0
  7. package/dist/auth/methods/signup.js +1 -0
  8. package/dist/auth/methods/social.js +1 -0
  9. package/dist/auth/methods/utils.d.ts +1 -1
  10. package/dist/auth/platform.d.ts +46 -0
  11. package/dist/auth/platform.js +54 -0
  12. package/dist/auth/returnPath.d.ts +19 -0
  13. package/dist/auth/returnPath.js +37 -0
  14. package/dist/auth/server.d.ts +6 -0
  15. package/dist/auth/server.js +7 -1
  16. package/dist/default-error-overlay.d.ts +2 -2
  17. package/dist/index.d.ts +4 -2
  18. package/dist/index.js +3 -2
  19. package/dist/mcp.d.ts +2 -8
  20. package/dist/mcp.js +8 -8
  21. package/dist/package.js +1 -1
  22. package/dist/pwa.d.ts +2 -2
  23. package/dist/softr-config.js +5 -15
  24. package/dist/softr-config.schema.js +40 -0
  25. package/dist/visibility/route.d.ts +16 -5
  26. package/dist/visibility/route.js +21 -13
  27. package/dist/vite.js +1 -1
  28. package/package.json +1 -1
  29. package/schema/softr.schema.json +42 -1
  30. package/skills/softr-sdk/SKILL.md +4 -3
  31. package/skills/softr-sdk/auth/SKILL.md +131 -31
  32. package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
  33. package/skills/softr-sdk/current-user/SKILL.md +1 -1
  34. package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
  35. package/skills/softr-sdk/formatting/SKILL.md +4 -4
  36. package/skills/softr-sdk/integrations/SKILL.md +1 -1
  37. package/skills/softr-sdk/mcp/SKILL.md +7 -14
  38. package/skills/softr-sdk/pwa/SKILL.md +3 -3
  39. package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
  40. package/skills/softr-sdk/record-queries/SKILL.md +1 -1
  41. package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
  42. package/skills/softr-sdk/routes-and-visibility/SKILL.md +24 -12
  43. 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). Covers the Result shape they answer
11
- with ({ success, data } | { success, error }) and its codes
12
- invalidCredentials, tooManyAttempts, emailTaken, invalidDetails, invalidCode
13
- and notAuthenticated, the two-step sign-up, and why a successful sign-in does
14
- not always mean the visitor is signed in. The blank template already ships
15
- working login and signup pages.
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.64
22
+ library_version: 0.0.65
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. Redirect into the app.
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
- Send them on by `step`:
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
- if (result.data.status === 'authenticatorRequired') {
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
- Three exits: a message for the form to render, a redirect to the second factor, a redirect into the app.
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
- if (result.data.status === 'authenticatorRequired') {
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
- throw redirect({ to: '/' })
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.64
15
+ library_version: 0.0.65
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.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.64
12
+ library_version: 0.0.65
13
13
  ---
14
14
 
15
15
  # The current user
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.64
13
+ library_version: 0.0.65
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
- why toLocaleDateString, toLocaleString and Intl formatters with the
9
- runtime's defaults must never run in render.
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.64
13
+ library_version: 0.0.65
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.64
13
+ library_version: 0.0.65
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 honour a `redirect` search param, or the approval step dead-ends.
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.64
12
+ library_version: 0.0.65
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 app's sign-in page with a `redirect` search param holding where to come back to. That page is `/login` unless you pass `loginPath`:
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
- ```ts
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 `redirect`.** Sign-in works, approval doesn't. The most common break, and silent — test the connect flow signed out, not only signed in.
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 `redirect` unchecked, or checked by prefix.** Anything that is not a path on this app is someone else's page, and `/\evil.test` is one of them. Let the URL parser decide, and fall back to `/`.
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
- are already served; never write them.
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.64
12
+ library_version: 0.0.65
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.64
14
+ library_version: 0.0.65
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.64
16
+ library_version: 0.0.65
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.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.64
12
+ library_version: 0.0.65
13
13
  ---
14
14
 
15
15
  **Boundary.** `Audience` and `defineVisibility` come from `@softr/sdk` and work anywhere.
@@ -13,7 +13,7 @@ description: >-
13
13
  metadata:
14
14
  type: sub-skill
15
15
  library: softr-sdk
16
- library_version: 0.0.64
16
+ library_version: 0.0.65
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
- All three reasons are required, because nothing fills in a reason the file leaves out:
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, context)` applies the same rule and throws the same access error, using the default behaviour for each reason. It takes no overrides.
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: ({ context }) => {
174
- assertRouteAccess(visibility, context)
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.64
12
+ library_version: 0.0.65
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.