@softr/sdk 0.0.68-alpha.2 → 0.0.68-props.1

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.
Files changed (43) hide show
  1. package/dist/assertions.js +1 -1
  2. package/dist/auth/isomorphic.d.ts +6 -6
  3. package/dist/auth/platform.d.ts +0 -1
  4. package/dist/auth/platform.js +33 -13
  5. package/dist/auth/session.js +1 -1
  6. package/dist/auth/user.d.ts +8 -1
  7. package/dist/auth/user.js +2 -1
  8. package/dist/cli.js +38 -12
  9. package/dist/codegen/datasource-docs.js +92 -1
  10. package/dist/codegen/datasource-types.js +16 -1
  11. package/dist/codegen/fetch-metadata.js +30 -12
  12. package/dist/codegen/softr-env.js +2 -1
  13. package/dist/cookies.js +10 -2
  14. package/dist/core/integration.d.ts +0 -7
  15. package/dist/core/integration.js +3 -10
  16. package/dist/default-error-overlay.d.ts +2 -2
  17. package/dist/formatting.d.ts +1 -1
  18. package/dist/formatting.js +1 -1
  19. package/dist/index.d.ts +2 -2
  20. package/dist/mcp.d.ts +2 -2
  21. package/dist/mcp.js +2 -2
  22. package/dist/package.js +1 -1
  23. package/dist/pwa.d.ts +2 -2
  24. package/dist/pwa.js +1 -1
  25. package/dist/softr-config.schema.js +52 -24
  26. package/dist/visibility/route.js +1 -1
  27. package/dist/vite.js +1 -19
  28. package/package.json +33 -10
  29. package/schema/softr.schema.json +13 -0
  30. package/skills/softr-sdk/SKILL.md +1 -5
  31. package/skills/softr-sdk/auth/SKILL.md +1 -1
  32. package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
  33. package/skills/softr-sdk/current-user/SKILL.md +24 -3
  34. package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
  35. package/skills/softr-sdk/formatting/SKILL.md +1 -1
  36. package/skills/softr-sdk/integrations/SKILL.md +5 -59
  37. package/skills/softr-sdk/mcp/SKILL.md +1 -1
  38. package/skills/softr-sdk/pwa/SKILL.md +1 -1
  39. package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
  40. package/skills/softr-sdk/record-queries/SKILL.md +1 -1
  41. package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
  42. package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
  43. package/skills/softr-sdk/workflows/SKILL.md +1 -1
@@ -59,4 +59,4 @@ function throwAtCaller(f, ex) {
59
59
  }
60
60
 
61
61
  //#endregion
62
- export { assert, panic, requireDefined, softAssert };
62
+ export { assert, panic, requireDefined, softAssert, throwAtCaller };
@@ -2,9 +2,9 @@ import { User } from "./user.js";
2
2
  import { Formatting } from "../formatting.js";
3
3
  import { Permissions } from "./permissions.js";
4
4
  import { SoftrContext } from "./context.js";
5
- import * as react0 from "react";
5
+ import * as react6 from "react";
6
6
  import { ReactNode } from "react";
7
- import * as _tanstack_react_start0 from "@tanstack/react-start";
7
+ import * as _tanstack_react_start1 from "@tanstack/react-start";
8
8
 
9
9
  //#region src/auth/isomorphic.d.ts
10
10
 
@@ -21,7 +21,7 @@ import * as _tanstack_react_start0 from "@tanstack/react-start";
21
21
  * requestMiddleware: [softrAuthMiddleware()],
22
22
  * }))
23
23
  */
24
- declare function softrAuthMiddleware(): _tanstack_react_start0.RequestMiddlewareAfterServer<{}, readonly [_tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>], {
24
+ declare function softrAuthMiddleware(): _tanstack_react_start1.RequestMiddlewareAfterServer<{}, readonly [_tanstack_react_start1.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start1.RequestMiddlewareAfterServer<{}, undefined, undefined>], {
25
25
  user: User | null;
26
26
  permissions: Permissions;
27
27
  formatting: Formatting;
@@ -35,7 +35,7 @@ declare function softrAuthMiddleware(): _tanstack_react_start0.RequestMiddleware
35
35
  * beforeLoad: async () => await getSoftrContext(),
36
36
  * })
37
37
  */
38
- declare const getSoftrContext: _tanstack_react_start0.OptionalFetcher<undefined, undefined, Promise<SoftrContext>>;
38
+ declare const getSoftrContext: _tanstack_react_start1.OptionalFetcher<undefined, undefined, Promise<SoftrContext>>;
39
39
  /**
40
40
  * The context to hand `createRouter`, before any request has resolved one.
41
41
  *
@@ -83,7 +83,7 @@ type AuthOptions = {
83
83
  * staleTime: 60_000,
84
84
  * })
85
85
  */
