@softr/sdk 0.0.22 → 0.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/session.js +7 -21
- package/dist/core/datasource.d.ts +17 -7
- package/dist/core/datasource.js +37 -32
- package/dist/core/integration.js +2 -2
- package/dist/core/utils.js +14 -0
- package/dist/default-error-overlay.d.ts +13 -0
- package/dist/default-error-overlay.js +319 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/types.d.ts +13 -3
- package/dist/vite.d.ts +2 -9
- package/dist/vite.js +3 -68
- package/package.json +1 -6
- package/skills/softr-sdk/SKILL.md +6 -5
- package/skills/softr-sdk/auth/SKILL.md +2 -2
- package/skills/softr-sdk/current-user/SKILL.md +2 -2
- package/skills/softr-sdk/data-mutations/SKILL.md +23 -8
- package/skills/softr-sdk/reading-records/SKILL.md +5 -7
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +7 -7
- package/dist/preview-identity/preview-identity.js +0 -121
- package/dist/routes/discover.d.ts +0 -14
- package/dist/routes/discover.js +0 -305
- package/dist/routes/manifest.d.ts +0 -24
- package/dist/routes/manifest.js +0 -168
- package/dist/routes/mem-fs.js +0 -71
package/dist/types.d.ts
CHANGED
|
@@ -26,11 +26,13 @@ type GetByIdOptions = {
|
|
|
26
26
|
id: string;
|
|
27
27
|
where?: never;
|
|
28
28
|
timeZone?: string;
|
|
29
|
+
select?: Record<string, string>;
|
|
29
30
|
};
|
|
30
31
|
type GetByWhereOptions = {
|
|
31
32
|
id?: never;
|
|
32
33
|
where: AnyConstructable;
|
|
33
34
|
timeZone?: string;
|
|
35
|
+
select?: Record<string, string>;
|
|
34
36
|
};
|
|
35
37
|
type GetOptions = GetByIdOptions | GetByWhereOptions;
|
|
36
38
|
interface ListOptions {
|
|
@@ -66,6 +68,14 @@ interface ListResult<T extends SoftrRecord = SoftrRecord> {
|
|
|
66
68
|
/** Offset to pass as `offset` in the next call, or `null` when there are no more pages. */
|
|
67
69
|
nextOffset: number | null;
|
|
68
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Augment this interface in a generated datasource-types file to enable
|
|
73
|
+
* typed `ds.list()` / `ds.get()` return values per datasource ID.
|
|
74
|
+
* Maps datasource ID → raw field shape (keyed by field ID).
|
|
75
|
+
*/
|
|
76
|
+
interface DatasourceTypeMap {}
|
|
77
|
+
type AliasedFields<Sel extends Record<string, string>, Raw extends Record<string, JsonValue>> = { [K in keyof Sel]: Sel[K] extends keyof Raw ? Raw[Sel[K]] : JsonValue };
|
|
78
|
+
type RecordFieldsFor<Id extends string, Sel extends Record<string, string>> = Id extends keyof DatasourceTypeMap ? [keyof Sel] extends [never] ? DatasourceTypeMap[Id] : AliasedFields<Sel, DatasourceTypeMap[Id]> : Record<string, JsonValue>;
|
|
69
79
|
type AggregationFunction = 'SUM' | 'AVG' | 'MAX' | 'MIN' | 'COUNT' | 'DISTINCT';
|
|
70
80
|
interface MetricOptions {
|
|
71
81
|
select?: Record<string, string>;
|
|
@@ -80,7 +90,7 @@ interface MetricResult {
|
|
|
80
90
|
value: number | null;
|
|
81
91
|
executionTimestamp: Date;
|
|
82
92
|
}
|
|
83
|
-
interface
|
|
93
|
+
interface ChartOptions {
|
|
84
94
|
select?: Record<string, string>;
|
|
85
95
|
where?: AnyConstructable;
|
|
86
96
|
/** Field alias (resolved via select) or raw field ID. */
|
|
@@ -93,7 +103,7 @@ interface ChartDataOptions {
|
|
|
93
103
|
/** Override the timezone for this request. */
|
|
94
104
|
timeZone?: string;
|
|
95
105
|
}
|
|
96
|
-
interface
|
|
106
|
+
interface ChartResult {
|
|
97
107
|
data: Record<string, JsonValue>[];
|
|
98
108
|
executionTimestamp: Date;
|
|
99
109
|
}
|
|
@@ -115,4 +125,4 @@ interface UpdateOptions {
|
|
|
115
125
|
data: Record<string, JsonValue>;
|
|
116
126
|
}
|
|
117
127
|
//#endregion
|
|
118
|
-
export { AggregationFunction,
|
|
128
|
+
export { AggregationFunction, ChartOptions, ChartResult, CreateOptions, DatasourcePermissions, DatasourceTypeMap, GetOptions, JsonValue, ListOptions, ListResult, MetricOptions, MetricResult, RecordFieldsFor, SoftrMe, SoftrPermissions, SoftrRecord, SoftrUser, UpdateOptions };
|
package/dist/vite.d.ts
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
|
-
import { Manifest, ManifestNode } from "./routes/manifest.js";
|
|
2
1
|
import { PluginOption } from "vite";
|
|
3
2
|
|
|
4
3
|
//#region src/vite.d.ts
|
|
5
|
-
|
|
6
|
-
/** Writes `<root>/dist/routes.json` and returns its path. Throws on a rule it cannot read. */
|
|
7
|
-
declare function generateManifest(root: string): Promise<string>;
|
|
8
|
-
/**
|
|
9
|
-
* Everything Softr adds to an app's Vite config: the build-time routes manifest, plus the studio
|
|
10
|
-
* edit engine when the pod supplies one.
|
|
11
|
-
*/
|
|
4
|
+
/** Everything Softr adds to an app's Vite config: the studio edit engine, when the pod supplies one. */
|
|
12
5
|
declare function softr(): PluginOption[];
|
|
13
6
|
//#endregion
|
|
14
|
-
export {
|
|
7
|
+
export { softr };
|
package/dist/vite.js
CHANGED
|
@@ -1,75 +1,10 @@
|
|
|
1
|
-
import { ManifestError, generateRoutesManifest } from "./routes/manifest.js";
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
1
|
import path from "node:path";
|
|
4
2
|
import { pathToFileURL } from "node:url";
|
|
5
3
|
|
|
6
4
|
//#region src/vite.ts
|
|
7
|
-
|
|
8
|
-
async function renderManifest(root) {
|
|
9
|
-
try {
|
|
10
|
-
return await generateRoutesManifest(root);
|
|
11
|
-
} catch (error) {
|
|
12
|
-
if (error instanceof ManifestError) {
|
|
13
|
-
const { filePath, line, column, message } = error.detail;
|
|
14
|
-
throw new Error(`[softr] ${filePath}:${line}:${column} — ${message}`, { cause: error });
|
|
15
|
-
}
|
|
16
|
-
throw error;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
async function writeManifest(root, contents) {
|
|
20
|
-
const target = path.resolve(root, MANIFEST_RELATIVE_PATH);
|
|
21
|
-
await mkdir(path.dirname(target), { recursive: true });
|
|
22
|
-
await writeFile(target, contents, "utf8");
|
|
23
|
-
return target;
|
|
24
|
-
}
|
|
25
|
-
/** Writes `<root>/dist/routes.json` and returns its path. Throws on a rule it cannot read. */
|
|
26
|
-
async function generateManifest(root) {
|
|
27
|
-
return writeManifest(root, await renderManifest(root));
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Everything Softr adds to an app's Vite config: the build-time routes manifest, plus the studio
|
|
31
|
-
* edit engine when the pod supplies one.
|
|
32
|
-
*/
|
|
5
|
+
/** Everything Softr adds to an app's Vite config: the studio edit engine, when the pod supplies one. */
|
|
33
6
|
function softr() {
|
|
34
|
-
return [
|
|
35
|
-
routesManifest(),
|
|
36
|
-
previewIdentity(),
|
|
37
|
-
editEngine()
|
|
38
|
-
];
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Signing the preview in writes the cookie this app's own session code reads back, so it lives here
|
|
42
|
-
* rather than in the edit engine — one implementation of the format, no version to keep in step.
|
|
43
|
-
*
|
|
44
|
-
* `SOFTR_APPLICATION_ID` gates it: the pod sets it, a developer's machine has none.
|
|
45
|
-
*/
|
|
46
|
-
async function previewIdentity() {
|
|
47
|
-
if (!process.env.SOFTR_APPLICATION_ID) return false;
|
|
48
|
-
const { PREVIEW_REDEMPTION_PATH, PREVIEW_SIGNED_IN_PATH, redeemGrant, reportSignedIn } = await import("./preview-identity/preview-identity.js");
|
|
49
|
-
return {
|
|
50
|
-
name: "softr-preview-identity",
|
|
51
|
-
apply: "serve",
|
|
52
|
-
configureServer(server) {
|
|
53
|
-
server.middlewares.use(PREVIEW_REDEMPTION_PATH, redeemGrant);
|
|
54
|
-
server.middlewares.use(PREVIEW_SIGNED_IN_PATH, reportSignedIn);
|
|
55
|
-
}
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
/** Plugin order does not matter: this puts nothing into the module graph. */
|
|
59
|
-
function routesManifest() {
|
|
60
|
-
let root = process.cwd();
|
|
61
|
-
let rendered = null;
|
|
62
|
-
return {
|
|
63
|
-
name: "softr",
|
|
64
|
-
apply: "build",
|
|
65
|
-
configResolved(config) {
|
|
66
|
-
root = config.root;
|
|
67
|
-
},
|
|
68
|
-
async closeBundle() {
|
|
69
|
-
rendered ??= renderManifest(root);
|
|
70
|
-
await writeManifest(root, await rendered);
|
|
71
|
-
}
|
|
72
|
-
};
|
|
7
|
+
return [editEngine()];
|
|
73
8
|
}
|
|
74
9
|
/**
|
|
75
10
|
* The edit engine is provided by the pod runtime rather than an app dependency: it is never in the
|
|
@@ -91,4 +26,4 @@ async function editEngine() {
|
|
|
91
26
|
}
|
|
92
27
|
|
|
93
28
|
//#endregion
|
|
94
|
-
export {
|
|
29
|
+
export { softr };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softr/sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
|
@@ -16,17 +16,12 @@
|
|
|
16
16
|
"typecheck": "tsc --noEmit"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@babel/parser": "^7.28.0",
|
|
20
|
-
"@babel/traverse": "^7.28.0",
|
|
21
|
-
"@babel/types": "^7.28.0",
|
|
22
|
-
"@tanstack/router-generator": "^1.167.0",
|
|
23
19
|
"zod": "^4.4.3"
|
|
24
20
|
},
|
|
25
21
|
"devDependencies": {
|
|
26
22
|
"@tanstack/intent": "^0.3.6",
|
|
27
23
|
"@tanstack/react-router": "^1.168.0",
|
|
28
24
|
"@tanstack/react-start": "^1.168.0",
|
|
29
|
-
"@types/babel__traverse": "^7.28.0",
|
|
30
25
|
"@types/node": "^24.3.0",
|
|
31
26
|
"tsdown": "^0.15.0",
|
|
32
27
|
"typescript": "^5.9.2",
|
|
@@ -6,11 +6,12 @@ description: >-
|
|
|
6
6
|
boundary between them, softrAuthMiddleware, getSoftrContext,
|
|
7
7
|
initialSoftrContext, SoftrContext, and which sub-skill to load for route
|
|
8
8
|
visibility, the current user, reading records, writing records, or auth. Load
|
|
9
|
-
this
|
|
9
|
+
this when unsure which sub-skill applies, or when touching the wiring; each
|
|
10
|
+
sub-skill stands alone for its own task.
|
|
10
11
|
metadata:
|
|
11
12
|
type: core
|
|
12
13
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
14
|
+
library_version: 0.0.24
|
|
14
15
|
---
|
|
15
16
|
|
|
16
17
|
# Softr SDK core
|
|
@@ -20,9 +21,9 @@ metadata:
|
|
|
20
21
|
> **CRITICAL**: The SDK is split across four entrypoints and the split is enforced. `ds` and `auth` exist only on `@softr/sdk/server` and only run inside a `createServerFn` handler. Importing them anywhere else fails the build.
|
|
21
22
|
> **CRITICAL**: Every route file must declare its audience with `defineVisibility`. There is no default and nothing is inherited from a sibling. A route file without a declaration is a lint error.
|
|
22
23
|
> **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register the middleware and resolve the session. Do not add a second registration.
|
|
23
|
-
> **CRITICAL**: `
|
|
24
|
+
> **CRITICAL**: `workflow.trigger` is exploratory. It exists on the entrypoints below and typechecks, but its endpoint is not final. Do not build a feature on it. Tell the user the capability is not ready and ask how they want to proceed.
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
Each sub-skill stands alone for its task and repeats the boundary above, so most work needs **one** sub-skill and not this file. Load this one when you are unsure which sub-skill applies, or when the wiring below is what you are touching. Add a second sub-skill only when the work genuinely crosses that boundary: a page that lists records behind a group restriction needs `reading-records` plus `routes-and-visibility`.
|
|
26
27
|
|
|
27
28
|
## Entrypoints
|
|
28
29
|
|
|
@@ -123,4 +124,4 @@ type User = {
|
|
|
123
124
|
|
|
124
125
|
## Version
|
|
125
126
|
|
|
126
|
-
Targets `@softr/sdk` v0.0.
|
|
127
|
+
Targets `@softr/sdk` v0.0.24.
|
|
@@ -10,10 +10,10 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.24
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineVisibility`, the hooks and every type come from `@softr/sdk`. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
17
17
|
|
|
18
18
|
# Auth
|
|
19
19
|
|
|
@@ -10,10 +10,10 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.24
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
**Boundary.** `useCurrentUser`, `usePermissions` and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
17
17
|
|
|
18
18
|
# The current user
|
|
19
19
|
|
|
@@ -8,14 +8,14 @@ description: >-
|
|
|
8
8
|
metadata:
|
|
9
9
|
type: sub-skill
|
|
10
10
|
library: softr-sdk
|
|
11
|
-
library_version: 0.0.
|
|
11
|
+
library_version: 0.0.24
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `q` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
15
15
|
|
|
16
16
|
# Writing records
|
|
17
17
|
|
|
18
|
-
`ds` writes records to a Softr datasource
|
|
18
|
+
`ds` writes records to a Softr datasource, from the server only.
|
|
19
19
|
|
|
20
20
|
## Setup
|
|
21
21
|
|
|
@@ -49,7 +49,7 @@ const createProject = createServerFn({ method: 'POST' })
|
|
|
49
49
|
)
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
Resolves to `{ id, fields }`, and on a Softr table `fields` holds **only the keys you wrote** — not the rest of the record. See "What a write answers with" below.
|
|
53
53
|
|
|
54
54
|
**The payload key is `data`, not `fields`.** A record answers with `fields`, which is where the confusion comes from, but the option you pass is `data`. `CreateOptions` requires it, so a call keyed by `fields` does not type-check.
|
|
55
55
|
|
|
@@ -73,7 +73,24 @@ const renameProject = createServerFn({ method: 'POST' })
|
|
|
73
73
|
|
|
74
74
|
The record id is its own option, so keep it out of `data`.
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
`update` writes the keys in `data` and leaves every other field alone, so a field you leave out is a field you are not writing. An empty `id` throws.
|
|
77
|
+
|
|
78
|
+
### What a write answers with
|
|
79
|
+
|
|
80
|
+
`create` and `update` resolve to `{ id, fields }`, but `fields` is **not the record**. On a Softr table it carries only the keys you sent, aliased through `select`; a status change answers with `{ id, fields: { status } }` and nothing else. (Other datasource types may answer with more. Rely on neither.)
|
|
81
|
+
|
|
82
|
+
So the answer is a receipt, not a replacement for what the page holds. Putting it where a loader-fetched record was blanks every column you did not write. Two honest ways to show the result:
|
|
83
|
+
|
|
84
|
+
- **A quick change to one field** — a status select, a card dragged to another column, an archive toggle — is optimistic: change that one field in your own state immediately, keep the rest of the record as the loader gave it, save in the background, and on a rejected write restore the old value with a clear message. Do not `router.invalidate()` or re-fetch after a successful write; the local change is already what the user sees.
|
|
85
|
+
- **A saved form, or anything that changes several fields**, refreshes the route's data — `router.invalidate()`, see `@tanstack/router-core#router-core/data-loading` — so the list or detail re-reads through its own `select`.
|
|
86
|
+
|
|
87
|
+
Do not re-read the one record with `ds.get` for either: it takes no `select`, so it answers with raw field ids.
|
|
88
|
+
|
|
89
|
+
### Writing a choice field
|
|
90
|
+
|
|
91
|
+
A `SELECT` or `LINKED_RECORD` field reads back as `{ id, label }` (see [field-types](../reading-records/references/field-types.md)). Writing one accepts the option's `id`, its `label`, or `{ id, label }`, matched case-insensitively.
|
|
92
|
+
|
|
93
|
+
**A value that matches no option is not an error — it silently adds a new option to the field.** A typo'd label leaves a junk choice in the schema. So write `{ label }` (or the `{ id, label }` you read), copy labels from the table's schema verbatim, and never invent one.
|
|
77
94
|
|
|
78
95
|
### Delete a record
|
|
79
96
|
|
|
@@ -101,7 +118,7 @@ Stamping a record with who created it, or scoping an update to the person making
|
|
|
101
118
|
|
|
102
119
|
**A datasource id built from a name.** `'ds_projects'`, the table id, or the table's name glued to a uuid are not datasource ids. The id is the opaque UUID assigned when the table was registered on the application; copy it from there. Anything else fails at request time as not found or forbidden.
|
|
103
120
|
|
|
104
|
-
**A datasource id stored in a variable.**
|
|
121
|
+
**A datasource id stored in a variable.** Pass the UUID as a string literal directly at the call site. The lint rule `@softr/ds-no-computed-id` catches this at development time.
|
|
105
122
|
|
|
106
123
|
**A write that sends `fields`.** The option is `data`. Only typecheck catches this, so it survives any run that does not typecheck.
|
|
107
124
|
|
|
@@ -113,8 +130,6 @@ Stamping a record with who created it, or scoping an update to the person making
|
|
|
113
130
|
|
|
114
131
|
**Expecting `select` on a read-then-write round trip.** `ds.get` takes no `select`, so a record fetched by id answers with raw field ids. Load `reading-records` before pairing a read with a write.
|
|
115
132
|
|
|
116
|
-
**A write outside a server function.** `ds` is server-only. Importing it into a component fails the build.
|
|
117
|
-
|
|
118
133
|
**Reading the visitor from somewhere other than `context`.** There is nothing to import. See [current-user](../current-user/SKILL.md).
|
|
119
134
|
|
|
120
135
|
## References
|
|
@@ -10,14 +10,14 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.24
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
**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. `q` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
17
17
|
|
|
18
18
|
# Reading records
|
|
19
19
|
|
|
20
|
-
`ds` reads records out of a Softr datasource
|
|
20
|
+
`ds` reads records out of a Softr datasource, from the server only.
|
|
21
21
|
|
|
22
22
|
## Setup
|
|
23
23
|
|
|
@@ -27,17 +27,15 @@ import { ds } from '@softr/sdk/server'
|
|
|
27
27
|
import { q } from '@softr/sdk'
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
`ds` is server-only and lives on `@softr/sdk/server`. `q` builds queries and nothing more, so it ships on the root entrypoint and can be imported anywhere.
|
|
31
|
-
|
|
32
30
|
Every `ds` call names its datasource by an opaque UUID from the table's registration on the application (Softr Studio's Data tab, or the `register_application_data_source` tool when an agent does it). It is not the table id, and it is never derived from the table's name — `'ds_posts'` or a name glued to a uuid fails at request time as not found or forbidden.
|
|
33
31
|
|
|
34
|
-
**The datasource id must be a string literal at the call site.** Do not assign it to a variable and pass the variable
|
|
32
|
+
**The datasource id must be a string literal at the call site.** Do not assign it to a variable and pass the variable. Write the UUID directly as the first argument:
|
|
35
33
|
|
|
36
34
|
```ts
|
|
37
35
|
// correct
|
|
38
36
|
ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { ... })
|
|
39
37
|
|
|
40
|
-
// wrong
|
|
38
|
+
// wrong
|
|
41
39
|
const POSTS = '8ffb4814-b028-4496-b679-7face2167ebc'
|
|
42
40
|
ds.list(POSTS, { ... })
|
|
43
41
|
```
|
|
@@ -7,19 +7,19 @@ description: >-
|
|
|
7
7
|
per-reason overrides, the redirect and throw strategies, rendering an access
|
|
8
8
|
error with isAccessError, isUnauthenticatedError, isForbiddenError and
|
|
9
9
|
isAlreadySignedInError, guarding a layout, and the lint rule that requires a
|
|
10
|
-
declaration alongside the
|
|
10
|
+
declaration alongside the rules that reject a duplicate, computed or
|
|
11
11
|
root-level one.
|
|
12
12
|
metadata:
|
|
13
13
|
type: sub-skill
|
|
14
14
|
library: softr-sdk
|
|
15
|
-
library_version: 0.0.
|
|
15
|
+
library_version: 0.0.24
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
**Boundary.** `defineVisibility`, the error predicates and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
19
19
|
|
|
20
20
|
# Route visibility
|
|
21
21
|
|
|
22
|
-
Every route file in a Softr app declares who may see it.
|
|
22
|
+
Every route file in a Softr app declares who may see it. Softr reads that declaration out of the source text without executing it and publishes it to Softr Studio, which shows the app owner every route and its audience. That is why the rules below are strict: a rule Softr cannot read is a rule Studio cannot show.
|
|
23
23
|
|
|
24
24
|
## Setup
|
|
25
25
|
|
|
@@ -43,11 +43,11 @@ function Billing() {
|
|
|
43
43
|
|
|
44
44
|
One `defineVisibility` call per file, at module scope, with an inline object literal as its argument.
|
|
45
45
|
|
|
46
|
-
Three rules about that shape, because
|
|
46
|
+
Three rules about that shape, because Softr reads the file rather than running it:
|
|
47
47
|
|
|
48
48
|
- The call must initialise a top-level `const`.
|
|
49
49
|
- The import must be a direct named import from `@softr/sdk`.
|
|
50
|
-
- Never declare on `__root.tsx`. The root cannot carry an audience, so
|
|
50
|
+
- 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.
|
|
51
51
|
|
|
52
52
|
The four rules:
|
|
53
53
|
|
|
@@ -82,7 +82,7 @@ export const Route = createFileRoute('/_app')({
|
|
|
82
82
|
|
|
83
83
|
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. Routes below a guarded layout still declare their own rule, because the declaration is what Studio reads.
|
|
84
84
|
|
|
85
|
-
A thrown error renders in place of the guarded route's component.
|
|
85
|
+
A thrown error renders in place of the guarded route's component. The template's `_app.tsx` shell is guarded with `LOGGED_IN_USERS`, whose default for a signed-out visitor is a redirect, so nothing ever renders there. A rule that can *throw* — a `userGroupIds` rule, whose `forbidden` default is throw — should not sit on the shell itself, or the refusal replaces the nav and chrome. Put it on a layout inside the shell, or on the restricted routes, so the refusal renders inside the chrome.
|
|
86
86
|
|
|
87
87
|
### Change what a denied visitor gets
|
|
88
88
|
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { makeSessionClearingCookies, makeSessionTokenCookie, sessionTokenCookieName } from "../auth/session.js";
|
|
2
|
-
|
|
3
|
-
//#region src/preview-identity/preview-identity.ts
|
|
4
|
-
const PREVIEW_REDEMPTION_PATH = "/__preview/identity";
|
|
5
|
-
const PREVIEW_SIGNED_IN_PATH = "/__preview/signed-in";
|
|
6
|
-
/** The grant in the URL is the only credential: studio-api authorized the builder before issuing it. */
|
|
7
|
-
const redeemGrant = async (req, res) => {
|
|
8
|
-
res.setHeader("Cache-Control", "no-store");
|
|
9
|
-
res.setHeader("Referrer-Policy", "no-referrer");
|
|
10
|
-
if (req.method !== "GET") {
|
|
11
|
-
sendJson(res, 405, { error: `${PREVIEW_REDEMPTION_PATH} only supports GET` });
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
const grant = searchParamsOf(req).get("grant");
|
|
15
|
-
if (!grant) {
|
|
16
|
-
sendJson(res, 400, { error: "no grant" });
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
const landing = landingPath(searchParamsOf(req).get("next"));
|
|
20
|
-
let session;
|
|
21
|
-
try {
|
|
22
|
-
session = await redeemPreviewGrant(req, grant);
|
|
23
|
-
} catch (error) {
|
|
24
|
-
sendJson(res, statusOf(error), { error: messageOf(error) });
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
const secure = requestIsHttps(req);
|
|
28
|
-
res.statusCode = 302;
|
|
29
|
-
res.setHeader("Location", landing);
|
|
30
|
-
res.setHeader("Set-Cookie", session.anonymous ? makeSessionClearingCookies(secure) : makeSessionTokenCookie(session.jwt, secure));
|
|
31
|
-
res.end();
|
|
32
|
-
};
|
|
33
|
-
/** Presence only, never decoded: decoding would put an app user's identity on a route Studio reads. */
|
|
34
|
-
const reportSignedIn = (req, res) => {
|
|
35
|
-
res.setHeader("Cache-Control", "no-store");
|
|
36
|
-
if (req.method !== "GET") {
|
|
37
|
-
sendJson(res, 405, { error: `${PREVIEW_SIGNED_IN_PATH} only supports GET` });
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
sendJson(res, 200, { signedIn: hasSessionCookie(req.headers.cookie, sessionTokenCookieName(requestIsHttps(req))) });
|
|
41
|
-
};
|
|
42
|
-
var RedemptionError = class extends Error {
|
|
43
|
-
status;
|
|
44
|
-
constructor(status, message) {
|
|
45
|
-
super(message);
|
|
46
|
-
this.status = status;
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
function statusOf(error) {
|
|
50
|
-
return error instanceof RedemptionError ? error.status : 503;
|
|
51
|
-
}
|
|
52
|
-
function messageOf(error) {
|
|
53
|
-
return error instanceof Error ? error.message : String(error);
|
|
54
|
-
}
|
|
55
|
-
async function redeemPreviewGrant(req, grant) {
|
|
56
|
-
const applicationId = process.env.SOFTR_APPLICATION_ID;
|
|
57
|
-
const studioApiUrl = process.env.SOFTR_STUDIO_API_URL?.trim() || studioApiUrlFor(req);
|
|
58
|
-
if (!applicationId || !studioApiUrl) throw new RedemptionError(503, "studio-api is unavailable");
|
|
59
|
-
let response;
|
|
60
|
-
try {
|
|
61
|
-
response = await fetch(`${studioApiUrl}/v1/applications/${encodeURIComponent(applicationId)}/vibe-coding/preview-identity/redeem`, {
|
|
62
|
-
method: "POST",
|
|
63
|
-
headers: { "content-type": "application/json" },
|
|
64
|
-
body: JSON.stringify({ grant })
|
|
65
|
-
});
|
|
66
|
-
} catch {
|
|
67
|
-
throw new RedemptionError(503, "studio-api is unreachable");
|
|
68
|
-
}
|
|
69
|
-
if (!response.ok) throw new RedemptionError(response.status, "studio-api refused the preview identity grant");
|
|
70
|
-
const body = await response.json().catch(() => null);
|
|
71
|
-
if (typeof body !== "object" || body === null) throw new RedemptionError(503, "studio-api returned no session");
|
|
72
|
-
const { anonymous, jwt } = body;
|
|
73
|
-
if (anonymous === true) return { anonymous: true };
|
|
74
|
-
if (typeof jwt !== "string" || jwt.length === 0) throw new RedemptionError(503, "studio-api returned no token");
|
|
75
|
-
return {
|
|
76
|
-
anonymous: false,
|
|
77
|
-
jwt
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
const STAGING_HOST_PATTERN = /(?:^|\.)staging(\d+)\.softr\.io$/;
|
|
81
|
-
/** The host is the only per-environment signal the app has. Anything but a staging one gives null. */
|
|
82
|
-
function studioApiUrlFor(req) {
|
|
83
|
-
const forwarded = req.headers["x-forwarded-host"];
|
|
84
|
-
const name = ((Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.headers.host ?? "").split(",")[0].trim().split(":")[0];
|
|
85
|
-
const digits = STAGING_HOST_PATTERN.exec(name)?.[1];
|
|
86
|
-
if (!digits) return null;
|
|
87
|
-
return `https://studio-api-staging${digits.padStart(2, "0")}.softr.io`;
|
|
88
|
-
}
|
|
89
|
-
function hasSessionCookie(header, name) {
|
|
90
|
-
if (!header) return false;
|
|
91
|
-
return header.split(";").some((pair) => {
|
|
92
|
-
const separator = pair.indexOf("=");
|
|
93
|
-
return separator !== -1 && pair.slice(0, separator).trim() === name;
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Exactly as strict as h3's `getRequestProtocol`, which reads the cookie back. Being more generous
|
|
98
|
-
* writes `__Host-session_token` where the app looks up `session_token`, and the visitor is silently
|
|
99
|
-
* anonymous with no error on either side.
|
|
100
|
-
*/
|
|
101
|
-
function requestIsHttps(req) {
|
|
102
|
-
const forwarded = req.headers["x-forwarded-proto"];
|
|
103
|
-
if (forwarded === "https") return true;
|
|
104
|
-
if (forwarded === "http") return false;
|
|
105
|
-
return Boolean(req.socket?.encrypted);
|
|
106
|
-
}
|
|
107
|
-
function landingPath(next) {
|
|
108
|
-
if (!next || !next.startsWith("/") || next.startsWith("//") || next.includes("\\") || next.includes("://") || /[\u0000-\u001f\u007f]/.test(next)) return "/";
|
|
109
|
-
return next;
|
|
110
|
-
}
|
|
111
|
-
function searchParamsOf(req) {
|
|
112
|
-
return new URL(req.url ?? "", "http://request.invalid").searchParams;
|
|
113
|
-
}
|
|
114
|
-
function sendJson(res, status, body) {
|
|
115
|
-
res.statusCode = status;
|
|
116
|
-
res.setHeader("Content-Type", "application/json");
|
|
117
|
-
res.end(JSON.stringify(body));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
//#endregion
|
|
121
|
-
export { PREVIEW_REDEMPTION_PATH, PREVIEW_SIGNED_IN_PATH, redeemGrant, reportSignedIn };
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import "@tanstack/router-generator";
|
|
2
|
-
|
|
3
|
-
//#region src/routes/discover.d.ts
|
|
4
|
-
|
|
5
|
-
type DatasourceMethod = "list" | "get" | "create" | "update" | "delete" | "metric" | "chartData";
|
|
6
|
-
type DatasourceAccess = {
|
|
7
|
-
id: string;
|
|
8
|
-
methods: Array<DatasourceMethod>;
|
|
9
|
-
};
|
|
10
|
-
type VisibilityValue = string | Array<VisibilityValue> | {
|
|
11
|
-
[key: string]: VisibilityValue;
|
|
12
|
-
};
|
|
13
|
-
//#endregion
|
|
14
|
-
export { DatasourceAccess, VisibilityValue };
|