@softr/sdk 0.0.50 → 0.0.52

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,20 +3,13 @@ import { z } from "zod";
3
3
  //#region src/auth/user.d.ts
4
4
 
5
5
  /**
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
- * }
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.
17
10
  */
18
11
  interface SoftrUserGroups {}
19
- /** A Softr user group id, narrowed to your own ids if you declare {@link SoftrUserGroups}. */
12
+ /** A Softr user group id, narrowed to the app's own ids by {@link SoftrUserGroups}. */
20
13
  type SoftrUserGroupId = SoftrUserGroups extends {
21
14
  ids: infer Id extends string;
22
15
  } ? Id : string;
package/dist/cli.js CHANGED
@@ -2,7 +2,9 @@
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";
5
6
  import { renderSoftrEnv } from "./codegen/softr-env.js";
7
+ import { writeIfChanged } from "./codegen/write-if-changed.js";
6
8
  import { writeDatasourceDocs } from "./codegen/write-datasource-docs.js";
7
9
  import { writeFileSync } from "node:fs";
8
10
  import path from "node:path";
@@ -11,26 +13,44 @@ import { parseArgs } from "node:util";
11
13
  //#region src/cli.ts
12
14
  const OUTPUT = "softr-env.d.ts";
13
15
  const DOCS_DIR = "docs/datasources";
16
+ const USER_GROUPS_DOC = "docs/user-groups.md";
14
17
  async function generate({ json }) {
15
18
  if (json) console.info = console.error;
16
19
  const input = await fetchSoftrEnvInput(metadataSourceFromEnv());
17
20
  writeFileSync(path.resolve(process.cwd(), OUTPUT), renderSoftrEnv(input, version), "utf8");
18
- const docFiles = writeDatasourceDocs(input.datasources, path.resolve(process.cwd(), DOCS_DIR));
19
- const report = {
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 {
20
31
  types: OUTPUT,
21
- docsIndex: `${DOCS_DIR}/README.md`,
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
+ }],
22
41
  datasources: input.datasources.map(({ id, name }) => ({
23
42
  id,
24
43
  name,
25
- doc: `${DOCS_DIR}/${requireDefined(docFiles.get(id))}`
44
+ doc: `${DOCS_DIR}/${requireDefined(datasourceDocs.fileNames.get(id))}`
26
45
  }))
27
46
  };
28
- if (json) {
29
- process.stdout.write(`${JSON.stringify(report)}\n`);
30
- return;
31
- }
47
+ }
48
+ function printReport(report) {
32
49
  console.log(`[softr] wrote ${report.types}`);
33
- console.log(`[softr] wrote ${report.docsIndex}`);
50
+ for (const doc of report.docs) {
51
+ const state = doc.changed ? "updated" : "unchanged";
52
+ console.log(`[softr] ${state} ${doc.path} for ${doc.subject}`);
53
+ }
34
54
  for (const datasource of report.datasources) console.log(`[softr] wrote ${datasource.doc} for ${datasource.name} (${datasource.id})`);
35
55
  }
36
56
  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 = await apiFetch(`${studioApiUrl}/v1/applications/${applicationId}/datasources`, token);