86
- declare const getAuthSettings: _tanstack_react_start0.OptionalFetcher<undefined, undefined, Promise<AuthOptions>>;
86
+ declare const getAuthSettings: _tanstack_react_start1.OptionalFetcher<undefined, undefined, Promise<AuthOptions>>;
87
87
  /**
88
88
  * Makes the auth options readable with {@link useAuthSettings} anywhere below.
89
89
  *
@@ -103,7 +103,7 @@ declare function AuthSettingsProvider({
103
103
  }: {
104
104
  value: AuthOptions;
105
105
  children?: ReactNode;
106
- }): react0.JSX.Element;
106
+ }): react6.JSX.Element;
107
107
  /**
108
108
  * Which ways in this app offers. Requires an {@link AuthSettingsProvider}
109
109
  * above it.
@@ -1,4 +1,3 @@
1
- import "./user.js";
2
1
  import "./context.js";
3
2
  import { z } from "zod";
4
3
 
@@ -1,8 +1,8 @@
1
- import { panic } from "../assertions.js";
1
+ import { assert, panic } from "../assertions.js";
2
2
  import { fetch } from "../fetch.js";
3
3
  import { permissionsSchema } from "./permissions.js";
4
4
  import { sessionConfig } from "./config.js";
5
- import { userSchema } from "./user.js";
5
+ import { softrConfig } from "../softr-config.js";
6
6
  import { z } from "zod";
7
7
 
8
8
  //#region src/auth/platform.ts
@@ -33,10 +33,30 @@ const authSettingsSchema = z.object({
33
33
  sso: z.object({ type: z.enum(["SAML", "OPEN_ID"]).nullable() }).nullable()
34
34
  });
35
35
  function makePlatform(applicationId) {
36
+ const aliases = Object.entries(softrConfig().user?.properties ?? {});
37
+ const withAttributes = aliases.length > 0 ? "?includeAttributes=true" : "";
38
+ const userSchema = z.object({
39
+ id: z.string(),
40
+ email: z.string(),
41
+ fullName: z.string(),
42
+ userGroupIds: z.array(z.string()),
43
+ attributes: z.record(z.string(), z.unknown()).optional()
44
+ }).transform(({ attributes,...identity }) => {
45
+ const properties = {};
46
+ for (const [alias, field] of aliases) {
47
+ assert(attributes !== void 0, "The signed-in user came back without their properties.");
48
+ assert(Object.hasOwn(attributes, field), `softr.json user.properties.${alias} names the field "${field}", which the users table does not have.`);
49
+ properties[alias] = attributes[field];
50
+ }
51
+ return {
52
+ ...identity,
53
+ properties
54
+ };
55
+ });
36
56
  return {
37
57
  authSettings: () => request(`/v1/vibe/applications/${applicationId}/users/auth-settings`, { method: "GET" }, authSettingsSchema),
38
58
  session: async (token) => {
39
- const result = await request(`/v1/vibe/applications/${applicationId}/users/session`, {
59
+ const result = await request(`/v1/vibe/applications/${applicationId}/users/session${withAttributes}`, {
40
60
  method: "GET",
41
61
  token
42
62
  }, z.object({
@@ -46,7 +66,7 @@ function makePlatform(applicationId) {
46
66
  if (result.status === 404) panic(`Application ${applicationId} not found. Check SOFTR_APPLICATION_ID.`);
47
67
  return result;
48
68
  },
49
- tokens: (credentials) => request(`/v1/vibe/applications/${applicationId}/users/tokens`, {
69
+ tokens: (credentials) => request(`/v1/vibe/applications/${applicationId}/users/tokens${withAttributes}`, {
50
70
  method: "POST",
51
71
  json: credentials
52
72
  }, z.object({
@@ -69,7 +89,7 @@ function makePlatform(applicationId) {
69
89
  method: "POST",
70
90
  json: { email }
71
91
  }, z.null()),
72
- signInWithCode: (credentials) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-code`, {
92
+ signInWithCode: (credentials) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-code${withAttributes}`, {
73
93
  method: "POST",
74
94
  json: credentials
75
95
  }, z.object({
@@ -88,7 +108,7 @@ function makePlatform(applicationId) {
88
108
  challengeToken: z.string()
89
109
  }).nullish()
90
110
  })),
91
- signInWithGoogle: (body) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-google`, {
111
+ signInWithGoogle: (body) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-google${withAttributes}`, {
92
112
  method: "POST",
93
113
  json: body
94
114
  }, z.object({
@@ -107,7 +127,7 @@ function makePlatform(applicationId) {
107
127
  challengeToken: z.string()
108
128
  }).nullish()
109
129
  })),
110
- signInWithMagicLink: (body) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-magic-link`, {
130
+ signInWithMagicLink: (body) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-magic-link${withAttributes}`, {
111
131
  method: "POST",
112
132
  json: body
113
133
  }, z.object({
@@ -134,7 +154,7 @@ function makePlatform(applicationId) {
134
154
  method: "POST",
135
155
  json: body
136
156
  }, z.object({ email: z.string() })),
137
- setPassword: (body) => request(`/v1/vibe/applications/${applicationId}/users/set-password`, {
157
+ setPassword: (body) => request(`/v1/vibe/applications/${applicationId}/users/set-password${withAttributes}`, {
138
158
  method: "POST",
139
159
  json: body
140
160
  }, z.object({
@@ -157,7 +177,7 @@ function makePlatform(applicationId) {
157
177
  method: "POST",
158
178
  json: body
159
179
  }, z.null()),
160
- signUpWithCode: (body) => request(`/v1/vibe/applications/${applicationId}/users/sign-up/with-code`, {
180
+ signUpWithCode: (body) => request(`/v1/vibe/applications/${applicationId}/users/sign-up/with-code${withAttributes}`, {
161
181
  method: "POST",
162
182
  json: body
163
183
  }, z.object({
@@ -176,7 +196,7 @@ function makePlatform(applicationId) {
176
196
  challengeToken: z.string()
177
197
  }).nullish()
178
198
  })),
179
- signUpWithPassword: (body) => request(`/v1/vibe/applications/${applicationId}/users/sign-up/with-password`, {
199
+ signUpWithPassword: (body) => request(`/v1/vibe/applications/${applicationId}/users/sign-up/with-password${withAttributes}`, {
180
200
  method: "POST",
181
201
  json: body
182
202
  }, z.object({
@@ -202,7 +222,7 @@ function makePlatform(applicationId) {
202
222
  otpauthUri: z.string(),
203
223
  qrPngBase64: z.string()
204
224
  })),
205
- authenticatorSetupComplete: (token, code) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/setup/complete`, {
225
+ authenticatorSetupComplete: (token, code) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/setup/complete${withAttributes}`, {
206
226
  method: "POST",
207
227
  token,
208
228
  json: { code }
@@ -214,7 +234,7 @@ function makePlatform(applicationId) {
214
234
  }),
215
235
  backupCodes: z.array(z.string())
216
236
  })),
217
- authenticatorSetupDismiss: (token) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/setup/dismiss`, {
237
+ authenticatorSetupDismiss: (token) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/setup/dismiss${withAttributes}`, {
218
238
  method: "POST",
219
239
  token
220
240
  }, z.object({
@@ -233,7 +253,7 @@ function makePlatform(applicationId) {
233
253
  challengeToken: z.string()
234
254
  }).nullish()
235
255
  })),
236
- authenticatorVerify: (token, body) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/totp/verify`, {
256
+ authenticatorVerify: (token, body) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/totp/verify${withAttributes}`, {
237
257
  method: "POST",
238
258
  token,
239
259
  json: body
@@ -2,10 +2,10 @@ import { readRequestCookies, serializeCookie, writeCookies } from "../cookies.js
2
2
  import { permissionsSchema } from "./permissions.js";
3
3
  import { sessionConfig } from "./config.js";
4
4
  import { userSchema } from "./user.js";
5
+ import { z } from "zod";
5
6
  import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
6
7
  import { AsyncLocalStorage } from "node:async_hooks";
7
8
  import { getRequestProtocol } from "@tanstack/react-start/server";
8
- import { z } from "zod";
9
9
 
10
10
  //#region src/auth/session.ts
11
11
  const SESSION_TOKEN = "session_token";
@@ -13,12 +13,19 @@ interface SoftrUserGroups {}
13
13
  type SoftrUserGroupId = SoftrUserGroups extends {
14
14
  ids: infer Id extends string;
15
15
  } ? Id : string;
16
+ /**
17
+ * The fields of the visitor's row that softr.json declares under
18
+ * `user.properties`, keyed by their aliases. The app's generated types declare
19
+ * it.
20
+ */
21
+ interface SoftrUserProperties {}
16
22
  /** The signed-in visitor. */
17
23
  type User = {
18
24
  id: string;
19
25
  email: string;
20
26
  fullName: string;
21
27
  userGroupIds: Array<SoftrUserGroupId>;
28
+ properties: SoftrUserProperties;
22
29
  };
23
30
  //#endregion
24
- export { SoftrUserGroupId, SoftrUserGroups, User };
31
+ export { SoftrUserGroupId, SoftrUserGroups, SoftrUserProperties, User };
package/dist/auth/user.js CHANGED
@@ -5,7 +5,8 @@ const userSchema = z.object({
5
5
  id: z.string(),
6
6
  email: z.string(),
7
7
  fullName: z.string(),
8
- userGroupIds: z.array(z.string())
8
+ userGroupIds: z.array(z.string()),
9
+ properties: z.record(z.string(), z.unknown())
9
10
  });
10
11
 
11
12
  //#endregion
package/dist/cli.js CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { version } from "./package.js";
3
3
  import { requireDefined } from "./assertions.js";
4
+ import { SoftrConfigError, loadSoftrConfig } from "./softr-config.schema.js";
4
5
  import { fetchSoftrEnvInput, metadataSourceFromEnv } from "./codegen/fetch-metadata.js";
6
+ import { renderUserPropertiesDoc } from "./codegen/datasource-docs.js";
5
7
  import { renderUserGroupsDoc } from "./codegen/user-groups.js";
6
8
  import { renderSoftrEnv } from "./codegen/softr-env.js";
7
9
  import { writeIfChanged } from "./codegen/write-if-changed.js";
