@protobase/server 0.33.4 → 0.34.0

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 (77) hide show
  1. package/dist/assistant/query-tools.js +1 -1
  2. package/dist/auth/jwt.d.ts +15 -1
  3. package/dist/auth/jwt.js +41 -5
  4. package/dist/better-auth/authenticator.d.ts +16 -1
  5. package/dist/better-auth/authenticator.js +41 -24
  6. package/dist/better-auth/create-auth.d.ts +3063 -364
  7. package/dist/better-auth/create-auth.js +36 -19
  8. package/dist/better-auth/mcp/connected-apps.d.ts +13 -0
  9. package/dist/better-auth/mcp/connected-apps.js +86 -0
  10. package/dist/better-auth/mcp/consent-check.js +38 -0
  11. package/dist/better-auth/mcp/consent-reference.js +16 -0
  12. package/dist/better-auth/mcp/discovery-routes.js +19 -0
  13. package/dist/better-auth/mcp/grant-reference.js +25 -0
  14. package/dist/better-auth/mcp/mcp-plugins.d.ts +13 -0
  15. package/dist/better-auth/mcp/mcp-plugins.js +107 -0
  16. package/dist/better-auth/mcp/mcp-token-claims.js +39 -0
  17. package/dist/better-auth/mcp/request-defaults.js +57 -0
  18. package/dist/better-auth/mcp/revoke-grants.js +75 -0
  19. package/dist/better-auth/mcp/setup-gate.js +38 -0
  20. package/dist/better-auth/mcp/signed-query-guard.js +29 -0
  21. package/dist/better-auth/origins.d.ts +7 -0
  22. package/dist/better-auth/origins.js +8 -1
  23. package/dist/better-auth/password-reset.js +4 -3
  24. package/dist/better-auth/policy-plugin.js +1 -24
  25. package/dist/better-auth/sign-in-enforcement.js +6 -1
  26. package/dist/better-auth/sign-in-setup.js +38 -0
  27. package/dist/better-auth/snake-case-names.js +102 -1
  28. package/dist/better-auth/staff-checks.js +9 -2
  29. package/dist/better-auth/staff-plugin.js +9 -2
  30. package/dist/better-auth/status-route.js +2 -1
  31. package/dist/better-auth/token-claims.js +19 -0
  32. package/dist/better-auth/users.d.ts +4 -1
  33. package/dist/better-auth/users.js +6 -1
  34. package/dist/check-roles.js +1 -1
  35. package/dist/create-admin.js +8 -0
  36. package/dist/deps.d.ts +1 -0
  37. package/dist/encryption/apply.d.ts +40 -0
  38. package/dist/encryption/apply.js +99 -0
  39. package/dist/encryption/binding.d.ts +2 -0
  40. package/dist/encryption/binding.js +53 -0
  41. package/dist/encryption/capture-off.js +12 -0
  42. package/dist/encryption/cascading-keys.d.ts +9 -0
  43. package/dist/encryption/cascading-keys.js +21 -0
  44. package/dist/encryption/envelope-open.d.ts +5 -0
  45. package/dist/encryption/envelope-open.js +57 -0
  46. package/dist/encryption/envelope-seal.js +38 -0
  47. package/dist/encryption/keyring.js +60 -0
  48. package/dist/encryption/mask-stored.js +16 -0
  49. package/dist/encryption/rebind-check.js +28 -0
  50. package/dist/encryption/remnants.js +53 -0
  51. package/dist/encryption/runtime.d.ts +1 -0
  52. package/dist/encryption/runtime.js +18 -0
  53. package/dist/encryption/scan.d.ts +4 -0
  54. package/dist/encryption/scan.js +61 -0
  55. package/dist/encryption/seal-values.js +30 -0
  56. package/dist/encryption/shared-columns.js +19 -0
  57. package/dist/encryption/status.d.ts +39 -0
  58. package/dist/encryption/status.js +92 -0
  59. package/dist/encryption/stored-value.js +16 -0
  60. package/dist/encryption/tools.d.ts +26 -0
  61. package/dist/encryption/tools.js +34 -0
  62. package/dist/forms/check-forms.js +1 -1
  63. package/dist/gotrue/user-routes.js +6 -0
  64. package/dist/index.d.ts +9 -5
  65. package/dist/index.js +3 -2
  66. package/dist/records.js +3 -3
  67. package/dist/request-access.js +9 -5
  68. package/dist/rest/encrypted-columns.js +37 -0
  69. package/dist/rest/table-routes.js +25 -13
  70. package/dist/reveal-service.js +17 -3
  71. package/dist/types.d.ts +5 -0
  72. package/dist/validate-body.js +4 -2
  73. package/dist/webhooks/subscriber-view.js +14 -1
  74. package/dist/write-pipeline.js +14 -2
  75. package/dist/write-shared.js +7 -3
  76. package/dist/write-update.js +12 -2
  77. package/package.json +11 -7
