@softr/sdk 0.0.12 → 0.0.14
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/assertions.js +68 -0
- package/dist/auth/config.js +27 -0
- package/dist/auth/context.d.ts +18 -0
- package/dist/auth/isomorphic.d.ts +73 -0
- package/dist/auth/isomorphic.js +113 -0
- package/dist/auth/methods/password.d.ts +22 -0
- package/dist/auth/methods/password.js +51 -0
- package/dist/auth/methods/signup.d.ts +37 -0
- package/dist/auth/methods/signup.js +89 -0
- package/dist/auth/methods/utils.d.ts +15 -0
- package/dist/auth/methods/utils.js +13 -0
- package/dist/auth/permissions.d.ts +17 -0
- package/dist/auth/permissions.js +13 -0
- package/dist/auth/platform.js +138 -0
- package/dist/auth/server.d.ts +17 -0
- package/dist/auth/server.js +17 -0
- package/dist/auth/session.js +167 -0
- package/dist/auth/user.d.ts +28 -0
- package/dist/client/timezone.d.ts +4 -0
- package/dist/client/timezone.js +11 -0
- package/dist/client.d.ts +1 -4
- package/dist/client.js +1 -9
- package/dist/cookies.js +47 -0
- package/dist/core/datasource.d.ts +21 -0
- package/dist/core/datasource.js +221 -0
- package/dist/core/integration.d.ts +7 -0
- package/dist/core/integration.js +32 -0
- package/dist/{types-n-Sir_Y2.d.ts → core/query.d.ts} +1 -129
- package/dist/core/query.js +528 -0
- package/dist/core/workflow.d.ts +7 -0
- package/dist/core/workflow.js +19 -0
- package/dist/index.d.ts +8 -172
- package/dist/index.js +4 -176
- package/dist/routes/discover.d.ts +9 -0
- package/dist/routes/discover.js +198 -0
- package/dist/routes/manifest.d.ts +17 -0
- package/dist/routes/manifest.js +148 -0
- package/dist/routes/mem-fs.js +71 -0
- package/dist/routes/visibility.d.ts +142 -0
- package/dist/routes/visibility.js +125 -0
- package/dist/server.d.ts +4 -113
- package/dist/server.js +4 -429
- package/dist/types.d.ts +115 -0
- package/dist/vite.d.ts +1 -21
- package/dist/vite.js +2 -405
- package/package.json +4 -4
- package/dist/session-kTqxT5_K.js +0 -891
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { crawlRoutes, extractVisibility } from "./discover.js";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { inferFullPath } from "@tanstack/router-generator";
|
|
5
|
+
|
|
6
|
+
//#region src/routes/manifest.ts
|
|
7
|
+
var ManifestError = class extends Error {
|
|
8
|
+
detail;
|
|
9
|
+
constructor(detail) {
|
|
10
|
+
super(detail.message);
|
|
11
|
+
this.name = "ManifestError";
|
|
12
|
+
this.detail = detail;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/** Returns the manifest as the exact text to write. Throws on a declaration it cannot read. */
|
|
16
|
+
async function generateRoutesManifest(root) {
|
|
17
|
+
const crawl = await crawlRoutes({ root });
|
|
18
|
+
const routeFiles = new Set([crawl.rootRouteNode.filePath]);
|
|
19
|
+
for (const node of crawl.routeNodes) routeFiles.add(node.filePath);
|
|
20
|
+
for (const pieces of Object.values(crawl.routePiecesByPath)) if (pieces.lazy) routeFiles.add(pieces.lazy.filePath);
|
|
21
|
+
const sources = /* @__PURE__ */ new Map();
|
|
22
|
+
for (const relative of routeFiles) {
|
|
23
|
+
const absolute = path.resolve(root, crawl.routesDirectory, relative);
|
|
24
|
+
const source = await readIfPresent(absolute);
|
|
25
|
+
if (source !== null) sources.set(absolute, source);
|
|
26
|
+
}
|
|
27
|
+
const manifest = buildManifest({
|
|
28
|
+
crawl,
|
|
29
|
+
root,
|
|
30
|
+
sources
|
|
31
|
+
});
|
|
32
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
33
|
+
}
|
|
34
|
+
function buildManifest(opts) {
|
|
35
|
+
const routesDirectory = opts.crawl.routesDirectory;
|
|
36
|
+
const toNode = (node) => {
|
|
37
|
+
const projectRelative = toProjectRelative(opts.root, routesDirectory, node.filePath);
|
|
38
|
+
const routePath = node.routePath;
|
|
39
|
+
if (routePath === void 0) throw new Error(`the route crawl produced a node with no route path: ${projectRelative}`);
|
|
40
|
+
let declared = visibilityOf(opts, node.filePath);
|
|
41
|
+
const lazyPiece = opts.crawl.routePiecesByPath[routePath]?.lazy;
|
|
42
|
+
if (lazyPiece && lazyPiece.filePath !== node.filePath) {
|
|
43
|
+
const lazyDeclared = visibilityOf(opts, lazyPiece.filePath);
|
|
44
|
+
if (lazyDeclared !== null && declared !== null) throw new ManifestError({
|
|
45
|
+
code: "duplicate-declaration",
|
|
46
|
+
message: `${routePath} declares visibility in both ${projectRelative} and ${lazyDeclared.filePath}; a route has exactly one rule`,
|
|
47
|
+
filePath: lazyDeclared.filePath,
|
|
48
|
+
line: lazyDeclared.line,
|
|
49
|
+
column: lazyDeclared.column
|
|
50
|
+
});
|
|
51
|
+
if (lazyDeclared !== null) declared = lazyDeclared;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
routePath,
|
|
55
|
+
filePath: projectRelative,
|
|
56
|
+
...node._fsRouteType ? { _fsRouteType: node._fsRouteType } : {},
|
|
57
|
+
inferFullPath: inferFullPath(node) ?? null,
|
|
58
|
+
createFileRouteProps: [...node.createFileRouteProps ?? []].sort(),
|
|
59
|
+
visibility: declared?.value ?? null,
|
|
60
|
+
children: (node.children ?? []).map(toNode)
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const rootNode = opts.crawl.rootRouteNode;
|
|
64
|
+
const rootRelative = toProjectRelative(opts.root, routesDirectory, rootNode.filePath);
|
|
65
|
+
const rootDeclared = visibilityOf(opts, rootNode.filePath);
|
|
66
|
+
if (rootDeclared !== null) throw new ManifestError({
|
|
67
|
+
code: "root-declaration",
|
|
68
|
+
message: "the root route cannot carry a visibility rule: it would gate sign-in with no way back in. Move this declaration to the routes you want gated.",
|
|
69
|
+
filePath: rootRelative,
|
|
70
|
+
line: rootDeclared.line,
|
|
71
|
+
column: rootDeclared.column
|
|
72
|
+
});
|
|
73
|
+
return { root: {
|
|
74
|
+
routePath: ROOT_ROUTE_ID,
|
|
75
|
+
filePath: rootRelative,
|
|
76
|
+
inferFullPath: inferFullPath(rootNode) ?? null,
|
|
77
|
+
createFileRouteProps: [...rootNode.createFileRouteProps ?? []].sort(),
|
|
78
|
+
visibility: null,
|
|
79
|
+
children: opts.crawl.routeTree.map(toNode)
|
|
80
|
+
} };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `rootRouteId` from `@tanstack/router-core`, which is what a consumer matches against — not the
|
|
84
|
+
* generator's `rootPathId`, which is the base name of `__root.tsx` and names a file, not a route.
|
|
85
|
+
*/
|
|
86
|
+
const ROOT_ROUTE_ID = "__root__";
|
|
87
|
+
/** The crawl's paths are relative to the routes directory; the manifest's are project-relative. */
|
|
88
|
+
function toProjectRelative(root, routesDirectory, filePath) {
|
|
89
|
+
const absolute = path.resolve(root, routesDirectory, filePath);
|
|
90
|
+
return path.relative(root, absolute).split(path.sep).join("/");
|
|
91
|
+
}
|
|
92
|
+
const USER_GROUPS = [
|
|
93
|
+
"ALL_USERS",
|
|
94
|
+
"NON_LOGGED_IN_USERS",
|
|
95
|
+
"LOGGED_IN_USERS"
|
|
96
|
+
];
|
|
97
|
+
function checkVisibilityShape(declared) {
|
|
98
|
+
const reject = (message) => {
|
|
99
|
+
throw new ManifestError({
|
|
100
|
+
code: "invalid-visibility",
|
|
101
|
+
message: `${declared.filePath}: ${message}`,
|
|
102
|
+
filePath: declared.filePath,
|
|
103
|
+
line: declared.line,
|
|
104
|
+
column: declared.column
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
const visibility = declared.value;
|
|
108
|
+
if (typeof visibility !== "object" || Array.isArray(visibility)) reject(`the visibility rule must be an object literal like { userGroup: 'ALL_USERS' }, not ${JSON.stringify(visibility)}`);
|
|
109
|
+
for (const key of Object.keys(visibility)) {
|
|
110
|
+
if (key === "userGroup" || key === "userGroupIds") continue;
|
|
111
|
+
reject(`"${key}" is not part of a visibility rule; a rule has userGroup and, under LOGGED_IN_USERS, userGroupIds. Check the spelling.`);
|
|
112
|
+
}
|
|
113
|
+
const userGroup = visibility["userGroup"];
|
|
114
|
+
if (typeof userGroup !== "string" || !USER_GROUPS.includes(userGroup)) reject(`userGroup must be one of ${USER_GROUPS.join(", ")}, not ${JSON.stringify(userGroup)}. A rule the build cannot read is a route with no rule at all.`);
|
|
115
|
+
if (!("userGroupIds" in visibility)) return;
|
|
116
|
+
if (userGroup !== "LOGGED_IN_USERS") reject(`userGroupIds only means anything under LOGGED_IN_USERS, not under ${userGroup}`);
|
|
117
|
+
const ids = visibility["userGroupIds"];
|
|
118
|
+
if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string")) reject(`userGroupIds must be an array of group id strings, not ${JSON.stringify(ids)}. Drop the key entirely to mean "any logged-in user".`);
|
|
119
|
+
}
|
|
120
|
+
function visibilityOf(opts, filePath) {
|
|
121
|
+
const projectRelative = toProjectRelative(opts.root, opts.crawl.routesDirectory, filePath);
|
|
122
|
+
const source = opts.sources.get(path.resolve(opts.root, opts.crawl.routesDirectory, filePath));
|
|
123
|
+
if (source === void 0) return null;
|
|
124
|
+
const result = extractVisibility(source, projectRelative);
|
|
125
|
+
if (!result.ok) throw new ManifestError(result.error);
|
|
126
|
+
if (result.visibility === null) return null;
|
|
127
|
+
const declared = {
|
|
128
|
+
...result.visibility,
|
|
129
|
+
filePath: projectRelative
|
|
130
|
+
};
|
|
131
|
+
checkVisibilityShape(declared);
|
|
132
|
+
return declared;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Only ENOENT becomes `null`. The caller reads `null` as "nothing declared here", so swallowing any
|
|
136
|
+
* other read error would turn an unreadable declaration into an ungated route.
|
|
137
|
+
*/
|
|
138
|
+
async function readIfPresent(filePath) {
|
|
139
|
+
try {
|
|
140
|
+
return await readFile(filePath, "utf8");
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error.code === "ENOENT") return null;
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
//#endregion
|
|
148
|
+
export { ManifestError, generateRoutesManifest };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as nodeFs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
//#region src/routes/mem-fs.ts
|
|
5
|
+
/**
|
|
6
|
+
* Reads through to disk and swallows every write. Driving the generator without this rewrites the
|
|
7
|
+
* app's route sources and strips Start's `Register` block from its routeTree.gen.ts.
|
|
8
|
+
*/
|
|
9
|
+
function createReadOnlyFs() {
|
|
10
|
+
const writes = /* @__PURE__ */ new Map();
|
|
11
|
+
return {
|
|
12
|
+
async stat(filePath) {
|
|
13
|
+
const stat = await statOrNull(path.resolve(filePath));
|
|
14
|
+
if (stat === null) return {
|
|
15
|
+
mtimeMs: SYNTHETIC_MTIME,
|
|
16
|
+
mode: 420,
|
|
17
|
+
uid: 0,
|
|
18
|
+
gid: 0
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
mtimeMs: stat.mtimeMs,
|
|
22
|
+
mode: Number(stat.mode),
|
|
23
|
+
uid: Number(stat.uid),
|
|
24
|
+
gid: Number(stat.gid)
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
async rename(oldPath, newPath) {
|
|
28
|
+
const from = path.resolve(oldPath);
|
|
29
|
+
const content = writes.get(from);
|
|
30
|
+
if (content !== void 0) {
|
|
31
|
+
writes.delete(from);
|
|
32
|
+
writes.set(path.resolve(newPath), content);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
async writeFile(filePath, content) {
|
|
36
|
+
writes.set(path.resolve(filePath), content);
|
|
37
|
+
},
|
|
38
|
+
async readFile(filePath) {
|
|
39
|
+
const resolved = path.resolve(filePath);
|
|
40
|
+
const pending = writes.get(resolved);
|
|
41
|
+
const stat = await statOrNull(resolved);
|
|
42
|
+
if (pending !== void 0) return {
|
|
43
|
+
stat: { mtimeMs: stat?.mtimeMs ?? SYNTHETIC_MTIME },
|
|
44
|
+
fileContent: pending
|
|
45
|
+
};
|
|
46
|
+
if (stat === null) return "file-not-existing";
|
|
47
|
+
try {
|
|
48
|
+
const fileContent = await nodeFs.readFile(resolved, "utf8");
|
|
49
|
+
return {
|
|
50
|
+
stat: { mtimeMs: stat.mtimeMs },
|
|
51
|
+
fileContent
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return "file-not-existing";
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
async chmod() {},
|
|
58
|
+
async chown() {}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const SYNTHETIC_MTIME = 1n;
|
|
62
|
+
async function statOrNull(filePath) {
|
|
63
|
+
try {
|
|
64
|
+
return await nodeFs.stat(filePath, { bigint: true });
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
//#endregion
|
|
71
|
+
export { createReadOnlyFs };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { SoftrUserGroupId, User } from "../auth/user.js";
|
|
2
|
+
import { SoftrContext } from "../auth/context.js";
|
|
3
|
+
|
|
4
|
+
//#region src/routes/visibility.d.ts
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Who may see a route.
|
|
8
|
+
*
|
|
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.
|
|
13
|
+
*/
|
|
14
|
+
type RouteVisibility = {
|
|
15
|
+
userGroup: "ALL_USERS";
|
|
16
|
+
} | {
|
|
17
|
+
userGroup: "NON_LOGGED_IN_USERS";
|
|
18
|
+
} | {
|
|
19
|
+
userGroup: "LOGGED_IN_USERS";
|
|
20
|
+
userGroupIds?: Array<SoftrUserGroupId>;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Why a visitor was denied.
|
|
24
|
+
*
|
|
25
|
+
* - `unauthenticated` — the route needs a signed-in visitor and there is none.
|
|
26
|
+
* - `forbidden` — signed in, but in none of the groups the route names.
|
|
27
|
+
* - `alreadySignedIn` — the route is for signed-out visitors only.
|
|
28
|
+
*/
|
|
29
|
+
declare const ACCESS_REASONS: readonly ["unauthenticated", "forbidden", "alreadySignedIn"];
|
|
30
|
+
type AccessReason = (typeof ACCESS_REASONS)[number];
|
|
31
|
+
/**
|
|
32
|
+
* What a guard does when it refuses a visitor: redirect to a path, or throw an
|
|
33
|
+
* {@link AccessError} for the route's `errorComponent` to render. A bare string
|
|
34
|
+
* is shorthand for redirecting there.
|
|
35
|
+
*/
|
|
36
|
+
type AccessBehaviour = string | {
|
|
37
|
+
strategy: "redirect";
|
|
38
|
+
to: string;
|
|
39
|
+
} | {
|
|
40
|
+
strategy: "throw";
|
|
41
|
+
};
|
|
42
|
+
/** Per-reason overrides of the default {@link AccessBehaviour}. */
|
|
43
|
+
type GuardOverrides = Partial<Record<AccessReason, AccessBehaviour>>;
|
|
44
|
+
/** The result of {@link VisibilityHandle.check}: passed, or denied with a reason. */
|
|
45
|
+
type AccessCheck = {
|
|
46
|
+
ok: true;
|
|
47
|
+
} | {
|
|
48
|
+
ok: false;
|
|
49
|
+
reason: AccessReason;
|
|
50
|
+
};
|
|
51
|
+
/** The one field of {@link SoftrContext} every access check reads. */
|
|
52
|
+
type AccessContext = Pick<SoftrContext, "user">;
|
|
53
|
+
/** Decides whether a visitor passes a visibility rule, without throwing. */
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The access error a guard throws. It is plain data, not an `Error` subclass, so
|
|
57
|
+
* take it as `unknown` in an `errorComponent` and narrow it with
|
|
58
|
+
* {@link isAccessError}. It carries the reason and nothing else: the error is
|
|
59
|
+
* serialized into the page, so it never names the groups the route wanted.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* errorComponent: ({ error }: { error: unknown }) =>
|
|
63
|
+
* isAccessError(error) ? <NoAccess /> : <AppError />
|
|
64
|
+
*/
|
|
65
|
+
type AccessError = {
|
|
66
|
+
type: "access_error";
|
|
67
|
+
reason: AccessReason;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* True for any access error thrown by a guard, whatever its reason.
|
|
71
|
+
*
|
|
72
|
+
* An access error arrives as `unknown` — hydrated from the page, or thrown by
|
|
73
|
+
* anything else at all — so every field is checked before it is narrowed.
|
|
74
|
+
*/
|
|
75
|
+
declare function isAccessError(error: unknown): error is AccessError;
|
|
76
|
+
/** True for an access error thrown because nobody was signed in. */
|
|
77
|
+
declare function isUnauthenticatedError(error: unknown): error is AccessError;
|
|
78
|
+
/** True for an access error thrown because the visitor was in none of the groups. */
|
|
79
|
+
declare function isForbiddenError(error: unknown): error is AccessError;
|
|
80
|
+
/** True for an access error thrown because a signed-out-only route saw a user. */
|
|
81
|
+
declare function isAlreadySignedInError(error: unknown): error is AccessError;
|
|
82
|
+
type Narrowed<R extends RouteVisibility> = R extends {
|
|
83
|
+
userGroup: "LOGGED_IN_USERS";
|
|
84
|
+
} ? AccessContext & {
|
|
85
|
+
user: User;
|
|
86
|
+
} : R extends {
|
|
87
|
+
userGroup: "NON_LOGGED_IN_USERS";
|
|
88
|
+
} ? AccessContext & {
|
|
89
|
+
user: null;
|
|
90
|
+
} : AccessContext;
|
|
91
|
+
/** The four ways to apply a rule, returned by {@link defineVisibility}. */
|
|
92
|
+
type VisibilityHandle<R extends RouteVisibility = RouteVisibility> = {
|
|
93
|
+
/**
|
|
94
|
+
* A `beforeLoad` handler that denies a visitor who fails the rule.
|
|
95
|
+
*
|
|
96
|
+
* By default an unauthenticated visitor is redirected to `/login`, an already
|
|
97
|
+
* signed-in one to `/`, and a forbidden one gets an {@link AccessError} thrown
|
|
98
|
+
* in place, for the route's `errorComponent` to render. Pass `overrides` to
|
|
99
|
+
* change what any one reason does.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* beforeLoad: visibility.guard({ unauthenticated: "/sign-in" })
|
|
103
|
+
*/
|
|
104
|
+
guard(overrides?: GuardOverrides): (opts: {
|
|
105
|
+
context: AccessContext;
|
|
106
|
+
}) => void;
|
|
107
|
+
/**
|
|
108
|
+
* Throws the same access error or redirect `guard()` would, for code that
|
|
109
|
+
* needs to run its own checks after the rule.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* beforeLoad: ({ context }) => {
|
|
113
|
+
* visibility.assert(context)
|
|
114
|
+
* if (context.user.trialExpired) throw redirect({ to: "/renew" })
|
|
115
|
+
* }
|
|
116
|
+
*/
|
|
117
|
+
assert(context: AccessContext): void;
|
|
118
|
+
/**
|
|
119
|
+
* Whether the visitor passes the rule. Narrows `context.user` where the rule
|
|
120
|
+
* allows it.
|
|
121
|
+
*/
|
|
122
|
+
isAuthorized(context: AccessContext): context is Narrowed<R>;
|
|
123
|
+
/** The decision and, when a visitor is refused, the reason. Throws nothing. */
|
|
124
|
+
check(context: AccessContext): AccessCheck;
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Declares who may see a route, and returns the handle that enforces it.
|
|
128
|
+
*
|
|
129
|
+
* One call per route file, at module scope: the build reads these declarations
|
|
130
|
+
* to publish the app's route visibility, so the rule must be a literal.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* const visibility = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
134
|
+
*
|
|
135
|
+
* export const Route = createFileRoute("/about")({
|
|
136
|
+
* beforeLoad: visibility.guard(),
|
|
137
|
+
* component: About,
|
|
138
|
+
* })
|
|
139
|
+
*/
|
|
140
|
+
declare function defineVisibility<const R extends RouteVisibility>(rule: R): VisibilityHandle<R>;
|
|
141
|
+
//#endregion
|
|
142
|
+
export { AccessBehaviour, AccessCheck, AccessError, AccessReason, RouteVisibility, VisibilityHandle, defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { redirect } from "@tanstack/react-router";
|
|
2
|
+
|
|
3
|
+
//#region src/routes/visibility.ts
|
|
4
|
+
/**
|
|
5
|
+
* Why a visitor was denied.
|
|
6
|
+
*
|
|
7
|
+
* - `unauthenticated` — the route needs a signed-in visitor and there is none.
|
|
8
|
+
* - `forbidden` — signed in, but in none of the groups the route names.
|
|
9
|
+
* - `alreadySignedIn` — the route is for signed-out visitors only.
|
|
10
|
+
*/
|
|
11
|
+
const ACCESS_REASONS = [
|
|
12
|
+
"unauthenticated",
|
|
13
|
+
"forbidden",
|
|
14
|
+
"alreadySignedIn"
|
|
15
|
+
];
|
|
16
|
+
/** Decides whether a visitor passes a visibility rule, without throwing. */
|
|
17
|
+
function checkAccess(rule, context) {
|
|
18
|
+
const { user } = context;
|
|
19
|
+
switch (rule.userGroup) {
|
|
20
|
+
case "ALL_USERS": return { ok: true };
|
|
21
|
+
case "NON_LOGGED_IN_USERS": return user === null ? { ok: true } : {
|
|
22
|
+
ok: false,
|
|
23
|
+
reason: "alreadySignedIn"
|
|
24
|
+
};
|
|
25
|
+
case "LOGGED_IN_USERS": {
|
|
26
|
+
if (user === null) return {
|
|
27
|
+
ok: false,
|
|
28
|
+
reason: "unauthenticated"
|
|
29
|
+
};
|
|
30
|
+
const required = rule.userGroupIds ?? [];
|
|
31
|
+
if (required.length === 0) return { ok: true };
|
|
32
|
+
return required.some((id) => user.userGroupIds.includes(id)) ? { ok: true } : {
|
|
33
|
+
ok: false,
|
|
34
|
+
reason: "forbidden"
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function isAccessReason(value) {
|
|
40
|
+
return ACCESS_REASONS.some((reason) => reason === value);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* True for any access error thrown by a guard, whatever its reason.
|
|
44
|
+
*
|
|
45
|
+
* An access error arrives as `unknown` — hydrated from the page, or thrown by
|
|
46
|
+
* anything else at all — so every field is checked before it is narrowed.
|
|
47
|
+
*/
|
|
48
|
+
function isAccessError(error) {
|
|
49
|
+
if (typeof error !== "object" || error === null) return false;
|
|
50
|
+
const candidate = error;
|
|
51
|
+
if (candidate.type !== "access_error") return false;
|
|
52
|
+
return isAccessReason(candidate.reason);
|
|
53
|
+
}
|
|
54
|
+
function isReason(error, reason) {
|
|
55
|
+
return isAccessError(error) && error.reason === reason;
|
|
56
|
+
}
|
|
57
|
+
/** True for an access error thrown because nobody was signed in. */
|
|
58
|
+
function isUnauthenticatedError(error) {
|
|
59
|
+
return isReason(error, "unauthenticated");
|
|
60
|
+
}
|
|
61
|
+
/** True for an access error thrown because the visitor was in none of the groups. */
|
|
62
|
+
function isForbiddenError(error) {
|
|
63
|
+
return isReason(error, "forbidden");
|
|
64
|
+
}
|
|
65
|
+
/** True for an access error thrown because a signed-out-only route saw a user. */
|
|
66
|
+
function isAlreadySignedInError(error) {
|
|
67
|
+
return isReason(error, "alreadySignedIn");
|
|
68
|
+
}
|
|
69
|
+
const DEFAULT_ACCESS_BEHAVIOUR = {
|
|
70
|
+
unauthenticated: {
|
|
71
|
+
strategy: "redirect",
|
|
72
|
+
to: "/login"
|
|
73
|
+
},
|
|
74
|
+
forbidden: { strategy: "throw" },
|
|
75
|
+
alreadySignedIn: {
|
|
76
|
+
strategy: "redirect",
|
|
77
|
+
to: "/"
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/** What a guard throws for a reason: a redirect, or an {@link AccessError}. */
|
|
81
|
+
function thrownFor(reason, overrides) {
|
|
82
|
+
const behaviour = overrides?.[reason] ?? DEFAULT_ACCESS_BEHAVIOUR[reason];
|
|
83
|
+
const resolved = typeof behaviour === "string" ? {
|
|
84
|
+
strategy: "redirect",
|
|
85
|
+
to: behaviour
|
|
86
|
+
} : behaviour;
|
|
87
|
+
if (resolved.strategy === "redirect") return redirect({ to: resolved.to });
|
|
88
|
+
return {
|
|
89
|
+
type: "access_error",
|
|
90
|
+
reason
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Declares who may see a route, and returns the handle that enforces it.
|
|
95
|
+
*
|
|
96
|
+
* One call per route file, at module scope: the build reads these declarations
|
|
97
|
+
* to publish the app's route visibility, so the rule must be a literal.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* const visibility = defineVisibility({ userGroup: "LOGGED_IN_USERS" })
|
|
101
|
+
*
|
|
102
|
+
* export const Route = createFileRoute("/about")({
|
|
103
|
+
* beforeLoad: visibility.guard(),
|
|
104
|
+
* component: About,
|
|
105
|
+
* })
|
|
106
|
+
*/
|
|
107
|
+
function defineVisibility(rule) {
|
|
108
|
+
return {
|
|
109
|
+
guard: (overrides) => ({ context }) => {
|
|
110
|
+
const result = checkAccess(rule, context);
|
|
111
|
+
if (result.ok) return;
|
|
112
|
+
throw thrownFor(result.reason, overrides);
|
|
113
|
+
},
|
|
114
|
+
assert: (context) => {
|
|
115
|
+
const result = checkAccess(rule, context);
|
|
116
|
+
if (result.ok) return;
|
|
117
|
+
throw thrownFor(result.reason, void 0);
|
|
118
|
+
},
|
|
119
|
+
isAuthorized: (context) => checkAccess(rule, context).ok,
|
|
120
|
+
check: (context) => checkAccess(rule, context)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
//#endregion
|
|
125
|
+
export { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,115 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { auth } from "./auth/server.js";
|
|
2
|
+
import { ds } from "./core/datasource.js";
|
|
3
|
+
import { workflow } from "./core/workflow.js";
|
|
4
|
+
import { integration } from "./core/integration.js";
|
|
2
5
|
import "@tanstack/react-start/server-only";
|
|
3
|
-
|
|
4
|
-
//#region src/auth/methods/utils.d.ts
|
|
5
|
-
type Result<T, E = Error> = {
|
|
6
|
-
success: false;
|
|
7
|
-
error: E;
|
|
8
|
-
} | {
|
|
9
|
-
success: true;
|
|
10
|
-
data: T;
|
|
11
|
-
};
|
|
12
|
-
type AuthError = {
|
|
13
|
-
code: AuthErrorCode;
|
|
14
|
-
message: string;
|
|
15
|
-
};
|
|
16
|
-
type AuthErrorCode = "invalidCredentials" | "tooManyAttempts" | "emailTaken" | "invalidDetails" | "invalidCode";
|
|
17
|
-
//#endregion
|
|
18
|
-
//#region src/auth/methods/password.d.ts
|
|
19
|
-
/**
|
|
20
|
-
* Signs a visitor in with an email and password.
|
|
21
|
-
*
|
|
22
|
-
* The cache is primed from the same `/me` the resolver would have called anyway,
|
|
23
|
-
* so the next request costs no round-trip.
|
|
24
|
-
*/
|
|
25
|
-
declare function signIn(credentials: {
|
|
26
|
-
email: string;
|
|
27
|
-
password: string;
|
|
28
|
-
}): Promise<Result<User, AuthError>>;
|
|
29
|
-
/**
|
|
30
|
-
* Signs the visitor out and clears the session cookies.
|
|
31
|
-
*
|
|
32
|
-
* This revokes **every** outstanding session for that user in this app, on every
|
|
33
|
-
* device: the platform stamps `tokensInvalidatedAt` and rejects anything issued
|
|
34
|
-
* before it. There is no per-device sign-out.
|
|
35
|
-
*
|
|
36
|
-
* A platform that could not revoke leaves the caller signed out here and
|
|
37
|
-
* possibly still signed in elsewhere — loud outside production, logged in it,
|
|
38
|
-
* because the local half already succeeded.
|
|
39
|
-
*/
|
|
40
|
-
declare function signOut(): Promise<void>;
|
|
41
|
-
//#endregion
|
|
42
|
-
//#region src/auth/methods/signup.d.ts
|
|
43
|
-
/**
|
|
44
|
-
* Starts a sign-up flow by emailing a verification code.
|
|
45
|
-
*
|
|
46
|
-
* User only exists once `confirmSignUp` returns the code.
|
|
47
|
-
*/
|
|
48
|
-
declare function signUp(details: {
|
|
49
|
-
email: string;
|
|
50
|
-
name?: string;
|
|
51
|
-
}): Promise<Result<null, AuthError>>;
|
|
52
|
-
/**
|
|
53
|
-
* Finishes sign-up flow with the emailed code and signs the visitor in.
|
|
54
|
-
*
|
|
55
|
-
* The platform splits on how the app is configured: a code-only app creates the
|
|
56
|
-
* user on the second `sign-up` request and answers with a token, while a
|
|
57
|
-
* password app answers it with an empty 200 and expects the password on
|
|
58
|
-
* `users/new`.
|
|
59
|
-
*/
|
|
60
|
-
declare function confirmSignUp(details: {
|
|
61
|
-
email: string;
|
|
62
|
-
/**
|
|
63
|
-
* What `users/new` creates the account with, and unused on a code-only app,
|
|
64
|
-
* which never reaches that request.
|
|
65
|
-
*
|
|
66
|
-
* TODO: temporary in one respect — it is spent a second time on the
|
|
67
|
-
* 400-after-create workaround below, which goes away with the platform fix.
|
|
68
|
-
*/
|
|
69
|
-
password: string;
|
|
70
|
-
code: string;
|
|
71
|
-
name?: string;
|
|
72
|
-
}): Promise<Result<User, AuthError>>;
|
|
73
|
-
//#endregion
|
|
74
|
-
//#region src/auth/server.d.ts
|
|
75
|
-
/**
|
|
76
|
-
* Server-only auth methods, should be called from the app's own `createServerFn` handlers.
|
|
77
|
-
*/
|
|
78
|
-
declare const auth: {
|
|
79
|
-
signIn: typeof signIn;
|
|
80
|
-
signOut: typeof signOut;
|
|
81
|
-
signUp: typeof signUp;
|
|
82
|
-
confirmSignUp: typeof confirmSignUp;
|
|
83
|
-
};
|
|
84
|
-
//#endregion
|
|
85
|
-
//#region src/core/datasource.d.ts
|
|
86
|
-
declare function listRecords(datasourceId: string, options?: ListOptions): Promise<ListResult>;
|
|
87
|
-
declare function getRecord(datasourceId: string, options: GetOptions): Promise<SoftrRecord | null>;
|
|
88
|
-
declare function createRecord(datasourceId: string, options: CreateOptions): Promise<SoftrRecord>;
|
|
89
|
-
declare function updateRecord(datasourceId: string, options: UpdateOptions): Promise<SoftrRecord>;
|
|
90
|
-
declare function deleteRecord(datasourceId: string, id: string): Promise<void>;
|
|
91
|
-
declare function getMetric(datasourceId: string, options: MetricOptions): Promise<MetricResult>;
|
|
92
|
-
declare function getChartData(datasourceId: string, options: ChartDataOptions): Promise<ChartDataResult>;
|
|
93
|
-
declare const ds: {
|
|
94
|
-
list: typeof listRecords;
|
|
95
|
-
get: typeof getRecord;
|
|
96
|
-
create: typeof createRecord;
|
|
97
|
-
update: typeof updateRecord;
|
|
98
|
-
delete: typeof deleteRecord;
|
|
99
|
-
metric: typeof getMetric;
|
|
100
|
-
chartData: typeof getChartData;
|
|
101
|
-
};
|
|
102
|
-
//#endregion
|
|
103
|
-
//#region src/core/workflow.d.ts
|
|
104
|
-
declare function triggerWorkflow(workflowId: string, payload?: Record<string, unknown>): Promise<void>;
|
|
105
|
-
declare const workflow: {
|
|
106
|
-
trigger: typeof triggerWorkflow;
|
|
107
|
-
};
|
|
108
|
-
//#endregion
|
|
109
|
-
//#region src/core/integration.d.ts
|
|
110
|
-
declare function integrationFetch(integrationId: string, input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
111
|
-
declare const integration: {
|
|
112
|
-
fetch: typeof integrationFetch;
|
|
113
|
-
};
|
|
114
|
-
//#endregion
|
|
115
6
|
export { auth, ds, integration, workflow };
|