@protobase/server 0.66.9 → 0.66.10

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.
@@ -2579,6 +2579,41 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
2579
2579
  handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
2580
2580
  }[];
2581
2581
  };
2582
+ } | {
2583
+ id: "protobase-password-confirmation";
2584
+ endpoints: {
2585
+ confirmPassword: import("better-call").StrictEndpoint<"/account/confirm-password", {
2586
+ method: "POST";
2587
+ use: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
2588
+ session: {
2589
+ session: Record<string, any> & {
2590
+ id: string;
2591
+ createdAt: Date;
2592
+ updatedAt: Date;
2593
+ userId: string;
2594
+ expiresAt: Date;
2595
+ token: string;
2596
+ ipAddress?: string | null | undefined;
2597
+ userAgent?: string | null | undefined;
2598
+ };
2599
+ user: Record<string, any> & {
2600
+ id: string;
2601
+ createdAt: Date;
2602
+ updatedAt: Date;
2603
+ email: string;
2604
+ emailVerified: boolean;
2605
+ name: string;
2606
+ image?: string | null | undefined;
2607
+ };
2608
+ };
2609
+ }>>[];
2610
+ body: import("zod").ZodObject<{
2611
+ password: import("zod").ZodString;
2612
+ }, import("zod/v4/core").$strip>;
2613
+ }, {
2614
+ status: boolean;
2615
+ }>;
2616
+ };
2582
2617
  } | {
2583
2618
  id: "email-otp";
2584
2619
  version: string;
@@ -8893,6 +8928,10 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
8893
8928
  window: number;
8894
8929
  max: number;
8895
8930
  };
8931
+ "/account/confirm-password": {
8932
+ window: number;
8933
+ max: number;
8934
+ };
8896
8935
  };
8897
8936
  };
8898
8937
  advanced: {
@@ -1,6 +1,7 @@
1
1
  import { sendAuthEmail } from "./auth-mail/send-auth-email.js";
2
2
  import { codeLifetime } from "./sign-in-mail.js";
3
3
  import { saveStrongSignIn } from "./strong-sign-ins.js";
4
+ import { hasPasskeyOrSecondStep } from "./strong-factors.js";
4
5
  import { checkConfirmationCode, dropConfirmationCode, newConfirmationCode, saveConfirmationCode } from "./confirmation-codes.js";
5
6
  import * as z$1 from "zod";
6
7
  import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api";
@@ -31,13 +32,7 @@ const refusals = {
31
32
  message: "The code has expired. Ask for a new one."
32
33
  }
33
34
  };
34
- const isOffered = async (context, { user }) => !user.twoFactorEnabled && await context.adapter.count({
35
- model: "passkey",
36
- where: [{
37
- field: "userId",
38
- value: user.id
39
- }]
40
- }) === 0;
35
+ const isOffered = async (context, { user }) => !await hasPasskeyOrSecondStep(context.adapter, user);
41
36
  /**
42
37
  * An emailed code that confirms it's them inside a session someone is signed in with, for an account without a passkey
43
38
  * or a second step, which has nothing stronger to confirm with: a right code notes a strong sign-in for that session
@@ -1666,7 +1666,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
1666
1666
  email: string;
1667
1667
  emailVerified: boolean;
1668
1668
  name: string;
1669
- image?: string | null | undefined;
1669
+ image? /** 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;
1670
1670
  };
1671
1671
  };
1672
1672
  }>>[];
@@ -2411,7 +2411,9 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
2411
2411
  email: string;
2412
2412
  emailVerified: boolean;
2413
2413
  name: string;
2414
- image?: string | null | undefined;
2414
+ image?
2415
+ /** The RP ID of new passkeys, from `resolvePasskeyDomain`; default the host of `baseURL`. */
2416
+ /** The RP ID of new passkeys, from `resolvePasskeyDomain`; default the host of `baseURL`. */ : string | null | undefined;
2415
2417
  };
2416
2418
  };
2417
2419
  }>>[];
@@ -2712,6 +2714,41 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
2712
2714
  handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
2713
2715
  }[];
2714
2716
  };
