@protobase/server 0.53.7 → 0.54.0

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;
@@ -8751,6 +8781,7 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
8751
8781
  signInFirst: "password" | "emailCode";
8752
8782
  passwordReset: boolean;
8753
8783
  leakedPasswords: boolean;
8784
+ accountDeletion: boolean;
8754
8785
  signUp: boolean;
8755
8786
  signUpRole: string | undefined;
8756
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;
@@ -1364,7 +1400,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
1364
1400
  expiresAt: Date;
1365
1401
  token: string;
1366
1402
  ipAddress?: string | null | undefined;
1367
- userAgent? /** Public origin of the API, for example `https://admin.example.com`. Issuer and audience of the tokens; new passkeys are made for its host unless `passkeyDomain` names another. */ : string | null | undefined;
1403
+ userAgent?: string | null | undefined;
1368
1404
  };
1369
1405
  user: Record<string, any> & {
1370
1406
  id: string;
@@ -1757,7 +1793,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
1757
1793
  email: string;
1758
1794
  emailVerified: boolean;
1759
1795
  name: string;
1760
- 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;
1761
1797
  }) | import("better-auth/plugins").UserWithTwoFactor;
1762
1798
  }>;
1763
1799
  generateBackupCodes: import("better-call").StrictEndpoint<"/two-factor/generate-backup-codes", {
@@ -1952,9 +1988,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
1952
1988
  email: string;
1953
1989
  emailVerified: boolean;
1954
1990
  name: string;
1955
- image?
1956
- /** The app's name, from `PROTOBASE_APP_NAME` or `appName`; default `Protobase`. */
1957
- /** The app's name, from `PROTOBASE_APP_NAME` or `appName`; default `Protobase`. */ : string | null | undefined;
1991
+ image?: string | null | undefined;
1958
1992
  };
1959
1993
  }>;
1960
1994
  generateTOTP: import("better-call").StrictEndpoint<string, {
@@ -5023,7 +5057,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
5023
5057
  organization: {
5024
5058
  name?: string;
5025
5059
  slug?: string;
5026
- logo? /** Encrypt the OAuth access, refresh and ID tokens Better Auth stores for social sign-ins, with `secret`. Default off. */ : string | null;
5060
+ logo?: string | null;
5027
5061
  metadata?: Record<string, any>;
5028
5062
  [key: string]: any;
5029
5063
  };
@@ -8881,6 +8915,8 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
8881
8915
  passwordReset: boolean;
8882
8916
  /** New passwords are checked against Have I Been Pwned's list of leaked ones (`leakedPasswords`). */
8883
8917
  leakedPasswords: boolean;
8918
+ /** The signed-in person can delete their own account (`accountDeletion`). */
8919
+ accountDeletion: boolean;
8884
8920
  /** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
8885
8921
  signUp: boolean;
8886
8922
  /** The role of accounts made by open sign-up, while it is offered. */
@@ -19,8 +19,9 @@ 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
21
  import { lockedAccountsPlugin, readLockedAccounts } from "./locked-accounts.js";
22
- import { userDeletion } from "./user-deletion.js";
23
22
  import { revokeMcpGrants } from "./mcp/revoke-grants.js";
23
+ import { selfDeletionCallbackPath, selfDeletionOptions, selfDeletionPath } from "./self-deletion.js";
24
+ import { userDeletion } from "./user-deletion.js";
24
25
  import { mcpUnusedPaths, resolveMcp } from "./mcp/mcp-plugins.js";
25
26
  import { lastOrganizationField, sessionOrganizationHooks } from "./organizations/session-organization.js";
26
27
  import { organizationPlugin } from "./organizations/organization-plugin.js";
@@ -149,6 +150,13 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
149
150
  ...organizations && userFieldsNamed(lastOrganizationField, users),
150
151
  ...metadataField(users)
151
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
+ }) } : {};
152
160
  return {
153
161
  appName,
154
162
  database: options.database,
@@ -167,10 +175,11 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
167
175
  ...mail && passwordResetOptions(mail, mcp ? { onPasswordReset: async ({ user }) => revokeMcpGrants((await context()).adapter, user.id) } : {})
168
176
  },
169
177
  ...options.socialProviders && { socialProviders: options.socialProviders },
170
- user: Object.keys(userFields).length > 0 ? {
178
+ user: {
171
179
  ...userModel(users),
172
- additionalFields: userFields
173
- } : userModel(users),
180
+ ...Object.keys(userFields).length > 0 && { additionalFields: userFields },
181
+ ...deleteUser
182
+ },
174
183
  session: coreNames.session,
175
184
  verification: coreNames.verification,
176
185
  account: {
@@ -252,6 +261,8 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
252
261
  }) : []
