@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,26 @@
1
+ import { EventsOptions } from "../changes/events-options.js";
2
+ import { ResourceSource } from "../resource-source.js";
3
+ import { EncryptionStatus } from "./status.js";
4
+ import { ApplyOptions, ApplyResult } from "./apply.js";
5
+ import { Db } from "@protobase/query";
6
+ //#region src/encryption/tools.d.ts
7
+ export type EncryptionToolsInput = {
8
+ resources: ResourceSource[];
9
+ db: Db;
10
+ events?: EventsOptions;
11
+ env: Record<string, string | undefined>;
12
+ };
13
+ /**
14
+ * What `protobase encryption status` and `apply` run on a project: `undefined` when it has no encrypted fields. It fails
15
+ * on the same config and key problems as the server's startup. `kid` is the id of the current key.
16
+ */
17
+ export declare const createEncryptionTools: ({ resources, db, events, env }: EncryptionToolsInput) => {
18
+ kid: string;
19
+ /** What every encrypted field's column holds, and what remains of its plaintext; it writes nothing. */
20
+ status: () => Promise<EncryptionStatus>;
21
+ /** Encrypts the plaintext, moves previous keys' values to the current one, rebinds renamed ones, and scrubs remnants. */
22
+ apply: (options?: ApplyOptions) => Promise<ApplyResult>;
23
+ } | undefined;
24
+ /** The tools of a project with encrypted fields. */
25
+ export type EncryptionTools = NonNullable<ReturnType<typeof createEncryptionTools>>;
26
+ //#endregion
@@ -0,0 +1,34 @@
1
+ import { changesSchema } from "../changes/events-options.js";
2
+ import { createEncryptionRuntime } from "./runtime.js";
3
+ import { buildRegistry } from "../registry.js";
4
+ import { encryptionStatus } from "./status.js";
5
+ import { applyEncryption } from "./apply.js";
6
+ import { rebindProblem } from "./rebind-check.js";
7
+ //#region src/encryption/tools.ts
8
+ /**
9
+ * What `protobase encryption status` and `apply` run on a project: `undefined` when it has no encrypted fields. It fails
10
+ * on the same config and key problems as the server's startup. `kid` is the id of the current key.
11
+ */
12
+ const createEncryptionTools = ({ resources, db, events, env }) => {
13
+ const { entries } = buildRegistry(resources);
14
+ const runtime = createEncryptionRuntime({
15
+ models: entries.map((entry) => entry.model),
16
+ env
17
+ });
18
+ if (!runtime) return void 0;
19
+ const { keyring } = runtime;
20
+ const schema = changesSchema(events);
21
+ return {
22
+ kid: keyring.current.id,
23
+ /** What every encrypted field's column holds, and what remains of its plaintext; it writes nothing. */
24
+ status: () => encryptionStatus(db, entries, keyring, schema),
25
+ /** Encrypts the plaintext, moves previous keys' values to the current one, rebinds renamed ones, and scrubs remnants. */
26
+ apply: async (options = {}) => {
27
+ const problem = options.rebind && rebindProblem(entries, options.rebind);
28
+ if (problem) throw new Error(problem);
29
+ return applyEncryption(db, entries, keyring, schema, options);
30
+ }
31
+ };
32
+ };
33
+ //#endregion
34
+ export { createEncryptionTools };
@@ -20,7 +20,7 @@ const fieldErrors = (registry, { form, view }, granted) => {
20
20
  const field = model.fields[name];
21
21
  if (!field) return [`field "${name}" does not exist on ${view.resource}`];
22
22
  if (field.readOnly) return [`field "${name}" is read-only, so nobody fills it in`];
23
- if (field.sensitive) return [`field "${name}" is sensitive, and a public form does not take sensitive fields`];
23
+ if (field.sensitive && !field.encrypted) return [`field "${name}" is sensitive, and a public form takes only encrypted ones of them`];
24
24
  if (field.type === "relation") return [`field "${name}" is a relation, whose records a visitor cannot read to pick one`];
25
25
  if (field.type === "file") return [`field "${name}" is a file field, and a visitor cannot upload`];
26
26
  if (!plainTypes.includes(field.type)) return [`field "${name}" is ${field.type}, which a form has no input for`];
@@ -1,6 +1,7 @@
1
1
  import { effectiveSignInPolicy } from "../better-auth/sign-in-policy.js";
2
2
  import { readSignInPolicy } from "../better-auth/policy-store.js";
3
3
  import { goTrueUser, userMetadata } from "./user-json.js";
4
+ import { revokeMcpGrants } from "../better-auth/mcp/revoke-grants.js";
4
5
  import { checkNewAccountPassword } from "../better-auth/new-account-password.js";
5
6
  import { goTrueTokens } from "./access-tokens.js";
6
7
  import { GoTrueError } from "./errors.js";
@@ -22,6 +23,9 @@ const logoutScopes = [
22
23
  /**
23
24
  * The signed-in user's own endpoints, with their access token: `GET /user`, `PUT /user` for a new password or metadata,
24
25
  * and `POST /logout`. A token whose session has ended, by signing out or a ban, is GoTrue's `session_not_found`.
26
+ * With MCP on, signing out everywhere or the other sessions, and setting a password, disconnect the user's MCP clients,
27
+ * as the same steps do through Better Auth: GoTrue has no way to tell a password reset from a change, so every new
28
+ * password does.
25
29
  */
26
30
  const userRoutes = ({ auth, context }) => {
27
31
  const claimsOf = accessClaimsOf(goTrueTokens({ auth }));
@@ -61,6 +65,7 @@ const userRoutes = ({ auth, context }) => {
61
65
  model: "session",
62
66
  where
63
67
  });
68
+ if (scope !== "local" && auth.mcp) await revokeMcpGrants(adapter, claims.sub);
64
69
  };
65
70
  return new Hono().get("/user", async (c) => c.json(await userJson(await signedIn(c.req.raw)))).put("/user", async (c) => {
66
71
  const { email, password, data } = await readBody(c.req.raw, updateBody);
@@ -78,6 +83,7 @@ const userRoutes = ({ auth, context }) => {
78
83
  accountId: user.id,
79
84
  password: hash
80
85
  });
86
+ if (auth.mcp) await revokeMcpGrants(ctx.adapter, user.id);
81
87
  }
82
88
  const merged = data && Object.fromEntries(Object.entries({
83
89
  ...userMetadata(user.metadata),
package/dist/index.d.ts CHANGED
@@ -2,13 +2,13 @@ import { MobileConfig } from "./mobile-config.js";
2
2
  import { AppIdentity } from "./app-identity.js";
3
3
  import { AppIcons, PngIcon, pngIcons } from "./web-app/app-icons.js";
4
4
  import { StaffSignIn } from "./better-auth/staff-log.js";
5
+ import { RoleDefinition, RolesInput } from "./better-auth/organizations/role-definitions.js";
5
6
  import { MailMessage, Mailer } from "./mail/smtp-mailer.js";
6
7
  import { OperatorProvider, readOperatorSettings } from "./better-auth/operator-provider.js";
7
- import { RoleDefinition, RolesInput } from "./better-auth/organizations/role-definitions.js";
8
8
  import { OrganizationRole, StoredMember, StoredOrganization } from "./better-auth/organizations/store.js";
9
+ import { SignInPolicy, SignInRule, StaffAccessRule, defaultSignInPolicy, signInPolicyMethods } from "./better-auth/sign-in-policy.js";
9
10
  import { OrganizationsOptions } from "./better-auth/organizations/options.js";
10
11
  import { PlatformSignIn, readPlatformSignIn } from "./better-auth/platform-sign-in.js";
11
- import { SignInPolicy, SignInRule, StaffAccessRule, defaultSignInPolicy, signInPolicyMethods } from "./better-auth/sign-in-policy.js";
12
12
  import { EmailComponent, EmailOptions, EmailTemplate, RenderedEmail, defineEmail } from "./mail/define-email.js";
13
13
  import { AdminAuth, CreateAuthOptions, UserCreatedHook, createAuth, normalizeRoles } from "./better-auth/create-auth.js";
14
14
  import { EventsOptions } from "./changes/events-options.js";
@@ -54,11 +54,11 @@ import { consoleAuditQueue } from "./audit/console-queue.js";
54
54
  import { JwtOptions, JwtVerifyOptions, jwtAuthenticator, jwtVerifier } from "./auth/jwt.js";
55
55
  import { HttpProblem, MissingCredentials, badRequest, forbidden, notFound, unauthorized } from "./problem.js";
56
56
  import { TrustedApp, withTrustedApps } from "./better-auth/app-tokens.js";
57
- import { BetterAuthAuthenticatorOptions, betterAuthAuthenticator, betterAuthTokens } from "./better-auth/authenticator.js";
57
+ import { BetterAuthAuthenticator, BetterAuthAuthenticatorOptions, betterAuthAuthenticator, betterAuthTokens } from "./better-auth/authenticator.js";
58
58
  import { parseRoles } from "./better-auth/parse-roles.js";
59
59
  import { LinkedAccount, NewUser, StoredUser, createUser, deleteUser, hasUsers, listUsers, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
60
60
  import { IssueTokenOptions, issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
61
- import { webauthnPath } from "./better-auth/origins.js";
61
+ import { isOAuthWellKnownPath, oauthWellKnownPaths, webauthnPath } from "./better-auth/origins.js";
62
62
  import { goTruePath } from "./gotrue/keys.js";
63
63
  import { supabasePath } from "./supabase-paths.js";
64
64
  import { defaultAppName } from "./app-name.js";
@@ -97,6 +97,10 @@ import { S3Bucket, S3FilesOptions, S3ListPage, S3Object, s3FileStore, s3Files }
97
97
  import { storageProvider } from "./files/storage-provider.js";
98
98
  import { objectKey } from "./storage/object-key.js";
99
99
  import { FileCleanupInput, createFileCleanup } from "./files/file-cleanup.js";
100
+ import { CascadingKey } from "./encryption/cascading-keys.js";
101
+ import { EncryptionStatus, FieldStatus, StrayValues, UnreadableEntry } from "./encryption/status.js";
102
+ import { ApplyOptions, ApplyResult, FieldApplied, RebindOption } from "./encryption/apply.js";
103
+ import { EncryptionTools, EncryptionToolsInput, createEncryptionTools } from "./encryption/tools.js";
100
104
  import { FileObject, FileVariantObject } from "./files/present.js";
101
105
  import { FileRef, formatUri, variantPath } from "./files/uri.js";
102
106
  import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/processors.js";
@@ -106,4 +110,4 @@ import { isSchedule } from "./jobs/schedule.js";
106
110
  import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/runtime.js";
107
111
  import { jobsDrainApp } from "./jobs/drain-route.js";
108
112
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
109
- export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type AppIcons, type AppIdentity, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentUsers, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type MemberInput, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, backgroundSettled, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, goTruePath, handleDroppedConnections, hasUsers, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, objectKey, parseColumns, parseRoles, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
113
+ export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type AppIcons, type AppIdentity, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentUsers, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type MemberInput, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, backgroundSettled, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, goTruePath, handleDroppedConnections, hasUsers, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import { requestApproval } from "./assistant/approval.js";
17
17
  import { withCacheBreakpoints } from "./assistant/cache-breakpoints.js";
18
18
  import { runTurn } from "./assistant/turn.js";
19
19
  import { consoleAuditQueue } from "./audit/console-queue.js";
20
- import { webauthnPath } from "./better-auth/origins.js";
20
+ import { isOAuthWellKnownPath, oauthWellKnownPaths, webauthnPath } from "./better-auth/origins.js";
21
21
  import { apiKeySettings, apiKeyVariables } from "./api-keys/api-key-settings.js";
22
22
  import { apiKeyCaller } from "./api-keys/resolve-caller.js";
23
23
  import { jwtAuthenticator, jwtVerifier } from "./auth/jwt.js";
@@ -76,8 +76,9 @@ import { requireEnv } from "./functions/require-env.js";
76
76
  import { backgroundSettled, waitUntil } from "./functions/wait-until.js";
77
77
  import { storeFile } from "./files/store-file.js";
78
78
  import { createFileCleanup } from "./files/file-cleanup.js";
79
+ import { createEncryptionTools } from "./encryption/tools.js";
79
80
  import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/processors.js";
80
81
  import { defineEmail } from "./mail/define-email.js";
81
82
  import { startJobs } from "./jobs/runtime.js";
82
83
  import { jobsDrainApp } from "./jobs/drain-route.js";
83
- export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, backgroundSettled, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, goTruePath, handleDroppedConnections, hasUsers, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, objectKey, parseColumns, parseRoles, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
84
+ export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, backgroundSettled, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, goTruePath, handleDroppedConnections, hasUsers, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
package/dist/records.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { storedValue } from "./encryption/stored-value.js";
1
2
  import { columnId, filterCondition, keyConditions, returningList, scopeConditions, tableId, whereClause } from "./sql-parts.js";
2
3
  import { etagOf } from "./etag.js";
3
4
  import { exposeRow, pickFields } from "./rows.js";
@@ -24,10 +25,9 @@ const fetchRecord = async (db, entry, key, tenant, options = {}) => {
24
25
  const query = sql`select ${returningList(entry, shape.fields)} from ${tableId(entry.model)} as t${whereClause(conditions)} limit 1${lockClause(lock)}`;
25
26
  return stored(entry, (await rowsOf(db, query))[0], shape);
26
27
  };
27
- const writeValue = (field, value) => field.type === "json" && value !== null ? JSON.stringify(value) : value;
28
28
  const insertRecord = async (db, entry, values, shape = {}) => {
29
29
  const fields = Object.keys(values).map((name) => entry.model.fields[name]);
30
- const body = fields.length === 0 ? sql`default values` : sql`(${sql.join(fields.map(columnId))}) values (${sql.join(fields.map((field) => writeValue(field, values[field.name])))})`;
30
+ const body = fields.length === 0 ? sql`default values` : sql`(${sql.join(fields.map(columnId))}) values (${sql.join(fields.map((field) => storedValue(field, values[field.name])))})`;
31
31
  const query = sql`insert into ${tableId(entry.model)} ${body} returning ${returningList(entry, shape.fields)}`;
32
32
  return await stored(entry, (await rowsOf(db, query))[0], shape);
33
33
  };
@@ -39,7 +39,7 @@ const etagBump = (entry) => {
39
39
  const updateRecord = async (db, entry, key, tenant, values, shape = {}) => {
40
40
  const assignments = [...Object.entries(values).map(([name, value]) => {
41
41
  const field = entry.model.fields[name];
42
- return sql`${columnId(field)} = ${writeValue(field, value)}`;
42
+ return sql`${columnId(field)} = ${storedValue(field, value)}`;
43
43
  }), ...etagBump(entry)];
44
44
  const conditions = [...scopeConditions(entry.model, tenant), ...keyConditions(entry.model, key)];
45
45
  const query = sql`update ${tableId(entry.model)} as t set ${sql.join(assignments)}${whereClause(conditions)} returning ${returningList(entry, shape.fields)}`;
@@ -1,3 +1,4 @@
1
+ import { maskEncrypted } from "./encryption/mask-stored.js";
1
2
  import { forbidden, notFound, unauthorized } from "./problem.js";
2
3
  import { checkedFilter } from "./filter-input.js";
3
4
  import { checkOwnerId } from "./owner-id.js";
@@ -98,7 +99,7 @@ const trusted = (model) => ({
98
99
  ...model,
99
100
  fields: Object.fromEntries(Object.entries(model.fields).map(([name, field]) => [name, {
100
101
  ...field,
101
- filterable: true
102
+ filterable: !field.encrypted
102
103
  }]))
103
104
  });
104
105
  const asFilter = (access, expr, operation) => {
@@ -109,13 +110,16 @@ const asFilter = (access, expr, operation) => {
109
110
  };
110
111
  /**
111
112
  * The record-level rule of an operation, run with the stored record (and the input of a write). Returns a verdict, or a
112
- * filter that the caller checks in the database, so its semantics match reads exactly.
113
+ * filter that the caller checks in the database, so its semantics match reads exactly. The rule gets a set encrypted
114
+ * field of the stored record as `encrypted`, never its ciphertext; the input it gets as written.
113
115
  */
114
116
  const recordDecision = async (access, operation, record, input) => {
115
117
  const { entry, deps, full, session } = access;
116
118
  const explicit = entry.source.accessRules[operation] ?? (operation === "list" ? entry.source.accessRules.read : void 0);
117
119
  const fallback = deps.accessOptions.defaultAccess ? () => deps.accessOptions.defaultAccess === "allow" : deps.accessOptions.roles ? deps.accessOptions.roles.can(`${entry.name}.${operation === "list" ? "read" : operation}`) : () => true;
118
- const result = await (explicit ?? fallback)({
120
+ const rule = explicit ?? fallback;
121
+ const stored = record && maskEncrypted(full, record);
122
+ const result = await rule({
119
123
  user: {
120
124
  id: session.user.id,
121
125
  roles: session.user.roles
@@ -123,8 +127,8 @@ const recordDecision = async (access, operation, record, input) => {
123
127
  ...organizationOf(session),
124
128
  model: full,
125
129
  operation,
126
- record
127
- }, record, input);
130
+ record: stored
131
+ }, stored, input);
128
132
  if (typeof result === "boolean") return result;
129
133
  return asFilter(access, result, operation);
130
134
  };
@@ -0,0 +1,37 @@
1
+ import { restError } from "./rest-error.js";
2
+ import { sameTable } from "../encryption/shared-columns.js";
3
+ //#region src/rest/encrypted-columns.ts
4
+ const guardedTable = (served, models) => ({
5
+ name: served.table.name,
6
+ models: models.filter((model) => sameTable(model, served))
7
+ });
8
+ const encryptedColumns = (table) => new Set(table.models.flatMap((model) => Object.values(model.fields).filter((field) => field.encrypted).map((field) => field.column)));
9
+ const bindingColumns = (table) => table.models.filter((model) => Object.values(model.fields).some((field) => field.encrypted)).flatMap((model) => [...model.primaryKey, ...model.tenant ? [model.tenant] : []].map((name) => model.fields[name].column));
10
+ const refusal = (table, column, what) => restError(400, "encrypted-column", `Column '${column}' of '${table.name}' is encrypted: it cannot be ${what} here`, null, "Write and reveal encrypted fields through /api/v1");
11
+ const filterColumns = (filter) => filter.kind === "condition" ? [filter.field.column] : filter.args.flatMap(filterColumns);
12
+ /** Refuses filters, a sort or a join on an encrypted column, whose ciphertext no comparison matches. */
13
+ const refuseEncryptedReads = (table, filters, order, joined = []) => {
14
+ const encrypted = encryptedColumns(table);
15
+ const read = [
16
+ ...filters.flatMap(filterColumns),
17
+ ...order.map((term) => term.field.column),
18
+ ...joined
19
+ ].find((column) => encrypted.has(column));
20
+ if (read !== void 0) throw refusal(table, read, "filtered, sorted or joined on");
21
+ };
22
+ /** Refuses a write of an encrypted column, or matching an upsert's rows on one. */
23
+ const refuseEncryptedWrite = (table, columns) => {
24
+ const encrypted = encryptedColumns(table);
25
+ const written = columns.find((column) => encrypted.has(column));
26
+ if (written !== void 0) throw refusal(table, written, "written or matched on");
27
+ };
28
+ /**
29
+ * On a table with encrypted fields, refuses a write that changes the key or tenant of a row, which its encrypted values
30
+ * are bound to: an update's `columns`, or an upsert's beyond the `kept` ones it matches rows on and so writes unchanged.
31
+ */
32
+ const refuseRebinding = (table, columns, kept = []) => {
33
+ const rebound = bindingColumns(table).find((column) => columns.includes(column) && !kept.includes(column));
34
+ if (rebound !== void 0) throw restError(400, "encrypted-column", `Column '${rebound}' of '${table.name}' cannot change here: its encrypted fields are bound to it`, null, "Change it through /api/v1");
35
+ };
36
+ //#endregion
37
+ export { guardedTable, refuseEncryptedReads, refuseEncryptedWrite, refuseRebinding };
@@ -7,6 +7,7 @@ import { parseQuery } from "./parse-query.js";
7
7
  import { embedsOf, resolveShape } from "./embed-shape.js";
8
8
  import { readRows } from "./read-rows.js";
9
9
  import { asCaller, queryParams, readBody } from "./rest-context.js";
10
+ import { guardedTable, refuseEncryptedReads, refuseEncryptedWrite, refuseRebinding } from "./encrypted-columns.js";
10
11
  import { scanRefusal, uncoveredReads } from "./rest-scan.js";
11
12
  import { deleteRows, insertRows, updateRows } from "./write-rows.js";
12
13
  import { Hono } from "hono";
@@ -35,27 +36,32 @@ const shapeOf = async (deps, foreignKeys, entry, query) => {
35
36
  embedded: query.embedded
36
37
  }, entry.model.table.name, query.select);
37
38
  };
39
+ const guarded = (deps, entry) => guardedTable(entry.model, deps.registry.entries.map((one) => one.model));
38
40
  const targetOf = (entry) => ({
39
41
  table: tableId(entry.model),
40
42
  alias: entry.model.table.name
41
43
  });
44
+ /** What a request reads of the table and of every embedded one: its filters, its sort and the columns it joins on. */
45
+ const readsOf = (deps, entry, filters, order, shape) => [{
46
+ entry,
47
+ filters,
48
+ order,
49
+ joined: []
50
+ }, ...embedsOf(shape).map((embed) => ({
51
+ entry: tableEntry(deps, embed.table),
52
+ filters: embed.params.filters,
53
+ order: embed.params.order,
54
+ joined: embed.relationship.join.map((pair) => pair.target)
55
+ }))];
42
56
  /**
43
- * Refuses filters and a sort no index serves, as the scan guard does on `/api/v1`, on the table and on every embedded
44
- * one, whose join columns need an index too; in `warn` mode, a warning header instead.
57
+ * Refuses filters, a sort or a join on an encrypted column; then filters and a sort no index serves, as the scan guard
58
+ * does on `/api/v1`, on the table and on every embedded one, whose join columns need an index too; in `warn` mode, a
59
+ * warning header instead.
45
60
  */
46
61
  const scanCheck = async (deps, entry, filters, order, shape = []) => {
62
+ const reads = readsOf(deps, entry, filters, order, shape);
63
+ for (const read of reads) refuseEncryptedReads(guarded(deps, read.entry), read.filters, read.order, read.joined);
47
64
  if (deps.guard.mode === "off") return {};
48
- const reads = [{
49
- entry,
50
- filters,
51
- order,
52
- joined: []
53
- }, ...embedsOf(shape).map((embed) => ({
54
- entry: tableEntry(deps, embed.table),
55
- filters: embed.params.filters,
56
- order: embed.params.order,
57
- joined: embed.relationship.join.map((pair) => pair.target)
58
- }))];
59
65
  for (const read of reads) {
60
66
  const [first] = uncoveredReads(await deps.guard.indexesOf(read.entry), read.filters, read.order, read.joined);
61
67
  if (!first) continue;
@@ -130,6 +136,9 @@ const insert = async (deps, foreignKeys, c) => {
130
136
  if (columns.length === 0 && rows.length > 1) throw restError(400, "PGRST102", "Several rows without any column cannot be inserted at once");
131
137
  if (rows.length === 0 && object) checkSingle(0);
132
138
  const conflict = query.onConflict ?? entry.model.primaryKey.map((name) => entry.model.fields[name].column);
139
+ const table = guarded(deps, entry);
140
+ refuseEncryptedWrite(table, prefer.resolution ? [...columns, ...conflict] : columns);
141
+ if (prefer.resolution === "merge-duplicates") refuseRebinding(table, columns, conflict);
133
142
  const written = rows.length === 0 ? {
134
143
  total: 0,
135
144
  ...prefer.return === "representation" && { body: "[]" }
@@ -154,6 +163,9 @@ const update = async (deps, foreignKeys, c) => {
154
163
  const object = wantsObject(c.req.raw);
155
164
  const body = await readBody(c);
156
165
  if (!isRecord(body)) throw restError(400, "PGRST102", "Empty or invalid json");
166
+ const table = guarded(deps, entry);
167
+ refuseEncryptedWrite(table, Object.keys(body));
168
+ refuseRebinding(table, Object.keys(body));
157
169
  const shape = await shapeOf(deps, foreignKeys, entry, query);
158
170
  const warning = await scanCheck(deps, entry, query.filters, [], shape);
159
171
  const written = Object.keys(body).length === 0 ? {
@@ -1,10 +1,21 @@
1
1
  import { badRequest, notFound } from "./problem.js";
2
2
  import { tenantScope } from "./tenant.js";
3
3
  import { requestAccess, requireOperation } from "./request-access.js";
4
+ import { bindingOf } from "./encryption/binding.js";
4
5
  import { fetchReadable } from "./fetch-readable.js";
5
6
  import { readTransaction } from "./transactions.js";
7
+ import { openText } from "./encryption/envelope-open.js";
6
8
  import { encodeKey } from "@protobase/schema/internal";
7
9
  //#region src/reveal-service.ts
10
+ const revealedValue = (deps, model, field, record) => {
11
+ const stored = record[field.name] ?? null;
12
+ if (!field.encrypted || stored === null) return stored;
13
+ if (!deps.encryption) throw new Error(`"${model.name}" has encrypted fields but no encryption runtime`);
14
+ return openText(deps.encryption.keyring, bindingOf(model, field, record), String(stored), {
15
+ resource: model.name,
16
+ field: field.name
17
+ });
18
+ };
8
19
  /**
9
20
  * The value of one sensitive field of one record, for a caller who may read the record and the field: the same tenant,
10
21
  * row filter and record-level rule as a get. The reveal is published to the audit queue before the value is returned;
@@ -17,9 +28,12 @@ const revealField = async (deps, entry, session, request) => {
17
28
  const field = Object.hasOwn(access.readable.fields, name) ? access.readable.fields[name] : void 0;
18
29
  if (!field) throw badRequest("invalid-parameter", `Unknown field "${name}"`);
19
30
  if (!field.sensitive) throw badRequest("invalid-parameter", `"${name}" is not sensitive; it comes with the record`);
20
- const keyFields = access.full.primaryKey.map((part) => access.full.fields[part]);
21
- const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchReadable(trx, access, request.key, "hide", { fields: [...keyFields, access.full.fields[name]] }));
31
+ const { full } = access;
32
+ const tenantField = full.fields[name].encrypted && full.tenant && !full.primaryKey.includes(full.tenant) ? [full.tenant] : [];
33
+ const keyFields = [...full.primaryKey, ...tenantField].map((part) => full.fields[part]);
34
+ const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchReadable(trx, access, request.key, "hide", { fields: [...keyFields, full.fields[name]] }));
22
35
  if (!found) throw notFound(`No ${entry.name} with this key`);
36
+ const value = revealedValue(deps, access.full, full.fields[name], found.record);
23
37
  const tenant = tenantScope(entry, session)?.tenantValue;
24
38
  await deps.audit.publish({
25
39
  type: "field.revealed",
@@ -36,7 +50,7 @@ const revealField = async (deps, entry, session, request) => {
36
50
  });
37
51
  return {
38
52
  field: name,
39
- value: found.record[name] ?? null
53
+ value
40
54
  };
41
55
  };
42
56
  //#endregion
package/dist/types.d.ts CHANGED
@@ -19,6 +19,11 @@ export type Session = {
19
19
  tenant?: TenantValue;
20
20
  organization?: SessionOrganization;
21
21
  anonymous?: true;
22
+ /** The outside app acting as the user, such as an MCP client: its OAuth client id and the scopes its token carries. */
23
+ client?: {
24
+ id: string;
25
+ scopes: string[];
26
+ };
22
27
  };
23
28
  /**
24
29
  * Resolves the caller of a request, or throws a 401 problem (see `unauthorized`). `userIdType` declares the type of
@@ -1,3 +1,4 @@
1
+ import { maskEncrypted } from "./encryption/mask-stored.js";
1
2
  import { badRequest } from "./problem.js";
2
3
  import { writableFields } from "./request-access.js";
3
4
  //#region src/validate-body.ts
@@ -31,7 +32,8 @@ const requiredOnCreate = (field, schema) => !(field.default && "db" in field.def
31
32
  * for a text field that may not be null counts as not given: a create fills in its default or reports it required,
32
33
  * an update reports it required. Only the values written are checked, so a stored '' still reads.
33
34
  * Returns only the values to write: on create the defaults are filled in, on update only the provided fields.
34
- * `current` is the stored record an update merges into for `.validate()` rules.
35
+ * `current` is the stored record an update merges into for `.validate()` rules, which get a set encrypted field of it as
36
+ * `encrypted`: they see an encrypted value as text only when this write gives it.
35
37
  */
36
38
  const validateBody = (access, mode, body, current = {}) => {
37
39
  if (!isObject(body)) throw badRequest("invalid-record", "The request body must be a JSON object");
@@ -85,7 +87,7 @@ const validateBody = (access, mode, body, current = {}) => {
85
87
  }
86
88
  if (errors.length > 0) throw fail(errors);
87
89
  const merged = {
88
- ...current,
90
+ ...maskEncrypted(full, current),
89
91
  ...values
90
92
  };
91
93
  const issues = entry.source.validators.flatMap((validate) => validate(merged) ?? []);
@@ -40,7 +40,20 @@ const subscriptionEvent = (change, event, { fields, thin = false } = {}) => ({
40
40
  transaction: change.transaction
41
41
  }
42
42
  });
43
- const mayRead = async (access, record) => {
43
+ const withUnknownEncrypted = (model, record) => {
44
+ const fields = Object.values(model.fields).filter((field) => field.encrypted);
45
+ if (fields.length === 0) return record;
46
+ const unknown = { ...record };
47
+ for (const field of fields) Object.defineProperty(unknown, field.name, {
48
+ enumerable: true,
49
+ get: () => {
50
+ throw new Error(`a subscription's access check cannot tell whether the encrypted field "${field.name}" is set`);
51
+ }
52
+ });
53
+ return unknown;
54
+ };
55
+ const mayRead = async (access, captured) => {
56
+ const record = withUnknownEncrypted(access.full, captured);
44
57
  const { resolved, session, entry } = access;
45
58
  if (!resolved.operations.read) return false;
46
59
  if (resolved.rowFilter.read && evaluateFilter(resolved.rowFilter.read, record) !== true) return false;
@@ -1,11 +1,13 @@
1
1
  import { HttpProblem, badRequest } from "./problem.js";
2
2
  import { tenantScope } from "./tenant.js";
3
3
  import { requestAccess, requireOperation, targetFilter, writableFields } from "./request-access.js";
4
- import { deleteRecord, fetchRecord, insertRecord, restoreRecord } from "./records.js";
4
+ import { bindingOf, pendingBinding } from "./encryption/binding.js";
5
+ import { deleteRecord, fetchRecord, insertRecord, restoreRecord, updateRecord } from "./records.js";
5
6
  import { writeTransaction } from "./transactions.js";
6
7
  import { blockedDelete } from "./blocked-delete.js";
7
8
  import { isDatabaseError } from "./error-handler.js";
8
9
  import { attachFiles } from "./files/attach.js";
10
+ import { encryptedValuesOf, hasEncryptedValues, sealValues } from "./encryption/seal-values.js";
9
11
  import { actorSettings, wakeDispatch } from "./changes/actor.js";
10
12
  import { validateBody } from "./validate-body.js";
11
13
  import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown } from "./write-shared.js";
@@ -21,6 +23,14 @@ const withOwner = (access, values) => {
21
23
  [owner]: caller
22
24
  };
23
25
  };
26
+ const bindEncrypted = async (trx, deps, access, row, inserted, shape) => {
27
+ const { entry, full } = access;
28
+ if (!hasEncryptedValues(full, row)) return inserted;
29
+ const values = sealValues(deps.encryption, full, encryptedValuesOf(full, row), (field) => bindingOf(full, field, inserted.record));
30
+ const bound = await updateRecord(trx, entry, keyOf(access, inserted.record), tenantScope(entry, access.session)?.tenantValue, values, shape);
31
+ if (!bound) throw new Error(`A ${entry.name} record was created whose key the API cannot address, so its encrypted fields cannot be bound to it`);
32
+ return bound;
33
+ };
24
34
  const create = async (trx, deps, access, body) => {
25
35
  const { entry, full } = access;
26
36
  const scope = tenantScope(entry, access.session);
@@ -33,7 +43,9 @@ const create = async (trx, deps, access, body) => {
33
43
  const filter = await decide(access, "create", void 0, row);
34
44
  const parents = checkParents(trx, access, "create");
35
45
  await parents(row);
36
- const created = await insertRecord(trx, entry, row, { etagFields: Object.keys(access.model.fields) });
46
+ const shape = { etagFields: Object.keys(access.model.fields) };
47
+ const inserted = await insertRecord(trx, entry, sealValues(deps.encryption, full, row, (field) => pendingBinding(full, field)), shape);
48
+ const created = await bindEncrypted(trx, deps, access, row, inserted, shape);
37
49
  await assertWritten(trx, access, created, "create", access.resolved.rowFilter.create, filter);
38
50
  await parents(created.record);
39
51
  if (full.position) await checkRankAfter(trx, deps, access, full.position.field, [{
@@ -1,3 +1,4 @@
1
+ import { maskEncrypted } from "./encryption/mask-stored.js";
1
2
  import { forbidden, notFound, preconditionFailed, preconditionRequired } from "./problem.js";
2
3
  import { tenantScope } from "./tenant.js";
3
4
  import { andFilters, recordDecision } from "./request-access.js";
@@ -44,7 +45,10 @@ const assertWritten = async (trx, access, written, operation, ...filters) => {
44
45
  fields: [firstKeyField(access)]
45
46
  })) throw denied(access.entry, `leave in that state (${operation})`);
46
47
  };
47
- /** The caller sees the readable fields only, files as objects with their URL; hooks get the whole record. */
48
+ /**
49
+ * The caller sees the readable fields only, files as objects with their URL; hooks get the whole record, a set encrypted
50
+ * field as `encrypted`.
51
+ */
48
52
  const shown = async (access, written) => ({
49
53
  record: (await presentFiles(access, [pickFields(written.record, Object.keys(access.model.fields))]))[0],
50
54
  etag: written.etag
@@ -58,8 +62,8 @@ const notifier = (deps, access) => async (trx, kind, before, after) => {
58
62
  user: session.user,
59
63
  ...session.tenant !== void 0 && { tenant: session.tenant },
60
64
  ...session.anonymous && { anonymous: true },
61
- ...before && { before },
62
- ...after && { after }
65
+ ...before && { before: maskEncrypted(access.full, before) },
66
+ ...after && { after: maskEncrypted(access.full, after) }
63
67
  };
64
68
  for (const hook of deps.hooks) await hook(event, trx);
65
69
  await scheduleRemovedFiles(access, kind, before, after);
@@ -1,7 +1,10 @@
1
+ import { badRequest } from "./problem.js";
1
2
  import { tenantScope } from "./tenant.js";
2
3
  import { targetFilter } from "./request-access.js";
4
+ import { bindingOf } from "./encryption/binding.js";
3
5
  import { fetchRecord, updateRecord } from "./records.js";
4
6
  import { attachFiles } from "./files/attach.js";
7
+ import { sealValues } from "./encryption/seal-values.js";
5
8
  import { validateBody } from "./validate-body.js";
6
9
  import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, missing, notifier } from "./write-shared.js";
7
10
  //#region src/write-update.ts
@@ -40,11 +43,18 @@ const planUpdate = async (trx, access, key, body, ifMatch, required = true) => {
40
43
  parents
41
44
  };
42
45
  };
46
+ const refuseKeyChange = (access, plan) => {
47
+ const { full, entry } = access;
48
+ if (!Object.values(full.fields).some((field) => field.encrypted)) return;
49
+ if (full.primaryKey.some((name) => Object.hasOwn(plan.values, name) && String(plan.values[name]) !== String(plan.current.record[name]))) throw badRequest("key-change-unsupported", `The key of a ${entry.name} record cannot change: its encrypted fields are bound to it`);
50
+ };
43
51
  /** Writes a planned update, checks the row as written against the filters, and runs the hooks. */
44
52
  const applyUpdate = async (trx, deps, access, key, plan) => {
45
- const { entry } = access;
53
+ const { entry, full } = access;
54
+ refuseKeyChange(access, plan);
46
55
  const tenant = tenantScope(entry, access.session)?.tenantValue;
47
- const updated = await updateRecord(trx, entry, key, tenant, plan.values, { etagFields: Object.keys(access.model.fields) });
56
+ const values = sealValues(deps.encryption, full, plan.values, (field) => bindingOf(full, field, plan.current.record));
57
+ const updated = await updateRecord(trx, entry, key, tenant, values, { etagFields: Object.keys(access.model.fields) });
48
58
  await assertWritten(trx, access, updated, "update", access.resolved.rowFilter.update, plan.filter);
49
59
  await plan.parents(updated.record);
50
60
  await notifier(deps, access)(trx, "update", plan.current.record, updated.record);