@softr/sdk 0.0.50-alpha.1 → 0.0.51
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/action.d.ts +9 -10
- package/dist/auth/user.d.ts +12 -5
- package/dist/cli.js +9 -29
- package/dist/codegen/datasource-docs.js +2 -2
- package/dist/codegen/fetch-metadata.js +2 -2
- package/dist/codegen/softr-env.js +2 -4
- package/dist/codegen/user-groups.js +3 -24
- package/dist/codegen/write-datasource-docs.js +6 -8
- package/dist/datasource/fields.js +10 -2
- package/dist/package.js +1 -1
- package/dist/visibility/audience.d.ts +1 -1
- package/dist/visibility/audience.js +1 -1
- package/dist/visibility/visibility.d.ts +15 -20
- package/dist/visibility/visibility.js +16 -20
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +2 -2
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +2 -2
- package/skills/softr-sdk/current-user/SKILL.md +4 -13
- package/skills/softr-sdk/file-uploads/SKILL.md +2 -2
- package/skills/softr-sdk/integrations/SKILL.md +4 -4
- package/skills/softr-sdk/mcp/SKILL.md +1 -1
- package/skills/softr-sdk/record-mutations/SKILL.md +6 -6
- package/skills/softr-sdk/record-queries/SKILL.md +7 -7
- package/skills/softr-sdk/region-visibility/SKILL.md +3 -3
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +30 -25
- package/skills/softr-sdk/workflows/SKILL.md +2 -2
- package/dist/codegen/write-if-changed.js +0 -22
package/dist/action.d.ts
CHANGED
|
@@ -3,12 +3,11 @@ import { SoftrUserGroupId } from "./auth/user.js";
|
|
|
3
3
|
//#region src/action.d.ts
|
|
4
4
|
type ActionOperation = "read" | "create" | "update" | "delete";
|
|
5
5
|
type ActionPermissions = {
|
|
6
|
-
|
|
6
|
+
userGroupTypes: readonly ["NON_LOGGED_IN_USERS"];
|
|
7
|
+
customGroups?: never;
|
|
7
8
|
} | {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
userGroup: "LOGGED_IN_USERS";
|
|
11
|
-
userGroupIds?: Array<SoftrUserGroupId>;
|
|
9
|
+
userGroupTypes: readonly ["LOGGED_IN_USERS"] | readonly ["NON_LOGGED_IN_USERS", "LOGGED_IN_USERS"] | readonly ["LOGGED_IN_USERS", "NON_LOGGED_IN_USERS"];
|
|
10
|
+
customGroups?: ReadonlyArray<SoftrUserGroupId>;
|
|
12
11
|
};
|
|
13
12
|
type Action<TResource> = {
|
|
14
13
|
id: string;
|
|
@@ -109,7 +108,7 @@ type DeleteDeclaration<TId extends string, TDatasource$1> = {
|
|
|
109
108
|
* id: "publishedPosts",
|
|
110
109
|
* datasourceId: "8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77",
|
|
111
110
|
* fields: { title: "V1StGXR8", author: "Z7pQm4Kd", publishedAt: "Xp2dLh6v" },
|
|
112
|
-
* permissions: {
|
|
111
|
+
* permissions: { userGroupTypes: ["NON_LOGGED_IN_USERS", "LOGGED_IN_USERS"] },
|
|
113
112
|
* })
|
|
114
113
|
*
|
|
115
114
|
* const { records } = await ds
|
|
@@ -135,7 +134,7 @@ declare function defineQuery<const TId extends string, const TDatasource$1 exten
|
|
|
135
134
|
* datasourceId: "8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77",
|
|
136
135
|
* operation: "update",
|
|
137
136
|
* fields: { status: "V1StGXR8", closedAt: "Z7pQm4Kd" },
|
|
138
|
-
* permissions: {
|
|
137
|
+
* permissions: { userGroupTypes: ["LOGGED_IN_USERS"], customGroups: ["grp_staff"] },
|
|
139
138
|
* })
|
|
140
139
|
*
|
|
141
140
|
* await ds.update(closeOrder, { id, data: { status: "closed" } })
|
|
@@ -145,7 +144,7 @@ declare function defineQuery<const TId extends string, const TDatasource$1 exten
|
|
|
145
144
|
* id: "removeOrder",
|
|
146
145
|
* datasourceId: "8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77",
|
|
147
146
|
* operation: "delete",
|
|
148
|
-
* permissions: {
|
|
147
|
+
* permissions: { userGroupTypes: ["LOGGED_IN_USERS"], customGroups: ["grp_staff"] },
|
|
149
148
|
* })
|
|
150
149
|
*
|
|
151
150
|
* await ds.delete(removeOrder, { id })
|
|
@@ -191,7 +190,7 @@ type WorkflowDefinition<TId extends string> = {
|
|
|
191
190
|
* id: "notifyTeam",
|
|
192
191
|
* workflowId: "wf_abc123",
|
|
193
192
|
* triggerId: "tr_xyz789",
|
|
194
|
-
* permissions: {
|
|
193
|
+
* permissions: { userGroupTypes: ["LOGGED_IN_USERS"] },
|
|
195
194
|
* body: { requestedBy: "{LOGGED_IN_USER:EMAIL}", at: "{CURRENT_DATE_TIME}" },
|
|
196
195
|
* });
|
|
197
196
|
*
|
|
@@ -228,7 +227,7 @@ type IntegrationDefinition<TId extends string> = {
|
|
|
228
227
|
* integrationId: "25d60213-ef4f-4fa0-b094-a46931b2d120",
|
|
229
228
|
* method: "GET",
|
|
230
229
|
* url: "https://www.googleapis.com/calendar/v3/calendars/primary/events",
|
|
231
|
-
* permissions: {
|
|
230
|
+
* permissions: { userGroupTypes: ["LOGGED_IN_USERS"] },
|
|
232
231
|
* })
|
|
233
232
|
*
|
|
234
233
|
* const listEvents = integration.createFetcher(listCalendarEvents)
|
package/dist/auth/user.d.ts
CHANGED
|
@@ -3,13 +3,20 @@ import { z } from "zod";
|
|
|
3
3
|
//#region src/auth/user.d.ts
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Declare this interface in your app to narrow every group id the SDK takes and
|
|
7
|
+
* returns. Without a declaration, a group id is a plain `string`.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* declare module "@softr/sdk" {
|
|
11
|
+
* interface SoftrUserGroups {
|
|
12
|
+
* ids:
|
|
13
|
+
* | "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
|
|
14
|
+
* | "f9e8d7c6-5b4a-4938-8271-605f4e3d2c1b"
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
10
17
|
*/
|
|
11
18
|
interface SoftrUserGroups {}
|
|
12
|
-
/** A Softr user group id, narrowed to
|
|
19
|
+
/** A Softr user group id, narrowed to your own ids if you declare {@link SoftrUserGroups}. */
|
|
13
20
|
type SoftrUserGroupId = SoftrUserGroups extends {
|
|
14
21
|
ids: infer Id extends string;
|
|
15
22
|
} ? Id : string;
|
package/dist/cli.js
CHANGED
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
import { version } from "./package.js";
|
|
3
3
|
import { requireDefined } from "./assertions.js";
|
|
4
4
|
import { fetchSoftrEnvInput, metadataSourceFromEnv } from "./codegen/fetch-metadata.js";
|
|
5
|
-
import { renderUserGroupsDoc } from "./codegen/user-groups.js";
|
|
6
5
|
import { renderSoftrEnv } from "./codegen/softr-env.js";
|
|
7
|
-
import { writeIfChanged } from "./codegen/write-if-changed.js";
|
|
8
6
|
import { writeDatasourceDocs } from "./codegen/write-datasource-docs.js";
|
|
9
7
|
import { writeFileSync } from "node:fs";
|
|
10
8
|
import path from "node:path";
|
|
@@ -13,44 +11,26 @@ import { parseArgs } from "node:util";
|
|
|
13
11
|
//#region src/cli.ts
|
|
14
12
|
const OUTPUT = "softr-env.d.ts";
|
|
15
13
|
const DOCS_DIR = "docs/datasources";
|
|
16
|
-
const USER_GROUPS_DOC = "docs/user-groups.md";
|
|
17
14
|
async function generate({ json }) {
|
|
18
15
|
if (json) console.info = console.error;
|
|
19
16
|
const input = await fetchSoftrEnvInput(metadataSourceFromEnv());
|
|
20
17
|
writeFileSync(path.resolve(process.cwd(), OUTPUT), renderSoftrEnv(input, version), "utf8");
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
printReport(report);
|
|
27
|
-
}
|
|
28
|
-
function writeDocs(input) {
|
|
29
|
-
const datasourceDocs = writeDatasourceDocs(input.datasources, path.resolve(process.cwd(), DOCS_DIR));
|
|
30
|
-
return {
|
|
18
|
+
const docFiles = writeDatasourceDocs(input.datasources, path.resolve(process.cwd(), DOCS_DIR));
|
|
19
|
+
const report = {
|
|
31
20
|
types: OUTPUT,
|
|
32
|
-
|
|
33
|
-
subject: "datasources",
|
|
34
|
-
path: `${DOCS_DIR}/README.md`,
|
|
35
|
-
changed: datasourceDocs.changed
|
|
36
|
-
}, {
|
|
37
|
-
subject: "user groups",
|
|
38
|
-
path: USER_GROUPS_DOC,
|
|
39
|
-
changed: writeIfChanged(path.resolve(process.cwd(), USER_GROUPS_DOC), renderUserGroupsDoc(input.userGroups))
|
|
40
|
-
}],
|
|
21
|
+
docsIndex: `${DOCS_DIR}/README.md`,
|
|
41
22
|
datasources: input.datasources.map(({ id, name }) => ({
|
|
42
23
|
id,
|
|
43
24
|
name,
|
|
44
|
-
doc: `${DOCS_DIR}/${requireDefined(
|
|
25
|
+
doc: `${DOCS_DIR}/${requireDefined(docFiles.get(id))}`
|
|
45
26
|
}))
|
|
46
27
|
};
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
for (const doc of report.docs) {
|
|
51
|
-
const state = doc.changed ? "updated" : "unchanged";
|
|
52
|
-
console.log(`[softr] ${state} ${doc.path} for ${doc.subject}`);
|
|
28
|
+
if (json) {
|
|
29
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
30
|
+
return;
|
|
53
31
|
}
|
|
32
|
+
console.log(`[softr] wrote ${report.types}`);
|
|
33
|
+
console.log(`[softr] wrote ${report.docsIndex}`);
|
|
54
34
|
for (const datasource of report.datasources) console.log(`[softr] wrote ${datasource.doc} for ${datasource.name} (${datasource.id})`);
|
|
55
35
|
}
|
|
56
36
|
const { positionals, values } = parseArgs({
|
|
@@ -194,7 +194,7 @@ function renderExamples(datasource, datasources) {
|
|
|
194
194
|
" fields: {",
|
|
195
195
|
...sampled.map((field) => ` ${keyOf(field)}: ${quoted(field.id)}, // ${fieldTypeLiteral(field).slice(1, -1)}`),
|
|
196
196
|
" },",
|
|
197
|
-
" permissions: {
|
|
197
|
+
" permissions: { userGroupTypes: ['NON_LOGGED_IN_USERS', 'LOGGED_IN_USERS'] },",
|
|
198
198
|
"})",
|
|
199
199
|
"",
|
|
200
200
|
`const list${name} = createServerFn().handler(async () => {`,
|
|
@@ -226,7 +226,7 @@ function renderExamples(datasource, datasources) {
|
|
|
226
226
|
" fields: {",
|
|
227
227
|
...writable.map((field) => ` ${keyOf(field)}: ${quoted(field.id)},`),
|
|
228
228
|
" },",
|
|
229
|
-
" permissions: {
|
|
229
|
+
" permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },",
|
|
230
230
|
"})",
|
|
231
231
|
"",
|
|
232
232
|
`const add${name} = createServerFn({ method: 'POST' }).handler(async () =>`,
|
|
@@ -19,7 +19,7 @@ function metadataSourceFromEnv(env = process.env) {
|
|
|
19
19
|
}
|
|
20
20
|
async function fetchSoftrEnvInput(source) {
|
|
21
21
|
const { studioApiUrl, datasourceApiUrl, applicationId, token } = source;
|
|
22
|
-
const
|
|
22
|
+
const registered = await apiFetch(`${studioApiUrl}/v1/applications/${applicationId}/datasources`, token);
|
|
23
23
|
return {
|
|
24
24
|
datasources: await Promise.all(registered.map(async ({ name, datasource }) => {
|
|
25
25
|
return {
|
|
@@ -28,7 +28,7 @@ async function fetchSoftrEnvInput(source) {
|
|
|
28
28
|
name
|
|
29
29
|
};
|
|
30
30
|
})),
|
|
31
|
-
|
|
31
|
+
userGroupIds: null
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
async function apiFetch(url, token) {
|
|
@@ -13,8 +13,7 @@ function renderSoftrEnv(input, sdkVersion) {
|
|
|
13
13
|
"",
|
|
14
14
|
"declare module \"@softr/sdk\" {",
|
|
15
15
|
indent(interfaces),
|
|
16
|
-
"",
|
|
17
|
-
indent(renderUserGroups(input.userGroups)),
|
|
16
|
+
...input.userGroupIds === null ? [] : ["", indent(renderUserGroups(input.userGroupIds))],
|
|
18
17
|
"}",
|
|
19
18
|
""
|
|
20
19
|
].join("\n");
|
|
@@ -34,11 +33,10 @@ function header(sdkVersion) {
|
|
|
34
33
|
* THIS FILE IS AUTO GENERATED, DO NOT EDIT THIS MANUALLY
|
|
35
34
|
*
|
|
36
35
|
* This file exists for type checking. See docs/datasources/ for field reference
|
|
37
|
-
* and guide how to use it
|
|
36
|
+
* and guide how to use it.
|
|
38
37
|
*
|
|
39
38
|
* Generated by @softr/sdk@${sdkVersion}
|
|
40
39
|
* This file is regenerated automatically when a change is made to the datasource
|
|
41
|
-
* or to the app's user groups
|
|
42
40
|
*/
|
|
43
41
|
|
|
44
42
|
export {};`;
|
|
@@ -1,32 +1,11 @@
|
|
|
1
1
|
//#region src/codegen/user-groups.ts
|
|
2
|
-
|
|
3
|
-
function renderUserGroups(groups) {
|
|
2
|
+
function renderUserGroups(ids) {
|
|
4
3
|
return [
|
|
5
4
|
"interface SoftrUserGroups {",
|
|
6
|
-
` ids: ${
|
|
5
|
+
` ids: ${ids.length === 0 ? "never" : ids.map((id) => JSON.stringify(id)).join(" | ")};`,
|
|
7
6
|
"}"
|
|
8
7
|
].join("\n");
|
|
9
8
|
}
|
|
10
|
-
function renderUserGroupsDoc(groups) {
|
|
11
|
-
const heading = [
|
|
12
|
-
"# User groups",
|
|
13
|
-
"",
|
|
14
|
-
DOC_NOTE,
|
|
15
|
-
""
|
|
16
|
-
];
|
|
17
|
-
if (groups.length === 0) return [
|
|
18
|
-
...heading,
|
|
19
|
-
"This app has no user groups.",
|
|
20
|
-
""
|
|
21
|
-
].join("\n");
|
|
22
|
-
return [
|
|
23
|
-
...heading,
|
|
24
|
-
"User groups available in this app:",
|
|
25
|
-
"",
|
|
26
|
-
...groups.map(({ id, name, type }) => `- ${name} (${id}), ${type.toLowerCase()}`),
|
|
27
|
-
""
|
|
28
|
-
].join("\n");
|
|
29
|
-
}
|
|
30
9
|
|
|
31
10
|
//#endregion
|
|
32
|
-
export { renderUserGroups
|
|
11
|
+
export { renderUserGroups };
|
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
import { requireDefined } from "../assertions.js";
|
|
2
2
|
import { renderDatasourceDoc, renderDatasourceIndex } from "./datasource-docs.js";
|
|
3
3
|
import { docFileNames } from "./doc-file-names.js";
|
|
4
|
-
import {
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
|
|
7
7
|
//#region src/codegen/write-datasource-docs.ts
|
|
8
8
|
/**
|
|
9
9
|
* Writes the index and one file per datasource, and touches nothing else in
|
|
10
10
|
* `docsDir`: the caller resolves it against `process.cwd()`, which can be any
|
|
11
|
-
* directory a developer ran the bin from.
|
|
12
|
-
* wrote now reads differently.
|
|
11
|
+
* directory a developer ran the bin from.
|
|
13
12
|
*/
|
|
14
13
|
function writeDatasourceDocs(datasources, docsDir) {
|
|
15
14
|
const context = {
|
|
16
15
|
datasources: new Map(datasources.map((datasource) => [datasource.id, datasource])),
|
|
17
16
|
fileNames: docFileNames(datasources)
|
|
18
17
|
};
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
};
|
|
18
|
+
mkdirSync(docsDir, { recursive: true });
|
|
19
|
+
writeFileSync(path.join(docsDir, "README.md"), renderDatasourceIndex(datasources, context), "utf8");
|
|
20
|
+
for (const datasource of datasources) writeFileSync(path.join(docsDir, requireDefined(context.fileNames.get(datasource.id))), renderDatasourceDoc(datasource, context), "utf8");
|
|
21
|
+
return context.fileNames;
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
//#endregion
|
|
@@ -24,9 +24,17 @@ function makeSortSet(aliases) {
|
|
|
24
24
|
};
|
|
25
25
|
return set;
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Ands the filters of every `where` call into one, which is what an accumulated `where` needs.
|
|
29
|
+
*
|
|
30
|
+
* The backend takes a group inside a group and no deeper, and the docs promise the caller both levels.
|
|
31
|
+
* A wrapper added here would spend one of them: a single `where` handing over an AND with an OR inside
|
|
32
|
+
* would arrive three deep and be refused. So one filter goes through as it is, and a group that is itself
|
|
33
|
+
* an AND contributes its members to the group built here rather than sitting inside it.
|
|
34
|
+
*/
|
|
28
35
|
function andAll(filters) {
|
|
29
|
-
|
|
36
|
+
if (filters.length === 1) return filters[0];
|
|
37
|
+
return group("AND", filters.flatMap((filter) => filter[INTERNAL_KIND] === "LOGICAL_GROUP" && filter.logicalOperator === "AND" ? filter.filters : [filter]));
|
|
30
38
|
}
|
|
31
39
|
function group(logicalOperator, filters) {
|
|
32
40
|
return {
|
package/dist/package.js
CHANGED
|
@@ -16,7 +16,7 @@ type Props = {
|
|
|
16
16
|
* Requires the root route to resolve {@link getSoftrContext}.
|
|
17
17
|
*
|
|
18
18
|
* @example
|
|
19
|
-
* const membersOnly = defineVisibility({
|
|
19
|
+
* const membersOnly = defineVisibility({ userGroupTypes: ["LOGGED_IN_USERS"] })
|
|
20
20
|
*
|
|
21
21
|
* <Audience visibility={membersOnly} fallback={<Upgrade />}>
|
|
22
22
|
* <DangerZone />
|
|
@@ -11,7 +11,7 @@ import { useCurrentUser } from "../auth/isomorphic.js";
|
|
|
11
11
|
* Requires the root route to resolve {@link getSoftrContext}.
|
|
12
12
|
*
|
|
13
13
|
* @example
|
|
14
|
-
* const membersOnly = defineVisibility({
|
|
14
|
+
* const membersOnly = defineVisibility({ userGroupTypes: ["LOGGED_IN_USERS"] })
|
|
15
15
|
*
|
|
16
16
|
* <Audience visibility={membersOnly} fallback={<Upgrade />}>
|
|
17
17
|
* <DangerZone />
|
|
@@ -2,22 +2,20 @@ import { SoftrUserGroupId, User } from "../auth/user.js";
|
|
|
2
2
|
import { SoftrContext } from "../auth/context.js";
|
|
3
3
|
|
|
4
4
|
//#region src/visibility/visibility.d.ts
|
|
5
|
-
|
|
5
|
+
type AudienceSet = readonly ["NON_LOGGED_IN_USERS"] | readonly ["LOGGED_IN_USERS"] | readonly ["NON_LOGGED_IN_USERS", "LOGGED_IN_USERS"] | readonly ["LOGGED_IN_USERS", "NON_LOGGED_IN_USERS"];
|
|
6
6
|
/**
|
|
7
|
-
* Who may see a route.
|
|
7
|
+
* Who may see a route or region. `customGroups` narrows only the
|
|
8
|
+
* `LOGGED_IN_USERS` half.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* @example
|
|
11
|
+
* { userGroupTypes: ["NON_LOGGED_IN_USERS", "LOGGED_IN_USERS"] }
|
|
12
|
+
* { userGroupTypes: ["NON_LOGGED_IN_USERS"] }
|
|
13
|
+
* { userGroupTypes: ["LOGGED_IN_USERS"], customGroups: ["…"] }
|
|
14
|
+
* { userGroupTypes: ["NON_LOGGED_IN_USERS", "LOGGED_IN_USERS"], customGroups: ["…"] }
|
|
13
15
|
*/
|
|
14
16
|
type VisibilityRule = {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
userGroup: "NON_LOGGED_IN_USERS";
|
|
18
|
-
} | {
|
|
19
|
-
userGroup: "LOGGED_IN_USERS";
|
|
20
|
-
userGroupIds?: Array<SoftrUserGroupId>;
|
|
17
|
+
userGroupTypes: AudienceSet;
|
|
18
|
+
customGroups?: ReadonlyArray<SoftrUserGroupId>;
|
|
21
19
|
};
|
|
22
20
|
declare const visibilityBrand: unique symbol;
|
|
23
21
|
/** A rule {@link defineVisibility} has produced. */
|
|
@@ -43,15 +41,12 @@ type AccessCheck = {
|
|
|
43
41
|
type AccessContext = Pick<SoftrContext, "user">;
|
|
44
42
|
/** The decision and, when a visitor is denied, the reason. Throws nothing. */
|
|
45
43
|
declare function checkAccess(visibility: Visibility, context: AccessContext): AccessCheck;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
} ? AccessContext & {
|
|
44
|
+
/** Narrows `context.user` only for a rule naming exactly one audience; naming both narrows to neither. */
|
|
45
|
+
type Narrowed<R extends VisibilityRule> = R["userGroupTypes"] extends ReadonlyArray<infer T> ? [T] extends ["LOGGED_IN_USERS"] ? AccessContext & {
|
|
49
46
|
user: User;
|
|
50
|
-
} :
|
|
51
|
-
userGroup: "NON_LOGGED_IN_USERS";
|
|
52
|
-
} ? AccessContext & {
|
|
47
|
+
} : [T] extends ["NON_LOGGED_IN_USERS"] ? AccessContext & {
|
|
53
48
|
user: null;
|
|
54
|
-
} : AccessContext;
|
|
49
|
+
} : AccessContext : AccessContext;
|
|
55
50
|
/**
|
|
56
51
|
* Declares who may see a route or a region of the page.
|
|
57
52
|
*
|
|
@@ -59,7 +54,7 @@ type Narrowed<R extends VisibilityRule> = R extends {
|
|
|
59
54
|
* declarations out of the source to publish the app's route visibility.
|
|
60
55
|
*
|
|
61
56
|
* @example
|
|
62
|
-
* const visibility = defineVisibility({
|
|
57
|
+
* const visibility = defineVisibility({ userGroupTypes: ["LOGGED_IN_USERS"] })
|
|
63
58
|
*
|
|
64
59
|
* export const Route = createFileRoute("/about")({
|
|
65
60
|
* beforeLoad: guardRoute(visibility),
|
|
@@ -14,25 +14,21 @@ const ACCESS_REASONS = [
|
|
|
14
14
|
/** The decision and, when a visitor is denied, the reason. Throws nothing. */
|
|
15
15
|
function checkAccess(visibility, context) {
|
|
16
16
|
const { user } = context;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
reason: "forbidden"
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
}
|
|
17
|
+
const audiences = visibility.userGroupTypes;
|
|
18
|
+
if (user === null) return audiences.includes("NON_LOGGED_IN_USERS") ? { ok: true } : {
|
|
19
|
+
ok: false,
|
|
20
|
+
reason: "unauthenticated"
|
|
21
|
+
};
|
|
22
|
+
if (!audiences.includes("LOGGED_IN_USERS")) return {
|
|
23
|
+
ok: false,
|
|
24
|
+
reason: "alreadyAuthenticated"
|
|
25
|
+
};
|
|
26
|
+
const required = visibility.customGroups ?? [];
|
|
27
|
+
if (required.length === 0) return { ok: true };
|
|
28
|
+
return required.some((id) => user.userGroupIds.includes(id)) ? { ok: true } : {
|
|
29
|
+
ok: false,
|
|
30
|
+
reason: "forbidden"
|
|
31
|
+
};
|
|
36
32
|
}
|
|
37
33
|
/**
|
|
38
34
|
* Declares who may see a route or a region of the page.
|
|
@@ -41,7 +37,7 @@ function checkAccess(visibility, context) {
|
|
|
41
37
|
* declarations out of the source to publish the app's route visibility.
|
|
42
38
|
*
|
|
43
39
|
* @example
|
|
44
|
-
* const visibility = defineVisibility({
|
|
40
|
+
* const visibility = defineVisibility({ userGroupTypes: ["LOGGED_IN_USERS"] })
|
|
45
41
|
*
|
|
46
42
|
* export const Route = createFileRoute("/about")({
|
|
47
43
|
* beforeLoad: guardRoute(visibility),
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
metadata:
|
|
12
12
|
type: core
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.
|
|
14
|
+
library_version: 0.0.51
|
|
15
15
|
---
|
|
16
16
|
|
|
17
17
|
# Softr SDK core
|
|
@@ -137,6 +137,6 @@ type User = {
|
|
|
137
137
|
id: string
|
|
138
138
|
email: string
|
|
139
139
|
fullName: string
|
|
140
|
-
userGroupIds: Array<SoftrUserGroupId> //
|
|
140
|
+
userGroupIds: Array<SoftrUserGroupId> // plain string unless you declare your ids
|
|
141
141
|
}
|
|
142
142
|
```
|
|
@@ -16,7 +16,7 @@ description: >-
|
|
|
16
16
|
metadata:
|
|
17
17
|
type: sub-skill
|
|
18
18
|
library: softr-sdk
|
|
19
|
-
library_version: 0.0.
|
|
19
|
+
library_version: 0.0.51
|
|
20
20
|
---
|
|
21
21
|
|
|
22
22
|
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
|
|
@@ -12,7 +12,7 @@ description: >-
|
|
|
12
12
|
metadata:
|
|
13
13
|
type: sub-skill
|
|
14
14
|
library: softr-sdk
|
|
15
|
-
library_version: 0.0.
|
|
15
|
+
library_version: 0.0.51
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -38,7 +38,7 @@ const sales = defineQuery({
|
|
|
38
38
|
closedAt: 'Xp2dLh6vKz9Fa3', // DATETIME
|
|
39
39
|
rep: 'Gq5wNt8rJy3Hb1', // LINKED_RECORD
|
|
40
40
|
},
|
|
41
|
-
permissions: {
|
|
41
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
42
42
|
})
|
|
43
43
|
```
|
|
44
44
|
|
|
@@ -4,12 +4,11 @@ description: >-
|
|
|
4
4
|
Read the signed-in Softr visitor: useCurrentUser and the User type,
|
|
5
5
|
context.user in a route's beforeLoad, and context.user inside a server
|
|
6
6
|
function handler, where Start types it from the app's registered request
|
|
7
|
-
middleware. Covers the null guest
|
|
8
|
-
narrows group ids to the app's own.
|
|
7
|
+
middleware. Covers the null guest.
|
|
9
8
|
metadata:
|
|
10
9
|
type: sub-skill
|
|
11
10
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
11
|
+
library_version: 0.0.51
|
|
13
12
|
---
|
|
14
13
|
|
|
15
14
|
# The current user
|
|
@@ -30,7 +29,7 @@ type User = {
|
|
|
30
29
|
id: string
|
|
31
30
|
email: string
|
|
32
31
|
fullName: string
|
|
33
|
-
userGroupIds: Array<SoftrUserGroupId> //
|
|
32
|
+
userGroupIds: Array<SoftrUserGroupId> // plain string unless you declare your ids
|
|
34
33
|
}
|
|
35
34
|
```
|
|
36
35
|
|
|
@@ -72,7 +71,7 @@ const myInvoices = defineQuery({
|
|
|
72
71
|
// The datasource id from the table's registration on the application — not the table id.
|
|
73
72
|
datasourceId: '00000000-0000-0000-0000-000000000001',
|
|
74
73
|
fields: { owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' },
|
|
75
|
-
permissions: {
|
|
74
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
76
75
|
})
|
|
77
76
|
|
|
78
77
|
const getMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
@@ -84,14 +83,6 @@ const getMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
|
84
83
|
|
|
85
84
|
`context.user` is typed. Start derives a handler's context from the app's registered request middleware as well as the server function's own, so there is nothing to import and nothing to cast.
|
|
86
85
|
|
|
87
|
-
### Group ids are the app's own
|
|
88
|
-
|
|
89
|
-
The generated `softr-env.d.ts` declares `SoftrUserGroups` with every group id the app has, the predefined groups included. So `user.userGroupIds` and every `userGroupIds` the SDK accepts narrow to those ids, and an id the app does not have stops compiling. A new group appears there once it exists on the app.
|
|
90
|
-
|
|
91
|
-
For every group's name and id, read `docs/user-groups.md`. It lists every group the app has, the predefined ones included. Copy an id from there; never derive one from a group's name.
|
|
92
|
-
|
|
93
|
-
Never declare `SoftrUserGroups` yourself. A second declaration does not merge with the generated one. TypeScript keeps one of the two without an error, so the ids you wrote are either ignored or hide the real ones.
|
|
94
|
-
|
|
95
86
|
## References
|
|
96
87
|
|
|
97
88
|
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `isAuthorized` narrows `context.user` while applying the route's rule
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.51
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
**Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
|
|
@@ -34,7 +34,7 @@ const createApplication = defineMutation({
|
|
|
34
34
|
datasourceId: '8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77',
|
|
35
35
|
operation: 'create',
|
|
36
36
|
fields: { name: 'V1StGXR8', resume: 'Z7pQm4Kd' },
|
|
37
|
-
permissions: {
|
|
37
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
38
38
|
})
|
|
39
39
|
|
|
40
40
|
const submitApplication = createServerFn({ method: 'POST' })
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.51
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
**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.
|
|
@@ -48,7 +48,7 @@ const listCalendarEvents = defineIntegration({
|
|
|
48
48
|
integrationId: '25d60213-ef4f-4fa0-b094-a46931b2d120',
|
|
49
49
|
method: 'GET',
|
|
50
50
|
url: 'https://www.googleapis.com/calendar/v3/calendars/primary/events',
|
|
51
|
-
permissions: {
|
|
51
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
52
52
|
})
|
|
53
53
|
```
|
|
54
54
|
|
|
@@ -88,7 +88,7 @@ const getStripeCustomer = defineIntegration({
|
|
|
88
88
|
integrationId: 'wsint-stripe',
|
|
89
89
|
method: 'GET',
|
|
90
90
|
url: 'https://api.stripe.com/v1/customers/{id}',
|
|
91
|
-
permissions: {
|
|
91
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
92
92
|
})
|
|
93
93
|
|
|
94
94
|
const fetchCustomer = createServerFn({ method: 'GET' })
|
|
@@ -109,7 +109,7 @@ const postSlackMessage = defineIntegration({
|
|
|
109
109
|
integrationId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
|
110
110
|
method: 'POST',
|
|
111
111
|
url: 'https://slack.com/api/chat.postMessage',
|
|
112
|
-
permissions: {
|
|
112
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
113
113
|
})
|
|
114
114
|
|
|
115
115
|
const sendSlackMessage = createServerFn({ method: 'POST' })
|
|
@@ -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.51
|
|
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.
|
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
metadata:
|
|
12
12
|
type: sub-skill
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.
|
|
14
|
+
library_version: 0.0.51
|
|
15
15
|
---
|
|
16
16
|
|
|
17
17
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -45,7 +45,7 @@ const createProject = defineMutation({
|
|
|
45
45
|
status: 'Ru7bVx2cNq0Ed9', // SELECT, choice ids 'draft' | 'active' | 'archived'
|
|
46
46
|
dueDate: 'Xp2dLh6vKz9Fa3', // DATETIME
|
|
47
47
|
},
|
|
48
|
-
permissions: {
|
|
48
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
49
49
|
})
|
|
50
50
|
```
|
|
51
51
|
|
|
@@ -53,7 +53,7 @@ The declaration follows the same rules as a read, spelled out in [record-queries
|
|
|
53
53
|
|
|
54
54
|
The mutation's `fields` bound the write: `data` can only name them, and the returned record only carries them. A delete mutation declares no `fields`, and giving it some is a type error.
|
|
55
55
|
|
|
56
|
-
An update or a delete must not be open to visitors: its `permissions` must be `{
|
|
56
|
+
An update or a delete must not be open to visitors: its `permissions` must be `{ userGroupTypes: ['LOGGED_IN_USERS'] }`. A visitor who is not logged in must never change or remove a record, so naming `NON_LOGGED_IN_USERS` is rejected for these two operations. A create may stay open to visitors, for a sign-up or a contact form.
|
|
57
57
|
|
|
58
58
|
## Core Patterns
|
|
59
59
|
|
|
@@ -81,7 +81,7 @@ const updateProject = defineMutation({
|
|
|
81
81
|
datasourceId: '00000000-0000-0000-0000-000000000000',
|
|
82
82
|
operation: 'update',
|
|
83
83
|
fields: { status: 'Ru7bVx2cNq0Ed9', dueDate: 'Xp2dLh6vKz9Fa3' },
|
|
84
|
-
permissions: {
|
|
84
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
85
85
|
})
|
|
86
86
|
|
|
87
87
|
const archiveProject = createServerFn({ method: 'POST' })
|
|
@@ -102,7 +102,7 @@ const deleteProject = defineMutation({
|
|
|
102
102
|
id: 'deleteProject',
|
|
103
103
|
datasourceId: '00000000-0000-0000-0000-000000000000',
|
|
104
104
|
operation: 'delete',
|
|
105
|
-
permissions: {
|
|
105
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
106
106
|
})
|
|
107
107
|
|
|
108
108
|
const removeProject = createServerFn({ method: 'POST' })
|
|
@@ -123,7 +123,7 @@ const projectDetail = defineQuery({
|
|
|
123
123
|
id: 'projectDetail',
|
|
124
124
|
datasourceId: '00000000-0000-0000-0000-000000000000',
|
|
125
125
|
fields: { status: 'Ru7bVx2cNq0Ed9', dueDate: 'Xp2dLh6vKz9Fa3' },
|
|
126
|
-
permissions: {
|
|
126
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
127
127
|
})
|
|
128
128
|
|
|
129
129
|
const project = await ds.get(projectDetail, { id })
|
|
@@ -13,7 +13,7 @@ description: >-
|
|
|
13
13
|
metadata:
|
|
14
14
|
type: sub-skill
|
|
15
15
|
library: softr-sdk
|
|
16
|
-
library_version: 0.0.
|
|
16
|
+
library_version: 0.0.51
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -46,7 +46,7 @@ const publishedPosts = defineQuery({
|
|
|
46
46
|
publishedAt: 'Xp2dLh6vKz9Fa3',// DATETIME
|
|
47
47
|
views: 'mB4kQ8yTn1sWc7', // NUMBER
|
|
48
48
|
},
|
|
49
|
-
permissions: {
|
|
49
|
+
permissions: { userGroupTypes: ['NON_LOGGED_IN_USERS', 'LOGGED_IN_USERS'] },
|
|
50
50
|
})
|
|
51
51
|
```
|
|
52
52
|
|
|
@@ -54,9 +54,9 @@ const publishedPosts = defineQuery({
|
|
|
54
54
|
- `datasourceId` is the opaque UUID from the table's registration on the application (Softr Studio's Data tab, or the `application_register_data_source` tool when an agent does it). It is not the table id and is never derived from the table's name.
|
|
55
55
|
- The keys of `fields` are names you choose. The values are field ids, opaque strings the table's schema gives you: `database_get_table` returns them for a Softr table, `integration_list_table_fields` for a table on a connected database. Everything downstream speaks your names: the returned records, `where`, `orderBy`.
|
|
56
56
|
- `permissions` declares who may run the query, and the SDK enforces it:
|
|
57
|
-
- `{
|
|
58
|
-
- `{
|
|
59
|
-
- `{
|
|
57
|
+
- `{ userGroupTypes: ['NON_LOGGED_IN_USERS', 'LOGGED_IN_USERS'] }`: everyone, signed out included. `customGroups` narrows only the signed-in half.
|
|
58
|
+
- `{ userGroupTypes: ['NON_LOGGED_IN_USERS'] }`: signed-out visitors only.
|
|
59
|
+
- `{ userGroupTypes: ['LOGGED_IN_USERS'] }`: any signed-in visitor, narrowed to particular groups with `customGroups: ['00000000-0000-0000-0000-00000000000a']`.
|
|
60
60
|
- 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.
|
|
61
61
|
|
|
62
62
|
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.
|
|
@@ -135,7 +135,7 @@ const myInvoices = defineQuery({
|
|
|
135
135
|
id: 'myInvoices',
|
|
136
136
|
datasourceId: '00000000-0000-0000-0000-000000000001',
|
|
137
137
|
fields: { owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' },
|
|
138
|
-
permissions: {
|
|
138
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
139
139
|
})
|
|
140
140
|
|
|
141
141
|
const listMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
@@ -157,7 +157,7 @@ const productStatuses = defineQuery({
|
|
|
157
157
|
id: 'productStatuses',
|
|
158
158
|
datasourceId: '00000000-0000-0000-0000-000000000001', // the linked table's datasource id
|
|
159
159
|
fields: { name: 'fZk3Qm9pLw2Xa1' },
|
|
160
|
-
permissions: {
|
|
160
|
+
permissions: { userGroupTypes: ['NON_LOGGED_IN_USERS', 'LOGGED_IN_USERS'] },
|
|
161
161
|
})
|
|
162
162
|
|
|
163
163
|
const listProductStatuses = createServerFn().handler(async () => {
|
|
@@ -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.51
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `Audience` and `defineVisibility` come from `@softr/sdk` and work anywhere.
|
|
@@ -26,8 +26,8 @@ Wrap the region in `Audience` and give it a rule:
|
|
|
26
26
|
import { Audience, defineVisibility } from '@softr/sdk'
|
|
27
27
|
|
|
28
28
|
const staffOnly = defineVisibility({
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
userGroupTypes: ['LOGGED_IN_USERS'],
|
|
30
|
+
customGroups: ['00000000-0000-0000-0000-00000000000a'],
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
function InvoiceActions({ invoice }: { invoice: Invoice }) {
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
name: routes-and-visibility
|
|
3
3
|
description: >-
|
|
4
4
|
Add a route file to a Softr app and declare who may see it: defineVisibility
|
|
5
|
-
with
|
|
6
|
-
ways to apply a rule: guardRoute and assertRouteAccess
|
|
7
|
-
isAuthorized and checkAccess anywhere. Covers per-reason
|
|
8
|
-
redirect and throw strategies, rendering an access error with
|
|
9
|
-
isUnauthenticatedError, isForbiddenError and
|
|
10
|
-
guarding a layout, and the lint rule that
|
|
11
|
-
rules that reject a duplicate, computed
|
|
5
|
+
with a userGroupTypes set naming LOGGED_IN_USERS and/or NON_LOGGED_IN_USERS,
|
|
6
|
+
customGroups, and the ways to apply a rule: guardRoute and assertRouteAccess
|
|
7
|
+
in a route, isAuthorized and checkAccess anywhere. Covers per-reason
|
|
8
|
+
overrides, the redirect and throw strategies, rendering an access error with
|
|
9
|
+
isAccessError, isUnauthenticatedError, isForbiddenError and
|
|
10
|
+
isAlreadyAuthenticatedError, guarding a layout, and the lint rule that
|
|
11
|
+
requires a declaration alongside the rules that reject a duplicate, computed
|
|
12
|
+
or root-level one.
|
|
12
13
|
metadata:
|
|
13
14
|
type: sub-skill
|
|
14
15
|
library: softr-sdk
|
|
15
|
-
library_version: 0.0.
|
|
16
|
+
library_version: 0.0.51
|
|
16
17
|
---
|
|
17
18
|
|
|
18
19
|
**Boundary.** `defineVisibility`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
|
|
@@ -29,7 +30,7 @@ Every route file, without exception, looks like this:
|
|
|
29
30
|
import { createFileRoute } from '@tanstack/react-router'
|
|
30
31
|
import { defineVisibility, guardRoute } from '@softr/sdk'
|
|
31
32
|
|
|
32
|
-
const visibility = defineVisibility({
|
|
33
|
+
const visibility = defineVisibility({ userGroupTypes: ['LOGGED_IN_USERS'] })
|
|
33
34
|
|
|
34
35
|
export const Route = createFileRoute('/_app/billing')({
|
|
35
36
|
beforeLoad: guardRoute(visibility),
|
|
@@ -50,21 +51,25 @@ Four constraints on that shape, because Softr reads the file rather than running
|
|
|
50
51
|
- The route guards itself in `beforeLoad`, naming a rule declared in the same file. A rule imported from elsewhere is rejected.
|
|
51
52
|
- Never declare on `__root.tsx`. The root cannot carry an audience, so a declaration there would gate nothing while looking like it does. Softr rejects it. Put the rule on a layout below the root instead.
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
`userGroupTypes` is a set, so a rule names one audience or several:
|
|
54
55
|
|
|
55
56
|
```ts
|
|
56
|
-
defineVisibility({
|
|
57
|
-
defineVisibility({
|
|
58
|
-
defineVisibility({
|
|
59
|
-
defineVisibility({
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
defineVisibility({ userGroupTypes: ['NON_LOGGED_IN_USERS', 'LOGGED_IN_USERS'] }) // everyone, signed in or not
|
|
58
|
+
defineVisibility({ userGroupTypes: ['NON_LOGGED_IN_USERS'] }) // signed-out only: login, signup
|
|
59
|
+
defineVisibility({ userGroupTypes: ['LOGGED_IN_USERS'] }) // any signed-in visitor
|
|
60
|
+
defineVisibility({ // those groups only
|
|
61
|
+
userGroupTypes: ['LOGGED_IN_USERS'],
|
|
62
|
+
customGroups: ['00000000-0000-0000-0000-00000000000a'],
|
|
63
|
+
})
|
|
64
|
+
defineVisibility({ // signed-out visitors AND those groups
|
|
65
|
+
userGroupTypes: ['NON_LOGGED_IN_USERS', 'LOGGED_IN_USERS'],
|
|
66
|
+
customGroups: ['00000000-0000-0000-0000-00000000000a'],
|
|
62
67
|
})
|
|
63
68
|
```
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
Everyone is both audiences, as in the first line above. A set names each audience at most once and
|
|
71
|
+
is never empty — the type and the build both reject one that does not. `customGroups` narrows only the `LOGGED_IN_USERS` half of the set; absent or empty it
|
|
72
|
+
means **any** signed-in visitor, not "nobody".
|
|
68
73
|
|
|
69
74
|
## Core Patterns
|
|
70
75
|
|
|
@@ -75,7 +80,7 @@ Take every id in `userGroupIds` from `docs/user-groups.md`, which lists each gro
|
|
|
75
80
|
import { Outlet, createFileRoute } from '@tanstack/react-router'
|
|
76
81
|
import { defineVisibility, guardRoute } from '@softr/sdk'
|
|
77
82
|
|
|
78
|
-
const visibility = defineVisibility({
|
|
83
|
+
const visibility = defineVisibility({ userGroupTypes: ['LOGGED_IN_USERS'] })
|
|
79
84
|
|
|
80
85
|
export const Route = createFileRoute('/_app')({
|
|
81
86
|
beforeLoad: guardRoute(visibility),
|
|
@@ -85,7 +90,7 @@ export const Route = createFileRoute('/_app')({
|
|
|
85
90
|
|
|
86
91
|
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.
|
|
87
92
|
|
|
88
|
-
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 `
|
|
93
|
+
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.
|
|
89
94
|
|
|
90
95
|
### Change what a denied visitor gets
|
|
91
96
|
|
|
@@ -125,7 +130,7 @@ All three reasons are required, because nothing fills in a reason the file leave
|
|
|
125
130
|
| `forbidden` | Signed in, in none of the named groups |
|
|
126
131
|
| `alreadyAuthenticated` | Signed-in visitor on a signed-out-only route |
|
|
127
132
|
|
|
128
|
-
That file covers every route at once; a route's own overrides win over it. `
|
|
133
|
+
That file covers every route at once; a route's own overrides win over it. A rule naming both audiences with no `customGroups` admits everyone, so it never redirects and never throws. Only `strategy: 'throw'` reaches an `errorComponent`. A redirect navigates away and the route never renders.
|
|
129
134
|
|
|
130
135
|
### Render the error in place
|
|
131
136
|
|
|
@@ -134,8 +139,8 @@ import { createFileRoute } from '@tanstack/react-router'
|
|
|
134
139
|
import { defineVisibility, guardRoute, isAccessError, isForbiddenError } from '@softr/sdk'
|
|
135
140
|
|
|
136
141
|
const visibility = defineVisibility({
|
|
137
|
-
|
|
138
|
-
|
|
142
|
+
userGroupTypes: ['LOGGED_IN_USERS'],
|
|
143
|
+
customGroups: ['00000000-0000-0000-0000-00000000000a'],
|
|
139
144
|
})
|
|
140
145
|
|
|
141
146
|
export const Route = createFileRoute('/_app/billing')({
|
|
@@ -157,7 +162,7 @@ Use the predicates: `isAccessError` for any of the three, and `isUnauthenticated
|
|
|
157
162
|
|
|
158
163
|
`guardRoute` covers almost everything. When a route needs its own logic afterwards, `assertRouteAccess(visibility, context)` applies the same rule and throws the same access error, using the default behaviour for each reason. It takes no overrides.
|
|
159
164
|
|
|
160
|
-
It also narrows `context.user` for the rest of the handler: `User` under `LOGGED_IN_USERS`, `null` under `NON_LOGGED_IN_USERS`. `guardRoute` cannot, because it hands `beforeLoad` a function rather than running inside one.
|
|
165
|
+
It also narrows `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.
|
|
161
166
|
|
|
162
167
|
```ts
|
|
163
168
|
import { redirect } from '@tanstack/react-router'
|
|
@@ -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.51
|
|
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.
|
|
@@ -35,7 +35,7 @@ const sendWelcomeEmail = defineWorkflow({
|
|
|
35
35
|
id: 'sendWelcomeEmail',
|
|
36
36
|
workflowId: 'c9d8e7f6-a5b4-4c3d-8e2f-1a0b9c8d7e6f',
|
|
37
37
|
triggerId: '926da496-749f-4006-999c-197d8a556122',
|
|
38
|
-
permissions: {
|
|
38
|
+
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
39
39
|
body: {
|
|
40
40
|
email: '{LOGGED_IN_USER:EMAIL}', // resolved server-side from the signed-in user
|
|
41
41
|
name: '{LOGGED_IN_USER:NAME}',
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
//#region src/codegen/write-if-changed.ts
|
|
5
|
-
/** Returns whether it had to write. */
|
|
6
|
-
function writeIfChanged(file, content) {
|
|
7
|
-
if (readOrNull(file) === content) return false;
|
|
8
|
-
mkdirSync(path.dirname(file), { recursive: true });
|
|
9
|
-
writeFileSync(file, content, "utf8");
|
|
10
|
-
return true;
|
|
11
|
-
}
|
|
12
|
-
function readOrNull(file) {
|
|
13
|
-
try {
|
|
14
|
-
return readFileSync(file, "utf8");
|
|
15
|
-
} catch (error) {
|
|
16
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
|
|
17
|
-
throw error;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
//#endregion
|
|
22
|
-
export { writeIfChanged };
|