@protobase/server 0.4.1 → 0.6.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 (47) hide show
  1. package/dist/admin-options.d.ts +35 -0
  2. package/dist/aggregate-service.js +1 -1
  3. package/dist/assistant/assistant-settings.d.ts +4 -7
  4. package/dist/assistant/built-in-assistant.js +21 -5
  5. package/dist/assistant/chat-completions.d.ts +7 -1
  6. package/dist/assistant/conversations.d.ts +1 -1
  7. package/dist/assistant/records.d.ts +23 -0
  8. package/dist/assistant/records.js +53 -0
  9. package/dist/assistant/tools.d.ts +3 -0
  10. package/dist/assistant/turn.d.ts +4 -2
  11. package/dist/assistant/turn.js +3 -1
  12. package/dist/assistant/widget.d.ts +15 -0
  13. package/dist/assistant/widget.js +32 -0
  14. package/dist/batch/reorder.js +3 -3
  15. package/dist/better-auth/auth-schema.d.ts +14 -0
  16. package/dist/better-auth/auth-schema.js +35 -0
  17. package/dist/better-auth/create-auth.d.ts +41 -6
  18. package/dist/better-auth/create-auth.js +29 -8
  19. package/dist/better-auth/platform-sign-in.d.ts +30 -0
  20. package/dist/better-auth/platform-sign-in.js +88 -0
  21. package/dist/better-auth/policy-plugin.js +10 -5
  22. package/dist/better-auth/policy-store.js +5 -0
  23. package/dist/better-auth/sign-in-enforcement.js +11 -3
  24. package/dist/better-auth/sign-in-policy.d.ts +8 -1
  25. package/dist/better-auth/sign-in-policy.js +21 -7
  26. package/dist/better-auth/staff-plugin.js +1 -1
  27. package/dist/better-auth/status-route.js +10 -4
  28. package/dist/better-auth/users.d.ts +8 -1
  29. package/dist/better-auth/users.js +16 -1
  30. package/dist/create-admin.d.ts +2 -1
  31. package/dist/create-admin.js +15 -8
  32. package/dist/deps.d.ts +6 -0
  33. package/dist/index.d.ts +15 -9
  34. package/dist/index.js +5 -1
  35. package/dist/list-service.js +4 -4
  36. package/dist/project-config.d.ts +28 -0
  37. package/dist/project-config.js +111 -0
  38. package/dist/read-service.js +3 -3
  39. package/dist/registry.d.ts +2 -0
  40. package/dist/routes/record.js +2 -2
  41. package/dist/row-permissions.js +2 -2
  42. package/dist/scan-guard.d.ts +4 -0
  43. package/dist/types.d.ts +1 -32
  44. package/dist/write-pipeline.js +2 -2
  45. package/dist/write-shared.js +2 -2
  46. package/dist/write-update.js +2 -2
  47. package/package.json +4 -4
