@protobase/server 0.66.6 → 0.66.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.
Files changed (45) hide show
  1. package/dist/assistant/delete-record-tool.d.ts +1 -1
  2. package/dist/assistant/delete-record-tool.js +11 -11
  3. package/dist/better-auth/auth-mail/auth-emails.d.ts +2 -0
  4. package/dist/better-auth/auth-mail/auth-emails.js +3 -1
  5. package/dist/better-auth/auth-mail/confirmation-code-email.d.ts +1 -0
  6. package/dist/better-auth/auth-mail/confirmation-code-email.js +32 -0
  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 +108 -2
  10. package/dist/better-auth/confirmation-code-plugin.d.ts +9 -0
  11. package/dist/better-auth/confirmation-code-plugin.js +95 -0
  12. package/dist/better-auth/confirmation-codes.js +54 -0
  13. package/dist/better-auth/create-auth.d.ts +119 -3
  14. package/dist/better-auth/create-auth.js +34 -7
  15. package/dist/better-auth/mcp/revoke-grants.js +7 -4
  16. package/dist/better-auth/mobile-requests.js +23 -3
  17. package/dist/better-auth/password-change.js +68 -0
  18. package/dist/better-auth/password-checks.js +69 -0
  19. package/dist/better-auth/password-reset.js +2 -1
  20. package/dist/better-auth/recent-confirmation.js +58 -0
  21. package/dist/better-auth/sign-in-mail.js +8 -5
  22. package/dist/better-auth/sign-up/sign-up-code.js +4 -3
  23. package/dist/better-auth/sign-up/sign-up-plugin.js +3 -2
  24. package/dist/better-auth/staff-checks.js +2 -0
  25. package/dist/better-auth/strong-sign-ins.js +2 -2
  26. package/dist/built-in-resources.d.ts +89 -0
  27. package/dist/built-in-resources.js +31 -0
  28. package/dist/changes/capture-schema.js +3 -1
  29. package/dist/comments/comments-sql.d.ts +4 -1
  30. package/dist/comments/comments-sql.js +16 -7
  31. package/dist/comments/record-comments.js +1 -1
  32. package/dist/config-exports.d.ts +3 -1
  33. package/dist/config-exports.js +3 -1
  34. package/dist/create-admin.js +9 -16
  35. package/dist/gotrue/user-routes.js +6 -4
  36. package/dist/has-indexes.js +9 -0
  37. package/dist/history/history-sql.js +13 -4
  38. package/dist/internal.js +1 -1
  39. package/dist/mcp/mcp-input-schemas.js +24 -0
  40. package/dist/mcp/mcp-server.js +3 -9
  41. package/dist/package.js +1 -1
  42. package/dist/reveal-service.js +1 -1
  43. package/dist/webhooks/table/schema-sql.js +30 -6
  44. package/dist/webhooks/table/schema.js +10 -4
  45. package/package.json +4 -4
@@ -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,11 +46,12 @@ 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";
50
52
  import { strongSignInsPlugin } from "./strong-sign-ins.js";
53
+ import { recentConfirmationPlugin } from "./recent-confirmation.js";
54
+ import { confirmationCodePaths, confirmationCodePlugin } from "./confirmation-code-plugin.js";
51
55
  import { tokenClaims } from "./token-claims.js";
52
56
  import { twoFactorAfterPasswordOrCode } from "./two-factor-challenge.js";
53
57
  import { metadataField, userFieldsNamed, userModel, userTable, withUserColumns } from "./user-table.js";
@@ -177,7 +181,11 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
177
181
  minPasswordLength: passwordLength.min,
178
182
  maxPasswordLength: passwordLength.max,
179
183
  ...passwordHashes.length > 0 && { password: { verify: verifyAcceptedHash(passwordHashes) } },
180
- ...mail && passwordResetOptions(mail, mcp ? { onPasswordReset: async ({ user }) => revokeMcpGrants((await context()).adapter, user.id) } : {})
184
+ ...mail && passwordResetOptions(mail, { onPasswordReset: async ({ user }) => {
185
+ const auth = await context();
186
+ if (mcp) await revokeMcpGrants(auth.adapter, user.id);
187
+ await mailPasswordChanged({ context: auth }, mail, user.email);
188
+ } })
181
189
  },
182
190
  ...options.socialProviders && { socialProviders: options.socialProviders },