@@ -0,0 +1,29 @@
1
+ import { APIError, createAuthMiddleware } from "better-auth/api";
2
+ //#region src/better-auth/mcp/signed-query-guard.ts
3
+ const signedQueryPaths = [
4
+ "/oauth2/continue",
5
+ "/oauth2/consent",
6
+ "/oauth2/authorize"
7
+ ];
8
+ /** Whether a request to `path` may carry a signed authorization query. */
9
+ const isSignedQueryAllowed = (path) => path !== void 0 && signedQueryPaths.includes(path);
10
+ const hasSignedQuery = (body) => typeof body === "object" && body !== null && "oauth_query" in body;
11
+ /**
12
+ * Refuses a signed authorization query on any other request (`400 OAUTH_QUERY_REFUSED`), so a sign-in by password,
13
+ * emailed code, passkey, two-factor or provider never resumes an authorization on its own: the person goes on from
14
+ * the connect page, which shows what they allow. Runs before the provider's own hook.
15
+ */
16
+ const mcpSignedQueryGuard = () => ({
17
+ id: "protobase-mcp-signed-query-guard",
18
+ hooks: { before: [{
19
+ matcher: (context) => hasSignedQuery(context.body) && !isSignedQueryAllowed(context.path),
20
+ handler: createAuthMiddleware(async () => {
21
+ throw new APIError("BAD_REQUEST", {
22
+ code: "OAUTH_QUERY_REFUSED",
23
+ message: "Sign in first, then continue from the connect page."
24
+ });
25
+ })
26
+ }] }
27
+ });
28
+ //#endregion
29
+ export { isSignedQueryAllowed, mcpSignedQueryGuard };
@@ -1,4 +1,11 @@
1
1
  //#region src/better-auth/origins.d.ts
2
2
  /** Where browsers read which origins may use the app's passkeys (WebAuthn Related Origin Requests). */
3
3
  export declare const webauthnPath = "/.well-known/webauthn";
4
+ /**
5
+ * Where MCP clients discover how to sign in: the OAuth authorization server's metadata, and the protected resource's
6
+ * (also below it, at the resource's path). Every bundle sends them to the API, which answers them with MCP on.
7
+ */
8
+ export declare const oauthWellKnownPaths: string[];
9
+ /** Whether `path` is one of `oauthWellKnownPaths` or below one. */
10
+ export declare const isOAuthWellKnownPath: (path: string) => boolean;
4
11
  //#endregion
@@ -44,5 +44,12 @@ const multiHostBaseURL = (baseURL, origins) => {
44
44
  };
45
45
  /** Where browsers read which origins may use the app's passkeys (WebAuthn Related Origin Requests). */
46
46
  const webauthnPath = "/.well-known/webauthn";
47
+ /**
48
+ * Where MCP clients discover how to sign in: the OAuth authorization server's metadata, and the protected resource's
49
+ * (also below it, at the resource's path). Every bundle sends them to the API, which answers them with MCP on.
50
+ */
51
+ const oauthWellKnownPaths = ["/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource"];
52
+ /** Whether `path` is one of `oauthWellKnownPaths` or below one. */
53
+ const isOAuthWellKnownPath = (path) => oauthWellKnownPaths.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
47
54
  //#endregion
48
- export { appOrigins, multiHostBaseURL, originsVariable, readList, readOrigins, resolveOrigins, webauthnPath };
55
+ export { appOrigins, isOAuthWellKnownPath, multiHostBaseURL, oauthWellKnownPaths, originsVariable, readList, readOrigins, resolveOrigins, webauthnPath };
@@ -5,15 +5,16 @@ 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.
8
+ * after a leaked password also signs out whoever used it, and `onPasswordReset` disconnects the user's MCP clients.
9
9
  */
10
- const passwordResetOptions = (mail) => ({
10
+ const passwordResetOptions = (mail, { onPasswordReset } = {}) => ({
11
11
  resetPasswordTokenExpiresIn: resetLinkLifetimeSeconds,
12
12
  revokeSessionsOnPasswordReset: true,
13
13
  sendResetPassword: ({ user, url }) => sendAuthEmail(mail, "resetPasswordEmail", user.email, {
14
14
  url,
15
15
  lifetime: resetLinkLifetime
16
- }, url)
16
+ }, url),
17
+ ...onPasswordReset && { onPasswordReset }
17
18
  });
18
19
  //#endregion
19
20
  export { passwordResetOptions, resetLinkLifetimeSeconds };
@@ -1,35 +1,12 @@
1
1
  import { effectiveSignInPolicy, missingRequiredMethods, parseSignInPolicy, signInPolicyProblem } from "./sign-in-policy.js";
2
2
  import { readSignInPolicy, saveSignInPolicy, signInPolicySchema } from "./policy-store.js";
