@softr/sdk 0.0.45-alpha.0 → 0.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/context.d.ts +1 -1
- package/dist/auth/isomorphic.d.ts +2 -19
- package/dist/auth/isomorphic.js +2 -24
- package/dist/auth/permissions.d.ts +1 -1
- package/dist/auth/session.js +1 -15
- package/dist/datasource/chart.js +3 -3
- package/dist/datasource/record.js +12 -7
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/package.js +1 -1
- package/dist/server.js +1 -1
- package/dist/visibility/audience.d.ts +31 -0
- package/dist/visibility/audience.js +25 -0
- package/dist/visibility/route.d.ts +71 -0
- package/dist/visibility/route.js +83 -0
- package/dist/visibility/visibility.d.ts +76 -0
- package/dist/visibility/visibility.js +63 -0
- package/package.json +10 -33
- package/skills/softr-sdk/SKILL.md +11 -12
- package/skills/softr-sdk/auth/SKILL.md +2 -2
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +2 -2
- package/skills/softr-sdk/current-user/SKILL.md +12 -31
- package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
- package/skills/softr-sdk/integrations/SKILL.md +2 -2
- package/skills/softr-sdk/record-mutations/SKILL.md +2 -2
- package/skills/softr-sdk/record-queries/SKILL.md +3 -3
- package/skills/softr-sdk/region-visibility/SKILL.md +63 -0
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +33 -28
- package/skills/softr-sdk/workflows/SKILL.md +2 -2
- package/dist/mcp.d.ts +0 -36
- package/dist/mcp.js +0 -581
- package/dist/routes/visibility.d.ts +0 -142
- package/dist/routes/visibility.js +0 -115
- package/skills/softr-sdk/mcp/SKILL.md +0 -74
package/dist/auth/context.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { Permissions } from "./permissions.js";
|
|
|
6
6
|
type Session = {
|
|
7
7
|
/** The visitor, or `null` when signed out. */
|
|
8
8
|
user: User | null;
|
|
9
|
-
/**
|
|
9
|
+
/** The visitor's global data restrictions. An escape hatch; the SDK already checks them. */
|
|
10
10
|
permissions: Permissions;
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { User } from "./user.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Permissions } from "./permissions.js";
|
|
3
3
|
import { Session, SoftrContext } from "./context.js";
|
|
4
4
|
import * as _tanstack_react_start0 from "@tanstack/react-start";
|
|
5
5
|
|
|
@@ -51,22 +51,5 @@ declare function initialSoftrContext(): SoftrContext;
|
|
|
51
51
|
* }
|
|
52
52
|
*/
|
|
53
53
|
declare function useCurrentUser(): User | null;
|
|
54
|
-
/**
|
|
55
|
-
* Resolves what the current user may do with one datasource.
|
|
56
|
-
*
|
|
57
|
-
* A rendering hint: every request is authorized again on the server, so never
|
|
58
|
-
* let this stand in for a permission check.
|
|
59
|
-
*
|
|
60
|
-
* Requires the root route to resolve {@link getSoftrContext}.
|
|
61
|
-
*
|
|
62
|
-
* @example
|
|
63
|
-
* function NewRecordButton() {
|
|
64
|
-
* const { create } = usePermissions(datasourceId)
|
|
65
|
-
* return create ? <button>New record</button> : null
|
|
66
|
-
* }
|
|
67
|
-
*
|
|
68
|
-
* @throws if the application has no such datasource.
|
|
69
|
-
*/
|
|
70
|
-
declare function usePermissions(datasourceId: string): DatasourcePermissions;
|
|
71
54
|
//#endregion
|
|
72
|
-
export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser
|
|
55
|
+
export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser };
|
package/dist/auth/isomorphic.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assert, panic
|
|
1
|
+
import { assert, panic } from "../assertions.js";
|
|
2
2
|
import { writeCookies } from "../cookies.js";
|
|
3
3
|
import { sessionConfig } from "./config.js";
|
|
4
4
|
import { clearSession, makeSessionDataCookie, readSessionData, readSessionToken } from "./session.js";
|
|
@@ -87,28 +87,6 @@ function useCurrentUser() {
|
|
|
87
87
|
select: (context) => context.user
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
|
-
/**
|
|
91
|
-
* Resolves what the current user may do with one datasource.
|
|
92
|
-
*
|
|
93
|
-
* A rendering hint: every request is authorized again on the server, so never
|
|
94
|
-
* let this stand in for a permission check.
|
|
95
|
-
*
|
|
96
|
-
* Requires the root route to resolve {@link getSoftrContext}.
|
|
97
|
-
*
|
|
98
|
-
* @example
|
|
99
|
-
* function NewRecordButton() {
|
|
100
|
-
* const { create } = usePermissions(datasourceId)
|
|
101
|
-
* return create ? <button>New record</button> : null
|
|
102
|
-
* }
|
|
103
|
-
*
|
|
104
|
-
* @throws if the application has no such datasource.
|
|
105
|
-
*/
|
|
106
|
-
function usePermissions(datasourceId) {
|
|
107
|
-
return useRouteContext({
|
|
108
|
-
from: rootRouteId,
|
|
109
|
-
select: (context) => requireDefined(context.permissions.datasources[datasourceId], `the application has no datasource "${datasourceId}"`)
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
90
|
|
|
113
91
|
//#endregion
|
|
114
|
-
export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser
|
|
92
|
+
export { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser };
|
package/dist/auth/session.js
CHANGED
|
@@ -3,7 +3,6 @@ import { permissionsSchema } from "./permissions.js";
|
|
|
3
3
|
import { sessionConfig } from "./config.js";
|
|
4
4
|
import { userSchema } from "./user.js";
|
|
5
5
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
6
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
6
|
import { getRequestProtocol } from "@tanstack/react-start/server";
|
|
8
7
|
import { z } from "zod";
|
|
9
8
|
|
|
@@ -36,20 +35,8 @@ function sessionCookie(base) {
|
|
|
36
35
|
secure
|
|
37
36
|
};
|
|
38
37
|
}
|
|
39
|
-
/**
|
|
40
|
-
* Runs `fn` with `token` standing in for the request's session cookie, so that
|
|
41
|
-
* every datasource call inside it acts as that visitor.
|
|
42
|
-
*
|
|
43
|
-
* For requests that carry who they are somewhere other than the cookie: an MCP
|
|
44
|
-
* call authenticates with a bearer token and has no cookie at all.
|
|
45
|
-
*/
|
|
46
|
-
function withSessionToken(token, fn) {
|
|
47
|
-
return standInToken.run(token, fn);
|
|
48
|
-
}
|
|
49
38
|
/** The platform token this request carries, or `null`. */
|
|
50
39
|
function readSessionToken() {
|
|
51
|
-
const standIn = standInToken.getStore();
|
|
52
|
-
if (standIn !== void 0) return standIn;
|
|
53
40
|
const { name } = sessionCookie(SESSION_TOKEN);
|
|
54
41
|
return openEnvelope(readRequestCookies()[name], SESSION_TOKEN);
|
|
55
42
|
}
|
|
@@ -118,7 +105,6 @@ function clearSession() {
|
|
|
118
105
|
PENDING_AUTH
|
|
119
106
|
]);
|
|
120
107
|
}
|
|
121
|
-
const standInToken = new AsyncLocalStorage();
|
|
122
108
|
const sessionDataSchema = z.object({
|
|
123
109
|
user: userSchema.nullable(),
|
|
124
110
|
permissions: permissionsSchema,
|
|
@@ -210,4 +196,4 @@ function openEnvelope(raw, purpose) {
|
|
|
210
196
|
}
|
|
211
197
|
|
|
212
198
|
//#endregion
|
|
213
|
-
export { clearPendingAuth, clearSession, makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie, readPendingAuthToken, readSessionData, readSessionToken
|
|
199
|
+
export { clearPendingAuth, clearSession, makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie, readPendingAuthToken, readSessionData, readSessionToken };
|
package/dist/datasource/chart.js
CHANGED
|
@@ -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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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";
|
|
@@ -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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { ActionPermissions, SoftrDatasources, defineIntegration, defineMutation,
|
|
|
3
3
|
import { FileAttachment } from "./core/file-upload.js";
|
|
4
4
|
import { FileItem, useFileUpload } from "./core/use-file-upload.js";
|
|
5
5
|
import { SoftrContext } from "./auth/context.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
6
|
+
import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
|
|
7
|
+
import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
|
|
8
|
+
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
|
|
9
9
|
import { useUserTimezone } from "./timezone.js";
|
|
10
10
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
11
|
-
|
|
11
|
+
import { Audience } from "./visibility/audience.js";
|
|
12
|
+
export { type ActionPermissions, Audience, DefaultErrorOverlay, type FileAttachment, type FileItem, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useFileUpload } from "./core/use-file-upload.js";
|
|
2
2
|
import { defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
|
|
4
|
+
import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
|
|
5
|
+
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
|
|
6
6
|
import { useUserTimezone } from "./timezone.js";
|
|
7
7
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
8
|
+
import { Audience } from "./visibility/audience.js";
|
|
8
9
|
|
|
9
|
-
export { DefaultErrorOverlay, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware,
|
|
10
|
+
export { Audience, DefaultErrorOverlay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
|
package/dist/package.js
CHANGED
package/dist/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { upload } from "./core/file-upload.js";
|
|
2
|
-
import { ds } from "./datasource/datasource.js";
|
|
3
2
|
import { auth } from "./auth/server.js";
|
|
3
|
+
import { ds } from "./datasource/datasource.js";
|
|
4
4
|
import { workflow } from "./core/workflow.js";
|
|
5
5
|
import { integration } from "./core/integration.js";
|
|
6
6
|
import "@tanstack/react-start/server-only";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Visibility } from "./visibility.js";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
//#region src/visibility/audience.d.ts
|
|
5
|
+
type Props = {
|
|
6
|
+
visibility: Visibility;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
fallback?: ReactNode;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Renders `children` to the visitors a rule admits, and `fallback` to the rest.
|
|
12
|
+
*
|
|
13
|
+
* Use it to hide parts of the tree. The UI for visibility is accessible by the
|
|
14
|
+
* builder in Studio.
|
|
15
|
+
*
|
|
16
|
+
* Requires the root route to resolve {@link getSoftrContext}.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const membersOnly = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
20
|
+
*
|
|
21
|
+
* <Audience visibility={membersOnly} fallback={<Upgrade />}>
|
|
22
|
+
* <DangerZone />
|
|
23
|
+
* </Audience>
|
|
24
|
+
*/
|
|
25
|
+
declare function Audience({
|
|
26
|
+
visibility,
|
|
27
|
+
children,
|
|
28
|
+
fallback
|
|
29
|
+
}: Props): ReactNode;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { Audience };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { checkAccess } from "./visibility.js";
|
|
2
|
+
import { useCurrentUser } from "../auth/isomorphic.js";
|
|
3
|
+
|
|
4
|
+
//#region src/visibility/audience.tsx
|
|
5
|
+
/**
|
|
6
|
+
* Renders `children` to the visitors a rule admits, and `fallback` to the rest.
|
|
7
|
+
*
|
|
8
|
+
* Use it to hide parts of the tree. The UI for visibility is accessible by the
|
|
9
|
+
* builder in Studio.
|
|
10
|
+
*
|
|
11
|
+
* Requires the root route to resolve {@link getSoftrContext}.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const membersOnly = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
15
|
+
*
|
|
16
|
+
* <Audience visibility={membersOnly} fallback={<Upgrade />}>
|
|
17
|
+
* <DangerZone />
|
|
18
|
+
* </Audience>
|
|
19
|
+
*/
|
|
20
|
+
function Audience({ visibility, children, fallback = null }) {
|
|
21
|
+
return checkAccess(visibility, { user: useCurrentUser() }).ok ? children : fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
export { Audience };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { AccessContext, AccessReason, Narrowed, Visibility, VisibilityRule } from "./visibility.js";
|
|
2
|
+
|
|
3
|
+
//#region src/visibility/route.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a guard does when it denies a visitor: redirect to a path, or throw an
|
|
7
|
+
* {@link AccessError} for the route's `errorComponent` to render. A bare string
|
|
8
|
+
* is shorthand for redirecting there.
|
|
9
|
+
*/
|
|
10
|
+
type AccessBehaviour = string | {
|
|
11
|
+
strategy: "redirect";
|
|
12
|
+
to: string;
|
|
13
|
+
} | {
|
|
14
|
+
strategy: "throw";
|
|
15
|
+
};
|
|
16
|
+
/** Per-reason overrides of the default {@link AccessBehaviour}. */
|
|
17
|
+
type GuardOverrides = Partial<Record<AccessReason, AccessBehaviour>>;
|
|
18
|
+
/**
|
|
19
|
+
* The access error a guard throws. It is plain data, not an `Error` subclass, so
|
|
20
|
+
* take it as `unknown` in an `errorComponent` and narrow it with
|
|
21
|
+
* {@link isAccessError}. It carries the reason and nothing else: the error is
|
|
22
|
+
* serialized into the page, so it never names the groups the route wanted.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* errorComponent: ({ error }: { error: unknown }) =>
|
|
26
|
+
* isAccessError(error) ? <NoAccess /> : <AppError />
|
|
27
|
+
*/
|
|
28
|
+
type AccessError = {
|
|
29
|
+
type: "access_error";
|
|
30
|
+
reason: AccessReason;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* True for any access error thrown by a guard, whatever its reason.
|
|
34
|
+
*
|
|
35
|
+
* An access error arrives as `unknown` — hydrated from the page, or thrown by
|
|
36
|
+
* anything else at all — so every field is checked before it is narrowed.
|
|
37
|
+
*/
|
|
38
|
+
declare function isAccessError(error: unknown): error is AccessError;
|
|
39
|
+
/** True for an access error thrown because nobody was signed in. */
|
|
40
|
+
declare function isUnauthenticatedError(error: unknown): error is AccessError;
|
|
41
|
+
/** True for an access error thrown because the visitor was in none of the groups. */
|
|
42
|
+
declare function isForbiddenError(error: unknown): error is AccessError;
|
|
43
|
+
/** True for an access error thrown because a signed-out-only route saw a user. */
|
|
44
|
+
declare function isAlreadyAuthenticatedError(error: unknown): error is AccessError;
|
|
45
|
+
/**
|
|
46
|
+
* A `beforeLoad` handler that denies a visitor who fails the rule.
|
|
47
|
+
*
|
|
48
|
+
* Each reason falls back to the app's configured behaviour. Pass `overrides` to
|
|
49
|
+
* change what any one reason does.
|
|
50
|
+
*
|
|
51
|
+
* Throws a router redirect, so it belongs in `beforeLoad` and nowhere else.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* beforeLoad: guardRoute(visibility, { unauthenticated: "/sign-in" })
|
|
55
|
+
*/
|
|
56
|
+
declare function guardRoute(visibility: Visibility, overrides?: GuardOverrides): (opts: {
|
|
57
|
+
context: AccessContext;
|
|
58
|
+
}) => void;
|
|
59
|
+
/**
|
|
60
|
+
* Throws what {@link guardRoute} would, for a `beforeLoad` that runs its own
|
|
61
|
+
* checks after the rule. It takes no overrides.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* beforeLoad: ({ context, params }) => {
|
|
65
|
+
* assertRouteAccess(visibility, context)
|
|
66
|
+
* if (params.projectId === "demo") throw redirect({ to: "/projects" })
|
|
67
|
+
* }
|
|
68
|
+
*/
|
|
69
|
+
declare function assertRouteAccess<R extends VisibilityRule>(visibility: Visibility<R>, context: AccessContext): asserts context is Narrowed<R>;
|
|
70
|
+
//#endregion
|
|
71
|
+
export { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { panic } from "../assertions.js";
|
|
2
|
+
import { ACCESS_REASONS, checkAccess } from "./visibility.js";
|
|
3
|
+
import { redirect } from "@tanstack/react-router";
|
|
4
|
+
|
|
5
|
+
//#region src/visibility/route.ts
|
|
6
|
+
function isAccessReason(value) {
|
|
7
|
+
return ACCESS_REASONS.some((reason) => reason === value);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* True for any access error thrown by a guard, whatever its reason.
|
|
11
|
+
*
|
|
12
|
+
* An access error arrives as `unknown` — hydrated from the page, or thrown by
|
|
13
|
+
* anything else at all — so every field is checked before it is narrowed.
|
|
14
|
+
*/
|
|
15
|
+
function isAccessError(error) {
|
|
16
|
+
if (typeof error !== "object" || error === null) return false;
|
|
17
|
+
if (!("type" in error) || error.type !== "access_error") return false;
|
|
18
|
+
return "reason" in error && isAccessReason(error.reason);
|
|
19
|
+
}
|
|
20
|
+
function isReason(error, reason) {
|
|
21
|
+
return isAccessError(error) && error.reason === reason;
|
|
22
|
+
}
|
|
23
|
+
/** True for an access error thrown because nobody was signed in. */
|
|
24
|
+
function isUnauthenticatedError(error) {
|
|
25
|
+
return isReason(error, "unauthenticated");
|
|
26
|
+
}
|
|
27
|
+
/** True for an access error thrown because the visitor was in none of the groups. */
|
|
28
|
+
function isForbiddenError(error) {
|
|
29
|
+
return isReason(error, "forbidden");
|
|
30
|
+
}
|
|
31
|
+
/** True for an access error thrown because a signed-out-only route saw a user. */
|
|
32
|
+
function isAlreadyAuthenticatedError(error) {
|
|
33
|
+
return isReason(error, "alreadyAuthenticated");
|
|
34
|
+
}
|
|
35
|
+
/** What a guard throws for a reason: a redirect, or an {@link AccessError}. */
|
|
36
|
+
function thrownFor(reason, overrides) {
|
|
37
|
+
const behaviour = overrides?.[reason] ?? (typeof __SOFTR_CONFIG__ === "undefined" ? panic("softr.json is missing, so no guard behaviour is configured") : __SOFTR_CONFIG__.auth.guard[reason]);
|
|
38
|
+
const resolved = typeof behaviour === "string" ? {
|
|
39
|
+
strategy: "redirect",
|
|
40
|
+
to: behaviour
|
|
41
|
+
} : behaviour;
|
|
42
|
+
if (resolved.strategy === "redirect") return redirect({ to: resolved.to });
|
|
43
|
+
return {
|
|
44
|
+
type: "access_error",
|
|
45
|
+
reason
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A `beforeLoad` handler that denies a visitor who fails the rule.
|
|
50
|
+
*
|
|
51
|
+
* Each reason falls back to the app's configured behaviour. Pass `overrides` to
|
|
52
|
+
* change what any one reason does.
|
|
53
|
+
*
|
|
54
|
+
* Throws a router redirect, so it belongs in `beforeLoad` and nowhere else.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* beforeLoad: guardRoute(visibility, { unauthenticated: "/sign-in" })
|
|
58
|
+
*/
|
|
59
|
+
function guardRoute(visibility, overrides) {
|
|
60
|
+
return ({ context }) => {
|
|
61
|
+
const result = checkAccess(visibility, context);
|
|
62
|
+
if (result.ok) return;
|
|
63
|
+
throw thrownFor(result.reason, overrides);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Throws what {@link guardRoute} would, for a `beforeLoad` that runs its own
|
|
68
|
+
* checks after the rule. It takes no overrides.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* beforeLoad: ({ context, params }) => {
|
|
72
|
+
* assertRouteAccess(visibility, context)
|
|
73
|
+
* if (params.projectId === "demo") throw redirect({ to: "/projects" })
|
|
74
|
+
* }
|
|
75
|
+
*/
|
|
76
|
+
function assertRouteAccess(visibility, context) {
|
|
77
|
+
const result = checkAccess(visibility, context);
|
|
78
|
+
if (result.ok) return;
|
|
79
|
+
throw thrownFor(result.reason, void 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//#endregion
|
|
83
|
+
export { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { SoftrUserGroupId, User } from "../auth/user.js";
|
|
2
|
+
import { SoftrContext } from "../auth/context.js";
|
|
3
|
+
|
|
4
|
+
//#region src/visibility/visibility.d.ts
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Who may see a route.
|
|
8
|
+
*
|
|
9
|
+
* - `ALL_USERS` — everyone, signed in or not.
|
|
10
|
+
* - `NON_LOGGED_IN_USERS` — signed-out visitors only.
|
|
11
|
+
* - `LOGGED_IN_USERS` — signed-in visitors; with `userGroupIds`, only members of
|
|
12
|
+
* at least one of those groups.
|
|
13
|
+
*/
|
|
14
|
+
type VisibilityRule = {
|
|
15
|
+
userGroup: "ALL_USERS";
|
|
16
|
+
} | {
|
|
17
|
+
userGroup: "NON_LOGGED_IN_USERS";
|
|
18
|
+
} | {
|
|
19
|
+
userGroup: "LOGGED_IN_USERS";
|
|
20
|
+
userGroupIds?: Array<SoftrUserGroupId>;
|
|
21
|
+
};
|
|
22
|
+
declare const visibilityBrand: unique symbol;
|
|
23
|
+
/** A rule {@link defineVisibility} has produced. */
|
|
24
|
+
type Visibility<R extends VisibilityRule = VisibilityRule> = R & {
|
|
25
|
+
readonly [visibilityBrand]: "visibility";
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Why a visitor was denied.
|
|
29
|
+
*
|
|
30
|
+
* - `unauthenticated` — the route needs a signed-in visitor and there is none.
|
|
31
|
+
* - `forbidden` — signed in, but in none of the groups the route names.
|
|
32
|
+
* - `alreadyAuthenticated` — the route is for signed-out visitors only.
|
|
33
|
+
*/
|
|
34
|
+
declare const ACCESS_REASONS: readonly ["unauthenticated", "forbidden", "alreadyAuthenticated"];
|
|
35
|
+
type AccessReason = (typeof ACCESS_REASONS)[number];
|
|
36
|
+
type AccessCheck = {
|
|
37
|
+
ok: true;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
reason: AccessReason;
|
|
41
|
+
};
|
|
42
|
+
/** The one field of {@link SoftrContext} every access check reads. */
|
|
43
|
+
type AccessContext = Pick<SoftrContext, "user">;
|
|
44
|
+
/** The decision and, when a visitor is denied, the reason. Throws nothing. */
|
|
45
|
+
declare function checkAccess(visibility: Visibility, context: AccessContext): AccessCheck;
|
|
46
|
+
type Narrowed<R extends VisibilityRule> = R extends {
|
|
47
|
+
userGroup: "LOGGED_IN_USERS";
|
|
48
|
+
} ? AccessContext & {
|
|
49
|
+
user: User;
|
|
50
|
+
} : R extends {
|
|
51
|
+
userGroup: "NON_LOGGED_IN_USERS";
|
|
52
|
+
} ? AccessContext & {
|
|
53
|
+
user: null;
|
|
54
|
+
} : AccessContext;
|
|
55
|
+
/**
|
|
56
|
+
* Declares who may see a route or a region of the page.
|
|
57
|
+
*
|
|
58
|
+
* At module scope in a route file, with a literal rule: the build reads these
|
|
59
|
+
* declarations out of the source to publish the app's route visibility.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const visibility = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
63
|
+
*
|
|
64
|
+
* export const Route = createFileRoute("/about")({
|
|
65
|
+
* beforeLoad: guardRoute(visibility),
|
|
66
|
+
* component: About,
|
|
67
|
+
* })
|
|
68
|
+
*/
|
|
69
|
+
declare function defineVisibility<const R extends VisibilityRule>(rule: R): Visibility<R>;
|
|
70
|
+
/**
|
|
71
|
+
* Whether the visitor passes the rule, narrowing `context.user` where the rule
|
|
72
|
+
* allows it. Throws nothing, so it is safe in a component.
|
|
73
|
+
*/
|
|
74
|
+
declare function isAuthorized<R extends VisibilityRule>(visibility: Visibility<R>, context: AccessContext): context is Narrowed<R>;
|
|
75
|
+
//#endregion
|
|
76
|
+
export { AccessContext, AccessReason, Narrowed, Visibility, VisibilityRule, checkAccess, defineVisibility, isAuthorized };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//#region src/visibility/visibility.ts
|
|
2
|
+
/**
|
|
3
|
+
* Why a visitor was denied.
|
|
4
|
+
*
|
|
5
|
+
* - `unauthenticated` — the route needs a signed-in visitor and there is none.
|
|
6
|
+
* - `forbidden` — signed in, but in none of the groups the route names.
|
|
7
|
+
* - `alreadyAuthenticated` — the route is for signed-out visitors only.
|
|
8
|
+
*/
|
|
9
|
+
const ACCESS_REASONS = [
|
|
10
|
+
"unauthenticated",
|
|
11
|
+
"forbidden",
|
|
12
|
+
"alreadyAuthenticated"
|
|
13
|
+
];
|
|
14
|
+
/** The decision and, when a visitor is denied, the reason. Throws nothing. */
|
|
15
|
+
function checkAccess(visibility, context) {
|
|
16
|
+
const { user } = context;
|
|
17
|
+
switch (visibility.userGroup) {
|
|
18
|
+
case "ALL_USERS": return { ok: true };
|
|
19
|
+
case "NON_LOGGED_IN_USERS": return user === null ? { ok: true } : {
|
|
20
|
+
ok: false,
|
|
21
|
+
reason: "alreadyAuthenticated"
|
|
22
|
+
};
|
|
23
|
+
case "LOGGED_IN_USERS": {
|
|
24
|
+
if (user === null) return {
|
|
25
|
+
ok: false,
|
|
26
|
+
reason: "unauthenticated"
|
|
27
|
+
};
|
|
28
|
+
const required = visibility.userGroupIds ?? [];
|
|
29
|
+
if (required.length === 0) return { ok: true };
|
|
30
|
+
return required.some((id) => user.userGroupIds.includes(id)) ? { ok: true } : {
|
|
31
|
+
ok: false,
|
|
32
|
+
reason: "forbidden"
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Declares who may see a route or a region of the page.
|
|
39
|
+
*
|
|
40
|
+
* At module scope in a route file, with a literal rule: the build reads these
|
|
41
|
+
* declarations out of the source to publish the app's route visibility.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* const visibility = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
45
|
+
*
|
|
46
|
+
* export const Route = createFileRoute("/about")({
|
|
47
|
+
* beforeLoad: guardRoute(visibility),
|
|
48
|
+
* component: About,
|
|
49
|
+
* })
|
|
50
|
+
*/
|
|
51
|
+
function defineVisibility(rule) {
|
|
52
|
+
return rule;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether the visitor passes the rule, narrowing `context.user` where the rule
|
|
56
|
+
* allows it. Throws nothing, so it is safe in a component.
|
|
57
|
+
*/
|
|
58
|
+
function isAuthorized(visibility, context) {
|
|
59
|
+
return checkAccess(visibility, context).ok;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
63
|
+
export { ACCESS_REASONS, checkAccess, defineVisibility, isAuthorized };
|