183
191
  user: {
@@ -185,7 +193,10 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
185
193
  ...Object.keys(userFields).length > 0 && { additionalFields: userFields },
186
194
  ...deleteUser
187
195
  },
188
- session: coreNames.session,
196
+ session: {
197
+ ...coreNames.session,
198
+ freshAge: 0
199
+ },
189
200
  verification: coreNames.verification,
190
201
  account: {
191
202
  ...coreNames.account,
@@ -213,7 +224,12 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
213
224
  origins: appOrigins(options.baseURL, origins)
214
225
  }),
215
226
  strongSignInsPlugin(),
216
- ...mail ? [emailOTP(emailCodeOptions(mail)), noAccountMailPlugin(mail)] : [],
227
+ recentConfirmationPlugin(),
228
+ ...mail ? [
229
+ emailOTP(emailCodeOptions(mail)),
230
+ noAccountMailPlugin(mail),
231
+ confirmationCodePlugin(mail)
232
+ ] : [],
217
233
  ...mail && signUpRole ? [signUpPlugin({
218
234
  role: signUpRole,
219
235
  mail,
@@ -222,7 +238,9 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
222
238
  ...signIn ? [genericOAuth({ config: [platformSignInProvider(signIn)] })] : [],
223
239
  ...saml.length > 0 ? [loaded(plugins.samlPlugin, "sso.saml")(saml, options.baseURL), samlGuardPlugin(samlIds)] : [],
224
240
  signInPolicyPlugin(policy),
241
+ passwordChecksPlugin(),
225
242
  setPasswordPlugin(),
243
+ passwordChangePlugin(mail),
226
244
  ...options.leakedPasswords ? [leakedPasswordsPlugin()] : [],
227
245
  goTrueSessionsPlugin({
228
246
  issuer: goTrueIssuer(options.baseURL),
@@ -335,6 +353,14 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
335
353
  [signUpPaths.create]: {
336
354
  window: 60,
337
355
  max: options.signInPerMinute ?? 5
356
+ },
357
+ [confirmationCodePaths.send]: {
358
+ window: 60,
359
+ max: 5
360
+ },
361
+ [confirmationCodePaths.verify]: {
362
+ window: 60,
363
+ max: 5
338
364
  }
339
365
  }
340
366
  },
@@ -412,9 +438,10 @@ const createAuth = (options, plugins = {}) => {
412
438
  return Object.assign(auth, {
413
439
  /**
414
440
  * 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.
441
+ * names. Only `createAdmin` with a `mobile` section uses it; `handler` and `api` never trust a Capacitor origin. A
442
+ * Capacitor origin's POST without a JSON body is refused before it is granted, as only a page sends one.
416
443
  */
417
- handleMobile: (request) => auth.handler(mobile.grant(request)),
444
+ handleMobile: async (request) => jsonOnlyOnMobile(request) ?? auth.handler(mobile.grant(request)),
418
445
  /**
419
446
  * What runs when a user is deleted: `set` a named listener that removes what the app keeps of the user elsewhere,
420
447
  * 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 };
@@ -5,7 +5,8 @@ const resetLinkLifetimeSeconds = 3600;
5
5
  const resetLinkLifetime = "1 hour";
6
6
  /**
7
7
  * Better Auth's own password reset, sending the link with `mail`. Resetting ends the user's sessions, so a reset
8
- * after a leaked password also signs out whoever used it, and `onPasswordReset` disconnects the user's MCP clients.
8
+ * after a leaked password also signs out whoever used it, and `onPasswordReset` disconnects the user's MCP clients
9
+ * and tells them their password changed.
9
10
  */
10
11
  const passwordResetOptions = (mail, { onPasswordReset } = {}) => ({
11
12
  resetPasswordTokenExpiresIn: resetLinkLifetimeSeconds,
@@ -0,0 +1,58 @@
1
+ import { isStaffSession } from "./staff-checks.js";
2
+ import { strongSignInAt } from "./strong-sign-ins.js";
3
+ import { confirmationMinutes } from "@protobase/schema/internal";
4
+ import { APIError, createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
5
+ //#region src/better-auth/recent-confirmation.ts
6
+ /** How long after signing in a session counts as recent without confirming: Better Auth's own default fresh age, a day. */
7
+ const recentSessionSeconds = 86400;
8
+ /** The refusal of an account change the session has to confirm first, which the account page answers by asking in place. */
9
+ const confirmationRequired = {
10
+ code: "STRONG_SIGN_IN_REQUIRED",
11
+ message: `Confirm it's you first: this change needs a passkey, a second step or an emailed code in the last ${confirmationMinutes} minutes.`
12
+ };
13
+ const passkeyPaths = ["/passkey/generate-register-options", "/passkey/verify-registration"];
14
+ const guardedPaths = [
15
+ ...passkeyPaths,
16
+ "/unlink-account",
17
+ "/delete-user"
18
+ ];
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;
30
+ };
31
+ /** Whether the session signed in within a day, or confirmed it's them within `confirmationMinutes`. */
32
+ const isRecentlyConfirmed = async (context, session, now = /* @__PURE__ */ new Date()) => {
33
+ if (now.getTime() - new Date(session.createdAt).getTime() < 864e5) return true;
34
+ const at = await strongSignInAt(context.internalAdapter, session.id, now);
35
+ return at !== void 0 && now.getTime() - at.getTime() <= confirmationMinutes * 6e4;
36
+ };
37
+ /**
38
+ * 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.
43
+ */
44
+ const recentConfirmationPlugin = () => ({
45
+ id: "protobase-recent-confirmation",
46
+ hooks: { before: [{
47
+ matcher: (context) => context.path !== void 0 && guardedPaths.includes(context.path),
48
+ handler: createAuthMiddleware(async (ctx) => {
49
+ const signedIn = await getSessionFromCtx(ctx);
50
+ if (!signedIn || isStaffSession(signedIn.session)) return;
51
+ if (!await asksToConfirm(ctx.context, ctx.path, ctx.body, signedIn)) return;
52
+ if (await isRecentlyConfirmed(ctx.context, signedIn.session)) return;
53
+ throw new APIError("FORBIDDEN", confirmationRequired);
54
+ })
55
+ }] }
56
+ });
57
+ //#endregion
58
+ export { confirmationRequired, isRecentlyConfirmed, recentConfirmationPlugin, recentSessionSeconds };
@@ -1,6 +1,9 @@
1
1
  import { sendAuthEmail } from "./auth-mail/send-auth-email.js";
2
- const codeLifetimeMinutes = 10;
3
- const codeLifetime = "10 minutes";
2
+ import { emailCodeMinutes } from "@protobase/schema/internal";
3
+ //#region src/better-auth/sign-in-mail.ts
4
+ /** How long an emailed code works: in seconds for the sign-in code, and in the email's words. */
5
+ const codeLifetimeSeconds = emailCodeMinutes * 60;
6
+ const codeLifetime = `${emailCodeMinutes} minutes`;
4
7
  /**
5
8
  * Better Auth's email OTP plugin for signing in and changing the address: never a sign-up, codes stored hashed, sent
6
9
  * with `mail`. A new address gets its own email, with the code that moves the account to it. The plugin's other uses
@@ -8,7 +11,7 @@ const codeLifetime = "10 minutes";
8
11
  */
9
12
  const emailCodeOptions = (mail) => ({
10
13
  disableSignUp: true,
11
- expiresIn: 600,
14
+ expiresIn: codeLifetimeSeconds,
12
15
  storeOTP: "hashed",
13
16
  changeEmail: { enabled: true },
14
17
  sendVerificationOTP: ({ email, otp, type }, ctx) => sendAuthEmail(mail, type === "change-email" ? "changeEmailCodeEmail" : "signInCodeEmail", email, {
@@ -26,7 +29,7 @@ const unusedEmailCodePaths = [
26
29
  ];
27
30
  /** The two-factor plugin's emailed code, sent with `mail`; stored hashed. */
28
31
  const twoFactorCodeOptions = (mail) => ({
29
- period: codeLifetimeMinutes,
32
+ period: emailCodeMinutes,
30
33
  storeOTP: "hashed",
31
34
  sendOTP: ({ user, otp }, ctx) => sendAuthEmail(mail, "twoFactorCodeEmail", user.email, {
32
35
  code: otp,
@@ -34,4 +37,4 @@ const twoFactorCodeOptions = (mail) => ({
34
37
  }, ctx?.context.baseURL)
35
38
  });
36
39
  //#endregion
37
- export { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths };
40
+ export { codeLifetime, codeLifetimeSeconds, emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths };
@@ -1,5 +1,6 @@
1
+ import { codeLifetimeSeconds } from "../sign-in-mail.js";
1
2
  import { APIError } from "better-auth/api";
2
- const signUpCodeLifetime = "10 minutes";
3
+ //#region src/better-auth/sign-up/sign-up-code.ts
3
4
  const allowedAttempts = 3;
4
5
  const identifierOf = (email) => `sign-up-otp-${email}`;
5
6
  /** The SHA-256 of `secret`, base64: what the store keeps of a code or a ticket. */
@@ -26,7 +27,7 @@ const issueSignUpCode = async (context, email) => {
26
27
  await context.internalAdapter.createVerificationValue({
27
28
  identifier,
28
29
  value: `${await hashOf(code)}:0`,
29
- expiresAt: new Date(Date.now() + 6e5)
30
+ expiresAt: new Date(Date.now() + codeLifetimeSeconds * 1e3)
30
31
  });
31
32
  return code;
32
33
  };
@@ -50,4 +51,4 @@ const consumeSignUpCode = async (context, email, code) => {
50
51
  throw invalidCode();
51
52
  };
52
53
  //#endregion
53
- export { consumeSignUpCode, hashOf, issueSignUpCode, signUpCodeLifetime };
54
+ export { consumeSignUpCode, hashOf, issueSignUpCode };
@@ -2,8 +2,9 @@ import { effectiveSignInPolicy } from "../sign-in-policy.js";
2
2
  import { readSignInPolicy } from "../policy-store.js";
3
3
  import { checkNewAccountPassword } from "../new-account-password.js";
4
4
  import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
5
+ import { codeLifetime } from "../sign-in-mail.js";
5
6
  import { createSignedUpAccount } from "./sign-up-account.js";
6
- import { consumeSignUpCode, issueSignUpCode, signUpCodeLifetime } from "./sign-up-code.js";
7
+ import { consumeSignUpCode, issueSignUpCode } from "./sign-up-code.js";
7
8
  import { consumeSignUpTicket, issueSignUpTicket } from "./sign-up-ticket.js";
8
9
  import * as z$1 from "zod";
9
10
  import { APIError, createAuthEndpoint } from "better-auth/api";
@@ -40,7 +41,7 @@ const signUpPlugin = ({ role, mail, defaultPolicy }) => ({
40
41
  if (await adapter.count({ model: "user" }) === 0) throw noAdminYet();
41
42
  const sending = await internalAdapter.findUserByEmail(email) ? sendAuthEmail(mail, "accountExistsEmail", email, {}, ctx.context.baseURL) : sendAuthEmail(mail, "signUpCodeEmail", email, {
42
43
  code: await issueSignUpCode(ctx.context, email),
43
- lifetime: signUpCodeLifetime
44
+ lifetime: codeLifetime
44
45
  }, ctx.context.baseURL);
45
46
  await ctx.context.runInBackgroundOrAwait(sending);
46
47
  return ctx.json({ sent: true });
@@ -55,6 +55,8 @@ const accountChanges = [
55
55
  "/passkey/verify-registration",
56
56
  "/passkey/update-passkey",
57
57
  "/passkey/delete-passkey",
58
+ "/account/confirmation-code/send",
59
+ "/account/confirmation-code/verify",
58
60
  "/invitation/accept",
59
61
  "/organization/leave",
60
62
  "/organization/transfer-ownership",
@@ -12,7 +12,7 @@ const twoFactorPaths = [
12
12
  */
13
13
  const strongSignInPaths = ["/passkey/verify-authentication", ...twoFactorPaths];
14
14
  const identifierOf = (sessionId) => `protobase-strong-sign-in-${sessionId}`;
15
- /** Notes that the session proved a passkey or a second step now; the note ends with the session. */
15
+ /** Notes that the session proved a passkey, a second step or an emailed confirmation code now; the note ends with the session. */
16
16
  const saveStrongSignIn = async (internal, session, now = /* @__PURE__ */ new Date()) => {
17
17
  const identifier = identifierOf(session.id);
18
18
  await internal.deleteVerificationByIdentifier(identifier);
@@ -22,7 +22,7 @@ const saveStrongSignIn = async (internal, session, now = /* @__PURE__ */ new Dat
22
22
  expiresAt: session.expiresAt
23
23
  });
24
24
  };
25
- /** When the session last proved a passkey or a second step, if it did and its note has not expired. */
25
+ /** When the session last proved a passkey, a second step or an emailed confirmation code, if it did and its note has not expired. */
26
26
  const strongSignInAt = async (internal, sessionId, now = /* @__PURE__ */ new Date()) => {
27
27
  const found = await internal.findVerificationValue(identifierOf(sessionId));
28
28
  if (!found || new Date(found.expiresAt) <= now) return void 0;
@@ -0,0 +1,89 @@
1
+ import { EventsOptions } from "./changes/events-options.js";
2
+ import { ResourceModel } from "@protobase/schema";
3
+ //#region src/built-in-resources.d.ts
4
+ export type BuiltInInput = {
5
+ models: ResourceModel[];
6
+ /** Whether the app declares jobs of its own. */
7
+ jobs: boolean;
8
+ /** Whether the app sends notifications. */
9
+ notifications: boolean;
10
+ events?: EventsOptions;
11
+ };
12
+ /**
13
+ * The resources and views the API adds beside the app's own, by what the app uses: Failed jobs once anything runs
14
+ * jobs (events, history, comments, AI fields, notifications or the app's jobs), Activity with history and Comments
15
+ * with comments.
16
+ */
17
+ export declare const builtInResources: ({ models, jobs, notifications, events }: BuiltInInput) => {
18
+ resources: (import("@protobase/schema").ResourceBuilder<{
19
+ readonly id: import("@protobase/schema").Field<string>;
20
+ readonly name: import("@protobase/schema").Field<string>;
21
+ readonly error: import("@protobase/schema").Field<string | null>;
22
+ readonly attempts: import("@protobase/schema").Field<number>;
23
+ readonly failed_at: import("@protobase/schema").Field<string>;
24
+ readonly created_at: import("@protobase/schema").Field<string>;
25
+ readonly key: import("@protobase/schema").Field<string | null>;
26
+ readonly payload: import("@protobase/schema").Field<import("zod").JSONType>;
27
+ readonly output: import("@protobase/schema").Field<import("zod").JSONType>;
28
+ readonly retry: import("@protobase/schema").Field<boolean>;
29
+ }, import("@protobase/schema").Ref<"id", string>> | import("@protobase/schema").ResourceBuilder<{
30
+ readonly id: import("@protobase/schema").Field<string>;
31
+ readonly resource: import("@protobase/schema").Field<string>;
32
+ readonly record_key: import("@protobase/schema").Field<string>;
33
+ readonly tenant_id: import("@protobase/schema").Field<string | null>;
34
+ readonly operation: import("@protobase/schema").Field<"create" | "update" | "delete" | "undelete">;
35
+ readonly before: import("@protobase/schema").Field<import("zod").JSONType>;
36
+ readonly after: import("@protobase/schema").Field<import("zod").JSONType>;
37
+ readonly changed: import("@protobase/schema").Field<import("zod").JSONType>;
38
+ readonly actor_id: import("@protobase/schema").Field<string | null>;
39
+ readonly actor_roles: import("@protobase/schema").Field<import("zod").JSONType>;
40
+ readonly database_role: import("@protobase/schema").Field<string | null>;
41
+ readonly transaction: import("@protobase/schema").Field<string>;
42
+ readonly at: import("@protobase/schema").Field<string>;
43
+ }, import("@protobase/schema").Ref<"id", string>> | import("@protobase/schema").ResourceBuilder<{
44
+ readonly id: import("@protobase/schema").Field<string>;
45
+ readonly resource: import("@protobase/schema").Field<string>;
46
+ readonly record_key: import("@protobase/schema").Field<string>;
47
+ readonly tenant_id: import("@protobase/schema").Field<string | null>;
48
+ readonly author_id: import("@protobase/schema").Field<string>;
49
+ readonly body: import("@protobase/schema").Field<string>;
50
+ readonly mentions: import("@protobase/schema").Field<import("zod").JSONType>;
51
+ readonly created_at: import("@protobase/schema").Field<string>;
52
+ }, import("@protobase/schema").Ref<"id", string>>)[];
53
+ views: (import("@protobase/schema").ViewBuilder<import("@protobase/schema").ResourceBuilder<{
54
+ readonly id: import("@protobase/schema").Field<string>;
55
+ readonly name: import("@protobase/schema").Field<string>;
56
+ readonly error: import("@protobase/schema").Field<string | null>;
57
+ readonly attempts: import("@protobase/schema").Field<number>;
58
+ readonly failed_at: import("@protobase/schema").Field<string>;
59
+ readonly created_at: import("@protobase/schema").Field<string>;
60
+ readonly key: import("@protobase/schema").Field<string | null>;
61
+ readonly payload: import("@protobase/schema").Field<import("zod").JSONType>;
62
+ readonly output: import("@protobase/schema").Field<import("zod").JSONType>;
63
+ readonly retry: import("@protobase/schema").Field<boolean>;
64
+ }, import("@protobase/schema").Ref<"id", string>>> | import("@protobase/schema").ViewBuilder<import("@protobase/schema").ResourceBuilder<{
65
+ readonly id: import("@protobase/schema").Field<string>;
66
+ readonly resource: import("@protobase/schema").Field<string>;
67
+ readonly record_key: import("@protobase/schema").Field<string>;
68
+ readonly tenant_id: import("@protobase/schema").Field<string | null>;
69
+ readonly operation: import("@protobase/schema").Field<"create" | "update" | "delete" | "undelete">;
70
+ readonly before: import("@protobase/schema").Field<import("zod").JSONType>;
71
+ readonly after: import("@protobase/schema").Field<import("zod").JSONType>;
72
+ readonly changed: import("@protobase/schema").Field<import("zod").JSONType>;
73
+ readonly actor_id: import("@protobase/schema").Field<string | null>;
74
+ readonly actor_roles: import("@protobase/schema").Field<import("zod").JSONType>;
75
+ readonly database_role: import("@protobase/schema").Field<string | null>;
76
+ readonly transaction: import("@protobase/schema").Field<string>;
77
+ readonly at: import("@protobase/schema").Field<string>;
78
+ }, import("@protobase/schema").Ref<"id", string>>> | import("@protobase/schema").ViewBuilder<import("@protobase/schema").ResourceBuilder<{
79
+ readonly id: import("@protobase/schema").Field<string>;
80
+ readonly resource: import("@protobase/schema").Field<string>;
81
+ readonly record_key: import("@protobase/schema").Field<string>;
82
+ readonly tenant_id: import("@protobase/schema").Field<string | null>;
83
+ readonly author_id: import("@protobase/schema").Field<string>;
84
+ readonly body: import("@protobase/schema").Field<string>;
85
+ readonly mentions: import("@protobase/schema").Field<import("zod").JSONType>;
86
+ readonly created_at: import("@protobase/schema").Field<string>;
87
+ }, import("@protobase/schema").Ref<"id", string>>>)[];
88
+ };
89
+ //#endregion
@@ -0,0 +1,31 @@
1
+ import { hasAiFields } from "./ai/ai-fields.js";
2
+ import { isCaptured } from "./changes/capture-sql.js";
3
+ import { changesSchema } from "./changes/events-options.js";
4
+ import { commentsResource, commentsView } from "./comments/record-comments.js";
5
+ import { activityResource, activityView } from "./history/activity.js";
6
+ import { failedJobs, failedJobsView } from "./jobs/failed-jobs.js";
7
+ //#region src/built-in-resources.ts
8
+ /**
9
+ * The resources and views the API adds beside the app's own, by what the app uses: Failed jobs once anything runs
10
+ * jobs (events, history, comments, AI fields, notifications or the app's jobs), Activity with history and Comments
11
+ * with comments.
12
+ */
13
+ const builtInResources = ({ models, jobs, notifications, events }) => {
14
+ const withComments = models.some((model) => model.comments);
15
+ const withJobs = jobs || models.some(isCaptured) || withComments || hasAiFields(models) || notifications;
16
+ const withHistory = models.some((model) => model.history);
17
+ return {
18
+ resources: [
19
+ ...withJobs ? [failedJobs] : [],
20
+ ...withHistory ? [activityResource(changesSchema(events), { tenant: models.some((model) => model.history && model.tenant) })] : [],
21
+ ...withComments ? [commentsResource({ tenant: models.some((model) => model.comments && model.tenant) })] : []
22
+ ],
23
+ views: [
24
+ ...withJobs ? [failedJobsView] : [],
25
+ ...withHistory ? [activityView] : [],
26
+ ...withComments ? [commentsView] : []
27
+ ]
28
+ };
29
+ };
30
+ //#endregion
31
+ export { builtInResources };