@softr/sdk 0.0.64-alpha.1 → 0.0.65-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/auth/isomorphic.d.ts +4 -63
  2. package/dist/auth/isomorphic.js +5 -63
  3. package/dist/auth/methods/signup.js +0 -1
  4. package/dist/auth/methods/social.js +0 -1
  5. package/dist/auth/methods/utils.d.ts +1 -1
  6. package/dist/auth/platform.js +0 -54
  7. package/dist/auth/server.d.ts +0 -6
  8. package/dist/auth/server.js +1 -7
  9. package/dist/index.d.ts +3 -4
  10. package/dist/index.js +3 -3
  11. package/dist/mcp.d.ts +8 -2
  12. package/dist/mcp.js +8 -8
  13. package/dist/package.js +1 -1
  14. package/dist/softr-config.js +5 -26
  15. package/dist/visibility/route.d.ts +6 -34
  16. package/dist/visibility/route.js +13 -55
  17. package/package.json +1 -1
  18. package/schema/softr.schema.json +1 -42
  19. package/skills/softr-sdk/SKILL.md +3 -4
  20. package/skills/softr-sdk/auth/SKILL.md +31 -131
  21. package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
  22. package/skills/softr-sdk/current-user/SKILL.md +1 -1
  23. package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
  24. package/skills/softr-sdk/formatting/SKILL.md +4 -4
  25. package/skills/softr-sdk/integrations/SKILL.md +1 -1
  26. package/skills/softr-sdk/mcp/SKILL.md +14 -7
  27. package/skills/softr-sdk/pwa/SKILL.md +3 -3
  28. package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
  29. package/skills/softr-sdk/record-queries/SKILL.md +3 -2
  30. package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
  31. package/skills/softr-sdk/routes-and-visibility/SKILL.md +14 -25
  32. package/skills/softr-sdk/workflows/SKILL.md +1 -1
  33. package/dist/auth/methods/passwordLink.d.ts +0 -47
  34. package/dist/auth/methods/passwordLink.js +0 -86
  35. package/dist/auth/methods/settings.d.ts +0 -11
  36. package/dist/auth/methods/settings.js +0 -17
  37. package/dist/auth/platform.d.ts +0 -46
  38. package/dist/softr-config.d.ts +0 -1
@@ -32,24 +32,14 @@ function isForbiddenError(error) {
32
32
  function isAlreadyAuthenticatedError(error) {
33
33
  return isReason(error, "alreadyAuthenticated");
34
34
  }
35
- function softrConfig() {
36
- return typeof __SOFTR_CONFIG__ === "undefined" ? panic("softr.json is missing") : __SOFTR_CONFIG__;
37
- }
38
35
  /** What a guard throws for a reason: a redirect, or an {@link AccessError}. */
39
- function thrownFor(reason, overrides, location) {
40
- const behaviour = overrides?.[reason] ?? softrConfig().auth.guard[reason];
36
+ function thrownFor(reason, overrides) {
37
+ const behaviour = overrides?.[reason] ?? (typeof __SOFTR_CONFIG__ === "undefined" ? panic("softr.json is missing, so no guard behaviour is configured") : __SOFTR_CONFIG__.auth.guard[reason]);
41
38
  const resolved = typeof behaviour === "string" ? {
42
39
  strategy: "redirect",
43
40
  to: behaviour
44
41
  } : behaviour;
45
- if (resolved.strategy === "redirect") {
46
- if (reason !== "unauthenticated") return redirect({ to: resolved.to });
47
- const { redirectParam } = softrConfig().auth.routes.login;
48
- return redirect({
49
- to: resolved.to,
50
- search: { [redirectParam]: location.href }
51
- });
52
- }
42
+ if (resolved.strategy === "redirect") return redirect({ to: resolved.to });
53
43
  return {
54
44
  type: "access_error",
55
45
  reason
@@ -59,8 +49,7 @@ function thrownFor(reason, overrides, location) {
59
49
  * A `beforeLoad` handler that denies a visitor who fails the rule.
60
50
  *
61
51
  * Each reason falls back to the app's configured behaviour. Pass `overrides` to
62
- * change what any one reason does. A redirected signed-out visitor carries the
63
- * page they wanted, for {@link toReturnPath} to read back.
52
+ * change what any one reason does.
64
53
  *
65
54
  * Throws a router redirect, so it belongs in `beforeLoad` and nowhere else.
66
55
  *
@@ -68,10 +57,10 @@ function thrownFor(reason, overrides, location) {
68
57
  * beforeLoad: guardRoute(visibility, { unauthenticated: "/sign-in" })
69
58
  */
70
59
  function guardRoute(visibility, overrides) {
71
- return ({ context, location }) => {
60
+ return ({ context }) => {
72
61
  const result = checkAccess(visibility, context);
73
62
  if (result.ok) return;
74
- throw thrownFor(result.reason, overrides, location);
63
+ throw thrownFor(result.reason, overrides);
75
64
  };
76
65
  }
77
66
  /**
@@ -79,47 +68,16 @@ function guardRoute(visibility, overrides) {
79
68
  * checks after the rule. It takes no overrides.
80
69
  *
81
70
  * @example
82
- * beforeLoad: (opts) => {
83
- * assertRouteAccess(visibility, opts)
84
- * if (opts.params.projectId === "demo") throw redirect({ to: "/projects" })
71
+ * beforeLoad: ({ context, params }) => {
72
+ * assertRouteAccess(visibility, context)
73
+ * if (params.projectId === "demo") throw redirect({ to: "/projects" })
85
74
  * }
86
75
  */
87
- function assertRouteAccess(visibility, opts) {
88
- const result = checkAccess(visibility, opts.context);
76
+ function assertRouteAccess(visibility, context) {
77
+ const result = checkAccess(visibility, context);
89
78
  if (result.ok) return;
90
- throw thrownFor(result.reason, void 0, opts.location);
91
- }
92
- /**
93
- * Where to send a visitor who just signed in: the path a guard sent them away
94
- * from, else where `auth.guard.alreadyAuthenticated` in softr.json points, else
95
- * `/`. Anything but a path on this app counts as missing. It always reloads the
96
- * page, so the router picks up the new session.
97
- *
98
- * @example
99
- * const location = useLocation()
100
- * navigate(toReturnPath(location))
101
- */
102
- function toReturnPath(location) {
103
- const { auth } = softrConfig();
104
- const target = location.search[auth.routes.login.redirectParam];
105
- if (typeof target === "string" && target.startsWith("/") && new URL(target, "https://app.invalid").origin === "https://app.invalid") return {
106
- href: target,
107
- reloadDocument: true
108
- };
109
- const fallback = auth.guard.alreadyAuthenticated;
110
- if (typeof fallback === "string") return {
111
- href: fallback,
112
- reloadDocument: true
113
- };
114
- if (fallback.strategy === "redirect") return {
115
- href: fallback.to,
116
- reloadDocument: true
117
- };
118
- return {
119
- href: "/",
120
- reloadDocument: true
121
- };
79
+ throw thrownFor(result.reason, void 0);
122
80
  }
123
81
 
124
82
  //#endregion
125
- export { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError, softrConfig, toReturnPath };
83
+ export { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.64-alpha.1",
3
+ "version": "0.0.65-alpha.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -7,30 +7,6 @@
7
7
  "type": "object",
8
8
  "description": "Authentication. Written in this repo and read by the app's build.",
9
9
  "properties": {
10
- "routes": {
11
- "type": "object",
12
- "description": "Where the app's sign-in pages live.",
13
- "properties": {
14
- "login": {
15
- "type": "object",
16
- "description": "The sign-in page.",
17
- "properties": {
18
- "path": { "$ref": "#/$defs/appPath" },
19
- "redirectParam": {
20
- "type": "string",
21
- "description": "The search param that carries where to go after signing in. Every unauthenticated redirect sets it."
22
- }
23
- },
24
- "required": ["path", "redirectParam"],
25
- "additionalProperties": false
26
- },
27
- "resetPassword": { "$ref": "#/$defs/tokenRoute", "description": "The page a password reset email links to." },
28
- "acceptInvitation": { "$ref": "#/$defs/tokenRoute", "description": "The page an invitation email links to." },
29
- "magicLink": { "$ref": "#/$defs/tokenRoute", "description": "The page a magic link email links to." }
30
- },
31
- "required": ["login", "resetPassword", "acceptInvitation", "magicLink"],
32
- "additionalProperties": false
33
- },
34
10
  "guard": {
35
11
  "type": "object",
36
12
  "description": "What a route guard does when it denies a visitor. A route's own overrides win over these.",
@@ -52,29 +28,12 @@
52
28
  "additionalProperties": false
53
29
  }
54
30
  },
55
- "required": ["routes", "guard"],
31
+ "required": ["guard"],
56
32
  "additionalProperties": false
57
33
  }
58
34
  },
59
35
  "required": ["auth"],
60
36
  "$defs": {
61
- "appPath": {
62
- "type": "string",
63
- "pattern": "^/[^?#]*$",
64
- "description": "A path on this app, with no search or hash."
65
- },
66
- "tokenRoute": {
67
- "type": "object",
68
- "properties": {
69
- "path": { "$ref": "#/$defs/appPath" },
70
- "tokenParam": {
71
- "type": "string",
72
- "description": "The search param that carries the link's token."
73
- }
74
- },
75
- "required": ["path", "tokenParam"],
76
- "additionalProperties": false
77
- },
78
37
  "accessBehaviour": {
79
38
  "oneOf": [
80
39
  { "type": "string", "description": "A path to redirect to." },
@@ -6,13 +6,12 @@ description: >-
6
6
  boundary between them, softrAuthMiddleware, getSoftrContext,
7
7
  initialSoftrContext, SoftrContext, and which sub-skill to load for route
8
8
  visibility, the current user, reading records, charts and metrics, writing
9
- records, auth, or showing dates and numbers. Load this when unsure which
10
- sub-skill applies, or when touching the wiring; each sub-skill stands alone
11
- for its own task.
9
+ records, auth, or showing dates and numbers. Load this when unsure which sub-skill applies, or when
10
+ touching the wiring; each sub-skill stands alone for its own task.
12
11
  metadata:
13
12
  type: core
14
13
  library: softr-sdk
15
- library_version: 0.0.64-alpha.1
14
+ library_version: 0.0.61
16
15
  ---
17
16
 
18
17
  # Softr SDK core
@@ -7,19 +7,16 @@ 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), 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.
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.
19
16
  metadata:
20
17
  type: sub-skill
21
18
  library: softr-sdk
22
- library_version: 0.0.64-alpha.1
19
+ library_version: 0.0.61
23
20
  ---
24
21
 
25
22
  **Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
@@ -28,12 +25,6 @@ metadata:
28
25
 
29
26
  `auth` is what changes who the visitor is.
30
27
 
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
-
37
28
  Signing in:
38
29
 
39
30
  | Call | Does |
@@ -51,14 +42,6 @@ Signing up:
51
42
  | `auth.signUp({ email, name? })` | Emails a verification code, creates no account |
52
43
  | `auth.confirmSignUp({ email, code, name?, password? })` | Creates the account and signs the visitor in |
53
44
 
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
-
62
45
  Signing out:
63
46
 
64
47
  | Call | Does |
@@ -82,6 +65,8 @@ Changing an authenticator from a settings page:
82
65
  | `auth.resetAuthenticator({ code })` | Unpairs the app, so a new one can be enrolled |
83
66
  | `auth.removeAuthenticator({ code })` | Turns the authenticator off |
84
67
 
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
+
85
70
  ## Setup
86
71
 
87
72
  `auth` is server-only, so every call sits inside a `createServerFn` handler:
@@ -122,73 +107,29 @@ type AuthErrorCode =
122
107
  | 'invalidCredentials' // signIn, signInWithGoogle, signInWithMagicLink: wrong password, or a token Softr would not take
123
108
  | 'tooManyAttempts' // any call: rate limited
124
109
  | 'emailTaken' // signUp, confirmSignUp: that email already has an account
125
- | 'invalidDetails' // signUp, sendSignInCode, setPasswordWithLink: details Softr rejected
110
+ | 'invalidDetails' // signUp, sendSignInCode: details Softr rejected
126
111
  | 'invalidCode' // any call that takes a code: wrong or expired
127
112
  | '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
130
113
  ```
131
114
 
132
115
  Branch on `error.code` with a `switch` and write your own wording. Never put `error.message` in front of the visitor.
133
116
 
134
117
  `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.
135
118
 
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
-
178
119
  ## A successful sign-in is not always a session
179
120
 
180
- `signIn`, `signInWithCode`, `signInWithGoogle`, `signInWithMagicLink`, `setPasswordWithLink` and `confirmSignUp` all answer the same two ways:
121
+ `signIn`, `signInWithCode`, `signInWithGoogle`, `signInWithMagicLink` and `confirmSignUp` all answer the same two ways:
181
122
 
182
123
  ```ts
183
124
  { status: 'signedIn', user }
184
125
  { status: 'authenticatorRequired', step: 'verify' | 'setup' | 'setupOptional' }
185
126
  ```
186
127
 
187
- `signedIn` is a session. Send the visitor to the return target, see [Landing after sign-in](#landing-after-sign-in).
128
+ `signedIn` is a session. Redirect into the app.
188
129
 
189
130
  `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.
190
131
 
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`:
132
+ Send them on by `step`:
192
133
 
193
134
  | `step` | Show them | Finish with |
194
135
  | --- | --- | --- |
@@ -200,27 +141,6 @@ Those calls take only what the visitor typed. The sign-in they finish is the one
200
141
 
201
142
  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`.
202
143
 
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
-
224
144
  ## Core Patterns
225
145
 
226
146
  ### Sign in
@@ -242,27 +162,24 @@ const signIn = createServerFn({ method: 'POST' })
242
162
  }
243
163
  }
244
164
 
245
- return { message: null, outcome: result.data }
165
+ if (result.data.status === 'authenticatorRequired') {
166
+ throw redirect({ to: '/verify', search: { step: result.data.step } })
167
+ }
168
+
169
+ throw redirect({ to: '/' })
246
170
  })
247
171
  ```
248
172
 
249
- The component takes it from there:
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`:
250
176
 
251
177
  ```tsx
252
178
  import { useServerFn } from '@tanstack/react-start'
253
179
 
254
180
  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 })
262
181
  ```
263
182
 
264
- Three exits: a message for the form to render, the second-factor step, the return target.
265
-
266
183
  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.
267
184
 
268
185
  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).
@@ -304,25 +221,6 @@ const result = await auth.signInWithMagicLink({ token })
304
221
 
305
222
  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.
306
223
 
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
-
326
224
  ### Sign out
327
225
 
328
226
  ```ts
@@ -351,8 +249,6 @@ const startSignUp = createServerFn({ method: 'POST' })
351
249
  return { message: 'That email already has an account.' }
352
250
  case 'invalidDetails':
353
251
  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.' }
356
252
  case 'tooManyAttempts':
357
253
  return { message: 'Too many attempts. Please try again in a few minutes.' }
358
254
  default:
@@ -380,7 +276,11 @@ const finishSignUp = createServerFn({ method: 'POST' })
380
276
  }
381
277
  }
382
278
 
383
- return { message: null, outcome: result.data } // handled as sign-in's is
279
+ if (result.data.status === 'authenticatorRequired') {
280
+ throw redirect({ to: '/verify', search: { step: result.data.step } })
281
+ }
282
+
283
+ throw redirect({ to: '/' })
384
284
  })
385
285
  ```
386
286
 
@@ -407,7 +307,7 @@ const verify = createServerFn({ method: 'POST' })
407
307
  return { message: 'Could not check that code. Please try again.' }
408
308
  }
409
309
  }
410
- return { message: null } // signed in: the component goes to the target
310
+ throw redirect({ to: '/' })
411
311
  })
412
312
  ```
413
313
 
@@ -429,12 +329,11 @@ Neither of those enrols anybody. `confirmAuthenticatorSetup` does, once it accep
429
329
  ```ts
430
330
  const done = await auth.confirmAuthenticatorSetup({ code })
431
331
  if (done.success) {
432
- return { backupCodes: done.data.backupCodes } // returned once. Render them now, nothing fetches them again.
332
+ done.data.backupCodes // returned once. Render them now, nothing fetches them again.
333
+ throw redirect({ to: '/' })
433
334
  }
434
335
  ```
435
336
 
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
-
438
337
  Call `startAuthenticatorSetup` again to hand back a fresh QR code, for a visitor who lost the first one. Only the newest one enrols them.
439
338
 
440
339
  `skipAuthenticatorSetup` finishes the sign-in without enrolling, and only on `setupOptional`. Later sign-ins stop asking.
@@ -454,4 +353,5 @@ After a reset, enrol the new app with `startAuthenticatorSetup` and `confirmAuth
454
353
  ## References
455
354
 
456
355
  - [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor these calls establish
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
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
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.64-alpha.1
15
+ library_version: 0.0.61
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-alpha.1
12
+ library_version: 0.0.61
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-alpha.1
13
+ library_version: 0.0.61
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 why
8
- toLocaleDateString, toLocaleString and Intl formatters with the runtime's
9
- defaults must never run in render.
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.
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.64-alpha.1
13
+ library_version: 0.0.61
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-alpha.1
13
+ library_version: 0.0.61
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 land on toReturnPath, or the approval step dead-ends.
8
+ login must honour a `redirect` search param, 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-alpha.1
12
+ library_version: 0.0.61
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,11 +59,18 @@ 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 login page named in `softr.json` (`auth.routes.login.path`), with the approval page in the search param named there (`auth.routes.login.redirectParam`).
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`:
63
63
 
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.
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.
65
73
 
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.
67
74
  ## Failure modes
68
75
 
69
76
  **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.
@@ -72,9 +79,9 @@ Connecting starts with an approval page the app serves. Most people hit it signe
72
79
 
73
80
  **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.
74
81
 
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.
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.
76
83
 
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.
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 `/`.
78
85
 
79
86
  ## References
80
87
 
@@ -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 are
8
- already served; never write them.
7
+ types, and how to check that the app is installable. The manifest and icon
8
+ are already served; never write them.
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.64-alpha.1
12
+ library_version: 0.0.61
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-alpha.1
14
+ library_version: 0.0.61
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-alpha.1
16
+ library_version: 0.0.61
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.
@@ -56,7 +56,8 @@ const publishedPosts = defineQuery({
56
56
  - `permissions` declares who may run the query, and the SDK enforces it:
57
57
  - `{ userGroupTypes: ['NON_LOGGED_IN_USERS', 'LOGGED_IN_USERS'] }`: everyone, signed out included. `customGroups` narrows only the signed-in half.
58
58
  - `{ userGroupTypes: ['NON_LOGGED_IN_USERS'] }`: signed-out visitors only.
59
- - `{ userGroupTypes: ['LOGGED_IN_USERS'] }`: any signed-in visitor, narrowed to particular groups with `customGroups: ['00000000-0000-0000-0000-00000000000a']`.
59
+ - `{ userGroupTypes: ['LOGGED_IN_USERS'] }`: any signed-in visitor, narrowed to particular groups with `customGroups: ['00000000-0000-0000-0000-00000000000a']`. `customGroups` is added to `userGroupTypes`, never written in place of it.
60
+ - Write each group id as a literal in `permissions`. An id taken from a `const` is a lint error, as it is in a route's visibility.
60
61
  - Write the object inline in the `defineQuery` call. Passing the declaration or its `fields` through a variable, or spreading one in, widens the field ids to `string` and the types stop checking.
61
62
 
62
63
  A query fixes the table and the columns, not the rows: filters, sorting and paging stay at the call site, because a list screen changes them as the user types. Declare as many queries over one datasource as the app needs, each carrying the fields its own screen touches.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.64-alpha.1
12
+ library_version: 0.0.61
13
13
  ---
14
14
 
15
15
  **Boundary.** `Audience` and `defineVisibility` come from `@softr/sdk` and work anywhere.