@@ -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";
@@ -0,0 +1,14 @@
1
+ import { AdminAuth } from "./create-auth.js";
2
+ //#region src/better-auth/auth-schema.d.ts
3
+ /**
4
+ * The SQL that brings the admin store up to what this version of Protobase and the project's Better Auth plugins need:
5
+ * new tables, columns and indexes, never a drop, each skipped where it exists. `undefined` when the store is current. A
6
+ * change Better Auth cannot make safely, such as a required column without a default on a table with rows, is an error.
7
+ */
8
+ export declare const authSchemaMigration: (auth: AdminAuth) => Promise<string | undefined>;
9
+ /**
10
+ * Refuses to go on while the admin store lacks a table or column Better Auth needs, since every sign-in would fail.
11
+ * The server never changes the schema itself: the project's migrations do, before the new version serves.
12
+ */
13
+ export declare const checkAuthSchema: (auth: AdminAuth) => Promise<void>;
14
+ //#endregion
@@ -0,0 +1,35 @@
1
+ import { getMigrations } from "better-auth/db/migration";
2
+ //#region src/better-auth/auth-schema.ts
3
+ /** The command that writes the pending changes as a migration file of the project. */
4
+ const authMigrationCommand = "protobase auth migration";
5
+ /**
6
+ * Better Auth's statements, each made to skip what exists already, so a migration written from an empty database also
7
+ * applies to one an earlier Protobase set up in place.
8
+ */
9
+ const skipExisting = (sql) => sql.split(";\n\n").map((statement) => statement.replace(/^create table (?!if not exists )/, "create table if not exists ").replace(/^create (unique )?index (?!if not exists )/, (_, unique) => `create ${unique ?? ""}index if not exists `).replace(/^(alter table .+? )add column (?!if not exists )/, "$1add column if not exists ")).join(";\n\n");
10
+ /**
11
+ * The SQL that brings the admin store up to what this version of Protobase and the project's Better Auth plugins need:
12
+ * new tables, columns and indexes, never a drop, each skipped where it exists. `undefined` when the store is current. A
13
+ * change Better Auth cannot make safely, such as a required column without a default on a table with rows, is an error.
14
+ */
15
+ const authSchemaMigration = async (auth) => {
16
+ const { toBeCreated, toBeAdded, toBeAddedIndexes, compileMigrations } = await getMigrations(auth.options);
17
+ if (toBeCreated.length === 0 && toBeAdded.length === 0 && toBeAddedIndexes.length === 0) return void 0;
18
+ return skipExisting(await compileMigrations());
19
+ };
20
+ /** The tables and `table.column`s the admin store lacks; an index missing is not a reason to refuse requests. */
21
+ const missingAuthSchema = async (auth) => {
22
+ const { toBeCreated, toBeAdded } = await getMigrations(auth.options, { throwOnUnsafe: false });
23
+ return [...toBeCreated.map(({ table }) => table), ...toBeAdded.flatMap(({ table, fields }) => Object.keys(fields).map((field) => `${table}.${field}`))];
24
+ };
25
+ /**
26
+ * Refuses to go on while the admin store lacks a table or column Better Auth needs, since every sign-in would fail.
27
+ * The server never changes the schema itself: the project's migrations do, before the new version serves.
28
+ */
29
+ const checkAuthSchema = async (auth) => {
30
+ const missing = await missingAuthSchema(auth);
31
+ if (missing.length === 0) return;
32
+ throw new Error(`The auth schema is behind this version of Protobase (missing ${missing.join(", ")}). Write the change as a migration with \`${authMigrationCommand}\`, apply it with the project's migrations, then start again.`);
33
+ };
34
+ //#endregion
35
+ export { authMigrationCommand, authSchemaMigration, checkAuthSchema, missingAuthSchema, skipExisting };
@@ -2,6 +2,7 @@ import { SignInPolicy } from "./sign-in-policy.js";
2
2
  import { StaffSignIn } from "./staff-log.js";
3
3
  import { Mailer } from "../mail/smtp-mailer.js";
4
4
  import { OperatorProvider } from "./operator-provider.js";
5
+ import { PlatformSignIn } from "./platform-sign-in.js";
5
6
  import { BetterAuthOptions } from "better-auth";
6
7
  //#region src/better-auth/create-auth.d.ts
