@softr/sdk 0.0.87 → 0.0.88-signing.0
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/platform.js +1 -1
- package/dist/codegen/datasource-docs.js +3 -9
- package/dist/core/file-upload.js +1 -1
- package/dist/core/workflow.js +1 -1
- package/dist/datasource/record.js +1 -1
- package/dist/fetch.js +61 -1
- package/dist/locale-settings.js +1 -1
- package/dist/mcp.js +1 -1
- package/dist/package.js +1 -1
- package/dist/pwa.js +2 -2
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +3 -3
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
- package/skills/softr-sdk/current-user/SKILL.md +5 -9
- package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
- package/skills/softr-sdk/formatting/SKILL.md +1 -1
- package/skills/softr-sdk/integrations/SKILL.md +1 -1
- package/skills/softr-sdk/mcp/SKILL.md +1 -1
- package/skills/softr-sdk/pwa/SKILL.md +1 -1
- package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
- package/skills/softr-sdk/record-queries/SKILL.md +2 -2
- package/skills/softr-sdk/region-visibility/SKILL.md +5 -3
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +44 -85
- package/skills/softr-sdk/workflows/SKILL.md +1 -1
package/dist/auth/platform.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { assert, panic } from "../assertions.js";
|
|
2
|
+
import { softrApiUrl } from "../softr-api.js";
|
|
2
3
|
import { fetch } from "../fetch.js";
|
|
3
4
|
import { permissionsSchema } from "./permissions.js";
|
|
4
|
-
import { softrApiUrl } from "../softr-api.js";
|
|
5
5
|
import { softrConfig } from "../softr-config.js";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
|
|
@@ -73,16 +73,15 @@ function renderUserPropertiesDoc(users, context) {
|
|
|
73
73
|
"",
|
|
74
74
|
"```tsx",
|
|
75
75
|
"import { createFileRoute, redirect } from '@tanstack/react-router'",
|
|
76
|
-
"import { assertRouteAccess,
|
|
76
|
+
"import { assertRouteAccess, defineVisibility } from '@softr/sdk'",
|
|
77
77
|
"",
|
|
78
|
-
"const
|
|
78
|
+
"const visibility = defineVisibility({ userGroupTypes: ['LOGGED_IN_USERS'] })",
|
|
79
79
|
"",
|
|
80
80
|
"export const Route = createFileRoute('/example')({",
|
|
81
81
|
" beforeLoad: (opts) => {",
|
|
82
|
-
" assertRouteAccess(
|
|
82
|
+
" assertRouteAccess(visibility, opts)",
|
|
83
83
|
` if (opts.context.user.properties.${first} == null) throw redirect({ to: '/' })`,
|
|
84
84
|
" },",
|
|
85
|
-
" head: pageHead(pageSettings),",
|
|
86
85
|
"})",
|
|
87
86
|
"```",
|
|
88
87
|
"",
|
|
@@ -91,19 +90,14 @@ function renderUserPropertiesDoc(users, context) {
|
|
|
91
90
|
"```tsx",
|
|
92
91
|
"import { createFileRoute } from '@tanstack/react-router'",
|
|
93
92
|
"import { createServerFn } from '@tanstack/react-start'",
|
|
94
|
-
"import { definePage, guardRoute, pageHead } from '@softr/sdk'",
|
|
95
93
|
"",
|
|
96
94
|
`const get${pascalCase(first)} = createServerFn().handler(async ({ context }) => {`,
|
|
97
95
|
" if (!context.user) throw new Error('Not signed in')",
|
|
98
96
|
` return context.user.properties.${first}`,
|
|
99
97
|
"})",
|
|
100
98
|
"",
|
|
101
|
-
"const pageSettings = definePage({ visibility: { userGroupTypes: ['LOGGED_IN_USERS'] }, title: 'Example' })",
|
|
102
|
-
"",
|
|
103
99
|
"export const Route = createFileRoute('/example')({",
|
|
104
|
-
" beforeLoad: guardRoute(pageSettings),",
|
|
105
100
|
` loader: () => get${pascalCase(first)}(),`,
|
|
106
|
-
" head: pageHead(pageSettings),",
|
|
107
101
|
" component: Example,",
|
|
108
102
|
"})",
|
|
109
103
|
"",
|
package/dist/core/file-upload.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { softrApiUrl } from "../softr-api.js";
|
|
1
2
|
import { fetch } from "../fetch.js";
|
|
2
3
|
import { sessionConfig } from "../auth/config.js";
|
|
3
4
|
import { readSessionToken } from "../auth/session.js";
|
|
4
|
-
import { softrApiUrl } from "../softr-api.js";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
|
|
7
7
|
//#region src/core/file-upload.ts
|
package/dist/core/workflow.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { softrApiUrl } from "../softr-api.js";
|
|
1
2
|
import { fetch } from "../fetch.js";
|
|
2
3
|
import { sessionConfig } from "../auth/config.js";
|
|
3
4
|
import { readSessionToken } from "../auth/session.js";
|
|
4
|
-
import { softrApiUrl } from "../softr-api.js";
|
|
5
5
|
|
|
6
6
|
//#region src/core/workflow.ts
|
|
7
7
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { assert, panic } from "../assertions.js";
|
|
2
|
+
import { softrApiUrl } from "../softr-api.js";
|
|
2
3
|
import { fetch } from "../fetch.js";
|
|
3
4
|
import { readUserTimezone } from "../cookies.js";
|
|
4
5
|
import { readSessionToken } from "../auth/session.js";
|
|
5
|
-
import { softrApiUrl } from "../softr-api.js";
|
|
6
6
|
import { isDatasourceAction } from "../action.js";
|
|
7
7
|
import { buildFilterCriteria, buildOrderBy, dealiasFilterCriteria, dealiasOrderBy } from "./filter.js";
|
|
8
8
|
import { andAll, makeFieldSet, makeSortSet } from "./fields.js";
|
package/dist/fetch.js
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
|
+
import { version } from "./package.js";
|
|
2
|
+
import { assert, requireDefined } from "./assertions.js";
|
|
1
3
|
import { logger } from "./logger.js";
|
|
4
|
+
import { softrApiUrl } from "./softr-api.js";
|
|
2
5
|
|
|
3
6
|
//#region src/fetch.ts
|
|
4
7
|
const fetch = async (input, init) => {
|
|
5
|
-
if (process.env.NODE_ENV === "production") return globalThis.fetch(input, init);
|
|
6
8
|
const url = input instanceof Request ? input.url : String(input);
|
|
9
|
+
if ([
|
|
10
|
+
softrApiUrl("SOFTR_PLATFORM_API_URL"),
|
|
11
|
+
softrApiUrl("SOFTR_DATASOURCE_API_URL"),
|
|
12
|
+
softrApiUrl("SOFTR_WORKFLOWS_API_URL")
|
|
13
|
+
].some((base) => url.startsWith(`${base}/`))) {
|
|
14
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
|
|
15
|
+
headers.set("user-agent", `softr-sdk/${version}`);
|
|
16
|
+
init = {
|
|
17
|
+
...init,
|
|
18
|
+
headers
|
|
19
|
+
};
|
|
20
|
+
const signed = await sign(input, init);
|
|
21
|
+
if (signed) {
|
|
22
|
+
input = signed;
|
|
23
|
+
init = void 0;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (process.env.NODE_ENV === "production") return globalThis.fetch(input, init);
|
|
7
27
|
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
8
28
|
const started = Date.now();
|
|
9
29
|
try {
|
|
@@ -16,6 +36,46 @@ const fetch = async (input, init) => {
|
|
|
16
36
|
throw error;
|
|
17
37
|
}
|
|
18
38
|
};
|
|
39
|
+
async function sign(input, init) {
|
|
40
|
+
const key = process.env.SOFTR_REQUEST_SIGNING_KEY;
|
|
41
|
+
const keyId = process.env.SOFTR_REQUEST_SIGNING_KEY_ID;
|
|
42
|
+
if (!key || !keyId) return void 0;
|
|
43
|
+
assert(/^[0-9a-f]{64}$/i.test(key), "SOFTR_REQUEST_SIGNING_KEY must be 64 hex characters");
|
|
44
|
+
assert(!/["\\]/.test(keyId), "SOFTR_REQUEST_SIGNING_KEY_ID must not contain quotes or backslashes");
|
|
45
|
+
const request = new Request(input, init);
|
|
46
|
+
const headers = new Headers(request.headers);
|
|
47
|
+
const { host, pathname, search } = new URL(request.url);
|
|
48
|
+
const derived = {
|
|
49
|
+
"@method": request.method,
|
|
50
|
+
"@authority": host,
|
|
51
|
+
"@path": pathname,
|
|
52
|
+
"@query": search || "?"
|
|
53
|
+
};
|
|
54
|
+
const fields = Object.keys(derived);
|
|
55
|
+
const body = request.body ? new Uint8Array(await request.arrayBuffer()) : void 0;
|
|
56
|
+
if (body) {
|
|
57
|
+
headers.set("content-digest", `sha-256=:${base64(await crypto.subtle.digest("SHA-256", body))}:`);
|
|
58
|
+
fields.push("content-type", "content-digest");
|
|
59
|
+
}
|
|
60
|
+
if (headers.has("authorization")) fields.push("authorization");
|
|
61
|
+
const created = Math.floor(Date.now() / 1e3);
|
|
62
|
+
const params = `(${fields.map((field) => `"${field}"`).join(" ")});created=${created};keyid="${keyId}";tag="softr-code-app"`;
|
|
63
|
+
const base = [...fields.map((field) => `"${field}": ${derived[field] ?? requireDefined(headers.get(field), field)}`), `"@signature-params": ${params}`].join("\n");
|
|
64
|
+
const hmacKey = await crypto.subtle.importKey("raw", Uint8Array.from(key.match(/../g) ?? [], (byte) => parseInt(byte, 16)), {
|
|
65
|
+
name: "HMAC",
|
|
66
|
+
hash: "SHA-256"
|
|
67
|
+
}, false, ["sign"]);
|
|
68
|
+
const signature = base64(await crypto.subtle.sign("HMAC", hmacKey, new TextEncoder().encode(base)));
|
|
69
|
+
headers.set("signature-input", `softr=${params}`);
|
|
70
|
+
headers.set("signature", `softr=:${signature}:`);
|
|
71
|
+
return new Request(request, {
|
|
72
|
+
headers,
|
|
73
|
+
body
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function base64(bytes) {
|
|
77
|
+
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
|
78
|
+
}
|
|
19
79
|
|
|
20
80
|
//#endregion
|
|
21
81
|
export { fetch };
|
package/dist/locale-settings.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { logger } from "./logger.js";
|
|
2
|
+
import { softrApiUrl } from "./softr-api.js";
|
|
2
3
|
import { fetch } from "./fetch.js";
|
|
3
4
|
import { readRequestCookies, serializeCookie, writeCookies } from "./cookies.js";
|
|
4
5
|
import { sessionConfig } from "./auth/config.js";
|
|
5
|
-
import { softrApiUrl } from "./softr-api.js";
|
|
6
6
|
import { localeSettingsSchema } from "./formatting.js";
|
|
7
7
|
import { getRequestProtocol } from "@tanstack/react-start/server";
|
|
8
8
|
|
package/dist/mcp.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { assert } from "./assertions.js";
|
|
2
|
+
import { softrApiUrl } from "./softr-api.js";
|
|
2
3
|
import { fetch } from "./fetch.js";
|
|
3
4
|
import { sessionConfig } from "./auth/config.js";
|
|
4
5
|
import { readSessionToken, withSessionToken } from "./auth/session.js";
|
|
5
|
-
import { softrApiUrl } from "./softr-api.js";
|
|
6
6
|
import { isDatasourceAction } from "./action.js";
|
|
7
7
|
import { softrConfig } from "./softr-config.js";
|
|
8
8
|
import { makePlatform } from "./auth/platform.js";
|
package/dist/package.js
CHANGED
package/dist/pwa.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { logger } from "./logger.js";
|
|
2
|
+
import { softrApiUrl } from "./softr-api.js";
|
|
2
3
|
import { fetch } from "./fetch.js";
|
|
3
4
|
import { sessionConfig } from "./auth/config.js";
|
|
4
|
-
import { softrApiUrl } from "./softr-api.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { Fragment, createElement, useEffect, useState } from "react";
|
|
7
7
|
import { createMiddleware } from "@tanstack/react-start";
|
|
@@ -125,7 +125,7 @@ function usePwaInstall() {
|
|
|
125
125
|
useEffect(() => {
|
|
126
126
|
if (window.matchMedia("(display-mode: standalone)").matches || "standalone" in navigator && navigator.standalone === true) return;
|
|
127
127
|
let cancelled = false;
|
|
128
|
-
fetch(INSTALL_PATH, { headers: { accept: "application/json" } }).then(async (response) => {
|
|
128
|
+
globalThis.fetch(INSTALL_PATH, { headers: { accept: "application/json" } }).then(async (response) => {
|
|
129
129
|
if (!response.ok) return;
|
|
130
130
|
const parsed = installSchema.safeParse(await response.json());
|
|
131
131
|
if (!parsed.success || cancelled) return;
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ description: >-
|
|
|
8
8
|
metadata:
|
|
9
9
|
type: core
|
|
10
10
|
library: softr-sdk
|
|
11
|
-
library_version: 0.0.
|
|
11
|
+
library_version: 0.0.88-signing.0
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
# Softr SDK core
|
|
@@ -17,7 +17,7 @@ metadata:
|
|
|
17
17
|
|
|
18
18
|
> **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.
|
|
19
19
|
>
|
|
20
|
-
> **CRITICAL**: Every route file must declare its
|
|
20
|
+
> **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.
|
|
21
21
|
>
|
|
22
22
|
> **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register `softrAuthMiddleware` and resolve the session. Do not add a second registration of it. `softrMcpMiddleware` is the one thing an app adds to `src/start.ts` itself — load the `softr-sdk/mcp` sub-skill before doing so.
|
|
23
23
|
>
|
|
@@ -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`, `
|
|
32
|
+
| `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `FormattedDate`, `FormattingScope`, `useFormatting`, `formatDate`, `formatDateTime`, `formatNumber`, `formatValue`, `formatField`, `fieldShape`, `registerSoftrFields`, `useOverrides`, `SoftrPwaHead`, `usePwaInstall`, `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 | `useFileUpload` |
|
|
35
35
|
| `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.88-signing.0
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.88-signing.0
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**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.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.88-signing.0
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
# The current user
|
|
@@ -55,16 +55,12 @@ It returns `User | null` and never suspends or fetches. It selects off root rout
|
|
|
55
55
|
|
|
56
56
|
```tsx
|
|
57
57
|
export const Route = createFileRoute('/_app/invoices')({
|
|
58
|
-
beforeLoad: guardRoute(
|
|
59
|
-
loader: (
|
|
60
|
-
assertRouteAccess(pageSettings, opts)
|
|
61
|
-
return fetchInvoicesFor({ data: { userId: opts.context.user.id } })
|
|
62
|
-
},
|
|
63
|
-
head: pageHead(pageSettings),
|
|
58
|
+
beforeLoad: guardRoute(visibility),
|
|
59
|
+
loader: ({ context }) => fetchInvoicesFor({ data: { userId: context.user!.id } }),
|
|
64
60
|
})
|
|
65
61
|
```
|
|
66
62
|
|
|
67
|
-
A `guardRoute` in `beforeLoad` guarantees a visitor at runtime, but it does not change what the compiler sees. `context.user` is still `User | null`
|
|
63
|
+
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).
|
|
68
64
|
|
|
69
65
|
### In a server function: `context`
|
|
70
66
|
|
|
@@ -98,7 +94,7 @@ For every group's name and id, read `docs/user-groups.md`. It lists every group
|
|
|
98
94
|
|
|
99
95
|
## References
|
|
100
96
|
|
|
101
|
-
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `
|
|
97
|
+
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `isAuthorized` narrows `context.user` while applying the route's rule
|
|
102
98
|
- [region-visibility/SKILL.md](../region-visibility/SKILL.md): rendering a region only to the visitors a rule admits
|
|
103
99
|
- [record-queries/SKILL.md](../record-queries/SKILL.md): filtering a query by the visitor, and who really authorizes the read
|
|
104
100
|
- [auth/SKILL.md](../auth/SKILL.md): what changes the value `useCurrentUser` returns
|
|
@@ -7,7 +7,7 @@ description: >-
|
|
|
7
7
|
metadata:
|
|
8
8
|
type: sub-skill
|
|
9
9
|
library: softr-sdk
|
|
10
|
-
library_version: 0.0.
|
|
10
|
+
library_version: 0.0.88-signing.0
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
**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.
|
|
@@ -7,7 +7,7 @@ description: >-
|
|
|
7
7
|
metadata:
|
|
8
8
|
type: sub-skill
|
|
9
9
|
library: softr-sdk
|
|
10
|
-
library_version: 0.0.
|
|
10
|
+
library_version: 0.0.88-signing.0
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
**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.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.88-signing.0
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.
|
|
@@ -8,7 +8,7 @@ description: >-
|
|
|
8
8
|
metadata:
|
|
9
9
|
type: sub-skill
|
|
10
10
|
library: softr-sdk
|
|
11
|
-
library_version: 0.0.
|
|
11
|
+
library_version: 0.0.88-signing.0
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
**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.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.88-signing.0
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**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. The same holds for any helper that calls `ds`: declare it inside the handler. A function at module level that calls `ds` keeps the server-only import in the browser bundle, and every page stops loading until it moves. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -53,7 +53,7 @@ const publishedPosts = defineQuery({
|
|
|
53
53
|
- `{ userGroupTypes: ['NON_LOGGED_IN_USERS', 'LOGGED_IN_USERS'] }`: everyone, signed out included. `customGroups` narrows only the signed-in half.
|
|
54
54
|
- `{ userGroupTypes: ['NON_LOGGED_IN_USERS'] }`: signed-out visitors only.
|
|
55
55
|
- `{ userGroupTypes: ['LOGGED_IN_USERS'] }`: any signed-in visitor, narrowed to particular groups with `customGroups: ['00000000-0000-0000-0000-00000000000a']`. `customGroups` is added to `userGroupTypes`, never written in place of it.
|
|
56
|
-
- Write each group id as a literal in `permissions`. An id taken from a `const` is a lint error.
|
|
56
|
+
- Write each group id as a literal in `permissions`. An id taken from a `const` is a lint error, as it is in a route's visibility.
|
|
57
57
|
- Write the object inline in the `defineQuery` call. Passing the declaration or its `fields` through a variable, or spreading one in, widens the field ids to `string` and the types stop checking.
|
|
58
58
|
|
|
59
59
|
A query fixes the table and the columns, not the rows: filters, sorting and paging stay at the call site, because a list screen changes them as the user types. Declare as many queries over one datasource as the app needs, each carrying the fields its own screen touches.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.88-signing.0
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `Audience` and `defineVisibility` come from `@softr/sdk` and work anywhere.
|
|
@@ -47,9 +47,11 @@ The rules are the same four a route uses, and [routes-and-visibility](../routes-
|
|
|
47
47
|
|
|
48
48
|
### Where the rule is declared
|
|
49
49
|
|
|
50
|
-
A region's rule is an ordinary value. It can sit beside the component,
|
|
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
51
|
|
|
52
|
-
|
|
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.
|
|
53
55
|
|
|
54
56
|
### Hiding a control is not authorization
|
|
55
57
|
|
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: routes-and-visibility
|
|
3
3
|
description: >-
|
|
4
|
-
Add a route file and declare
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
lint rules that require the declaration.
|
|
4
|
+
Add a route file and declare who may see it: defineVisibility with
|
|
5
|
+
LOGGED_IN_USERS, NON_LOGGED_IN_USERS and customGroups, guardRoute and
|
|
6
|
+
assertRouteAccess in a route, isAuthorized and checkAccess anywhere, rendering
|
|
7
|
+
an access error, and the lint rule that requires the declaration.
|
|
9
8
|
metadata:
|
|
10
9
|
type: sub-skill
|
|
11
10
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
11
|
+
library_version: 0.0.88-signing.0
|
|
13
12
|
---
|
|
14
13
|
|
|
15
|
-
**Boundary.** `
|
|
14
|
+
**Boundary.** `defineVisibility`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
|
|
16
15
|
|
|
17
16
|
# Route visibility
|
|
18
17
|
|
|
19
|
-
Every route file in a Softr app declares
|
|
18
|
+
Every route file in a Softr app declares who may see it. Softr reads that declaration out of the source text without executing it and publishes it to Softr Studio, which shows the app owner every route and its audience. That is why the rules below are strict: a rule Softr cannot read is a rule Studio cannot show.
|
|
20
19
|
|
|
21
20
|
## Setup
|
|
22
21
|
|
|
@@ -24,16 +23,12 @@ Every route file, without exception, looks like this:
|
|
|
24
23
|
|
|
25
24
|
```tsx
|
|
26
25
|
import { createFileRoute } from '@tanstack/react-router'
|
|
27
|
-
import {
|
|
26
|
+
import { defineVisibility, guardRoute } from '@softr/sdk'
|
|
28
27
|
|
|
29
|
-
const
|
|
30
|
-
visibility: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
31
|
-
title: 'Billing',
|
|
32
|
-
})
|
|
28
|
+
const visibility = defineVisibility({ userGroupTypes: ['LOGGED_IN_USERS'] })
|
|
33
29
|
|
|
34
30
|
export const Route = createFileRoute('/_app/billing')({
|
|
35
|
-
beforeLoad: guardRoute(
|
|
36
|
-
head: pageHead(pageSettings),
|
|
31
|
+
beforeLoad: guardRoute(visibility),
|
|
37
32
|
component: Billing,
|
|
38
33
|
})
|
|
39
34
|
|
|
@@ -42,31 +37,30 @@ function Billing() {
|
|
|
42
37
|
}
|
|
43
38
|
```
|
|
44
39
|
|
|
45
|
-
The route's
|
|
40
|
+
The route's rule is one `defineVisibility` call at module scope, with an inline object literal as its argument.
|
|
46
41
|
|
|
47
|
-
|
|
42
|
+
Four constraints on that shape, because Softr reads the file rather than running it:
|
|
48
43
|
|
|
49
44
|
- The call must initialise a top-level `const`.
|
|
50
45
|
- The import must be a direct named import from `@softr/sdk`.
|
|
51
|
-
-
|
|
52
|
-
- Every value inside the literal is written there too. A group id taken from a `const`, a spread or an import
|
|
53
|
-
-
|
|
54
|
-
- Never state `visibility` on `__root.tsx`. The root cannot carry an audience, so a rule there would gate nothing while looking like it does. Its `definePage` holds the app-wide head defaults only. Put the rule on a layout below the root instead.
|
|
46
|
+
- The route guards itself in `beforeLoad`, naming a rule declared in the same file. A rule imported from elsewhere is rejected.
|
|
47
|
+
- Every value inside the literal is written there too. A group id taken from a `const`, a spread or an import is rejected, even when two routes share it: repeat the id on each.
|
|
48
|
+
- 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.
|
|
55
49
|
|
|
56
50
|
`userGroupTypes` is a set, so a rule names one audience or several:
|
|
57
51
|
|
|
58
52
|
```ts
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
defineVisibility({ userGroupTypes: ['NON_LOGGED_IN_USERS', 'LOGGED_IN_USERS'] }) // everyone, signed in or not
|
|
54
|
+
defineVisibility({ userGroupTypes: ['NON_LOGGED_IN_USERS'] }) // signed-out only: login, signup
|
|
55
|
+
defineVisibility({ userGroupTypes: ['LOGGED_IN_USERS'] }) // any signed-in visitor
|
|
56
|
+
defineVisibility({ // those groups only
|
|
63
57
|
userGroupTypes: ['LOGGED_IN_USERS'],
|
|
64
58
|
customGroups: ['00000000-0000-0000-0000-00000000000a'],
|
|
65
|
-
}
|
|
66
|
-
|
|
59
|
+
})
|
|
60
|
+
defineVisibility({ // signed-out visitors AND those groups
|
|
67
61
|
userGroupTypes: ['NON_LOGGED_IN_USERS', 'LOGGED_IN_USERS'],
|
|
68
62
|
customGroups: ['00000000-0000-0000-0000-00000000000a'],
|
|
69
|
-
}
|
|
63
|
+
})
|
|
70
64
|
```
|
|
71
65
|
|
|
72
66
|
Everyone is both audiences, as in the first line above. A set names each audience at most once and is never empty — the type and the build both reject one that does not. `customGroups` never stands alone: it narrows the `LOGGED_IN_USERS` half of the set, so the rule still names that half, and one without `userGroupTypes` is a type error. Absent or empty it means **any** signed-in visitor, not "nobody".
|
|
@@ -80,20 +74,17 @@ Take every id in `customGroups` from `docs/user-groups.md`, which lists each gro
|
|
|
80
74
|
```tsx
|
|
81
75
|
// src/routes/_app.tsx: guards every route under /_app
|
|
82
76
|
import { Outlet, createFileRoute } from '@tanstack/react-router'
|
|
83
|
-
import {
|
|
77
|
+
import { defineVisibility, guardRoute } from '@softr/sdk'
|
|
84
78
|
|
|
85
|
-
const
|
|
79
|
+
const visibility = defineVisibility({ userGroupTypes: ['LOGGED_IN_USERS'] })
|
|
86
80
|
|
|
87
81
|
export const Route = createFileRoute('/_app')({
|
|
88
|
-
beforeLoad: guardRoute(
|
|
89
|
-
head: pageHead(pageSettings),
|
|
82
|
+
beforeLoad: guardRoute(visibility),
|
|
90
83
|
component: () => <Outlet />,
|
|
91
84
|
})
|
|
92
85
|
```
|
|
93
86
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
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 settings too.
|
|
87
|
+
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.
|
|
97
88
|
|
|
98
89
|
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 `customGroups` 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.
|
|
99
90
|
|
|
@@ -102,9 +93,9 @@ A thrown error renders in place of the guarded route's component. A shell layout
|
|
|
102
93
|
`guardRoute` takes per-reason overrides as a second argument. A bare string is shorthand for a redirect.
|
|
103
94
|
|
|
104
95
|
```ts
|
|
105
|
-
beforeLoad: guardRoute(
|
|
106
|
-
beforeLoad: guardRoute(
|
|
107
|
-
beforeLoad: guardRoute(
|
|
96
|
+
beforeLoad: guardRoute(visibility, { unauthenticated: '/marketing/sign-up' }),
|
|
97
|
+
beforeLoad: guardRoute(visibility, { forbidden: { strategy: 'throw' } }),
|
|
98
|
+
beforeLoad: guardRoute(visibility, {
|
|
108
99
|
unauthenticated: { strategy: 'redirect', to: '/marketing/sign-up' },
|
|
109
100
|
forbidden: { strategy: 'throw' },
|
|
110
101
|
}),
|
|
@@ -151,19 +142,15 @@ That file covers every route at once; a route's own overrides win over it. A rul
|
|
|
151
142
|
|
|
152
143
|
```tsx
|
|
153
144
|
import { createFileRoute } from '@tanstack/react-router'
|
|
154
|
-
import {
|
|
145
|
+
import { defineVisibility, guardRoute, isAccessError, isForbiddenError } from '@softr/sdk'
|
|
155
146
|
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
customGroups: ['00000000-0000-0000-0000-00000000000a'],
|
|
160
|
-
},
|
|
161
|
-
title: 'Billing',
|
|
147
|
+
const visibility = defineVisibility({
|
|
148
|
+
userGroupTypes: ['LOGGED_IN_USERS'],
|
|
149
|
+
customGroups: ['00000000-0000-0000-0000-00000000000a'],
|
|
162
150
|
})
|
|
163
151
|
|
|
164
152
|
export const Route = createFileRoute('/_app/billing')({
|
|
165
|
-
beforeLoad: guardRoute(
|
|
166
|
-
head: pageHead(pageSettings),
|
|
153
|
+
beforeLoad: guardRoute(visibility),
|
|
167
154
|
component: Billing,
|
|
168
155
|
errorComponent: ({ error }: { error: unknown }) => {
|
|
169
156
|
if (isForbiddenError(error)) return <UpgradePrompt />
|
|
@@ -179,53 +166,25 @@ Use the predicates: `isAccessError` for any of the three, and `isUnauthenticated
|
|
|
179
166
|
|
|
180
167
|
### Run the check and then keep going
|
|
181
168
|
|
|
182
|
-
`guardRoute` covers almost everything. When a route needs its own logic afterwards, `assertRouteAccess(
|
|
169
|
+
`guardRoute` covers almost everything. When a route needs its own logic afterwards, `assertRouteAccess(visibility, opts)` applies the same rule to the whole `beforeLoad` argument and throws the same access error, using the default behaviour for each reason. It takes no overrides.
|
|
183
170
|
|
|
184
171
|
It also narrows `opts.context.user` for the rest of the handler, where the set names one audience and no other: `User` under `['LOGGED_IN_USERS']`, `null` under `['NON_LOGGED_IN_USERS']`. A set naming both admits either kind of visitor, so it narrows to neither. `guardRoute` cannot, because it hands `beforeLoad` a function rather than running inside one.
|
|
185
172
|
|
|
186
|
-
```
|
|
187
|
-
import {
|
|
188
|
-
import { assertRouteAccess
|
|
189
|
-
|
|
190
|
-
const pageSettings = definePage({
|
|
191
|
-
visibility: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
192
|
-
title: 'Reports',
|
|
193
|
-
})
|
|
173
|
+
```ts
|
|
174
|
+
import { redirect } from '@tanstack/react-router'
|
|
175
|
+
import { assertRouteAccess } from '@softr/sdk'
|
|
194
176
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
},
|
|
200
|
-
head: pageHead(pageSettings),
|
|
201
|
-
component: Reports,
|
|
202
|
-
})
|
|
177
|
+
beforeLoad: (opts) => {
|
|
178
|
+
assertRouteAccess(visibility, opts)
|
|
179
|
+
if (opts.context.user.email.endsWith('@example.com')) throw redirect({ to: '/internal' })
|
|
180
|
+
},
|
|
203
181
|
```
|
|
204
182
|
|
|
205
183
|
Take `opts` whole. A `context` destructured in the parameter list does not narrow.
|
|
206
184
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
### Check a rule without a throw
|
|
210
|
-
|
|
211
|
-
`isAuthorized(rule, context)` and `checkAccess(rule, context)` answer the same question and throw nothing, so they work anywhere: in a loader, a component or a server function. They take a rule from `defineVisibility`, not a page. Use `isAuthorized` for the same narrowing of `context.user` as `assertRouteAccess`, and `checkAccess` to branch on why: it returns `{ ok: true } | { ok: false, reason }`. All of them are synchronous and make no request.
|
|
212
|
-
|
|
213
|
-
```tsx
|
|
214
|
-
import { defineVisibility, isAuthorized, useCurrentUser } from '@softr/sdk'
|
|
215
|
-
|
|
216
|
-
const staffOnly = defineVisibility({
|
|
217
|
-
userGroupTypes: ['LOGGED_IN_USERS'],
|
|
218
|
-
customGroups: ['00000000-0000-0000-0000-00000000000a'],
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
function StaffNote() {
|
|
222
|
-
const context = { user: useCurrentUser() }
|
|
223
|
-
if (!isAuthorized(staffOnly, context)) return null
|
|
224
|
-
return <p>Signed in as staff: {context.user.email}</p>
|
|
225
|
-
}
|
|
226
|
-
```
|
|
185
|
+
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.
|
|
227
186
|
|
|
228
|
-
|
|
187
|
+
`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.
|
|
229
188
|
|
|
230
189
|
## References
|
|
231
190
|
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.88-signing.0
|
|
13
13
|
---
|
|
14
14
|
|
|
15
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.
|