22
+ const [registered, userGroups] = await Promise.all([apiFetch(`${studioApiUrl}/v1/applications/${applicationId}/datasources`, token), apiFetch(`${studioApiUrl}/v1/applications/${applicationId}/access-control/user-groups`, 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
- userGroupIds: null
31
+ userGroups
32
32
  };
33
33
  }
34
34
  async function apiFetch(url, token) {
@@ -13,7 +13,8 @@ function renderSoftrEnv(input, sdkVersion) {
13
13
  "",
14
14
  "declare module \"@softr/sdk\" {",
15
15
  indent(interfaces),
16
- ...input.userGroupIds === null ? [] : ["", indent(renderUserGroups(input.userGroupIds))],
16
+ "",
17
+ indent(renderUserGroups(input.userGroups)),
17
18
  "}",
18
19
  ""
19
20
  ].join("\n");
@@ -33,10 +34,11 @@ function header(sdkVersion) {
33
34
  * THIS FILE IS AUTO GENERATED, DO NOT EDIT THIS MANUALLY
34
35
  *
35
36
  * This file exists for type checking. See docs/datasources/ for field reference
36
- * and guide how to use it.
37
+ * and guide how to use it, and docs/user-groups.md for the app's user groups.
37
38
  *
38
39
  * Generated by @softr/sdk@${sdkVersion}
39
40
  * This file is regenerated automatically when a change is made to the datasource
41
+ * or to the app's user groups
40
42
  */
41
43
 
42
44
  export {};`;
@@ -1,11 +1,32 @@
1
1
  //#region src/codegen/user-groups.ts
2
- function renderUserGroups(ids) {
2
+ const DOC_NOTE = "> This document is generated automatically when a change is made in the app's user groups.";
3
+ function renderUserGroups(groups) {
3
4
  return [
4
5
  "interface SoftrUserGroups {",
5
- ` ids: ${ids.length === 0 ? "never" : ids.map((id) => JSON.stringify(id)).join(" | ")};`,
6
+ ` ids: ${groups.length === 0 ? "never" : groups.map(({ id }) => JSON.stringify(id)).join(" | ")};`,
6
7
  "}"
7
8
  ].join("\n");
8
9
  }
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
+ }
9
30
 
10
31
  //#endregion
11
- export { renderUserGroups };
32
+ export { renderUserGroups, renderUserGroupsDoc };
@@ -1,24 +1,26 @@
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 { mkdirSync, writeFileSync } from "node:fs";
4
+ import { writeIfChanged } from "./write-if-changed.js";
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.
11
+ * directory a developer ran the bin from. `changed` is true when any file it
12
+ * wrote now reads differently.
12
13
  */
13
14
  function writeDatasourceDocs(datasources, docsDir) {
14
15
  const context = {
15
16
  datasources: new Map(datasources.map((datasource) => [datasource.id, datasource])),
16
17
  fileNames: docFileNames(datasources)
17
18
  };
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;
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
+ };
22
24
  }
23
25
 
24
26
  //#endregion
@@ -0,0 +1,22 @@
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 };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.50";
2
+ var version = "0.0.52";
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",
3
+ "version": "0.0.52",
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
14
+ library_version: 0.0.52
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> // plain string unless you declare your ids
140
+ userGroupIds: Array<SoftrUserGroupId> // the app's own group 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
19
+ library_version: 0.0.52
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
15
+ library_version: 0.0.52
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,11 +4,12 @@ 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.
7
+ middleware. Covers the null guest and the generated SoftrUserGroups that
8
+ narrows group ids to the app's own.
8
9
  metadata:
9
10
  type: sub-skill
10
11
  library: softr-sdk
11
- library_version: 0.0.50
12
+ library_version: 0.0.52
12
13
  ---
13
14
 
14
15
  # The current user
@@ -29,7 +30,7 @@ type User = {
29
30
  id: string
30
31
  email: string
31
32
  fullName: string
32
- userGroupIds: Array<SoftrUserGroupId> // plain string unless you declare your ids
33
+ userGroupIds: Array<SoftrUserGroupId> // the app's own group ids
33
34
  }
34
35
  ```
35
36
 
@@ -71,7 +72,7 @@ const myInvoices = defineQuery({
71
72
  // The datasource id from the table's registration on the application — not the table id.
72
73
  datasourceId: '00000000-0000-0000-0000-000000000001',
73
74
  fields: { owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' },
74
- permissions: { userGroup: 'LOGGED_IN_USERS' },
75
+ permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
75
76
  })
76
77
 
77
78
  const getMyInvoices = createServerFn().handler(async ({ context }) => {
@@ -83,6 +84,12 @@ const getMyInvoices = createServerFn().handler(async ({ context }) => {
83
84
 
84
85
  `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.
85
86
 
87
+ ### Group ids are the app's own
88
+
89
+ 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.
90
+
91
+ `user.userGroupIds` and every `customGroups` the SDK accepts take only those ids, so an id the app does not have stops compiling. A new group appears in the list once it exists on the app.
92
+
86
93
  ## References
87
94
 
88
95
  - [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
13
+ library_version: 0.0.52
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
13
+ library_version: 0.0.52
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
12
+ library_version: 0.0.52
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
14
+ library_version: 0.0.52
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
16
+ library_version: 0.0.52
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
12
+ library_version: 0.0.52
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
16
+ library_version: 0.0.52
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,19 +51,27 @@ 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".
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".
73
+
74
+ Take every id in `customGroups` 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.
66
75
 
67
76
  ## Core Patterns
68
77
 
@@ -73,7 +82,7 @@ Absent or empty `userGroupIds` means **any** signed-in visitor, not "nobody".
73
82
  import { Outlet, createFileRoute } from '@tanstack/react-router'
74
83
  import { defineVisibility, guardRoute } from '@softr/sdk'
75
84
 
76
- const visibility = defineVisibility({ userGroup: 'LOGGED_IN_USERS' })
85
+ const visibility = defineVisibility({ userGroupTypes: ['LOGGED_IN_USERS'] })
77
86
 
78
87
  export const Route = createFileRoute('/_app')({
79
88
  beforeLoad: guardRoute(visibility),
@@ -83,7 +92,7 @@ export const Route = createFileRoute('/_app')({
83
92
 
84
93
  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.
85
94
 
86
- 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.
95
+ 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.
87
96
 
88
97
  ### Change what a denied visitor gets
89
98
 
@@ -123,7 +132,7 @@ All three reasons are required, because nothing fills in a reason the file leave
123
132
  | `forbidden` | Signed in, in none of the named groups |
124
133
  | `alreadyAuthenticated` | Signed-in visitor on a signed-out-only route |
125
134
 
126
- 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.
135
+ 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.
127
136
 
128
137
  ### Render the error in place
129
138
 
@@ -132,8 +141,8 @@ import { createFileRoute } from '@tanstack/react-router'
132
141
  import { defineVisibility, guardRoute, isAccessError, isForbiddenError } from '@softr/sdk'
133
142
 
134
143
  const visibility = defineVisibility({
135
- userGroup: 'LOGGED_IN_USERS',
136
- userGroupIds: ['00000000-0000-0000-0000-00000000000a'],
144
+ userGroupTypes: ['LOGGED_IN_USERS'],
145
+ customGroups: ['00000000-0000-0000-0000-00000000000a'],
137
146
  })
138
147
 
139
148
  export const Route = createFileRoute('/_app/billing')({
@@ -155,7 +164,7 @@ Use the predicates: `isAccessError` for any of the three, and `isUnauthenticated
155
164
 
156
165
  `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.
157
166
 
158
- 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.
167
+ 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.
159
168
 
160
169
  ```ts
161
170
  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
12
+ library_version: 0.0.52
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}',