@softr/sdk 0.0.45-alpha.0 → 0.0.45-alpha.3

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.
Files changed (36) hide show
  1. package/dist/auth/context.d.ts +1 -1
  2. package/dist/auth/isomorphic.d.ts +2 -19
  3. package/dist/auth/isomorphic.js +2 -24
  4. package/dist/auth/permissions.d.ts +1 -1
  5. package/dist/auth/session.js +1 -15
  6. package/dist/boot-error.js +10 -5
  7. package/dist/datasource/chart.js +3 -3
  8. package/dist/datasource/record.js +13 -8
  9. package/dist/default-error-overlay.js +8 -3
  10. package/dist/index.d.ts +5 -4
  11. package/dist/index.js +5 -4
  12. package/dist/package.js +1 -1
  13. package/dist/server.js +1 -1
  14. package/dist/visibility/audience.d.ts +31 -0
  15. package/dist/visibility/audience.js +25 -0
  16. package/dist/visibility/route.d.ts +71 -0
  17. package/dist/visibility/route.js +83 -0
  18. package/dist/visibility/visibility.d.ts +76 -0
  19. package/dist/visibility/visibility.js +63 -0
  20. package/package.json +10 -33
  21. package/skills/softr-sdk/SKILL.md +11 -12
  22. package/skills/softr-sdk/auth/SKILL.md +2 -2
  23. package/skills/softr-sdk/charts-and-metrics/SKILL.md +2 -2
  24. package/skills/softr-sdk/current-user/SKILL.md +12 -31
  25. package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
  26. package/skills/softr-sdk/integrations/SKILL.md +2 -2
  27. package/skills/softr-sdk/record-mutations/SKILL.md +2 -2
  28. package/skills/softr-sdk/record-queries/SKILL.md +3 -3
  29. package/skills/softr-sdk/region-visibility/SKILL.md +63 -0
  30. package/skills/softr-sdk/routes-and-visibility/SKILL.md +33 -28
  31. package/skills/softr-sdk/workflows/SKILL.md +2 -2
  32. package/dist/mcp.d.ts +0 -36
  33. package/dist/mcp.js +0 -581
  34. package/dist/routes/visibility.d.ts +0 -142
  35. package/dist/routes/visibility.js +0 -115
  36. package/skills/softr-sdk/mcp/SKILL.md +0 -74
@@ -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 };
@@ -69,16 +69,21 @@ function errorPage(error) {
69
69
  setOpen(toggle.getAttribute('aria-expanded') === 'false')
70
70
  })
71
71
 
