@protobase/server 0.4.1 → 0.5.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.
@@ -0,0 +1,35 @@
1
+ import { AuditQueue, PipelineHook, ScanGuardMode } from "./types.js";
2
+ import { AssistantOptions } from "./assistant/assistant-settings.js";
3
+ import { RuntimeOptions } from "./runtime/runtime-settings.js";
4
+ import { ResolveOptions } from "@protobase/schema";
5
+ //#region src/admin-options.d.ts
6
+ export type AdminOptions = {
7
+ /** Default `/api/v1`. */
8
+ basePath?: string;
9
+ /** Applied to every request's transaction with SET LOCAL. Default 15000. */
10
+ statementTimeoutMs?: number;
11
+ /** List shapes that would sequentially scan a table of more than `seqScanRows` rows (default 10000). Default mode `reject`. */
12
+ scanGuard?: {
13
+ mode?: ScanGuardMode;
14
+ seqScanRows?: number;
15
+ };
16
+ /** Runs inside the write transaction after each create, update and delete. */
17
+ writeHooks?: PipelineHook[];
18
+ /** The project's roles (from `defineRoles`): an operation without an access rule needs the matching capability. */
19
+ roles?: ResolveOptions['roles'];
20
+ /** What an operation without a rule does; overrides the capability check. Without `roles` the default is allow. */
21
+ defaultAccess?: ResolveOptions['defaultAccess'];
22
+ /** Whether list items carry their `permissions` (update, delete): true by default, or `{ except: [...] }` for resources that opt out. */
23
+ rowPermissions?: boolean | {
24
+ except: string[];
25
+ };
26
+ /** Receives an event for every reveal of a sensitive field. Default: printed to the console, nothing kept. */
27
+ audit?: AuditQueue;
28
+ /** Called with every error the API turns into a 500, so the host can report it. */
29
+ onUnhandledError?: (error: unknown) => void;
30
+ /** The assistant in the admin app's dock: a backend elsewhere or the built-in one (see `AssistantOptions`); `false` turns it off. */
31
+ assistant?: AssistantOptions | false;
32
+ /** The runtime updates endpoint behind the top bar's update button (see `RuntimeOptions`); `false` turns it off. */
33
+ runtime?: RuntimeOptions | false;
34
+ };
35
+ //#endregion
@@ -1,8 +1,8 @@
1
1
  import { badRequest } from "./problem.js";
2
+ import { columnId, filterCondition, scopeConditions, tableId, whereClause } from "./sql-parts.js";
2
3
  import { checkedFilter } from "./filter-input.js";
3
4
  import { readScope, requestAccess, requireOperation } from "./request-access.js";
4
5
  import { readTransaction } from "./transactions.js";
5
- import { columnId, filterCondition, scopeConditions, tableId, whereClause } from "./sql-parts.js";
6
6
  import { parseParams } from "./params.js";
7
7
  import { facetCounts, histogram, series } from "@protobase/query";
8
8
  import { sql } from "kysely";
@@ -1,16 +1,12 @@
1
+ import { ModelSettings } from "./chat-completions.js";
2
+ import { AssistantTool } from "./tools.js";
1
3
  //#region src/assistant/assistant-settings.d.ts
2
- /** An OpenAI-compatible chat completions endpoint for the built-in assistant. */
3
- export type ModelSettings = {
4
- baseUrl: string;
5
- apiKey: string;
6
- model: string;
7
- fetch?: typeof fetch;
8
- };
9
4
  /**
10
5
  * `url`: an assistant backend elsewhere, which the dock talks to instead of the built-in one.
11
6
  * `apiKey`, `model`, `baseUrl`: the built-in assistant, which answers questions about the app with a chat model.
12
7
  * `chats`: the directory the built-in assistant keeps conversations in, one file each.
13
8
  * Each falls back to its environment variable; `fetch` replaces the model endpoint, for tests.
9
+ * `tools`: the app's own tools, which the built-in assistant offers after its own; a backend elsewhere has its own.
14
10
  */