8
10
  import { writeDatasourceDocs } from "./codegen/write-datasource-docs.js";
9
- import { writeFileSync } from "node:fs";
11
+ import { rmSync, writeFileSync } from "node:fs";
10
12
  import path from "node:path";
11
13
  import { parseArgs } from "node:util";
12
14
 
@@ -14,9 +16,10 @@ import { parseArgs } from "node:util";
14
16
  const OUTPUT = "softr-env.d.ts";
15
17
  const DOCS_DIR = "docs/datasources";
16
18
  const USER_GROUPS_DOC = "docs/user-groups.md";
19
+ const USER_PROPERTIES_DOC = "docs/user-properties.md";
17
20
  async function generate({ json }) {
18
21
  if (json) console.info = console.error;
19
- const input = await fetchSoftrEnvInput(metadataSourceFromEnv());
22
+ const input = await fetchSoftrEnvInput(metadataSourceFromEnv(), loadSoftrConfig(process.cwd()).user?.properties ?? {});
20
23
  writeFileSync(path.resolve(process.cwd(), OUTPUT), renderSoftrEnv(input, version), "utf8");
21
24
  const report = writeDocs(input);
22
25
  if (json) {
@@ -26,18 +29,30 @@ async function generate({ json }) {
26
29
  printReport(report);
27
30
  }
28
31
  function writeDocs(input) {
32
+ if (input.users === null) rmSync(path.resolve(process.cwd(), USER_PROPERTIES_DOC), { force: true });
29
33
  const datasourceDocs = writeDatasourceDocs(input.datasources, path.resolve(process.cwd(), DOCS_DIR));
30
34
  return {
31
35
  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
- }],
36
+ docs: [
37
+ {
38
+ subject: "datasources",
39
+ path: `${DOCS_DIR}/README.md`,
40
+ changed: datasourceDocs.changed
41
+ },
42
+ {
43
+ subject: "user groups",
44
+ path: USER_GROUPS_DOC,
45
+ changed: writeIfChanged(path.resolve(process.cwd(), USER_GROUPS_DOC), renderUserGroupsDoc(input.userGroups))
46
+ },
47
+ ...input.users === null ? [] : [{
48
+ subject: "user properties",
49
+ path: USER_PROPERTIES_DOC,
50
+ changed: writeIfChanged(path.resolve(process.cwd(), USER_PROPERTIES_DOC), renderUserPropertiesDoc(input.users, {
51
+ datasources: new Map(input.datasources.map((d) => [d.id, d])),
52
+ fileNames: datasourceDocs.fileNames
53
+ }))
54
+ }]
55
+ ],
41
56
  datasources: input.datasources.map(({ id, name }) => ({
42
57
  id,
43
58
  name,
@@ -61,7 +76,18 @@ const { positionals, values } = parseArgs({
61
76
  } }
62
77
  });
63
78
  const [command] = positionals;
64
- if (command === "generate") await generate({ json: values.json });
79
+ if (command === "generate") try {
80
+ await generate({ json: values.json });
81
+ } catch (error) {
82
+ if (!values.json) throw error;
83
+ const kind = error instanceof SoftrConfigError ? "softr-config" : "other";
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ process.stdout.write(`${JSON.stringify({ error: {
86
+ kind,
87
+ message
88
+ } })}\n`);
89
+ process.exit(1);
90
+ }
65
91
  else {
66
92
  console.error(command ? `softr-sdk: unknown command '${command}'` : "softr-sdk: no command given");
67
93
  console.error("usage: softr-sdk generate [--json]");
@@ -18,6 +18,97 @@ function renderDatasourceDoc(datasource, context) {
18
18
  ...renderExamples(datasource, context.datasources)
19
19
  ].join("\n");
20
20
  }
21
+ function renderUserPropertiesDoc(users, context) {
22
+ const aliases = Object.entries(users.properties);
23
+ assert(aliases.length > 0, "Expected user properties to document, but got none");
24
+ const [first] = requireDefined(aliases[0]);
25
+ const registered = linkedDatasource(users.tableId, context.datasources);
26
+ const types = /* @__PURE__ */ new Set();
27
+ const sampled = aliases.filter(([, field]) => {
28
+ const type = fieldTypeLiteral(field);
29
+ if (types.has(type)) return false;
30
+ types.add(type);
31
+ return true;
32
+ });
33
+ return [
34
+ "# User properties",
35
+ "",
36
+ `Users datasource: ${displayName(users.name) || users.tableId}` + (registered === void 0 ? "" : ` \nRegistered as: [${datasourceLabel(registered)}](./datasources/${requireDefined(context.fileNames.get(registered.id))}) (${registered.id})`),
37
+ "",
38
+ "> This document is generated automatically when softr.json's `user.properties` or the users table changes.",
39
+ "",
40
+ "Each alias below is declared in softr.json under `user.properties`, and read as `user.properties.<alias>`.",
41
+ "",
42
+ "## Field reference",
43
+ "",
44
+ ...aliases.flatMap(([alias, field]) => {
45
+ const linkedId = option(field.options, "linkedTableId");
46
+ const linked = typeof linkedId === "string" ? linkedDatasource(linkedId, context.datasources) : void 0;
47
+ return [
48
+ `### ${alias}`,
49
+ "",
50
+ `- Field: ${displayName(field.name)}`,
51
+ `- ID: ${field.id}`,
52
+ `- Type: ${fieldTypeLiteral(field).slice(1, -1)}`,
53
+ ...optionLines(field),
54
+ ...choiceLines(field),
55
+ ...typeof linkedId !== "string" ? [] : linked === void 0 ? [`- Links to: ${linkedId}, not registered`] : [`- Links to: [${datasourceLabel(linked)}](./datasources/${requireDefined(context.fileNames.get(linked.id))}) (${linked.id})`],
56
+ ""
57
+ ];
58
+ }),
59
+ "## Read shape",
60
+ "",
61
+ "In a component:",
62
+ "",
63
+ "```tsx",
64
+ "import { useCurrentUser } from '@softr/sdk'",
65
+ "",
66
+ "const user = useCurrentUser()",
67
+ "if (user === null) return null",
68
+ "",
69
+ ...sampled.map(([alias, field]) => `user.properties.${alias} // ${fieldTypeLiteral(field).slice(1, -1)}`),
70
+ "```",
71
+ "",
72
+ "In a route's `beforeLoad`, to send a visitor elsewhere. It guards navigation only, not data:",
73
+ "",
74
+ "```tsx",
75
+ "import { createFileRoute, redirect } from '@tanstack/react-router'",
76
+ "import { assertRouteAccess, defineVisibility } from '@softr/sdk'",
77
+ "",
78
+ "const visibility = defineVisibility({ userGroupTypes: ['LOGGED_IN_USERS'] })",
79
+ "",
80
+ "export const Route = createFileRoute('/example')({",
81
+ " beforeLoad: (opts) => {",
82
+ " assertRouteAccess(visibility, opts)",
83
+ ` if (opts.context.user.properties.${first} == null) throw redirect({ to: '/' })`,
84
+ " },",
85
+ "})",
86
+ "```",
87
+ "",
88
+ "In a server function, called from a route's `loader`. The handler checks the visitor itself:",
89
+ "",
90
+ "```tsx",
91
+ "import { createFileRoute } from '@tanstack/react-router'",
92
+ "import { createServerFn } from '@tanstack/react-start'",
93
+ "",
94
+ `const get${pascalCase(first)} = createServerFn().handler(async ({ context }) => {`,
95
+ " if (!context.user) throw new Error('Not signed in')",
96
+ ` return context.user.properties.${first}`,
97
+ "})",
98
+ "",
99
+ "export const Route = createFileRoute('/example')({",
100
+ ` loader: () => get${pascalCase(first)}(),`,
101
+ " component: Example,",
102
+ "})",
103
+ "",
104
+ "function Example() {",
105
+ ` const ${first} = Route.useLoaderData()`,
106
+ " // ...",
107
+ "}",
108
+ "```",
109
+ ""
110
+ ].join("\n");
111
+ }
21
112
  /** Two trailing spaces are a markdown hard break, which keeps the block one paragraph. */
22
113
  function summary(datasource, context) {
23
114
  const lines = [`Datasource ID: ${datasource.id}`];
@@ -315,4 +406,4 @@ function words(value) {
315
406
  }
316
407
 
317
408
  //#endregion
318
- export { renderDatasourceDoc, renderDatasourceIndex };
409
+ export { renderDatasourceDoc, renderDatasourceIndex, renderUserPropertiesDoc };
@@ -251,6 +251,21 @@ function linkedRecord(options, context) {
251
251
  if (linked === void 0 || linked.primaryFieldId === "") return "LinkedRecord";
252
252
  return `LinkedRecord<NonNullable<${`${nameOf(linked.id, context)}Read[${quote(linked.primaryFieldId)}]`}>>`;
253
253
  }
254
+ /** `SoftrUserProperties`, each alias typed as its field reads. */
255
+ function renderUserProperties(properties, datasources) {
256
+ const context = {
257
+ datasources: byId(datasources),
258
+ names: typeNames(datasources)
259
+ };
260
+ return [
261
+ "interface SoftrUserProperties {",
262
+ ...Object.entries(properties).map(([alias, field]) => indent(readMember({
263
+ ...field,
264
+ id: alias
265
+ }, context))),
266
+ "}"
267
+ ].join("\n");
268
+ }
254
269
  function renderRegistry(datasources, context) {
255
270
  return [
256
271
  "interface SoftrDatasources {",
@@ -494,4 +509,4 @@ function indent(block) {
494
509
  }
495
510
 
496
511
  //#endregion
497
- export { choiceEntries, fieldTypeLiteral, isWritable, linkedDatasource, option, readExample, renderDatasourceTypes, resolvedShape, scalarExample, underlyingType };
512
+ export { choiceEntries, fieldTypeLiteral, isWritable, linkedDatasource, option, readExample, renderDatasourceTypes, renderUserProperties, resolvedShape, scalarExample, underlyingType };
@@ -1,5 +1,6 @@
1
1
  import { panic } from "../assertions.js";
2
2
  import { fetch } from "../fetch.js";
3
+ import { panicConfig } from "../softr-config.schema.js";
3
4
 
4
5
  //#region src/codegen/fetch-metadata.ts
5
6
  function metadataSourceFromEnv(env = process.env) {
@@ -17,27 +18,44 @@ function metadataSourceFromEnv(env = process.env) {
17
18
  token: env.SOFTR_STUDIO_TOKEN
18
19
  };
19
20
  }
20
- async function fetchSoftrEnvInput(source) {
21
+ async function fetchSoftrEnvInput(source, aliases) {
21
22
  const { studioApiUrl, datasourceApiUrl, applicationId, token } = source;
22
23
  const [registered, userGroups] = await Promise.all([apiFetch(`${studioApiUrl}/v1/applications/${applicationId}/datasources`, token), apiFetch(`${studioApiUrl}/v1/applications/${applicationId}/access-control/user-groups`, token)]);
24
+ const datasources = await Promise.all(registered.map(async ({ name, datasource }) => {
25
+ const metadata = await apiFetch(`${datasourceApiUrl}/v1/datasource/vibe/studio/applications/${applicationId}/datasources/${datasource.id}/metadata`, token);
26
+ return {
27
+ ...metadata,
28
+ id: datasource.id,
29
+ tableId: metadata.id,
30
+ name
31
+ };
32
+ }));
33
+ let users = null;
34
+ if (Object.keys(aliases).length > 0) {
35
+ const metadata = await apiFetch(`${datasourceApiUrl}/v1/datasource/vibe/studio/applications/${applicationId}/datasources/user-connection/metadata`, token, () => panic("softr.json declares user.properties, but this app has no users table."));
36
+ users = {
37
+ tableId: metadata.id,
38
+ name: metadata.name,
39
+ properties: {}
40
+ };
41
+ for (const [alias, fieldId] of Object.entries(aliases)) {
42
+ const field = metadata.fields.find(({ id }) => id === fieldId) ?? panicConfig(`softr.json user.properties.${alias} names "${fieldId}", which is not a field of the users table.`);
43
+ if (field.type === "ATTACHMENT" || field.type === "LONG_TEXT" || field.allowMultipleEntries && (field.type === "LINKED_RECORD" || field.type === "LOOKUP")) panicConfig(`softr.json user.properties.${alias} names "${field.name}", a ${field.allowMultipleEntries ? "multiple " : ""}${field.type} field, which can be too big to read with the signed-in user. Remove it, and read it with defineQuery on the users table instead.`);
44
+ users.properties[alias] = field;
45
+ }
46
+ }
23
47
  return {
24
- datasources: await Promise.all(registered.map(async ({ name, datasource }) => {
25
- const metadata = await apiFetch(`${datasourceApiUrl}/v1/datasource/vibe/studio/applications/${applicationId}/datasources/${datasource.id}/metadata`, token);
26
- return {
27
- ...metadata,
28
- id: datasource.id,
29
- tableId: metadata.id,
30
- name
31
- };
32
- })),
33
- userGroups
48
+ datasources,
49
+ userGroups,
50
+ users
34
51
  };
35
52
  }
36
- async function apiFetch(url, token) {
53
+ async function apiFetch(url, token, notFound = () => panic(`${url} answered 404`)) {
37
54
  const response = await fetch(url, { headers: {
38
55
  accept: "application/json",
39
56
  Authorization: `Bearer ${token}`
40
57
  } }).catch(() => panic(`Could not reach ${url}`));
58
+ if (response.status === 404) return notFound();
41
59
  if (!response.ok) panic(`${url} answered ${response.status}`);
42
60
  return await response.json();
43
61
  }
@@ -1,4 +1,4 @@
1
- import { renderDatasourceTypes } from "./datasource-types.js";
1
+ import { renderDatasourceTypes, renderUserProperties } from "./datasource-types.js";
2
2
  import { renderUserGroups } from "./user-groups.js";
3
3
 
4
4
  //#region src/codegen/softr-env.ts
@@ -15,6 +15,7 @@ function renderSoftrEnv(input, sdkVersion) {
15
15
  indent(interfaces),
16
16
  "",
17
17
  indent(renderUserGroups(input.userGroups)),
18
+ ...input.users === null ? [] : ["", indent(renderUserProperties(input.users.properties, input.datasources))],
18
19
  "}",
19
20
  ""
20
21
  ].join("\n");
package/dist/cookies.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { softAssert } from "./assertions.js";
1
2
  import { getRequestHeader, getResponseHeaders, setResponseHeader } from "@tanstack/react-start/server";
2
3
 
3
4
  //#region src/cookies.ts
@@ -38,9 +39,16 @@ const USER_TIMEZONE_COOKIE = "user-timezone";
38
39
  function readUserTimezone() {
39
40
  return readRequestCookies()[USER_TIMEZONE_COOKIE] ?? null;
40
41
  }
42
+ /** What browsers keep of one cookie's name and value. They drop a larger one silently. */
43
+ const COOKIE_MAX_BYTES = 4096;
41
44
  function writeCookies(cookies) {
42
- if (cookies.length === 0) return;
43
- setResponseHeader("Set-Cookie", [...getResponseHeaders().getSetCookie(), ...cookies]);
45
+ const written = cookies.filter((cookie) => {
46
+ const size = cookie.split(";")[0].length;
47
+ softAssert(size <= COOKIE_MAX_BYTES, () => `The ${cookie.slice(0, cookie.indexOf("="))} cookie is ${size} bytes, over the ${COOKIE_MAX_BYTES} a browser keeps. It was not written.`);
48
+ return size <= COOKIE_MAX_BYTES;
49
+ });
50
+ if (written.length === 0) return;
51
+ setResponseHeader("Set-Cookie", [...getResponseHeaders().getSetCookie(), ...written]);
44
52
  }
45
53
 
46
54
  //#endregion
@@ -7,13 +7,6 @@ interface IntegrationFetchInit {
7
7
  headers?: Record<string, string>;
8
8
  body?: string;
9
9
  signal?: AbortSignal;
10
- /**
11
- * Relay the vendor's answer as it is produced instead of waiting for all of it — for any endpoint
12
- * that streams, such as an LLM completion requested with `"stream": true`. The returned `Response`
13
- * body is then the vendor's own stream, readable chunk by chunk. A vendor that answers in one piece
14
- * still works.
15
- */
16
- stream?: boolean;
17
10
  }
18
11
  declare function integrationCreateFetcher(action: IntegrationAction): (init?: IntegrationFetchInit) => Promise<Response>;
19
12
  declare const integration: {
@@ -3,16 +3,15 @@ import { readSessionToken } from "../auth/session.js";
3
3
  import { dsUrl, refererHeader } from "../datasource/record.js";
4
4
 
5
5
  //#region src/core/integration.ts
6
- const EVENT_STREAM = "text/event-stream";
7
- async function integrationFetch(action, { params, query, headers = {}, body, signal, stream = false } = {}) {
6
+ async function integrationFetch(action, { params, query, headers = {}, body, signal } = {}) {
8
7
  if (signal?.aborted) throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
9
8
  const payload = {
10
9
  method: action.resource.method,
11
10
  url: buildUrl(action.resource.url, params, query),
12
- headers: stream ? withAcceptEventStream(headers) : headers
11
+ headers
13
12
  };
14
13
  if (body !== void 0) payload.body = body;
15
- return fetch(`${dsUrl(action.id)}/${stream ? "proxy/stream" : "proxy"}`, {
14
+ return fetch(`${dsUrl(action.id)}/proxy`, {
16
15
  method: "POST",
17
16
  headers: {
18
17
  "Content-Type": "application/json",
@@ -31,12 +30,6 @@ function buildUrl(template, params, query) {
31
30
  if (query !== void 0 && Object.keys(query).length > 0) url += `?${new URLSearchParams(query).toString()}`;
32
31
  return url;
33
32
  }
34
- function withAcceptEventStream(headers) {
35
- return Object.keys(headers).some((name) => name.toLowerCase() === "accept") ? headers : {
36
- ...headers,
37
- Accept: EVENT_STREAM
38
- };
39
- }
40
33
  function integrationCreateFetcher(action) {
41
34
  return (init) => integrationFetch(action, init);
42
35
  }
@@ -1,4 +1,4 @@
1
- import * as react6 from "react";
1
+ import * as react5 from "react";
2
2
 
3
3
  //#region src/default-error-overlay.d.ts
4
4
  type Props = {
@@ -8,6 +8,6 @@ type Props = {
8
8
  declare function DefaultErrorOverlay({
9
9
  error,
10
10
  reset
11
- }: Props): react6.JSX.Element;
11
+ }: Props): react5.JSX.Element;
12
12
  //#endregion
13
13
  export { DefaultErrorOverlay };
@@ -1,6 +1,6 @@
1
+ import { z } from "zod";
1
2
  import * as react0 from "react";
2
3
  import { ReactNode } from "react";
3
- import { z } from "zod";
4
4
 
5
5
  //#region src/formatting.d.ts
6
6
  type FixedDateFormat = "FRIENDLY" | "DD-MM-YYYY" | "MM-DD-YYYY" | "YYYY-MM-DD";
@@ -1,6 +1,6 @@
1
1
  import { logger } from "./logger.js";
2
- import { Fragment, createContext, createElement, useContext, useMemo, useSyncExternalStore } from "react";
3
2
  import { z } from "zod";
3
+ import { Fragment, createContext, createElement, useContext, useMemo, useSyncExternalStore } from "react";
4
4
  import { rootRouteId, useRouteContext } from "@tanstack/react-router";
5
5
 
6
6
  //#region src/formatting.ts
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SoftrUserGroupId, SoftrUserGroups, User } from "./auth/user.js";
1
+ import { SoftrUserGroupId, SoftrUserGroups, SoftrUserProperties, User } from "./auth/user.js";
2
2
  import { ActionPermissions, SoftrDatasources, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
3
3
  import { FileAttachment } from "./core/file-upload.js";
4
4
  import { FileItem, useFileUpload } from "./core/use-file-upload.js";
@@ -14,4 +14,4 @@ import { useUserTimezone } from "./timezone.js";
14
14
  import { PwaInstall, PwaPlatform, SoftrPwaHead, usePwaInstall } from "./pwa.js";
15
15
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
16
16
  import { Audience } from "./visibility/audience.js";
17
- export { type ActionPermissions, Audience, type AuthOptions, type AuthSettings, AuthSettingsProvider, type DateOptions, type DateParts, type DateSeparator, type DateValue, DefaultErrorOverlay, type FileAttachment, type FileItem, type FixedDateFormat, FormattedDate, type Formatting, FormattingScope, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type User, type WeekDay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, formatDate, formatDateTime, formatNumber, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, usePwaInstall, useUserTimezone };
17
+ export { type ActionPermissions, Audience, type AuthOptions, type AuthSettings, AuthSettingsProvider, type DateOptions, type DateParts, type DateSeparator, type DateValue, DefaultErrorOverlay, type FileAttachment, type FileItem, type FixedDateFormat, FormattedDate, type Formatting, FormattingScope, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type SoftrUserProperties, type User, type WeekDay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, formatDate, formatDateTime, formatNumber, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, usePwaInstall, useUserTimezone };
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DatasourceAction } from "./action.js";
2
- import * as _tanstack_react_start4 from "@tanstack/react-start";
2
+ import * as _tanstack_react_start0 from "@tanstack/react-start";
3
3
 
4
4
  //#region src/mcp.d.ts
5
5
 
@@ -31,6 +31,6 @@ type SoftrMcpOptions = {
31
31
  * ],
32
32
  * }))
33
33
  */
34
- declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start4.RequestMiddlewareAfterServer<{}, undefined, undefined>;
34
+ declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>;
35
35
  //#endregion
36
36
  export { softrMcpMiddleware };
package/dist/mcp.js CHANGED
@@ -2,12 +2,12 @@ import { assert } from "./assertions.js";
2
2
  import { fetch } from "./fetch.js";
3
3
  import { sessionConfig } from "./auth/config.js";
4
4
  import { readSessionToken, withSessionToken } from "./auth/session.js";
5
+ import { softrConfig } from "./softr-config.js";
5
6
  import { makePlatform, platformApiUrl } from "./auth/platform.js";
6
7
  import { isDatasourceAction } from "./action.js";
7
- import { softrConfig } from "./softr-config.js";
8
8
  import { ds } from "./datasource/datasource.js";
9
- import { createMiddleware } from "@tanstack/react-start";
10
9
  import { z } from "zod";
10
+ import { createMiddleware } from "@tanstack/react-start";
11
11
 
12
12
  //#region src/mcp.ts
13
13
  const MCP_PATH = "/_softr/mcp";
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.68-alpha.2";
2
+ var version = "0.0.68-props.1";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/dist/pwa.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import * as react4 from "react";
1
+ import * as react3 from "react";
2
2
  import "@tanstack/react-start";
3
3
 
4
4
  //#region src/pwa.d.ts
@@ -23,7 +23,7 @@ import "@tanstack/react-start";
23
23
  * )
24
24
  * }
25
25
  */
26
- declare function SoftrPwaHead(): react4.FunctionComponentElement<react4.FragmentProps>;
26
+ declare function SoftrPwaHead(): react3.FunctionComponentElement<react3.FragmentProps>;
27
27
  type PwaPlatform = "ios" | "android" | "desktop";
28
28
  type PwaInstall = {
29
29
  available: false;
package/dist/pwa.js CHANGED
@@ -2,9 +2,9 @@ import { logger } from "./logger.js";
2
2
  import { fetch } from "./fetch.js";
3
3
  import { sessionConfig } from "./auth/config.js";
4
4
  import { platformApiUrl } from "./auth/platform.js";
5
+ import { z } from "zod";
5
6
  import { Fragment, createElement, useEffect, useState } from "react";
6
7
  import { createMiddleware } from "@tanstack/react-start";
7
- import { z } from "zod";
8
8
 
9
9
  //#region src/pwa.ts
10
10
  const MANIFEST_PATH = "/manifest.webmanifest";
@@ -1,6 +1,17 @@
1
+ import { throwAtCaller } from "./assertions.js";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
1
4
  import { z } from "zod";
2
5
 
3
6
  //#region src/softr-config.schema.ts
7
+ const SOFTR_CONFIG_FILE = "softr.json";
8
+ /** The cause is in softr.json, so the app's author can fix it. */
9
+ var SoftrConfigError = class extends Error {
10
+ name = "SoftrConfigError";
11
+ };
12
+ function panicConfig(message) {
13
+ throwAtCaller(panicConfig, new SoftrConfigError(message));
14
+ }
4
15
  const AccessBehaviour = z.union([
5
16
  z.string(),
6
17
  z.object({
@@ -9,32 +20,49 @@ const AccessBehaviour = z.union([
9
20
  }),
10
21
  z.object({ strategy: z.literal("throw") })
11
22
  ], { error: "must be a path like \"/login\", { \"strategy\": \"redirect\", \"to\": \"/login\" } or { \"strategy\": \"throw\" }" });
12
- const AppPath = z.string().refine((path) => path.startsWith("/") && !path.includes("?") && !path.includes("#") && new URL(path, "https://app.invalid").origin === "https://app.invalid", { error: "must be a path on this app like \"/login\", with no \"?\" or \"#\"" });
13
- const SoftrConfigSchema = z.object({ auth: z.strictObject({
14
- routes: z.strictObject({
15
- login: z.strictObject({
16
- path: AppPath,
17
- redirectParam: z.string()
23
+ const AppPath = z.string().refine((path$1) => path$1.startsWith("/") && !path$1.includes("?") && !path$1.includes("#") && new URL(path$1, "https://app.invalid").origin === "https://app.invalid", { error: "must be a path on this app like \"/login\", with no \"?\" or \"#\"" });
24
+ const SoftrConfigSchema = z.object({
25
+ auth: z.strictObject({
26
+ routes: z.strictObject({
27
+ login: z.strictObject({
28
+ path: AppPath,
29
+ redirectParam: z.string()
30
+ }),
31
+ resetPassword: z.strictObject({
32
+ path: AppPath,
33
+ tokenParam: z.string()
34
+ }),
35
+ acceptInvitation: z.strictObject({
36
+ path: AppPath,
37
+ tokenParam: z.string()
38
+ }),
39
+ magicLink: z.strictObject({
40
+ path: AppPath,
41
+ tokenParam: z.string()
42
+ })
18
43
  }),
19
- resetPassword: z.strictObject({
20
- path: AppPath,
21
- tokenParam: z.string()
22
- }),
23
- acceptInvitation: z.strictObject({
24
- path: AppPath,
25
- tokenParam: z.string()
26
- }),
27
- magicLink: z.strictObject({
28
- path: AppPath,
29
- tokenParam: z.string()
44
+ guard: z.strictObject({
45
+ unauthenticated: AccessBehaviour,
46
+ forbidden: AccessBehaviour,
47
+ alreadyAuthenticated: AccessBehaviour
30
48
  })
31
49
  }),
32
- guard: z.strictObject({
33
- unauthenticated: AccessBehaviour,
34
- forbidden: AccessBehaviour,
35
- alreadyAuthenticated: AccessBehaviour
36
- })
37
- }) });
50
+ user: z.strictObject({ properties: z.record(z.string(), z.string()) }).optional()
51
+ });
52
+ function loadSoftrConfig(root) {
53
+ const file = path.join(root, SOFTR_CONFIG_FILE);
54
+ if (!existsSync(file)) panicConfig(`${SOFTR_CONFIG_FILE} is missing from ${root}`);
55
+ const source = readFileSync(file, "utf8");
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(source);
59
+ } catch (error) {
60
+ panicConfig(`${SOFTR_CONFIG_FILE} is not valid JSON: ${error instanceof Error ? error.message : error}`);
61
+ }
62
+ const result = SoftrConfigSchema.safeParse(parsed);
63
+ if (!result.success) panicConfig(`${SOFTR_CONFIG_FILE} does not match the schema:\n\n${z.prettifyError(result.error)}`);
64
+ return result.data;
65
+ }
38
66
 
39
67
  //#endregion
40
- export { SoftrConfigSchema };
68
+ export { SOFTR_CONFIG_FILE, SoftrConfigError, loadSoftrConfig, panicConfig };
@@ -1,5 +1,5 @@
1
- import { ACCESS_REASONS, checkAccess } from "./visibility.js";
2
1
  import { softrConfig } from "../softr-config.js";
2
+ import { ACCESS_REASONS, checkAccess } from "./visibility.js";
3
3
  import { redirect } from "@tanstack/react-router";
4
4
 
5
5
  //#region src/visibility/route.ts
package/dist/vite.js CHANGED
@@ -1,12 +1,8 @@
1
- import { assert, panic } from "./assertions.js";
2
- import { SoftrConfigSchema } from "./softr-config.schema.js";
3
- import { existsSync, readFileSync } from "node:fs";
1
+ import { SOFTR_CONFIG_FILE, loadSoftrConfig } from "./softr-config.schema.js";
4
2
  import path from "node:path";
5
- import { z } from "zod";
6
3
  import { pathToFileURL } from "node:url";
7
4
 
8
5
  //#region src/vite.ts
9
- const SOFTR_CONFIG_FILE = "softr.json";
10
6
  function softr() {
11
7
  return [softrConfig(), editEngine()];
12
8
  }
@@ -27,20 +23,6 @@ function softrConfig() {
27
23
  }
28
24
  };
29
25
  }
30
- function loadSoftrConfig(root) {
31
- const file = path.join(root, SOFTR_CONFIG_FILE);
32
- assert(existsSync(file), `${SOFTR_CONFIG_FILE} is missing from ${root}`);
33
- const source = readFileSync(file, "utf8");
34
- let parsed;
35
- try {
36
- parsed = JSON.parse(source);
37
- } catch (error) {
38
- panic(`${SOFTR_CONFIG_FILE} is not valid JSON: ${error instanceof Error ? error.message : error}`);
39
- }
40
- const result = SoftrConfigSchema.safeParse(parsed);
41
- if (!result.success) panic(`${SOFTR_CONFIG_FILE} does not match the schema:\n\n${z.prettifyError(result.error)}`);
42
- return result.data;
43
- }
44
26
  /**
45
27
  * The edit engine is provided by the pod runtime rather than an app dependency: it is never in the
46
28
  * app's `package.json` and never in its `node_modules`. The pod points `SOFTR_EDIT_ENGINE_DIR` at
package/package.json CHANGED
@@ -1,16 +1,37 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.68-alpha.2",
3
+ "version": "0.0.68-props.1",
4
4
  "type": "module",
5
5
  "exports": {
6
- ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
7
- "./client": { "types": "./dist/client.d.ts", "import": "./dist/client.js" },
8
- "./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js" },
9
- "./vite": { "types": "./dist/vite.d.ts", "import": "./dist/vite.js" }
10
- },
11
- "bin": { "softr-sdk": "./dist/cli.js" },
12
- "files": ["dist", "schema", "skills", "!skills/_artifacts"],
13
- "publishConfig": { "access": "public" },
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "import": "./dist/index.js"
9
+ },
10
+ "./client": {
11
+ "types": "./dist/client.d.ts",
12
+ "import": "./dist/client.js"
13
+ },
14
+ "./server": {
15
+ "types": "./dist/server.d.ts",
16
+ "import": "./dist/server.js"
17
+ },
18
+ "./vite": {
19
+ "types": "./dist/vite.d.ts",
20
+ "import": "./dist/vite.js"
21
+ }
22
+ },
23
+ "bin": {
24
+ "softr-sdk": "./dist/cli.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "schema",
29
+ "skills",
30
+ "!skills/_artifacts"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
14
35
  "scripts": {
15
36
  "build": "tsdown",
16
37
  "test": "node --import ./src/test-utils/setup.ts --test",
@@ -42,7 +63,9 @@
42
63
  "optional": true
43
64
  }
44
65
  },
45
- "keywords": ["tanstack-intent"],
66
+ "keywords": [
67
+ "tanstack-intent"
68
+ ],
46
69
  "intent": {
47
70
  "version": 1,
48
71
  "repo": "@softr/sdk",
@@ -54,6 +54,19 @@
54
54
  },
55
55
  "required": ["routes", "guard"],
56
56
  "additionalProperties": false
57
+ },
58
+ "user": {
59
+ "type": "object",
60
+ "description": "The signed-in user.",
61
+ "properties": {
62
+ "properties": {
63
+ "type": "object",
64
+ "description": "Fields of the users table the app reads as user.properties, keyed by the name the app uses. Each value is a field id. No other field leaves the server.",
65
+ "additionalProperties": { "type": "string" }
66
+ }
67
+ },
68
+ "required": ["properties"],
69
+ "additionalProperties": false
57
70
  }
58
71
  },
59
72
  "required": ["auth"],
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: core
14
14
  library: softr-sdk
15
- library_version: 0.0.68-alpha.2
15
+ library_version: 0.0.68-props.1
16
16
  ---
17
17
 
18
18
  # Softr SDK core
@@ -48,7 +48,6 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
48
48
  | Trigger a Softr workflow | [workflows/SKILL.md](./workflows/SKILL.md) |
49
49
  | Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
50
50
  | Call a connected third-party API (Slack, Stripe, Gmail, …) | [integrations/SKILL.md](./integrations/SKILL.md) |
51
- | Show an AI answer as it is written (a chat, an assistant, a live summary) | [integrations/SKILL.md](./integrations/SKILL.md), section "Streaming a vendor answer" |
52
51
  | Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
53
52
  | Let visitors install the app on their home screen | [pwa/SKILL.md](./pwa/SKILL.md) |
54
53
  | Show a date, a time or a number | [formatting/SKILL.md](./formatting/SKILL.md) |
@@ -83,9 +82,6 @@ Wiring a login, signup, or sign-out?
83
82
  Calling a connected third-party API (Slack, Stripe, Gmail, Google Calendar, …)?
84
83
  load softr-sdk/integrations
85
84
 
86
- Showing an AI answer as it is written: a chat, an assistant, a summary that types itself out?
87
- load softr-sdk/integrations and stream the call: its "Streaming a vendor answer" section
88
-
89
85
  Opening the app to an AI assistant over MCP?
90
86
  load softr-sdk/mcp
91
87
 
@@ -19,7 +19,7 @@ description: >-
19
19
  metadata:
20
20
  type: sub-skill
21
21
  library: softr-sdk
22
- library_version: 0.0.68-alpha.2
22
+ library_version: 0.0.68-props.1
23
23
  ---
24
24
 
25
25
  **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.68-alpha.2
15
+ library_version: 0.0.68-props.1
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.
@@ -4,12 +4,13 @@ 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, the generated SoftrUserGroups that narrows
8
+ group ids to the app's own, and user.properties, the fields of the visitor's
9
+ row that softr.json declares.
9
10
  metadata:
10
11
  type: sub-skill
11
12
  library: softr-sdk
12
- library_version: 0.0.68-alpha.2
13
+ library_version: 0.0.68-props.1
13
14
  ---
14
15
 
15
16
  # The current user
@@ -31,6 +32,7 @@ type User = {
31
32
  email: string
32
33
  fullName: string
33
34
  userGroupIds: Array<SoftrUserGroupId> // the app's own group ids
35
+ properties: SoftrUserProperties // the aliases softr.json declares
34
36
  }
35
37
  ```
36
38
 
@@ -90,6 +92,25 @@ For every group's name and id, read `docs/user-groups.md`. It lists every group
90
92
 
91
93
  `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
94
 
95
+ ### Fields of the visitor's row: `user.properties`
96
+
97
+ To read more of the visitor's row in the users table, give each field an alias in `softr.json`, under `user.properties`. The value is the field's id:
98
+
99
+ ```json
100
+ {
101
+ "user": {
102
+ "properties": {
103
+ "plan": "OeIz1",
104
+ "seats": "1iHKE"
105
+ }
106
+ }
107
+ }
108
+ ```
109
+
110
+ `user.properties` then holds exactly those aliases. To read them, see `docs/user-properties.md`.
111
+
112
+ Attachment and long text fields, and linked record and lookup fields with multiple entries, cannot be properties. Read those with `defineQuery` on the users table. See [record-queries](../record-queries/SKILL.md).
113
+
93
114
  ## References
94
115
 
95
116
  - [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.68-alpha.2
13
+ library_version: 0.0.68-props.1
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.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.68-alpha.2
13
+ library_version: 0.0.68-props.1
14
14
  ---
15
15
 
16
16
  # Dates, times and numbers
@@ -5,12 +5,12 @@ description: >-
5
5
  defineIntegration from @softr/sdk declares the action with its integrationId,
6
6
  the one HTTP method and endpoint url it allows, and permissions;
7
7
  integration.createFetcher from @softr/sdk/server binds it and returns a
8
- fetcher function you call with optional params, query, headers, body, and
9
- stream for a vendor answer that arrives in pieces. Check res.ok before res.json().
8
+ fetcher function you call with optional params, query, headers, and body.
9
+ Check res.ok before res.json().
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.68-alpha.2
13
+ library_version: 0.0.68-props.1
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.
@@ -129,16 +129,9 @@ Do not add an `Authorization` key to `headers` — Softr injects the integration
129
129
 
130
130
  ## Calling an AI model
131
131
 
132
- **Two shapes, decided by who reads the answer.** An answer the user reads as it is written — a chat, an
133
- assistant, a long summary shown on the page — streams: pass `stream: true` to the fetcher, ask the vendor
134
- for its stream in the request, and relay the body, see
135
- [Streaming a vendor answer](#streaming-a-vendor-answer) below. An answer the server reads — JSON the page
136
- lays out, text saved to a field — is buffered: the call below, then `res.json()`. A chat is never buffered:
137
- the user would watch a blank panel until the last token.
138
-
139
132
  A button on one record that hands its fields to the model and shows the answer is the `ai-action` pattern:
140
- it carries the buffered call and the model id, so write the call by hand only for another shape — a batch,
141
- a field the model fills on create, or a chat, which streams. An AI integration is called like any other. For `ANTHROPIC_AI`, the action is `POST` on
133
+ it carries this call and the model id, so write the call by hand only for another shape — a batch, a chat,
134
+ a field the model fills on create. An AI integration is called like any other. For `ANTHROPIC_AI`, the action is `POST` on
142
135
  `https://api.anthropic.com/v1/messages`, the request carries the `anthropic-version` header and a body of
143
136
  `model`, `max_tokens`, `system` and `messages`, and the answer's text is in `content[].text`:
144
137
 
@@ -171,49 +164,6 @@ const text = body.content.flatMap((block) => (block.type === 'text' && block.tex
171
164
  the button is pressed. `claude-opus-5-5` is the current default; `claude-sonnet-5-5` is the cheaper one for a
172
165
  short summary.
173
166
 
174
- ## Streaming a vendor answer
175
-
176
- Some vendor endpoints can send their answer in pieces while they produce it — LLM completions token by token, long exports, live event feeds. The fetcher relays such an answer as it arrives when you pass `stream: true`; the response body is then the vendor's own stream, unchanged.
177
-
178
- Every connected LLM streams its completion. Ask the vendor for it in the request **and** pass `stream: true` to the fetcher; one without the other gains nothing:
179
-
180
- | integrationType | Endpoint | How to ask for the stream |
181
- |---|---|---|
182
- | ANTHROPIC_AI | `POST /v1/messages` | `"stream": true` in the body |
183
- | OPEN_AI | `POST /v1/chat/completions` or `POST /v1/responses` | `"stream": true` in the body |
184
- | GEMINI_AI | `POST /v1beta/models/{model}:streamGenerateContent` with `query: { alt: 'sse' }` | the endpoint itself; `generateContent` never streams, so the action's `url` must name the streaming one |
185
- | MISTRAL_AI | `POST /v1/chat/completions` | `"stream": true` in the body |
186
- | DEEP_SEEK_AI | `POST /chat/completions` | `"stream": true` in the body |
187
- | COHERE | `POST /v2/chat` | `"stream": true` in the body |
188
-
189
- For any other vendor, read that endpoint's docs: it streams when they offer a `stream` flag, an `Accept: text/event-stream` variant, or a chunked response to read incrementally. Most REST calls answer in one piece; call those without `stream` and `res.json()` works.
190
-
191
- Hand a streamed body to the browser as a `Response` from the server function and read it there. The example below streams a completion through the `askClaude` action declared above; the same shape applies to any vendor endpoint that streams.
192
-
193
- ```ts
194
- const streamAnswer = createServerFn({ method: 'POST' })
195
- .validator((data: { prompt: string }) => data)
196
- .handler(async ({ data }) => {
197
- const claude = integration.createFetcher(askClaude)
198
- const res = await claude({
199
- stream: true,
200
- headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
201
- body: JSON.stringify({
202
- model: 'claude-opus-5-5',
203
- max_tokens: 1024,
204
- stream: true,
205
- messages: [{ role: 'user', content: data.prompt }],
206
- }),
207
- })
208
- if (!res.ok) throw new Error(`AI error: ${res.status} ${await res.text()}`)
209
- return new Response(res.body, { headers: { 'Content-Type': 'text/event-stream' } })
210
- })
211
- ```
212
-
213
- On the client, call the server function's URL with `fetch` and read `response.body` with a `ReadableStream` reader; each chunk arrives in the vendor's own format, for an event stream one or more `event:`/`data:` frames. Use `stream: true` only together with a request that asks the vendor to stream: without it the vendor answers in one piece, which still works but gains nothing.
214
-
215
- `stream: true` is not available for REST_API integrations; those always answer in one piece.
216
-
217
167
  ## Base URLs by integration type
218
168
 
219
169
  Match the `integrationType` from the attached integrations section to find the base URL:
@@ -285,7 +235,3 @@ If the API the user needs is not in the table above, call it directly from a ser
285
235
  **Not checking `res.ok`.** The fetcher returns the proxied response as-is. A 4xx or 5xx from the third-party API is not thrown — call `res.ok` or `res.status` before calling `res.json()`.
286
236
 
287
237
  **Reusing one action for a different endpoint.** An action authorizes one `method` and one endpoint `url`. A different endpoint belongs to a different action — declare one for it, with its own audience.
288
-
289
- **Buffering an answer the user watches being written.** A chat or assistant called without streaming shows nothing until the last token arrives, and a long answer then hits the proxy's read timeout. Two flags are needed, and they are not the same thing: `stream: true` on the fetcher init is Softr's, it routes the call through the streaming proxy; the vendor's own request for a stream, such as `"stream": true` in an Anthropic or OpenAI body, or Gemini's `streamGenerateContent` endpoint, is what makes the vendor send pieces. Set both, then return `res.body` in a `Response`.
290
-
291
- **Reading a streamed body with `res.json()`.** With `stream: true` the body is an event stream, not one JSON document. Return `res.body` in a `Response` or read it with a stream reader; `res.json()` waits for the end and then fails to parse.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.68-alpha.2
12
+ library_version: 0.0.68-props.1
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.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.68-alpha.2
12
+ library_version: 0.0.68-props.1
13
13
  ---
14
14
 
15
15
  # Installing the app
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: sub-skill
13
13
  library: softr-sdk
14
- library_version: 0.0.68-alpha.2
14
+ library_version: 0.0.68-props.1
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.
@@ -13,7 +13,7 @@ description: >-
13
13
  metadata:
14
14
  type: sub-skill
15
15
  library: softr-sdk
16
- library_version: 0.0.68-alpha.2
16
+ library_version: 0.0.68-props.1
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. The same holds for any helper that calls `ds`: declare it inside the handler. A function at module level that calls `ds` keeps the server-only import in the browser bundle, and every page stops loading until it moves. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.68-alpha.2
12
+ library_version: 0.0.68-props.1
13
13
  ---
14
14
 
15
15
  **Boundary.** `Audience` and `defineVisibility` come from `@softr/sdk` and work anywhere.
@@ -13,7 +13,7 @@ description: >-
13
13
  metadata:
14
14
  type: sub-skill
15
15
  library: softr-sdk
16
- library_version: 0.0.68-alpha.2
16
+ library_version: 0.0.68-props.1
17
17
  ---
18
18
 
19
19
  **Boundary.** `defineVisibility`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.68-alpha.2
12
+ library_version: 0.0.68-props.1
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.