@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.
@@ -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
- /** What the visitor may do, signed in or not. */
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 { DatasourcePermissions, Permissions } from "./permissions.js";
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, usePermissions };
55
+ export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser };
@@ -1,4 +1,4 @@
1
- import { assert, panic, requireDefined } from "../assertions.js";
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, usePermissions };
92
+ export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser };
@@ -19,4 +19,4 @@ type Permissions = {
19
19
  datasources: Record<string, DatasourcePermissions>;
20
20
  };
21
21
  //#endregion
22
- export { DatasourcePermissions, Permissions };
22
+ export { Permissions };
@@ -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, withSessionToken };
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 { defineVisibility, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
7
- import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
8
- import { softrMcpMiddleware } from "./mcp.js";
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
- export { type ActionPermissions, DefaultErrorOverlay, type FileAttachment, type FileItem, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, usePermissions, useUserTimezone };
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 { defineVisibility, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
4
- import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
5
- import { softrMcpMiddleware } from "./mcp.js";
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, softrMcpMiddleware, useCurrentUser, useFileUpload, usePermissions, useUserTimezone };
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
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.45-alpha.0";
2
+ var version = "0.0.45";
3
3
 
4
4
  //#endregion
5
5
  export { version };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.45-alpha.0",
3
+ "version": "0.0.45-alpha.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: core
13
13
  library: softr-sdk
14
- library_version: 0.0.43
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. A route file without a declaration is a lint error.
23
- > **CRITICAL**: The session wiring is already in place. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register `softrAuthMiddleware` and resolve the visitor. Do not add a second registration of it. `softrMcpMiddleware` is the one thing an app adds to `src/start.ts` itself — load the `softr-sdk/mcp` sub-skill before doing so.
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`, `useCurrentUser`, `usePermissions`, `useUserTimezone`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
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
- | Read the signed-in visitor or what they may do | [current-user/SKILL.md](./current-user/SKILL.md) |
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
- Showing the visitor's name, or hiding UI they cannot use?
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. `softrMcpMiddleware` is the exception: it is not wired by default and is added here when the app opens itself to AI assistants.
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.43
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. `defineVisibility`, the hooks and every type come from `@softr/sdk`. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
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.43
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. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
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