@protobase/server 0.57.6 → 0.58.1

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.
@@ -5,6 +5,7 @@ import { MapOptions } from "./maps/map-settings.js";
5
5
  import { PluginOption } from "./plugins/plugin-settings.js";
6
6
  import { SourceOption } from "./sources/source-settings.js";
7
7
  import { CorsOrigins } from "./api-cors.js";
8
+ import { AssistantTool } from "./assistant/tools.js";
8
9
  import { AssistantOptions } from "./assistant/assistant-settings.js";
9
10
  import { LocaleOption } from "./locale/locale-option.js";
10
11
  import { McpWrites } from "./mcp/mcp-writes.js";
@@ -75,12 +76,16 @@ export type AdminOptions = {
75
76
  * `writes: { rolesWithoutApproval: ['service'] }` lets a user holding one of those roles, such as a service account
76
77
  * with no person present, write without the approval; any other user is still asked. Each must be a role the app's
77
78
  * users can hold.
79
+ * `tools`: the app's own tools, offered after the built-in ones whatever `writes` says, each run as the caller under
80
+ * their access rules. One that changes data asks through `approve`, which `rolesWithoutApproval` skips too; one that
81
+ * never calls it writes unasked. A name taken twice, or by a built-in tool, stops startup.
78
82
  * `false`: the host serves its own MCP endpoint at `auth.mcp.path`, in front of this app. This app then answers 404
79
83
  * there and needs no `forAudience`, while the OAuth provider's discovery stays.
80
84
  */
81
85
  mcp?: {
82
86
  sql?: boolean;
83
87
  writes?: McpWrites;
88
+ tools?: AssistantTool[];
84
89
  } | false;
85
90
  /**
86
91
  * The websites whose browser code may call the API (`/api/*` but `/api/auth/*`) besides the app's own origins (see
@@ -21,7 +21,7 @@ export type ToolContext = {
21
21
  show: (part: AssistantPart) => void;
22
22
  /** Asks the user to approve with a card in that message (see `requestApproval`). */
23
23
  approve: (request: ApprovalRequest) => Promise<ApprovalAnswer>;
24
- /** The app's resources as the session's user, through their access rules and write hooks (built-in assistant only). */
24
+ /** The app's resources as the session's user, through their access rules and write hooks. */
25
25
  records: ToolRecords;
26
26
  /** The resources the caller may read, each restricted to their fields, with their views. */
27
27
  resources: () => Promise<ReadableResources>;
@@ -38,7 +38,7 @@ export type ToolAnnotations = {
38
38
  };
39
39
  /**
40
40
  * A function the model may call during a turn. `parameters` is the JSON Schema of its arguments; `run` gets them
41
- * parsed, but not checked against the schema, and returns what the model reads as the result.
41
+ * parsed, checked against the schema over MCP but not in the dock, and returns what the model reads as the result.
42
42
  */
43
43
  export type AssistantTool = {
44
44
  name: string;
@@ -77,6 +77,7 @@ import { createFilesRuntime } from "./files/runtime.js";
77
77
  import { fileRoutes } from "./files/routes.js";
78
78
  import { formRoutes } from "./forms/routes.js";
79
79
  import { createFormsRuntime } from "./forms/runtime.js";
80
+ import { fieldDecrypter } from "./encryption/decrypt-field.js";
80
81
  import { createEncryptionRuntime } from "./encryption/runtime.js";
81
82
  import { functionRoutes } from "./functions/routes.js";
82
83
  import { supabaseFunctionRoutes } from "./functions/supabase-routes.js";
@@ -333,7 +334,8 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
333
334
  db,
334
335
  sql: options.mcp !== false && options.mcp?.sql === true,
335
336
  writes,
336
- commented
337
+ commented,
338
+ own: options.mcp !== false && options.mcp?.tools || []
337
339
  });
338
340
  const approvals = mcpApprovals();
339
341
  const { resource } = auth.mcp;
@@ -373,6 +375,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
373
375
  sessionToken: betterAuthSessionToken(auth)
374
376
  }
375
377
  });
