@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.
- package/dist/assistant/query-tools.js +1 -1
- package/dist/auth/jwt.d.ts +15 -1
- package/dist/auth/jwt.js +41 -5
- package/dist/better-auth/authenticator.d.ts +16 -1
- package/dist/better-auth/authenticator.js +41 -24
- package/dist/better-auth/create-auth.d.ts +3063 -364
- package/dist/better-auth/create-auth.js +36 -19
- package/dist/better-auth/mcp/connected-apps.d.ts +13 -0
- package/dist/better-auth/mcp/connected-apps.js +86 -0
- package/dist/better-auth/mcp/consent-check.js +38 -0
- package/dist/better-auth/mcp/consent-reference.js +16 -0
- package/dist/better-auth/mcp/discovery-routes.js +19 -0
- package/dist/better-auth/mcp/grant-reference.js +25 -0
- package/dist/better-auth/mcp/mcp-plugins.d.ts +13 -0
- package/dist/better-auth/mcp/mcp-plugins.js +107 -0
- package/dist/better-auth/mcp/mcp-token-claims.js +39 -0
- package/dist/better-auth/mcp/request-defaults.js +57 -0
- package/dist/better-auth/mcp/revoke-grants.js +75 -0
- package/dist/better-auth/mcp/setup-gate.js +38 -0
- package/dist/better-auth/mcp/signed-query-guard.js +29 -0
- package/dist/better-auth/origins.d.ts +7 -0
- package/dist/better-auth/origins.js +8 -1
- package/dist/better-auth/password-reset.js +4 -3
- package/dist/better-auth/policy-plugin.js +1 -24
- package/dist/better-auth/sign-in-enforcement.js +6 -1
- package/dist/better-auth/sign-in-setup.js +38 -0
- package/dist/better-auth/snake-case-names.js +102 -1
- package/dist/better-auth/staff-checks.js +9 -2
- package/dist/better-auth/staff-plugin.js +9 -2
- package/dist/better-auth/status-route.js +2 -1
- package/dist/better-auth/token-claims.js +19 -0
- package/dist/better-auth/users.d.ts +4 -1
- package/dist/better-auth/users.js +6 -1
- package/dist/check-roles.js +1 -1
- package/dist/create-admin.js +8 -0
- package/dist/deps.d.ts +1 -0
- package/dist/encryption/apply.d.ts +40 -0
- package/dist/encryption/apply.js +99 -0
- package/dist/encryption/binding.d.ts +2 -0
- package/dist/encryption/binding.js +53 -0
- package/dist/encryption/capture-off.js +12 -0
- package/dist/encryption/cascading-keys.d.ts +9 -0
- package/dist/encryption/cascading-keys.js +21 -0
- package/dist/encryption/envelope-open.d.ts +5 -0
- package/dist/encryption/envelope-open.js +57 -0
- package/dist/encryption/envelope-seal.js +38 -0
- package/dist/encryption/keyring.js +60 -0
- package/dist/encryption/mask-stored.js +16 -0
- package/dist/encryption/rebind-check.js +28 -0
- package/dist/encryption/remnants.js +53 -0
- package/dist/encryption/runtime.d.ts +1 -0
- package/dist/encryption/runtime.js +18 -0
- package/dist/encryption/scan.d.ts +4 -0
- package/dist/encryption/scan.js +61 -0
- package/dist/encryption/seal-values.js +30 -0
- package/dist/encryption/shared-columns.js +19 -0
- package/dist/encryption/status.d.ts +39 -0
- package/dist/encryption/status.js +92 -0
- package/dist/encryption/stored-value.js +16 -0
- package/dist/encryption/tools.d.ts +26 -0
- package/dist/encryption/tools.js +34 -0
- package/dist/forms/check-forms.js +1 -1
- package/dist/gotrue/user-routes.js +6 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.js +3 -2
- package/dist/records.js +3 -3
- package/dist/request-access.js +9 -5
- package/dist/rest/encrypted-columns.js +37 -0
- package/dist/rest/table-routes.js +25 -13
- package/dist/reveal-service.js +17 -3
- package/dist/types.d.ts +5 -0
- package/dist/validate-body.js +4 -2
- package/dist/webhooks/subscriber-view.js +14 -1
- package/dist/write-pipeline.js +14 -2
- package/dist/write-shared.js +7 -3
- package/dist/write-update.js +12 -2
- 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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 };
|
package/dist/check-roles.js
CHANGED
|
@@ -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:
|
|
21
|
+
filterable: !value.encrypted
|
|
22
22
|
}]))
|
|
23
23
|
};
|
|
24
24
|
const checked = checkFilter(trusted, filter);
|
package/dist/create-admin.js
CHANGED
|
@@ -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
|
@@ -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 };
|