3
3
  import { isPoliced, lastPasskeyRequired, policyRefusal, setupRequired } from "./sign-in-enforcement.js";
4
+ import { accountMethods } from "./sign-in-setup.js";
4
5
  import { adminOnly } from "./admin-only.js";
5
6
  import { isStaffSession } from "./staff-checks.js";
6
7
  import * as z$1 from "zod";
7
8
  import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
8
9
  //#region src/better-auth/policy-plugin.ts
9
- const accountMethods = async (context, user, socialProviders) => {
10
- const accounts = await context.internalAdapter.findAccounts(user.id);
11
- const passkeys = await context.adapter.count({
12
- model: "passkey",
13
- where: [{
14
- field: "userId",
15
- value: user.id
16
- }]
17
- });
18
- const app = await context.adapter.findOne({
19
- model: "twoFactor",
20
- where: [{
21
- field: "userId",
22
- value: user.id
23
- }]
24
- });
25
- return {
26
- password: accounts.some((account) => account.providerId === "credential"),
27
- passkeys,
28
- twoFactor: Boolean(user.twoFactorEnabled),
29
- authenticatorApp: Boolean(user.twoFactorEnabled) && app !== null && app.verified !== false,
30
- socialSignIn: accounts.some((account) => socialProviders.includes(account.providerId))
31
- };
32
- };
33
10
  const refuse = (status, refusal) => new APIError(status, refusal);
34
11
  const currentPolicy = async (context, { mail, defaultPolicy }) => effectiveSignInPolicy((await readSignInPolicy(context.adapter, defaultPolicy)).policy, { mail });
35
12
  const providersUnder = (policy, { socialProviders, platformSignIn }) => [...socialProviders, ...platformSignIn && policy.platformSignIn === "allowed" ? [platformSignIn.provider] : []];
@@ -65,9 +65,14 @@ const setupRequired = {
65
65
  code: "SIGN_IN_SETUP_REQUIRED",
66
66
  message: "Set up what this app requires for signing in first."
67
67
  };
68
+ /** The answer to someone whose address is not verified who asks to allow an app (an MCP client) to act as them. */
69
+ const addressNotVerified = {
70
+ code: "ADDRESS_NOT_VERIFIED",
71
+ message: "Confirm your address first: sign out, then sign in with a code emailed to you."
72
+ };
68
73
  const lastPasskeyRequired = {
69
74
  code: "PASSKEY_REQUIRED",
70
75
  message: "Passkeys are required for every account, so the last one cannot be removed."
71
76
  };
72
77
  //#endregion