378
+ const decryptField = fieldDecrypter(deps);
376
379
  app.route("/", functionRoutes({
377
380
  basePath: `${systemPath}/functions`,
378
381
  functions: apiFunctions,
@@ -380,6 +383,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
380
383
  access: deps.accessOptions,
381
384
  db,
382
385
  records: (session) => callerRecords(deps, session),
386
+ decryptField,
383
387
  onError,
384
388
  wake: () => wakeDispatch(deps),
385
389
  upgradeWebSocket,
@@ -469,6 +473,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
469
473
  });
470
474
  return Object.assign(app, {
471
475
  startRealtimePrune: prune,
476
+ decryptField,
472
477
  jobs: [
473
478
  ...webhooks.jobs,
474
479
  ...withHistory && days !== void 0 ? [pruneHistoryJob(changes, days)] : [],
@@ -0,0 +1,25 @@
1
+ //#region src/encryption/decrypt-field-types.d.ts
2
+ /** One record's key: its value, or its parts in the order of the resource's primary key. */
3
+ export type RecordKey = string | number | readonly (string | number)[];
4
+ /** Reads one encrypted field of one record as its cleartext, or `null` when it is empty. */
5
+ export type DecryptField = (resource: string, key: RecordKey, field: string) => Promise<string | null>;
6
+ /**
7
+ * Who reads, as the audit names them: a job, or a function with its caller and the request's origin (`AuditOrigin`).
8
+ * Filled in by the runtime, never by app code.
9
+ */
10
+ export type FieldReader = {
11
+ job: string;
12
+ } | {
13
+ function: string;
14
+ actor: {
15
+ id: string | number;
16
+ roles: string[];
17
+ };
18
+ origin: {
19
+ userAgent?: string;
20
+ forwardedFor?: string;
21
+ };
22
+ };
23
+ /** The app's server-side read of encrypted fields, for the reader the runtime names. */
24
+ export type FieldDecrypter = (reader: FieldReader) => DecryptField;
25
+ //#endregion
@@ -0,0 +1,63 @@
1
+ import { readTransaction } from "../transactions.js";
2
+ import { bindingOf } from "./binding.js";
3
+ import { fetchRecord } from "../records.js";
4
+ import { openText } from "./envelope-open.js";
5
+ import { encodeKey } from "@protobase/schema/internal";
6
+ //#region src/encryption/decrypt-field.ts
7
+ const eventOf = (reader, base) => {
8
+ if ("job" in reader) return {
9
+ ...base,
10
+ reader: { job: reader.job },
11
+ origin: {}
12
+ };
13
+ return {
14
+ ...base,
15
+ actor: reader.actor,
16
+ reader: { function: reader.function },
17
+ origin: reader.origin
18
+ };
19
+ };
20
+ /**
21
+ * The read `decryptField` of a job's and a protected function's context: privileged like their `db`, so no access rules
22
+ * or tenant scope, of a live (not soft-deleted) record. It opens the value as the reveal does, then publishes a
23
+ * `field.revealed` event naming the reader, and only returns the value once that is accepted: when publishing fails,
24
+ * so does the read.
25
+ */
26
+ const fieldDecrypter = (deps) => (reader) => async (resource, key, field) => {
27
+ const entry = deps.registry.find(resource);
28
+ if (!entry) throw new Error(`Unknown resource "${resource}"`);
29
+ const { model } = entry;
30
+ const target = Object.hasOwn(model.fields, field) ? model.fields[field] : void 0;
31
+ if (!target?.encrypted) throw new Error(`"${resource}.${field}" is not an encrypted field`);
32
+ if (!deps.encryption) throw new Error(`"${resource}" has encrypted fields but no encryption runtime`);
33
+ const parts = Array.isArray(key) ? key : [key];
34
+ if (parts.length !== model.primaryKey.length) throw new Error(`A ${resource} key has ${model.primaryKey.length} parts, not ${parts.length}`);
35
+ const tenantField = model.tenant && !model.primaryKey.includes(model.tenant) ? [model.tenant] : [];
36
+ const fields = [
37
+ ...model.primaryKey,
38
+ ...tenantField,
39
+ field
40
+ ].map((name) => model.fields[name]);
41
+ const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchRecord(trx, entry, parts, void 0, {
42
+ deleted: "hide",
43
+ fields
44
+ }));
45
+ if (!found) throw new Error(`No ${resource} with key "${encodeKey(parts)}"`);
46
+ const stored = found.record[field] ?? null;
47
+ const value = stored === null ? null : openText(deps.encryption.keyring, bindingOf(model, target, found.record), String(stored), {
48
+ resource,
49
+ field
50
+ });
51
+ const tenant = model.tenant ? found.record[model.tenant] : void 0;
52
+ await deps.audit.publish(eventOf(reader, {
53
+ type: "field.revealed",
54
+ at: (/* @__PURE__ */ new Date()).toISOString(),
55
+ ...tenant !== void 0 && tenant !== null && { tenant },
56
+ resource,
57
+ recordKey: encodeKey(parts),
58
+ field
59
+ }));
60
+ return value;
61
+ };
62
+ //#endregion
63
+ export { fieldDecrypter };
@@ -42,7 +42,7 @@ const decrypt = (aes, aad, bytes) => {
42
42
  };
43
43
  /**
44
44
  * The plaintext of `stored`, sealed under `binding` with a key of `keyring`, or an `UnreadableValue` thrown with why it
45
- * does not open. The only place, with the CLI's status and apply, that decrypts.
45
+ * does not open. The only place, with the CLI's status and apply and the audited `decryptField`, that decrypts.
46
46
  */
47
47
  const openText = (keyring, binding, stored, subject) => {
48
48
  const parsed = parse(stored);
@@ -1,3 +1,4 @@
1
+ import { DecryptField } from "../encryption/decrypt-field-types.js";
1
2
  import { Session } from "../types.js";
2
3
  import { Caller } from "../api-keys/resolve-caller.js";
3
4
  import { SocketEvents } from "./function-socket.js";
@@ -29,7 +30,14 @@ export type FunctionCaller = {
29
30
  records: FunctionRecords;
30
31
  };
31
32
  /** What a function that needs a signed-in caller gets: the caller resolved before it runs. */
32
- export type FunctionContext = PublicFunctionContext & FunctionCaller;
33
+ export type FunctionContext = PublicFunctionContext & FunctionCaller & {
34
+ /**
35
+ * The cleartext of an `.encrypted()` field of one record, or `null` when it is empty: privileged like `db`, with no
36
+ * access rules or tenant scope. Each read is audited as a reveal, naming this function and its caller, and fails when
37
+ * the audit does. Not on a public function's context: a public function that needs a value enqueues a job.
38
+ */
39
+ decryptField: DecryptField;
40
+ };
33
41
  /** What a function gets at `/api/sb/functions/v1/<name>`, where supabase-js calls it: the caller its keys and token make. */
34
42
  export type KeyFunctionContext = {
35
43
  name: string;
@@ -1,5 +1,6 @@
1
1
  import { forbidden, notFound } from "../problem.js";
2
2
  import { namingTimeouts } from "../statement-timeout.js";
3
+ import { auditOrigin } from "../audit/origin.js";
3
4
  import { invokeFunction } from "./define-function.js";
4
5
  import { runInBackground } from "../background-work.js";
5
6
  import { socketAcceptor } from "./accept-socket.js";
@@ -22,7 +23,7 @@ const isUpgrade = (request) => request.headers.get("upgrade")?.toLowerCase() ===
22
23
  * A function may answer an upgrade with a socket, only once it returned. While it is open, its caller is checked again
23
24
  * every minute: a protected function's always, a public one's once `caller()` resolved.
24
25
  */
25
- const functionRoutes = ({ basePath, functions, authenticate, access, db, records, onError, wake, upgradeWebSocket, upgradeAuthenticate = authenticate, report }) => {
26
+ const functionRoutes = ({ basePath, functions, authenticate, access, db, records, decryptField, onError, wake, upgradeWebSocket, upgradeAuthenticate = authenticate, report }) => {
26
27
  const app = new Hono().basePath(basePath);
27
28
  app.onError(onError);
28
29
  const sessionOf = (request) => isUpgrade(request) ? upgradeAuthenticate(request) : authenticate(request);
@@ -79,11 +80,21 @@ const functionRoutes = ({ basePath, functions, authenticate, access, db, records
79
80
  report,
80
81
  recheck
81
82
  });
83
+ const { user } = caller.session;
84
+ const reader = {
85
+ function: name,
86
+ actor: {
87
+ id: user.id,
88
+ roles: [...user.roles]
89
+ },
90
+ origin: auditOrigin(c.req.raw.headers)
91
+ };
82
92
  const context = {
83
93
  name,
84
94
  db,
85
95
  ...caller,
86
96
  caller: async () => caller,
97
+ decryptField: decryptField(reader),
87
98
  acceptWebSocket: acceptor.accept
88
99
  };
89
100
  return acceptor.answer(name, await invokeFunction(name, fn, request, context));
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ import { AppIdentity } from "./app-identity.js";
3
3
  import { AppIcons, PngIcon, pngIcons } from "./web-app/app-icons.js";
4
4
  import { RealtimeOptions, relayed } from "./realtime/relay/realtime-options.js";
5
5
  import { EventsOptions } from "./changes/events-options.js";
6
+ import { DecryptField, FieldDecrypter, FieldReader, RecordKey } from "./encryption/decrypt-field-types.js";
6
7
  import { StandardSchema, readJson } from "./functions/read-json.js";
7
8
  import { MailMessage, Mailer } from "./mail/smtp-mailer.js";
8
9
  import { JsonObject, JsonValue } from "./notifications/json-value.js";
@@ -169,4 +170,4 @@ import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/ru
169
170
  import { jobsDrainApp } from "./jobs/drain-route.js";
170
171
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
171
172
  import { AdminAuth, CreateAuthOptions, UserCreatedHook, normalizeRoles } from "./better-auth/create-auth.js";
172
- export { type Admin, type AdminAuth, type AdminOptions, type AggregateParams, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApnsEnvironment, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type ArgumentsPayload, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type AuthHooks, type AuthSettings, type AuthenticateSettings, type Authenticator, type BatchOperation, type BatchResult, 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 CommentToolsInput, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type Device, type DeviceColumn, type DeviceKind, type DeviceStore, DeviceStoreError, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueJob, 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 FileProviderSettings, 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 HistoryColumn, type HistoryOptions, type HistoryPosition, type HistoryStore, HistoryStoreError, type HookContext, HttpProblem, type IssueTokenOptions, type JobContext, type JobDeclaration, type JobDefinition, type JobName, type JobOptions, type JobPayload, type JobRun, type JobsRuntime, type JsonObject, type JsonValue, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpApprovals, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, type Membership, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewDevice, type NewNotification, type NewOrganization, type NewUser, type NotificationRecipients, NotificationSendError, type NotificationsOptions, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type PushAnswer, type PushMessage, PushMessageError, type PushSender, type QueryOptions, type QueryResult, type ReadableResources, type RealtimeOptions, type RealtimePrune, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordGroups, 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 SendNotification, 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 StoredNotification, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolComment, type ToolCommentPage, type ToolComments, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type Urgency, type User, type UserCreatedHook, type VapidSettings, type ViewSource, type WebPushFields, type WebPushOptions, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addCommentTool, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appNameOver, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaDrift, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentTools, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, countRecordsTool, createAdmin, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, describeResourceTool, deviceIdParam, deviceStore, devicesPath, enqueueJob, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listCommentsTool, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpApprovals, mcpGate, mcpHandler, mcpIcons, mcpInstructions, mcpRoutes, mcpServer, mcpToolContext, mcpTools, migrationCommand, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, realtimeRelayMigration, recordOf, relayed, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
173
+ export { type Admin, type AdminAuth, type AdminOptions, type AggregateParams, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApnsEnvironment, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type ArgumentsPayload, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type AuthHooks, type AuthSettings, type AuthenticateSettings, type Authenticator, type BatchOperation, type BatchResult, 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 CommentToolsInput, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DecryptField, type Device, type DeviceColumn, type DeviceKind, type DeviceStore, DeviceStoreError, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueJob, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldDecrypter, type FieldReader, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileProviderSettings, 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 HistoryColumn, type HistoryOptions, type HistoryPosition, type HistoryStore, HistoryStoreError, type HookContext, HttpProblem, type IssueTokenOptions, type JobContext, type JobDeclaration, type JobDefinition, type JobName, type JobOptions, type JobPayload, type JobRun, type JobsRuntime, type JsonObject, type JsonValue, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpApprovals, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, type Membership, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewDevice, type NewNotification, type NewOrganization, type NewUser, type NotificationRecipients, NotificationSendError, type NotificationsOptions, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type PushAnswer, type PushMessage, PushMessageError, type PushSender, type QueryOptions, type QueryResult, type ReadableResources, type RealtimeOptions, type RealtimePrune, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordGroups, type RecordKey, 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 SendNotification, 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 StoredNotification, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolComment, type ToolCommentPage, type ToolComments, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type Urgency, type User, type UserCreatedHook, type VapidSettings, type ViewSource, type WebPushFields, type WebPushOptions, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addCommentTool, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appNameOver, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaDrift, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentTools, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, countRecordsTool, createAdmin, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, describeResourceTool, deviceIdParam, deviceStore, devicesPath, enqueueJob, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listCommentsTool, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpApprovals, mcpGate, mcpHandler, mcpIcons, mcpInstructions, mcpRoutes, mcpServer, mcpToolContext, mcpTools, migrationCommand, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, realtimeRelayMigration, recordOf, relayed, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
@@ -1,3 +1,4 @@
1
+ import { DecryptField } from "../encryption/decrypt-field-types.js";
1
2
  import { StandardSchema } from "../functions/read-json.js";
2
3
  import { Mailer } from "../mail/smtp-mailer.js";
3
4
  import { SendNotification } from "../notifications/send-input.js";
@@ -17,6 +18,11 @@ export type JobContext = {
17
18
  };
18
19
  /** Aborted when the job's timeout passes, or when the worker shuts down and its grace period ends. */
19
20
  signal: AbortSignal;
21
+ /**
22
+ * The cleartext of an `.encrypted()` field of one record, or `null` when it is empty: privileged like `db`, with no
23
+ * access rules or tenant scope. Each read is audited as a reveal, naming this job, and fails when the audit does.
24
+ */
25
+ decryptField: DecryptField;
20
26
  /** Sends mail with the app's mailer: its auth/mailer.ts, else the SMTP server of `PROTOBASE_SMTP_URL` and `PROTOBASE_MAIL_FROM`; `undefined` when the app has no mail. */
21
27
  mail?: Mailer;
22
28
  /** Sends a notification as `sendNotification` does; `undefined` when the app has no `notifications`. */
@@ -49,11 +49,14 @@ const namedTimeout = (definition, error) => {
49
49
  if (!isStatementTimeout(error)) return error;
50
50
  return new Error(statementTimeoutDetail(`Job "${definition.name}"`, { kind: definition.options.longQueries ? "long" : "database" }), { cause: error });
51
51
  };
52
+ const noDecrypter = (name) => async () => {
53
+ throw new Error(`Job "${name}" cannot decrypt fields: startJobs was not given the app's decryptField`);
54
+ };
52
55
  /**
53
56
  * One try of one job: completed when `run` returns, failed (to be retried while attempts are left) when it throws, and
54
57
  * failed for good on a `FatalJobError` or an invalid payload.
55
58
  */
56
- const runJob = async (definition, job, db, { mail, sendNotification } = {}) => {
59
+ const runJob = async (definition, job, db, { mail, sendNotification, decryptField } = {}) => {
57
60
  try {
58
61
  const payload = await checkPayload(definition, job.data ?? void 0);
59
62
  const context = {
@@ -66,6 +69,7 @@ const runJob = async (definition, job, db, { mail, sendNotification } = {}) => {
66
69
  startedOn: job.startedOn
67
70
  },
68
71
  signal: job.signal,
72
+ decryptField: decryptField ? decryptField({ job: definition.name }) : noDecrypter(definition.name),
69
73
  ...mail && { mail },
70
74
  ...sendNotification && { sendNotification }
71
75
  };
@@ -1,3 +1,4 @@
1
+ import { FieldDecrypter } from "../encryption/decrypt-field-types.js";
1
2
  import { Mailer } from "../mail/smtp-mailer.js";
2
3
  import { JobDefinition } from "./job-types.js";
3
4
  import { JobsEnqueuer } from "./current.js";
@@ -21,6 +22,8 @@ export type StartJobsOptions = {
21
22
  * `PROTOBASE_MAIL_FROM`, when set; `false` turns mail off whatever the environment says.
22
23
  */
23
24
  mailer?: Mailer | false;
25
+ /** The app's read of encrypted fields (`createAdmin(...).decryptField`), which each job's context binds to the job. */
26
+ decryptField?: FieldDecrypter;
24
27
  };
25
28
  /** What a drain left: whether no job is due or running any more, how many are, and when the next one is due. */
26
29
  export type DrainResult = {
@@ -31,8 +34,8 @@ export type DrainResult = {
31
34
  export type JobsRuntime = JobsEnqueuer & {
32
35
  /** Whether this process runs the jobs, or only enqueues them. */
33
36
  readonly works: boolean;
34
- /** Takes the project's jobs again, as `protobase dev` does when their files change. */
35
- update(jobs: JobDefinition[]): Promise<void>;
37
+ /** Takes the project's jobs again, as `protobase dev` does when their files change, with the rebuilt app's `decryptField`. */
38
+ update(jobs: JobDefinition[], decryptField?: FieldDecrypter): Promise<void>;
36
39
  /**
37
40
  * Runs the jobs that are due until none is due or running, or `seconds` have passed: for a host that wakes a sleeping
38
41
  * app to run its jobs. Needs this process to run jobs.
@@ -48,5 +51,5 @@ export type JobsRuntime = JobsEnqueuer & {
48
51
  * that retries jobs whose worker died. It reads the jobs schema, never changes it: check it first with
49
52
  * `checkJobsSchema`. Enqueueing through `defineJob(...).enqueue` reaches the runtime started here.
50
53
  */
51
- export declare const startJobs: ({ db, jobs, work, pollSeconds, shutdownSeconds, onError, now, mailer }: StartJobsOptions) => Promise<JobsRuntime>;
54
+ export declare const startJobs: ({ db, jobs, work, pollSeconds, shutdownSeconds, onError, now, mailer, decryptField }: StartJobsOptions) => Promise<JobsRuntime>;
52
55
  //#endregion
@@ -30,8 +30,9 @@ const createBoss = (db, work) => new PgBoss({
30
30
  * that retries jobs whose worker died. It reads the jobs schema, never changes it: check it first with
31
31
  * `checkJobsSchema`. Enqueueing through `defineJob(...).enqueue` reaches the runtime started here.
32
32
  */
33
- const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSeconds = 30, onError, now = () => /* @__PURE__ */ new Date(), mailer }) => {
33
+ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSeconds = 30, onError, now = () => /* @__PURE__ */ new Date(), mailer, decryptField }) => {
34
34
  const mail = mailer === void 0 ? platformMailer() : mailer || void 0;
35
+ let decrypter = decryptField;
35
36
  const database = busyDatabase(jobsDatabase(db));
36
37
  const boss = createBoss(database.db, work);
37
38
  boss.on("error", onError);
@@ -60,6 +61,7 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
60
61
  if (current.options.schedule !== void 0 && job.singletonKey === "protobase.schedule") await schedules.following(current, job.startAfter);
61
62
  return [await runJob(current, job, db, {
62
63
  mail,
64
+ ...decrypter && { decryptField: decrypter },
63
65
  ...definitions.has("notifications.send") && { sendNotification }
64
66
  })];
65
67
  }, { longQueries: current.options.longQueries });
@@ -69,7 +71,8 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
69
71
  concurrency
70
72
  });
71
73
  };
72
- const update = async (next) => {
74
+ const update = async (next, nextDecrypter) => {
75
+ if (nextDecrypter) decrypter = nextDecrypter;
73
76
  for (const definition of next) await ensureQueue(definition);
74
77
  for (const name of definitions.keys()) {
75
78
  if (next.some((definition) => definition.name === name)) continue;
@@ -1,15 +1,15 @@
1
1
  import { isMcpWrites } from "./mcp-writes.js";
2
2
  //#region src/mcp/mcp-authenticator.ts
3
3
  const hasAudiences = (authenticate) => typeof authenticate.forAudience === "function";
4
- const isMcpOptions = (options) => options === void 0 || options === false || typeof options === "object" && options !== null && !Array.isArray(options) && Object.entries(options).every(([name, value]) => value === void 0 || name === "sql" && typeof value === "boolean" || name === "writes" && isMcpWrites(value));
4
+ const isMcpOptions = (options) => options === void 0 || options === false || typeof options === "object" && options !== null && !Array.isArray(options) && Object.entries(options).every(([name, value]) => value === void 0 || name === "sql" && typeof value === "boolean" || name === "writes" && isMcpWrites(value) || name === "tools" && Array.isArray(value));
5
5
  /**
6
6
  * The authenticator of the MCP endpoint's tokens, when MCP is on (`auth.mcp`); none when the host serves its
7
7
  * own (`options.mcp: false`). Stops the server at startup when `options.mcp` is neither `false` nor
8
- * `{ sql?: boolean; writes?: boolean | { resources?: string[]; rolesWithoutApproval?: string[] } }`, when it is set without MCP, or when `authenticate` cannot verify tokens for
8
+ * `{ sql?: boolean; writes?: boolean | { resources?: string[]; rolesWithoutApproval?: string[] }; tools?: AssistantTool[] }`, when it is set without MCP, or when `authenticate` cannot verify tokens for
9
9
  * another audience.
10
10
  */
11
11
  const mcpAuthenticator = (authenticate, mcp, options) => {
12
- if (!isMcpOptions(options)) throw new Error("options.mcp takes false or { sql?: boolean, writes?: boolean | { resources?: [<resource>, ...], rolesWithoutApproval?: [<role>, ...] } }");
12
+ if (!isMcpOptions(options)) throw new Error("options.mcp takes false or { sql?: boolean, writes?: boolean | { resources?: [<resource>, ...], rolesWithoutApproval?: [<role>, ...] }, tools?: [<tool>, ...] }");
13
13
  if (options === false) return void 0;
14
14
  if (options && !mcp) throw new Error("mcpEndpoint needs auth.mcp in protobase.config.ts");
15
15
  if (!mcp) return void 0;
@@ -18,6 +18,14 @@ export type McpToolsInput = {
18
18
  * those of them that `writes` lists when it lists some. None by default.
19
19
  */
20
20
  commented?: string[];
21
+ /** The app's own tools, offered to every caller after the built-in ones. */
22
+ own?: AssistantTool[];
21
23
  };
22
- export declare const mcpTools: ({ db, sql, writes, commented }: McpToolsInput) => (session: Session) => AssistantTool[];
24
+ /**
25
+ * The MCP tools for a caller: the record tools, the write tools when `writes` is on, the comment tools when a resource
26
+ * takes comments, read-only SQL when `sql` is on and the caller sees the assistant, then the app's own tools. The
27
+ * tools are built once and only chosen per session: the MCP server compiles each tool's schema, and a schema object made
28
+ * per request would be compiled again.
29
+ */
30
+ export declare const mcpTools: ({ db, sql, writes, commented, own }: McpToolsInput) => (session: Session) => AssistantTool[];
23
31
  //#endregion
@@ -1,16 +1,33 @@
1
1
  import { seesAssistant } from "../assistant/assistant-settings.js";
2
+ import { checkedTools } from "../assistant/checked-tools.js";
2
3
  import { readOnlyQueryTool } from "../assistant/query-tools.js";
3
4
  import { readRecordTools } from "../assistant/record-tools.js";
4
5
  import { writeRecordTools } from "../assistant/write-record-tools.js";
5
6
  import { batchWriteTool } from "../assistant/batch-write-tool.js";
6
7
  import { commentTools } from "../assistant/comment-tools.js";
7
8
  //#region src/mcp/mcp-tools.ts
9
+ const writeTools = (input) => [...writeRecordTools(input), batchWriteTool(input)];
10
+ const builtInNames = (db) => new Set([
11
+ ...readRecordTools(),
12
+ ...writeTools({}),
13
+ ...commentTools({ commented: ["comments"] }, {}),
14
+ readOnlyQueryTool(db)
15
+ ].map((tool) => tool.name));
16
+ /** The app's own tools; one whose name is taken twice, or by a built-in tool, stops the server at startup. */
17
+ const checkedOwnTools = (db, own) => {
18
+ const builtIn = builtInNames(db);
19
+ const taken = own.find((tool) => builtIn.has(tool.name));
20
+ if (taken) throw new Error(`The MCP tool "${taken.name}" has the name of a built-in MCP tool; give it another name`);
21
+ return checkedTools(own);
22
+ };
8
23
  /**
9
- * The default MCP tools for a caller: the record tools, the write tools when `writes` is on, the comment tools when a
10
- * resource takes comments, and read-only SQL when `sql` is on and the caller sees the assistant.
24
+ * The MCP tools for a caller: the record tools, the write tools when `writes` is on, the comment tools when a resource
25
+ * takes comments, read-only SQL when `sql` is on and the caller sees the assistant, then the app's own tools. The
26
+ * tools are built once and only chosen per session: the MCP server compiles each tool's schema, and a schema object made
27
+ * per request would be compiled again.
11
28
  */
12
- const writeTools = (input) => [...writeRecordTools(input), batchWriteTool(input)];
13
- const mcpTools = ({ db, sql, writes, commented = [] }) => {
29
+ const mcpTools = ({ db, sql, writes, commented = [], own = [] }) => {
30
+ const ownTools = checkedOwnTools(db, own);
14
31
  const read = readRecordTools();
15
32
  const limits = writes === false ? false : writes === true || !writes.resources ? {} : { resources: writes.resources };
16
33
  const write = limits === false ? [] : writeTools(limits);
@@ -20,7 +37,8 @@ const mcpTools = ({ db, sql, writes, commented = [] }) => {
20
37
  ...read,
21
38
  ...write,
22
39
  ...comments,
23
- ...seesAssistant(session.user.roles) ? query : []
40
+ ...seesAssistant(session.user.roles) ? query : [],
41
+ ...ownTools
24
42
  ];
25
43
  };
26
44
  //#endregion
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.57.6";
2
+ var version = "0.58.1";
3
3
  //#endregion
4
4
  export { version };
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { FieldDecrypter } from "./encryption/decrypt-field-types.js";
1
2
  import { JobDefinition } from "./jobs/job-types.js";
2
3
  import { RealtimePrune } from "./realtime/realtime-prune.js";
3
4
  import { Hono } from "hono";
@@ -95,11 +96,18 @@ export type FieldRevealedEvent = {
95
96
  type: 'field.revealed';
96
97
  /** ISO 8601, when the server answered. */
97
98
  at: string;
98
- actor: {
99
+ /** The caller; absent when a job read the value (`reader.job`). */
100
+ actor?: {
99
101
  id: string | number;
100
102
  roles: string[];
101
103
  };
102
- /** The caller's tenant (organization), when the resource has one. */
104
+ /** The job or API function that read the value with `decryptField`; absent for a reveal. */
105
+ reader?: {
106
+ job: string;
107
+ } | {
108
+ function: string;
109
+ };
110
+ /** The caller's tenant (organization), or the record's for `decryptField`, when the resource has one. */
103
111
  tenant?: TenantValue;
104
112
  resource: string;
105
113
  /** The record's key as its URL has it (composite keys joined with a comma). */
@@ -137,11 +145,13 @@ export type PageSource = {
137
145
  toModel(): PageModel;
138
146
  };
139
147
  /**
140
- * The admin API, the jobs it needs a worker to run (the delivery of resource events), and the pruning of the realtime
141
- * tables a serving process starts once it listens and stops as it closes.
148
+ * The admin API, the jobs it needs a worker to run (the delivery of resource events), the pruning of the realtime
149
+ * tables a serving process starts once it listens and stops as it closes, and the read of encrypted fields that
150
+ * `startJobs` gives the jobs' context.
142
151
  */
143
152
  export type Admin = Hono<AdminEnv> & {
144
153
  jobs: JobDefinition[];
145
154
  startRealtimePrune: () => RealtimePrune;
155
+ decryptField: FieldDecrypter;
146
156
  };
147
157
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.57.6",
3
+ "version": "0.58.1",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -41,9 +41,9 @@
41
41
  "@better-auth/passkey": "^1.7.12",
42
42
  "@better-auth/sso": "^1.7.12",
43
43
  "@modelcontextprotocol/server": "^2.3.1",
44
- "@protobase/layout": "0.57.6",
45
- "@protobase/query": "0.57.6",
46
- "@protobase/schema": "0.57.6",
44
+ "@protobase/layout": "0.58.1",
45
+ "@protobase/query": "0.58.1",
46
+ "@protobase/schema": "0.58.1",
47
47
  "@react-email/render": "^2.1.0",
48
48
  "@scalar/hono-api-reference": "^0.12.10",
49
49
  "@sentry/core": "^11.7.0",