@protobase/server 0.53.6 → 0.53.8
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/better-auth/auth-settings.d.ts +42 -4
- package/dist/better-auth/create-auth.d.ts +50 -5
- package/dist/better-auth/create-auth.js +24 -6
- package/dist/better-auth/last-admin-guard.js +9 -5
- package/dist/better-auth/locked-accounts.js +124 -0
- package/dist/better-auth/self-deletion.js +40 -0
- package/dist/better-auth/status-route.js +2 -1
- package/dist/better-auth/user-deletion.js +4 -3
- package/dist/package.js +1 -1
- package/package.json +4 -4
|
@@ -74,6 +74,35 @@ export declare const appAuthOptions: (settings: AuthSettings, { pool, db, hooks,
|
|
|
74
74
|
/** The app's Better Auth from its `auth` settings, built at serve time with only the plugins they turn on. */
|
|
75
75
|
export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput) => Promise<import("better-auth").Auth<{
|
|
76
76
|
user: {
|
|
77
|
+
deleteUser: {
|
|
78
|
+
enabled: boolean;
|
|
79
|
+
beforeDelete: (user: {
|
|
80
|
+
id: string;
|
|
81
|
+
email: string;
|
|
82
|
+
}) => Promise<void>;
|
|
83
|
+
afterDelete: (user: {
|
|
84
|
+
id: string;
|
|
85
|
+
}) => Promise<void>;
|
|
86
|
+
};
|
|
87
|
+
additionalFields?: {
|
|
88
|
+
metadata?: {
|
|
89
|
+
readonly type: "json";
|
|
90
|
+
readonly required: false;
|
|
91
|
+
readonly fieldName: string;
|
|
92
|
+
} | undefined;
|
|
93
|
+
lastOrganizationId?: {
|
|
94
|
+
readonly type: "string";
|
|
95
|
+
readonly required: false;
|
|
96
|
+
readonly input: false;
|
|
97
|
+
readonly fieldName: "last_organization_id";
|
|
98
|
+
} | undefined;
|
|
99
|
+
emailConfirmedAt: {
|
|
100
|
+
readonly type: "date";
|
|
101
|
+
readonly required: false;
|
|
102
|
+
readonly input: false;
|
|
103
|
+
readonly fieldName: "email_confirmed_at";
|
|
104
|
+
};
|
|
105
|
+
} | undefined;
|
|
77
106
|
modelName: string;
|
|
78
107
|
fields: {
|
|
79
108
|
name: string;
|
|
@@ -84,7 +113,8 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
84
113
|
updatedAt: string;
|
|
85
114
|
};
|
|
86
115
|
} | {
|
|
87
|
-
|
|
116
|
+
deleteUser?: undefined;
|
|
117
|
+
additionalFields?: {
|
|
88
118
|
metadata?: {
|
|
89
119
|
readonly type: "json";
|
|
90
120
|
readonly required: false;
|
|
@@ -102,7 +132,7 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
102
132
|
readonly input: false;
|
|
103
133
|
readonly fieldName: "email_confirmed_at";
|
|
104
134
|
};
|
|
105
|
-
};
|
|
135
|
+
} | undefined;
|
|
106
136
|
modelName: string;
|
|
107
137
|
fields: {
|
|
108
138
|
name: string;
|
|
@@ -160,6 +190,13 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
160
190
|
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
161
191
|
}[];
|
|
162
192
|
};
|
|
193
|
+
} | {
|
|
194
|
+
id: "protobase-locked-accounts";
|
|
195
|
+
init: (ctx: import("better-auth").AuthContext) => {
|
|
196
|
+
context: {
|
|
197
|
+
adapter: import("better-auth").DBAdapter;
|
|
198
|
+
};
|
|
199
|
+
};
|
|
163
200
|
} | {
|
|
164
201
|
id: "admin";
|
|
165
202
|
version: string;
|
|
@@ -970,8 +1007,8 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
970
1007
|
$Infer: {
|
|
971
1008
|
body: {
|
|
972
1009
|
permissions: {
|
|
973
|
-
readonly user?: ("create" | "list" | "set-role" | "ban" | "impersonate" | "
|
|
974
|
-
readonly session?: ("
|
|
1010
|
+
readonly user?: ("create" | "update" | "delete" | "list" | "set-role" | "ban" | "impersonate" | "set-password" | "set-email" | "get" | "impersonate-admins")[] | undefined;
|
|
1011
|
+
readonly session?: ("delete" | "list" | "revoke")[] | undefined;
|
|
975
1012
|
};
|
|
976
1013
|
} & {
|
|
977
1014
|
userId?: string | undefined;
|
|
@@ -8744,6 +8781,7 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
8744
8781
|
signInFirst: "password" | "emailCode";
|
|
8745
8782
|
passwordReset: boolean;
|
|
8746
8783
|
leakedPasswords: boolean;
|
|
8784
|
+
accountDeletion: boolean;
|
|
8747
8785
|
signUp: boolean;
|
|
8748
8786
|
signUpRole: string | undefined;
|
|
8749
8787
|
userMetadata: boolean;
|
|
@@ -135,6 +135,12 @@ export type CreateAuthOptions = {
|
|
|
135
135
|
* the password's SHA-1 hash leave the server. While the list cannot be reached, setting a password fails. Default off.
|
|
136
136
|
*/
|
|
137
137
|
leakedPasswords?: boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Lets the signed-in person delete their own account from the account page, with their password or from a session
|
|
140
|
+
* that signed in within the last day; `false` turns it off, for apps whose accounts are managed by an organization's
|
|
141
|
+
* admins. Default on, as app stores require an app that makes accounts to delete them too.
|
|
142
|
+
*/
|
|
143
|
+
accountDeletion?: boolean;
|
|
138
144
|
/**
|
|
139
145
|
* The identity provider the operator's staff sign in with to sign in as people of this app, for support. Defaults
|
|
140
146
|
* to the one the platform passes in `PROTOBASE_OPERATOR_ISSUER`, `PROTOBASE_OPERATOR_CLIENT_ID` and
|
|
@@ -193,6 +199,35 @@ export type AuthPlugins = {
|
|
|
193
199
|
export declare const loadAuthPlugins: (options: Pick<CreateAuthOptions, "sso" | "mcp">) => Promise<AuthPlugins>;
|
|
194
200
|
export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlugins) => import("better-auth").Auth<{
|
|
195
201
|
user: {
|
|
202
|
+
deleteUser: {
|
|
203
|
+
enabled: boolean;
|
|
204
|
+
beforeDelete: (user: {
|
|
205
|
+
id: string;
|
|
206
|
+
email: string;
|
|
207
|
+
}) => Promise<void>;
|
|
208
|
+
afterDelete: (user: {
|
|
209
|
+
id: string;
|
|
210
|
+
}) => Promise<void>;
|
|
211
|
+
};
|
|
212
|
+
additionalFields?: {
|
|
213
|
+
metadata?: {
|
|
214
|
+
readonly type: "json";
|
|
215
|
+
readonly required: false;
|
|
216
|
+
readonly fieldName: string;
|
|
217
|
+
} | undefined;
|
|
218
|
+
lastOrganizationId?: {
|
|
219
|
+
readonly type: "string";
|
|
220
|
+
readonly required: false;
|
|
221
|
+
readonly input: false;
|
|
222
|
+
readonly fieldName: "last_organization_id";
|
|
223
|
+
} | undefined;
|
|
224
|
+
emailConfirmedAt: {
|
|
225
|
+
readonly type: "date";
|
|
226
|
+
readonly required: false;
|
|
227
|
+
readonly input: false;
|
|
228
|
+
readonly fieldName: "email_confirmed_at";
|
|
229
|
+
};
|
|
230
|
+
} | undefined;
|
|
196
231
|
modelName: string;
|
|
197
232
|
fields: {
|
|
198
233
|
name: string;
|
|
@@ -203,7 +238,8 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
203
238
|
updatedAt: string;
|
|
204
239
|
};
|
|
205
240
|
} | {
|
|
206
|
-
|
|
241
|
+
deleteUser?: undefined;
|
|
242
|
+
additionalFields?: {
|
|
207
243
|
metadata?: {
|
|
208
244
|
readonly type: "json";
|
|
209
245
|
readonly required: false;
|
|
@@ -221,7 +257,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
221
257
|
readonly input: false;
|
|
222
258
|
readonly fieldName: "email_confirmed_at";
|
|
223
259
|
};
|
|
224
|
-
};
|
|
260
|
+
} | undefined;
|
|
225
261
|
modelName: string;
|
|
226
262
|
fields: {
|
|
227
263
|
name: string;
|
|
@@ -279,6 +315,13 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
279
315
|
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
280
316
|
}[];
|
|
281
317
|
};
|
|
318
|
+
} | {
|
|
319
|
+
id: "protobase-locked-accounts";
|
|
320
|
+
init: (ctx: import("better-auth").AuthContext) => {
|
|
321
|
+
context: {
|
|
322
|
+
adapter: import("better-auth").DBAdapter;
|
|
323
|
+
};
|
|
324
|
+
};
|
|
282
325
|
} | {
|
|
283
326
|
id: "admin";
|
|
284
327
|
version: string;
|
|
@@ -1089,8 +1132,8 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
1089
1132
|
$Infer: {
|
|
1090
1133
|
body: {
|
|
1091
1134
|
permissions: {
|
|
1092
|
-
readonly user?: ("create" | "list" | "set-role" | "ban" | "impersonate" | "
|
|
1093
|
-
readonly session?: ("
|
|
1135
|
+
readonly user?: ("create" | "update" | "delete" | "list" | "set-role" | "ban" | "impersonate" | "set-password" | "set-email" | "get" | "impersonate-admins")[] | undefined;
|
|
1136
|
+
readonly session?: ("delete" | "list" | "revoke")[] | undefined;
|
|
1094
1137
|
};
|
|
1095
1138
|
} & {
|
|
1096
1139
|
userId?: string | undefined;
|
|
@@ -1750,7 +1793,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
1750
1793
|
email: string;
|
|
1751
1794
|
emailVerified: boolean;
|
|
1752
1795
|
name: string;
|
|
1753
|
-
image
|
|
1796
|
+
image? /** Runs after a user is created, by any route: `createUser`, an admin, or a social sign-up. Default none. */ : string | null | undefined;
|
|
1754
1797
|
}) | import("better-auth/plugins").UserWithTwoFactor;
|
|
1755
1798
|
}>;
|
|
1756
1799
|
generateBackupCodes: import("better-call").StrictEndpoint<"/two-factor/generate-backup-codes", {
|
|
@@ -8872,6 +8915,8 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
8872
8915
|
passwordReset: boolean;
|
|
8873
8916
|
/** New passwords are checked against Have I Been Pwned's list of leaked ones (`leakedPasswords`). */
|
|
8874
8917
|
leakedPasswords: boolean;
|
|
8918
|
+
/** The signed-in person can delete their own account (`accountDeletion`). */
|
|
8919
|
+
accountDeletion: boolean;
|
|
8875
8920
|
/** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
|
|
8876
8921
|
signUp: boolean;
|
|
8877
8922
|
/** The role of accounts made by open sign-up, while it is offered. */
|
|
@@ -18,8 +18,10 @@ import { lastAdminGuardPlugin } from "./last-admin-guard.js";
|
|
|
18
18
|
import { setPasswordPath, setPasswordPlugin } from "./set-password.js";
|
|
19
19
|
import { signUpPaths, signUpPlugin } from "./sign-up/sign-up-plugin.js";
|
|
20
20
|
import { leakedPasswordsPlugin } from "./leaked-passwords.js";
|
|
21
|
-
import {
|
|
21
|
+
import { lockedAccountsPlugin, readLockedAccounts } from "./locked-accounts.js";
|
|
22
22
|
import { revokeMcpGrants } from "./mcp/revoke-grants.js";
|
|
23
|
+
import { selfDeletionCallbackPath, selfDeletionOptions, selfDeletionPath } from "./self-deletion.js";
|
|
24
|
+
import { userDeletion } from "./user-deletion.js";
|
|
23
25
|
import { mcpUnusedPaths, resolveMcp } from "./mcp/mcp-plugins.js";
|
|
24
26
|
import { lastOrganizationField, sessionOrganizationHooks } from "./organizations/session-organization.js";
|
|
25
27
|
import { organizationPlugin } from "./organizations/organization-plugin.js";
|
|
@@ -62,6 +64,7 @@ const platformOperator = () => readOperatorSettings(globalThis.process?.env ?? {
|
|
|
62
64
|
const platformSignInSettings = () => readPlatformSignIn(globalThis.process?.env ?? {});
|
|
63
65
|
const platformOrigins = () => readOrigins(globalThis.process?.env ?? {});
|
|
64
66
|
const platformPasskeyDomain = () => readPasskeyDomain(globalThis.process?.env ?? {});
|
|
67
|
+
const platformLockedAccounts = () => readLockedAccounts(globalThis.process?.env ?? {});
|
|
65
68
|
const tokenLifetime = "15m";
|
|
66
69
|
/**
|
|
67
70
|
* Better Auth for a single-tenant admin: sign-in with a password, an emailed code or a passkey, two-factor
|
|
@@ -88,7 +91,7 @@ const organizationPaths = ["/organization/set-active", "/organization/accept-inv
|
|
|
88
91
|
* The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
|
|
89
92
|
* sign-in provider already resolved.
|
|
90
93
|
*/
|
|
91
|
-
const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, appName = defaultAppName, mailer, signUpRole, operator, platformSignIn, saml = [], organizations, mcp, context = noContext, signInPolicy = defaultSignInPolicy, mobile, deletion, ...plugins }) => {
|
|
94
|
+
const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, appName = defaultAppName, mailer, signUpRole, operator, platformSignIn, saml = [], organizations, mcp, context = noContext, signInPolicy = defaultSignInPolicy, mobile, deletion, lockedAccounts = [], ...plugins }) => {
|
|
92
95
|
const secure = options.baseURL.startsWith("https://");
|
|
93
96
|
const signIn = platformSignInFor(platformSignIn, Object.keys(options.socialProviders ?? {}));
|
|
94
97
|
const hosts = passkeyHosts([options.baseURL, ...origins]);
|
|
@@ -147,6 +150,13 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
147
150
|
...organizations && userFieldsNamed(lastOrganizationField, users),
|
|
148
151
|
...metadataField(users)
|
|
149
152
|
};
|
|
153
|
+
const accountDeletion = options.accountDeletion !== false;
|
|
154
|
+
const deleteUser = accountDeletion ? { deleteUser: selfDeletionOptions({
|
|
155
|
+
lockedAccounts,
|
|
156
|
+
mcp: Boolean(mcp),
|
|
157
|
+
adapter: async () => (await context()).adapter,
|
|
158
|
+
...deletion && { deletion }
|
|
159
|
+
}) } : {};
|
|
150
160
|
return {
|
|
151
161
|
appName,
|
|
152
162
|
database: options.database,
|
|
@@ -165,10 +175,11 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
165
175
|
...mail && passwordResetOptions(mail, mcp ? { onPasswordReset: async ({ user }) => revokeMcpGrants((await context()).adapter, user.id) } : {})
|
|
166
176
|
},
|
|
167
177
|
...options.socialProviders && { socialProviders: options.socialProviders },
|
|
168
|
-
user:
|
|
178
|
+
user: {
|
|
169
179
|
...userModel(users),
|
|
170
|
-
additionalFields: userFields
|
|
171
|
-
|
|
180
|
+
...Object.keys(userFields).length > 0 && { additionalFields: userFields },
|
|
181
|
+
...deleteUser
|
|
182
|
+
},
|
|
172
183
|
session: coreNames.session,
|
|
173
184
|
verification: coreNames.verification,
|
|
174
185
|
account: {
|
|
@@ -177,6 +188,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
177
188
|
...options.encryptOAuthTokens && { encryptOAuthTokens: true }
|
|
178
189
|
},
|
|
179
190
|
plugins: [
|
|
191
|
+
...lockedAccounts.length > 0 ? [lockedAccountsPlugin(lockedAccounts)] : [],
|
|
180
192
|
admin({
|
|
181
193
|
schema: pluginNames.admin,
|
|
182
194
|
defaultRole: defaultRole ?? "unassigned",
|
|
@@ -249,6 +261,8 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
249
261
|
}) : []
|
|
250
262
|
].map((plugin) => withUserColumns(plugin, users)),
|
|
251
263
|
disabledPaths: [
|
|
264
|
+
selfDeletionCallbackPath,
|
|
265
|
+
...accountDeletion ? [] : [selfDeletionPath],
|
|
252
266
|
...unusedEmailCodePaths,
|
|
253
267
|
...adminImpersonationPaths,
|
|
254
268
|
...organizations ? organizationPaths : [],
|
|
@@ -362,10 +376,12 @@ const createAuth = (options, plugins = {}) => {
|
|
|
362
376
|
const context = () => instance?.$context ?? Promise.reject(/* @__PURE__ */ new Error("The auth instance is not made yet"));
|
|
363
377
|
const mobile = mobileRequests();
|
|
364
378
|
const deletion = userDeletion();
|
|
379
|
+
const lockedAccounts = platformLockedAccounts();
|
|
365
380
|
const authOptions = betterAuthOptions(options, {
|
|
366
381
|
...plugins,
|
|
367
382
|
mobile,
|
|
368
383
|
deletion,
|
|
384
|
+
lockedAccounts,
|
|
369
385
|
origins,
|
|
370
386
|
passkeyDomain,
|
|
371
387
|
roles,
|
|
@@ -380,7 +396,7 @@ const createAuth = (options, plugins = {}) => {
|
|
|
380
396
|
signInPolicy,
|
|
381
397
|
...organizations && { organizations },
|
|
382
398
|
...mcp && { mcp },
|
|
383
|
-
|
|
399
|
+
context
|
|
384
400
|
});
|
|
385
401
|
assertNoEndpointKeyClashes(authOptions.plugins);
|
|
386
402
|
const auth = betterAuth(authOptions);
|
|
@@ -419,6 +435,8 @@ const createAuth = (options, plugins = {}) => {
|
|
|
419
435
|
passwordReset: Boolean(mailer),
|
|
420
436
|
/** New passwords are checked against Have I Been Pwned's list of leaked ones (`leakedPasswords`). */
|
|
421
437
|
leakedPasswords: Boolean(options.leakedPasswords),
|
|
438
|
+
/** The signed-in person can delete their own account (`accountDeletion`). */
|
|
439
|
+
accountDeletion: options.accountDeletion !== false,
|
|
422
440
|
/** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
|
|
423
441
|
signUp: Boolean(signUpRole && mailer),
|
|
424
442
|
/** The role of accounts made by open sign-up, while it is offered. */
|
|
@@ -29,6 +29,13 @@ const adminChange = (path, body) => {
|
|
|
29
29
|
...banned !== void 0 && { banned }
|
|
30
30
|
};
|
|
31
31
|
};
|
|
32
|
+
/** Refuses, with `400 LAST_ADMIN`, a change that would leave no active admin. */
|
|
33
|
+
const refuseLastAdmin = () => {
|
|
34
|
+
throw new APIError("BAD_REQUEST", {
|
|
35
|
+
code: "LAST_ADMIN",
|
|
36
|
+
message: "This is the last admin: make someone else an admin first."
|
|
37
|
+
});
|
|
38
|
+
};
|
|
32
39
|
const guardedPaths = [
|
|
33
40
|
"/admin/set-role",
|
|
34
41
|
"/admin/ban-user",
|
|
@@ -51,12 +58,9 @@ const lastAdminGuardPlugin = () => ({
|
|
|
51
58
|
"banned"
|
|
52
59
|
]
|
|
53
60
|
});
|
|
54
|
-
if (wouldLeaveNoAdmin(users, change))
|
|
55
|
-
code: "LAST_ADMIN",
|
|
56
|
-
message: "This is the last admin: make someone else an admin first."
|
|
57
|
-
});
|
|
61
|
+
if (wouldLeaveNoAdmin(users, change)) refuseLastAdmin();
|
|
58
62
|
})
|
|
59
63
|
}] }
|
|
60
64
|
});
|
|
61
65
|
//#endregion
|
|
62
|
-
export { adminChange, lastAdminGuardPlugin };
|
|
66
|
+
export { adminChange, lastAdminGuardPlugin, refuseLastAdmin };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { APIError } from "better-auth/api";
|
|
2
|
+
//#region src/better-auth/locked-accounts.ts
|
|
3
|
+
const lockedAccountsVariable = "PROTOBASE_LOCKED_ACCOUNTS";
|
|
4
|
+
/** The addresses the platform passes in `PROTOBASE_LOCKED_ACCOUNTS`, comma separated, in lower case; none when it is unset. */
|
|
5
|
+
const readLockedAccounts = (env) => [...new Set((env["PROTOBASE_LOCKED_ACCOUNTS"] ?? "").split(",").map((email) => email.trim().toLowerCase()).filter(Boolean))];
|
|
6
|
+
const lockedFields = {
|
|
7
|
+
user: (field) => [
|
|
8
|
+
"email",
|
|
9
|
+
"emailVerified",
|
|
10
|
+
"twoFactorEnabled"
|
|
11
|
+
].includes(field),
|
|
12
|
+
account: (field) => [
|
|
13
|
+
"userId",
|
|
14
|
+
"providerId",
|
|
15
|
+
"accountId",
|
|
16
|
+
"password"
|
|
17
|
+
].includes(field),
|
|
18
|
+
passkey: (field) => !["counter", "updatedAt"].includes(field),
|
|
19
|
+
twoFactor: (field) => ![
|
|
20
|
+
"failedVerificationCount",
|
|
21
|
+
"lockedUntil",
|
|
22
|
+
"updatedAt"
|
|
23
|
+
].includes(field)
|
|
24
|
+
};
|
|
25
|
+
/** Refuses, with `403 ACCOUNT_LOCKED`, a change to or the deletion of a locked account. */
|
|
26
|
+
const refuseLockedAccount = () => {
|
|
27
|
+
throw new APIError("FORBIDDEN", {
|
|
28
|
+
code: "ACCOUNT_LOCKED",
|
|
29
|
+
message: "This account is locked: its address, password, passkeys, two-factor authentication and linked accounts stay as they are, and it cannot be deleted."
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* The adapter with the accounts of `emails` locked: their user's address, password, passkeys, two-factor
|
|
34
|
+
* authentication and linked accounts stay as they are, and the user is not deleted, whatever endpoint or host call
|
|
35
|
+
* writes. A locked account's first account row, made with its user, is the one write of those it allows.
|
|
36
|
+
*/
|
|
37
|
+
const lockAccounts = (adapter, emails) => {
|
|
38
|
+
const lockedIds = async () => new Set((await adapter.findMany({
|
|
39
|
+
model: "user",
|
|
40
|
+
where: [{
|
|
41
|
+
field: "email",
|
|
42
|
+
operator: "in",
|
|
43
|
+
value: emails
|
|
44
|
+
}],
|
|
45
|
+
select: ["id"]
|
|
46
|
+
})).map((user) => user.id));
|
|
47
|
+
const matchesLocked = async (model, where) => {
|
|
48
|
+
const ids = await lockedIds();
|
|
49
|
+
if (ids.size === 0) return false;
|
|
50
|
+
const owner = model === "user" ? "id" : "userId";
|
|
51
|
+
return (await adapter.findMany({
|
|
52
|
+
model,
|
|
53
|
+
where,
|
|
54
|
+
select: [owner]
|
|
55
|
+
})).some((row) => ids.has(row[owner]));
|
|
56
|
+
};
|
|
57
|
+
const checkUpdate = async (model, where, fields) => {
|
|
58
|
+
const locked = lockedFields[model];
|
|
59
|
+
if (locked && fields.some(locked) && await matchesLocked(model, where)) refuseLockedAccount();
|
|
60
|
+
};
|
|
61
|
+
const checkDelete = async (model, where) => {
|
|
62
|
+
if (lockedFields[model] && await matchesLocked(model, where)) refuseLockedAccount();
|
|
63
|
+
};
|
|
64
|
+
const checkCreate = async (model, data) => {
|
|
65
|
+
if (model === "user" || !lockedFields[model]) return;
|
|
66
|
+
if (!(await lockedIds()).has(data.userId)) return;
|
|
67
|
+
if (model === "account" && await adapter.count({
|
|
68
|
+
model: "account",
|
|
69
|
+
where: [{
|
|
70
|
+
field: "userId",
|
|
71
|
+
value: data.userId
|
|
72
|
+
}]
|
|
73
|
+
}) === 0) return;
|
|
74
|
+
refuseLockedAccount();
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
...adapter,
|
|
78
|
+
create: async (data) => {
|
|
79
|
+
await checkCreate(data.model, data.data);
|
|
80
|
+
return adapter.create(data);
|
|
81
|
+
},
|
|
82
|
+
update: async (data) => {
|
|
83
|
+
await checkUpdate(data.model, data.where, Object.keys(data.update));
|
|
84
|
+
return adapter.update(data);
|
|
85
|
+
},
|
|
86
|
+
updateMany: async (data) => {
|
|
87
|
+
await checkUpdate(data.model, data.where, Object.keys(data.update));
|
|
88
|
+
return adapter.updateMany(data);
|
|
89
|
+
},
|
|
90
|
+
incrementOne: async (data) => {
|
|
91
|
+
await checkUpdate(data.model, data.where, [...Object.keys(data.increment), ...Object.keys(data.set ?? {})]);
|
|
92
|
+
return adapter.incrementOne(data);
|
|
93
|
+
},
|
|
94
|
+
delete: async (data) => {
|
|
95
|
+
await checkDelete(data.model, data.where);
|
|
96
|
+
return adapter.delete(data);
|
|
97
|
+
},
|
|
98
|
+
deleteMany: async (data) => {
|
|
99
|
+
await checkDelete(data.model, data.where);
|
|
100
|
+
return adapter.deleteMany(data);
|
|
101
|
+
},
|
|
102
|
+
consumeOne: async (data) => {
|
|
103
|
+
await checkDelete(data.model, data.where);
|
|
104
|
+
return adapter.consumeOne(data);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Locks the accounts of `emails` (see `lockAccounts`) by guarding the auth store's adapter, which Better Auth's own
|
|
110
|
+
* writes, its plugins' (passkeys and two-factor authentication write the store directly, past the database hooks)
|
|
111
|
+
* and the host calls all go through, also in a transaction.
|
|
112
|
+
*/
|
|
113
|
+
const lockedAccountsPlugin = (emails) => ({
|
|
114
|
+
id: "protobase-locked-accounts",
|
|
115
|
+
init: (ctx) => {
|
|
116
|
+
const adapter = ctx.adapter;
|
|
117
|
+
return { context: { adapter: {
|
|
118
|
+
...lockAccounts(adapter, emails),
|
|
119
|
+
transaction: (callback) => adapter.transaction((trx) => callback(lockAccounts(trx, emails)))
|
|
120
|
+
} } };
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
//#endregion
|
|
124
|
+
export { lockAccounts, lockedAccountsPlugin, lockedAccountsVariable, readLockedAccounts, refuseLockedAccount };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { wouldLeaveNoAdmin } from "./last-admin.js";
|
|
2
|
+
import { refuseLastAdmin } from "./last-admin-guard.js";
|
|
3
|
+
import { refuseLockedAccount } from "./locked-accounts.js";
|
|
4
|
+
import { revokeMcpGrants } from "./mcp/revoke-grants.js";
|
|
5
|
+
//#region src/better-auth/self-deletion.ts
|
|
6
|
+
/** The paths of Better Auth's own deletion of the signed-in person's account, and of its emailed confirmation, which Protobase does not send. */
|
|
7
|
+
const selfDeletionPath = "/delete-user";
|
|
8
|
+
const selfDeletionCallbackPath = "/delete-user/callback";
|
|
9
|
+
/**
|
|
10
|
+
* Better Auth's `user.deleteUser`: the signed-in person deletes their own account, with their password or from a
|
|
11
|
+
* session that signed in within the last day. Once Better Auth has checked that, a locked account and the last active
|
|
12
|
+
* admin are refused, the `userDeletion` listeners and the MCP grants go, and Better Auth deletes the user with their
|
|
13
|
+
* sessions and accounts; the listeners run once more after.
|
|
14
|
+
*/
|
|
15
|
+
const selfDeletionOptions = ({ lockedAccounts, deletion, mcp, adapter }) => ({
|
|
16
|
+
enabled: true,
|
|
17
|
+
beforeDelete: async (user) => {
|
|
18
|
+
if (lockedAccounts.includes(user.email.toLowerCase())) refuseLockedAccount();
|
|
19
|
+
const store = await adapter();
|
|
20
|
+
const users = await store.findMany({
|
|
21
|
+
model: "user",
|
|
22
|
+
select: [
|
|
23
|
+
"id",
|
|
24
|
+
"role",
|
|
25
|
+
"banned"
|
|
26
|
+
]
|
|
27
|
+
});
|
|
28
|
+
if (wouldLeaveNoAdmin(users, {
|
|
29
|
+
userId: user.id,
|
|
30
|
+
removed: true
|
|
31
|
+
})) refuseLastAdmin();
|
|
32
|
+
await deletion?.run(user.id);
|
|
33
|
+
if (mcp) await revokeMcpGrants(store, user.id);
|
|
34
|
+
},
|
|
35
|
+
afterDelete: async (user) => {
|
|
36
|
+
await deletion?.run(user.id);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
//#endregion
|
|
40
|
+
export { selfDeletionCallbackPath, selfDeletionOptions, selfDeletionPath };
|
|
@@ -9,7 +9,7 @@ import { Hono } from "hono";
|
|
|
9
9
|
* `GET {authBase}/status`: `needsAdmin` while the admin store has no user, so a login page can say how to create one;
|
|
10
10
|
* `signInMethods`, the ways to sign in the sign-in policy leaves on (`password`, `emailCode`, `passkey`), so it offers
|
|
11
11
|
* only those; `passwordReset` when reset mail can be sent and passwords are on, so it offers "Forgot password?" only
|
|
12
|
-
* then; `signInFirst: 'emailCode'` when the app has the sign-in page lead with an emailed code and the policy leaves both it and passwords on; `changeEmail` when mail can be sent, so the account page offers to change the address with an emailed code; `signUp` when the app has open sign-up, so it offers "Create an account"; the ids of the `socialProviders` people can sign in with, so it offers "Continue with GitHub" when `github` is
|
|
12
|
+
* then; `signInFirst: 'emailCode'` when the app has the sign-in page lead with an emailed code and the policy leaves both it and passwords on; `changeEmail` when mail can be sent, so the account page offers to change the address with an emailed code; `accountDeletion` when people may delete their own account, so the account page offers it; `signUp` when the app has open sign-up, so it offers "Create an account"; the ids of the `socialProviders` people can sign in with, so it offers "Continue with GitHub" when `github` is
|
|
13
13
|
* one; `platformSignIn`, the id and name of the platform's provider when it is one of them; `samlProviders`, the id and name of each SAML provider, when the app has any; `staffSignIn`, the
|
|
14
14
|
* operator provider's name, when staff can sign in as people; `passkeyDomains`, when people have passkeys for more than
|
|
15
15
|
* one of the app's hosts, the RP IDs a passkey sign-in can ask for, the passkey domain first; with organizations, who may create them and the roles
|
|
@@ -39,6 +39,7 @@ const statusRoute = (auth, { anonymous = false, plugins = [], locale } = {}) =>
|
|
|
39
39
|
passwordReset: auth.passwordReset && policy.password === "allowed",
|
|
40
40
|
...auth.signInFirst === "emailCode" && signInMethods.includes("emailCode") && signInMethods.includes("password") && { signInFirst: "emailCode" },
|
|
41
41
|
...auth.mail && { changeEmail: true },
|
|
42
|
+
...auth.accountDeletion && { accountDeletion: true },
|
|
42
43
|
...auth.signUp && { signUp: true },
|
|
43
44
|
socialProviders,
|
|
44
45
|
...platformSignIn && { platformSignIn },
|
|
@@ -4,9 +4,10 @@ import { createAuthMiddleware, getSessionFromCtx, isAPIError } from "better-auth
|
|
|
4
4
|
//#region src/better-auth/user-deletion.ts
|
|
5
5
|
const removePath = "/admin/remove-user";
|
|
6
6
|
/**
|
|
7
|
-
* What runs when a user is deleted, by an admin (`/admin/remove-user`)
|
|
8
|
-
* before anything is deleted, so one that throws stops the
|
|
9
|
-
* place, and once more after the user is gone, for what was
|
|
7
|
+
* What runs when a user is deleted, by an admin (`/admin/remove-user`), by `deleteUser` (the CLI) or by themselves
|
|
8
|
+
* (`/delete-user`, see `selfDeletionOptions`): each listener before anything is deleted, so one that throws stops the
|
|
9
|
+
* deletion with the user, their sessions and accounts in place, and once more after the user is gone, for what was
|
|
10
|
+
* added in between.
|
|
10
11
|
*/
|
|
11
12
|
const userDeletion = () => {
|
|
12
13
|
const listeners = /* @__PURE__ */ new Map();
|
package/dist/package.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.53.
|
|
3
|
+
"version": "0.53.8",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"@better-auth/passkey": "^1.7.12",
|
|
41
41
|
"@better-auth/sso": "^1.7.12",
|
|
42
42
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
43
|
-
"@protobase/layout": "0.53.
|
|
44
|
-
"@protobase/query": "0.53.
|
|
45
|
-
"@protobase/schema": "0.53.
|
|
43
|
+
"@protobase/layout": "0.53.8",
|
|
44
|
+
"@protobase/query": "0.53.8",
|
|
45
|
+
"@protobase/schema": "0.53.8",
|
|
46
46
|
"@react-email/render": "^2.1.0",
|
|
47
47
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
48
48
|
"@sentry/core": "^11.7.0",
|