73
- export { isPoliced, lastPasskeyRequired, policyRefusal, setupRequired, staffSignInPath };
78
+ export { addressNotVerified, isPoliced, lastPasskeyRequired, policyRefusal, setupRequired, staffSignInPath };
@@ -0,0 +1,38 @@
1
+ import { effectiveSignInPolicy, missingRequiredMethods } from "./sign-in-policy.js";
2
+ import { readSignInPolicy } from "./policy-store.js";
3
+ //#region src/better-auth/sign-in-setup.ts
4
+ /** What `user`'s account can sign in with and has set up; `socialProviders` are the provider ids that count as social sign-in. */
5
+ const accountMethods = async (context, user, socialProviders) => {
6
+ const accounts = await context.internalAdapter.findAccounts(user.id);
7
+ const passkeys = await context.adapter.count({
8
+ model: "passkey",
9
+ where: [{
10
+ field: "userId",
11
+ value: user.id
12
+ }]
13
+ });
14
+ const app = await context.adapter.findOne({
15
+ model: "twoFactor",
16
+ where: [{
17
+ field: "userId",
18
+ value: user.id
19
+ }]
20
+ });
21
+ return {
22
+ password: accounts.some((account) => account.providerId === "credential"),
23
+ passkeys,
24
+ twoFactor: Boolean(user.twoFactorEnabled),
25
+ authenticatorApp: Boolean(user.twoFactorEnabled) && app !== null && app.verified !== false,
26
+ socialSignIn: accounts.some((account) => socialProviders.includes(account.providerId))
27
+ };
28
+ };
29
+ /**
30
+ * The methods the sign-in policy requires that `user` has not set up yet; empty when nothing is missing. `defaultPolicy`
31
+ * applies until an admin saves one.
32
+ */
33
+ const missingSetup = async (context, user, { mail, defaultPolicy }) => {
34
+ const policy = effectiveSignInPolicy((await readSignInPolicy(context.adapter, defaultPolicy)).policy, { mail });
35
+ return missingRequiredMethods(policy, await accountMethods(context, user, []));
36
+ };
37
+ //#endregion
38
+ export { accountMethods, missingSetup };
@@ -61,7 +61,108 @@ const pluginNames = {
61
61
  deviceType: "device_type",
62
62
  backedUp: "backed_up",
63
63
  createdAt: "created_at"
64
- } } }
64
+ } } },
65
+ mcp: {
66
+ oauthClient: {
67
+ modelName: "oauth_client",
68
+ fields: {
69
+ clientId: "client_id",
70
+ clientSecret: "client_secret",
71
+ clientDiscoveryId: "client_discovery_id",
72
+ skipConsent: "skip_consent",
73
+ enableEndSession: "enable_end_session",
74
+ subjectType: "subject_type",
75
+ clientCredentialsScopes: "client_credentials_scopes",
76
+ userId: "user_id",
77
+ createdAt: "created_at",
78
+ updatedAt: "updated_at",
79
+ softwareId: "software_id",
80
+ softwareVersion: "software_version",
81
+ softwareStatement: "software_statement",
82
+ redirectUris: "redirect_uris",
83
+ postLogoutRedirectUris: "post_logout_redirect_uris",
84
+ backchannelLogoutUri: "backchannel_logout_uri",
85
+ backchannelLogoutSessionRequired: "backchannel_logout_session_required",
86
+ tokenEndpointAuthMethod: "token_endpoint_auth_method",
87
+ applicationType: "application_type",
88
+ jwksUri: "jwks_uri",
89
+ grantTypes: "grant_types",
90
+ responseTypes: "response_types",
91
+ requirePKCE: "require_pkce",
92
+ dpopBoundAccessTokens: "dpop_bound_access_tokens",
93
+ referenceId: "reference_id"
94
+ }
95
+ },
96
+ oauthResource: {
97
+ modelName: "oauth_resource",
98
+ fields: {
99
+ accessTokenTtl: "access_token_ttl",
100
+ refreshTokenTtl: "refresh_token_ttl",
101
+ signingAlgorithm: "signing_algorithm",
102
+ signingKeyId: "signing_key_id",
103
+ allowedScopes: "allowed_scopes",
104
+ customClaims: "custom_claims",
105
+ dpopBoundAccessTokensRequired: "dpop_bound_access_tokens_required",
106
+ createdAt: "created_at",
107
+ updatedAt: "updated_at",
108
+ policyVersion: "policy_version"
109
+ }
110
+ },
111
+ oauthClientResource: {
112
+ modelName: "oauth_client_resource",
113
+ fields: {
114
+ clientId: "client_id",
115
+ resourceId: "resource_id",
116
+ createdAt: "created_at"
117
+ }
118
+ },
119
+ oauthRefreshToken: {
120
+ modelName: "oauth_refresh_token",
121
+ fields: {
122
+ clientId: "client_id",
123
+ sessionId: "session_id",
124
+ userId: "user_id",
125
+ referenceId: "reference_id",
126
+ authorizationCodeId: "authorization_code_id",
127
+ requestedUserInfoClaims: "requested_user_info_claims",
128
+ expiresAt: "expires_at",
129
+ createdAt: "created_at",
130
+ rotatedAt: "rotated_at",
131
+ rotationReplayResponse: "rotation_replay_response",
132
+ rotationReplayExpiresAt: "rotation_replay_expires_at",
133
+ authTime: "auth_time"
134
+ }
135
+ },
136
+ oauthAccessToken: {
137
+ modelName: "oauth_access_token",
138
+ fields: {
139
+ clientId: "client_id",
140
+ sessionId: "session_id",
141
+ userId: "user_id",
142
+ referenceId: "reference_id",
143
+ authorizationCodeId: "authorization_code_id",
144
+ requestedUserInfoClaims: "requested_user_info_claims",
145
+ refreshId: "refresh_id",
146
+ expiresAt: "expires_at",
147
+ createdAt: "created_at"
148
+ }
149
+ },
150
+ oauthConsent: {
151
+ modelName: "oauth_consent",
152
+ fields: {
153
+ clientId: "client_id",
154
+ userId: "user_id",
155
+ referenceId: "reference_id",
156
+ requestedUserInfoClaims: "requested_user_info_claims",
157
+ createdAt: "created_at",
158
+ updatedAt: "updated_at"
159
+ }
160
+ },
161
+ oauthClientAssertion: {
162
+ modelName: "oauth_client_assertion",
163
+ fields: { expiresAt: "expires_at" }
164
+ }
165
+ }
65
166
  };
66
167
  //#endregion
67
168
  export { coreNames, pluginNames };
@@ -55,7 +55,8 @@ const accountChanges = [
55
55
  "/invitation/accept",
56
56
  "/organization/leave",
57
57
  "/organization/transfer-ownership",
58
- "/organization/delete"
58
+ "/organization/delete",
59
+ "/account/connected-apps/disconnect"
59
60
  ];
