@protobase/server 0.66.5 → 0.66.7

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.
Files changed (38) hide show
  1. package/dist/app-pool.d.ts +5 -3
  2. package/dist/app-pool.js +12 -5
  3. package/dist/assistant/delete-record-tool.d.ts +1 -1
  4. package/dist/assistant/delete-record-tool.js +11 -11
  5. package/dist/better-auth/auth-mail/auth-emails.d.ts +1 -0
  6. package/dist/better-auth/auth-mail/auth-emails.js +2 -1
  7. package/dist/better-auth/auth-mail/password-changed-email.d.ts +1 -0
  8. package/dist/better-auth/auth-mail/password-changed-email.js +32 -0
  9. package/dist/better-auth/auth-settings.d.ts +36 -2
  10. package/dist/better-auth/create-auth.d.ts +51 -7
  11. package/dist/better-auth/create-auth.js +14 -5
  12. package/dist/better-auth/mcp/revoke-grants.js +7 -4
  13. package/dist/better-auth/mobile-requests.js +23 -3
  14. package/dist/better-auth/password-change.js +68 -0
  15. package/dist/better-auth/password-checks.js +69 -0
  16. package/dist/better-auth/password-reset.js +2 -1
  17. package/dist/better-auth/sign-in-mail.js +8 -5
  18. package/dist/better-auth/sign-up/sign-up-code.js +4 -3
  19. package/dist/better-auth/sign-up/sign-up-plugin.js +3 -2
  20. package/dist/built-in-resources.d.ts +89 -0
  21. package/dist/built-in-resources.js +31 -0
  22. package/dist/changes/capture-schema.js +3 -1
  23. package/dist/comments/comments-sql.d.ts +4 -1
  24. package/dist/comments/comments-sql.js +16 -7
  25. package/dist/comments/record-comments.js +1 -1
  26. package/dist/config-exports.d.ts +3 -1
  27. package/dist/config-exports.js +3 -1
  28. package/dist/create-admin.js +9 -16
  29. package/dist/gotrue/user-routes.js +6 -4
  30. package/dist/has-indexes.js +9 -0
  31. package/dist/history/history-sql.js +13 -4
  32. package/dist/internal.js +1 -1
  33. package/dist/mcp/mcp-input-schemas.js +24 -0
  34. package/dist/mcp/mcp-server.js +3 -9
  35. package/dist/package.js +1 -1
  36. package/dist/webhooks/table/schema-sql.js +30 -6
  37. package/dist/webhooks/table/schema.js +10 -4
  38. package/package.json +4 -4
