@podosoft/podokit 1.6.1 → 1.6.3
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/deploy-checks.d.ts +16 -5
- package/dist/deploy-checks.js +14 -6
- package/dist/deploy-render.js +28 -3
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +83 -17
- package/dist/index.js +9 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +1 -1
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -1
- package/dist/templates/fullstack/tests/README.md +11 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +13 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +96 -7
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +3 -3
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +24 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +609 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +178 -42
- package/dist/templates/modules/audit-log/module.manifest.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.spec.ts +67 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/empty-lists.ts +54 -0
- package/dist/templates/modules/auth/module.manifest.json +1 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +208 -62
- package/dist/update.d.ts +25 -5
- package/dist/update.js +86 -10
- package/package.json +1 -1
|
@@ -1,13 +1,49 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { BetterAuthPlugin } from "better-auth";
|
|
2
|
+
import { createAuthMiddleware, isAPIError } from "better-auth/api";
|
|
3
|
+
import { parseSetCookieHeader } from "better-auth/cookies";
|
|
2
4
|
import { clientAddressOf } from "../core/client-address";
|
|
3
5
|
import { recordAudit } from "./audit-events";
|
|
4
6
|
import { auditEnabled } from "./audit-enabled";
|
|
5
7
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
// The method that auth.login records for Better Auth's sign-in endpoints. A
|
|
9
|
+
// sign-in through any other endpoint, such as one of a plugin the application
|
|
10
|
+
// adds, is recorded too, with the endpoint's path as its method. ctx.path is
|
|
11
|
+
// the route pattern an endpoint was registered with, not the request URL, so
|
|
12
|
+
// every social provider's callback arrives as "/callback/:id".
|
|
13
|
+
export const SIGN_IN_METHODS: Readonly<Record<string, string>> = {
|
|
14
|
+
"/sign-in/email": "password",
|
|
15
|
+
"/sign-in/username": "username",
|
|
16
|
+
"/sign-in/phone-number": "phone-number",
|
|
17
|
+
"/sign-in/email-otp": "email-otp",
|
|
18
|
+
"/sign-in/social": "social",
|
|
19
|
+
"/callback/:id": "social",
|
|
20
|
+
"/magic-link/verify": "magic-link",
|
|
21
|
+
"/passkey/verify-authentication": "passkey",
|
|
22
|
+
"/phone-number/verify": "phone-number-otp",
|
|
23
|
+
"/two-factor/verify-totp": "two-factor-totp",
|
|
24
|
+
"/two-factor/verify-otp": "two-factor-otp",
|
|
25
|
+
"/two-factor/verify-backup-code": "two-factor-backup-code",
|
|
26
|
+
"/verify-email": "email-verification",
|
|
27
|
+
"/email-otp/verify-email": "email-otp-verification",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Endpoints that can end with a session the request did not come with without
|
|
31
|
+
// signing anyone in: a sign-up, recorded as auth.signup; an administrator
|
|
32
|
+
// starting or ending an impersonation; a switch to another session the browser
|
|
33
|
+
// already holds; and requests that replace the signed-in user's own session.
|
|
34
|
+
export const NOT_SIGN_INS: ReadonlySet<string> = new Set([
|
|
35
|
+
"/sign-up/email",
|
|
36
|
+
"/admin/impersonate-user",
|
|
37
|
+
"/admin/stop-impersonating",
|
|
38
|
+
"/multi-session/set-active",
|
|
39
|
+
"/multi-session/revoke",
|
|
40
|
+
"/two-factor/enable",
|
|
41
|
+
"/two-factor/disable",
|
|
42
|
+
"/change-password",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// Other auth and admin endpoints, with the action each records once it succeeds.
|
|
46
|
+
const ACTIONS: Readonly<Record<string, string>> = {
|
|
11
47
|
"/sign-up/email": "auth.signup",
|
|
12
48
|
"/sign-out": "auth.logout",
|
|
13
49
|
"/change-password": "auth.password_change",
|
|
@@ -35,43 +71,143 @@ const ACTIONS: Record<string, string> = {
|
|
|
35
71
|
"/admin/stop-impersonating": "user.stop_impersonate",
|
|
36
72
|
};
|
|
37
73
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
74
|
+
interface SessionRecord {
|
|
75
|
+
session: { token: string };
|
|
76
|
+
user: { id: string; name?: string | null; email?: string | null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface SignInContext {
|
|
80
|
+
path: string;
|
|
81
|
+
context: {
|
|
82
|
+
session: SessionRecord | null;
|
|
83
|
+
responseHeaders?: Headers;
|
|
84
|
+
secret: string;
|
|
85
|
+
authCookies: { sessionToken: { name: string } };
|
|
86
|
+
internalAdapter: { findSession: (token: string) => Promise<unknown> };
|
|
49
87
|
};
|
|
50
|
-
|
|
51
|
-
|
|
88
|
+
getSignedCookie: (name: string, secret: string) => Promise<string | null | false>;
|
|
89
|
+
}
|
|
52
90
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
91
|
+
// After hooks run for failed requests too, with the APIError the endpoint threw
|
|
92
|
+
// in ctx.context.returned. Redirects are APIErrors as well: a 3xx status, and an
|
|
93
|
+
// `error` query parameter on the location when the redirect reports a failure.
|
|
94
|
+
function failed(returned: unknown): boolean {
|
|
95
|
+
if (!isAPIError(returned)) return false;
|
|
96
|
+
if (returned.statusCode >= 400) return true;
|
|
97
|
+
const location = new Headers(returned.headers).get("location");
|
|
98
|
+
if (!location) return false;
|
|
99
|
+
try {
|
|
100
|
+
return new URL(location, "http://localhost").searchParams.has("error");
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
66
103
|
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A sign-in ends with a session the request did not come with, on any endpoint
|
|
107
|
+
// but those in NOT_SIGN_INS.
|
|
108
|
+
// - Its response sets the session cookie to the new session. An endpoint that
|
|
109
|
+
// another one dispatches, as oauthProvider() runs /oauth2/authorize when a
|
|
110
|
+
// sign-in resumes an authorization request, inherits that request's new
|
|
111
|
+
// session without setting it.
|
|
112
|
+
// - A request that refreshes its own session, such as a session read that
|
|
113
|
+
// extends it or a profile update, sets it again: the session cookie it sent,
|
|
114
|
+
// or the one bearer() made of its Authorization header, has the same token.
|
|
115
|
+
// - Confirming two-factor replaces the signed-in user's session, which the
|
|
116
|
+
// endpoint loaded into ctx.context.session.
|
|
117
|
+
// - When the session the endpoint loaded is the new one, a stored session is a
|
|
118
|
+
// sign-in: oauthProvider() loads it when a sign-in resumes an authorization
|
|
119
|
+
// request. The session of an API key request (apiKey's
|
|
120
|
+
// enableSessionForAPIKeys) has the key as its token and is not stored.
|
|
121
|
+
async function signedIn(ctx: SignInContext, created: SessionRecord): Promise<boolean> {
|
|
122
|
+
if (NOT_SIGN_INS.has(ctx.path)) return false;
|
|
123
|
+
const { token } = created.session;
|
|
124
|
+
const set = parseSetCookieHeader(ctx.context.responseHeaders?.get("set-cookie") ?? "");
|
|
125
|
+
const cookie = set.get(ctx.context.authCookies.sessionToken.name)?.value;
|
|
126
|
+
if (decodeURIComponent(cookie ?? "").split(".")[0] !== token) return false;
|
|
127
|
+
const presented = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
|
|
128
|
+
if (token === presented) return false;
|
|
129
|
+
const current = ctx.context.session;
|
|
130
|
+
if (!current) return true;
|
|
131
|
+
if (current.session.token !== token) return current.user.id !== created.user.id;
|
|
132
|
+
return Boolean(await ctx.context.internalAdapter.findSession(token));
|
|
133
|
+
}
|
|
67
134
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
135
|
+
export interface AuditPluginOptions {
|
|
136
|
+
/** Whether auditing is on. Defaults to the admin Settings toggle. */
|
|
137
|
+
enabled?: () => Promise<boolean>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Records auth and admin actions through the application audit pipeline. Pushed
|
|
141
|
+
// after every other plugin, because after hooks run in plugin order and earlier
|
|
142
|
+
// ones change the outcome: two-factor turns a password sign-in into a pending
|
|
143
|
+
// challenge by clearing ctx.context.newSession. It runs for every endpoint, the
|
|
144
|
+
// server-side session lookup of each API request included, so a request with
|
|
145
|
+
// neither an action nor a new session returns before any other work.
|
|
146
|
+
export function auditPlugin(options: AuditPluginOptions = {}): BetterAuthPlugin & { id: "podokit-audit" } {
|
|
147
|
+
const enabled = options.enabled ?? auditEnabled;
|
|
148
|
+
return {
|
|
149
|
+
id: "podokit-audit",
|
|
150
|
+
hooks: {
|
|
151
|
+
after: [
|
|
152
|
+
{
|
|
153
|
+
matcher: (context) => Boolean(context.path),
|
|
154
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
155
|
+
const action = ACTIONS[ctx.path];
|
|
156
|
+
const created = ctx.context.newSession;
|
|
157
|
+
if (!action && !created) return;
|
|
158
|
+
if (failed(ctx.context.returned)) return;
|
|
159
|
+
const login = created && (await signedIn(ctx, created)) ? created : null;
|
|
160
|
+
if (!action && !login) return;
|
|
161
|
+
// AuditService checks the toggle again on write; checking here as well
|
|
162
|
+
// skips the lookups below when auditing is off.
|
|
163
|
+
if (!(await enabled())) return;
|
|
164
|
+
const ip = clientAddressOf(ctx.headers) ?? null;
|
|
165
|
+
|
|
166
|
+
if (action) {
|
|
167
|
+
const actor = ctx.context.session?.user ?? ctx.context.newSession?.user;
|
|
168
|
+
const body = (ctx.body ?? {}) as { email?: string; userId?: string };
|
|
169
|
+
|
|
170
|
+
// Prefer a human-readable target: the email in the body, otherwise
|
|
171
|
+
// the target user's email by id (the id for already-deleted users).
|
|
172
|
+
const targetId = body.userId ?? null;
|
|
173
|
+
let targetLabel = body.email ?? null;
|
|
174
|
+
if (!targetLabel && targetId) {
|
|
175
|
+
try {
|
|
176
|
+
const target = await ctx.context.internalAdapter.findUserById(targetId);
|
|
177
|
+
targetLabel = target?.email ?? targetId;
|
|
178
|
+
} catch {
|
|
179
|
+
targetLabel = targetId;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
await recordAudit({
|
|
183
|
+
action,
|
|
184
|
+
actorId: actor?.id ?? null,
|
|
185
|
+
actorName: actor?.name ?? null,
|
|
186
|
+
actorEmail: actor?.email ?? null,
|
|
187
|
+
targetId,
|
|
188
|
+
targetLabel,
|
|
189
|
+
ip,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!login) return;
|
|
194
|
+
const method = SIGN_IN_METHODS[ctx.path] ?? ctx.path;
|
|
195
|
+
const params = (ctx.params ?? {}) as { id?: string };
|
|
196
|
+
const body = (ctx.body ?? {}) as { provider?: string };
|
|
197
|
+
const provider = method === "social" ? (params.id ?? body.provider) : undefined;
|
|
198
|
+
await recordAudit({
|
|
199
|
+
action: "auth.login",
|
|
200
|
+
actorId: login.user.id,
|
|
201
|
+
actorName: login.user.name ?? null,
|
|
202
|
+
actorEmail: login.user.email ?? null,
|
|
203
|
+
targetId: login.user.id,
|
|
204
|
+
targetLabel: login.user.email ?? null,
|
|
205
|
+
ip,
|
|
206
|
+
metadata: provider ? { method, provider } : { method },
|
|
207
|
+
});
|
|
208
|
+
}),
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
{
|
|
27
27
|
"file": "apps/api/src/auth/auth.ts",
|
|
28
28
|
"marker": "// podokit:end:auth-imports",
|
|
29
|
-
"text": "import {
|
|
29
|
+
"text": "import { auditPlugin } from \"../audit/audit-hook\";"
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
"file": "apps/api/src/auth/auth.ts",
|
|
33
33
|
"marker": "// podokit:end:auth-hooks",
|
|
34
|
-
"text": "
|
|
34
|
+
"text": "plugins.push(auditPlugin());"
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
"file": "infra/k3s/configmap.yaml",
|
|
@@ -192,7 +192,9 @@ export function buildAuth(config: AuthConfig) {
|
|
|
192
192
|
// Request-time hooks. The feature gate turns the admin Settings toggles into a
|
|
193
193
|
// real server boundary (disabled features 404) for endpoint calls, including
|
|
194
194
|
// in-process auth.api calls the plugin above never sees. Other PodoKit modules
|
|
195
|
-
// add their own hooks below the marker.
|
|
195
|
+
// add their own hooks below the marker. The top-level `hooks.after` runs before
|
|
196
|
+
// every plugin's after hooks, so a hook that needs a request's final outcome is
|
|
197
|
+
// pushed below the marker as a plugin, after all the plugins above.
|
|
196
198
|
const hooks: NonNullable<BetterAuthOptions["hooks"]> = {
|
|
197
199
|
before: createFeatureGate(gateSettings),
|
|
198
200
|
};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Database as SqliteDatabase } from "bun:sqlite";
|
|
2
2
|
import type { SQL } from "bun";
|
|
3
|
+
import { Kysely, PostgresDialect } from "kysely";
|
|
3
4
|
import type { AuthConfigRow, ConfigVersions } from "@podosoft/podokit-auth";
|
|
4
5
|
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
5
6
|
import { validateEnv } from "../config/env.validation";
|
|
6
7
|
import { PROVIDERS } from "../config/providers";
|
|
7
8
|
import { databaseUrl, sqliteDatabasePath } from "../database/database";
|
|
8
9
|
import { answersWithin, sharedProbe, type DatabaseProbe } from "./database-outage";
|
|
10
|
+
import { EmptyListPlugin } from "./empty-lists";
|
|
9
11
|
import { isMaintenanceCommand } from "./maintenance";
|
|
10
12
|
import { AuthPostgresPool } from "./postgres-pool";
|
|
11
13
|
|
|
@@ -30,7 +32,17 @@ export const sqliteDatabase = provider === "sqlite"
|
|
|
30
32
|
? new SqliteDatabase(sqliteDatabasePath(url), { create: true, strict: true })
|
|
31
33
|
: null;
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
/** The Kysely instance Better Auth queries PostgreSQL through, or null on SQLite. */
|
|
36
|
+
export const postgresAuthQueries = postgresPool
|
|
37
|
+
? new Kysely<object>({
|
|
38
|
+
dialect: new PostgresDialect({ pool: postgresPool }),
|
|
39
|
+
plugins: [new EmptyListPlugin()],
|
|
40
|
+
})
|
|
41
|
+
: null;
|
|
42
|
+
|
|
43
|
+
export const authDatabase = postgresAuthQueries
|
|
44
|
+
? { db: postgresAuthQueries, type: "postgres" as const, transaction: true }
|
|
45
|
+
: sqliteDatabase;
|
|
34
46
|
if (!authDatabase) throw new Error("No authentication database is configured");
|
|
35
47
|
|
|
36
48
|
/** Whether the authentication database answers `SELECT 1` within the readiness check timeout. */
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from "kysely";
|
|
3
|
+
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
4
|
+
import { PROVIDERS } from "../config/providers";
|
|
5
|
+
import { postgresAuthQueries } from "./db";
|
|
6
|
+
import { EmptyListPlugin } from "./empty-lists";
|
|
7
|
+
|
|
8
|
+
function databaseProvider(): DatabaseProviderName {
|
|
9
|
+
return PROVIDERS.database;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// A type alias, not an interface: withTables() needs an index signature.
|
|
13
|
+
type Tables = {
|
|
14
|
+
user: { id: string; email: string };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const db = new Kysely<Tables>({
|
|
18
|
+
dialect: {
|
|
19
|
+
createAdapter: () => new PostgresAdapter(),
|
|
20
|
+
createDriver: () => new DummyDriver(),
|
|
21
|
+
createIntrospector: (kysely) => new PostgresIntrospector(kysely),
|
|
22
|
+
createQueryCompiler: () => new PostgresQueryCompiler(),
|
|
23
|
+
},
|
|
24
|
+
plugins: [new EmptyListPlugin()],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("empty lists in authentication queries", () => {
|
|
28
|
+
it("sends an empty in list as a condition that never holds", () => {
|
|
29
|
+
const query = db.selectFrom("user").selectAll().where("id", "in", []).limit(0).compile();
|
|
30
|
+
expect(query.sql).toBe('select * from "user" where 1 = 0 limit $1');
|
|
31
|
+
expect(query.parameters).toEqual([0]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("sends an empty not in list as a condition that always holds", () => {
|
|
35
|
+
const query = db
|
|
36
|
+
.selectFrom("user")
|
|
37
|
+
.select("id")
|
|
38
|
+
.where("email", "=", "a@example.com")
|
|
39
|
+
.where("id", "not in", [])
|
|
40
|
+
.compile();
|
|
41
|
+
expect(query.sql).toBe('select "id" from "user" where "email" = $1 and 1 = 1');
|
|
42
|
+
expect(query.parameters).toEqual(["a@example.com"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("leaves lists with values and other operators as they are", () => {
|
|
46
|
+
const query = db
|
|
47
|
+
.deleteFrom("user")
|
|
48
|
+
.where((eb) => eb.or([eb("id", "in", ["a", "b"]), eb("email", "=", "c@example.com")]))
|
|
49
|
+
.compile();
|
|
50
|
+
expect(query.sql).toBe('delete from "user" where ("id" in ($1, $2) or "email" = $3)');
|
|
51
|
+
expect(query.parameters).toEqual(["a", "b", "c@example.com"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("applies to the queries Better Auth sends to PostgreSQL", () => {
|
|
55
|
+
if (databaseProvider() !== "postgres") {
|
|
56
|
+
expect(postgresAuthQueries).toBeNull();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const query = postgresAuthQueries
|
|
60
|
+
?.withTables<Tables>()
|
|
61
|
+
.selectFrom("user")
|
|
62
|
+
.selectAll()
|
|
63
|
+
.where("id", "in", [])
|
|
64
|
+
.compile();
|
|
65
|
+
expect(query?.sql).toBe('select * from "user" where 1 = 0');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BinaryOperationNode,
|
|
3
|
+
OperationNodeTransformer,
|
|
4
|
+
OperatorNode,
|
|
5
|
+
PrimitiveValueListNode,
|
|
6
|
+
ValueListNode,
|
|
7
|
+
ValueNode,
|
|
8
|
+
type KyselyPlugin,
|
|
9
|
+
type PluginTransformQueryArgs,
|
|
10
|
+
type PluginTransformResultArgs,
|
|
11
|
+
type QueryId,
|
|
12
|
+
type QueryResult,
|
|
13
|
+
type RootOperationNode,
|
|
14
|
+
type UnknownRow,
|
|
15
|
+
} from "kysely";
|
|
16
|
+
|
|
17
|
+
function constantComparison(holds: boolean): BinaryOperationNode {
|
|
18
|
+
return BinaryOperationNode.create(
|
|
19
|
+
ValueNode.createImmediate(1),
|
|
20
|
+
OperatorNode.create("="),
|
|
21
|
+
ValueNode.createImmediate(holds ? 1 : 0),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class EmptyListTransformer extends OperationNodeTransformer {
|
|
26
|
+
protected override transformBinaryOperation(node: BinaryOperationNode, queryId?: QueryId): BinaryOperationNode {
|
|
27
|
+
const list = node.rightOperand;
|
|
28
|
+
const empty = (PrimitiveValueListNode.is(list) || ValueListNode.is(list)) && list.values.length === 0;
|
|
29
|
+
if (empty && OperatorNode.is(node.operator)) {
|
|
30
|
+
if (node.operator.operator === "in") return constantComparison(false);
|
|
31
|
+
if (node.operator.operator === "not in") return constantComparison(true);
|
|
32
|
+
}
|
|
33
|
+
return super.transformBinaryOperation(node, queryId);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Better Auth's Kysely adapter sends an `in` filter on an empty array as
|
|
39
|
+
* `IN ()`, which PostgreSQL rejects as a syntax error. The organization
|
|
40
|
+
* plugin's list-members endpoint looks the members' users up that way, so a
|
|
41
|
+
* filter or page that matched no member answered 500. This plugin sends an
|
|
42
|
+
* empty `in` list as `1 = 0` and an empty `not in` list as `1 = 1`.
|
|
43
|
+
*/
|
|
44
|
+
export class EmptyListPlugin implements KyselyPlugin {
|
|
45
|
+
private readonly transformer = new EmptyListTransformer();
|
|
46
|
+
|
|
47
|
+
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
|
48
|
+
return this.transformer.transformNode(args.node, args.queryId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
transformResult(args: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> {
|
|
52
|
+
return Promise.resolve(args.result);
|
|
53
|
+
}
|
|
54
|
+
}
|