60
61
  const staffCannotChange = {
61
62
  code: "STAFF_CANNOT_CHANGE_SIGN_IN",
@@ -63,7 +64,13 @@ const staffCannotChange = {
63
64
  };
64
65
  /** Whether a staff session's request to `path` is refused: changes to the person's sign-in, and saving the sign-in policy. */
65
66
  const isRefusedForStaff = (path, method) => accountChanges.includes(path) || path === "/policy/sign-in" && method === "POST";
67
+ const staffCannotConnect = {
68
+ code: "STAFF_CANNOT_CONNECT",
69
+ message: "Staff cannot allow apps to act for a person."
70
+ };
71
+ /** Whether `path` is one of the OAuth provider's, through which an outside app (an MCP client) is allowed to act as someone. */
72
+ const isOAuthPath = (path) => path.startsWith("/oauth2/");
66
73
  /** Whether a Better Auth session is one staff started as someone: the admin plugin's `impersonatedBy` names the staff member. */
67
74
  const isStaffSession = (session) => Boolean(session.impersonatedBy);
68
75
  //#endregion
69
- export { isRefusedForStaff, isStaffSession, isStrongSignIn, permissionMissing, reasonProblem, signInNotStrong, signInStale, staffCannotChange, staffClaimsRefusal, staffLabel };
76
+ export { isOAuthPath, isRefusedForStaff, isStaffSession, isStrongSignIn, permissionMissing, reasonProblem, signInNotStrong, signInStale, staffCannotChange, staffCannotConnect, staffClaimsRefusal, staffLabel };
@@ -1,5 +1,5 @@
1
1
  import { adminOnly } from "./admin-only.js";
2
- import { isRefusedForStaff, isStaffSession, staffCannotChange } from "./staff-checks.js";
2
+ import { isOAuthPath, isRefusedForStaff, isStaffSession, staffCannotChange, staffCannotConnect } from "./staff-checks.js";
3
3
  import { endStaffSignIn, listStaffSignIns, staffSignInOfSession, staffSignInSchema } from "./staff-log.js";
4
4
  import { staffSignInEndpoints } from "./staff-sign-in-endpoints.js";
5
5
  import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
@@ -7,7 +7,8 @@ import { deleteSessionCookie } from "better-auth/cookies";
7
7
  //#region src/better-auth/staff-plugin.ts
8
8
  /**
9
9
  * Staff of the operator sign in as a person of this app, for support (see `staffSignInEndpoints`). A staff session
10
- * shows who started it and why, can be stopped, and cannot change the person's sign-in or the sign-in policy. Every
10
+ * shows who started it and why, can be stopped, and cannot change the person's sign-in or the sign-in policy, or allow
11
+ * an outside app to act as the person. Every
11
12
  * staff sign-in is logged for the app's admins. The plugin is there without an operator provider too, so its table
12
13
  * exists from the first auth migration.
13
14
  */
@@ -56,6 +57,12 @@ const staffSignInPlugin = ({ operator, mail, defaultPolicy }) => ({
56
57
  const session = await getSessionFromCtx(ctx);
57
58
  if (session && isStaffSession(session.session)) throw new APIError("FORBIDDEN", staffCannotChange);
58
59
  })
60
+ }, {
61
+ matcher: (context) => context.path !== void 0 && isOAuthPath(context.path),
62
+ handler: createAuthMiddleware(async (ctx) => {
63
+ const session = await getSessionFromCtx(ctx);
64
+ if (session && isStaffSession(session.session)) throw new APIError("FORBIDDEN", staffCannotConnect);
65
+ })
59
66
  }] }
60
67
  });
61
68
  //#endregion
@@ -14,7 +14,7 @@ import { Hono } from "hono";
14
14
  * operator provider's name, when staff can sign in as people; `passkeyDomains`, when people have passkeys for more than
15
15
  * one of the app's hosts, the RP IDs a passkey sign-in can ask for, the passkey domain first; with organizations, who may create them and the roles
16
16
  * with their labels, `membership` for those a member can hold, and the roles each one's holders may also give; and
17
- * `anonymous` when the project's anonymous role reads a resource, so the admin app opens for visitors before anyone signs in; and
17
+ * `anonymous` when the project's anonymous role reads a resource, so the admin app opens for visitors before anyone signs in; `mcp` when MCP clients can connect, so the account page lists the connected ones; and
18
18
  * `plugins`, those the app loads before sign-in (see `PluginOption`); and `locale`, the language `options.locale` puts
19
19
  * the screens in, so the sign-in screens and public forms, which come before `/meta`, are in it too.
20
20
  */
@@ -44,6 +44,7 @@ const statusRoute = (auth, { anonymous = false, plugins = [], locale } = {}) =>
44
44
  ...staffSignIn && { staffSignIn },
45
45
  ...passkeyDomains.length > 1 && { passkeyDomains },
46
46
  ...anonymous && { anonymous },
