@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.
- package/dist/auth/isomorphic.d.ts +4 -63
- package/dist/auth/isomorphic.js +5 -63
- package/dist/auth/methods/signup.js +0 -1
- package/dist/auth/methods/social.js +0 -1
- package/dist/auth/methods/utils.d.ts +1 -1
- package/dist/auth/platform.js +0 -54
- package/dist/auth/server.d.ts +0 -6
- package/dist/auth/server.js +1 -7
- package/dist/index.d.ts +3 -4
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +8 -2
- package/dist/mcp.js +8 -8
- package/dist/package.js +1 -1
- package/dist/softr-config.js +5 -26
- package/dist/visibility/route.d.ts +6 -34
- package/dist/visibility/route.js +13 -55
- package/package.json +1 -1
- package/schema/softr.schema.json +1 -42
- package/skills/softr-sdk/SKILL.md +3 -4
- package/skills/softr-sdk/auth/SKILL.md +31 -131
- 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 +14 -7
- 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 +3 -2
- package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +14 -25
- package/skills/softr-sdk/workflows/SKILL.md +1 -1
- package/dist/auth/methods/passwordLink.d.ts +0 -47
- package/dist/auth/methods/passwordLink.js +0 -86
- package/dist/auth/methods/settings.d.ts +0 -11
- package/dist/auth/methods/settings.js +0 -17
- package/dist/auth/platform.d.ts +0 -46
- package/dist/softr-config.d.ts +0 -1
package/dist/visibility/route.js
CHANGED
|
@@ -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
|
|
40
|
-
const behaviour = overrides?.[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.
|
|
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
|
|
60
|
+
return ({ context }) => {
|
|
72
61
|
const result = checkAccess(visibility, context);
|
|
73
62
|
if (result.ok) return;
|
|
74
|
-
throw thrownFor(result.reason, overrides
|
|
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: (
|
|
83
|
-
* assertRouteAccess(visibility,
|
|
84
|
-
* if (
|
|
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,
|
|
88
|
-
const result = checkAccess(visibility,
|
|
76
|
+
function assertRouteAccess(visibility, context) {
|
|
77
|
+
const result = checkAccess(visibility, context);
|
|
89
78
|
if (result.ok) return;
|
|
90
|
-
throw thrownFor(result.reason, void 0
|
|
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
|
|
83
|
+
export { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
|
package/package.json
CHANGED
package/schema/softr.schema.json
CHANGED
|
@@ -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": ["
|
|
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
|
-
|
|
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.
|
|
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)
|
|
11
|
-
(
|
|
12
|
-
|
|
13
|
-
and
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
@@ -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.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
|
|
8
|
-
toLocaleDateString, toLocaleString and Intl formatters with the
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|