7
8
  export type CreateAuthOptions = {
@@ -33,7 +34,7 @@ export type CreateAuthOptions = {
33
34
  /**
34
35
  * Sign-in providers for Better Auth, for example `{ github: { clientId, clientSecret } }`; the callback is
35
36
  * `{baseURL}/api/auth/callback/<provider>`. A provider also signs up people without an account, with the default role.
36
- * Default none.
37
+ * One with the id of `signInProvider` takes its place. Default none.
37
38
  */
38
39
  socialProviders?: BetterAuthOptions['socialProviders'];
39
40
  /** Encrypt the OAuth access, refresh and ID tokens Better Auth stores for social sign-ins, with `secret`. Default off. */
@@ -47,6 +48,13 @@ export type CreateAuthOptions = {
47
48
  * whatever the environment says.
48
49
  */
49
50
  operator?: OperatorProvider | false;
51
+ /**
52
+ * A sign-in provider the platform adds for people who already have an account: an OpenID Connect provider that never
53
+ * signs anyone up. Defaults to the one the platform passes in `PROTOBASE_SIGN_IN_ISSUER`, `PROTOBASE_SIGN_IN_CLIENT_ID`
54
+ * and `PROTOBASE_SIGN_IN_CLIENT_SECRET` (see `readPlatformSignIn`), when set; `false` turns it off whatever the
55
+ * environment says. Left out when `socialProviders` has a provider with the same id.
56
+ */
57
+ signInProvider?: PlatformSignIn | false;
50
58
  };
51
59
  type UserCreateHooks = NonNullable<NonNullable<NonNullable<BetterAuthOptions['databaseHooks']>['user']>['create']>;
52
60
  /** Better Auth's `databaseHooks.user.create.after`: the created user (with its `role`) and the request context, `null` outside a request. */
@@ -54,6 +62,12 @@ export type UserCreatedHook = NonNullable<UserCreateHooks['after']>;
54
62
  /** The configured roles with `admin` first; `admin` is added when missing. */
55
63
  export declare const normalizeRoles: (roles?: string[]) => string[];
56
64
  export declare const createAuth: (options: CreateAuthOptions) => import("better-auth").Auth<{
65
+ account: {
66
+ encryptOAuthTokens?: true | undefined;
67
+ accountLinking: {
68
+ allowDifferentEmails: true;
69
+ };
70
+ };
57
71
  plugins: [{
58
72
  id: "admin";
59
73
  version: string;
@@ -2458,7 +2472,7 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
2458
2472
  RESOLVED_USER_INVALID: import("better-auth").RawError<"RESOLVED_USER_INVALID">;
2459
2473
  };
2460
2474
  options: import("@better-auth/passkey").PasskeyOptions | undefined;
2461
- }, ...{
2475
+ }, ...({
2462
2476
  id: "email-otp";
2463
2477
  version: string;
2464
2478
  init(ctx: import("better-auth").AuthContext): {
@@ -2990,7 +3004,20 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
2990
3004
  INVALID_OTP: import("better-auth").RawError<"INVALID_OTP">;
2991
3005
  TOO_MANY_ATTEMPTS: import("better-auth").RawError<"TOO_MANY_ATTEMPTS">;
2992
3006
  };
2993
- }[], {
3007
+ } | {
3008
+ id: "generic-oauth";
3009
+ version: string;
3010
+ init: (ctx: import("better-auth").AuthContext) => Promise<{
3011
+ context: {
3012
+ socialProviders: import("better-auth").OAuthProvider<object, Partial<import("better-auth").ProviderOptions<object>>>[];
3013
+ };
3014
+ }>;
3015
+ options: import("better-auth/plugins").GenericOAuthOptions<string>;
3016
+ $ERROR_CODES: {
3017
+ INVALID_OAUTH_CONFIGURATION: import("better-auth").RawError<"INVALID_OAUTH_CONFIGURATION">;
3018
+ TOKEN_URL_NOT_FOUND: import("better-auth").RawError<"TOKEN_URL_NOT_FOUND">;
3019
+ };
3020
+ })[], {
2994
3021
  id: "protobase-sign-in-policy";
2995
3022
  schema: {
2996
3023
  readonly signInPolicy: {
@@ -3016,6 +3043,11 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
3016
3043
  readonly required: true;
3017
3044
  readonly defaultValue: "allowed";
3018
3045
  };
3046
+ readonly platformSignIn: {
3047
+ readonly type: "string";
3048
+ readonly required: true;
3049
+ readonly defaultValue: "allowed";
3050
+ };
3019
3051
  readonly createdAt: {
3020
3052
  readonly type: "date";
3021
3053
  readonly required: true;
@@ -3124,6 +3156,7 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
3124
3156
  }, {
3125
3157
  savedBy?: string | undefined;
3126
3158
  savedAt?: string | undefined;
3159
+ platformSignIn?: string | undefined;
3127
3160
  operator?: string | undefined;
3128
3161
  policy: SignInPolicy;
3129
3162
  effective: SignInPolicy;
@@ -3226,6 +3259,7 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
3226
3259
  }, {
3227
3260
  savedBy?: string | undefined;
3228
3261
  savedAt?: string | undefined;
3262
+ platformSignIn?: string | undefined;
3229
3263
  operator?: string | undefined;
3230
3264
  policy: SignInPolicy;
3231
3265
  effective: SignInPolicy;
@@ -3556,9 +3590,6 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
3556
3590
  handler: () => void;
3557
3591
  };
3558
3592
  };
3559
- account?: {
3560
- encryptOAuthTokens: true;
3561
- } | undefined;
3562
3593
  socialProviders?: import("better-auth").SocialProviders | undefined;
3563
3594
  appName: string;
3564
3595
  database: any;
@@ -3585,6 +3616,10 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
3585
3616
  passwordReset: boolean;
3586
3617
  socialProviders: string[];
3587
3618
  operator: string | undefined;
3619
+ platformSignIn: {
3620
+ provider: string;
3621
+ name: string;
3622
+ } | undefined;
3588
3623
  };
3589
3624
  export type AdminAuth = ReturnType<typeof createAuth>;
3590
3625
  //#endregion
@@ -2,6 +2,7 @@ import { readMailSettings } from "../mail/mail-settings.js";
2
2
  import { smtpMailer } from "../mail/smtp-mailer.js";
3
3
  import { readOperatorSettings, resolveOperator } from "./operator-provider.js";
4
4
  import { passwordResetOptions } from "./password-reset.js";
5
+ import { platformSignInFor, platformSignInProvider, readPlatformSignIn, resolvePlatformSignIn } from "./platform-sign-in.js";
5
6
  import { signInPolicyPlugin } from "./policy-plugin.js";
6
7
  import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./sign-in-mail.js";
7
8
  import { staffSignInPlugin } from "./staff-plugin.js";
@@ -9,10 +10,11 @@ import { twoFactorAfterPasswordOrCode } from "./two-factor-challenge.js";
9
10
  import { passkey } from "@better-auth/passkey";
10
11
  import { APIError } from "better-auth/api";
11
12
  import { betterAuth } from "better-auth";
12
- import { admin, emailOTP, jwt } from "better-auth/plugins";
13
+ import { admin, emailOTP, genericOAuth, jwt } from "better-auth/plugins";
13
14
  import { adminAc, userAc } from "better-auth/plugins/admin/access";
14
15
  //#region src/better-auth/create-auth.ts
15
16
  const platformOperator = () => readOperatorSettings(globalThis.process?.env ?? {});
17
+ const platformSignInSettings = () => readPlatformSignIn(globalThis.process?.env ?? {});
16
18
  const platformMailer = () => {
17
19
  const settings = readMailSettings(globalThis.process?.env ?? {});
18
20
  return settings && smtpMailer(settings);
@@ -33,9 +35,13 @@ const normalizeRoles = (roles = ["admin", "user"]) => {
33
35
  return ["admin", ...new Set(roles.filter((role) => role !== "admin"))];
34
36
  };
35
37
  const adminImpersonationPaths = ["/admin/impersonate-user", "/admin/stop-impersonating"];
36
- /** The options `createAuth` gives Better Auth, with the roles, default role, mailer and operator provider already resolved. */
37
- const betterAuthOptions = (options, { roles, defaultRole, mailer, operator }) => {
38
+ /**
39
+ * The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
40
+ * sign-in provider already resolved.
41
+ */
42
+ const betterAuthOptions = (options, { roles, defaultRole, mailer, operator, platformSignIn }) => {
38
43
  const secure = options.baseURL.startsWith("https://");
44
+ const signIn = platformSignInFor(platformSignIn, Object.keys(options.socialProviders ?? {}));
39
45
  return {
40
46
  appName: options.appName ?? "Protobase",
41
47
  database: options.database,
@@ -54,7 +60,10 @@ const betterAuthOptions = (options, { roles, defaultRole, mailer, operator }) =>
54
60
  ...mailer && passwordResetOptions(mailer)
55
61
  },
56
62
  ...options.socialProviders && { socialProviders: options.socialProviders },
57
- ...options.encryptOAuthTokens && { account: { encryptOAuthTokens: true } },
63
+ account: {
64
+ accountLinking: { allowDifferentEmails: true },
65
+ ...options.encryptOAuthTokens && { encryptOAuthTokens: true }
66
+ },
58
67
  plugins: [
59
68
  admin({
60
69
  defaultRole: defaultRole ?? "unassigned",
@@ -78,10 +87,15 @@ const betterAuthOptions = (options, { roles, defaultRole, mailer, operator }) =>
78
87
  }),
79
88
  passkey(),
80
89
  ...mailer ? [emailOTP(emailCodeOptions(mailer))] : [],
90
+ ...signIn ? [genericOAuth({ config: [platformSignInProvider(signIn)] })] : [],
81
91
  signInPolicyPlugin({
82
92
  mail: Boolean(mailer),
83
93
  socialProviders: Object.keys(options.socialProviders ?? {}),
84
- ...operator && { operator: operator.name }
94
+ ...operator && { operator: operator.name },
95
+ ...signIn && { platformSignIn: {
96
+ provider: signIn.provider,
97
+ name: signIn.name
98
+ } }
85
99
  }),
86
100
  staffSignInPlugin({
87
101
  ...operator && { operator },
@@ -134,20 +148,27 @@ const createAuth = (options) => {
134
148
  const mailer = options.mailer === void 0 ? platformMailer() : options.mailer || void 0;
135
149
  const provider = options.operator === void 0 ? platformOperator() : options.operator || void 0;
136
150
  const operator = provider && resolveOperator(provider);
151
+ const signInSettings = options.signInProvider === void 0 ? platformSignInSettings() : options.signInProvider || void 0;
152
+ const socialProviders = Object.keys(options.socialProviders ?? {});
153
+ const platformSignIn = platformSignInFor(signInSettings && resolvePlatformSignIn(signInSettings), socialProviders);
137
154
  const auth = betterAuth(betterAuthOptions(options, {
138
155
  roles,
139
156
  defaultRole,
140
157
  mailer,
141
- operator
158
+ operator,
159
+ platformSignIn
142
160
  }));
143
- const socialProviders = Object.keys(options.socialProviders ?? {});
144
161
  return Object.assign(auth, {
145
162
  roles,
146
163
  defaultRole,
147
164
  mail: Boolean(mailer),
148
165
  passwordReset: Boolean(mailer),
149
166
  socialProviders,
150
- operator: operator?.name
167
+ operator: operator?.name,
168
+ platformSignIn: platformSignIn && {
169
+ provider: platformSignIn.provider,
170
+ name: platformSignIn.name
171
+ }
151
172
  });
152
173
  };
153
174
  //#endregion
@@ -0,0 +1,30 @@
1
+ import "better-auth/plugins";
2
+ //#region src/better-auth/platform-sign-in.d.ts
3
+ /**
4
+ * A sign-in provider the platform adds to the app, for people who already have an account: any OpenID Connect
5
+ * provider, with this app registered as a confidential client whose redirect URI is
6
+ * `{baseURL}/api/auth/callback/{provider}`. It never creates accounts.
7
+ */
8
+ export type PlatformSignIn = {
9
+ /** The provider's issuer URL; its settings are read from `{issuer}/.well-known/openid-configuration`. */
10
+ issuer: string;
11
+ clientId: string;
12
+ clientSecret: string;
13
+ /**
14
+ * The id of the provider: the button, the callback path, and the namespace of the linked accounts. Default `oidc`.
15
+ * Use `github` only when the provider's `sub` is the GitHub user id, so its accounts are those of Better Auth's own
16
+ * GitHub provider.
17
+ */
18
+ provider?: string;
19
+ /** Shown on the sign-in page as "Continue with {name}". Default `GitHub` for the `github` provider, `SSO` otherwise. */
20
+ name?: string;
21
+ /** Scopes asked for besides `openid email profile`. */
22
+ scopes?: string[];
23
+ };
24
+ /**
25
+ * The provider the platform passes in `PROTOBASE_SIGN_IN_ISSUER`, `PROTOBASE_SIGN_IN_CLIENT_ID` and
26
+ * `PROTOBASE_SIGN_IN_CLIENT_SECRET` (with `PROTOBASE_SIGN_IN_PROVIDER` and `PROTOBASE_SIGN_IN_NAME` optional);
27
+ * `undefined` when none of the three is set. Some but not all of them stops the server at startup.
28
+ */
29
+ export declare const readPlatformSignIn: (env: Record<string, string | undefined>) => PlatformSignIn | undefined;
30
+ //#endregion