47
+ ...auth.mcp && { mcp: true },
47
48
  ...plugins.length > 0 && { plugins },
48
49
  ...locale && { locale },
49
50
  ...auth.organizations && { organizations: {
@@ -0,0 +1,19 @@
1
+ import { narrowClaims } from "./roles/active-role.js";
2
+ import { organizationClaims } from "./organizations/session-organization.js";
3
+ //#region src/better-auth/token-claims.ts
4
+ /**
5
+ * The claims of a token for `user`: the address and the role claims narrowed to one role; with organizations also the
6
+ * claims of `organizationId` while they may work in it (see `organizationClaims`).
7
+ */
8
+ const tokenClaims = async (user, { wanted, organizationId, organizations }) => {
9
+ const claims = {
10
+ role: user.role,
11
+ ...organizations && await organizationClaims(organizations, user, organizationId)
12
+ };
13
+ return {
14
+ email: user.email,
15
+ ...narrowClaims(claims, wanted, Boolean(organizations))
16
+ };
17
+ };
18
+ //#endregion
19
+ export { tokenClaims };
@@ -52,7 +52,10 @@ export declare const setUserRole: (auth: AdminAuth, input: {
52
52
  email: string;
53
53
  role: string;
54
54
  }>;
55
- /** Disables or re-enables sign-in. Banning ends the user's sessions; the last active admin cannot be banned. */
55
+ /**
56
+ * Disables or re-enables sign-in. Banning ends the user's sessions and, with MCP on, disconnects their MCP clients and
57
+ * forgets them, so re-enabling brings none back: each has to be allowed again. The last active admin cannot be banned.
58
+ */
56
59
  export declare const setUserBanned: (auth: AdminAuth, input: {
57
60
  email: string;
58
61
  banned: boolean;
@@ -1,5 +1,6 @@
1
1
  import { parseRoles } from "./parse-roles.js";
2
2
  import { isActiveAdmin } from "./last-admin.js";
3
+ import { revokeMcpGrants } from "./mcp/revoke-grants.js";
3
4
  import { ownOrganizationsWithoutMembers } from "./organizations/host.js";
4
5
  //#region src/better-auth/users.ts
5
6
  /** The roles this auth instance allows, `admin` first. */
@@ -98,7 +99,10 @@ const setUserRole = async (auth, input) => {
98
99
  role: wanted.join(",")
99
100
  };
100
101
  };
101
- /** Disables or re-enables sign-in. Banning ends the user's sessions; the last active admin cannot be banned. */
102
+ /**
103
+ * Disables or re-enables sign-in. Banning ends the user's sessions and, with MCP on, disconnects their MCP clients and
104
+ * forgets them, so re-enabling brings none back: each has to be allowed again. The last active admin cannot be banned.
105
+ */
102
106
  const setUserBanned = async (auth, input) => {
103
107
  const user = await findUser(auth, input.email);
104
108
  const { internalAdapter } = await auth.$context;
@@ -117,6 +121,7 @@ const setUserBanned = async (auth, input) => {
117
121
  banExpires: null
118
122
  });
119
123
  await internalAdapter.deleteUserSessions(user.id);
124
+ if (auth.mcp) await revokeMcpGrants((await auth.$context).adapter, user.id);
120
125
  };
121
126
  //#endregion
122
127
  export { createUser, deleteUser, hasUsers, listUsers, parseRoles, roleChoices, setUserBanned, setUserRole };
@@ -18,7 +18,7 @@ const checkRoleLimits = (registry, limits) => {
18
18
  ...model,
19
19
  fields: Object.fromEntries(Object.entries(model.fields).map(([field, value]) => [field, {
20
20
  ...value,
21
- filterable: true
21
+ filterable: !value.encrypted
22
22
  }]))
23
23
  };
24
24
  const checked = checkFilter(trusted, filter);
@@ -22,6 +22,7 @@ import { readAppName } from "./app-name.js";
22
22
  import { realtimePath, restPath, storagePath, supabaseFunctionsPath, supabasePath } from "./supabase-paths.js";
23
23
  import { goTruePath } from "./gotrue/keys.js";
24
24
  import { authBasePath } from "./better-auth/auth-base-path.js";
25
+ import { mcpDiscovery } from "./better-auth/mcp/discovery-routes.js";
25
26
  import { withoutSessionTokens } from "./better-auth/without-session-tokens.js";
26
27
  import { organizationRoutes } from "./better-auth/organizations/admin-routes.js";
27
28
  import { statusRoute } from "./better-auth/status-route.js";
@@ -57,6 +58,7 @@ import { createFilesRuntime } from "./files/runtime.js";
57
58
  import { fileRoutes } from "./files/routes.js";
58
59
  import { formRoutes } from "./forms/routes.js";
59
60
  import { createFormsRuntime } from "./forms/runtime.js";
61
+ import { createEncryptionRuntime } from "./encryption/runtime.js";
60
62
  import { functionRoutes } from "./functions/routes.js";
61
63
  import { supabaseFunctionRoutes } from "./functions/supabase-routes.js";
62
64
  import { upgradeAuthenticator } from "./functions/upgrade-caller.js";
@@ -146,6 +148,10 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
146
148
  });
147
149
  const apiKeys = apiKeySettings(options.apiKeys, globalThis.process?.env ?? {});
148
150
  const servesKeys = Boolean(apiKeys.publishable || apiKeys.secret);
151
+ const encryption = createEncryptionRuntime({
152
+ models: registry.entries.map((entry) => entry.model),
153
+ env: globalThis.process?.env ?? {}
154
+ });
149
155
  const files = createFilesRuntime({
150
156
  ...filesOptions && { options: filesOptions },
151
157
  env: globalThis.process?.env ?? {},
@@ -187,6 +193,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
187
193
  plugins,
188
194
  sources,
189
195
  ...files && { files },
196
+ ...encryption && { encryption },
190
197
  ...appModels.some(isCaptured) && { actorSetting: `${changes}.actor` },
191
198
  ...withHistory && { history: {
192
199
  schema: changes,
@@ -256,6 +263,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
256
263
  registry,
257
264
  db
258
265
  }));
266
+ if (auth.mcp) mcpDiscovery(app, auth);
259
267
  app.on(["GET", "POST"], `${authBasePath}/*`, async (c) => withoutSessionTokens(c.req.raw, await auth.handler(c.req.raw)));
260
268
  app.route(goTruePath, goTrueRoutes(auth));
261
269
  if (auth.origins.length > 1) app.get(webauthnPath, (c) => c.json({ origins: auth.origins }));
package/dist/deps.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import "./types.js";
2
2
  import "./comments/comment-users.js";
3
3
  import "./comments/mention-job.js";
4
+ import "./encryption/runtime.js";
4
5
  import "./files/runtime.js";
5
6
  import "./plugins/plugin-settings.js";
6
7
  import "./registry.js";
@@ -0,0 +1,40 @@
1
+ import "../registry.js";
2
+ import { CascadingKey } from "./cascading-keys.js";
3
+ import { UnreadableEntry } from "./status.js";
4
+ import { Db } from "@protobase/query";
5
+ //#region src/encryption/apply.d.ts
6
+ /** Values sealed under another table (as `.table()` wrote it) or column, before a rename: of one field, or every one of the resource. */
7
+ export type RebindOption = {
8
+ resource: string;
9
+ field?: string;
10
+ from: {
11
+ table?: string;
12
+ column?: string;
13
+ };
14
+ };
15
+ export type ApplyOptions = {
16
+ rebind?: RebindOption;
17
+ };
18
+ /** What `apply` did to one encrypted field. */
19
+ export type FieldApplied = {
20
+ resource: string;
21
+ field: string;
22
+ /** Plaintext values it encrypted. */
23
+ encrypted: number;
24
+ /** Values under a previous key it encrypted with the current one. */
25
+ reencrypted: number;
26
+ /** Values that opened only where they were before a rename, sealed again where they are now. */
27
+ rebound: number;
28
+ /** Values a write changed while it ran, which it left alone. */
29
+ skipped: number;
30
+ unreadable: UnreadableEntry[];
31
+ /** History rows and undispatched changes it removed the field's old values from. */
32
+ historyScrubbed: number;
33
+ outboxScrubbed: number;
34
+ };
35
+ export type ApplyResult = {
36
+ kid: string;
37
+ fields: FieldApplied[];
38
+ warnings: CascadingKey[];
39
+ };
40
+ //#endregion
@@ -0,0 +1,99 @@
1
+ import { bindingOf, movedBinding } from "./binding.js";
2
+ import { Sealed, sealText } from "./envelope-seal.js";
3
+ import { columnId, tableId } from "../sql-parts.js";
4
+ import { cascadingKeys } from "./cascading-keys.js";
5
+ import { captureOff } from "./capture-off.js";
6
+ import { scrubHistory, scrubOutbox } from "./remnants.js";
7
+ import { scanColumn } from "./scan.js";
8
+ import { encryptedFields, examine } from "./status.js";
9
+ import { sql } from "kysely";
10
+ //#region src/encryption/apply.ts
11
+ const rebindsField = (rebind, entry, field) => rebind !== void 0 && rebind.resource === entry.name && (rebind.field === void 0 || rebind.field === field.name) ? rebind : void 0;
12
+ const changeOf = (keyring, entry, field, row, rebind) => {
13
+ const binding = bindingOf(entry.model, field, row.record);
14
+ const seal = (plaintext, count) => ({
15
+ address: row.address,
16
+ old: row.stored,
17
+ value: Sealed.textOf(sealText(keyring.current, binding, plaintext)),
18
+ count
19
+ });
20
+ const found = examine(keyring, entry, field, row, binding);
21
+ if (found.kind === "plaintext") return seal(row.stored, "encrypted");
22
+ if (found.kind === "open") return found.kid === keyring.current.id ? void 0 : seal(found.plaintext, "reencrypted");
23
+ if (found.entry.kind !== "failed-authentication" || !rebind) return found.entry;
24
+ const moved = examine(keyring, entry, field, row, movedBinding(binding, rebind.from));
25
+ return moved.kind === "open" ? seal(moved.plaintext, "rebound") : found.entry;
26
+ };
27
+ const write = async (trx, entry, field, change) => {
28
+ const { model } = entry;
29
+ const keys = model.primaryKey.map((name, i) => sql`${columnId(model.fields[name])} = ${change.address.key[i]}`);
30
+ const tenant = model.tenant ? [sql`${columnId(model.fields[model.tenant])} is not distinct from ${change.address.tenant}`] : [];
31
+ const conditions = sql.join([
32
+ ...keys,
33
+ ...tenant,
34
+ sql`${columnId(field)} = ${change.old}`
35
+ ], sql` and `);
36
+ const result = await sql`update ${tableId(model)} set ${columnId(field)} = ${change.value} where ${conditions}`.execute(trx);
37
+ return Number(result.numAffectedRows ?? 0n) > 0;
38
+ };
39
+ const applyField = async (db, entry, field, keyring, changesSchema, rebind) => {
40
+ const applied = {
41
+ resource: entry.name,
42
+ field: field.name,
43
+ encrypted: 0,
44
+ reencrypted: 0,
45
+ rebound: 0,
46
+ skipped: 0,
47
+ unreadable: [],
48
+ historyScrubbed: 0,
49
+ outboxScrubbed: 0
50
+ };
51
+ await scanColumn(db, entry, field, async (rows, connection) => {
52
+ const changes = [];
53
+ for (const row of rows) {
54
+ const change = changeOf(keyring, entry, field, row, rebind);
55
+ if (change && "address" in change) changes.push(change);
56
+ else if (change) applied.unreadable.push(change);
57
+ }
58
+ if (changes.length === 0) return;
59
+ await captureOff(connection, changesSchema, async (trx) => {
60
+ for (const change of changes) if (await write(trx, entry, field, change)) applied[change.count]++;
61
+ else applied.skipped++;
62
+ });
63
+ });
64
+ applied.historyScrubbed = await scrubHistory(db, changesSchema, entry.model, field);
65
+ applied.outboxScrubbed = await scrubOutbox(db, changesSchema, entry.model, field);
66
+ return applied;
67
+ };
68
+ const keyIdsOf = async (db, entry, field) => {
69
+ const { rows } = await sql`select distinct split_part(${columnId(field)}, ':', 3) as kid from ${tableId(entry.model)} where ${columnId(field)} like 'pbenc:%'`.execute(db);
70
+ return rows.map(({ kid }) => kid);
71
+ };
72
+ const checkKeys = async (db, entries, keyring) => {
73
+ for (const entry of entries) for (const field of encryptedFields(entry)) {
74
+ const kids = await keyIdsOf(db, entry, field);
75
+ if (kids.length > 0 && !kids.some((kid) => keyring.byId.has(kid))) throw new Error(`${entry.name}.${field.name} holds values under keys ${kids.join(", ")} only, none of which this run has (key ${keyring.current.id}): run with the keys the app serves with`);
76
+ }
77
+ };
78
+ /**
79
+ * Brings every encrypted field's column to the current key: plaintext values are encrypted, values under a previous key
80
+ * encrypted again, and with `rebind` values sealed where they were before a rename sealed where they are now. Values that
81
+ * do not open are listed and left alone. Then the fields' old values are removed from the history and the changes not
82
+ * yet dispatched. Its writes capture no change. It refuses to run when a column holds envelopes under none of its keys.
83
+ */
84
+ const applyEncryption = async (db, entries, keyring, changesSchema, options = {}) => {
85
+ await checkKeys(db, entries, keyring);
86
+ const fields = [];
87
+ const warnings = [];
88
+ for (const entry of entries) {
89
+ for (const field of encryptedFields(entry)) fields.push(await applyField(db, entry, field, keyring, changesSchema, rebindsField(options.rebind, entry, field)));
90
+ if (encryptedFields(entry).length > 0) warnings.push(...await cascadingKeys(db, entry.model));
91
+ }
92
+ return {
93
+ kid: keyring.current.id,
94
+ fields,
95
+ warnings
96
+ };
97
+ };
98
+ //#endregion
99
+ export { applyEncryption };
@@ -0,0 +1,2 @@
1
+ import "../types.js";
2
+ import { ResourceModel } from "@protobase/schema";