@softr/sdk 0.0.44 → 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 };
package/dist/index.d.ts CHANGED
@@ -3,8 +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";
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";
8
9
  import { useUserTimezone } from "./timezone.js";
9
10
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
10
- 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, 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,8 +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";
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";
5
6
  import { useUserTimezone } from "./timezone.js";
6
7
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
8
+ import { Audience } from "./visibility/audience.js";
7
9
 
8
- export { DefaultErrorOverlay, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, 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.44";
2
+ var version = "0.0.45";
3
3
 
4
4
  //#endregion
5
5
  export { version };
@@ -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,16 +1,37 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.44",
3
+ "version": "0.0.45-alpha.1",
4
4
  "type": "module",
5
5
  "exports": {
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" },
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "import": "./dist/index.js"
9
+ },
10
+ "./client": {
11
+ "types": "./dist/client.d.ts",
12
+ "import": "./dist/client.js"
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
+ },
14
35
  "scripts": {
15
36
  "build": "tsdown",
16
37
  "test": "node --import ./src/test-utils/setup.ts --test",
@@ -42,7 +63,9 @@
42
63
  "optional": true
43
64
  }
44
65
  },
45
- "keywords": ["tanstack-intent"],
66
+ "keywords": [
67
+ "tanstack-intent"
68
+ ],
46
69
  "intent": {
47
70
  "version": 1,
48
71
  "repo": "@softr/sdk",
@@ -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,7 +19,7 @@ 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.
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
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
 
@@ -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,7 +39,8 @@ 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) |
@@ -53,7 +54,10 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
53
54
  Adding a page, or changing who may see one?
54
55
  load softr-sdk/routes-and-visibility
55
56
 
56
- 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?
57
61
  load softr-sdk/current-user
58
62
 
59
63
  Putting records on a page, in a table, a list or a detail view?
