@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/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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }