@softr/sdk 0.0.64-alpha.0 → 0.0.64
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 +1 -60
- package/dist/auth/isomorphic.js +9 -65
- 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/default-error-overlay.d.ts +2 -2
- 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/pwa.d.ts +2 -2
- package/dist/softr-config.js +5 -26
- package/dist/visibility/route.d.ts +6 -26
- package/dist/visibility/route.js +13 -36
- package/package.json +10 -33
- package/schema/softr.schema.json +1 -42
- package/skills/softr-sdk/SKILL.md +3 -4
- package/skills/softr-sdk/auth/SKILL.md +31 -129
- 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 +1 -1
- package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +12 -24
- 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 authRedirectTarget} 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,28 +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, or `home`. Anything but a path on this app is `home`.
|
|
95
|
-
*
|
|
96
|
-
* @example
|
|
97
|
-
* const target = authRedirectTarget(useLocation({ select: (l) => l.search }), "/")
|
|
98
|
-
*/
|
|
99
|
-
function authRedirectTarget(search, home) {
|
|
100
|
-
const target = search[softrConfig().auth.routes.login.redirectParam];
|
|
101
|
-
if (typeof target !== "string" || !target.startsWith("/")) return home;
|
|
102
|
-
return new URL(target, "https://app.invalid").origin === "https://app.invalid" ? target : home;
|
|
79
|
+
throw thrownFor(result.reason, void 0);
|
|
103
80
|
}
|
|
104
81
|
|
|
105
82
|
//#endregion
|
|
106
|
-
export { assertRouteAccess,
|
|
83
|
+
export { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
|
package/package.json
CHANGED
|
@@ -1,37 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softr/sdk",
|
|
3
|
-
"version": "0.0.64
|
|
3
|
+
"version": "0.0.64",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
|
-
".": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"./server": {
|
|
15
|
-
"types": "./dist/server.d.ts",
|
|
16
|
-
"import": "./dist/server.js"
|
|
17
|
-
},
|
|
18
|
-
"./vite": {
|
|
19
|
-
"types": "./dist/vite.d.ts",
|
|
20
|
-
"import": "./dist/vite.js"
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
"bin": {
|
|
24
|
-
"softr-sdk": "./dist/cli.js"
|
|
25
|
-
},
|
|
26
|
-
"files": [
|
|
27
|
-
"dist",
|
|
28
|
-
"schema",
|
|
29
|
-
"skills",
|
|
30
|
-
"!skills/_artifacts"
|
|
31
|
-
],
|
|
32
|
-
"publishConfig": {
|
|
33
|
-
"access": "public"
|
|
34
|
-
},
|
|
6
|
+
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
|
7
|
+
"./client": { "types": "./dist/client.d.ts", "import": "./dist/client.js" },
|
|
8
|
+
"./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js" },
|
|
9
|
+
"./vite": { "types": "./dist/vite.d.ts", "import": "./dist/vite.js" }
|
|
10
|
+
},
|
|
11
|
+
"bin": { "softr-sdk": "./dist/cli.js" },
|
|
12
|
+
"files": ["dist", "schema", "skills", "!skills/_artifacts"],
|
|
13
|
+
"publishConfig": { "access": "public" },
|
|
35
14
|
"scripts": {
|
|
36
15
|
"build": "tsdown",
|
|
37
16
|
"test": "node --import ./src/test-utils/setup.ts --test",
|
|
@@ -63,9 +42,7 @@
|
|
|
63
42
|
"optional": true
|
|
64
43
|
}
|
|
65
44
|
},
|
|
66
|
-
"keywords": [
|
|
67
|
-
"tanstack-intent"
|
|
68
|
-
],
|
|
45
|
+
"keywords": ["tanstack-intent"],
|
|
69
46
|
"intent": {
|
|
70
47
|
"version": 1,
|
|
71
48
|
"repo": "@softr/sdk",
|
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.64
|
|
14
|
+
library_version: 0.0.64
|
|
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 authRedirectTarget 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
|
|
19
|
+
library_version: 0.0.64
|
|
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,25 +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. `authRedirectTarget` from `@softr/sdk` reads it back:
|
|
206
|
-
|
|
207
|
-
```tsx
|
|
208
|
-
import { useLocation, useNavigate } from '@tanstack/react-router'
|
|
209
|
-
import { authRedirectTarget } from '@softr/sdk'
|
|
210
|
-
|
|
211
|
-
const target = authRedirectTarget(useLocation({ select: (l) => l.search }), '/')
|
|
212
|
-
const navigate = useNavigate()
|
|
213
|
-
|
|
214
|
-
// once the visitor is signed in
|
|
215
|
-
await navigate({ href: target })
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
It returns the page they wanted when it is a path on this app, and the `home` you pass otherwise. Follow its answer as is. Never read the param yourself: anyone can put a link to another site in it.
|
|
219
|
-
|
|
220
|
-
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.
|
|
221
|
-
|
|
222
144
|
## Core Patterns
|
|
223
145
|
|
|
224
146
|
### Sign in
|
|
@@ -240,27 +162,24 @@ const signIn = createServerFn({ method: 'POST' })
|
|
|
240
162
|
}
|
|
241
163
|
}
|
|
242
164
|
|
|
243
|
-
|
|
165
|
+
if (result.data.status === 'authenticatorRequired') {
|
|
166
|
+
throw redirect({ to: '/verify', search: { step: result.data.step } })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
throw redirect({ to: '/' })
|
|
244
170
|
})
|
|
245
171
|
```
|
|
246
172
|
|
|
247
|
-
|
|
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`:
|
|
248
176
|
|
|
249
177
|
```tsx
|
|
250
178
|
import { useServerFn } from '@tanstack/react-start'
|
|
251
179
|
|
|
252
180
|
const submit = useServerFn(signIn)
|
|
253
|
-
|
|
254
|
-
const answer = await submit({ data: { email, password } })
|
|
255
|
-
if (answer.message !== null) return setMessage(answer.message)
|
|
256
|
-
if (answer.outcome.status === 'authenticatorRequired') {
|
|
257
|
-
return setStep({ name: 'secondFactor', opensOn: answer.outcome.step })
|
|
258
|
-
}
|
|
259
|
-
await navigate({ href: target })
|
|
260
181
|
```
|
|
261
182
|
|
|
262
|
-
Three exits: a message for the form to render, the second-factor step, the return target.
|
|
263
|
-
|
|
264
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.
|
|
265
184
|
|
|
266
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).
|
|
@@ -302,25 +221,6 @@ const result = await auth.signInWithMagicLink({ token })
|
|
|
302
221
|
|
|
303
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.
|
|
304
223
|
|
|
305
|
-
### Set a password from an emailed link
|
|
306
|
-
|
|
307
|
-
A password reset and an invitation both email a link with a token. The same two calls redeem either.
|
|
308
|
-
|
|
309
|
-
```ts
|
|
310
|
-
// The forgot-password form. Succeeds whether or not the address has an account.
|
|
311
|
-
const sent = await auth.requestPasswordReset({ email })
|
|
312
|
-
|
|
313
|
-
// The loader of the page the link opens. Show an expired message on linkExpired, before any form.
|
|
314
|
-
const link = await auth.checkPasswordLink({ token })
|
|
315
|
-
|
|
316
|
-
// The form's submit. Same two-armed result as signIn.
|
|
317
|
-
const result = await auth.setPasswordWithLink({ token, password })
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
Say "check your email" after `requestPasswordReset` either way, and never branch the UI on it.
|
|
321
|
-
|
|
322
|
-
`setPasswordWithLink` uses up the link on any success, `authenticatorRequired` included, and signs the visitor out everywhere else.
|
|
323
|
-
|
|
324
224
|
### Sign out
|
|
325
225
|
|
|
326
226
|
```ts
|
|
@@ -349,8 +249,6 @@ const startSignUp = createServerFn({ method: 'POST' })
|
|
|
349
249
|
return { message: 'That email already has an account.' }
|
|
350
250
|
case 'invalidDetails':
|
|
351
251
|
return { message: 'Please check the details and try again.' }
|
|
352
|
-
case 'emailDomainNotAllowed':
|
|
353
|
-
return { message: 'Sign-up is open only to some email domains, and yours is not one.' }
|
|
354
252
|
case 'tooManyAttempts':
|
|
355
253
|
return { message: 'Too many attempts. Please try again in a few minutes.' }
|
|
356
254
|
default:
|
|
@@ -378,7 +276,11 @@ const finishSignUp = createServerFn({ method: 'POST' })
|
|
|
378
276
|
}
|
|
379
277
|
}
|
|
380
278
|
|
|
381
|
-
|
|
279
|
+
if (result.data.status === 'authenticatorRequired') {
|
|
280
|
+
throw redirect({ to: '/verify', search: { step: result.data.step } })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
throw redirect({ to: '/' })
|
|
382
284
|
})
|
|
383
285
|
```
|
|
384
286
|
|
|
@@ -405,7 +307,7 @@ const verify = createServerFn({ method: 'POST' })
|
|
|
405
307
|
return { message: 'Could not check that code. Please try again.' }
|
|
406
308
|
}
|
|
407
309
|
}
|
|
408
|
-
|
|
310
|
+
throw redirect({ to: '/' })
|
|
409
311
|
})
|
|
410
312
|
```
|
|
411
313
|
|
|
@@ -427,12 +329,11 @@ Neither of those enrols anybody. `confirmAuthenticatorSetup` does, once it accep
|
|
|
427
329
|
```ts
|
|
428
330
|
const done = await auth.confirmAuthenticatorSetup({ code })
|
|
429
331
|
if (done.success) {
|
|
430
|
-
|
|
332
|
+
done.data.backupCodes // returned once. Render them now, nothing fetches them again.
|
|
333
|
+
throw redirect({ to: '/' })
|
|
431
334
|
}
|
|
432
335
|
```
|
|
433
336
|
|
|
434
|
-
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.
|
|
435
|
-
|
|
436
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.
|
|
437
338
|
|
|
438
339
|
`skipAuthenticatorSetup` finishes the sign-in without enrolling, and only on `setupOptional`. Later sign-ins stop asking.
|
|
@@ -452,4 +353,5 @@ After a reset, enrol the new app with `startAuthenticatorSetup` and `confirmAuth
|
|
|
452
353
|
## References
|
|
453
354
|
|
|
454
355
|
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor these calls establish
|
|
455
|
-
- [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
|
|
15
|
+
library_version: 0.0.64
|
|
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.64
|
|
13
|
+
library_version: 0.0.64
|
|
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.64
|
|
13
|
+
library_version: 0.0.64
|
|
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.64
|
|
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.64
|
|
12
|
+
library_version: 0.0.64
|
|
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
|
-
`authRedirectTarget` returns only 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.64
|
|
12
|
+
library_version: 0.0.64
|
|
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.64
|
|
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.64
|
|
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.
|