@@ -123,7 +127,6 @@ export function getRouter() {
123
127
  ```ts
124
128
  type SoftrContext = {
125
129
  user: User | null // null means signed out
126
- permissions: { datasources: Record<string, DatasourcePermissions> }
127
130
  }
128
131
 
129
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
 
@@ -1,20 +1,17 @@
1
1
  ---
2
2
  name: current-user
3
3
  description: >-
4
- Read the signed-in Softr visitor and what they may do: useCurrentUser and the
5
- User type, usePermissions and DatasourcePermissions, context.user in a route's
6
- beforeLoad, and context.user inside a server function handler, where Start
7
- types it from the app's registered request middleware. Covers the null guest,
8
- SoftrUserGroups module augmentation for narrowing group ids, and why
9
- permissions are a rendering hint rather than an authorization check.
4
+ Read the signed-in Softr visitor: useCurrentUser and the User type,
5
+ context.user in a route's beforeLoad, and context.user inside a server
6
+ function handler, where Start types it from the app's registered request
7
+ middleware. Covers the null guest and the SoftrUserGroups module augmentation
8
+ that narrows group ids to your own.
10
9
  metadata:
11
10
  type: sub-skill
12
11
  library: softr-sdk
13
- library_version: 0.0.43
12
+ library_version: 0.0.45
14
13
  ---
15
14
 
16
- **Boundary.** `useCurrentUser`, `usePermissions` and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
17
-
18
15
  # The current user
19
16
 
20
17
  One visitor is resolved once per request and published three ways: as a hook in components, as route context in `beforeLoad`, and on the `context` argument inside a server function. All three read the same `SoftrContext`. Which one you reach for depends only on where the code runs.
@@ -24,7 +21,7 @@ One visitor is resolved once per request and published three ways: as a hook in
24
21
  Nothing to install or register, since the app is already wired. Import from the root entrypoint:
25
22
 
26
23
  ```ts
27
- import { useCurrentUser, usePermissions } from '@softr/sdk'
24
+ import { useCurrentUser } from '@softr/sdk'
28
25
  import type { User } from '@softr/sdk'
29
26
  ```
30
27
 
@@ -56,12 +53,12 @@ It returns `User | null` and never suspends or fetches. It selects off root rout
56
53
 
57
54
  ```tsx
58
55
  export const Route = createFileRoute('/_app/invoices')({
59
- beforeLoad: visibility.guard(),
56
+ beforeLoad: guardRoute(visibility),
60
57
  loader: ({ context }) => fetchInvoicesFor({ data: { userId: context.user!.id } }),
61
58
  })
62
59
  ```
63
60
 
64
- A `guard()` in `beforeLoad` guarantees a visitor at runtime, but it does not change what the compiler sees. `context.user` is still `User | null` further down the route. `visibility.isAuthorized(context)` is the check that narrows it, so prefer that over `!`. See [routes-and-visibility](../routes-and-visibility/SKILL.md).
61
+ A `guardRoute` in `beforeLoad` guarantees a visitor at runtime, but it does not change what the compiler sees. `context.user` is still `User | null` further down the route. `isAuthorized(visibility, context)` is the check that narrows it, so prefer that over `!`. See [routes-and-visibility](../routes-and-visibility/SKILL.md).
65
62
 
66
63
  ### In a server function: `context`
67
64
 
@@ -85,24 +82,7 @@ const getMyInvoices = createServerFn().handler(async ({ context }) => {
85
82
  })
86
83
  ```
87
84
 
88
- `context.user` and `context.permissions` are typed. Start derives a handler's context from the app's registered request middleware as well as the server function's own, so there is nothing to import and nothing to cast.
89
-
90
- ### Hiding UI the visitor cannot use
91
-
92
- ```tsx
93
- import { usePermissions } from '@softr/sdk'
94
-
95
- const INVOICES = '00000000-0000-0000-0000-000000000001' // the registered datasource's id
96
-
97
- function NewInvoiceButton() {
98
- const { create } = usePermissions(INVOICES)
99
- return create ? <button>New invoice</button> : null
100
- }
101
- ```
102
-
103
- `usePermissions(datasourceId)` returns `{ read, create, update, delete }` for one datasource. The id is the opaque UUID from the table's registration on the application, the same one `ds.*` takes — not the table id, and never built from the table's name. It hides a control. It does not protect what the control does.
104
-
105
- Pass an id the visitor's session has no entry for and it throws while rendering, so a typo takes the page down rather than returning four falses.
85
+ `context.user` is typed. Start derives a handler's context from the app's registered request middleware as well as the server function's own, so there is nothing to import and nothing to cast.
106
86
 
107
87
  ### Narrowing group ids to your own
108
88
 
@@ -122,6 +102,7 @@ With this declaration, `user.userGroupIds` and every `userGroupIds` the SDK acce
122
102
 
123
103
  ## References
124
104
 
125
- - [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `isAuthorized()` narrows `context.user` while applying the route's rule
105
+ - [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `isAuthorized` narrows `context.user` while applying the route's rule
106
+ - [region-visibility/SKILL.md](../region-visibility/SKILL.md): rendering a region only to the visitors a rule admits
126
107
  - [record-queries/SKILL.md](../record-queries/SKILL.md): filtering a query by the visitor, and who really authorizes the read
127
108
  - [auth/SKILL.md](../auth/SKILL.md): what changes the value `useCurrentUser` returns
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.43
13
+ library_version: 0.0.45
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.
@@ -9,10 +9,10 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.43
12
+ library_version: 0.0.45
13
13
  ---
14
14
 
15
- **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. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
15
+ **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.
16
16
 
17
17
  # Integrations
18
18
 
@@ -11,10 +11,10 @@ description: >-
11
11
  metadata:
12
12
  type: sub-skill
13
13
  library: softr-sdk
14
- library_version: 0.0.43
14
+ library_version: 0.0.45
15
15
  ---
16
16
 
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. 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.
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.
18
18
 
19
19
  # Writing records
20
20
 
@@ -13,10 +13,10 @@ description: >-
13
13
  metadata:
14
14
  type: sub-skill
15
15
  library: softr-sdk
16
- library_version: 0.0.43
16
+ library_version: 0.0.45
17
17
  ---
18
18
 
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. `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.
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. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
20
20
 
21
21
  # Reading records
22
22
 
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: region-visibility
3
+ description: >-
4
+ Show part of a page only to the visitors a rule admits: the Audience
5
+ component, its children and fallback, where a region's rule may be declared,
6
+ and why hiding a control is not authorization. Use when a button, a column, a
7
+ nav item or a panel should appear for some visitors and not others, on a page
8
+ everyone may open. Gating the whole route is routes-and-visibility.
9
+ metadata:
10
+ type: sub-skill
11
+ library: softr-sdk
12
+ library_version: 0.0.45
13
+ ---
14
+
15
+ **Boundary.** `Audience` and `defineVisibility` come from `@softr/sdk` and work anywhere.
16
+
17
+ # Region visibility
18
+
19
+ A route's rule decides who may open the page. A region's rule decides what they see once they are on it. The two are independent: an admin panel on a page every signed-in visitor may open is a region, and so is a refund button only staff should reach for.
20
+
21
+ ## Setup
22
+
23
+ Wrap the region in `Audience` and give it a rule:
24
+
25
+ ```tsx
26
+ import { Audience, defineVisibility } from '@softr/sdk'
27
+
28
+ const staffOnly = defineVisibility({
29
+ userGroup: 'LOGGED_IN_USERS',
30
+ userGroupIds: ['00000000-0000-0000-0000-00000000000a'],
31
+ })
32
+
33
+ function InvoiceActions({ invoice }: { invoice: Invoice }) {
34
+ return (
35
+ <Audience visibility={staffOnly} fallback={<p>Contact your account manager to refund.</p>}>
36
+ <RefundButton invoice={invoice} />
37
+ </Audience>
38
+ )
39
+ }
40
+ ```
41
+
42
+ `Audience` renders its children for the visitors the rule admits and `fallback` for everyone else. Without a `fallback` it renders nothing.
43
+
44
+ The rules are the same four a route uses, and [routes-and-visibility](../routes-and-visibility/SKILL.md) covers them.
45
+
46
+ ## Core Patterns
47
+
48
+ ### Where the rule is declared
49
+
50
+ A region's rule is an ordinary value. It can sit beside the component, or come from a module the whole app shares so several regions read the same one. A route's rule cannot. Softr reads that one out of the route file, so it has to be declared there.
51
+
52
+ Two files cannot hold a region's rule. Any declaration in `__root.tsx` is a build error. In the lazy half of a split route, Softr takes the first declaration as the route's own. Put the rule in the component's file or a shared module and import it.
53
+
54
+ A route file may declare a region's rule alongside its own. The route's rule is the one `beforeLoad` names.
55
+
56
+ ### Hiding a control is not authorization
57
+
58
+ `Audience` decides what to render and nothing else. The server function behind a hidden button is still reachable on its own, and Softr authorizes that request whatever the page rendered. If a region hides something sensitive, declare the action behind it with the permissions it should have.
59
+
60
+ ## References
61
+
62
+ - [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): the rule shapes, and gating a whole page
63
+ - [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor a rule has admitted
@@ -3,19 +3,20 @@ name: routes-and-visibility
3
3
  description: >-
4
4
  Add a route file to a Softr app and declare who may see it: defineVisibility
5
5
  with ALL_USERS, LOGGED_IN_USERS or NON_LOGGED_IN_USERS, userGroupIds, and the
6
- four ways to enforce a rule (guard, assert, isAuthorized, check). Covers
7
- per-reason overrides, the redirect and throw strategies, rendering an access
8
- error with isAccessError, isUnauthenticatedError, isForbiddenError and
9
- isAlreadyAuthenticatedError, guarding a layout, and the lint rule that
10
- requires a declaration alongside the rules that reject a duplicate, computed
11
- or root-level one.
6
+ ways to apply a rule: guardRoute and assertRouteAccess in a route,
7
+ isAuthorized and checkAccess anywhere. Covers per-reason overrides, the
8
+ redirect and throw strategies, rendering an access error with
9
+ isAccessError, isUnauthenticatedError, isForbiddenError and
10
+ isAlreadyAuthenticatedError, guarding a layout, and the lint rule that requires a
11
+ declaration alongside the rules that reject a duplicate, computed or
12
+ root-level one.
12
13
  metadata:
13
14
  type: sub-skill
14
15
  library: softr-sdk
15
- library_version: 0.0.43
16
+ library_version: 0.0.45
16
17
  ---
17
18
 
18
- **Boundary.** `defineVisibility`, the error predicates and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
19
+ **Boundary.** `defineVisibility`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
19
20
 
20
21
  # Route visibility
21
22
 
@@ -27,12 +28,12 @@ Every route file, without exception, looks like this:
27
28
 
28
29
  ```tsx
29
30
  import { createFileRoute } from '@tanstack/react-router'
30
- import { defineVisibility } from '@softr/sdk'
31
+ import { defineVisibility, guardRoute } from '@softr/sdk'
31
32
 
32
33
  const visibility = defineVisibility({ userGroup: 'LOGGED_IN_USERS' })
33
34
 
34
35
  export const Route = createFileRoute('/_app/billing')({
35
- beforeLoad: visibility.guard(),
36
+ beforeLoad: guardRoute(visibility),
36
37
  component: Billing,
37
38
  })
38
39
 
@@ -41,12 +42,13 @@ function Billing() {
41
42
  }
42
43
  ```
43
44
 
44
- One `defineVisibility` call per file, at module scope, with an inline object literal as its argument.
45
+ The route's rule is one `defineVisibility` call at module scope, with an inline object literal as its argument.
45
46
 
46
- Three rules about that shape, because Softr reads the file rather than running it:
47
+ Four constraints on that shape, because Softr reads the file rather than running it:
47
48
 
48
49
  - The call must initialise a top-level `const`.
49
50
  - The import must be a direct named import from `@softr/sdk`.
51
+ - The route guards itself in `beforeLoad`, naming a rule declared in the same file. A rule imported from elsewhere is rejected.
50
52
  - Never declare on `__root.tsx`. The root cannot carry an audience, so a declaration there would gate nothing while looking like it does. Softr rejects it. Put the rule on a layout below the root instead.
51
53
 
52
54
  The four rules:
@@ -70,28 +72,28 @@ Absent or empty `userGroupIds` means **any** signed-in visitor, not "nobody".
70
72
  ```tsx
71
73
  // src/routes/_app.tsx: guards every route under /_app
72
74
  import { Outlet, createFileRoute } from '@tanstack/react-router'
73
- import { defineVisibility } from '@softr/sdk'
75
+ import { defineVisibility, guardRoute } from '@softr/sdk'
74
76
 
75
77
  const visibility = defineVisibility({ userGroup: 'LOGGED_IN_USERS' })
76
78
 
77
79
  export const Route = createFileRoute('/_app')({
78
- beforeLoad: visibility.guard(),
80
+ beforeLoad: guardRoute(visibility),
79
81
  component: () => <Outlet />,
80
82
  })
81
83
  ```
82
84
 
83
- The whole match chain runs, so a layout guards everything beneath it. Sibling routes do not guard each other. `/admin/index.tsx` does nothing for `/admin/users.tsx` unless an `admin.tsx` layout exists. Routes below a guarded layout still declare their own rule, because the declaration is what Studio reads.
85
+ The whole match chain runs, so a layout guards everything beneath it. Sibling routes do not guard each other. `/admin/index.tsx` does nothing for `/admin/users.tsx` unless an `admin.tsx` layout exists. A route below a guarded layout declares and guards its own rule too.
84
86
 
85
87
  A thrown error renders in place of the guarded route's component. A shell layout guarded with `LOGGED_IN_USERS`, where `unauthenticated` redirects, sends a signed-out visitor away before anything renders there. A rule that can *throw* — a `userGroupIds` rule with `forbidden` set to throw — should not sit on the shell itself, or the error replaces the nav and chrome. Put it on a layout inside the shell, or on the restricted routes, so the error renders inside the chrome.
86
88
 
87
89
  ### Change what a denied visitor gets
88
90
 
89
- `guard()` takes per-reason overrides. A bare string is shorthand for a redirect.
91
+ `guardRoute` takes per-reason overrides as a second argument. A bare string is shorthand for a redirect.
90
92
 
91
93
  ```ts
92
- beforeLoad: visibility.guard({ unauthenticated: '/marketing/sign-up' }),
93
- beforeLoad: visibility.guard({ forbidden: { strategy: 'throw' } }),
94
- beforeLoad: visibility.guard({
94
+ beforeLoad: guardRoute(visibility, { unauthenticated: '/marketing/sign-up' }),
95
+ beforeLoad: guardRoute(visibility, { forbidden: { strategy: 'throw' } }),
96
+ beforeLoad: guardRoute(visibility, {
95
97
  unauthenticated: { strategy: 'redirect', to: '/marketing/sign-up' },
96
98
  forbidden: { strategy: 'throw' },
97
99
  }),
@@ -122,13 +124,13 @@ All three reasons are required, because nothing fills in a reason the file leave
122
124
  | `forbidden` | Signed in, in none of the named groups |
123
125
  | `alreadyAuthenticated` | Signed-in visitor on a signed-out-only route |
124
126
 
125
- That file covers every route at once; a route's own `guard()` overrides win over it. `ALL_USERS` admits everyone, so it never redirects and never throws. Only `strategy: 'throw'` reaches an `errorComponent`. A redirect navigates away and the route never renders.
127
+ That file covers every route at once; a route's own overrides win over it. `ALL_USERS` admits everyone, so it never redirects and never throws. Only `strategy: 'throw'` reaches an `errorComponent`. A redirect navigates away and the route never renders.
126
128
 
127
129
  ### Render the error in place
128
130
 
129
131
  ```tsx
130
132
  import { createFileRoute } from '@tanstack/react-router'
131
- import { defineVisibility, isAccessError, isForbiddenError } from '@softr/sdk'
133
+ import { defineVisibility, guardRoute, isAccessError, isForbiddenError } from '@softr/sdk'
132
134
 
133
135
  const visibility = defineVisibility({
134
136
  userGroup: 'LOGGED_IN_USERS',
@@ -136,7 +138,7 @@ const visibility = defineVisibility({
136
138
  })
137
139
 
138
140
  export const Route = createFileRoute('/_app/billing')({
139
- beforeLoad: visibility.guard(),
141
+ beforeLoad: guardRoute(visibility),
140
142
  component: Billing,
141
143
  errorComponent: ({ error }: { error: unknown }) => {
142
144
  if (isForbiddenError(error)) return <UpgradePrompt />
@@ -152,24 +154,27 @@ Use the predicates: `isAccessError` for any of the three, and `isUnauthenticated
152
154
 
153
155
  ### Run the check and then keep going
154
156
 
155
- `guard()` covers almost everything. When a route needs its own logic afterwards, `assert()` applies the same rule and throws the same access error, using `softr.json` for each reason. It takes no overrides.
157
+ `guardRoute` covers almost everything. When a route needs its own logic afterwards, `assertRouteAccess(visibility, context)` applies the same rule and throws the same access error, using the default behaviour for each reason. It takes no overrides.
156
158
 
157
- `assert()` guarantees the visitor at runtime but does not narrow `context.user` for the compiler. `isAuthorized()` is the one that narrows. It is a type predicate, so inside its `true` branch `context.user` is `User` under `LOGGED_IN_USERS` and `null` under `NON_LOGGED_IN_USERS`.
159
+ It also narrows `context.user` for the rest of the handler: `User` under `LOGGED_IN_USERS`, `null` under `NON_LOGGED_IN_USERS`. `guardRoute` cannot, because it hands `beforeLoad` a function rather than running inside one.
158
160
 
159
161
  ```ts
160
162
  import { redirect } from '@tanstack/react-router'
163
+ import { assertRouteAccess } from '@softr/sdk'
161
164
 
162
165
  beforeLoad: ({ context }) => {
163
- if (visibility.isAuthorized(context)) {
164
- if (context.user.email.endsWith('@example.com')) throw redirect({ to: '/internal' })
165
- }
166
+ assertRouteAccess(visibility, context)
167
+ if (context.user.email.endsWith('@example.com')) throw redirect({ to: '/internal' })
166
168
  },
167
169
  ```
168
170
 
169
- Reach for `assert()` when the route wants the error thrown for it, and `check(context)` when it needs to branch on why. `check` returns `{ ok: true } | { ok: false, reason }` and throws nothing. All of them are synchronous and make no request.
171
+ Reach for `assertRouteAccess` when the route wants the error thrown for it, `isAuthorized(visibility, context)` when it wants the same narrowing without a throw, and `checkAccess(visibility, context)` when it needs to branch on why. `checkAccess` returns `{ ok: true } | { ok: false, reason }` and throws nothing. All of them are synchronous and make no request.
172
+
173
+ `guardRoute` and `assertRouteAccess` throw router redirects, so they belong in a route and nowhere else. That is what `Route` in their names marks. `isAuthorized` and `checkAccess` throw nothing and answer anywhere, in a loader, a component or a server function.
170
174
 
171
175
  ## References
172
176
 
173
177
  - [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor a guard has just admitted
174
178
  - [record-queries/SKILL.md](../record-queries/SKILL.md): a guard covers the page, not the server function behind it
175
179
  - [auth/SKILL.md](../auth/SKILL.md): `NON_LOGGED_IN_USERS` and the sign-in flow it fronts
180
+ - [region-visibility/SKILL.md](../region-visibility/SKILL.md): showing part of a page to some of the visitors the route admits
@@ -9,10 +9,10 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.43
12
+ library_version: 0.0.45
13
13
  ---
14
14
 
15
- **Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
15
+ **Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.
16
16
 
17
17
  # Triggering workflows
18
18
 
@@ -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 };