2717
+ } | {
2718
+ id: "protobase-password-confirmation";
2719
+ endpoints: {
2720
+ confirmPassword: import("better-call").StrictEndpoint<"/account/confirm-password", {
2721
+ method: "POST";
2722
+ use: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
2723
+ session: {
2724
+ session: Record<string, any> & {
2725
+ id: string;
2726
+ createdAt: Date;
2727
+ updatedAt: Date;
2728
+ userId: string;
2729
+ expiresAt: Date;
2730
+ token: string;
2731
+ ipAddress?: string | null | undefined;
2732
+ userAgent?: string | null | undefined;
2733
+ };
2734
+ user: Record<string, any> & {
2735
+ id: string;
2736
+ createdAt: Date;
2737
+ updatedAt: Date;
2738
+ email: string;
2739
+ emailVerified: boolean;
2740
+ name: string;
2741
+ image?: string | null | undefined;
2742
+ };
2743
+ };
2744
+ }>>[];
2745
+ body: import("zod").ZodObject<{
2746
+ password: import("zod").ZodString;
2747
+ }, import("zod/v4/core").$strip>;
2748
+ }, {
2749
+ status: boolean;
2750
+ }>;
2751
+ };
2715
2752
  } | {
2716
2753
  id: "email-otp";
2717
2754
  version: string;
@@ -9028,6 +9065,10 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
9028
9065
  window: number;
9029
9066
  max: number;
9030
9067
  };
9068
+ "/account/confirm-password": {
9069
+ window: number;
9070
+ max: number;
9071
+ };
9031
9072
  };
9032
9073
  };
9033
9074
  advanced: {
@@ -50,6 +50,7 @@ import { confirmedOnCreate, confirmedOnUpdate, emailConfirmedField } from "./ema
50
50
  import { rehashPasswordsPlugin, verifyAcceptedHash } from "./password-hashes.js";
51
51
  import { staffSignInPlugin } from "./staff-plugin.js";
52
52
  import { strongSignInsPlugin } from "./strong-sign-ins.js";
53
+ import { passwordConfirmationPath, passwordConfirmationPlugin } from "./password-confirmation.js";
53
54
  import { recentConfirmationPlugin } from "./recent-confirmation.js";
54
55
  import { confirmationCodePaths, confirmationCodePlugin } from "./confirmation-code-plugin.js";
55
56
  import { tokenClaims } from "./token-claims.js";
@@ -225,6 +226,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
225
226
  }),
226
227
  strongSignInsPlugin(),
227
228
  recentConfirmationPlugin(),
229
+ passwordConfirmationPlugin(),
228
230
  ...mail ? [
229
231
  emailOTP(emailCodeOptions(mail)),
230
232
  noAccountMailPlugin(mail),
@@ -361,6 +363,10 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
361
363
  [confirmationCodePaths.verify]: {
362
364
  window: 60,
363
365
  max: 5
366
+ },
367
+ [passwordConfirmationPath]: {
368
+ window: 60,
369
+ max: 5
364
370
  }
365
371
  }
366
372
  },
@@ -7,7 +7,8 @@ const checkedFields = {
7
7
  "/two-factor/enable": "password",
8
8
  "/two-factor/disable": "password",
9
9
  "/two-factor/get-totp-uri": "password",
10
- "/two-factor/generate-backup-codes": "password"
10
+ "/two-factor/generate-backup-codes": "password",
11
+ "/account/confirm-password": "password"
11
12
  };
