@softr/sdk 0.0.45-alpha.0 → 0.0.45

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.
@@ -1,142 +0,0 @@
1
- import { SoftrUserGroupId, User } from "../auth/user.js";
2
- import { SoftrContext } from "../auth/context.js";
3
-
4
- //#region src/routes/visibility.d.ts
5
-
6
- /**
7
- * Who may see a route.
8
- *
9
- * - `ALL_USERS` — everyone, signed in or not.
10
- * - `NON_LOGGED_IN_USERS` — signed-out visitors only.
11
- * - `LOGGED_IN_USERS` — signed-in visitors; with `userGroupIds`, only members of
12
- * at least one of those groups.
13
- */
14
- type RouteVisibility = {
15
- userGroup: "ALL_USERS";
16
- } | {
17
- userGroup: "NON_LOGGED_IN_USERS";
18
- } | {
19
- userGroup: "LOGGED_IN_USERS";
20
- userGroupIds?: Array<SoftrUserGroupId>;
21
- };
22
- /**
23
- * Why a visitor was denied.
24
- *
25
- * - `unauthenticated` — the route needs a signed-in visitor and there is none.
26
- * - `forbidden` — signed in, but in none of the groups the route names.
27
- * - `alreadyAuthenticated` — the route is for signed-out visitors only.
28
- */
29
- declare const ACCESS_REASONS: readonly ["unauthenticated", "forbidden", "alreadyAuthenticated"];
30
- type AccessReason = (typeof ACCESS_REASONS)[number];
31
- /**
32
- * What a guard does when it refuses a visitor: redirect to a path, or throw an
33
- * {@link AccessError} for the route's `errorComponent` to render. A bare string
34
- * is shorthand for redirecting there.
35
- */
36
- type AccessBehaviour = string | {
37
- strategy: "redirect";
38
- to: string;
39
- } | {
40
- strategy: "throw";
41
- };
42
- /** Per-reason overrides of the default {@link AccessBehaviour}. */
43
- type GuardOverrides = Partial<Record<AccessReason, AccessBehaviour>>;
44
- /** The result of {@link VisibilityHandle.check}: passed, or denied with a reason. */
45
- type AccessCheck = {
46
- ok: true;
47
- } | {
48
- ok: false;
49
- reason: AccessReason;
50
- };
51
- /** The one field of {@link SoftrContext} every access check reads. */
52
- type AccessContext = Pick<SoftrContext, "user">;
53
- /** Decides whether a visitor passes a visibility rule, without throwing. */
54
-
55
- /**
56
- * The access error a guard throws. It is plain data, not an `Error` subclass, so
57
- * take it as `unknown` in an `errorComponent` and narrow it with
58
- * {@link isAccessError}. It carries the reason and nothing else: the error is
59
- * serialized into the page, so it never names the groups the route wanted.
60
- *
61
- * @example
62
- * errorComponent: ({ error }: { error: unknown }) =>
63
- * isAccessError(error) ? <NoAccess /> : <AppError />
64
- */
65
- type AccessError = {
66
- type: "access_error";
67
- reason: AccessReason;
68
- };
69
- /**
70
- * True for any access error thrown by a guard, whatever its reason.
71
- *
72
- * An access error arrives as `unknown` — hydrated from the page, or thrown by
73
- * anything else at all — so every field is checked before it is narrowed.
74
- */
75
- declare function isAccessError(error: unknown): error is AccessError;
76
- /** True for an access error thrown because nobody was signed in. */
77
- declare function isUnauthenticatedError(error: unknown): error is AccessError;
78
- /** True for an access error thrown because the visitor was in none of the groups. */
79
- declare function isForbiddenError(error: unknown): error is AccessError;
80
- /** True for an access error thrown because a signed-out-only route saw a user. */
81
- declare function isAlreadyAuthenticatedError(error: unknown): error is AccessError;
82
- type Narrowed<R extends RouteVisibility> = R extends {
83
- userGroup: "LOGGED_IN_USERS";
84
- } ? AccessContext & {
85
- user: User;
86
- } : R extends {
87
- userGroup: "NON_LOGGED_IN_USERS";
88
- } ? AccessContext & {
89
- user: null;
90
- } : AccessContext;
91
- /** The four ways to apply a rule, returned by {@link defineVisibility}. */
92
- type VisibilityHandle<R extends RouteVisibility = RouteVisibility> = {
93
- /**
94
- * A `beforeLoad` handler that denies a visitor who fails the rule.
95
- *
96
- * By default an unauthenticated visitor is redirected to `/login`, an already
97
- * signed-in one to `/`, and a forbidden one gets an {@link AccessError} thrown
98
- * in place, for the route's `errorComponent` to render. Pass `overrides` to
99
- * change what any one reason does.
100
- *
101
- * @example
102
- * beforeLoad: visibility.guard({ unauthenticated: "/sign-in" })
103
- */
104
- guard(overrides?: GuardOverrides): (opts: {
105
- context: AccessContext;
106
- }) => void;
107
- /**
108
- * Throws the same access error or redirect `guard()` would, for code that
109
- * needs to run its own checks after the rule.
110
- *
111
- * @example
112
- * beforeLoad: ({ context }) => {
113
- * visibility.assert(context)
114
- * if (context.user.trialExpired) throw redirect({ to: "/renew" })
115
- * }
116
- */
117
- assert(context: AccessContext): void;
118
- /**
119
- * Whether the visitor passes the rule. Narrows `context.user` where the rule
120
- * allows it.
121
- */
122
- isAuthorized(context: AccessContext): context is Narrowed<R>;
123
- /** The decision and, when a visitor is refused, the reason. Throws nothing. */
124
- check(context: AccessContext): AccessCheck;
125
- };
126
- /**
127
- * Declares who may see a route, and returns the handle that enforces it.
128
- *
129
- * One call per route file, at module scope: the build reads these declarations
130
- * to publish the app's route visibility, so the rule must be a literal.
131
- *
132
- * @example
133
- * const visibility = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
134
- *
135
- * export const Route = createFileRoute("/about")({
136
- * beforeLoad: visibility.guard(),
137
- * component: About,
138
- * })
139
- */
140
- declare function defineVisibility<const R extends RouteVisibility>(rule: R): VisibilityHandle<R>;
141
- //#endregion
142
- export { defineVisibility, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
@@ -1,115 +0,0 @@
1
- import { panic } from "../assertions.js";
2
- import { redirect } from "@tanstack/react-router";
3
-
4
- //#region src/routes/visibility.ts
5
- /**
6
- * Why a visitor was denied.
7
- *
8
- * - `unauthenticated` — the route needs a signed-in visitor and there is none.
9
- * - `forbidden` — signed in, but in none of the groups the route names.
10
- * - `alreadyAuthenticated` — the route is for signed-out visitors only.
11
- */
12
- const ACCESS_REASONS = [
13
- "unauthenticated",
14
- "forbidden",
15
- "alreadyAuthenticated"
16
- ];
17
- /** Decides whether a visitor passes a visibility rule, without throwing. */
18
- function checkAccess(rule, context) {
19
- const { user } = context;
20
- switch (rule.userGroup) {
21
- case "ALL_USERS": return { ok: true };
22
- case "NON_LOGGED_IN_USERS": return user === null ? { ok: true } : {
23
- ok: false,
24
- reason: "alreadyAuthenticated"
25
- };
26
- case "LOGGED_IN_USERS": {
27
- if (user === null) return {
28
- ok: false,
29
- reason: "unauthenticated"
30
- };
31
- const required = rule.userGroupIds ?? [];
32
- if (required.length === 0) return { ok: true };
33
- return required.some((id) => user.userGroupIds.includes(id)) ? { ok: true } : {
34
- ok: false,
35
- reason: "forbidden"
36
- };
37
- }
38
- }
39
- }
40
- function isAccessReason(value) {
41
- return ACCESS_REASONS.some((reason) => reason === value);
42
- }
43
- /**
44
- * True for any access error thrown by a guard, whatever its reason.
45
- *
46
- * An access error arrives as `unknown` — hydrated from the page, or thrown by
47
- * anything else at all — so every field is checked before it is narrowed.
48
- */
49
- function isAccessError(error) {
50
- if (typeof error !== "object" || error === null) return false;
51
- const candidate = error;
52
- if (candidate.type !== "access_error") return false;
53
- return isAccessReason(candidate.reason);
54
- }
55
- function isReason(error, reason) {
56
- return isAccessError(error) && error.reason === reason;
57
- }
58
- /** True for an access error thrown because nobody was signed in. */
59
- function isUnauthenticatedError(error) {
60
- return isReason(error, "unauthenticated");
61
- }
62
- /** True for an access error thrown because the visitor was in none of the groups. */
63
- function isForbiddenError(error) {
64
- return isReason(error, "forbidden");
65
- }
66
- /** True for an access error thrown because a signed-out-only route saw a user. */
67
- function isAlreadyAuthenticatedError(error) {
68
- return isReason(error, "alreadyAuthenticated");
69
- }
70
- /** What a guard throws for a reason: a redirect, or an {@link AccessError}. */
71
- function thrownFor(reason, overrides) {
72
- const behaviour = overrides?.[reason] ?? (typeof __SOFTR_CONFIG__ === "undefined" ? panic("softr.json is missing, so no guard behaviour is configured") : __SOFTR_CONFIG__.auth.guard[reason]);
73
- const resolved = typeof behaviour === "string" ? {
74
- strategy: "redirect",
75
- to: behaviour
76
- } : behaviour;
77
- if (resolved.strategy === "redirect") return redirect({ to: resolved.to });
78
- return {
79
- type: "access_error",
80
- reason
81
- };
82
- }
83
- /**
84
- * Declares who may see a route, and returns the handle that enforces it.
85
- *
86
- * One call per route file, at module scope: the build reads these declarations
87
- * to publish the app's route visibility, so the rule must be a literal.
88
- *
89
- * @example
90
- * const visibility = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
91
- *
92
- * export const Route = createFileRoute("/about")({
93
- * beforeLoad: visibility.guard(),
94
- * component: About,
95
- * })
96
- */
97
- function defineVisibility(rule) {
98
- return {
99
- guard: (overrides) => ({ context }) => {
100
- const result = checkAccess(rule, context);
101
- if (result.ok) return;
102
- throw thrownFor(result.reason, overrides);
103
- },
104
- assert: (context) => {
105
- const result = checkAccess(rule, context);
106
- if (result.ok) return;
107
- throw thrownFor(result.reason, void 0);
108
- },
109
- isAuthorized: (context) => checkAccess(rule, context).ok,
110
- check: (context) => checkAccess(rule, context)
111
- };
112
- }
113
-
114
- //#endregion
115
- export { defineVisibility, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
@@ -1,74 +0,0 @@
1
- ---
2
- name: mcp
3
- description: >-
4
- Open the app to AI assistants over MCP: softrMcpMiddleware({ actions }) from
5
- @softr/sdk, registered in src/start.ts ahead of softrAuthMiddleware, turns the
6
- actions you list into tools an assistant can call as the signed-in visitor.
7
- The list is explicit — an action left out is invisible over MCP. The app's own
8
- login must honour a `redirect` search param, or the approval step dead-ends.
9
- metadata:
10
- type: sub-skill
11
- library: softr-sdk
12
- library_version: 0.0.44
13
- ---
14
-
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.
16
-
17
- # MCP
18
-
19
- An app can answer an AI assistant on its own origin. The assistant signs in as an ordinary app user, and the actions you list become the tools it may call. Not wired by default — a new app serves no MCP endpoint until you add the middleware.
20
-
21
- ## Setup
22
-
23
- ```ts
24
- // src/start.ts
25
- import { createCsrfMiddleware, createStart } from '@tanstack/react-start'
26
- import { softrAuthMiddleware, softrMcpMiddleware } from '@softr/sdk'
27
- import { listCafes, addCafe } from './actions'
28
-
29
- export const startInstance = createStart(() => ({
30
- requestMiddleware: [
31
- createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn' }),
32
- softrMcpMiddleware({ actions: [listCafes, addCafe] }),
33
- softrAuthMiddleware(),
34
- ],
35
- }))
36
- ```
37
-
38
- Order is part of the contract. `softrMcpMiddleware` goes **before** `softrAuthMiddleware`: an assistant arrives with a token, not a browser session, so it must not hit the session lookup first.
39
-
40
- ## Choosing the actions
41
-
42
- The list is the whole tool surface. Write it out:
43
-
44
- - An action you leave out is invisible to the assistant, even where the app's pages use it freely.
45
- - An action you list still obeys its own `permissions`. Listing it does not widen who may run it — the assistant runs as whoever signed in, with that user's groups.
46
- - Two actions with the same `id` throw at startup. So does anything that is not a `defineQuery` or `defineMutation` value.
47
-
48
- Reads first. Add a write only when the user asked for one, and name it back to them.
49
-
50
- ## The login round trip
51
-
52
- 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 `/login` with a `redirect` search param holding where to come back to.
53
-
54
- **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.
55
-
56
- Validate it before following it. A path on this app and nothing else — not a full URL, not one starting `//`. The whole flow starts with a link someone was sent, so this is the one redirect an attacker can reliably aim.
57
-
58
- 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.
59
-
60
- ## Failure modes
61
-
62
- **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.
63
-
64
- **Registering after `softrAuthMiddleware`.** The assistant's token is never read. Put the MCP middleware first.
65
-
66
- **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.
67
-
68
- **Following `redirect` unchecked.** Anything that is not a path on this app is someone else's page. Reject it, fall back to `/`.
69
-
70
- ## References
71
-
72
- - [record-queries/SKILL.md](../record-queries/SKILL.md): declaring the read actions you expose
73
- - [record-mutations/SKILL.md](../record-mutations/SKILL.md): declaring the write actions you expose
74
- - [auth/SKILL.md](../auth/SKILL.md): the sign-in and second-factor flows the round trip passes through