15
11
  export type AssistantOptions = {
16
12
  url?: string;
@@ -19,6 +15,7 @@ export type AssistantOptions = {
19
15
  baseUrl?: string;
20
16
  chats?: string;
21
17
  fetch?: typeof fetch;
18
+ tools?: AssistantTool[];
22
19
  };
23
20
  export type AssistantSettings = {
24
21
  kind: 'external';
@@ -6,16 +6,31 @@ import { assistantProtocolRoutes } from "./protocol-routes.js";
6
6
  import { readOnlyQueryTool, readWriteQueryTool } from "./query-tools.js";
7
7
  import { runTurn } from "./turn.js";
8
8
  //#region src/assistant/built-in-assistant.ts
9
+ const toolName = /^[A-Za-z0-9_-]{1,64}$/;
10
+ /** The tools in order; a name a model endpoint refuses, or one taken twice, stops the server at startup. */
11
+ const checkedTools = (tools) => {
12
+ const seen = /* @__PURE__ */ new Set();
13
+ for (const tool of tools) {
14
+ if (!toolName.test(tool.name)) throw new Error(`Assistant tool name "${tool.name}" must be 1 to 64 letters, digits, _ or -`);
15
+ if (seen.has(tool.name)) throw new Error(`Two assistant tools are named "${tool.name}"; give one of them another name`);
16
+ seen.add(tool.name);
17
+ }
18
+ return tools;
19
+ };
9
20
  const pageContext = (page) => page && `The user is on the page ${page} of the app (/<resource> lists records, /<resource>/<key> shows one).`;
10
21
  /**
11
22
  * Protobase's own assistant: answers questions about the app, reads its data with read-only queries and proposes
12
- * changes the user approves, for callers with the `admin` or `ai` role. Made only of the exported building blocks and
13
- * its prompt, which describes what the caller may see of the app. Conversations are files in the `chats` directory.
14
- * On OpenRouter it caches the prompt and asks for little reasoning.
23
+ * changes the user approves, with the app's own tools besides, for callers with the `admin` or `ai` role. Made only of
24
+ * the exported building blocks and its prompt, which describes what the caller may see of the app. Conversations are
25
+ * files in the `chats` directory. On OpenRouter it caches the prompt and asks for little reasoning.
15
26
  */
16
- const builtInAssistant = ({ model, chats, db, meta, authenticate, report }) => {
27
+ const builtInAssistant = ({ model, chats, db, meta, authenticate, tools: appTools = [], records, report }) => {
17
28
  const conversations = createConversations({ store: fileConversationStore(chats) });
18
- const tools = [readOnlyQueryTool(db), readWriteQueryTool(db)];
29
+ const tools = checkedTools([
30
+ readOnlyQueryTool(db),
31
+ readWriteQueryTool(db),
32
+ ...appTools
33
+ ]);
19
34
  const onOpenRouter = model.baseUrl === openRouterUrl;
20
35
  return assistantProtocolRoutes({
21
36
  authenticate,
@@ -30,6 +45,7 @@ const builtInAssistant = ({ model, chats, db, meta, authenticate, report }) => {
30
45
  session,
31
46
  system: appContext(body.resources, body.views),
32
47
  tools,
48
+ ...records && { records: records(session) },
33
49
  report,
34
50
  ...page && { context: pageContext(page) },
35
51
  ...onOpenRouter && {
@@ -1,5 +1,11 @@
1
- import { ModelSettings } from "./assistant-settings.js";
2
1
  //#region src/assistant/chat-completions.d.ts
2
+ /** An OpenAI-compatible chat completions endpoint for the built-in assistant. */
3
+ export type ModelSettings = {
4
+ baseUrl: string;
5
+ apiKey: string;
6
+ model: string;
7
+ fetch?: typeof fetch;
8
+ };
3
9
  export type ToolCall = {
4
10
  id: string;
5
11
  type: 'function';
@@ -1,5 +1,5 @@
1
- import { Session } from "../types.js";
2
1
  import { ChatMessage } from "./chat-completions.js";
2
+ import { Session } from "../types.js";
3
3
  import { ConversationStore } from "./conversation-store.js";
4
4
  import { AssistantEvent, AssistantState } from "@protobase/schema";
5
5
  //#region src/assistant/conversations.d.ts
@@ -0,0 +1,23 @@
1
+ import { Row } from "../types.js";
2
+ import "../deps.js";
3
+ //#region src/assistant/records.d.ts
4
+ /** A record's key: its URL text (`a,b` when composite), a scalar, or its parts. */
5
+ export type RecordKeyInput = string | number | Array<string | number>;
6
+ /** A record as the user may see it, with the ETag a later update can pass to fail when someone changed it since. */
7
+ export type ToolRecord = {
8
+ record: Row;
9
+ etag: string;
10
+ };
11
+ /**
12
+ * The app's resources as the session's user: their access rules, tenant scope, validation and write hooks apply as
13
+ * in the REST API. A refusal or a missing record rejects with an `HttpProblem`, which a tool can turn into text.
14
+ */
15
+ export type ToolRecords = {
16
+ get: (resource: string, key: RecordKeyInput) => Promise<ToolRecord>;
17
+ create: (resource: string, data: Row) => Promise<ToolRecord>;
18
+ /** With `etag`, fails with 412 when the record changed since it was read; without, it updates any version. */
19
+ update: (resource: string, key: RecordKeyInput, data: Row, options?: {
20
+ etag?: string;
21
+ }) => Promise<ToolRecord>;
22
+ };
23
+ //#endregion
@@ -0,0 +1,53 @@
1
+ import { notFound } from "../problem.js";
2
+ import { decodeKeyText } from "../key.js";
3
+ import { readRecord } from "../read-service.js";
4
+ import { runWrite } from "../write-pipeline.js";
5
+ import { encodeKey } from "@protobase/schema";
6
+ //#region src/assistant/records.ts
7
+ /** `ToolRecords` over the admin's resources for `session`. */
8
+ const assistantRecords = (deps, session) => {
9
+ const entryOf = (resource) => {
10
+ const entry = deps.registry.find(resource);
11
+ if (!entry) throw notFound(`There is no resource "${resource}"`);
12
+ return entry;
13
+ };
14
+ const keyOf = (resource, key) => {
15
+ const entry = entryOf(resource);
16
+ return {
17
+ entry,
18
+ key: decodeKeyText(entry, Array.isArray(key) ? encodeKey(key) : String(key))
19
+ };
20
+ };
21
+ return {
22
+ get: async (resource, input) => {
23
+ const { entry, key } = keyOf(resource, input);
24
+ const { record, etag } = await readRecord(deps, entry, session, key);
25
+ return {
26
+ record,
27
+ etag
28
+ };
29
+ },
30
+ create: async (resource, data) => runWrite(deps, entryOf(resource), session, {
31
+ operation: "create",
32
+ body: data
33
+ }),
34
+ update: async (resource, input, data, { etag } = {}) => {
35
+ const { entry, key } = keyOf(resource, input);
36
+ return runWrite(deps, entry, session, {
37
+ operation: "update",
38
+ key,
39
+ body: data,
40
+ ifMatch: etag ?? "*"
41
+ });
42
+ }
43
+ };
44
+ };
45
+ const withoutAdmin = () => Promise.reject(/* @__PURE__ */ new Error("This assistant has no records: ToolContext.records works in the built-in assistant"));
46
+ /** Records for a turn without an admin around it, such as in a backend of its own: every call fails. */
47
+ const noRecords = {
48
+ get: withoutAdmin,
49
+ create: withoutAdmin,
50
+ update: withoutAdmin
51
+ };
52
+ //#endregion
53
+ export { assistantRecords, noRecords };
@@ -1,6 +1,7 @@
1
1
  import { Session } from "../types.js";
2
2
  import { Conversation } from "./conversations.js";
3
3
  import { ApprovalAnswer, ApprovalRequest } from "./approval.js";
4
+ import { ToolRecords } from "./records.js";
4
5
  import { AssistantPart } from "@protobase/schema";
5
6
  //#region src/assistant/tools.d.ts
6
7
  export type ToolContext = {
@@ -10,6 +11,8 @@ export type ToolContext = {
10
11
  show: (part: AssistantPart) => void;
11
12
  /** Asks the user to approve with a card in that message (see `requestApproval`). */
12
13
  approve: (request: ApprovalRequest) => Promise<ApprovalAnswer>;
14
+ /** The app's resources as the session's user, through their access rules and write hooks (built-in assistant only). */
15
+ records: ToolRecords;
13
16
  };
14
17
  /**
15
18
  * A function the model may call during a turn. `parameters` is the JSON Schema of its arguments; `run` gets them
@@ -1,7 +1,7 @@
1
- import { ModelSettings } from "./assistant-settings.js";
1
+ import { ModelSettings, ReasoningEffort } from "./chat-completions.js";
2
2
  import { Session } from "../types.js";
3
- import { ReasoningEffort } from "./chat-completions.js";
4
3
  import { Conversation } from "./conversations.js";
4
+ import { ToolRecords } from "./records.js";
5
5
  import { AssistantTool } from "./tools.js";
6
6
  //#region src/assistant/turn.d.ts
7
7
  export type TurnOptions = {
@@ -11,6 +11,8 @@ export type TurnOptions = {
11
11
  /** The system prompt. */
12
12
  system: string;
13
13
  tools?: AssistantTool[];
14
+ /** What tools get as `records`; without it, their calls fail. */
15
+ records?: ToolRecords;
14
16
  /** Model calls in one turn before it stops. Default 8. */
15
17
  maxSteps?: number;
16
18
  /** Gets errors other than the model endpoint's refusals, which the user only sees as a failure. */
@@ -1,6 +1,7 @@
1
1
  import { requestApproval } from "./approval.js";
2
2
  import { withCacheBreakpoints } from "./cache-breakpoints.js";
3
3
  import { ModelError, streamChatCompletion } from "./chat-completions.js";
4
+ import { noRecords } from "./records.js";
4
5
  //#region src/assistant/turn.ts
5
6
  const toolResult = async (call, tools, context) => {
6
7
  const tool = tools.find((entry) => entry.name === call.function.name);
@@ -39,7 +40,8 @@ const runTurn = async (options, text) => {
39
40
  session,
40
41
  conversation,
41
42
  show,
42
- approve: (request) => requestApproval(conversation, messageId, request)
43
+ approve: (request) => requestApproval(conversation, messageId, request),
44
+ records: options.records ?? noRecords
43
45
  };
44
46
  const specs = tools.map((tool) => ({
45
47
  type: "function",
@@ -0,0 +1,15 @@
1
+ import { AssistantWidgetPart } from "@protobase/schema";
2
+ //#region src/assistant/widget.d.ts
3
+ /** The most a widget's props may take, as JSON in UTF-8: room for ids and settings, not for the state it shows. */
4
+ export declare const maxWidgetPropsBytes = 2048;
5
+ export type WidgetOptions = {
6
+ id?: string;
7
+ fallback?: AssistantWidgetPart['fallback'];
8
+ };
9
+ /**
10
+ * A widget part for `show`: the app's component `name` draws it with `props`, fetching what it shows itself. A name
11
+ * that is not PascalCase, or props that are not a JSON object of at most 2 KB, throw, so a tool cannot keep state in
12
+ * the chat by accident. Without an `id`, the part gets a new one; show a part with the same id again to replace it.
13
+ */
14
+ export declare const widget: (name: string, props: Record<string, unknown>, options?: WidgetOptions) => AssistantWidgetPart;
15
+ //#endregion
@@ -0,0 +1,32 @@
1
+ //#region src/assistant/widget.ts
2
+ const componentName = /^[A-Z][A-Za-z0-9]*$/;
3
+ /** The most a widget's props may take, as JSON in UTF-8: room for ids and settings, not for the state it shows. */
4
+ const maxWidgetPropsBytes = 2048;
5
+ const isPlainObject = (value) => typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
6
+ const isJson = (value) => {
7
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
8
+ if (typeof value === "number") return Number.isFinite(value);
9
+ if (Array.isArray(value)) return value.every(isJson);
10
+ return isPlainObject(value) && Object.values(value).every(isJson);
11
+ };
12
+ /**
13
+ * A widget part for `show`: the app's component `name` draws it with `props`, fetching what it shows itself. A name
14
+ * that is not PascalCase, or props that are not a JSON object of at most 2 KB, throw, so a tool cannot keep state in
15
+ * the chat by accident. Without an `id`, the part gets a new one; show a part with the same id again to replace it.
16
+ */
17
+ const widget = (name, props, options = {}) => {
18
+ if (!componentName.test(name)) throw new Error(`Widget name "${name}" must be PascalCase, such as TaskProposal`);
19
+ if (!isPlainObject(props) || !isJson(props)) throw new Error(`The props of widget ${name} must be a JSON object`);
20
+ const bytes = new TextEncoder().encode(JSON.stringify(props)).length;
21
+ if (bytes > 2048) throw new Error(`The props of widget ${name} take ${bytes} bytes, more than ${maxWidgetPropsBytes}; pass ids and let the widget fetch the rest`);
22
+ const { id = crypto.randomUUID(), fallback } = options;
23
+ return {
24
+ type: "widget",
25
+ id,
26
+ name,
27
+ props,
28
+ ...fallback && { fallback }
29
+ };
30
+ };
31
+ //#endregion
32
+ export { maxWidgetPropsBytes, widget };
@@ -1,10 +1,10 @@
1
1
  import { badRequest } from "../problem.js";
2
- import { tenantScope } from "../tenant.js";
3
- import { requireOperation, targetFilter, writableFields } from "../request-access.js";
4
2
  import { columnId, keyConditions, scopeConditions, tableId, whereClause } from "../sql-parts.js";
5
3
  import { fetchRecord } from "../records.js";
6
- import { missing, shown } from "../write-shared.js";
4
+ import { tenantScope } from "../tenant.js";
5
+ import { requireOperation, targetFilter, writableFields } from "../request-access.js";
7
6
  import { validateBody } from "../validate-body.js";
7
+ import { missing, shown } from "../write-shared.js";
8
8
  import { applyUpdate, planUpdate } from "../write-update.js";
9
9
  import { encodeKey } from "@protobase/schema";
10
10
  import { sql } from "kysely";
@@ -1,6 +1,7 @@
1
1
  import { AdminAuth } from "./better-auth/create-auth.js";
2
2
  import { ResourceSource } from "./resource-source.js";
3
- import { AdminEnv, AdminOptions, Authenticator, PageSource, ViewSource } from "./types.js";
3
+ import { AdminEnv, Authenticator, PageSource, ViewSource } from "./types.js";
4
+ import { AdminOptions } from "./admin-options.js";
4
5
  import { Hono } from "hono";
5
6
  import { UserMenuSource } from "@protobase/schema";
6
7
  import { Db } from "@protobase/query";
@@ -1,5 +1,6 @@
1
1
  import { assistantSettings } from "./assistant/assistant-settings.js";
2
2
  import { createErrorHandler } from "./error-handler.js";
3
+ import { assistantRecords } from "./assistant/records.js";
3
4
  import { builtInAssistant } from "./assistant/built-in-assistant.js";
4
5
  import { consoleAuditQueue } from "./audit/console-queue.js";
5
6
  import { authBasePath } from "./better-auth/create-auth.js";
@@ -84,14 +85,20 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
84
85
  app.route(authBasePath, statusRoute(auth));
85
86
  app.on(["GET", "POST"], `${authBasePath}/*`, (c) => auth.handler(c.req.raw));
86
87
  }
87
- if (assistant?.kind === "built-in") app.route(`${systemPath}/assistant`, builtInAssistant({
88
- model: assistant.model,
89
- chats: assistant.chats,
90
- db,
91
- meta,
92
- authenticate,
93
- report: options.onUnhandledError
94
- }));
88
+ if (assistant?.kind === "built-in") {
89
+ const tools = options.assistant ? options.assistant.tools : void 0;
90
+ const records = (session) => assistantRecords(deps, session);
91
+ app.route(`${systemPath}/assistant`, builtInAssistant({
92
+ model: assistant.model,
93
+ chats: assistant.chats,
94
+ db,
95
+ meta,
96
+ authenticate,
97
+ tools,
98
+ records,
99
+ report: options.onUnhandledError
100
+ }));
101
+ }
95
102
  app.route("/", batchRoute(deps, authenticate, meta));
96
103
  app.route(basePath, api);
97
104
  app.route(deps.systemPath || "/", system);
package/dist/deps.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import "./types.js";
2
+ import "./registry.js";
3
+ import "./scan-guard.js";
4
+ import { PageModel } from "@protobase/layout";
5
+ import { ResolveOptions, ViewModel } from "@protobase/schema";
6
+ import { Db } from "@protobase/query";
package/dist/index.d.ts CHANGED
@@ -3,10 +3,18 @@ import { StaffSignIn } from "./better-auth/staff-log.js";
3
3
  import { OperatorProvider, readOperatorSettings } from "./better-auth/operator-provider.js";
4
4
  import { AdminAuth, CreateAuthOptions, UserCreatedHook, createAuth, normalizeRoles } from "./better-auth/create-auth.js";
5
5
  import { ResourceSource, configExports } from "./resource-source.js";
6
- import { AssistantOptions, AssistantSettings, ModelSettings, assistantRoles, assistantSettings, assistantVariables, seesAssistant } from "./assistant/assistant-settings.js";
6
+ import { ChatMessage, ChatTextPart, Completion, CompletionOptions, ModelError, ModelSettings, ReasoningDetail, ReasoningEffort, ToolCall, ToolSpec, streamChatCompletion } from "./assistant/chat-completions.js";
7
+ import { Admin, AuditEvent, AuditQueue, Authenticator, PipelineHook, ScanGuardMode, Session, TenantValue, User, ViewSource, WriteEvent, WriteOperation } from "./types.js";
8
+ import { ConversationRecord, ConversationStore } from "./assistant/conversation-store.js";
9
+ import { Conversation, ConversationsOptions, conversationKey, createConversations } from "./assistant/conversations.js";
10
+ import { ApprovalAnswer, ApprovalRequest, requestApproval } from "./assistant/approval.js";
11
+ import { RecordKeyInput, ToolRecord, ToolRecords } from "./assistant/records.js";
12
+ import { AssistantTool, ToolContext, defineTool } from "./assistant/tools.js";
13
+ import { AssistantOptions, AssistantSettings, assistantRoles, assistantSettings, assistantVariables, seesAssistant } from "./assistant/assistant-settings.js";
7
14
  import { RuntimeOptions, runtimeUrl, runtimeVariable, seesRuntime } from "./runtime/runtime-settings.js";
8
- import { Admin, AdminOptions, AuditEvent, AuditQueue, Authenticator, PipelineHook, ScanGuardMode, Session, TenantValue, User, ViewSource, WriteEvent, WriteOperation } from "./types.js";
15
+ import { AdminOptions } from "./admin-options.js";
9
16
  import { CreateAdminInput, createAdmin } from "./create-admin.js";
17
+ import { ProjectConfig, defineConfig, mergeConfig } from "./project-config.js";
10
18
  import { consoleAuditQueue } from "./audit/console-queue.js";
11
19
  import { JwtOptions, jwtAuthenticator } from "./auth/jwt.js";
12
20
  import { HttpProblem, badRequest, forbidden, notFound, unauthorized } from "./problem.js";
@@ -14,15 +22,11 @@ import { BetterAuthAuthenticatorOptions, betterAuthAuthenticator } from "./bette
14
22
  import { parseRoles } from "./better-auth/parse-roles.js";
15
23
  import { NewUser, StoredUser, createUser, deleteUser, hasUsers, listUsers, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
16
24
  import { IssueTokenOptions, issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
17
- import { ChatMessage, ChatTextPart, Completion, CompletionOptions, ModelError, ReasoningDetail, ReasoningEffort, ToolCall, ToolSpec, streamChatCompletion } from "./assistant/chat-completions.js";
18
- import { ConversationRecord, ConversationStore } from "./assistant/conversation-store.js";
19
- import { Conversation, ConversationsOptions, conversationKey, createConversations } from "./assistant/conversations.js";
20
25
  import { AssistantBackend, assistantProtocolRoutes } from "./assistant/protocol-routes.js";
21
26
  import { fileConversationStore } from "./assistant/file-conversation-store.js";
22
27
  import { withCacheBreakpoints } from "./assistant/cache-breakpoints.js";
23
- import { ApprovalAnswer, ApprovalRequest, requestApproval } from "./assistant/approval.js";
24
- import { AssistantTool, ToolContext, defineTool } from "./assistant/tools.js";
25
28
  import { TurnOptions, runTurn } from "./assistant/turn.js";
29
+ import { WidgetOptions, maxWidgetPropsBytes, widget } from "./assistant/widget.js";
26
30
  import { QueryOptions, QueryResult, runReadOnlyQuery, runReadWriteQuery } from "./assistant/run-query.js";
27
31
  import { readOnlyQueryTool, readWriteQueryTool } from "./assistant/query-tools.js";
28
- export { type Admin, type AdminAuth, type AdminOptions, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type ChatMessage, type ChatTextPart, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CreateAdminInput, type CreateAuthOptions, HttpProblem, type IssueTokenOptions, type JwtOptions, ModelError, type ModelSettings, type NewUser, type OperatorProvider, type PipelineHook, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type ResourceSource, type RuntimeOptions, type ScanGuardMode, type Session, type SignInPolicy, type SignInRule, type StaffAccessRule, type StaffSignIn, type StoredUser, type TenantValue, type ToolCall, type ToolContext, type ToolSpec, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WriteEvent, type WriteOperation, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createUser, defaultSignInPolicy, defineTool, deleteUser, fileConversationStore, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, readOnlyQueryTool, readOperatorSettings, readWriteQueryTool, requestApproval, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, seesAssistant, seesRuntime, setUserBanned, setUserRole, signInPolicyMethods, streamChatCompletion, unauthorized, withCacheBreakpoints };
32
+ export { type Admin, type AdminAuth, type AdminOptions, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type ChatMessage, type ChatTextPart, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CreateAdminInput, type CreateAuthOptions, HttpProblem, type IssueTokenOptions, type JwtOptions, ModelError, type ModelSettings, type NewUser, type OperatorProvider, type PipelineHook, type ProjectConfig, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RecordKeyInput, type ResourceSource, type RuntimeOptions, type ScanGuardMode, type Session, type SignInPolicy, type SignInRule, type StaffAccessRule, type StaffSignIn, type StoredUser, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WidgetOptions, type WriteEvent, type WriteOperation, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createUser, defaultSignInPolicy, defineConfig, defineTool, deleteUser, fileConversationStore, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, normalizeRoles, notFound, parseRoles, readOnlyQueryTool, readOperatorSettings, readWriteQueryTool, requestApproval, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, seesAssistant, seesRuntime, setUserBanned, setUserRole, signInPolicyMethods, streamChatCompletion, unauthorized, widget, withCacheBreakpoints };
package/dist/index.js CHANGED
@@ -19,7 +19,9 @@ import { createUser, deleteUser, hasUsers, listUsers, roleChoices, setUserBanned
19
19
  import { runtimeUrl, runtimeVariable, seesRuntime } from "./runtime/runtime-settings.js";
20
20
  import { createAdmin } from "./create-admin.js";
21
21
  import { configExports } from "./resource-source.js";
22
+ import { defineConfig, mergeConfig } from "./project-config.js";
22
23
  import { jwtAuthenticator } from "./auth/jwt.js";
23
24
  import { betterAuthAuthenticator } from "./better-auth/authenticator.js";
24
25
  import { issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
25
- export { HttpProblem, ModelError, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createUser, defaultSignInPolicy, defineTool, deleteUser, fileConversationStore, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, readOnlyQueryTool, readOperatorSettings, readWriteQueryTool, requestApproval, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, seesAssistant, seesRuntime, setUserBanned, setUserRole, signInPolicyMethods, streamChatCompletion, unauthorized, withCacheBreakpoints };
26
+ import { maxWidgetPropsBytes, widget } from "./assistant/widget.js";
27
+ export { HttpProblem, ModelError, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createUser, defaultSignInPolicy, defineConfig, defineTool, deleteUser, fileConversationStore, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, normalizeRoles, notFound, parseRoles, readOnlyQueryTool, readOperatorSettings, readWriteQueryTool, requestApproval, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, seesAssistant, seesRuntime, setUserBanned, setUserRole, signInPolicyMethods, streamChatCompletion, unauthorized, widget, withCacheBreakpoints };
@@ -1,11 +1,11 @@
1
- import { checkedFilter, checkedSort } from "./filter-input.js";
2
- import { readScope, requestAccess, requireOperation } from "./request-access.js";
3
- import { readTransaction } from "./transactions.js";
4
1
  import { etagAlias } from "./sql-parts.js";
5
2
  import { etagOf } from "./etag.js";
6
3
  import { exposeRow, pickFields } from "./rows.js";
7
- import { deletedRows, listParams, pageSizeOf, parseParams, seekParams, selectedFields } from "./params.js";
4
+ import { checkedFilter, checkedSort } from "./filter-input.js";
5
+ import { readScope, requestAccess, requireOperation } from "./request-access.js";
8
6
  import { rowPermissions } from "./row-permissions.js";
7
+ import { readTransaction } from "./transactions.js";
8
+ import { deletedRows, listParams, pageSizeOf, parseParams, seekParams, selectedFields } from "./params.js";
9
9
  import { estimateCount, exactCount, listQuery, seekToPosition } from "@protobase/query";
10
10
  //#region src/list-service.ts
11
11
  /**
@@ -0,0 +1,28 @@
1
+ import { AdminAuth } from "./better-auth/create-auth.js";
2
+ import { Authenticator } from "./types.js";
3
+ import { AdminOptions } from "./admin-options.js";
4
+ import { Db } from "@protobase/query";
5
+ //#region src/project-config.d.ts
6
+ /** What `protobase.config.ts` may default-export. Everything is optional. */
7
+ export type ProjectConfig = {
8
+ /** Names the config in errors, such as two configs defining the same tool; an extension should set it. */
9
+ name?: string;
10
+ /** Configs this one extends: merged in order before it (see `mergeConfig`). */
11
+ extends?: ProjectConfig[];
12
+ /** The config module, e.g. `import * as config from './config'`; default: ./config/index.ts and ./config/*\/ui.ts. */
13
+ config?: Record<string, unknown>;
14
+ db?: Db;
15
+ authenticate?: Authenticator;
16
+ /** A Better Auth instance; createAdmin mounts its routes under /api/auth. */
17
+ auth?: AdminAuth;
18
+ options?: AdminOptions;
19
+ };
20
+ /** Typed identity, for `export default defineConfig({ extends: [...], config, auth, ... })` in `protobase.config.ts`. */
21
+ export declare const defineConfig: (config: ProjectConfig) => ProjectConfig;
22
+ /**
23
+ * One config from several, each after the ones it `extends`, the last being the app's own. Named things are all kept:
24
+ * resources, views and pages of the config modules, assistant tools; one name defined by two configs is an error naming
25
+ * both. Write hooks run in the same order. Every other value is the last config's that sets it, so the app's own wins.
26
+ */
27
+ export declare const mergeConfig: (...configs: ProjectConfig[]) => ProjectConfig;
28
+ //#endregion
@@ -0,0 +1,111 @@
1
+ import { isPageDefinition } from "@protobase/layout";
2
+ //#region src/project-config.ts
3
+ /** Typed identity, for `export default defineConfig({ extends: [...], config, auth, ... })` in `protobase.config.ts`. */
4
+ const defineConfig = (config) => config;
5
+ const lastSet = (objects) => Object.fromEntries(objects.flatMap((object) => Object.entries(object)).filter(([, value]) => value !== void 0));
6
+ const flatten = (config, label) => [...(config.extends ?? []).flatMap((parent, index) => flatten(parent, parent.name ?? `${label} extends[${index}]`)), {
7
+ config,
8
+ label
9
+ }];
10
+ const checkUnique = (kind, entries) => {
11
+ const seen = /* @__PURE__ */ new Map();
12
+ for (const { name, value, label } of entries) {
13
+ const first = seen.get(name);
14
+ if (first && first.value !== value) throw new Error(`${kind} "${name}" is defined twice, ${first.label === label ? `in ${label}` : `by ${first.label} and by ${label}`}; rename one of them`);
15
+ if (!first) seen.set(name, {
16
+ value,
17
+ label
18
+ });
19
+ }
20
+ };
21
+ const mergedModule = (named) => {
22
+ const withModule = named.filter(({ config }) => config.config);
23
+ if (withModule.length === 0) return void 0;
24
+ const exports = withModule.flatMap(({ config, label }) => Object.entries(config.config).map(([key, value]) => ({
25
+ key,
26
+ value,
27
+ label
28
+ })));
29
+ const values = exports.map((entry) => ({
30
+ ...entry,
31
+ value: entry.value
32
+ }));
33
+ checkUnique("The resource", values.filter(({ value }) => typeof value?.recordSchema === "function" && typeof value.toModel === "function").map(({ value, label }) => ({
34
+ name: String(value.toModel().name),
35
+ value,
36
+ label
37
+ })));
38
+ checkUnique("The page", exports.filter(({ value }) => isPageDefinition(value)).map(({ value, label }) => ({
39
+ name: value.name,
40
+ value,
41
+ label
42
+ })));
43
+ const menus = values.filter(({ value }) => typeof value?.toUserMenuModel === "function");
44
+ if (new Set(menus.map(({ value }) => value)).size > 1) throw new Error(`Only one config may export a user menu; ${menus.map(({ label }) => label).join(" and ")} each do`);
45
+ const module = {};
46
+ const own = withModule.at(-1).label;
47
+ for (const { key, value, label } of exports) {
48
+ if (Object.values(module).includes(value)) continue;
49
+ module[label === own ? key : `${label}:${key}`] = value;
50
+ }
51
+ return module;
52
+ };
53
+ const mergedAssistant = (named) => {
54
+ const options = named.map(({ config, label }) => ({
55
+ assistant: config.options?.assistant,
56
+ label
57
+ })).filter(({ assistant }) => assistant !== void 0);
58
+ if (options.length === 0) return void 0;
59
+ if (options.at(-1).assistant === false) return false;
60
+ const after = options.slice(options.findLastIndex(({ assistant }) => assistant === false) + 1);
61
+ const tools = after.flatMap(({ assistant, label }) => (assistant.tools ?? []).map((tool) => ({
62
+ tool,
63
+ label
64
+ })));
65
+ checkUnique("The assistant tool", tools.map(({ tool, label }) => ({
66
+ name: tool.name,
67
+ value: tool,
68
+ label
69
+ })));
70
+ return {
71
+ ...lastSet(after.map(({ assistant }) => assistant)),
72
+ ...tools.length > 0 && { tools: tools.map(({ tool }) => tool) }
73
+ };
74
+ };
75
+ const mergedOptions = (named) => {
76
+ const all = named.flatMap(({ config }) => config.options ? [config.options] : []);
77
+ if (all.length === 0) return void 0;
78
+ const writeHooks = all.flatMap((options) => options.writeHooks ?? []);
79
+ const assistant = mergedAssistant(named);
80
+ return {
81
+ ...lastSet(all),
82
+ ...writeHooks.length > 0 && { writeHooks },
83
+ ...assistant !== void 0 && { assistant }
84
+ };
85
+ };
86
+ /**
87
+ * One config from several, each after the ones it `extends`, the last being the app's own. Named things are all kept:
88
+ * resources, views and pages of the config modules, assistant tools; one name defined by two configs is an error naming
89
+ * both. Write hooks run in the same order. Every other value is the last config's that sets it, so the app's own wins.
90
+ */
91
+ const mergeConfig = (...configs) => {
92
+ const named = configs.flatMap((config, index) => flatten(config, config.name ?? `config ${index + 1}`));
93
+ const { db, authenticate, auth } = lastSet(named.map(({ config }) => ({
94
+ db: config.db,
95
+ authenticate: config.authenticate,
96
+ auth: config.auth
97
+ })));
98
+ const module = mergedModule(named);
99
+ const options = mergedOptions(named);
100
+ const name = configs.at(-1)?.name;
101
+ return {
102
+ ...name && { name },
103
+ ...module && { config: module },
104
+ ...db && { db },
105
+ ...authenticate && { authenticate },
106
+ ...auth && { auth },
107
+ ...options && { options }
108
+ };
109
+ };
110
+ //#endregion
111
+ export { defineConfig, mergeConfig };
@@ -1,10 +1,10 @@
1
1
  import { notFound } from "./problem.js";
2
- import { tenantScope } from "./tenant.js";
3
- import { recordDecision, requestAccess, requireOperation, targetFilter, visibleFields, writableFields } from "./request-access.js";
4
- import { readTransaction } from "./transactions.js";
5
2
  import { pickFields } from "./rows.js";
6
3
  import { fetchRecord } from "./records.js";
4
+ import { tenantScope } from "./tenant.js";
5
+ import { recordDecision, requestAccess, requireOperation, targetFilter, visibleFields, writableFields } from "./request-access.js";
7
6
  import { rowPermissions } from "./row-permissions.js";
7
+ import { readTransaction } from "./transactions.js";
8
8
  //#region src/read-service.ts
9
9
  /** `edit` for fields the caller may change on this record, `read` for the rest (sensitive ones included); hidden fields are not listed at all. */
10
10
  const fieldModes = (access, canUpdate) => Object.fromEntries(Object.keys(access.readable.fields).map((name) => [name, canUpdate && writableFields(access, "update").includes(name) ? "edit" : "read"]));
@@ -0,0 +1,2 @@
1
+ import "./resource-source.js";
2
+ import { ResourceModel } from "@protobase/schema";
@@ -1,11 +1,11 @@
1
1
  import { notFound } from "../problem.js";
2
+ import { keyFromPath } from "../key.js";
2
3
  import { pickFields } from "../rows.js";
4
+ import { readRecord } from "../read-service.js";
3
5
  import { runWrite } from "../write-pipeline.js";
4
- import { keyFromPath } from "../key.js";
5
6
  import { deletedRows, selectedFields } from "../params.js";
6
7
  import { returnPreference } from "./preferences.js";
7
8
  import { resolveTarget } from "./target.js";
8
- import { readRecord } from "../read-service.js";
9
9
  import { revealField } from "../reveal-service.js";
10
10
  import { Hono } from "hono";
11
11
  //#region src/routes/record.ts
@@ -1,7 +1,7 @@
1
- import { tenantScope } from "./tenant.js";
2
- import { recordDecision } from "./request-access.js";
3
1
  import { aliasedColumn, columnId, scopeConditions, tableId, whereClause } from "./sql-parts.js";
4
2
  import { fetchRecord } from "./records.js";
3
+ import { tenantScope } from "./tenant.js";
4
+ import { recordDecision } from "./request-access.js";
5
5
  import { compileFilter } from "@protobase/query";
6
6
  import { sql } from "kysely";
7
7
  //#region src/row-permissions.ts
@@ -0,0 +1,4 @@
1
+ import "./types.js";
2
+ import "./registry.js";
3
+ import { ResourceModel } from "@protobase/schema";
4
+ import { Db } from "@protobase/query";
package/dist/types.d.ts CHANGED
@@ -1,8 +1,6 @@
1
- import { AssistantOptions } from "./assistant/assistant-settings.js";
2
- import { RuntimeOptions } from "./runtime/runtime-settings.js";
3
1
  import { Hono } from "hono";
4
2
  import { PageModel } from "@protobase/layout";
5
- import { ResolveOptions, ResourceModel, ViewModel } from "@protobase/schema";
3
+ import { ResourceModel, ViewModel } from "@protobase/schema";
6
4
  import { Db } from "@protobase/query";
7
5
  //#region src/types.d.ts
8
6
  export type TenantValue = string | number;
@@ -64,35 +62,6 @@ export type AuditQueue = {
64
62
  publish(event: AuditEvent): Promise<void>;
65
63
  };
66
64
  export type ScanGuardMode = 'reject' | 'warn' | 'off';
67
- export type AdminOptions = {
68
- /** Default `/api/v1`. */
69
- basePath?: string;
70
- /** Applied to every request's transaction with SET LOCAL. Default 15000. */
71
- statementTimeoutMs?: number;
72
- /** List shapes that would sequentially scan a table of more than `seqScanRows` rows (default 10000). Default mode `reject`. */
73
- scanGuard?: {
74
- mode?: ScanGuardMode;
75
- seqScanRows?: number;
76
- };
77
- /** Runs inside the write transaction after each create, update and delete. */
78
- writeHooks?: PipelineHook[];
79
- /** The project's roles (from `defineRoles`): an operation without an access rule needs the matching capability. */
80
- roles?: ResolveOptions['roles'];
81
- /** What an operation without a rule does; overrides the capability check. Without `roles` the default is allow. */
82
- defaultAccess?: ResolveOptions['defaultAccess'];
83
- /** Whether list items carry their `permissions` (update, delete): true by default, or `{ except: [...] }` for resources that opt out. */
84
- rowPermissions?: boolean | {
85
- except: string[];
86
- };
87
- /** Receives an event for every reveal of a sensitive field. Default: printed to the console, nothing kept. */
88
- audit?: AuditQueue;
89
- /** Called with every error the API turns into a 500, so the host can report it. */
90
- onUnhandledError?: (error: unknown) => void;
91
- /** The assistant in the admin app's dock: a backend elsewhere or the built-in one (see `AssistantOptions`); `false` turns it off. */
92
- assistant?: AssistantOptions | false;
93
- /** The runtime updates endpoint behind the top bar's update button (see `RuntimeOptions`); `false` turns it off. */
94
- runtime?: RuntimeOptions | false;
95
- };
96
65
  export type ViewSource = {
97
66
  toModel(): ViewModel;
98
67
  };
@@ -1,10 +1,10 @@
1
1
  import { HttpProblem, badRequest } from "./problem.js";
2
+ import { deleteRecord, fetchRecord, insertRecord, restoreRecord } from "./records.js";
2
3
  import { tenantScope } from "./tenant.js";
3
4
  import { requestAccess, requireOperation, targetFilter, writableFields } from "./request-access.js";
4
5
  import { writeTransaction } from "./transactions.js";
5
- import { deleteRecord, fetchRecord, insertRecord, restoreRecord } from "./records.js";
6
- import { assertWritten, checkPrecondition, decide, denied, firstKeyField, missing, notifier, shown } from "./write-shared.js";
7
6
  import { validateBody } from "./validate-body.js";
7
+ import { assertWritten, checkPrecondition, decide, denied, firstKeyField, missing, notifier, shown } from "./write-shared.js";
8
8
  import { applyUpdate, planUpdate } from "./write-update.js";
9
9
  //#region src/write-pipeline.ts
10
10
  const withOwner = (access, values) => {
@@ -1,9 +1,9 @@
1
1
  import { forbidden, notFound, preconditionFailed, preconditionRequired } from "./problem.js";
2
- import { tenantScope } from "./tenant.js";
3
- import { andFilters, recordDecision } from "./request-access.js";
4
2
  import { ifMatchSatisfied } from "./etag.js";
5
3
  import { pickFields } from "./rows.js";
6
4
  import { fetchRecord } from "./records.js";
5
+ import { tenantScope } from "./tenant.js";
6
+ import { andFilters, recordDecision } from "./request-access.js";
7
7
  //#region src/write-shared.ts
8
8
  const missing = (entry) => notFound(`No ${entry.name} with this key`);
9
9
  const checkPrecondition = (current, ifMatch, required) => {
@@ -1,8 +1,8 @@
1
+ import { fetchRecord, updateRecord } from "./records.js";
1
2
  import { tenantScope } from "./tenant.js";
2
3
  import { targetFilter } from "./request-access.js";
3
- import { fetchRecord, updateRecord } from "./records.js";
4
- import { assertWritten, checkPrecondition, decide, denied, firstKeyField, missing, notifier } from "./write-shared.js";
5
4
  import { validateBody } from "./validate-body.js";
5
+ import { assertWritten, checkPrecondition, decide, denied, firstKeyField, missing, notifier } from "./write-shared.js";
6
6
  //#region src/write-update.ts
7
7
  /**
8
8
  * Everything an update needs before it writes: the row (locked, inside the caller's tenant and row filters), the ETag
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
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": {
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@better-auth/passkey": "^1.7.7",
33
- "@protobase/layout": "0.4.1",
34
- "@protobase/query": "0.4.1",
35
- "@protobase/schema": "0.4.1",
33
+ "@protobase/layout": "0.5.0",
34
+ "@protobase/query": "0.5.0",
35
+ "@protobase/schema": "0.5.0",
36
36
  "@scalar/hono-api-reference": "^0.12.10",
37
37
  "@simplewebauthn/server": "^13.3.1",
38
38
  "better-auth": "^1.7.7",