@protobase/server 0.46.7 → 0.46.9

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.
@@ -111,6 +111,12 @@ export type CreateAuthOptions = {
111
111
  * Supabase Auth's. The first sign-in with one stores the password again as Better Auth hashes it. Default none.
112
112
  */
113
113
  passwordHashes?: PasswordHash[];
114
+ /**
115
+ * Refuses a new password that appears in a known data breach, by Have I Been Pwned's password list: at sign-up, from
116
+ * an invitation, and when a password is set, changed, reset or given by an admin. Only the first five characters of
117
+ * the password's SHA-1 hash leave the server. While the list cannot be reached, setting a password fails. Default off.
118
+ */
119
+ leakedPasswords?: boolean;
114
120
  /**
115
121
  * The identity provider the operator's staff sign in with to sign in as people of this app, for support. Defaults
116
122
  * to the one the platform passes in `PROTOBASE_OPERATOR_ISSUER`, `PROTOBASE_OPERATOR_CLIENT_ID` and
@@ -2587,6 +2593,18 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
2587
2593
  RESOLVED_USER_INVALID: import("better-auth").RawError<"RESOLVED_USER_INVALID">;
2588
2594
  };
2589
2595
  options: import("@better-auth/passkey").PasskeyOptions | undefined;
2596
+ } | {
2597
+ id: "protobase-strong-sign-ins";
2598
+ hooks: {
2599
+ before: {
2600
+ matcher: (context: import("better-auth").HookEndpointContext) => boolean;
2601
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
2602
+ }[];
2603
+ after: {
2604
+ matcher: (context: import("better-auth").HookEndpointContext) => boolean;
2605
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
2606
+ }[];
2607
+ };
2590
2608
  } | {
2591
2609
  id: "email-otp";
2592
2610
  version: string;
@@ -4511,6 +4529,17 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
4511
4529
  status: boolean;
4512
4530
  }>;
4513
4531
  };
4532
+ } | {
4533
+ id: "protobase-leaked-passwords";
4534
+ hooks: {
4535
+ before: {
4536
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
4537
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
4538
+ }[];
4539
+ };
4540
+ $ERROR_CODES: {
4541
+ PASSWORD_COMPROMISED: import("better-auth").RawError<"PASSWORD_COMPROMISED">;
4542
+ };
4514
4543
  } | {
4515
4544
  id: "protobase-gotrue-sessions";
4516
4545
  endpoints: {
@@ -8790,6 +8819,8 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
8790
8819
  /** The policy until an admin saves one. */
8791
8820
  signInPolicy: SignInPolicy;
8792
8821
  passwordReset: boolean;
8822
+ /** New passwords are checked against Have I Been Pwned's list of leaked ones (`leakedPasswords`). */
8823
+ leakedPasswords: boolean;
8793
8824
  /** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
8794
8825
  signUp: boolean;
8795
8826
  /** The role of accounts made by open sign-up, while it is offered. */
@@ -13,6 +13,9 @@ import { platformMailer } from "../mail/platform-mailer.js";
13
13
  import { readOperatorSettings, resolveOperator } from "./operator-provider.js";
14
14
  import { defaultInvitationUrl } from "./invitations/invitation-link-hook.js";
15
15
  import { lastAdminGuardPlugin } from "./last-admin-guard.js";
16
+ import { setPasswordPath, setPasswordPlugin } from "./set-password.js";
17
+ import { signUpPaths, signUpPlugin } from "./sign-up/sign-up-plugin.js";
18
+ import { leakedPasswordsPlugin } from "./leaked-passwords.js";
16
19
  import { userDeletion } from "./user-deletion.js";
17
20
  import { revokeMcpGrants } from "./mcp/revoke-grants.js";
18
21
  import { coreNames, pluginNames } from "./snake-case-names.js";
@@ -25,7 +28,6 @@ import { resolveOrganizations } from "./organizations/options.js";
25
28
  import { passkeyHosts, readPasskeyDomain, resolvePasskeyDomain } from "./passkey-domain.js";
26
29
  import { passkeyPlugin } from "./passkey-plugin.js";
27
30
  import { passwordResetOptions } from "./password-reset.js";
28
- import { setPasswordPath, setPasswordPlugin } from "./set-password.js";
29
31
  import { rolesPlugin } from "./roles/roles-plugin.js";
30
32
  import { platformSignInFor, platformSignInProvider, readPlatformSignIn, resolvePlatformSignIn } from "./platform-sign-in.js";
31
33
  import { appSignInPolicy } from "./app-sign-in-policy.js";
@@ -33,10 +35,10 @@ import { assertNoEndpointKeyClashes } from "./endpoint-key-clashes.js";
33
35
  import { emailInDomains, resolveSamlProviders, samlDisabledPaths, samlGuardPlugin, samlPlugin } from "./saml-sign-in.js";
34
36
  import { noAccountMailPlugin } from "./no-account-mail.js";
35
37
  import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./sign-in-mail.js";
36
- import { signUpPaths, signUpPlugin } from "./sign-up/sign-up-plugin.js";
37
38
  import { confirmedOnCreate, confirmedOnUpdate, emailConfirmedField } from "./email-confirmed.js";
38
39
  import { rehashPasswordsPlugin, verifyAcceptedHash } from "./password-hashes.js";
39
40
  import { staffSignInPlugin } from "./staff-plugin.js";
41
+ import { strongSignInsPlugin } from "./strong-sign-ins.js";
40
42
  import { twoFactorAfterPasswordOrCode } from "./two-factor-challenge.js";
41
43
  import { metadataField, userFieldsNamed, userModel, userTable, withUserColumns } from "./user-table.js";
42
44
  import { usersPlugin } from "./users/users-plugin.js";
@@ -172,6 +174,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
172
174
  formerDomain: hosts[0],
173
175
  origins: appOrigins(options.baseURL, origins)
174
176
  }),