253
262
  ].map((plugin) => withUserColumns(plugin, users)),
254
263
  disabledPaths: [
264
+ selfDeletionCallbackPath,
265
+ ...accountDeletion ? [] : [selfDeletionPath],
255
266
  ...unusedEmailCodePaths,
256
267
  ...adminImpersonationPaths,
257
268
  ...organizations ? organizationPaths : [],
@@ -385,7 +396,7 @@ const createAuth = (options, plugins = {}) => {
385
396
  signInPolicy,
386
397
  ...organizations && { organizations },
387
398
  ...mcp && { mcp },
388
- ...(organizations || mcp) && { context }
399
+ context
389
400
  });
390
401
  assertNoEndpointKeyClashes(authOptions.plugins);
391
402
  const auth = betterAuth(authOptions);
@@ -424,6 +435,8 @@ const createAuth = (options, plugins = {}) => {
424
435
  passwordReset: Boolean(mailer),
425
436
  /** New passwords are checked against Have I Been Pwned's list of leaked ones (`leakedPasswords`). */
426
437
  leakedPasswords: Boolean(options.leakedPasswords),
438
+ /** The signed-in person can delete their own account (`accountDeletion`). */
439
+ accountDeletion: options.accountDeletion !== false,
427
440
  /** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
428
441
  signUp: Boolean(signUpRole && mailer),
429
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 };
@@ -22,7 +22,8 @@ const lockedFields = {
22
22
  "updatedAt"
23
23
  ].includes(field)
24
24
  };
25
- const refuse = () => {
25
+ /** Refuses, with `403 ACCOUNT_LOCKED`, a change to or the deletion of a locked account. */
26
+ const refuseLockedAccount = () => {
26
27
  throw new APIError("FORBIDDEN", {
27
28
  code: "ACCOUNT_LOCKED",
28
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."
@@ -55,10 +56,10 @@ const lockAccounts = (adapter, emails) => {
55
56
  };
56
57
  const checkUpdate = async (model, where, fields) => {
57
58
  const locked = lockedFields[model];
58
- if (locked && fields.some(locked) && await matchesLocked(model, where)) refuse();
59
+ if (locked && fields.some(locked) && await matchesLocked(model, where)) refuseLockedAccount();
59
60
  };
60
61
  const checkDelete = async (model, where) => {
61
- if (lockedFields[model] && await matchesLocked(model, where)) refuse();
62
+ if (lockedFields[model] && await matchesLocked(model, where)) refuseLockedAccount();
62
63
  };
63
64
  const checkCreate = async (model, data) => {
64
65
  if (model === "user" || !lockedFields[model]) return;
@@ -70,7 +71,7 @@ const lockAccounts = (adapter, emails) => {
70
71
  value: data.userId
71
72
  }]
72
73
  }) === 0) return;
73
- refuse();
74
+ refuseLockedAccount();
74
75
  };
75
76
  return {
76
77
  ...adapter,
@@ -120,4 +121,4 @@ const lockedAccountsPlugin = (emails) => ({
120
121
  }
121
122
  });
122
123
  //#endregion
123
- export { lockAccounts, lockedAccountsPlugin, lockedAccountsVariable, readLockedAccounts };
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();
@@ -34,7 +34,7 @@ const sensitiveErrors = (view, model) => {
34
34
  ["the sidebar", layoutItems(view.layout).flatMap((item) => item.kind === "sidebar" ? item.fields : [])],
35
35
  ["a map", layoutItems(view.layout).flatMap((item) => item.kind === "map" ? [item.latitude, item.longitude] : [])],
36
36
  ["a gallery", layoutItems(view.layout).flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? [item.image] : [])],
37
- ["the recent records", view.nav?.recent ? [view.nav.recent.status] : []],
37
+ ["the recent records", view.nav?.recent?.status === void 0 ? [] : [view.nav.recent.status]],
38
38
  ["an action", view.actions.flatMap((action) => [...actionFields(action.run), ...actionWhenFields(action.when)])]
39
39
  ].flatMap(([place, names]) => [...new Set(names.filter(sensitive))].map((name) => `sensitive field "${name}" cannot be in ${place}`));
40
40
  };
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.53.7";
2
+ var version = "0.54.0";
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.7",
3
+ "version": "0.54.0",
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.7",
44
- "@protobase/query": "0.53.7",
45
- "@protobase/schema": "0.53.7",
43
+ "@protobase/layout": "0.54.0",
44
+ "@protobase/query": "0.54.0",
45
+ "@protobase/schema": "0.54.0",
46
46
  "@react-email/render": "^2.1.0",
47
47
  "@scalar/hono-api-reference": "^0.12.10",
48
48
  "@sentry/core": "^11.7.0",