@softr/sdk 0.0.45-alpha.0 → 0.0.45-alpha.1
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/context.d.ts +1 -1
- package/dist/auth/isomorphic.d.ts +2 -19
- package/dist/auth/isomorphic.js +2 -24
- package/dist/auth/permissions.d.ts +1 -1
- package/dist/auth/session.js +1 -15
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/package.js +1 -1
- package/dist/server.js +1 -1
- package/dist/visibility/audience.d.ts +31 -0
- package/dist/visibility/audience.js +25 -0
- package/dist/visibility/route.d.ts +71 -0
- package/dist/visibility/route.js +83 -0
- package/dist/visibility/visibility.d.ts +76 -0
- package/dist/visibility/visibility.js +63 -0
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +11 -12
- package/skills/softr-sdk/auth/SKILL.md +2 -2
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +2 -2
- package/skills/softr-sdk/current-user/SKILL.md +12 -31
- package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
- package/skills/softr-sdk/integrations/SKILL.md +2 -2
- package/skills/softr-sdk/record-mutations/SKILL.md +2 -2
- package/skills/softr-sdk/record-queries/SKILL.md +2 -2
- package/skills/softr-sdk/region-visibility/SKILL.md +63 -0
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +33 -28
- package/skills/softr-sdk/workflows/SKILL.md +2 -2
- package/dist/mcp.d.ts +0 -36
- package/dist/mcp.js +0 -581
- package/dist/routes/visibility.d.ts +0 -142
- package/dist/routes/visibility.js +0 -115
- package/skills/softr-sdk/mcp/SKILL.md +0 -74
package/dist/auth/context.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { Permissions } from "./permissions.js";
|
|
|
6
6
|
type Session = {
|
|
7
7
|
/** The visitor, or `null` when signed out. */
|
|
8
8
|
user: User | null;
|
|
9
|
-
/**
|
|
9
|
+
/** The visitor's global data restrictions. An escape hatch; the SDK already checks them. */
|
|
10
10
|
permissions: Permissions;
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { User } from "./user.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Permissions } from "./permissions.js";
|
|
3
3
|
import { Session, SoftrContext } from "./context.js";
|
|
4
4
|
import * as _tanstack_react_start0 from "@tanstack/react-start";
|
|
5
5
|
|
|
@@ -51,22 +51,5 @@ declare function initialSoftrContext(): SoftrContext;
|
|
|
51
51
|
* }
|
|
52
52
|
*/
|
|
53
53
|
declare function useCurrentUser(): User | null;
|
|
54
|
-
/**
|
|
55
|
-
* Resolves what the current user may do with one datasource.
|
|
56
|
-
*
|
|
57
|
-
* A rendering hint: every request is authorized again on the server, so never
|
|
58
|
-
* let this stand in for a permission check.
|
|
59
|
-
*
|
|
60
|
-
* Requires the root route to resolve {@link getSoftrContext}.
|
|
61
|
-
*
|
|
62
|
-
* @example
|
|
63
|
-
* function NewRecordButton() {
|
|
64
|
-
* const { create } = usePermissions(datasourceId)
|
|
65
|
-
* return create ? <button>New record</button> : null
|
|
66
|
-
* }
|
|
67
|
-
*
|
|
68
|
-
* @throws if the application has no such datasource.
|
|
69
|
-
*/
|
|
70
|
-
declare function usePermissions(datasourceId: string): DatasourcePermissions;
|
|
71
54
|
//#endregion
|
|
72
|
-
export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser
|
|
55
|
+
export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser };
|
package/dist/auth/isomorphic.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assert, panic
|
|
1
|
+
import { assert, panic } from "../assertions.js";
|
|
2
2
|
import { writeCookies } from "../cookies.js";
|
|
3
3
|
import { sessionConfig } from "./config.js";
|
|
4
4
|
import { clearSession, makeSessionDataCookie, readSessionData, readSessionToken } from "./session.js";
|
|
@@ -87,28 +87,6 @@ function useCurrentUser() {
|
|
|
87
87
|
select: (context) => context.user
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
|
-
/**
|
|
91
|
-
* Resolves what the current user may do with one datasource.
|
|
92
|
-
*
|
|
93
|
-
* A rendering hint: every request is authorized again on the server, so never
|
|
94
|
-
* let this stand in for a permission check.
|
|
95
|
-
*
|
|
96
|
-
* Requires the root route to resolve {@link getSoftrContext}.
|
|
97
|
-
*
|
|
98
|
-
* @example
|
|
99
|
-
* function NewRecordButton() {
|
|
100
|
-
* const { create } = usePermissions(datasourceId)
|
|
101
|
-
* return create ? <button>New record</button> : null
|
|
102
|
-
* }
|
|
103
|
-
*
|
|
104
|
-
* @throws if the application has no such datasource.
|
|
105
|
-
*/
|
|
106
|
-
function usePermissions(datasourceId) {
|
|
107
|
-
return useRouteContext({
|
|
108
|
-
from: rootRouteId,
|
|
109
|
-
select: (context) => requireDefined(context.permissions.datasources[datasourceId], `the application has no datasource "${datasourceId}"`)
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
90
|
|
|
113
91
|
//#endregion
|
|
114
|
-
export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser
|
|
92
|
+
export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser };
|
package/dist/auth/session.js
CHANGED
|
@@ -3,7 +3,6 @@ import { permissionsSchema } from "./permissions.js";
|
|
|
3
3
|
import { sessionConfig } from "./config.js";
|
|
4
4
|
import { userSchema } from "./user.js";
|
|
5
5
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
6
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
6
|
import { getRequestProtocol } from "@tanstack/react-start/server";
|
|
8
7
|
import { z } from "zod";
|
|
9
8
|
|
|
@@ -36,20 +35,8 @@ function sessionCookie(base) {
|
|
|
36
35
|
secure
|
|
37
36
|
};
|
|
38
37
|
}
|
|
39
|
-
/**
|
|
40
|
-
* Runs `fn` with `token` standing in for the request's session cookie, so that
|
|
41
|
-
* every datasource call inside it acts as that visitor.
|
|
42
|
-
*
|
|
43
|
-
* For requests that carry who they are somewhere other than the cookie: an MCP
|
|
44
|
-
* call authenticates with a bearer token and has no cookie at all.
|
|
45
|
-
*/
|
|
46
|
-
function withSessionToken(token, fn) {
|
|
47
|
-
return standInToken.run(token, fn);
|
|
48
|
-
}
|
|
49
38
|
/** The platform token this request carries, or `null`. */
|
|
50
39
|
function readSessionToken() {
|
|
51
|
-
const standIn = standInToken.getStore();
|
|
52
|
-
if (standIn !== void 0) return standIn;
|
|
53
40
|
const { name } = sessionCookie(SESSION_TOKEN);
|
|
54
41
|
return openEnvelope(readRequestCookies()[name], SESSION_TOKEN);
|
|
55
42
|
}
|
|
@@ -118,7 +105,6 @@ function clearSession() {
|
|
|
118
105
|
PENDING_AUTH
|
|
119
106
|
]);
|
|
120
107
|
}
|
|
121
|
-
const standInToken = new AsyncLocalStorage();
|
|
122
108
|
const sessionDataSchema = z.object({
|
|
123
109
|
user: userSchema.nullable(),
|
|
124
110
|
permissions: permissionsSchema,
|
|
@@ -210,4 +196,4 @@ function openEnvelope(raw, purpose) {
|
|
|
210
196
|
}
|
|
211
197
|
|
|
212
198
|
//#endregion
|
|
213
|
-
export { clearPendingAuth, clearSession, makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie, readPendingAuthToken, readSessionData, readSessionToken
|
|
199
|
+
export { clearPendingAuth, clearSession, makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie, readPendingAuthToken, readSessionData, readSessionToken };
|
package/dist/index.d.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { ActionPermissions, SoftrDatasources, defineIntegration, defineMutation,
|
|
|
3
3
|
import { FileAttachment } from "./core/file-upload.js";
|
|
4
4
|
import { FileItem, useFileUpload } from "./core/use-file-upload.js";
|
|
5
5
|
import { SoftrContext } from "./auth/context.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
6
|
+
import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
|
|
7
|
+
import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
|
|
8
|
+
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
|
|
9
9
|
import { useUserTimezone } from "./timezone.js";
|
|
10
10
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
11
|
-
|
|
11
|
+
import { Audience } from "./visibility/audience.js";
|
|
12
|
+
export { type ActionPermissions, Audience, DefaultErrorOverlay, type FileAttachment, type FileItem, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useFileUpload } from "./core/use-file-upload.js";
|
|
2
2
|
import { defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
|
|
4
|
+
import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
|
|
5
|
+
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
|
|
6
6
|
import { useUserTimezone } from "./timezone.js";
|
|
7
7
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
8
|
+
import { Audience } from "./visibility/audience.js";
|
|
8
9
|
|
|
9
|
-
export { DefaultErrorOverlay, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware,
|
|
10
|
+
export { Audience, DefaultErrorOverlay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
|
package/dist/package.js
CHANGED
package/dist/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { upload } from "./core/file-upload.js";
|
|
2
|
-
import { ds } from "./datasource/datasource.js";
|
|
3
2
|
import { auth } from "./auth/server.js";
|
|
3
|
+
import { ds } from "./datasource/datasource.js";
|
|
4
4
|
import { workflow } from "./core/workflow.js";
|
|
5
5
|
import { integration } from "./core/integration.js";
|
|
6
6
|
import "@tanstack/react-start/server-only";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Visibility } from "./visibility.js";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/visibility/audience.d.ts
|
|
5
|
+
type Props = {
|
|
6
|
+
visibility: Visibility;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
fallback?: ReactNode;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Renders `children` to the visitors a rule admits, and `fallback` to the rest.
|
|
12
|
+
*
|
|
13
|
+
* Use it to hide parts of the tree. The UI for visibility is accessible by the
|
|
14
|
+
* builder in Studio.
|
|
15
|
+
*
|
|
16
|
+
* Requires the root route to resolve {@link getSoftrContext}.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const membersOnly = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
20
|
+
*
|
|
21
|
+
* <Audience visibility={membersOnly} fallback={<Upgrade />}>
|
|
22
|
+
* <DangerZone />
|
|
23
|
+
* </Audience>
|
|
24
|
+
*/
|
|
25
|
+
declare function Audience({
|
|
26
|
+
visibility,
|
|
27
|
+
children,
|
|
28
|
+
fallback
|
|
29
|
+
}: Props): ReactNode;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { Audience };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { checkAccess } from "./visibility.js";
|
|
2
|
+
import { useCurrentUser } from "../auth/isomorphic.js";
|
|
3
|
+
|
|
4
|
+
//#region src/visibility/audience.tsx
|
|
5
|
+
/**
|
|
6
|
+
* Renders `children` to the visitors a rule admits, and `fallback` to the rest.
|
|
7
|
+
*
|
|
8
|
+
* Use it to hide parts of the tree. The UI for visibility is accessible by the
|
|
9
|
+
* builder in Studio.
|
|
10
|
+
*
|
|
11
|
+
* Requires the root route to resolve {@link getSoftrContext}.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const membersOnly = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
15
|
+
*
|
|
16
|
+
* <Audience visibility={membersOnly} fallback={<Upgrade />}>
|
|
17
|
+
* <DangerZone />
|
|
18
|
+
* </Audience>
|
|
19
|
+
*/
|
|
20
|
+
function Audience({ visibility, children, fallback = null }) {
|
|
21
|
+
return checkAccess(visibility, { user: useCurrentUser() }).ok ? children : fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
export { Audience };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { AccessContext, AccessReason, Narrowed, Visibility, VisibilityRule } from "./visibility.js";
|
|
2
|
+
|
|
3
|
+
//#region src/visibility/route.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a guard does when it denies a visitor: redirect to a path, or throw an
|
|
7
|
+
* {@link AccessError} for the route's `errorComponent` to render. A bare string
|
|
8
|
+
* is shorthand for redirecting there.
|
|
9
|
+
*/
|
|
10
|
+
type AccessBehaviour = string | {
|
|
11
|
+
strategy: "redirect";
|
|
12
|
+
to: string;
|
|
13
|
+
} | {
|
|
14
|
+
strategy: "throw";
|
|
15
|
+
};
|
|
16
|
+
/** Per-reason overrides of the default {@link AccessBehaviour}. */
|
|
17
|
+
type GuardOverrides = Partial<Record<AccessReason, AccessBehaviour>>;
|
|
18
|
+
/**
|
|
19
|
+
* The access error a guard throws. It is plain data, not an `Error` subclass, so
|
|
20
|
+
* take it as `unknown` in an `errorComponent` and narrow it with
|
|
21
|
+
* {@link isAccessError}. It carries the reason and nothing else: the error is
|
|
22
|
+
* serialized into the page, so it never names the groups the route wanted.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* errorComponent: ({ error }: { error: unknown }) =>
|
|
26
|
+
* isAccessError(error) ? <NoAccess /> : <AppError />
|
|
27
|
+
*/
|
|
28
|
+
type AccessError = {
|
|
29
|
+
type: "access_error";
|
|
30
|
+
reason: AccessReason;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* True for any access error thrown by a guard, whatever its reason.
|
|
34
|
+
*
|
|
35
|
+
* An access error arrives as `unknown` — hydrated from the page, or thrown by
|
|
36
|
+
* anything else at all — so every field is checked before it is narrowed.
|
|
37
|
+
*/
|
|
38
|
+
declare function isAccessError(error: unknown): error is AccessError;
|
|
39
|
+
/** True for an access error thrown because nobody was signed in. */
|
|
40
|
+
declare function isUnauthenticatedError(error: unknown): error is AccessError;
|
|
41
|
+
/** True for an access error thrown because the visitor was in none of the groups. */
|
|
42
|
+
declare function isForbiddenError(error: unknown): error is AccessError;
|
|
43
|
+
/** True for an access error thrown because a signed-out-only route saw a user. */
|
|
44
|
+
declare function isAlreadyAuthenticatedError(error: unknown): error is AccessError;
|
|
45
|
+
/**
|
|
46
|
+
* A `beforeLoad` handler that denies a visitor who fails the rule.
|
|
47
|
+
*
|
|
48
|
+
* Each reason falls back to the app's configured behaviour. Pass `overrides` to
|
|
49
|
+
* change what any one reason does.
|
|
50
|
+
*
|
|
51
|
+
* Throws a router redirect, so it belongs in `beforeLoad` and nowhere else.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* beforeLoad: guardRoute(visibility, { unauthenticated: "/sign-in" })
|
|
55
|
+
*/
|
|
56
|
+
declare function guardRoute(visibility: Visibility, overrides?: GuardOverrides): (opts: {
|
|
57
|
+
context: AccessContext;
|
|
58
|
+
}) => void;
|
|
59
|
+
/**
|
|
60
|
+
* Throws what {@link guardRoute} would, for a `beforeLoad` that runs its own
|
|
61
|
+
* checks after the rule. It takes no overrides.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* beforeLoad: ({ context, params }) => {
|
|
65
|
+
* assertRouteAccess(visibility, context)
|
|
66
|
+
* if (params.projectId === "demo") throw redirect({ to: "/projects" })
|
|
67
|
+
* }
|
|
68
|
+
*/
|
|
69
|
+
declare function assertRouteAccess<R extends VisibilityRule>(visibility: Visibility<R>, context: AccessContext): asserts context is Narrowed<R>;
|
|
70
|
+
//#endregion
|
|
71
|
+
export { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { panic } from "../assertions.js";
|
|
2
|
+
import { ACCESS_REASONS, checkAccess } from "./visibility.js";
|
|
3
|
+
import { redirect } from "@tanstack/react-router";
|
|
4
|
+
|
|
5
|
+
//#region src/visibility/route.ts
|
|
6
|
+
function isAccessReason(value) {
|
|
7
|
+
return ACCESS_REASONS.some((reason) => reason === value);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* True for any access error thrown by a guard, whatever its reason.
|
|
11
|
+
*
|
|
12
|
+
* An access error arrives as `unknown` — hydrated from the page, or thrown by
|
|
13
|
+
* anything else at all — so every field is checked before it is narrowed.
|
|
14
|
+
*/
|
|
15
|
+
function isAccessError(error) {
|
|
16
|
+
if (typeof error !== "object" || error === null) return false;
|
|
17
|
+
if (!("type" in error) || error.type !== "access_error") return false;
|
|
18
|
+
return "reason" in error && isAccessReason(error.reason);
|
|
19
|
+
}
|
|
20
|
+
function isReason(error, reason) {
|
|
21
|
+
return isAccessError(error) && error.reason === reason;
|
|
22
|
+
}
|
|
23
|
+
/** True for an access error thrown because nobody was signed in. */
|
|
24
|
+
function isUnauthenticatedError(error) {
|
|
25
|
+
return isReason(error, "unauthenticated");
|
|
26
|
+
}
|
|
27
|
+
/** True for an access error thrown because the visitor was in none of the groups. */
|
|
28
|
+
function isForbiddenError(error) {
|
|
29
|
+
return isReason(error, "forbidden");
|
|
30
|
+
}
|
|
31
|
+
/** True for an access error thrown because a signed-out-only route saw a user. */
|
|
32
|
+
function isAlreadyAuthenticatedError(error) {
|
|
33
|
+
return isReason(error, "alreadyAuthenticated");
|
|
34
|
+
}
|
|
35
|
+
/** What a guard throws for a reason: a redirect, or an {@link AccessError}. */
|
|
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]);
|
|
38
|
+
const resolved = typeof behaviour === "string" ? {
|
|
39
|
+
strategy: "redirect",
|
|
40
|
+
to: behaviour
|
|
41
|
+
} : behaviour;
|
|
42
|
+
if (resolved.strategy === "redirect") return redirect({ to: resolved.to });
|
|
43
|
+
return {
|
|
44
|
+
type: "access_error",
|
|
45
|
+
reason
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A `beforeLoad` handler that denies a visitor who fails the rule.
|
|
50
|
+
*
|
|
51
|
+
* Each reason falls back to the app's configured behaviour. Pass `overrides` to
|
|
52
|
+
* change what any one reason does.
|
|
53
|
+
*
|
|
54
|
+
* Throws a router redirect, so it belongs in `beforeLoad` and nowhere else.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* beforeLoad: guardRoute(visibility, { unauthenticated: "/sign-in" })
|
|
58
|
+
*/
|
|
59
|
+
function guardRoute(visibility, overrides) {
|
|
60
|
+
return ({ context }) => {
|
|
61
|
+
const result = checkAccess(visibility, context);
|
|
62
|
+
if (result.ok) return;
|
|
63
|
+
throw thrownFor(result.reason, overrides);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Throws what {@link guardRoute} would, for a `beforeLoad` that runs its own
|
|
68
|
+
* checks after the rule. It takes no overrides.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* beforeLoad: ({ context, params }) => {
|
|
72
|
+
* assertRouteAccess(visibility, context)
|
|
73
|
+
* if (params.projectId === "demo") throw redirect({ to: "/projects" })
|
|
74
|
+
* }
|
|
75
|
+
*/
|
|
76
|
+
function assertRouteAccess(visibility, context) {
|
|
77
|
+
const result = checkAccess(visibility, context);
|
|
78
|
+
if (result.ok) return;
|
|
79
|
+
throw thrownFor(result.reason, void 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//#endregion
|
|
83
|
+
export { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { SoftrUserGroupId, User } from "../auth/user.js";
|
|
2
|
+
import { SoftrContext } from "../auth/context.js";
|
|
3
|
+
|
|
4
|
+
//#region src/visibility/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 VisibilityRule = {
|
|
15
|
+
userGroup: "ALL_USERS";
|
|
16
|
+
} | {
|
|
17
|
+
userGroup: "NON_LOGGED_IN_USERS";
|
|
18
|
+
} | {
|
|
19
|
+
userGroup: "LOGGED_IN_USERS";
|
|
20
|
+
userGroupIds?: Array<SoftrUserGroupId>;
|
|
21
|
+
};
|
|
22
|
+
declare const visibilityBrand: unique symbol;
|
|
23
|
+
/** A rule {@link defineVisibility} has produced. */
|
|
24
|
+
type Visibility<R extends VisibilityRule = VisibilityRule> = R & {
|
|
25
|
+
readonly [visibilityBrand]: "visibility";
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Why a visitor was denied.
|
|
29
|
+
*
|
|
30
|
+
* - `unauthenticated` — the route needs a signed-in visitor and there is none.
|
|
31
|
+
* - `forbidden` — signed in, but in none of the groups the route names.
|
|
32
|
+
* - `alreadyAuthenticated` — the route is for signed-out visitors only.
|
|
33
|
+
*/
|
|
34
|
+
declare const ACCESS_REASONS: readonly ["unauthenticated", "forbidden", "alreadyAuthenticated"];
|
|
35
|
+
type AccessReason = (typeof ACCESS_REASONS)[number];
|
|
36
|
+
type AccessCheck = {
|
|
37
|
+
ok: true;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
reason: AccessReason;
|
|
41
|
+
};
|
|
42
|
+
/** The one field of {@link SoftrContext} every access check reads. */
|
|
43
|
+
type AccessContext = Pick<SoftrContext, "user">;
|
|
44
|
+
/** The decision and, when a visitor is denied, the reason. Throws nothing. */
|
|
45
|
+
declare function checkAccess(visibility: Visibility, context: AccessContext): AccessCheck;
|
|
46
|
+
type Narrowed<R extends VisibilityRule> = R extends {
|
|
47
|
+
userGroup: "LOGGED_IN_USERS";
|
|
48
|
+
} ? AccessContext & {
|
|
49
|
+
user: User;
|
|
50
|
+
} : R extends {
|
|
51
|
+
userGroup: "NON_LOGGED_IN_USERS";
|
|
52
|
+
} ? AccessContext & {
|
|
53
|
+
user: null;
|
|
54
|
+
} : AccessContext;
|
|
55
|
+
/**
|
|
56
|
+
* Declares who may see a route or a region of the page.
|
|
57
|
+
*
|
|
58
|
+
* At module scope in a route file, with a literal rule: the build reads these
|
|
59
|
+
* declarations out of the source to publish the app's route visibility.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const visibility = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
63
|
+
*
|
|
64
|
+
* export const Route = createFileRoute("/about")({
|
|
65
|
+
* beforeLoad: guardRoute(visibility),
|
|
66
|
+
* component: About,
|
|
67
|
+
* })
|
|
68
|
+
*/
|
|
69
|
+
declare function defineVisibility<const R extends VisibilityRule>(rule: R): Visibility<R>;
|
|
70
|
+
/**
|
|
71
|
+
* Whether the visitor passes the rule, narrowing `context.user` where the rule
|
|
72
|
+
* allows it. Throws nothing, so it is safe in a component.
|
|
73
|
+
*/
|
|
74
|
+
declare function isAuthorized<R extends VisibilityRule>(visibility: Visibility<R>, context: AccessContext): context is Narrowed<R>;
|
|
75
|
+
//#endregion
|
|
76
|
+
export { AccessContext, AccessReason, Narrowed, Visibility, VisibilityRule, checkAccess, defineVisibility, isAuthorized };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//#region src/visibility/visibility.ts
|
|
2
|
+
/**
|
|
3
|
+
* Why a visitor was denied.
|
|
4
|
+
*
|
|
5
|
+
* - `unauthenticated` — the route needs a signed-in visitor and there is none.
|
|
6
|
+
* - `forbidden` — signed in, but in none of the groups the route names.
|
|
7
|
+
* - `alreadyAuthenticated` — the route is for signed-out visitors only.
|
|
8
|
+
*/
|
|
9
|
+
const ACCESS_REASONS = [
|
|
10
|
+
"unauthenticated",
|
|
11
|
+
"forbidden",
|
|
12
|
+
"alreadyAuthenticated"
|
|
13
|
+
];
|
|
14
|
+
/** The decision and, when a visitor is denied, the reason. Throws nothing. */
|
|
15
|
+
function checkAccess(visibility, context) {
|
|
16
|
+
const { user } = context;
|
|
17
|
+
switch (visibility.userGroup) {
|
|
18
|
+
case "ALL_USERS": return { ok: true };
|
|
19
|
+
case "NON_LOGGED_IN_USERS": return user === null ? { ok: true } : {
|
|
20
|
+
ok: false,
|
|
21
|
+
reason: "alreadyAuthenticated"
|
|
22
|
+
};
|
|
23
|
+
case "LOGGED_IN_USERS": {
|
|
24
|
+
if (user === null) return {
|
|
25
|
+
ok: false,
|
|
26
|
+
reason: "unauthenticated"
|
|
27
|
+
};
|
|
28
|
+
const required = visibility.userGroupIds ?? [];
|
|
29
|
+
if (required.length === 0) return { ok: true };
|
|
30
|
+
return required.some((id) => user.userGroupIds.includes(id)) ? { ok: true } : {
|
|
31
|
+
ok: false,
|
|
32
|
+
reason: "forbidden"
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Declares who may see a route or a region of the page.
|
|
39
|
+
*
|
|
40
|
+
* At module scope in a route file, with a literal rule: the build reads these
|
|
41
|
+
* declarations out of the source to publish the app's route visibility.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* const visibility = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
45
|
+
*
|
|
46
|
+
* export const Route = createFileRoute("/about")({
|
|
47
|
+
* beforeLoad: guardRoute(visibility),
|
|
48
|
+
* component: About,
|
|
49
|
+
* })
|
|
50
|
+
*/
|
|
51
|
+
function defineVisibility(rule) {
|
|
52
|
+
return rule;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether the visitor passes the rule, narrowing `context.user` where the rule
|
|
56
|
+
* allows it. Throws nothing, so it is safe in a component.
|
|
57
|
+
*/
|
|
58
|
+
function isAuthorized(visibility, context) {
|
|
59
|
+
return checkAccess(visibility, context).ok;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
63
|
+
export { ACCESS_REASONS, checkAccess, defineVisibility, isAuthorized };
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
metadata:
|
|
12
12
|
type: core
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.
|
|
14
|
+
library_version: 0.0.45
|
|
15
15
|
---
|
|
16
16
|
|
|
17
17
|
# Softr SDK core
|
|
@@ -19,8 +19,8 @@ metadata:
|
|
|
19
19
|
`@softr/sdk` is how a Softr application talks to Softr. The visitor's identity, who may see a route, records in a datasource, signing in: all of it goes through this package. Everything else is an ordinary TanStack Start app. Routes, server functions, loaders and middleware are Start's, not Softr's.
|
|
20
20
|
|
|
21
21
|
> **CRITICAL**: The SDK is split across four entrypoints and the split is enforced. `ds` and `auth` exist only on `@softr/sdk/server` and only run inside a `createServerFn` handler. Importing them anywhere else fails the build.
|
|
22
|
-
> **CRITICAL**: Every route file must declare its audience with `defineVisibility`. There is no default and nothing is inherited from a sibling.
|
|
23
|
-
> **CRITICAL**: The
|
|
22
|
+
> **CRITICAL**: Every route file must declare its audience with `defineVisibility` and guard itself with that rule in `beforeLoad`. There is no default and nothing is inherited from a sibling.
|
|
23
|
+
> **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register the middleware and resolve the session. Do not add a second registration.
|
|
24
24
|
> **CRITICAL**: `workflow.trigger` lives on `@softr/sdk/server` and only runs inside a `createServerFn` handler. Use `defineWorkflow` from `@softr/sdk` to declare the action, then pass it to `workflow.trigger`. Load the `softr-sdk/workflows` sub-skill for the full pattern.
|
|
25
25
|
|
|
26
26
|
Each sub-skill stands alone for its task and repeats the boundary above, so most work needs **one** sub-skill and not this file. Load this one when you are unsure which sub-skill applies, or when the wiring below is what you are touching. Add a second sub-skill only when the work genuinely crosses that boundary: a page that lists records behind a group restriction needs `record-queries` plus `routes-and-visibility`.
|
|
@@ -29,7 +29,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
29
29
|
|
|
30
30
|
| Import | Runs | Holds |
|
|
31
31
|
| --- | --- | --- |
|
|
32
|
-
| `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `
|
|
32
|
+
| `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
|
|
33
33
|
| `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration` |
|
|
34
34
|
| `@softr/sdk/client` | Browser only | Nothing yet, reserved for browser-only utilities |
|
|
35
35
|
| `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
|
|
@@ -39,14 +39,14 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
39
39
|
| Task | Sub-Skill |
|
|
40
40
|
| --- | --- |
|
|
41
41
|
| Add a route, or change who can see one | [routes-and-visibility/SKILL.md](./routes-and-visibility/SKILL.md) |
|
|
42
|
-
|
|
|
42
|
+
| Show part of a page to some visitors only | [region-visibility/SKILL.md](./region-visibility/SKILL.md) |
|
|
43
|
+
| Read the signed-in visitor | [current-user/SKILL.md](./current-user/SKILL.md) |
|
|
43
44
|
| Fetch records from a datasource | [record-queries/SKILL.md](./record-queries/SKILL.md) |
|
|
44
45
|
| Chart, aggregate or count records | [charts-and-metrics/SKILL.md](./charts-and-metrics/SKILL.md) |
|
|
45
46
|
| Create, update or delete a record | [record-mutations/SKILL.md](./record-mutations/SKILL.md) |
|
|
46
47
|
| Trigger a Softr workflow | [workflows/SKILL.md](./workflows/SKILL.md) |
|
|
47
48
|
| Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
|
|
48
49
|
| Call a connected third-party API (Slack, Stripe, Gmail, …) | [integrations/SKILL.md](./integrations/SKILL.md) |
|
|
49
|
-
| Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
|
|
50
50
|
|
|
51
51
|
## Quick Decision Tree
|
|
52
52
|
|
|
@@ -54,7 +54,10 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
54
54
|
Adding a page, or changing who may see one?
|
|
55
55
|
load softr-sdk/routes-and-visibility
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
Hiding a region of a page from some visitors?
|
|
58
|
+
load softr-sdk/region-visibility
|
|
59
|
+
|
|
60
|
+
Showing the visitor's name, email or groups?
|
|
58
61
|
load softr-sdk/current-user
|
|
59
62
|
|
|
60
63
|
Putting records on a page, in a table, a list or a detail view?
|
|
@@ -75,16 +78,13 @@ Wiring a login, signup, or sign-out?
|
|
|
75
78
|
Calling a connected third-party API (Slack, Stripe, Gmail, Google Calendar, …)?
|
|
76
79
|
load softr-sdk/integrations
|
|
77
80
|
|
|
78
|
-
Opening the app to an AI assistant over MCP?
|
|
79
|
-
load softr-sdk/mcp
|
|
80
|
-
|
|
81
81
|
Writing a server function, loader, or middleware in general?
|
|
82
82
|
not a Softr concern. Use the TanStack Start skills.
|
|
83
83
|
```
|
|
84
84
|
|
|
85
85
|
## The wiring
|
|
86
86
|
|
|
87
|
-
Already present in the app. Read it to understand where the visitor comes from. Do not recreate it.
|
|
87
|
+
Already present in the app. Read it to understand where the visitor comes from. Do not recreate it.
|
|
88
88
|
|
|
89
89
|
```ts
|
|
90
90
|
// src/start.ts: resolves the visitor once per request
|
|
@@ -127,7 +127,6 @@ export function getRouter() {
|
|
|
127
127
|
```ts
|
|
128
128
|
type SoftrContext = {
|
|
129
129
|
user: User | null // null means signed out
|
|
130
|
-
permissions: { datasources: Record<string, DatasourcePermissions> }
|
|
131
130
|
}
|
|
132
131
|
|
|
133
132
|
type User = {
|
|
@@ -16,10 +16,10 @@ description: >-
|
|
|
16
16
|
metadata:
|
|
17
17
|
type: sub-skill
|
|
18
18
|
library: softr-sdk
|
|
19
|
-
library_version: 0.0.
|
|
19
|
+
library_version: 0.0.45
|
|
20
20
|
---
|
|
21
21
|
|
|
22
|
-
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
|
|
22
|
+
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
|
|
23
23
|
|
|
24
24
|
# Auth
|
|
25
25
|
|
|
@@ -12,10 +12,10 @@ description: >-
|
|
|
12
12
|
metadata:
|
|
13
13
|
type: sub-skill
|
|
14
14
|
library: softr-sdk
|
|
15
|
-
library_version: 0.0.
|
|
15
|
+
library_version: 0.0.45
|
|
16
16
|
---
|
|
17
17
|
|
|
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.
|
|
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.
|
|
19
19
|
|
|
20
20
|
# Charts and metrics
|
|
21
21
|
|