@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 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
- userGroup: "ALL_USERS";
6
+ userGroupTypes: readonly ["NON_LOGGED_IN_USERS"];
7
+ customGroups?: never;
7
8
  } | {
8
- userGroup: "NON_LOGGED_IN_USERS";
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: { userGroup: "ALL_USERS" },
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: { userGroup: "LOGGED_IN_USERS", userGroupIds: ["grp_staff"] },
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: { userGroup: "LOGGED_IN_USERS", userGroupIds: ["grp_staff"] },
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: { userGroup: "LOGGED_IN_USERS" },
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: { userGroup: "LOGGED_IN_USERS" },
230
+ * permissions: { userGroupTypes: ["LOGGED_IN_USERS"] },
232
231
  * })
233
232
  *
234
233
  * const listEvents = integration.createFetcher(listCalendarEvents)
@@ -3,13 +3,20 @@ import { z } from "zod";
3
3
  //#region src/auth/user.d.ts
4
4
 
5
5
  /**
6
- * The app's group ids. The app's generated types declare them, the predefined
7
- * groups included. Every group id the SDK takes and returns narrows to them.
8
- * Never declare it by hand: a second declaration does not merge with the
9
- * generated one.
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 the app's own ids by {@link SoftrUserGroups}. */
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 report = writeDocs(input);
22
- if (json) {
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
- docs: [{
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(datasourceDocs.fileNames.get(id))}`
25
+ doc: `${DOCS_DIR}/${requireDefined(docFiles.get(id))}`
45
26
  }))
46
27
  };
47
- }
48
- function printReport(report) {
49
- console.log(`[softr] wrote ${report.types}`);
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: { userGroup: 'ALL_USERS' },",
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: { userGroup: 'LOGGED_IN_USERS' },",
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 [registered, userGroups] = await Promise.all([apiFetch(`${studioApiUrl}/v1/applications/${applicationId}/datasources`, token), apiFetch(`${studioApiUrl}/v1/applications/${applicationId}/access-control/user-groups`, token)]);
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
- userGroups
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, and docs/user-groups.md for the app's user groups.
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
- const DOC_NOTE = "> This document is generated automatically when a change is made in the app's user groups.";
3
- function renderUserGroups(groups) {
2
+ function renderUserGroups(ids) {
4
3
  return [
5
4
  "interface SoftrUserGroups {",
6
- ` ids: ${groups.length === 0 ? "never" : groups.map(({ id }) => JSON.stringify(id)).join(" | ")};`,
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, renderUserGroupsDoc };
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 { writeIfChanged } from "./write-if-changed.js";
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. `changed` is true when any file it
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
- const writes = [writeIfChanged(path.join(docsDir, "README.md"), renderDatasourceIndex(datasources, context)), ...datasources.map((datasource) => writeIfChanged(path.join(docsDir, requireDefined(context.fileNames.get(datasource.id))), renderDatasourceDoc(datasource, context)))];
20
- return {
21
- fileNames: context.fileNames,
22
- changed: writes.includes(true)
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
- /** Ands a list of filters into one group, which is what an accumulated `where` needs. */
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
- return group("AND", filters);
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
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.50-alpha.1";
2
+ var version = "0.0.51";
3
3
 
4
4
  //#endregion
5
5
  export { version };
@@ -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({ userGroup: "LOGGED_IN_USERS" })
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({ userGroup: "LOGGED_IN_USERS" })
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
- * - `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.
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
- userGroup: "ALL_USERS";
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
- type Narrowed<R extends VisibilityRule> = R extends {
47
- userGroup: "LOGGED_IN_USERS";
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
- } : R extends {
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({ userGroup: "LOGGED_IN_USERS" })
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
- 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
- }
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({ userGroup: "LOGGED_IN_USERS" })
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.50-alpha.1",
3
+ "version": "0.0.51",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: core
13
13
  library: softr-sdk
14
- library_version: 0.0.50-alpha.1
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> // the app's own group ids
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.50-alpha.1
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.50-alpha.1
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: { userGroup: 'LOGGED_IN_USERS' },
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 and the generated SoftrUserGroups that
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.50-alpha.1
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> // the app's own group ids
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: { userGroup: 'LOGGED_IN_USERS' },
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.50-alpha.1
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: { userGroup: 'LOGGED_IN_USERS' },
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.50-alpha.1
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: { userGroup: 'LOGGED_IN_USERS' },
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: { userGroup: 'LOGGED_IN_USERS' },
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: { userGroup: 'LOGGED_IN_USERS' },
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.50-alpha.1
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.50-alpha.1
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: { userGroup: 'LOGGED_IN_USERS' },
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 `{ userGroup: 'LOGGED_IN_USERS' }`. A visitor who is not logged in must never change or remove a record, so `ALL_USERS` and `NON_LOGGED_IN_USERS` are rejected for these two operations. A create may stay open to visitors, for a sign-up or a contact form.
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: { userGroup: 'LOGGED_IN_USERS' },
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: { userGroup: 'LOGGED_IN_USERS' },
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: { userGroup: 'LOGGED_IN_USERS' },
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.50-alpha.1
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: { userGroup: 'ALL_USERS' },
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
- - `{ userGroup: 'ALL_USERS' }`: everyone, signed out included.
58
- - `{ userGroup: 'NON_LOGGED_IN_USERS' }`: signed-out visitors only.
59
- - `{ userGroup: 'LOGGED_IN_USERS' }`: any signed-in visitor, narrowed to particular groups with `userGroupIds: ['00000000-0000-0000-0000-00000000000a']`.
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: { userGroup: 'LOGGED_IN_USERS' },
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: { userGroup: 'ALL_USERS' },
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.50-alpha.1
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
- userGroup: 'LOGGED_IN_USERS',
30
- userGroupIds: ['00000000-0000-0000-0000-00000000000a'],
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 ALL_USERS, LOGGED_IN_USERS or NON_LOGGED_IN_USERS, userGroupIds, and the
6
- ways to apply a rule: guardRoute and assertRouteAccess in a route,
7
- isAuthorized and checkAccess anywhere. Covers per-reason overrides, the
8
- redirect and throw strategies, rendering an access error with isAccessError,
9
- isUnauthenticatedError, isForbiddenError and isAlreadyAuthenticatedError,
10
- guarding a layout, and the lint rule that requires a declaration alongside the
11
- rules that reject a duplicate, computed or root-level one.
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.50-alpha.1
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({ userGroup: 'LOGGED_IN_USERS' })
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
- The four rules:
54
+ `userGroupTypes` is a set, so a rule names one audience or several:
54
55
 
55
56
  ```ts
56
- defineVisibility({ userGroup: 'ALL_USERS' }) // everyone, signed in or not
57
- defineVisibility({ userGroup: 'NON_LOGGED_IN_USERS' }) // signed-out only: login, signup
58
- defineVisibility({ userGroup: 'LOGGED_IN_USERS' }) // any signed-in visitor
59
- defineVisibility({ // those groups only
60
- userGroup: 'LOGGED_IN_USERS',
61
- userGroupIds: ['00000000-0000-0000-0000-00000000000a'],
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
- Absent or empty `userGroupIds` means **any** signed-in visitor, not "nobody".
66
-
67
- Take every id in `userGroupIds` from `docs/user-groups.md`, which lists each group the app has with its name and id. An id the app does not have is a type error.
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({ userGroup: 'LOGGED_IN_USERS' })
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 `userGroupIds` rule with `forbidden` set to throw — should not sit on the shell itself, or the error replaces the nav and chrome. Put it on a layout inside the shell, or on the restricted routes, so the error renders inside the chrome.
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. `ALL_USERS` admits everyone, so it never redirects and never throws. Only `strategy: 'throw'` reaches an `errorComponent`. A redirect navigates away and the route never renders.
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
- userGroup: 'LOGGED_IN_USERS',
138
- userGroupIds: ['00000000-0000-0000-0000-00000000000a'],
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.50-alpha.1
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: { userGroup: 'LOGGED_IN_USERS' },
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 };