@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.
@@ -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
- additionalFields: {
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" | "delete" | "set-password" | "set-email" | "get" | "update" | "impersonate-admins")[] | undefined;
974
- readonly session?: ("list" | "delete" | "revoke")[] | undefined;
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
- additionalFields: {
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" | "delete" | "set-password" | "set-email" | "get" | "update" | "impersonate-admins")[] | undefined;
1093
- readonly session?: ("list" | "delete" | "revoke")[] | undefined;
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?: string | null | undefined;
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 { userDeletion } from "./user-deletion.js";
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: Object.keys(userFields).length > 0 ? {
178
+ user: {
169
179
  ...userModel(users),
170
- additionalFields: userFields
171
- } : userModel(users),
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
- ...(organizations || mcp) && { context }
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)) throw new APIError("BAD_REQUEST", {
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`) or by `deleteUser` (the CLI): each listener
8
- * before anything is deleted, so one that throws stops the deletion with the user, their sessions and accounts in
9
- * place, and once more after the user is gone, for what was added in between.
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
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.53.6";
2
+ var version = "0.53.8";
3
3
  //#endregion
4
4
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.53.6",
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.6",
44
- "@protobase/query": "0.53.6",
45
- "@protobase/schema": "0.53.6",
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",