72
- document.getElementById('copy').addEventListener('click', async (event) => {
73
- const button = event.currentTarget
72
+ const copyStack = async () => {
74
73
  try {
75
74
  await navigator.clipboard.writeText(stack.textContent)
76
- button.dataset.copied = ''
77
- setTimeout(() => delete button.dataset.copied, 1500)
75
+ return true
78
76
  } catch {
79
- // Denied clipboard permission. Select it so Ctrl/Cmd+C works.
80
77
  getSelection().selectAllChildren(stack)
78
+ return document.execCommand('copy')
81
79
  }
80
+ }
81
+
82
+ document.getElementById('copy').addEventListener('click', async (event) => {
83
+ const button = event.currentTarget
84
+ if (!(await copyStack())) return
85
+ button.dataset.copied = ''
86
+ setTimeout(() => delete button.dataset.copied, 1500)
82
87
  })
83
88
  <\/script>` : ""
84
89
  });
@@ -4,7 +4,7 @@ import { readUserTimezone } from "../cookies.js";
4
4
  import { readSessionToken } from "../auth/session.js";
5
5
  import { INTERNAL_KIND, buildFilterCriteria, buildOrderBy, dealiasFilterCriteria } from "./filter.js";
6
6
  import { andAll, makeFieldSet, makeSortSet } from "./fields.js";
7
- import { dsUrl, resolveTarget } from "./record.js";
7
+ import { describeFailure, dsUrl, resolveTarget } from "./record.js";
8
8
 
9
9
  //#region src/datasource/chart.ts
10
10
  const COUNT_CNT = "COUNT_CNT";
@@ -202,7 +202,7 @@ async function executeChart(query, state) {
202
202
  },
203
203
  body: JSON.stringify(body)
204
204
  });
205
- if (!res.ok) throw new Error(`Softr datasource chart failed for action "${actionId}": ${res.status}`);
205
+ if (!res.ok) throw new Error(`datasource chart failed for action "${actionId}": ${await describeFailure(res)}`);
206
206
  const payload = await res.json();
207
207
  return {
208
208
  data: mapChartRows(columns, payload.data),
@@ -275,7 +275,7 @@ async function executeMetric(query, handle, filters) {
275
275
  },
276
276
  body: JSON.stringify(body)
277
277
  });
278
- if (!res.ok) throw new Error(`Softr datasource metric failed for action "${actionId}": ${res.status}`);
278
+ if (!res.ok) throw new Error(`datasource metric failed for action "${actionId}": ${await describeFailure(res)}`);
279
279
  const payload = await res.json();
280
280
  return {
281
281
  value: readMetricValue(responseKey, payload.data),
@@ -15,6 +15,11 @@ const dsUrl = (actionId) => {
15
15
  if (!applicationId) panic("SOFTR_APPLICATION_ID is not set.");
16
16
  return `${withoutTrailingSlash(datasourceApiUrl)}/v1/datasource/vibe/applications/${applicationId}/actions/${actionId}`;
17
17
  };
18
+ async function describeFailure(res) {
19
+ const body = await res.json().catch(() => null);
20
+ const message = typeof body === "object" && body !== null && "message" in body ? body.message : null;
21
+ return typeof message === "string" && message.length > 0 ? `${res.status} ${message}` : `${res.status}`;
22
+ }
18
23
  /**
19
24
  * Queries a list of records from the datasource.
20
25
  *
@@ -87,7 +92,7 @@ async function executeList(query, args) {
87
92
  }
88
93
  })
89
94
  });
90
- if (!res.ok) throw new Error(`Softr datasource list failed for "${actionId}": ${res.status}`);
95
+ if (!res.ok) throw new Error(`datasource list failed for "${actionId}": ${await describeFailure(res)}`);
91
96
  const payload = await res.json();
92
97
  const nextOffset = payload.offset < payload.total ? payload.offset : null;
93
98
  return {
@@ -153,7 +158,7 @@ async function executeGet(query, id, filters) {
153
158
  filterCriteria
154
159
  })
155
160
  });
156
- if (!res.ok) throw new Error(`Softr datasource get failed for action "${actionId}": ${res.status}`);
161
+ if (!res.ok) throw new Error(`datasource get failed for action "${actionId}": ${await describeFailure(res)}`);
157
162
  const contentLength = res.headers.get("Content-Length");
158
163
  if (contentLength && parseInt(contentLength, 10) === 0) return null;
159
164
  const record = await res.json();
@@ -182,7 +187,7 @@ async function createRecord(action, options) {
182
187
  },
183
188
  body: JSON.stringify({ fields: rawFields })
184
189
  });
185
- if (!res.ok) throw new Error(`Softr datasource create failed for action "${actionId}": ${res.status}`);
190
+ if (!res.ok) throw new Error(`datasource create failed for action "${actionId}": ${await describeFailure(res)}`);
186
191
  const record = await res.json();
187
192
  return {
188
193
  id: record.id,
@@ -199,7 +204,7 @@ async function createRecord(action, options) {
199
204
  * })
200
205
  */
201
206
  async function updateRecord(action, options) {
202
- if (!options.id) throw new Error(`Softr datasource update: id must not be empty`);
207
+ if (!options.id) throw new Error(`datasource update: id must not be empty`);
203
208
  const { actionId, select } = resolveTarget(action, "update");
204
209
  const data = options.data;
205
210
  const rawFields = dealiasData(data, select);
@@ -211,7 +216,7 @@ async function updateRecord(action, options) {
211
216
  },
212
217
  body: JSON.stringify({ fields: rawFields })
213
218
  });
214
- if (!res.ok) throw new Error(`Softr datasource update failed for action "${actionId}": ${res.status}`);
219
+ if (!res.ok) throw new Error(`datasource update failed for action "${actionId}": ${await describeFailure(res)}`);
215
220
  const record = await res.json();
216
221
  return {
217
222
  id: record.id,
@@ -230,7 +235,7 @@ async function deleteRecord(action, options) {
230
235
  method: "DELETE",
231
236
  headers: { Authorization: `Bearer ${readSessionToken()}` }
232
237
  });
233
- if (!res.ok) throw new Error(`Softr datasource delete failed for action "${actionId}": ${res.status}`);
238
+ if (!res.ok) throw new Error(`datasource delete failed for action "${actionId}": ${await describeFailure(res)}`);
234
239
  }
235
240
  function resolveTarget(subject, operation) {
236
241
  const declaredBy = operation === "read" ? "defineQuery" : "defineMutation";
@@ -244,7 +249,7 @@ function resolveTarget(subject, operation) {
244
249
  }
245
250
  function aliasFields(fields, aliases) {
246
251
  const result = {};
247
- for (const [alias, fieldId] of Object.entries(aliases)) result[alias] = fields[fieldId];
252
+ for (const [alias, fieldId] of Object.entries(aliases)) result[alias] = fields[fieldId] ?? null;
248
253
  return result;
249
254
  }
250
255
  function dealiasData(data, select) {
@@ -257,4 +262,4 @@ function withoutTrailingSlash(url) {
257
262
  }
258
263
 
259
264
  //#endregion
260
- export { createRecord, deleteRecord, dsUrl, getRecord, listRecords, resolveTarget, updateRecord };
265
+ export { createRecord, deleteRecord, describeFailure, dsUrl, getRecord, listRecords, resolveTarget, updateRecord };
@@ -65,15 +65,20 @@ function StackTrace({ stack }) {
65
65
  const [copied, setCopied] = useState(false);
66
66
  const ref = useRef(null);
67
67
  if (!cleaned) return null;
68
- async function copy() {
68
+ async function copyStack() {
69
69
  try {
70
70
  await navigator.clipboard.writeText(cleaned);
71
- setCopied(true);
72
- setTimeout(() => setCopied(false), 1500);
71
+ return true;
73
72
  } catch {
74
73
  if (ref.current) getSelection()?.selectAllChildren(ref.current);
74
+ return document.execCommand("copy");
75
75
  }
76
76
  }
77
+ async function copy() {
78
+ if (!await copyStack()) return;
79
+ setCopied(true);
80
+ setTimeout(() => setCopied(false), 1500);
81
+ }
77
82
  return /* @__PURE__ */ jsxs("div", {
78
83
  style: {
79
84
  display: "flex",
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-alpha.3";
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 };