12
13
  /**
13
14
  * Counts the checks of each account in a fixed window that opens at its first check: `take` answers how many seconds
@@ -0,0 +1,63 @@
1
+ import { hasPasskeyOrSecondStep } from "./strong-factors.js";
2
+ import { confirmationMinutes } from "@protobase/schema/internal";
3
+ import * as z$1 from "zod";
4
+ import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api";
5
+ //#region src/better-auth/password-confirmation.ts
6
+ /** The endpoint that confirms it's them with the account's password, as Better Auth's `ctx.path` names it. */
7
+ const passwordConfirmationPath = "/account/confirm-password";
8
+ const identifierOf = (sessionId) => `protobase-password-confirmation-${sessionId}`;
9
+ /** Notes that the session re-entered the account's password now; the note lasts `confirmationMinutes`. */
10
+ const savePasswordConfirmation = async (internal, sessionId, now = /* @__PURE__ */ new Date()) => {
11
+ const identifier = identifierOf(sessionId);
12
+ await internal.deleteVerificationByIdentifier(identifier);
13
+ await internal.createVerificationValue({
14
+ identifier,
15
+ value: now.toISOString(),
16
+ expiresAt: new Date(now.getTime() + confirmationMinutes * 6e4)
17
+ });
18
+ };
19
+ /** Whether the session re-entered the account's password within `confirmationMinutes`. */
20
+ const isPasswordConfirmed = async (internal, sessionId, now = /* @__PURE__ */ new Date()) => {
21
+ const found = await internal.findVerificationValue(identifierOf(sessionId));
22
+ return Boolean(found) && new Date(found.expiresAt) > now;
23
+ };
24
+ const notOffered = {
25
+ code: "PASSWORD_CONFIRMATION_NOT_OFFERED",
26
+ message: "Confirm it's you with your passkey or your second step instead."
27
+ };
28
+ const noPassword = {
29
+ code: "PASSWORD_CONFIRMATION_NOT_OFFERED",
30
+ message: "This account has no password: confirm it's you with an emailed code instead."
31
+ };
32
+ const wrongPassword = {
33
+ code: "INVALID_PASSWORD",
34
+ message: "The password is not right."
35
+ };
36
+ /**
37
+ * `POST /account/confirm-password` with `{ password }`: an account with a password and neither a passkey nor a second
38
+ * step confirms it's them with it, for adding its first passkey (`recentConfirmationPlugin`). Unlike an emailed code,
39
+ * it is no strong sign-in: a reveal still asks for one. Each try counts toward the password checks of the account
40
+ * (`passwordChecksPlugin`); staff sessions are refused by the staff plugin.
41
+ */
42
+ const passwordConfirmationPlugin = () => ({
43
+ id: "protobase-password-confirmation",
44
+ endpoints: { confirmPassword: createAuthEndpoint(passwordConfirmationPath, {
45
+ method: "POST",
46
+ use: [sessionMiddleware],
47
+ body: z$1.object({ password: z$1.string() })
48
+ }, async (ctx) => {
49
+ const signed = ctx.context.session;
50
+ if (await hasPasskeyOrSecondStep(ctx.context.adapter, signed.user)) throw new APIError("FORBIDDEN", notOffered);
51
+ const hash = (await ctx.context.internalAdapter.findCredentialAccount(signed.user.id))?.password;
52
+ if (!hash) throw new APIError("FORBIDDEN", noPassword);
53
+ if (ctx.body.password.length > ctx.context.password.config.maxPasswordLength) throw new APIError("BAD_REQUEST", wrongPassword);
54
+ if (!await ctx.context.password.verify({
55
+ hash,
56
+ password: ctx.body.password
57
+ })) throw new APIError("BAD_REQUEST", wrongPassword);
58
+ await savePasswordConfirmation(ctx.context.internalAdapter, signed.session.id);
59
+ return ctx.json({ status: true });
60
+ }) }
61
+ });
62
+ //#endregion
63
+ export { isPasswordConfirmed, passwordConfirmationPath, passwordConfirmationPlugin, savePasswordConfirmation };
@@ -1,5 +1,7 @@
1
1
  import { isStaffSession } from "./staff-checks.js";
2
2
  import { strongSignInAt } from "./strong-sign-ins.js";
3
+ import { hasPasskeyOrSecondStep } from "./strong-factors.js";
4
+ import { isPasswordConfirmed } from "./password-confirmation.js";
3
5
  import { confirmationMinutes } from "@protobase/schema/internal";