177
+ strongSignInsPlugin(),
175
178
  ...mail ? [emailOTP(emailCodeOptions(mail)), noAccountMailPlugin(mail)] : [],
176
179
  ...mail && signUpRole ? [signUpPlugin({
177
180
  role: signUpRole,
@@ -182,6 +185,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
182
185
  ...saml.length > 0 ? [samlPlugin(saml, options.baseURL), samlGuardPlugin(samlIds)] : [],
183
186
  signInPolicyPlugin(policy),
184
187
  setPasswordPlugin(),
188
+ ...options.leakedPasswords ? [leakedPasswordsPlugin()] : [],
185
189
  goTrueSessionsPlugin({
186
190
  issuer: goTrueIssuer(options.baseURL),
187
191
  jwt: jwtOptions,
@@ -384,6 +388,8 @@ const createAuth = (options) => {
384
388
  /** The policy until an admin saves one. */
385
389
  signInPolicy,
386
390
  passwordReset: Boolean(mailer),
391
+ /** New passwords are checked against Have I Been Pwned's list of leaked ones (`leakedPasswords`). */
392
+ leakedPasswords: Boolean(options.leakedPasswords),
387
393
  /** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
388
394
  signUp: Boolean(signUpRole && mailer),
389
395
  /** The role of accounts made by open sign-up, while it is offered. */
@@ -0,0 +1,47 @@
1
+ import { passwordLength } from "./new-account-password.js";
2
+ import { setPasswordPath } from "./set-password.js";
3
+ import { signUpPaths } from "./sign-up/sign-up-plugin.js";
4
+ import { APIError, createAuthMiddleware } from "better-auth/api";
5
+ import { haveIBeenPwned, isPasswordCompromised } from "better-auth/plugins/haveibeenpwned";
6
+ //#region src/better-auth/leaked-passwords.ts
7
+ const { PASSWORD_COMPROMISED } = haveIBeenPwned().$ERROR_CODES;
8
+ const leakedPasswordMessage = "This password appears in a known data breach. Choose a different one.";
9
+ const passwordFields = {
10
+ "/sign-up/email": "password",
11
+ "/change-password": "newPassword",
12
+ "/reset-password": "newPassword",
13
+ "/admin/create-user": "password",
14
+ "/admin/set-user-password": "newPassword",
15
+ [setPasswordPath]: "newPassword",
16
+ [signUpPaths.create]: "password",
17
+ "/invitation/sign-up": "password"
18
+ };
19
+ /** Refuses a password from the Have I Been Pwned corpus with `400 PASSWORD_COMPROMISED`, as Better Auth's plugin does. */
20
+ const rejectLeakedPassword = async (password) => {
21
+ if (!await isPasswordCompromised(password)) return;
22
+ throw APIError.from("BAD_REQUEST", {
23
+ code: PASSWORD_COMPROMISED.code,
24
+ message: leakedPasswordMessage
25
+ });
26
+ };
27
+ /**
28
+ * Better Auth's haveIBeenPwned check, on every endpoint of `passwordFields`. It runs before the endpoint, where the
29
+ * plugin itself checks only once the password is hashed: admin account creation, invitation sign-up and sign-up have
30
+ * made the account or used the ticket by then. Only the first five characters of the password's SHA-1 hash leave the
31
+ * server; a password outside the allowed lengths is left to the endpoint, which refuses it without a lookup. When the
32
+ * list cannot be reached, the request fails with a 500.
33
+ */
34
+ const leakedPasswordsPlugin = () => ({
35
+ id: "protobase-leaked-passwords",
36
+ hooks: { before: [{
37
+ matcher: (ctx) => ctx.path !== void 0 && Object.hasOwn(passwordFields, ctx.path),
38
+ handler: createAuthMiddleware(async (ctx) => {
39
+ const password = ctx.body?.[passwordFields[ctx.path]];
40
+ if (typeof password !== "string" || password.length < passwordLength.min || password.length > passwordLength.max) return;
41
+ await rejectLeakedPassword(password);
42
+ })
43
+ }] },
44
+ $ERROR_CODES: { PASSWORD_COMPROMISED }
45
+ });
46
+ //#endregion
47
+ export { leakedPasswordsPlugin, rejectLeakedPassword };
@@ -2,9 +2,9 @@ import { pluginNames } from "./snake-case-names.js";
2
2
  import * as z$1 from "zod";
3
3
  import { APIError, createAuthEndpoint, getSessionFromCtx } from "better-auth/api";
4
4
  import { generateRandomString } from "better-auth/crypto";
5
+ import { setSessionCookie } from "better-auth/cookies";
5
6
  import { PASSKEY_ERROR_CODES, passkey } from "@better-auth/passkey";
6
7
  import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
7
- import { setSessionCookie } from "better-auth/cookies";
8
8
  //#region src/better-auth/passkey-plugin.ts
9
9
  const challengeCookie = "better-auth-passkey";
10
10
  const challengeSeconds = 300;
@@ -0,0 +1,64 @@
1
+ import { isStaffSession } from "./staff-checks.js";
2
+ import { APIError, createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
3
+ //#region src/better-auth/strong-sign-ins.ts
4
+ const twoFactorPaths = [
5
+ "/two-factor/verify-totp",
6
+ "/two-factor/verify-otp",
7
+ "/two-factor/verify-backup-code"
8
+ ];
9
+ /**
10
+ * The Better Auth endpoints that prove a passkey or a second step: each signs in a new session, or confirms the one the
11
+ * request's cookie names, as the second step does for someone signed in already.
12
+ */
13
+ const strongSignInPaths = ["/passkey/verify-authentication", ...twoFactorPaths];
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. */
16
+ const saveStrongSignIn = async (internal, session, now = /* @__PURE__ */ new Date()) => {
17
+ const identifier = identifierOf(session.id);
18
+ await internal.deleteVerificationByIdentifier(identifier);
19
+ await internal.createVerificationValue({
20
+ identifier,
21
+ value: now.toISOString(),
22
+ expiresAt: session.expiresAt
23
+ });
24
+ };
25
+ /** When the session last proved a passkey or a second step, if it did and its note has not expired. */
26
+ const strongSignInAt = async (internal, sessionId, now = /* @__PURE__ */ new Date()) => {
27
+ const found = await internal.findVerificationValue(identifierOf(sessionId));
28
+ if (!found || new Date(found.expiresAt) <= now) return void 0;
29
+ return new Date(found.value);
30
+ };
31
+ /**
32
+ * Records the sessions that sign in, or confirm, with a passkey or a second step, since Better Auth's sessions do not
33
+ * say how they began: a reveal that asks for a recent strong sign-in reads it (`strongSignInAt`). The session is the
34
+ * one the answer signed in, or else the one the request's cookie names. A staff session is never recorded: staff
35
+ * proved nothing as the person. Nor is the code that turns two-factor authentication on, which a session alone may
36
+ * ask for on an account without a password: it proves the authenticator app just added, not the person.
37
+ */
38
+ const strongSignInsPlugin = () => {
39
+ const turningOn = /* @__PURE__ */ new WeakSet();
40
+ return {
41
+ id: "protobase-strong-sign-ins",
42
+ hooks: {
43
+ before: [{
44
+ matcher: (context) => context.path !== void 0 && twoFactorPaths.includes(context.path),
45
+ handler: createAuthMiddleware(async (ctx) => {
46
+ const signedIn = await getSessionFromCtx(ctx);
47
+ if (signedIn && !signedIn.user.twoFactorEnabled && ctx.headers) turningOn.add(ctx.headers);
48
+ })
49
+ }],
50
+ after: [{
51
+ matcher: (context) => context.path !== void 0 && strongSignInPaths.includes(context.path),
52
+ handler: createAuthMiddleware(async (ctx) => {
53
+ if (ctx.context.returned instanceof APIError) return;
54
+ if (ctx.headers && turningOn.has(ctx.headers)) return;
55
+ const session = ctx.context.newSession?.session ?? (await getSessionFromCtx(ctx))?.session;
56
+ if (!session || isStaffSession(session)) return;
57
+ await saveStrongSignIn(ctx.context.internalAdapter, session);
58
+ })
59
+ }]
60
+ }
61
+ };
62
+ };
63
+ //#endregion
64
+ export { saveStrongSignIn, strongSignInAt, strongSignInPaths, strongSignInsPlugin };
@@ -4,6 +4,7 @@ import { parseRoles } from "./parse-roles.js";
4
4
  import { effectiveRoles, globalRoles } from "./organizations/app-roles.js";
5
5
  import { findMember, findOrganization } from "./organizations/store.js";
6
6
  import { isStaffSession } from "./staff-checks.js";
7
+ import { strongSignInAt } from "./strong-sign-ins.js";
7
8
  //#region src/better-auth/token-standing.ts
8
9
  /**
9
10
  * The sign-in a session row stands for: staff started it when the admin plugin's `impersonatedBy` names someone. A row
@@ -53,7 +54,8 @@ const checkUserStanding = async (context, claims, organizations, now = /* @__PUR
53
54
  * Whether a token still stands, read from the store on every request: its session is not signed out or expired, and
54
55
  * its user still stands (`checkUserStanding`). A token that fails is a 401, so the client asks its session for a new
55
56
  * one, which carries the roles as they are now or is refused. Answers the sign-in the token's session stands for
56
- * (`signInOf`), `undefined` for a token without one.
57
+ * (`signInOf`), `undefined` for a token without one; the user's own one also reads, when asked, when it last proved a
58
+ * passkey or a second step (`strongSignInAt`).
57
59
  */
58
60
  const checkTokenStanding = async (context, claims, organizations, now = /* @__PURE__ */ new Date()) => {
59
61
  const session = claims.sessionId === void 0 ? void 0 : await context.adapter.findOne({
@@ -65,7 +67,12 @@ const checkTokenStanding = async (context, claims, organizations, now = /* @__PU
65
67
  });
66
68
  if (claims.sessionId !== void 0 && (!session || session.userId !== claims.userId || session.expiresAt <= now)) throw revoked();
67
69
  await checkUserStanding(context, claims, organizations, now);
68
- return session ? signInOf(session, claims.sessionId) : void 0;
70
+ const signIn = session ? signInOf(session, claims.sessionId) : void 0;
71
+ if (!signIn || signIn.staff) return signIn;
72
+ return {
73
+ ...signIn,
74
+ strongAt: () => strongSignInAt(context.internalAdapter, claims.sessionId)
75
+ };
69
76
  };
70
77
  //#endregion
71
78
  export { checkTokenStanding, checkUserStanding, signInOf, tokenClaims };
@@ -3,6 +3,7 @@ import { readSignInPolicy } from "../better-auth/policy-store.js";
3
3
  import { checkNewAccountPassword } from "../better-auth/new-account-password.js";
4
4
  import { createSignedUpAccount } from "../better-auth/sign-up/sign-up-account.js";
5
5
  import { consumeSignUpTicket } from "../better-auth/sign-up/sign-up-ticket.js";
6
+ import { rejectLeakedPassword } from "../better-auth/leaked-passwords.js";
6
7
  import { GoTrueError } from "./errors.js";
7
8
  import { callBetterAuth, signedInToken } from "./forward.js";
8
9
  import { hasPendingSignUp, savePendingSignUp, takePendingSignUp } from "./pending-sign-up.js";
@@ -79,6 +80,7 @@ const emailCodeRoutes = ({ auth, context, sessionOf }) => {
79
80
  if (!auth.signUp) throw signUpDisabled();
80
81
  checkMetadata(data);
81
82
  checkNewAccountPassword(await signUpPolicy(), password);
83
+ if (auth.leakedPasswords) await rejectLeakedPassword(password);
82
84
  await sendSignUpCode(c.req.raw, email);
83
85
  await savePendingSignUp(await context(), email, {
84
86
  passwordHash: await (await context()).password.hash(password),
@@ -35,7 +35,8 @@ const known = {
35
35
  BANNED_USER: [403, "user_banned"],
36
36
  NO_ADMIN_YET: [422, "signup_disabled"],
37
37
  PASSWORD_LENGTH: [422, "weak_password"],
38
- PASSWORD_REQUIRED: [422, "weak_password"]
38
+ PASSWORD_REQUIRED: [422, "weak_password"],
39
+ PASSWORD_COMPROMISED: [422, "weak_password"]
39
40
  };
40
41
  /** A refusal of Better Auth, by its status and body, as GoTrue's: a known code becomes GoTrue's, any other its own in lower case. */
41
42
  const fromBetterAuth = (status, body) => {
@@ -2,6 +2,7 @@ import { effectiveSignInPolicy } from "../better-auth/sign-in-policy.js";
2
2
  import { readSignInPolicy } from "../better-auth/policy-store.js";
3
3
  import { goTrueUser, userMetadata } from "./user-json.js";
4
4
  import { checkNewAccountPassword } from "../better-auth/new-account-password.js";
5
+ import { rejectLeakedPassword } from "../better-auth/leaked-passwords.js";
5
6
  import { revokeMcpGrants } from "../better-auth/mcp/revoke-grants.js";
6
7
  import { goTrueTokens } from "./access-tokens.js";
7
8
  import { GoTrueError } from "./errors.js";
@@ -75,6 +76,7 @@ const userRoutes = ({ auth, context }) => {
75
76
  if (data !== void 0 && !auth.userMetadata) throw new GoTrueError(400, "validation_failed", "This app keeps no user metadata: leave data out");
76
77
  if (password !== void 0) {
77
78
  checkNewAccountPassword(effectiveSignInPolicy((await readSignInPolicy(ctx.adapter, auth.signInPolicy)).policy, { mail: auth.mail }), password);
79
+ if (auth.leakedPasswords) await rejectLeakedPassword(password);
78
80
  const hash = await ctx.password.hash(password);
79
81
  if (await ctx.internalAdapter.findCredentialAccount(user.id)) await ctx.internalAdapter.updatePassword(user.id, hash);
80
82
  else await ctx.internalAdapter.linkAccount({
@@ -192,7 +192,7 @@ const resourcePaths = (access) => {
192
192
  ...auth,
193
193
  404: error("NotFound")
194
194
  };
195
- const sensitive = Object.values(access.readable.fields).filter((field) => field.sensitive).map((field) => field.name);
195
+ const sensitive = [...Object.values(access.readable.fields).filter((field) => field.sensitive).map((field) => field.name), ...Object.keys(entry.source.computedReveals ?? {})];
196
196
  const aiActions = (pickView(access.deps.views, access.session.user.roles, entry.name)?.actions ?? []).filter((action) => action.run?.kind === "ai").map((action) => action.name);
197
197
  const paths = {
198
198
  [`/${entry.name}`]: {
@@ -330,7 +330,7 @@ const resourcePaths = (access) => {
330
330
  operationId: `reveal_${entry.name}`,
331
331
  tags: tag,
332
332
  summary: `Reveal a sensitive field of a ${entry.name} record`,
333
- description: "A sensitive field never comes with the record. This returns its value for one record, with the same access as a get, and publishes an audit event first.",
333
+ description: "A sensitive field never comes with the record. This returns its value for one record, or makes the value of a computed reveal, with the same access as a get, and publishes an audit event first. A field that needs a recent strong sign-in is a 403 `strong-sign-in-required` until the caller confirms it's them with a passkey or a second step.",
334
334
  parameters: [param("key")],
335
335
  requestBody: {
336
336
  required: true,
@@ -356,7 +356,8 @@ const resourcePaths = (access) => {
356
356
  }
357
357
  })
358
358
  },
359
- ...keyErrors
359
+ ...keyErrors,
360
+ 403: error("Forbidden")
360
361
  }
361
362
  } } },
362
363
  ...aiActions.length > 0 && { [`/${entry.name}/{key}:generate`]: { post: {
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.46.7";
2
+ var version = "0.46.9";
3
3
  //#endregion
4
4
  export { version };
@@ -1,7 +1,7 @@
1
1
  import { SubscriptionDefinition } from "./webhooks/subscription.js";
2
2
  import "./index.js";
3
3
  import { PageDefinition } from "@protobase/layout";
4
- import { AccessSource, BottomBarSource, EventMatch, FileProcessor, UserMenuSource, ViewModel } from "@protobase/schema";
4
+ import { AccessSource, BottomBarSource, ComputedReveal, EventMatch, FileProcessor, UserMenuSource, ViewModel } from "@protobase/schema";
5
5
  import { z } from "zod";
6
6
  //#region src/resource-source.d.ts
7
7
  type FieldSource = {
@@ -24,6 +24,8 @@ export type ResourceSource = AccessSource & {
24
24
  message: string;
25
25
  }> | undefined)[];
26
26
  recordSchema(): z.ZodType;
27
+ /** Its values revealed like sensitive fields but computed when asked (`.reveals()`), by name. */
28
+ readonly computedReveals?: Readonly<Record<string, ComputedReveal<any>>>;
27
29
  /** The code predicates of its events, by event name. */
28
30
  eventMatches?(): Record<string, EventMatch<any>>;
29
31
  };
@@ -1,4 +1,4 @@
1
- import { badRequest, notFound } from "./problem.js";
1
+ import { HttpProblem, badRequest, notFound } from "./problem.js";
2
2
  import { tenantScope } from "./tenant.js";
3
3
  import { requestAccess, requireOperation } from "./request-access.js";
4
4
  import { readTransaction } from "./transactions.js";
@@ -16,15 +16,76 @@ const revealedValue = (deps, model, field, record) => {
16
16
  field: field.name
17
17
  });
18
18
  };
19
+ /** The refusal of a reveal that needs a strong sign-in more recent than the caller's, which the app answers by asking them to confirm it's them. */
20
+ const strongSignInRequired = (minutes) => new HttpProblem(403, "strong-sign-in-required", "Forbidden", `Confirm it's you with a passkey or a second step: this needs one in the last ${minutes} minutes.`, { minutes });
21
+ const requireStrongSignIn = async (session, minutes, now = /* @__PURE__ */ new Date()) => {
22
+ const at = session.signIn && !session.signIn.staff && !session.client ? await session.signIn.strongAt?.() : void 0;
23
+ if (at && now.getTime() - at.getTime() <= minutes * 6e4) return;
24
+ throw strongSignInRequired(minutes);
25
+ };
26
+ const computedRevealFor = async (access, name) => {
27
+ const reveals = access.entry.source.computedReveals ?? {};
28
+ const reveal = Object.hasOwn(reveals, name) ? reveals[name] : void 0;
29
+ if (!reveal?.read) return reveal;
30
+ const { session } = access;
31
+ return await reveal.read({
32
+ user: {
33
+ id: session.user.id,
34
+ roles: session.user.roles
35
+ },
36
+ operation: "read",
37
+ model: access.full,
38
+ ...session.organization && { organization: session.organization }
39
+ }) === true ? reveal : void 0;
40
+ };
41
+ const publishReveal = async (deps, entry, session, request, field) => {
42
+ const tenant = tenantScope(entry, session)?.tenantValue;
43
+ await deps.audit.publish({
44
+ type: "field.revealed",
45
+ at: (/* @__PURE__ */ new Date()).toISOString(),
46
+ actor: {
47
+ id: session.user.id,
48
+ roles: [...session.user.roles]
49
+ },
50
+ ...tenant !== void 0 && { tenant },
51
+ resource: entry.name,
52
+ recordKey: encodeKey(request.key),
53
+ field,
54
+ origin: request.origin
55
+ });
56
+ };
57
+ const revealComputed = async (access, reveal, request, name) => {
58
+ const { deps, entry, session } = access;
59
+ const fields = Object.values(access.model.fields);
60
+ const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchReadable(trx, access, request.key, "hide", { fields }));
61
+ if (!found) throw notFound(`No ${entry.name} with this key`);
62
+ if (reveal.strongSignIn) await requireStrongSignIn(session, reveal.strongSignIn.minutes ?? 15);
63
+ const record = Object.fromEntries(fields.map((field) => [field.name, found.record[field.name] ?? null]));
64
+ const value = await reveal.value(record, {
65
+ user: {
66
+ id: session.user.id,
67
+ roles: session.user.roles
68
+ },
69
+ ...session.organization && { organization: session.organization }
70
+ });
71
+ await publishReveal(deps, entry, session, request, name);
72
+ return {
73
+ field: name,
74
+ value: value ?? null
75
+ };
76
+ };
19
77
  /**
20
- * The value of one sensitive field of one record, for a caller who may read the record and the field: the same tenant,
21
- * row filter and record-level rule as a get. The reveal is published to the audit queue before the value is returned;
22
- * when publishing fails, so does the reveal.
78
+ * The value of one sensitive field of one record, or of one of its computed reveals (`.reveals()`), for a caller who may
79
+ * read the record and the field: the same tenant, row filter and record-level rule as a get. A field or reveal that
80
+ * needs a recent strong sign-in is refused without one (`strongSignInRequired`). The reveal is published to the audit
81
+ * queue before the value is returned; when publishing fails, so does the reveal.
23
82
  */
24
83
  const revealField = async (deps, entry, session, request) => {
25
84
  const access = await requestAccess(deps, entry, session);
26
85
  requireOperation(access, "read");
27
86
  const name = typeof request.field === "string" ? request.field : "";
87
+ const computed = await computedRevealFor(access, name);
88
+ if (computed) return revealComputed(access, computed, request, name);
28
89
  const field = Object.hasOwn(access.readable.fields, name) ? access.readable.fields[name] : void 0;
29
90
  if (!field) throw badRequest("invalid-parameter", `Unknown field "${name}"`);
30
91
  if (!field.sensitive) throw badRequest("invalid-parameter", `"${name}" is not sensitive; it comes with the record`);
@@ -33,25 +94,13 @@ const revealField = async (deps, entry, session, request) => {
33
94
  const keyFields = [...full.primaryKey, ...tenantField].map((part) => full.fields[part]);
34
95
  const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchReadable(trx, access, request.key, "hide", { fields: [...keyFields, full.fields[name]] }));
35
96
  if (!found) throw notFound(`No ${entry.name} with this key`);
97
+ if (field.strongSignIn) await requireStrongSignIn(session, field.strongSignIn.minutes);
36
98
  const value = revealedValue(deps, access.full, full.fields[name], found.record);
37
- const tenant = tenantScope(entry, session)?.tenantValue;
38
- await deps.audit.publish({
39
- type: "field.revealed",
40
- at: (/* @__PURE__ */ new Date()).toISOString(),
41
- actor: {
42
- id: session.user.id,
43
- roles: [...session.user.roles]
44
- },
45
- ...tenant !== void 0 && { tenant },
46
- resource: entry.name,
47
- recordKey: encodeKey(request.key),
48
- field: name,
49
- origin: request.origin
50
- });
99
+ await publishReveal(deps, entry, session, request, name);
51
100
  return {
52
101
  field: name,
53
102
  value
54
103
  };
55
104
  };
56
105
  //#endregion
57
- export { revealField };
106
+ export { revealField, strongSignInRequired };
package/dist/types.d.ts CHANGED
@@ -38,11 +38,13 @@ export type Session = {
38
38
  /**
39
39
  * The user's own sign-in this request comes with: its session id, when it has one, and whether staff started it as
40
40
  * the user. An authenticator sets it only when it knows; without it, what only the user may do, such as registering
41
- * their devices for notifications, is refused.
41
+ * their devices for notifications, is refused. `strongAt` reads when the sign-in last proved a passkey or a second
42
+ * step, `undefined` when it never did; without it, a reveal that needs a recent strong sign-in is refused.
42
43
  */
43
44
  signIn?: {
44
45
  session?: string;
45
46
  staff: boolean;
47
+ strongAt?: () => Promise<Date | undefined>;
46
48
  };
47
49
  };
48
50
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.46.7",
3
+ "version": "0.46.9",
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": {
@@ -36,9 +36,9 @@
36
36
  "@better-auth/passkey": "^1.7.12",
37
37
  "@better-auth/sso": "^1.7.12",
38
38
  "@modelcontextprotocol/server": "^2.3.1",
39
- "@protobase/layout": "0.46.7",
40
- "@protobase/query": "0.46.7",
41
- "@protobase/schema": "0.46.7",
39
+ "@protobase/layout": "0.46.9",
40
+ "@protobase/query": "0.46.9",
41
+ "@protobase/schema": "0.46.9",
42
42
  "@react-email/render": "^2.1.0",
43
43
  "@scalar/hono-api-reference": "^0.12.10",
44
44
  "@sentry/core": "^11.7.0",