@@ -34,9 +34,11 @@ export type PgModule<P extends PgPool> = {
34
34
  * runtime's, which `protobase dev` and `serve` open, and the one the app's own code uses, such as its Better Auth store.
35
35
  * Whoever asks first opens it, and everyone in the process shares it, across a dev server's reloads too, until it ends;
36
36
  * so the app never opens more connections than it may. Date and timestamp (without zone) come back as text, which the
37
- * API formats itself, a dropped connection is printed rather than fatal, and a query waits at most `poolWaitMs` for a
38
- * free connection. Idle connections keep no process alive, so a command exits when its work is done even while
39
- * nobody ends the pool, such as one that sent no query of its own after Better Auth's schema check.
37
+ * API formats itself, a dropped connection is printed rather than fatal, and a new connection may take `connectTimeoutMs`
38
+ * to open, as long as a database takes to wake. A query that finds every connection in use waits at most `poolWaitMs`
39
+ * for one where the runtime connects (`connectOrBusy`), and `connectTimeoutMs` elsewhere. Idle connections keep no
40
+ * process alive, so a command exits when its work is done even while nobody ends the pool, such as one that sent no
41
+ * query of its own after Better Auth's schema check.
40
42
  */
41
43
  export declare const appPool: <P extends PgPool>(pg: PgModule<P>, url: string, max?: number) => P;
42
44
  /**
package/dist/app-pool.js CHANGED
@@ -3,6 +3,11 @@ import { handleDroppedConnections } from "./dropped-connections.js";
3
3
  //#region src/app-pool.ts
4
4
  /** How long a query waits for a free connection of the app's pool before it fails with `ConnectionsBusy`. */
5
5
  const poolWaitMs = 2e3;
6
+ /**
7
+ * How long the app's pool waits for a new connection to open: long enough for a database that scaled to zero to wake,
8
+ * which the platform's pooler holds the login for, and under that pooler's own login timeout (60 s by default).
9
+ */
10
+ const connectTimeoutMs = 3e4;
6
11
  /** How many connections the app's pool opens: `PROTOBASE_DB_CONNECTIONS`, such as its plan's limit, or 10. */
7
12
  const readDbConnections = (env) => {
8
13
  if (!env.PROTOBASE_DB_CONNECTIONS) return 10;
@@ -17,9 +22,11 @@ const store = globalThis;
17
22
  * runtime's, which `protobase dev` and `serve` open, and the one the app's own code uses, such as its Better Auth store.
18
23
  * Whoever asks first opens it, and everyone in the process shares it, across a dev server's reloads too, until it ends;
19
24
  * so the app never opens more connections than it may. Date and timestamp (without zone) come back as text, which the
20
- * API formats itself, a dropped connection is printed rather than fatal, and a query waits at most `poolWaitMs` for a
21
- * free connection. Idle connections keep no process alive, so a command exits when its work is done even while
22
- * nobody ends the pool, such as one that sent no query of its own after Better Auth's schema check.
25
+ * API formats itself, a dropped connection is printed rather than fatal, and a new connection may take `connectTimeoutMs`
26
+ * to open, as long as a database takes to wake. A query that finds every connection in use waits at most `poolWaitMs`
27
+ * for one where the runtime connects (`connectOrBusy`), and `connectTimeoutMs` elsewhere. Idle connections keep no
28
+ * process alive, so a command exits when its work is done even while nobody ends the pool, such as one that sent no
29
+ * query of its own after Better Auth's schema check.
23
30
  */
24
31
  const appPool = (pg, url, max = readDbConnections(globalThis.process?.env ?? {})) => {
25
32
  const pools = store.__protobaseAppPools ??= /* @__PURE__ */ new Map();
@@ -32,7 +39,7 @@ const appPool = (pg, url, max = readDbConnections(globalThis.process?.env ?? {})
32
39
  const pool = handleDroppedConnections(new pg.Pool({
33
40
  connectionString: url,
34
41
  max,
35
- connectionTimeoutMillis: poolWaitMs,
42
+ connectionTimeoutMillis: connectTimeoutMs,
36
43
  allowExitOnIdle: true,
37
44
  types: textDates(pg)
38
45
  }));
@@ -57,4 +64,4 @@ const longQueryPool = (pg, url) => handleDroppedConnections(new pg.Pool({
57
64
  types: textDates(pg)
58
65
  }));
59
66
  //#endregion
60
- export { appPool, longQueryPool, poolWaitMs, readDbConnections };
67
+ export { appPool, connectTimeoutMs, longQueryPool, poolWaitMs, readDbConnections };
@@ -1,6 +1,6 @@
1
1
  import { AssistantTool, ToolContext } from "./tools.js";
2
2
  import { WriteRecordToolsInput } from "./write-tool-limits.js";
3
3
  //#region src/assistant/delete-record-tool.d.ts
4
- /** Deletes one record by its key and ETag once the user approves it, under the caller's access rules. */
4
+ /** Deletes one record by its key, and its ETag when given, once the user approves it, under the caller's access rules. */
5
5
  export declare const deleteRecordTool: (input?: WriteRecordToolsInput) => AssistantTool<ToolContext>;
6
6
  //#endregion
@@ -21,12 +21,15 @@ const deleteKeyOf = (value) => {
21
21
  if (key === void 0) return void 0;
22
22
  return (Array.isArray(key) ? key : [key]).every(keyPartOk) ? key : void 0;
23
23
  };
24
- const etagOf = (value) => typeof value === "string" && /^"[\x21\x23-\x2b\x2d-\x7e]+"$/.test(value) ? value : void 0;
25
- const usage = "Pass only the resource, key and etag as { \"resource\": \"<name>\", \"key\": \"<key>\", \"etag\": \"<the etag get_record gave>\" }; nothing was deleted.";
26
- /** Deletes one record by its key and ETag once the user approves it, under the caller's access rules. */
24
+ const etagOf = (value) => {
25
+ if (value === void 0 || value === "*") return { etag: value };
26
+ return typeof value === "string" && /^"[\x21\x23-\x2b\x2d-\x7e]+"$/.test(value) ? { etag: value } : false;
27
+ };
28
+ const usage = "Pass only the resource, key and, optionally, etag as { \"resource\": \"<name>\", \"key\": \"<key>\", \"etag\": \"<the etag get_record gave>\" }; nothing was deleted.";
29
+ /** Deletes one record by its key, and its ETag when given, once the user approves it, under the caller's access rules. */
27
30
  const deleteRecordTool = (input = {}) => defineTool({
28
31
  name: "delete_record",
29
- description: `Deletes one record of a resource by its key. Pass the etag get_record gave: it fails when someone changed the record since. A resource that soft deletes keeps the record, deleted; any other loses it for good. The user approves each delete in their client first; nothing is deleted without it.${onlyNote(input)}`,
32
+ description: `Deletes one record of a resource by its key. Pass the etag get_record gave to fail when someone changed the record since; without one, or with *, it deletes the record whatever its version. A resource that soft deletes keeps the record, deleted; any other loses it for good. The user approves each delete in their client first; nothing is deleted without it.${onlyNote(input)}`,
30
33
  parameters: {
31
34
  type: "object",
32
35
  properties: {
@@ -37,11 +40,7 @@ const deleteRecordTool = (input = {}) => defineTool({
37
40
  description: "The etag get_record gave, to fail when the record changed since."
38
41
  }
39
42
  },
40
- required: [
41
- "resource",
42
- "key",
43
- "etag"
44
- ],
43
+ required: ["resource", "key"],
45
44
  additionalProperties: false
46
45
  },
47
46
  annotations: {
@@ -52,12 +51,13 @@ const deleteRecordTool = (input = {}) => defineTool({
52
51
  run: async (args, context) => {
53
52
  const resource = resourceOf(args);
54
53
  const key = deleteKeyOf(args.key);
55
- const etag = etagOf(args.etag);
56
- if (!resource || key === void 0 || !etag || Object.keys(args).some((name) => !parameterNames.includes(name))) return usage;
54
+ const given = etagOf(args.etag);
55
+ if (!resource || key === void 0 || !given || Object.keys(args).some((name) => !parameterNames.includes(name))) return usage;
57
56
  const refusal = outside(input, resource);
58
57
  if (refusal) return refusal;
59
58
  const softDelete = await context.records.softDeleteField(resource).then((field) => ({ field }), refused);
60
59
  if (typeof softDelete === "string") return softDelete;
60
+ const { etag } = given;
61
61
  const before = await recordBefore(context, resource, key, etag);
62
62
  if (typeof before === "string") return before;
63
63
  const { text, described } = await approvalTexts(context, resource);
@@ -2,6 +2,7 @@ import "./account-exists-email.js";
2
2
  import "./change-email-code-email.js";
3
3
  import "./invitation-email.js";
4
4
  import "./no-account-email.js";
5
+ import "./password-changed-email.js";
5
6
  import "./reset-password-email.js";
6
7
  import "./sign-in-code-email.js";
7
8
  import "./sign-up-code-email.js";
@@ -2,9 +2,10 @@ import { accountExistsEmail } from "./account-exists-email.js";
2
2
  import { changeEmailCodeEmail } from "./change-email-code-email.js";
3
3
  import { invitationEmail } from "./invitation-email.js";
4
4
  import { noAccountEmail } from "./no-account-email.js";
5
+ import { passwordChangedEmail } from "./password-changed-email.js";
5
6
  import { resetPasswordEmail } from "./reset-password-email.js";
6
7
  import { signInCodeEmail } from "./sign-in-code-email.js";
7
8
  import { signUpCodeEmail } from "./sign-up-code-email.js";
8
9
  import { staffSignInEmail } from "./staff-sign-in-email.js";
9
10
  import { twoFactorCodeEmail } from "./two-factor-code-email.js";
10
- export { accountExistsEmail, changeEmailCodeEmail, invitationEmail, noAccountEmail, resetPasswordEmail, signInCodeEmail, signUpCodeEmail, staffSignInEmail, twoFactorCodeEmail };
11
+ export { accountExistsEmail, changeEmailCodeEmail, invitationEmail, noAccountEmail, passwordChangedEmail, resetPasswordEmail, signInCodeEmail, signUpCodeEmail, staffSignInEmail, twoFactorCodeEmail };
@@ -0,0 +1 @@
1
+ import "../../index.js";
@@ -0,0 +1,32 @@
1
+ import { defineEmail } from "../../mail/define-email.js";
2
+ import { EmailButton, EmailLayout, EmailNote } from "../../mail/email-layout.js";
3
+ import { signInPath } from "@protobase/schema";
4
+ import { Text } from "react-email";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/better-auth/auth-mail/password-changed-email.tsx
7
+ const PasswordChangedEmail = ({ app }) => /* @__PURE__ */ jsxs(EmailLayout, {
8
+ app,
9
+ preview: `The password of your ${app.name} account was changed`,
10
+ heading: "Your password was changed",
11
+ children: [
12
+ /* @__PURE__ */ jsxs(Text, { children: [
13
+ "The password of your account with ",
14
+ app.name,
15
+ " was just changed."
16
+ ] }),
17
+ /* @__PURE__ */ jsx(Text, { children: "If this was not you, choose a new password at once with \"Forgot password?\" on the sign-in page:" }),
18
+ /* @__PURE__ */ jsx(EmailButton, {
19
+ href: new URL(signInPath({ step: "forgot-password" }), app.url).href,
20
+ children: "Choose a new password"
21
+ }),
22
+ /* @__PURE__ */ jsx(EmailNote, { children: "If this was you, ignore this email." })
23
+ ]
24
+ });
25
+ /** The email to an account whose password changed: what happened, and how to take the account back if it was not them. */
26
+ const passwordChangedEmail = defineEmail({
27
+ name: "password-changed",
28
+ subject: ({ app }) => `Your ${app.name} password was changed`,
29
+ component: PasswordChangedEmail
30
+ });
31
+ //#endregion
32
+ export { passwordChangedEmail };
@@ -1009,8 +1009,8 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
1009
1009
  $Infer: {
1010
1010
  body: {
1011
1011
  permissions: {
1012
- readonly user?: ("create" | "update" | "delete" | "list" | "set-role" | "ban" | "impersonate" | "set-password" | "set-email" | "get" | "impersonate-admins")[] | undefined;
1013
- readonly session?: ("delete" | "list" | "revoke")[] | undefined;
1012
+ readonly user?: ("list" | "create" | "update" | "delete" | "set-role" | "ban" | "impersonate" | "set-password" | "set-email" | "get" | "impersonate-admins")[] | undefined;
1013
+ readonly session?: ("list" | "delete" | "revoke")[] | undefined;
1014
1014
  };
1015
1015
  } & {
1016
1016
  userId?: string | undefined;
@@ -4463,6 +4463,14 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
4463
4463
  handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
4464
4464
  }[];
4465
4465
  };
4466
+ } | {
4467
+ id: "protobase-password-checks";
4468
+ hooks: {
4469
+ before: {
4470
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
4471
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
4472
+ }[];
4473
+ };
4466
4474
  } | {
4467
4475
  id: "protobase-set-password";
4468
4476
  endpoints: {
@@ -4498,6 +4506,32 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
4498
4506
  status: boolean;
4499
4507
  }>;
4500
4508
  };
4509
+ } | {
4510
+ id: "protobase-password-change";
4511
+ endpoints: {
4512
+ passwordChanged: import("better-call").StrictEndpoint<string, {
4513
+ method: "POST";
4514
+ body: import("zod").ZodObject<{
4515
+ email: import("zod").ZodString;
4516
+ }, import("zod/v4/core").$strip>;
4517
+ }, {
4518
+ status: boolean;
4519
+ }>;
4520
+ };
4521
+ hooks: {
4522
+ before: {
4523
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
4524
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
4525
+ context: {
4526
+ body: any;
4527
+ };
4528
+ } | undefined>>;
4529
+ }[];
4530
+ after: {
4531
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
4532
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
4533
+ }[];
4534
+ };
4501
4535
  } | {
4502
4536
  id: "protobase-leaked-passwords";
4503
4537
  hooks: {
@@ -1141,8 +1141,8 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
1141
1141
  $Infer: {
1142
1142
  body: {
1143
1143
  permissions: {
1144
- readonly user?: ("create" | "update" | "delete" | "list" | "set-role" | "ban" | "impersonate" | "set-password" | "set-email" | "get" | "impersonate-admins")[] | undefined;
1145
- readonly session?: ("delete" | "list" | "revoke")[] | undefined;
1144
+ readonly user?: ("list" | "create" | "update" | "delete" | "set-role" | "ban" | "impersonate" | "set-password" | "set-email" | "get" | "impersonate-admins")[] | undefined;
1145
+ readonly session?: ("list" | "delete" | "revoke")[] | undefined;
1146
1146
  };
1147
1147
  } & {
1148
1148
  userId?: string | undefined;
@@ -2914,7 +2914,10 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
2914
2914
  email: string;
2915
2915
  emailVerified: boolean;
2916
2916
  name: string;
2917
- image?: string | null | undefined;
2917
+ image? /**
2918
+ * The secrets the app had before `secret`, newest first, such as `PROTOBASE_PREVIOUS_SECRETS`: what they encrypted
2919
+ * still decrypts, so the signing keys keep signing after the secret changes. Default none.
2920
+ */ : string | null | undefined;
2918
2921
  } & Record<string, any>;
2919
2922
  } | {
2920
2923
  status: boolean;
@@ -3099,7 +3102,11 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
3099
3102
  email: string;
3100
3103
  emailVerified: boolean;
3101
3104
  name: string;
3102
- image?: string | null | undefined;
3105
+ image? /**
3106
+ * Refuses a new password that appears in a known data breach, by Have I Been Pwned's password list: at sign-up, from
3107
+ * an invitation, and when a password is set, changed, reset or given by an admin. Only the first five characters of
3108
+ * the password's SHA-1 hash leave the server. While the list cannot be reached, setting a password fails. Default off.
3109
+ */ : string | null | undefined;
3103
3110
  };
3104
3111
  };
3105
3112
  }>>[];
@@ -4117,7 +4124,9 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
4117
4124
  expiresAt: Date;
4118
4125
  token: string;
4119
4126
  ipAddress?: string | null | undefined;
4120
- userAgent?: string | null | undefined;
4127
+ userAgent?
4128
+ /** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
4129
+ /** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */ : string | null | undefined;
4121
4130
  };
4122
4131
  user: Record<string, any> & {
4123
4132
  id: string;
@@ -4591,6 +4600,14 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
4591
4600
  handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
4592
4601
  }[];
4593
4602
  };
4603
+ } | {
4604
+ id: "protobase-password-checks";
4605
+ hooks: {
4606
+ before: {
4607
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
4608
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
4609
+ }[];
4610
+ };
4594
4611
  } | {
4595
4612
  id: "protobase-set-password";
4596
4613
  endpoints: {
@@ -4626,6 +4643,32 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
4626
4643
  status: boolean;
4627
4644
  }>;
4628
4645
  };
4646
+ } | {
4647
+ id: "protobase-password-change";
4648
+ endpoints: {
4649
+ passwordChanged: import("better-call").StrictEndpoint<string, {
4650
+ method: "POST";
4651
+ body: import("zod").ZodObject<{
4652
+ email: import("zod").ZodString;
4653
+ }, import("zod/v4/core").$strip>;
4654
+ }, {
4655
+ status: boolean;
4656
+ }>;
4657
+ };
4658
+ hooks: {
4659
+ before: {
4660
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
4661
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
4662
+ context: {
4663
+ body: any;
4664
+ };
4665
+ } | undefined>>;
4666
+ }[];
4667
+ after: {
4668
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
4669
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
4670
+ }[];
4671
+ };
4629
4672
  } | {
4630
4673
  id: "protobase-leaked-passwords";
4631
4674
  hooks: {
@@ -5091,7 +5134,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
5091
5134
  member: import("better-auth/plugins").Member & Record<string, any>;
5092
5135
  }) => Promise<void | {
5093
5136
  data: {
5094
- name?: string;
5137
+ name? /** Runs after a user is created, by any route: `createUser`, an admin, or a social sign-up. Default none. */ : string;
5095
5138
  slug?: string;
5096
5139
  logo?: string | null;
5097
5140
  metadata?: Record<string, any>;
@@ -8968,7 +9011,8 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
8968
9011
  }> & {
8969
9012
  /**
8970
9013
  * Answers a request from the app's iOS or Android build: Better Auth also trusts the Capacitor origin the request
8971
- * names. Only `createAdmin` with a `mobile` section uses it; `handler` and `api` never trust a Capacitor origin.
9014
+ * names. Only `createAdmin` with a `mobile` section uses it; `handler` and `api` never trust a Capacitor origin. A
9015
+ * Capacitor origin's POST without a JSON body is refused before it is granted, as only a page sends one.
8972
9016
  */
8973
9017
  handleMobile: (request: Request) => Promise<Response>;
8974
9018
  /**
@@ -13,13 +13,14 @@ import { endReplacedSession } from "./end-replaced-session.js";
13
13
  import { authBasePath } from "./auth-base-path.js";
14
14
  import { authLogger } from "./auth-logger.js";
15
15
  import { cookiePrefixOf } from "./cookie-prefix.js";
16
- import { mobileRequests } from "./mobile-requests.js";
16
+ import { jsonOnlyOnMobile, mobileRequests } from "./mobile-requests.js";
17
17
  import { passwordLength } from "./new-account-password.js";
18
18
  import { platformMailer } from "../mail/platform-mailer.js";
19
19
  import { readOperatorSettings, resolveOperator } from "./operator-provider.js";
20
20
  import { defaultInvitationUrl } from "./invitations/invitation-link-hook.js";
21
21
  import { lastAdminGuardPlugin } from "./last-admin-guard.js";
22
22
  import { setPasswordPath, setPasswordPlugin } from "./set-password.js";
23
+ import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./sign-in-mail.js";
23
24
  import { signUpPaths, signUpPlugin } from "./sign-up/sign-up-plugin.js";
24
25
  import { leakedPasswordsPlugin } from "./leaked-passwords.js";
25
26
  import { lockedAccountsPlugin, readLockedAccounts } from "./locked-accounts.js";
@@ -35,6 +36,8 @@ import { passkeyHosts, readPasskeyDomain, resolvePasskeyDomain } from "./passkey
35
36
  import { coreNames, pluginNames } from "./snake-case-names.js";
36
37
  import { passkeyPlugin } from "./passkey-plugin.js";
37
38
  import { passwordResetOptions } from "./password-reset.js";
39
+ import { mailPasswordChanged, passwordChangePlugin } from "./password-change.js";
40
+ import { passwordChecksPlugin } from "./password-checks.js";
38
41
  import { rolesPlugin } from "./roles/roles-plugin.js";
39
42
  import { versionedSecrets } from "./secret-versions.js";
40
43
  import { signingKeyRotation, signingKeys } from "./signing-keys.js";
@@ -43,7 +46,6 @@ import { appSignInPolicy } from "./app-sign-in-policy.js";
43
46
  import { assertNoEndpointKeyClashes } from "./endpoint-key-clashes.js";
44
47
  import { emailInDomains, resolveSamlProviders, samlDisabledPaths, samlGuardPlugin } from "./saml-sign-in.js";
45
48
  import { noAccountMailPlugin } from "./no-account-mail.js";
46
- import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./sign-in-mail.js";
47
49
  import { confirmedOnCreate, confirmedOnUpdate, emailConfirmedField } from "./email-confirmed.js";
48
50
  import { rehashPasswordsPlugin, verifyAcceptedHash } from "./password-hashes.js";
49
51
  import { staffSignInPlugin } from "./staff-plugin.js";
@@ -177,7 +179,11 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
177
179
  minPasswordLength: passwordLength.min,
178
180
  maxPasswordLength: passwordLength.max,
179
181
  ...passwordHashes.length > 0 && { password: { verify: verifyAcceptedHash(passwordHashes) } },
180
- ...mail && passwordResetOptions(mail, mcp ? { onPasswordReset: async ({ user }) => revokeMcpGrants((await context()).adapter, user.id) } : {})
182
+ ...mail && passwordResetOptions(mail, { onPasswordReset: async ({ user }) => {
183
+ const auth = await context();
184
+ if (mcp) await revokeMcpGrants(auth.adapter, user.id);
185
+ await mailPasswordChanged({ context: auth }, mail, user.email);
186
+ } })
181
187
  },
182
188
  ...options.socialProviders && { socialProviders: options.socialProviders },
183
189
  user: {
@@ -222,7 +228,9 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
222
228
  ...signIn ? [genericOAuth({ config: [platformSignInProvider(signIn)] })] : [],
223
229
  ...saml.length > 0 ? [loaded(plugins.samlPlugin, "sso.saml")(saml, options.baseURL), samlGuardPlugin(samlIds)] : [],
224
230
  signInPolicyPlugin(policy),
231
+ passwordChecksPlugin(),
225
232
  setPasswordPlugin(),
233
+ passwordChangePlugin(mail),
226
234
  ...options.leakedPasswords ? [leakedPasswordsPlugin()] : [],
227
235
  goTrueSessionsPlugin({
228
236
  issuer: goTrueIssuer(options.baseURL),
@@ -412,9 +420,10 @@ const createAuth = (options, plugins = {}) => {
412
420
  return Object.assign(auth, {
413
421
  /**
414
422
  * Answers a request from the app's iOS or Android build: Better Auth also trusts the Capacitor origin the request
415
- * names. Only `createAdmin` with a `mobile` section uses it; `handler` and `api` never trust a Capacitor origin.
423
+ * names. Only `createAdmin` with a `mobile` section uses it; `handler` and `api` never trust a Capacitor origin. A
424
+ * Capacitor origin's POST without a JSON body is refused before it is granted, as only a page sends one.
416
425
  */
417
- handleMobile: (request) => auth.handler(mobile.grant(request)),
426
+ handleMobile: async (request) => jsonOnlyOnMobile(request) ?? auth.handler(mobile.grant(request)),
418
427
  /**
419
428
  * What runs when a user is deleted: `set` a named listener that removes what the app keeps of the user elsewhere,
420
429
  * such as their devices; `run` is for a deletion outside Better Auth's endpoints (`deleteUser`).
@@ -28,7 +28,11 @@ const revokeMcpGrants = async (adapter, userId, clientId) => {
28
28
  where
29
29
  });
30
30
  };
31
- const ownPaths = ["/revoke-sessions", "/revoke-other-sessions"];
31
+ const ownPaths = [
32
+ "/revoke-sessions",
33
+ "/revoke-other-sessions",
34
+ "/change-password"
35
+ ];
32
36
  const adminPaths = [
33
37
  "/admin/revoke-user-sessions",
34
38
  "/admin/ban-user",
@@ -42,14 +46,14 @@ const securesAccount = (data) => {
42
46
  };
43
47
  /**
44
48
  * The user whose MCP grants a finished request ends, or `undefined`: the person who signed out everywhere, signed out
45
- * their other devices, or changed their password signing out the other devices; the user an admin ended the sessions
49
+ * their other devices, or changed their password; the user an admin ended the sessions
46
50
  * of, banned, set the password of, or marked unverified. Nobody when the request failed.
47
51
  */
48
52
  const grantOwnerToRevoke = (request) => {
49
53
  const { path, context } = request;
50
54
  if (context.returned instanceof Error || path === void 0) return void 0;
51
55
  const body = bodyOf(request);
52
- if (ownPaths.includes(path) || path === "/change-password" && body.revokeOtherSessions === true) return context.session?.user.id;
56
+ if (ownPaths.includes(path)) return context.session?.user.id;
53
57
  if (adminPaths.includes(path) || path === "/admin/update-user" && securesAccount(body.data)) return body.userId === void 0 ? void 0 : String(body.userId);
54
58
  };
55
59
  /**
@@ -62,7 +66,6 @@ const mcpGrantRevocation = () => ({
62
66
  matcher: (context) => context.path !== void 0 && [
63
67
  ...ownPaths,
64
68
  ...adminPaths,
65
- "/change-password",
66
69
  "/admin/update-user"
67
70
  ].includes(context.path),
68
71
  handler: createAuthMiddleware(async (ctx) => {
@@ -1,5 +1,9 @@
1
1
  import { capacitorOrigins } from "../mobile-config.js";
2
2
  //#region src/better-auth/mobile-requests.ts
3
+ const capacitorOriginOf = (request) => {
4
+ const origin = request.headers.get("origin");
5
+ return origin !== null && capacitorOrigins.includes(origin);
6
+ };
3
7
  /**
4
8
  * Which requests come from the app's iOS or Android build: `grant` marks a request, and `trusted` gives the Capacitor
5
9
  * origin it names as an origin Better Auth trusts, for that very request only. Any other request, or an Origin that is
@@ -14,10 +18,26 @@ const mobileRequests = () => {
14
18
  },
15
19
  trusted: (request) => {
16
20
  if (!request || !granted.has(request)) return [];
17
- const origin = request.headers.get("origin");
18
- return origin !== null && capacitorOrigins.includes(origin) ? [origin] : [];
21
+ return capacitorOriginOf(request) ? [request.headers.get("origin")] : [];
19
22
  }
20
23
  };
21
24
  };
25
+ const mediaType = (request) => request.headers.get("content-type")?.split(";")[0].trim().toLowerCase();
26
+ /**
27
+ * Refuses a POST from a Capacitor origin whose body is not JSON. A page can send a form, `text/plain` or multipart POST
28
+ * without a preflight, but not a JSON one, and `/api/auth` answers no preflight: so the grant only ever sees requests
29
+ * the app's native HTTP sends. A request from any other origin is left alone, such as an identity provider's form POST.
30
+ */
31
+ const jsonOnlyOnMobile = (request) => {
32
+ if (request.method !== "POST" || !capacitorOriginOf(request)) return;
33
+ if (mediaType(request) === "application/json") return;
34
+ return new Response(JSON.stringify({
35
+ message: "A request from the app takes a JSON body only",
36
+ code: "UNSUPPORTED_MEDIA_TYPE"
37
+ }), {
38
+ status: 415,
39
+ headers: { "content-type": "application/json" }
40
+ });
41
+ };
22
42
  //#endregion
23
- export { mobileRequests };
43
+ export { jsonOnlyOnMobile, mobileRequests };
@@ -0,0 +1,68 @@
1
+ import { setPasswordPath } from "./set-password.js";
2
+ import { sendAuthEmail } from "./auth-mail/send-auth-email.js";
3
+ import * as z$1 from "zod";
4
+ import { APIError, createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
5
+ //#region src/better-auth/password-change.ts
6
+ /**
7
+ * Mails `email` that its account's password changed, when the app has mail: in the background, as Better Auth sends
8
+ * its own mail, so the answer does not wait on the mail server.
9
+ */
10
+ const mailPasswordChanged = async (ctx, mail, email) => {
11
+ if (!mail) return;
12
+ await ctx.context.runInBackgroundOrAwait(sendAuthEmail(mail, "passwordChangedEmail", email, {}, ctx.context.baseURL));
13
+ };
14
+ const ownChanges = ["/change-password", setPasswordPath];
15
+ const adminChange = "/admin/set-user-password";
16
+ /** Whose account a finished request gave a new password: the address of the person, or the id of the user an admin set it for. */
17
+ const changedAccount = ({ path, body, context }) => {
18
+ if (context.returned instanceof Error || path === void 0) return;
19
+ if (ownChanges.includes(path)) return context.session ? { email: context.session.user.email } : void 0;
20
+ const userId = typeof body === "object" && body !== null ? body.userId : void 0;
21
+ if (path === adminChange && userId !== void 0) return { userId: String(userId) };
22
+ };
23
+ /**
24
+ * A password change ends the account's other sessions and tells its owner:
25
+ * - `/change-password` always signs out the other devices, whatever `revokeOtherSessions` says: this device gets a
26
+ * new session, and the MCP grant revocation disconnects the account's MCP clients.
27
+ * - When the app has mail, the owner is emailed after `/change-password`, a first password (`/account/set-password`)
28
+ * and an admin's `/admin/set-user-password`; `passwordChanged` mails them for a change outside these endpoints,
29
+ * such as the GoTrue-compatible `PUT /user`. A password reset mails them from `onPasswordReset`.
30
+ */
31
+ const passwordChangePlugin = (mail) => ({
32
+ id: "protobase-password-change",
33
+ endpoints: { passwordChanged: createAuthEndpoint.serverOnly({
34
+ method: "POST",
35
+ body: z$1.object({ email: z$1.string() })
36
+ }, async (ctx) => {
37
+ await mailPasswordChanged(ctx, mail, ctx.body.email);
38
+ return ctx.json({ status: true });
39
+ }) },
40
+ hooks: {
41
+ before: [{
42
+ matcher: (ctx) => ctx.path === "/change-password",
43
+ handler: createAuthMiddleware(async (ctx) => {
44
+ if (typeof ctx.body !== "object" || ctx.body === null) return;
45
+ return { context: { body: {
46
+ ...ctx.body,
47
+ revokeOtherSessions: true
48
+ } } };
49
+ })
50
+ }],
51
+ after: [{
52
+ matcher: (ctx) => ctx.path !== void 0 && [...ownChanges, adminChange].includes(ctx.path),
53
+ handler: createAuthMiddleware(async (ctx) => {
54
+ const changed = changedAccount(ctx);
55
+ if (!changed || !mail) return;
56
+ if ("email" in changed) {
57
+ await mailPasswordChanged(ctx, mail, changed.email);
58
+ return;
59
+ }
60
+ const user = await ctx.context.internalAdapter.findUserById(changed.userId);
61
+ if (!user) throw new APIError("NOT_FOUND", { message: "The user whose password was set is gone" });
62
+ await mailPasswordChanged(ctx, mail, user.email);
63
+ })
64
+ }]
65
+ }
66
+ });
67
+ //#endregion
68
+ export { changedAccount, mailPasswordChanged, passwordChangePlugin };
@@ -0,0 +1,69 @@
1
+ import { APIError, createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
2
+ const maxTrackedAccounts = 1e4;
3
+ const checkedFields = {
4
+ "/change-password": "currentPassword",
5
+ "/verify-password": "password",
6
+ "/delete-user": "password",
7
+ "/two-factor/enable": "password",
8
+ "/two-factor/disable": "password",
9
+ "/two-factor/get-totp-uri": "password",
10
+ "/two-factor/generate-backup-codes": "password"
11
+ };
12
+ /**
13
+ * Counts the checks of each account in a fixed window that opens at its first check: `take` answers how many seconds
14
+ * the account waits before its next check, or `undefined` when this one may go ahead and is counted. Bounded: a window
15
+ * that ended is dropped, and at `maxTracked` accounts the oldest window is dropped.
16
+ */
17
+ const checkCounter = ({ max, windowSeconds, maxTracked = maxTrackedAccounts, now = Date.now }) => {
18
+ const windows = /* @__PURE__ */ new Map();
19
+ const windowMs = windowSeconds * 1e3;
20
+ const prune = (at) => {
21
+ for (const [key, window] of windows) if (at - window.opened >= windowMs) windows.delete(key);
22
+ while (windows.size >= maxTracked) windows.delete(windows.keys().next().value);
23
+ };
24
+ return { take: (key) => {
25
+ const at = now();
26
+ const window = windows.get(key);
27
+ if (window && at - window.opened < windowMs) {
28
+ if (window.count >= max) return Math.ceil((window.opened + windowMs - at) / 1e3);
29
+ window.count += 1;
30
+ return;
31
+ }
32
+ windows.delete(key);
33
+ prune(at);
34
+ windows.set(key, {
35
+ opened: at,
36
+ count: 1
37
+ });
38
+ } };
39
+ };
40
+ /**
41
+ * Limits the current-password checks behind a session to `passwordChecksPerWindow` an account a minute, on top of
42
+ * Better Auth's limit per client address, so a stolen session cannot guess the password from many addresses. Every
43
+ * check counts, a right one too, and is counted before the password is compared, so requests sent at once cannot pass
44
+ * the limit together. A refused check answers `429 TOO_MANY_PASSWORD_CHECKS`, with `X-Retry-After` in seconds. A
45
+ * request without a session is left to the endpoint, which refuses it. The count lives in this process's memory.
46
+ */
47
+ const passwordChecksPlugin = (counter = checkCounter({
48
+ max: 5,
49
+ windowSeconds: 60
50
+ })) => ({
51
+ id: "protobase-password-checks",
52
+ hooks: { before: [{
53
+ matcher: (ctx) => ctx.path !== void 0 && Object.hasOwn(checkedFields, ctx.path),
54
+ handler: createAuthMiddleware(async (ctx) => {
55
+ const password = ctx.body?.[checkedFields[ctx.path]];
56
+ if (typeof password !== "string" || password === "") return;
57
+ const session = await getSessionFromCtx(ctx);
58
+ if (!session) return;
59
+ const wait = counter.take(session.user.id);
60
+ if (wait === void 0) return;
61
+ throw new APIError("TOO_MANY_REQUESTS", {
62
+ code: "TOO_MANY_PASSWORD_CHECKS",
63
+ message: "Too many password attempts. Try again in a minute."
64
+ }, { "X-Retry-After": String(wait) });
65
+ })
66
+ }] }
67
+ });
68
+ //#endregion
69
+ export { checkCounter, passwordChecksPlugin };