@protobase/server 0.46.8 → 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.
@@ -2593,6 +2593,18 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
2593
2593
  RESOLVED_USER_INVALID: import("better-auth").RawError<"RESOLVED_USER_INVALID">;
2594
2594
  };
2595
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
+ };
2596
2608
  } | {
2597
2609
  id: "email-otp";
2598
2610
  version: string;
@@ -38,6 +38,7 @@ import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./
38
38
  import { confirmedOnCreate, confirmedOnUpdate, emailConfirmedField } from "./email-confirmed.js";
39
39
  import { rehashPasswordsPlugin, verifyAcceptedHash } from "./password-hashes.js";
40
40
  import { staffSignInPlugin } from "./staff-plugin.js";
41
+ import { strongSignInsPlugin } from "./strong-sign-ins.js";
41
42
  import { twoFactorAfterPasswordOrCode } from "./two-factor-challenge.js";
42
43
  import { metadataField, userFieldsNamed, userModel, userTable, withUserColumns } from "./user-table.js";
43
44
  import { usersPlugin } from "./users/users-plugin.js";
@@ -173,6 +174,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
173
174
  formerDomain: hosts[0],
174
175
  origins: appOrigins(options.baseURL, origins)
175
176
  }),
177
+ strongSignInsPlugin(),
176
178
  ...mail ? [emailOTP(emailCodeOptions(mail)), noAccountMailPlugin(mail)] : [],
177
179
  ...mail && signUpRole ? [signUpPlugin({
178
180
  role: signUpRole,
@@ -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 };
@@ -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.8";
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.8",
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.8",
40
- "@protobase/query": "0.46.8",
41
- "@protobase/schema": "0.46.8",
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",