@checkstack/auth-backend 0.0.2
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/CHANGELOG.md +142 -0
- package/drizzle/0000_minor_virginia_dare.sql +90 -0
- package/drizzle/0001_certain_madame_hydra.sql +20 -0
- package/drizzle/meta/0000_snapshot.json +580 -0
- package/drizzle/meta/0001_snapshot.json +717 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +7 -0
- package/package.json +34 -0
- package/src/hooks.ts +14 -0
- package/src/index.ts +878 -0
- package/src/meta-config.ts +13 -0
- package/src/platform-registration-config.ts +25 -0
- package/src/router.test.ts +440 -0
- package/src/router.ts +1051 -0
- package/src/schema.ts +173 -0
- package/src/utils/auth-error-redirect.ts +42 -0
- package/src/utils/user.test.ts +99 -0
- package/src/utils/user.ts +62 -0
- package/src/utils/validate-schema.test.ts +85 -0
- package/src/utils/validate-schema.ts +45 -0
- package/tsconfig.json +6 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pgTable,
|
|
3
|
+
text,
|
|
4
|
+
boolean,
|
|
5
|
+
timestamp,
|
|
6
|
+
primaryKey,
|
|
7
|
+
} from "drizzle-orm/pg-core";
|
|
8
|
+
|
|
9
|
+
// --- Better Auth Schema ---
|
|
10
|
+
// Tables use pgTable (schemaless) - runtime schema is set via search_path
|
|
11
|
+
export const user = pgTable("user", {
|
|
12
|
+
id: text("id").primaryKey(),
|
|
13
|
+
name: text("name").notNull(),
|
|
14
|
+
email: text("email").notNull().unique(),
|
|
15
|
+
emailVerified: boolean("email_verified").notNull(),
|
|
16
|
+
image: text("image"),
|
|
17
|
+
createdAt: timestamp("created_at").notNull(),
|
|
18
|
+
updatedAt: timestamp("updated_at").notNull(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const session = pgTable("session", {
|
|
22
|
+
id: text("id").primaryKey(),
|
|
23
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
24
|
+
token: text("token").notNull().unique(),
|
|
25
|
+
createdAt: timestamp("created_at").notNull(),
|
|
26
|
+
updatedAt: timestamp("updated_at").notNull(),
|
|
27
|
+
ipAddress: text("ip_address"),
|
|
28
|
+
userAgent: text("user_agent"),
|
|
29
|
+
userId: text("user_id")
|
|
30
|
+
.notNull()
|
|
31
|
+
.references(() => user.id),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const account = pgTable("account", {
|
|
35
|
+
id: text("id").primaryKey(),
|
|
36
|
+
accountId: text("account_id").notNull(),
|
|
37
|
+
providerId: text("provider_id").notNull(),
|
|
38
|
+
userId: text("user_id")
|
|
39
|
+
.notNull()
|
|
40
|
+
.references(() => user.id),
|
|
41
|
+
accessToken: text("access_token"),
|
|
42
|
+
refreshToken: text("refresh_token"),
|
|
43
|
+
idToken: text("id_token"),
|
|
44
|
+
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
|
45
|
+
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
|
46
|
+
scope: text("scope"),
|
|
47
|
+
password: text("password"),
|
|
48
|
+
createdAt: timestamp("created_at").notNull(),
|
|
49
|
+
updatedAt: timestamp("updated_at").notNull(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const verification = pgTable("verification", {
|
|
53
|
+
id: text("id").primaryKey(),
|
|
54
|
+
identifier: text("identifier").notNull(),
|
|
55
|
+
value: text("value").notNull(),
|
|
56
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
57
|
+
createdAt: timestamp("created_at"),
|
|
58
|
+
updatedAt: timestamp("updated_at"),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// --- RBAC Schema ---
|
|
62
|
+
export const role = pgTable("role", {
|
|
63
|
+
id: text("id").primaryKey(), // 'admin', 'user', 'anonymous'
|
|
64
|
+
name: text("name").notNull(),
|
|
65
|
+
description: text("description"),
|
|
66
|
+
isSystem: boolean("is_system").default(false), // Prevent deletion of core roles
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const permission = pgTable("permission", {
|
|
70
|
+
id: text("id").primaryKey(), // 'core.manage-users', etc.
|
|
71
|
+
description: text("description"),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const rolePermission = pgTable(
|
|
75
|
+
"role_permission",
|
|
76
|
+
{
|
|
77
|
+
roleId: text("role_id")
|
|
78
|
+
.notNull()
|
|
79
|
+
.references(() => role.id),
|
|
80
|
+
permissionId: text("permission_id")
|
|
81
|
+
.notNull()
|
|
82
|
+
.references(() => permission.id),
|
|
83
|
+
},
|
|
84
|
+
(t) => ({
|
|
85
|
+
pk: primaryKey({ columns: [t.roleId, t.permissionId] }),
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
export const userRole = pgTable(
|
|
90
|
+
"user_role",
|
|
91
|
+
{
|
|
92
|
+
userId: text("user_id")
|
|
93
|
+
.notNull()
|
|
94
|
+
.references(() => user.id),
|
|
95
|
+
roleId: text("role_id")
|
|
96
|
+
.notNull()
|
|
97
|
+
.references(() => role.id),
|
|
98
|
+
},
|
|
99
|
+
(t) => ({
|
|
100
|
+
pk: primaryKey({ columns: [t.userId, t.roleId] }),
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Tracks authenticated default permissions that have been disabled by admins.
|
|
106
|
+
* When a plugin registers a permission with isAuthenticatedDefault=true, it gets assigned
|
|
107
|
+
* to the "users" role unless it's in this table.
|
|
108
|
+
*/
|
|
109
|
+
export const disabledDefaultPermission = pgTable(
|
|
110
|
+
"disabled_default_permission",
|
|
111
|
+
{
|
|
112
|
+
permissionId: text("permission_id")
|
|
113
|
+
.primaryKey()
|
|
114
|
+
.references(() => permission.id),
|
|
115
|
+
disabledAt: timestamp("disabled_at").notNull(),
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Tracks public default permissions that have been disabled by admins.
|
|
121
|
+
* When a plugin registers a permission with isPublicDefault=true, it gets assigned
|
|
122
|
+
* to the "anonymous" role unless it's in this table.
|
|
123
|
+
*/
|
|
124
|
+
export const disabledPublicDefaultPermission = pgTable(
|
|
125
|
+
"disabled_public_default_permission",
|
|
126
|
+
{
|
|
127
|
+
permissionId: text("permission_id")
|
|
128
|
+
.primaryKey()
|
|
129
|
+
.references(() => permission.id),
|
|
130
|
+
disabledAt: timestamp("disabled_at").notNull(),
|
|
131
|
+
}
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// --- External Applications Schema ---
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* External applications (API keys) for programmatic API access.
|
|
138
|
+
* Applications have roles assigned like users and authenticate via Bearer tokens.
|
|
139
|
+
*/
|
|
140
|
+
export const application = pgTable("application", {
|
|
141
|
+
id: text("id").primaryKey(), // UUID
|
|
142
|
+
name: text("name").notNull(),
|
|
143
|
+
description: text("description"),
|
|
144
|
+
// Hashed secret (bcrypt) - never stored in plain text
|
|
145
|
+
secretHash: text("secret_hash").notNull(),
|
|
146
|
+
// User who created this application
|
|
147
|
+
createdById: text("created_by_id")
|
|
148
|
+
.notNull()
|
|
149
|
+
.references(() => user.id),
|
|
150
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
151
|
+
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
152
|
+
// Track when the application was last used for API calls
|
|
153
|
+
lastUsedAt: timestamp("last_used_at"),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Application-to-Role mapping for RBAC.
|
|
158
|
+
* Similar to userRole but for external applications.
|
|
159
|
+
*/
|
|
160
|
+
export const applicationRole = pgTable(
|
|
161
|
+
"application_role",
|
|
162
|
+
{
|
|
163
|
+
applicationId: text("application_id")
|
|
164
|
+
.notNull()
|
|
165
|
+
.references(() => application.id, { onDelete: "cascade" }),
|
|
166
|
+
roleId: text("role_id")
|
|
167
|
+
.notNull()
|
|
168
|
+
.references(() => role.id),
|
|
169
|
+
},
|
|
170
|
+
(t) => ({
|
|
171
|
+
pk: primaryKey({ columns: [t.applicationId, t.roleId] }),
|
|
172
|
+
})
|
|
173
|
+
);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for handling authentication error redirects
|
|
3
|
+
* to the frontend error page with proper encoding.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Encode error message for URL (better-auth uses underscores for spaces)
|
|
8
|
+
* @param message - The error message to encode
|
|
9
|
+
* @returns The encoded message with spaces replaced by underscores
|
|
10
|
+
*/
|
|
11
|
+
export function encodeAuthError(message: string): string {
|
|
12
|
+
return message.replaceAll(" ", "_");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build auth error redirect URL
|
|
17
|
+
* @param errorMessage - User-friendly error message
|
|
18
|
+
* @param frontendUrl - Frontend base URL (defaults to VITE_FRONTEND_URL env var)
|
|
19
|
+
* @returns The full redirect URL to the error page
|
|
20
|
+
*/
|
|
21
|
+
export function buildAuthErrorUrl(
|
|
22
|
+
errorMessage: string,
|
|
23
|
+
frontendUrl?: string
|
|
24
|
+
): string {
|
|
25
|
+
const base =
|
|
26
|
+
frontendUrl || process.env.VITE_FRONTEND_URL || "http://localhost:5173";
|
|
27
|
+
const encoded = encodeAuthError(errorMessage);
|
|
28
|
+
return `${base}/auth/error?error=${encodeURIComponent(encoded)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create HTTP redirect response to auth error page
|
|
33
|
+
* @param errorMessage - User-friendly error message
|
|
34
|
+
* @param frontendUrl - Optional frontend base URL
|
|
35
|
+
* @returns HTTP redirect Response to the error page
|
|
36
|
+
*/
|
|
37
|
+
export function redirectToAuthError(
|
|
38
|
+
errorMessage: string,
|
|
39
|
+
frontendUrl?: string
|
|
40
|
+
): Response {
|
|
41
|
+
return Response.redirect(buildAuthErrorUrl(errorMessage, frontendUrl), 302);
|
|
42
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { enrichUser } from "./user";
|
|
3
|
+
import { User } from "better-auth/types";
|
|
4
|
+
|
|
5
|
+
// Mock Drizzle DB
|
|
6
|
+
const createMockDb = (data: { roles?: unknown[]; permissions?: unknown[] }) => {
|
|
7
|
+
const mockDb: any = {
|
|
8
|
+
select: mock(() => mockDb),
|
|
9
|
+
from: mock(() => mockDb),
|
|
10
|
+
innerJoin: mock(() => mockDb),
|
|
11
|
+
where: mock(() => mockDb),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Mock thenable for different chains
|
|
15
|
+
// eslint-disable-next-line unicorn/no-thenable
|
|
16
|
+
mockDb.then = (resolve: (arg0: unknown) => void) => {
|
|
17
|
+
// Determine which call this is based on the 'from' call
|
|
18
|
+
// const lastFrom =
|
|
19
|
+
// mockDb.from.mock.calls[mockDb.from.mock.calls.length - 1][0];
|
|
20
|
+
|
|
21
|
+
// We need to look at the schema name or some identifier.
|
|
22
|
+
// Since we are mocking the schema as well, we can check equality.
|
|
23
|
+
// However, for a simple mock, we can just alternate or use a counter.
|
|
24
|
+
|
|
25
|
+
// In enrichUser:
|
|
26
|
+
// 1. select from userRole (inner join role)
|
|
27
|
+
// 2. select from rolePermission (inner join permission) -> for each role
|
|
28
|
+
|
|
29
|
+
// Let's use a simpler approach: track call count for this specific mock instance
|
|
30
|
+
if (!mockDb._callCount) mockDb._callCount = 0;
|
|
31
|
+
mockDb._callCount++;
|
|
32
|
+
|
|
33
|
+
if (mockDb._callCount === 1) {
|
|
34
|
+
return resolve(data.roles || []);
|
|
35
|
+
}
|
|
36
|
+
return resolve(data.permissions || []);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return mockDb;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe("enrichUser", () => {
|
|
43
|
+
const baseUser: User = {
|
|
44
|
+
id: "user-1",
|
|
45
|
+
email: "test@example.com",
|
|
46
|
+
emailVerified: true,
|
|
47
|
+
name: "Test User",
|
|
48
|
+
createdAt: new Date(),
|
|
49
|
+
updatedAt: new Date(),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
it("should enrich user with admin role and wildcard permission", async () => {
|
|
53
|
+
const mockDb = createMockDb({
|
|
54
|
+
roles: [{ roleId: "admin" }],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const result = await enrichUser(baseUser, mockDb);
|
|
58
|
+
|
|
59
|
+
expect(result.roles).toContain("admin");
|
|
60
|
+
expect(result.permissions).toContain("*");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should enrich user with custom roles and permissions", async () => {
|
|
64
|
+
const mockDb = createMockDb({
|
|
65
|
+
roles: [{ roleId: "editor" }, { roleId: "viewer" }],
|
|
66
|
+
permissions: [{ permissionId: "blog.read" }],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Note: Our simple mock returns the same permissions for ALL roles if there are multiple roles.
|
|
70
|
+
// In enrichUser, it loops through roles.
|
|
71
|
+
// If we have 2 roles, enrichUser will call select from rolePermission twice.
|
|
72
|
+
// Our mock needs to handle multiple calls if we want to be precise.
|
|
73
|
+
|
|
74
|
+
let callCount = 0;
|
|
75
|
+
// eslint-disable-next-line unicorn/no-thenable
|
|
76
|
+
mockDb.then = (resolve: (arg0: unknown) => void) => {
|
|
77
|
+
callCount++;
|
|
78
|
+
if (callCount === 1) return resolve([{ roleId: "editor" }]);
|
|
79
|
+
if (callCount === 2) return resolve([{ permissionId: "blog.edit" }]);
|
|
80
|
+
return resolve([]);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const result = await enrichUser(baseUser, mockDb);
|
|
84
|
+
|
|
85
|
+
expect(result.roles).toContain("editor");
|
|
86
|
+
expect(result.permissions).toContain("blog.edit");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should handle user with no roles", async () => {
|
|
90
|
+
const mockDb = createMockDb({
|
|
91
|
+
roles: [],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await enrichUser(baseUser, mockDb);
|
|
95
|
+
|
|
96
|
+
expect(result.roles).toEqual([]);
|
|
97
|
+
expect(result.permissions).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { User } from "better-auth/types";
|
|
2
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import type { RealUser } from "@checkstack/backend-api";
|
|
5
|
+
import * as schema from "../schema";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Enriches a better-auth User with roles and permissions from the database.
|
|
9
|
+
* Returns a RealUser type for use in the RPC context.
|
|
10
|
+
*/
|
|
11
|
+
export const enrichUser = async (
|
|
12
|
+
user: User,
|
|
13
|
+
db: NodePgDatabase<typeof schema>
|
|
14
|
+
): Promise<RealUser> => {
|
|
15
|
+
// 1. Get Roles
|
|
16
|
+
const userRoles = await db
|
|
17
|
+
.select({
|
|
18
|
+
roleName: schema.role.name,
|
|
19
|
+
roleId: schema.role.id,
|
|
20
|
+
})
|
|
21
|
+
.from(schema.userRole)
|
|
22
|
+
.innerJoin(schema.role, eq(schema.role.id, schema.userRole.roleId))
|
|
23
|
+
.where(eq(schema.userRole.userId, user.id));
|
|
24
|
+
|
|
25
|
+
const roles = userRoles.map((r) => r.roleId);
|
|
26
|
+
const permissions = new Set<string>();
|
|
27
|
+
|
|
28
|
+
// 2. Get Permissions for each role
|
|
29
|
+
for (const roleId of roles) {
|
|
30
|
+
if (roleId === "admin") {
|
|
31
|
+
permissions.add("*");
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rolePermissions = await db
|
|
36
|
+
.select({
|
|
37
|
+
permissionId: schema.permission.id,
|
|
38
|
+
})
|
|
39
|
+
.from(schema.rolePermission)
|
|
40
|
+
.innerJoin(
|
|
41
|
+
schema.permission,
|
|
42
|
+
eq(schema.permission.id, schema.rolePermission.permissionId)
|
|
43
|
+
)
|
|
44
|
+
.where(eq(schema.rolePermission.roleId, roleId));
|
|
45
|
+
|
|
46
|
+
for (const p of rolePermissions) {
|
|
47
|
+
permissions.add(p.permissionId);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
// Spread user first to preserve additional properties
|
|
53
|
+
...user,
|
|
54
|
+
// Override with required RealUser fields
|
|
55
|
+
type: "user",
|
|
56
|
+
id: user.id,
|
|
57
|
+
email: user.email,
|
|
58
|
+
name: user.name,
|
|
59
|
+
roles,
|
|
60
|
+
permissions: [...permissions],
|
|
61
|
+
};
|
|
62
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { validateStrategySchema } from "./validate-schema";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
describe("validateStrategySchema", () => {
|
|
6
|
+
test("should pass for schema with all defaults", () => {
|
|
7
|
+
const validSchema = z.object({
|
|
8
|
+
enabled: z.boolean().default(false),
|
|
9
|
+
url: z.string().default("https://example.com"),
|
|
10
|
+
timeout: z.number().default(5000),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
expect(() =>
|
|
14
|
+
validateStrategySchema(validSchema, "test-strategy")
|
|
15
|
+
).not.toThrow();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("should pass for schema with all optional fields", () => {
|
|
19
|
+
const validSchema = z.object({
|
|
20
|
+
enabled: z.boolean().default(false),
|
|
21
|
+
url: z.string().optional(),
|
|
22
|
+
timeout: z.number().optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(() =>
|
|
26
|
+
validateStrategySchema(validSchema, "test-strategy")
|
|
27
|
+
).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("should throw for schema with required fields without defaults", () => {
|
|
31
|
+
const invalidSchema = z.object({
|
|
32
|
+
enabled: z.boolean().default(false),
|
|
33
|
+
url: z.string(), // Required, no default
|
|
34
|
+
baseDN: z.string(), // Required, no default
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(() =>
|
|
38
|
+
validateStrategySchema(invalidSchema, "test-strategy")
|
|
39
|
+
).toThrow(
|
|
40
|
+
/Strategy "test-strategy" has invalid configuration schema.*url, baseDN/
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("should throw for nested objects with required fields", () => {
|
|
45
|
+
const invalidSchema = z.object({
|
|
46
|
+
enabled: z.boolean().default(false),
|
|
47
|
+
settings: z.object({
|
|
48
|
+
host: z.string(), // Required, no default
|
|
49
|
+
port: z.number().default(443),
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(() =>
|
|
54
|
+
validateStrategySchema(invalidSchema, "test-strategy")
|
|
55
|
+
).toThrow(/settings/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("should include helpful error message", () => {
|
|
59
|
+
const invalidSchema = z.object({
|
|
60
|
+
url: z.string(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
validateStrategySchema(invalidSchema, "my-strategy");
|
|
65
|
+
expect.unreachable("Should have thrown");
|
|
66
|
+
} catch (error) {
|
|
67
|
+
expect(error).toBeInstanceOf(Error);
|
|
68
|
+
const message = (error as Error).message;
|
|
69
|
+
expect(message).toContain("my-strategy");
|
|
70
|
+
expect(message).toContain("url");
|
|
71
|
+
expect(message).toContain("missing defaults");
|
|
72
|
+
expect(message).toContain("optional or have default values");
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("should pass for schema with only enabled field", () => {
|
|
77
|
+
const validSchema = z.object({
|
|
78
|
+
enabled: z.boolean().default(false),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(() =>
|
|
82
|
+
validateStrategySchema(validSchema, "test-strategy")
|
|
83
|
+
).not.toThrow();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates that a Zod schema can be safely used with ConfigService.
|
|
5
|
+
* Checks for required fields without defaults that would fail when no config exists.
|
|
6
|
+
*
|
|
7
|
+
* @param schema - The Zod schema to validate
|
|
8
|
+
* @param strategyId - The strategy ID for error messages
|
|
9
|
+
* @throws Error if the schema has required fields without defaults
|
|
10
|
+
*/
|
|
11
|
+
export function validateStrategySchema(
|
|
12
|
+
schema: z.ZodTypeAny,
|
|
13
|
+
strategyId: string
|
|
14
|
+
): void {
|
|
15
|
+
// Try to parse an empty object to see if the schema has required fields
|
|
16
|
+
const result = schema.safeParse({});
|
|
17
|
+
|
|
18
|
+
if (!result.success) {
|
|
19
|
+
const requiredFields = result.error.issues
|
|
20
|
+
.filter((err) => {
|
|
21
|
+
// Filter for invalid_type errors where undefined was received
|
|
22
|
+
if (err.code !== "invalid_type") return false;
|
|
23
|
+
|
|
24
|
+
// Use type assertion since TypeScript types don't expose 'received'
|
|
25
|
+
// but it exists at runtime for invalid_type errors
|
|
26
|
+
const received = (err as unknown as { received: unknown }).received;
|
|
27
|
+
return received === undefined;
|
|
28
|
+
})
|
|
29
|
+
.map((err) => err.path.join("."));
|
|
30
|
+
|
|
31
|
+
if (requiredFields.length > 0) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Strategy "${strategyId}" has invalid configuration schema: ` +
|
|
34
|
+
`The following required fields are missing defaults: ${requiredFields.join(
|
|
35
|
+
", "
|
|
36
|
+
)}. ` +
|
|
37
|
+
`All fields in a strategy schema must either be optional or have default values ` +
|
|
38
|
+
`to ensure graceful initialization when no configuration exists in the database.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// If there are other validation errors besides missing required fields,
|
|
43
|
+
// it might be okay (e.g., format validation), so we don't throw
|
|
44
|
+
}
|
|
45
|
+
}
|