4
6
  import { APIError, createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
5
7
  //#region src/better-auth/recent-confirmation.ts
@@ -11,22 +13,19 @@ const confirmationRequired = {
11
13
  message: `Confirm it's you first: this change needs a passkey, a second step or an emailed code in the last ${confirmationMinutes} minutes.`
12
14
  };
13
15
  const passkeyPaths = ["/passkey/generate-register-options", "/passkey/verify-registration"];
16
+ const twoFactorPath = "/two-factor/enable";
14
17
  const guardedPaths = [
15
18
  ...passkeyPaths,
19
+ twoFactorPath,
16
20
  "/unlink-account",
17
21
  "/delete-user"
18
22
  ];
19
- const asksToConfirm = async (context, path, body, { user }) => {
20
- if (path === "/delete-user") return !body?.password;
21
- if (!passkeyPaths.includes(path)) return true;
22
- if (user.twoFactorEnabled) return true;
23
- return await context.adapter.count({
24
- model: "passkey",
25
- where: [{
26
- field: "userId",
27
- value: user.id
28
- }]
29
- }) > 0;
23
+ const confirmationAsked = async (context, path, body, { user }) => {
24
+ if (path === "/delete-user") return body?.password ? "nothing" : "confirm";
25
+ if (path === "/unlink-account") return "confirm";
26
+ const first = !await hasPasskeyOrSecondStep(context.adapter, user);
27
+ if (path === twoFactorPath) return first && !(await context.internalAdapter.findCredentialAccount(user.id))?.password ? "confirm" : "nothing";
28
+ return first ? "confirm-or-password" : "confirm";
30
29
  };
31
30
  /** Whether the session signed in within a day, or confirmed it's them within `confirmationMinutes`. */
32
31
  const isRecentlyConfirmed = async (context, session, now = /* @__PURE__ */ new Date()) => {
@@ -36,10 +35,12 @@ const isRecentlyConfirmed = async (context, session, now = /* @__PURE__ */ new D
36
35
  };
37
36
  /**
38
37
  * Replaces Better Auth's fresh-session check, which `createAuth` turns off (`freshAge: 0`), and which could only be met
39
- * by signing out and in again. A session of any age adds a first passkey, and lists its devices. Adding another passkey
40
- * to an account with a passkey or a second step, unlinking a sign-in provider and deleting the account without a
41
- * password ask a session older than a day to confirm it's them in place first (`strongSignInsPlugin` notes it), else
42
- * `403 STRONG_SIGN_IN_REQUIRED`. Staff sessions are left to the staff plugin, which refuses these changes.
38
+ * by signing out and in again. A session of any age lists its devices. Adding a passkey, turning on a second step
39
+ * without a password, unlinking a sign-in provider and deleting the account without a password ask a session older
40
+ * than a day to confirm it's them in place first (`strongSignInsPlugin` notes it), else `403 STRONG_SIGN_IN_REQUIRED`.
41
+ * An account with neither a passkey nor a second step confirms its first passkey with its password too, which counts
42
+ * for that change only, never as a strong sign-in. Staff sessions are left to the staff plugin, which refuses these
43
+ * changes.
43
44
  */
44
45
  const recentConfirmationPlugin = () => ({
45
46
  id: "protobase-recent-confirmation",
@@ -48,8 +49,10 @@ const recentConfirmationPlugin = () => ({
48
49
  handler: createAuthMiddleware(async (ctx) => {
49
50
  const signedIn = await getSessionFromCtx(ctx);
50
51
  if (!signedIn || isStaffSession(signedIn.session)) return;
51
- if (!await asksToConfirm(ctx.context, ctx.path, ctx.body, signedIn)) return;
52
+ const asked = await confirmationAsked(ctx.context, ctx.path, ctx.body, signedIn);
53
+ if (asked === "nothing") return;
52
54
  if (await isRecentlyConfirmed(ctx.context, signedIn.session)) return;
55
+ if (asked === "confirm-or-password" && await isPasswordConfirmed(ctx.context.internalAdapter, signedIn.session.id)) return;
53
56
  throw new APIError("FORBIDDEN", confirmationRequired);
54
57
  })
55
58
  }] }
@@ -57,6 +57,7 @@ const accountChanges = [
57
57
  "/passkey/delete-passkey",
58
58
  "/account/confirmation-code/send",
59
59
  "/account/confirmation-code/verify",
60
+ "/account/confirm-password",
60
61
  "/invitation/accept",
61
62
  "/organization/leave",
62
63
  "/organization/transfer-ownership",
@@ -0,0 +1,14 @@
1
+ //#region src/better-auth/strong-factors.ts
2
+ /**
3
+ * Whether the account has a passkey or a second step, which confirm it's them; one with neither confirms a first one
4
+ * with its password or an emailed code instead.
5
+ */
6
+ const hasPasskeyOrSecondStep = async (adapter, user) => Boolean(user.twoFactorEnabled) || await adapter.count({
7
+ model: "passkey",
8
+ where: [{
9
+ field: "userId",
10
+ value: user.id
11
+ }]
12
+ }) > 0;
13
+ //#endregion
14
+ export { hasPasskeyOrSecondStep };
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.66.9";
2
+ var version = "0.66.10";
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.66.9",
3
+ "version": "0.66.10",
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": {
@@ -43,9 +43,9 @@
43
43
  "@better-auth/sso": "^1.7.12",
44
44
  "@livekit/protocol": "1.53.1",
45
45
  "@modelcontextprotocol/server": "^2.3.1",
46
- "@protobase/layout": "0.66.9",
47
- "@protobase/query": "0.66.9",
48
- "@protobase/schema": "0.66.9",
46
+ "@protobase/layout": "0.66.10",
47
+ "@protobase/query": "0.66.10",
48
+ "@protobase/schema": "0.66.10",
49
49
  "@react-email/render": "^2.1.0",
50
50
  "@react-pdf/primitives": "^4.4.0",
51
51
  "@react-pdf/renderer": "^4.9.0",