@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.
- package/dist/better-auth/create-auth.d.ts +12 -0
- package/dist/better-auth/create-auth.js +2 -0
- package/dist/better-auth/strong-sign-ins.js +64 -0
- package/dist/better-auth/token-standing.js +9 -2
- package/dist/openapi/resource-paths.js +4 -3
- package/dist/package.js +1 -1
- package/dist/resource-source.d.ts +3 -1
- package/dist/reveal-service.js +68 -19
- package/dist/types.d.ts +3 -1
- package/package.json +4 -4
|
@@ -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
|
-
|
|
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,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
|
};
|
package/dist/reveal-service.js
CHANGED
|
@@ -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,
|
|
21
|
-
* row filter and record-level rule as a get.
|
|
22
|
-
*
|
|
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
|
-
|
|
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.
|
|
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.
|
|
40
|
-
"@protobase/query": "0.46.
|
|
41
|
-
"@protobase/schema": "0.46.
|
|
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",
|