@protobase/server 0.66.6 → 0.66.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assistant/delete-record-tool.d.ts +1 -1
- package/dist/assistant/delete-record-tool.js +11 -11
- package/dist/better-auth/auth-mail/auth-emails.d.ts +1 -0
- package/dist/better-auth/auth-mail/auth-emails.js +2 -1
- package/dist/better-auth/auth-mail/password-changed-email.d.ts +1 -0
- package/dist/better-auth/auth-mail/password-changed-email.js +32 -0
- package/dist/better-auth/auth-settings.d.ts +36 -2
- package/dist/better-auth/create-auth.d.ts +51 -7
- package/dist/better-auth/create-auth.js +14 -5
- package/dist/better-auth/mcp/revoke-grants.js +7 -4
- package/dist/better-auth/mobile-requests.js +23 -3
- package/dist/better-auth/password-change.js +68 -0
- package/dist/better-auth/password-checks.js +69 -0
- package/dist/better-auth/password-reset.js +2 -1
- package/dist/better-auth/sign-in-mail.js +8 -5
- package/dist/better-auth/sign-up/sign-up-code.js +4 -3
- package/dist/better-auth/sign-up/sign-up-plugin.js +3 -2
- package/dist/built-in-resources.d.ts +89 -0
- package/dist/built-in-resources.js +31 -0
- package/dist/changes/capture-schema.js +3 -1
- package/dist/comments/comments-sql.d.ts +4 -1
- package/dist/comments/comments-sql.js +16 -7
- package/dist/comments/record-comments.js +1 -1
- package/dist/config-exports.d.ts +3 -1
- package/dist/config-exports.js +3 -1
- package/dist/create-admin.js +9 -16
- package/dist/gotrue/user-routes.js +6 -4
- package/dist/has-indexes.js +9 -0
- package/dist/history/history-sql.js +13 -4
- package/dist/internal.js +1 -1
- package/dist/mcp/mcp-input-schemas.js +24 -0
- package/dist/mcp/mcp-server.js +3 -9
- package/dist/package.js +1 -1
- package/dist/webhooks/table/schema-sql.js +30 -6
- package/dist/webhooks/table/schema.js +10 -4
- package/package.json +4 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AssistantTool, ToolContext } from "./tools.js";
|
|
2
2
|
import { WriteRecordToolsInput } from "./write-tool-limits.js";
|
|
3
3
|
//#region src/assistant/delete-record-tool.d.ts
|
|
4
|
-
/** Deletes one record by its key and ETag once the user approves it, under the caller's access rules. */
|
|
4
|
+
/** Deletes one record by its key, and its ETag when given, once the user approves it, under the caller's access rules. */
|
|
5
5
|
export declare const deleteRecordTool: (input?: WriteRecordToolsInput) => AssistantTool<ToolContext>;
|
|
6
6
|
//#endregion
|
|
@@ -21,12 +21,15 @@ const deleteKeyOf = (value) => {
|
|
|
21
21
|
if (key === void 0) return void 0;
|
|
22
22
|
return (Array.isArray(key) ? key : [key]).every(keyPartOk) ? key : void 0;
|
|
23
23
|
};
|
|
24
|
-
const etagOf = (value) =>
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
const etagOf = (value) => {
|
|
25
|
+
if (value === void 0 || value === "*") return { etag: value };
|
|
26
|
+
return typeof value === "string" && /^"[\x21\x23-\x2b\x2d-\x7e]+"$/.test(value) ? { etag: value } : false;
|
|
27
|
+
};
|
|
28
|
+
const usage = "Pass only the resource, key and, optionally, etag as { \"resource\": \"<name>\", \"key\": \"<key>\", \"etag\": \"<the etag get_record gave>\" }; nothing was deleted.";
|
|
29
|
+
/** Deletes one record by its key, and its ETag when given, once the user approves it, under the caller's access rules. */
|
|
27
30
|
const deleteRecordTool = (input = {}) => defineTool({
|
|
28
31
|
name: "delete_record",
|
|
29
|
-
description: `Deletes one record of a resource by its key. Pass the etag get_record gave
|
|
32
|
+
description: `Deletes one record of a resource by its key. Pass the etag get_record gave to fail when someone changed the record since; without one, or with *, it deletes the record whatever its version. A resource that soft deletes keeps the record, deleted; any other loses it for good. The user approves each delete in their client first; nothing is deleted without it.${onlyNote(input)}`,
|
|
30
33
|
parameters: {
|
|
31
34
|
type: "object",
|
|
32
35
|
properties: {
|
|
@@ -37,11 +40,7 @@ const deleteRecordTool = (input = {}) => defineTool({
|
|
|
37
40
|
description: "The etag get_record gave, to fail when the record changed since."
|
|
38
41
|
}
|
|
39
42
|
},
|
|
40
|
-
required: [
|
|
41
|
-
"resource",
|
|
42
|
-
"key",
|
|
43
|
-
"etag"
|
|
44
|
-
],
|
|
43
|
+
required: ["resource", "key"],
|
|
45
44
|
additionalProperties: false
|
|
46
45
|
},
|
|
47
46
|
annotations: {
|
|
@@ -52,12 +51,13 @@ const deleteRecordTool = (input = {}) => defineTool({
|
|
|
52
51
|
run: async (args, context) => {
|
|
53
52
|
const resource = resourceOf(args);
|
|
54
53
|
const key = deleteKeyOf(args.key);
|
|
55
|
-
const
|
|
56
|
-
if (!resource || key === void 0 || !
|
|
54
|
+
const given = etagOf(args.etag);
|
|
55
|
+
if (!resource || key === void 0 || !given || Object.keys(args).some((name) => !parameterNames.includes(name))) return usage;
|
|
57
56
|
const refusal = outside(input, resource);
|
|
58
57
|
if (refusal) return refusal;
|
|
59
58
|
const softDelete = await context.records.softDeleteField(resource).then((field) => ({ field }), refused);
|
|
60
59
|
if (typeof softDelete === "string") return softDelete;
|
|
60
|
+
const { etag } = given;
|
|
61
61
|
const before = await recordBefore(context, resource, key, etag);
|
|
62
62
|
if (typeof before === "string") return before;
|
|
63
63
|
const { text, described } = await approvalTexts(context, resource);
|
|
@@ -2,6 +2,7 @@ import "./account-exists-email.js";
|
|
|
2
2
|
import "./change-email-code-email.js";
|
|
3
3
|
import "./invitation-email.js";
|
|
4
4
|
import "./no-account-email.js";
|
|
5
|
+
import "./password-changed-email.js";
|
|
5
6
|
import "./reset-password-email.js";
|
|
6
7
|
import "./sign-in-code-email.js";
|
|
7
8
|
import "./sign-up-code-email.js";
|
|
@@ -2,9 +2,10 @@ import { accountExistsEmail } from "./account-exists-email.js";
|
|
|
2
2
|
import { changeEmailCodeEmail } from "./change-email-code-email.js";
|
|
3
3
|
import { invitationEmail } from "./invitation-email.js";
|
|
4
4
|
import { noAccountEmail } from "./no-account-email.js";
|
|
5
|
+
import { passwordChangedEmail } from "./password-changed-email.js";
|
|
5
6
|
import { resetPasswordEmail } from "./reset-password-email.js";
|
|
6
7
|
import { signInCodeEmail } from "./sign-in-code-email.js";
|
|
7
8
|
import { signUpCodeEmail } from "./sign-up-code-email.js";
|
|
8
9
|
import { staffSignInEmail } from "./staff-sign-in-email.js";
|
|
9
10
|
import { twoFactorCodeEmail } from "./two-factor-code-email.js";
|
|
10
|
-
export { accountExistsEmail, changeEmailCodeEmail, invitationEmail, noAccountEmail, resetPasswordEmail, signInCodeEmail, signUpCodeEmail, staffSignInEmail, twoFactorCodeEmail };
|
|
11
|
+
export { accountExistsEmail, changeEmailCodeEmail, invitationEmail, noAccountEmail, passwordChangedEmail, resetPasswordEmail, signInCodeEmail, signUpCodeEmail, staffSignInEmail, twoFactorCodeEmail };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "../../index.js";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineEmail } from "../../mail/define-email.js";
|
|
2
|
+
import { EmailButton, EmailLayout, EmailNote } from "../../mail/email-layout.js";
|
|
3
|
+
import { signInPath } from "@protobase/schema";
|
|
4
|
+
import { Text } from "react-email";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/better-auth/auth-mail/password-changed-email.tsx
|
|
7
|
+
const PasswordChangedEmail = ({ app }) => /* @__PURE__ */ jsxs(EmailLayout, {
|
|
8
|
+
app,
|
|
9
|
+
preview: `The password of your ${app.name} account was changed`,
|
|
10
|
+
heading: "Your password was changed",
|
|
11
|
+
children: [
|
|
12
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
13
|
+
"The password of your account with ",
|
|
14
|
+
app.name,
|
|
15
|
+
" was just changed."
|
|
16
|
+
] }),
|
|
17
|
+
/* @__PURE__ */ jsx(Text, { children: "If this was not you, choose a new password at once with \"Forgot password?\" on the sign-in page:" }),
|
|
18
|
+
/* @__PURE__ */ jsx(EmailButton, {
|
|
19
|
+
href: new URL(signInPath({ step: "forgot-password" }), app.url).href,
|
|
20
|
+
children: "Choose a new password"
|
|
21
|
+
}),
|
|
22
|
+
/* @__PURE__ */ jsx(EmailNote, { children: "If this was you, ignore this email." })
|
|
23
|
+
]
|
|
24
|
+
});
|
|
25
|
+
/** The email to an account whose password changed: what happened, and how to take the account back if it was not them. */
|
|
26
|
+
const passwordChangedEmail = defineEmail({
|
|
27
|
+
name: "password-changed",
|
|
28
|
+
subject: ({ app }) => `Your ${app.name} password was changed`,
|
|
29
|
+
component: PasswordChangedEmail
|
|
30
|
+
});
|
|
31
|
+
//#endregion
|
|
32
|
+
export { passwordChangedEmail };
|
|
@@ -1009,8 +1009,8 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
1009
1009
|
$Infer: {
|
|
1010
1010
|
body: {
|
|
1011
1011
|
permissions: {
|
|
1012
|
-
readonly user?: ("
|
|
1013
|
-
readonly session?: ("
|
|
1012
|
+
readonly user?: ("list" | "create" | "update" | "delete" | "set-role" | "ban" | "impersonate" | "set-password" | "set-email" | "get" | "impersonate-admins")[] | undefined;
|
|
1013
|
+
readonly session?: ("list" | "delete" | "revoke")[] | undefined;
|
|
1014
1014
|
};
|
|
1015
1015
|
} & {
|
|
1016
1016
|
userId?: string | undefined;
|
|
@@ -4463,6 +4463,14 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
4463
4463
|
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
4464
4464
|
}[];
|
|
4465
4465
|
};
|
|
4466
|
+
} | {
|
|
4467
|
+
id: "protobase-password-checks";
|
|
4468
|
+
hooks: {
|
|
4469
|
+
before: {
|
|
4470
|
+
matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
|
|
4471
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
4472
|
+
}[];
|
|
4473
|
+
};
|
|
4466
4474
|
} | {
|
|
4467
4475
|
id: "protobase-set-password";
|
|
4468
4476
|
endpoints: {
|
|
@@ -4498,6 +4506,32 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
4498
4506
|
status: boolean;
|
|
4499
4507
|
}>;
|
|
4500
4508
|
};
|
|
4509
|
+
} | {
|
|
4510
|
+
id: "protobase-password-change";
|
|
4511
|
+
endpoints: {
|
|
4512
|
+
passwordChanged: import("better-call").StrictEndpoint<string, {
|
|
4513
|
+
method: "POST";
|
|
4514
|
+
body: import("zod").ZodObject<{
|
|
4515
|
+
email: import("zod").ZodString;
|
|
4516
|
+
}, import("zod/v4/core").$strip>;
|
|
4517
|
+
}, {
|
|
4518
|
+
status: boolean;
|
|
4519
|
+
}>;
|
|
4520
|
+
};
|
|
4521
|
+
hooks: {
|
|
4522
|
+
before: {
|
|
4523
|
+
matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
|
|
4524
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
|
|
4525
|
+
context: {
|
|
4526
|
+
body: any;
|
|
4527
|
+
};
|
|
4528
|
+
} | undefined>>;
|
|
4529
|
+
}[];
|
|
4530
|
+
after: {
|
|
4531
|
+
matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
|
|
4532
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
4533
|
+
}[];
|
|
4534
|
+
};
|
|
4501
4535
|
} | {
|
|
4502
4536
|
id: "protobase-leaked-passwords";
|
|
4503
4537
|
hooks: {
|
|
@@ -1141,8 +1141,8 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
1141
1141
|
$Infer: {
|
|
1142
1142
|
body: {
|
|
1143
1143
|
permissions: {
|
|
1144
|
-
readonly user?: ("
|
|
1145
|
-
readonly session?: ("
|
|
1144
|
+
readonly user?: ("list" | "create" | "update" | "delete" | "set-role" | "ban" | "impersonate" | "set-password" | "set-email" | "get" | "impersonate-admins")[] | undefined;
|
|
1145
|
+
readonly session?: ("list" | "delete" | "revoke")[] | undefined;
|
|
1146
1146
|
};
|
|
1147
1147
|
} & {
|
|
1148
1148
|
userId?: string | undefined;
|
|
@@ -2914,7 +2914,10 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
2914
2914
|
email: string;
|
|
2915
2915
|
emailVerified: boolean;
|
|
2916
2916
|
name: string;
|
|
2917
|
-
image
|
|
2917
|
+
image? /**
|
|
2918
|
+
* The secrets the app had before `secret`, newest first, such as `PROTOBASE_PREVIOUS_SECRETS`: what they encrypted
|
|
2919
|
+
* still decrypts, so the signing keys keep signing after the secret changes. Default none.
|
|
2920
|
+
*/ : string | null | undefined;
|
|
2918
2921
|
} & Record<string, any>;
|
|
2919
2922
|
} | {
|
|
2920
2923
|
status: boolean;
|
|
@@ -3099,7 +3102,11 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
3099
3102
|
email: string;
|
|
3100
3103
|
emailVerified: boolean;
|
|
3101
3104
|
name: string;
|
|
3102
|
-
image
|
|
3105
|
+
image? /**
|
|
3106
|
+
* Refuses a new password that appears in a known data breach, by Have I Been Pwned's password list: at sign-up, from
|
|
3107
|
+
* an invitation, and when a password is set, changed, reset or given by an admin. Only the first five characters of
|
|
3108
|
+
* the password's SHA-1 hash leave the server. While the list cannot be reached, setting a password fails. Default off.
|
|
3109
|
+
*/ : string | null | undefined;
|
|
3103
3110
|
};
|
|
3104
3111
|
};
|
|
3105
3112
|
}>>[];
|
|
@@ -4117,7 +4124,9 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
4117
4124
|
expiresAt: Date;
|
|
4118
4125
|
token: string;
|
|
4119
4126
|
ipAddress?: string | null | undefined;
|
|
4120
|
-
userAgent
|
|
4127
|
+
userAgent?
|
|
4128
|
+
/** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
|
|
4129
|
+
/** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */ : string | null | undefined;
|
|
4121
4130
|
};
|
|
4122
4131
|
user: Record<string, any> & {
|
|
4123
4132
|
id: string;
|
|
@@ -4591,6 +4600,14 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
4591
4600
|
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
4592
4601
|
}[];
|
|
4593
4602
|
};
|
|
4603
|
+
} | {
|
|
4604
|
+
id: "protobase-password-checks";
|
|
4605
|
+
hooks: {
|
|
4606
|
+
before: {
|
|
4607
|
+
matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
|
|
4608
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
4609
|
+
}[];
|
|
4610
|
+
};
|
|
4594
4611
|
} | {
|
|
4595
4612
|
id: "protobase-set-password";
|
|
4596
4613
|
endpoints: {
|
|
@@ -4626,6 +4643,32 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
4626
4643
|
status: boolean;
|
|
4627
4644
|
}>;
|
|
4628
4645
|
};
|
|
4646
|
+
} | {
|
|
4647
|
+
id: "protobase-password-change";
|
|
4648
|
+
endpoints: {
|
|
4649
|
+
passwordChanged: import("better-call").StrictEndpoint<string, {
|
|
4650
|
+
method: "POST";
|
|
4651
|
+
body: import("zod").ZodObject<{
|
|
4652
|
+
email: import("zod").ZodString;
|
|
4653
|
+
}, import("zod/v4/core").$strip>;
|
|
4654
|
+
}, {
|
|
4655
|
+
status: boolean;
|
|
4656
|
+
}>;
|
|
4657
|
+
};
|
|
4658
|
+
hooks: {
|
|
4659
|
+
before: {
|
|
4660
|
+
matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
|
|
4661
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
|
|
4662
|
+
context: {
|
|
4663
|
+
body: any;
|
|
4664
|
+
};
|
|
4665
|
+
} | undefined>>;
|
|
4666
|
+
}[];
|
|
4667
|
+
after: {
|
|
4668
|
+
matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
|
|
4669
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
4670
|
+
}[];
|
|
4671
|
+
};
|
|
4629
4672
|
} | {
|
|
4630
4673
|
id: "protobase-leaked-passwords";
|
|
4631
4674
|
hooks: {
|
|
@@ -5091,7 +5134,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
5091
5134
|
member: import("better-auth/plugins").Member & Record<string, any>;
|
|
5092
5135
|
}) => Promise<void | {
|
|
5093
5136
|
data: {
|
|
5094
|
-
name
|
|
5137
|
+
name? /** Runs after a user is created, by any route: `createUser`, an admin, or a social sign-up. Default none. */ : string;
|
|
5095
5138
|
slug?: string;
|
|
5096
5139
|
logo?: string | null;
|
|
5097
5140
|
metadata?: Record<string, any>;
|
|
@@ -8968,7 +9011,8 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
8968
9011
|
}> & {
|
|
8969
9012
|
/**
|
|
8970
9013
|
* Answers a request from the app's iOS or Android build: Better Auth also trusts the Capacitor origin the request
|
|
8971
|
-
* names. Only `createAdmin` with a `mobile` section uses it; `handler` and `api` never trust a Capacitor origin.
|
|
9014
|
+
* names. Only `createAdmin` with a `mobile` section uses it; `handler` and `api` never trust a Capacitor origin. A
|
|
9015
|
+
* Capacitor origin's POST without a JSON body is refused before it is granted, as only a page sends one.
|
|
8972
9016
|
*/
|
|
8973
9017
|
handleMobile: (request: Request) => Promise<Response>;
|
|
8974
9018
|
/**
|
|
@@ -13,13 +13,14 @@ import { endReplacedSession } from "./end-replaced-session.js";
|
|
|
13
13
|
import { authBasePath } from "./auth-base-path.js";
|
|
14
14
|
import { authLogger } from "./auth-logger.js";
|
|
15
15
|
import { cookiePrefixOf } from "./cookie-prefix.js";
|
|
16
|
-
import { mobileRequests } from "./mobile-requests.js";
|
|
16
|
+
import { jsonOnlyOnMobile, mobileRequests } from "./mobile-requests.js";
|
|
17
17
|
import { passwordLength } from "./new-account-password.js";
|
|
18
18
|
import { platformMailer } from "../mail/platform-mailer.js";
|
|
19
19
|
import { readOperatorSettings, resolveOperator } from "./operator-provider.js";
|
|
20
20
|
import { defaultInvitationUrl } from "./invitations/invitation-link-hook.js";
|
|
21
21
|
import { lastAdminGuardPlugin } from "./last-admin-guard.js";
|
|
22
22
|
import { setPasswordPath, setPasswordPlugin } from "./set-password.js";
|
|
23
|
+
import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./sign-in-mail.js";
|
|
23
24
|
import { signUpPaths, signUpPlugin } from "./sign-up/sign-up-plugin.js";
|
|
24
25
|
import { leakedPasswordsPlugin } from "./leaked-passwords.js";
|
|
25
26
|
import { lockedAccountsPlugin, readLockedAccounts } from "./locked-accounts.js";
|
|
@@ -35,6 +36,8 @@ import { passkeyHosts, readPasskeyDomain, resolvePasskeyDomain } from "./passkey
|
|
|
35
36
|
import { coreNames, pluginNames } from "./snake-case-names.js";
|
|
36
37
|
import { passkeyPlugin } from "./passkey-plugin.js";
|
|
37
38
|
import { passwordResetOptions } from "./password-reset.js";
|
|
39
|
+
import { mailPasswordChanged, passwordChangePlugin } from "./password-change.js";
|
|
40
|
+
import { passwordChecksPlugin } from "./password-checks.js";
|
|
38
41
|
import { rolesPlugin } from "./roles/roles-plugin.js";
|
|
39
42
|
import { versionedSecrets } from "./secret-versions.js";
|
|
40
43
|
import { signingKeyRotation, signingKeys } from "./signing-keys.js";
|
|
@@ -43,7 +46,6 @@ import { appSignInPolicy } from "./app-sign-in-policy.js";
|
|
|
43
46
|
import { assertNoEndpointKeyClashes } from "./endpoint-key-clashes.js";
|
|
44
47
|
import { emailInDomains, resolveSamlProviders, samlDisabledPaths, samlGuardPlugin } from "./saml-sign-in.js";
|
|
45
48
|
import { noAccountMailPlugin } from "./no-account-mail.js";
|
|
46
|
-
import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./sign-in-mail.js";
|
|
47
49
|
import { confirmedOnCreate, confirmedOnUpdate, emailConfirmedField } from "./email-confirmed.js";
|
|
48
50
|
import { rehashPasswordsPlugin, verifyAcceptedHash } from "./password-hashes.js";
|
|
49
51
|
import { staffSignInPlugin } from "./staff-plugin.js";
|
|
@@ -177,7 +179,11 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
177
179
|
minPasswordLength: passwordLength.min,
|
|
178
180
|
maxPasswordLength: passwordLength.max,
|
|
179
181
|
...passwordHashes.length > 0 && { password: { verify: verifyAcceptedHash(passwordHashes) } },
|
|
180
|
-
...mail && passwordResetOptions(mail,
|
|
182
|
+
...mail && passwordResetOptions(mail, { onPasswordReset: async ({ user }) => {
|
|
183
|
+
const auth = await context();
|
|
184
|
+
if (mcp) await revokeMcpGrants(auth.adapter, user.id);
|
|
185
|
+
await mailPasswordChanged({ context: auth }, mail, user.email);
|
|
186
|
+
} })
|
|
181
187
|
},
|
|
182
188
|
...options.socialProviders && { socialProviders: options.socialProviders },
|
|
183
189
|
user: {
|
|
@@ -222,7 +228,9 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
222
228
|
...signIn ? [genericOAuth({ config: [platformSignInProvider(signIn)] })] : [],
|
|
223
229
|
...saml.length > 0 ? [loaded(plugins.samlPlugin, "sso.saml")(saml, options.baseURL), samlGuardPlugin(samlIds)] : [],
|
|
224
230
|
signInPolicyPlugin(policy),
|
|
231
|
+
passwordChecksPlugin(),
|
|
225
232
|
setPasswordPlugin(),
|
|
233
|
+
passwordChangePlugin(mail),
|
|
226
234
|
...options.leakedPasswords ? [leakedPasswordsPlugin()] : [],
|
|
227
235
|
goTrueSessionsPlugin({
|
|
228
236
|
issuer: goTrueIssuer(options.baseURL),
|
|
@@ -412,9 +420,10 @@ const createAuth = (options, plugins = {}) => {
|
|
|
412
420
|
return Object.assign(auth, {
|
|
413
421
|
/**
|
|
414
422
|
* Answers a request from the app's iOS or Android build: Better Auth also trusts the Capacitor origin the request
|
|
415
|
-
* names. Only `createAdmin` with a `mobile` section uses it; `handler` and `api` never trust a Capacitor origin.
|
|
423
|
+
* names. Only `createAdmin` with a `mobile` section uses it; `handler` and `api` never trust a Capacitor origin. A
|
|
424
|
+
* Capacitor origin's POST without a JSON body is refused before it is granted, as only a page sends one.
|
|
416
425
|
*/
|
|
417
|
-
handleMobile: (request) => auth.handler(mobile.grant(request)),
|
|
426
|
+
handleMobile: async (request) => jsonOnlyOnMobile(request) ?? auth.handler(mobile.grant(request)),
|
|
418
427
|
/**
|
|
419
428
|
* What runs when a user is deleted: `set` a named listener that removes what the app keeps of the user elsewhere,
|
|
420
429
|
* such as their devices; `run` is for a deletion outside Better Auth's endpoints (`deleteUser`).
|
|
@@ -28,7 +28,11 @@ const revokeMcpGrants = async (adapter, userId, clientId) => {
|
|
|
28
28
|
where
|
|
29
29
|
});
|
|
30
30
|
};
|
|
31
|
-
const ownPaths = [
|
|
31
|
+
const ownPaths = [
|
|
32
|
+
"/revoke-sessions",
|
|
33
|
+
"/revoke-other-sessions",
|
|
34
|
+
"/change-password"
|
|
35
|
+
];
|
|
32
36
|
const adminPaths = [
|
|
33
37
|
"/admin/revoke-user-sessions",
|
|
34
38
|
"/admin/ban-user",
|
|
@@ -42,14 +46,14 @@ const securesAccount = (data) => {
|
|
|
42
46
|
};
|
|
43
47
|
/**
|
|
44
48
|
* The user whose MCP grants a finished request ends, or `undefined`: the person who signed out everywhere, signed out
|
|
45
|
-
* their other devices, or changed their password
|
|
49
|
+
* their other devices, or changed their password; the user an admin ended the sessions
|
|
46
50
|
* of, banned, set the password of, or marked unverified. Nobody when the request failed.
|
|
47
51
|
*/
|
|
48
52
|
const grantOwnerToRevoke = (request) => {
|
|
49
53
|
const { path, context } = request;
|
|
50
54
|
if (context.returned instanceof Error || path === void 0) return void 0;
|
|
51
55
|
const body = bodyOf(request);
|
|
52
|
-
if (ownPaths.includes(path)
|
|
56
|
+
if (ownPaths.includes(path)) return context.session?.user.id;
|
|
53
57
|
if (adminPaths.includes(path) || path === "/admin/update-user" && securesAccount(body.data)) return body.userId === void 0 ? void 0 : String(body.userId);
|
|
54
58
|
};
|
|
55
59
|
/**
|
|
@@ -62,7 +66,6 @@ const mcpGrantRevocation = () => ({
|
|
|
62
66
|
matcher: (context) => context.path !== void 0 && [
|
|
63
67
|
...ownPaths,
|
|
64
68
|
...adminPaths,
|
|
65
|
-
"/change-password",
|
|
66
69
|
"/admin/update-user"
|
|
67
70
|
].includes(context.path),
|
|
68
71
|
handler: createAuthMiddleware(async (ctx) => {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { capacitorOrigins } from "../mobile-config.js";
|
|
2
2
|
//#region src/better-auth/mobile-requests.ts
|
|
3
|
+
const capacitorOriginOf = (request) => {
|
|
4
|
+
const origin = request.headers.get("origin");
|
|
5
|
+
return origin !== null && capacitorOrigins.includes(origin);
|
|
6
|
+
};
|
|
3
7
|
/**
|
|
4
8
|
* Which requests come from the app's iOS or Android build: `grant` marks a request, and `trusted` gives the Capacitor
|
|
5
9
|
* origin it names as an origin Better Auth trusts, for that very request only. Any other request, or an Origin that is
|
|
@@ -14,10 +18,26 @@ const mobileRequests = () => {
|
|
|
14
18
|
},
|
|
15
19
|
trusted: (request) => {
|
|
16
20
|
if (!request || !granted.has(request)) return [];
|
|
17
|
-
|
|
18
|
-
return origin !== null && capacitorOrigins.includes(origin) ? [origin] : [];
|
|
21
|
+
return capacitorOriginOf(request) ? [request.headers.get("origin")] : [];
|
|
19
22
|
}
|
|
20
23
|
};
|
|
21
24
|
};
|
|
25
|
+
const mediaType = (request) => request.headers.get("content-type")?.split(";")[0].trim().toLowerCase();
|
|
26
|
+
/**
|
|
27
|
+
* Refuses a POST from a Capacitor origin whose body is not JSON. A page can send a form, `text/plain` or multipart POST
|
|
28
|
+
* without a preflight, but not a JSON one, and `/api/auth` answers no preflight: so the grant only ever sees requests
|
|
29
|
+
* the app's native HTTP sends. A request from any other origin is left alone, such as an identity provider's form POST.
|
|
30
|
+
*/
|
|
31
|
+
const jsonOnlyOnMobile = (request) => {
|
|
32
|
+
if (request.method !== "POST" || !capacitorOriginOf(request)) return;
|
|
33
|
+
if (mediaType(request) === "application/json") return;
|
|
34
|
+
return new Response(JSON.stringify({
|
|
35
|
+
message: "A request from the app takes a JSON body only",
|
|
36
|
+
code: "UNSUPPORTED_MEDIA_TYPE"
|
|
37
|
+
}), {
|
|
38
|
+
status: 415,
|
|
39
|
+
headers: { "content-type": "application/json" }
|
|
40
|
+
});
|
|
41
|
+
};
|
|
22
42
|
//#endregion
|
|
23
|
-
export { mobileRequests };
|
|
43
|
+
export { jsonOnlyOnMobile, mobileRequests };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { setPasswordPath } from "./set-password.js";
|
|
2
|
+
import { sendAuthEmail } from "./auth-mail/send-auth-email.js";
|
|
3
|
+
import * as z$1 from "zod";
|
|
4
|
+
import { APIError, createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
|
5
|
+
//#region src/better-auth/password-change.ts
|
|
6
|
+
/**
|
|
7
|
+
* Mails `email` that its account's password changed, when the app has mail: in the background, as Better Auth sends
|
|
8
|
+
* its own mail, so the answer does not wait on the mail server.
|
|
9
|
+
*/
|
|
10
|
+
const mailPasswordChanged = async (ctx, mail, email) => {
|
|
11
|
+
if (!mail) return;
|
|
12
|
+
await ctx.context.runInBackgroundOrAwait(sendAuthEmail(mail, "passwordChangedEmail", email, {}, ctx.context.baseURL));
|
|
13
|
+
};
|
|
14
|
+
const ownChanges = ["/change-password", setPasswordPath];
|
|
15
|
+
const adminChange = "/admin/set-user-password";
|
|
16
|
+
/** Whose account a finished request gave a new password: the address of the person, or the id of the user an admin set it for. */
|
|
17
|
+
const changedAccount = ({ path, body, context }) => {
|
|
18
|
+
if (context.returned instanceof Error || path === void 0) return;
|
|
19
|
+
if (ownChanges.includes(path)) return context.session ? { email: context.session.user.email } : void 0;
|
|
20
|
+
const userId = typeof body === "object" && body !== null ? body.userId : void 0;
|
|
21
|
+
if (path === adminChange && userId !== void 0) return { userId: String(userId) };
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A password change ends the account's other sessions and tells its owner:
|
|
25
|
+
* - `/change-password` always signs out the other devices, whatever `revokeOtherSessions` says: this device gets a
|
|
26
|
+
* new session, and the MCP grant revocation disconnects the account's MCP clients.
|
|
27
|
+
* - When the app has mail, the owner is emailed after `/change-password`, a first password (`/account/set-password`)
|
|
28
|
+
* and an admin's `/admin/set-user-password`; `passwordChanged` mails them for a change outside these endpoints,
|
|
29
|
+
* such as the GoTrue-compatible `PUT /user`. A password reset mails them from `onPasswordReset`.
|
|
30
|
+
*/
|
|
31
|
+
const passwordChangePlugin = (mail) => ({
|
|
32
|
+
id: "protobase-password-change",
|
|
33
|
+
endpoints: { passwordChanged: createAuthEndpoint.serverOnly({
|
|
34
|
+
method: "POST",
|
|
35
|
+
body: z$1.object({ email: z$1.string() })
|
|
36
|
+
}, async (ctx) => {
|
|
37
|
+
await mailPasswordChanged(ctx, mail, ctx.body.email);
|
|
38
|
+
return ctx.json({ status: true });
|
|
39
|
+
}) },
|
|
40
|
+
hooks: {
|
|
41
|
+
before: [{
|
|
42
|
+
matcher: (ctx) => ctx.path === "/change-password",
|
|
43
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
44
|
+
if (typeof ctx.body !== "object" || ctx.body === null) return;
|
|
45
|
+
return { context: { body: {
|
|
46
|
+
...ctx.body,
|
|
47
|
+
revokeOtherSessions: true
|
|
48
|
+
} } };
|
|
49
|
+
})
|
|
50
|
+
}],
|
|
51
|
+
after: [{
|
|
52
|
+
matcher: (ctx) => ctx.path !== void 0 && [...ownChanges, adminChange].includes(ctx.path),
|
|
53
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
54
|
+
const changed = changedAccount(ctx);
|
|
55
|
+
if (!changed || !mail) return;
|
|
56
|
+
if ("email" in changed) {
|
|
57
|
+
await mailPasswordChanged(ctx, mail, changed.email);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const user = await ctx.context.internalAdapter.findUserById(changed.userId);
|
|
61
|
+
if (!user) throw new APIError("NOT_FOUND", { message: "The user whose password was set is gone" });
|
|
62
|
+
await mailPasswordChanged(ctx, mail, user.email);
|
|
63
|
+
})
|
|
64
|
+
}]
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
//#endregion
|
|
68
|
+
export { changedAccount, mailPasswordChanged, passwordChangePlugin };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { APIError, createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
|
|
2
|
+
const maxTrackedAccounts = 1e4;
|
|
3
|
+
const checkedFields = {
|
|
4
|
+
"/change-password": "currentPassword",
|
|
5
|
+
"/verify-password": "password",
|
|
6
|
+
"/delete-user": "password",
|
|
7
|
+
"/two-factor/enable": "password",
|
|
8
|
+
"/two-factor/disable": "password",
|
|
9
|
+
"/two-factor/get-totp-uri": "password",
|
|
10
|
+
"/two-factor/generate-backup-codes": "password"
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Counts the checks of each account in a fixed window that opens at its first check: `take` answers how many seconds
|
|
14
|
+
* the account waits before its next check, or `undefined` when this one may go ahead and is counted. Bounded: a window
|
|
15
|
+
* that ended is dropped, and at `maxTracked` accounts the oldest window is dropped.
|
|
16
|
+
*/
|
|
17
|
+
const checkCounter = ({ max, windowSeconds, maxTracked = maxTrackedAccounts, now = Date.now }) => {
|
|
18
|
+
const windows = /* @__PURE__ */ new Map();
|
|
19
|
+
const windowMs = windowSeconds * 1e3;
|
|
20
|
+
const prune = (at) => {
|
|
21
|
+
for (const [key, window] of windows) if (at - window.opened >= windowMs) windows.delete(key);
|
|
22
|
+
while (windows.size >= maxTracked) windows.delete(windows.keys().next().value);
|
|
23
|
+
};
|
|
24
|
+
return { take: (key) => {
|
|
25
|
+
const at = now();
|
|
26
|
+
const window = windows.get(key);
|
|
27
|
+
if (window && at - window.opened < windowMs) {
|
|
28
|
+
if (window.count >= max) return Math.ceil((window.opened + windowMs - at) / 1e3);
|
|
29
|
+
window.count += 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
windows.delete(key);
|
|
33
|
+
prune(at);
|
|
34
|
+
windows.set(key, {
|
|
35
|
+
opened: at,
|
|
36
|
+
count: 1
|
|
37
|
+
});
|
|
38
|
+
} };
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Limits the current-password checks behind a session to `passwordChecksPerWindow` an account a minute, on top of
|
|
42
|
+
* Better Auth's limit per client address, so a stolen session cannot guess the password from many addresses. Every
|
|
43
|
+
* check counts, a right one too, and is counted before the password is compared, so requests sent at once cannot pass
|
|
44
|
+
* the limit together. A refused check answers `429 TOO_MANY_PASSWORD_CHECKS`, with `X-Retry-After` in seconds. A
|
|
45
|
+
* request without a session is left to the endpoint, which refuses it. The count lives in this process's memory.
|
|
46
|
+
*/
|
|
47
|
+
const passwordChecksPlugin = (counter = checkCounter({
|
|
48
|
+
max: 5,
|
|
49
|
+
windowSeconds: 60
|
|
50
|
+
})) => ({
|
|
51
|
+
id: "protobase-password-checks",
|
|
52
|
+
hooks: { before: [{
|
|
53
|
+
matcher: (ctx) => ctx.path !== void 0 && Object.hasOwn(checkedFields, ctx.path),
|
|
54
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
55
|
+
const password = ctx.body?.[checkedFields[ctx.path]];
|
|
56
|
+
if (typeof password !== "string" || password === "") return;
|
|
57
|
+
const session = await getSessionFromCtx(ctx);
|
|
58
|
+
if (!session) return;
|
|
59
|
+
const wait = counter.take(session.user.id);
|
|
60
|
+
if (wait === void 0) return;
|
|
61
|
+
throw new APIError("TOO_MANY_REQUESTS", {
|
|
62
|
+
code: "TOO_MANY_PASSWORD_CHECKS",
|
|
63
|
+
message: "Too many password attempts. Try again in a minute."
|
|
64
|
+
}, { "X-Retry-After": String(wait) });
|
|
65
|
+
})
|
|
66
|
+
}] }
|
|
67
|
+
});
|
|
68
|
+
//#endregion
|
|
69
|
+
export { checkCounter, passwordChecksPlugin };
|
|
@@ -5,7 +5,8 @@ const resetLinkLifetimeSeconds = 3600;
|
|
|
5
5
|
const resetLinkLifetime = "1 hour";
|
|
6
6
|
/**
|
|
7
7
|
* Better Auth's own password reset, sending the link with `mail`. Resetting ends the user's sessions, so a reset
|
|
8
|
-
* after a leaked password also signs out whoever used it, and `onPasswordReset` disconnects the user's MCP clients
|
|
8
|
+
* after a leaked password also signs out whoever used it, and `onPasswordReset` disconnects the user's MCP clients
|
|
9
|
+
* and tells them their password changed.
|
|
9
10
|
*/
|
|
10
11
|
const passwordResetOptions = (mail, { onPasswordReset } = {}) => ({
|
|
11
12
|
resetPasswordTokenExpiresIn: resetLinkLifetimeSeconds,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { sendAuthEmail } from "./auth-mail/send-auth-email.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { emailCodeMinutes } from "@protobase/schema/internal";
|
|
3
|
+
//#region src/better-auth/sign-in-mail.ts
|
|
4
|
+
/** How long an emailed code works: in seconds for the sign-in code, and in the email's words. */
|
|
5
|
+
const codeLifetimeSeconds = emailCodeMinutes * 60;
|
|
6
|
+
const codeLifetime = `${emailCodeMinutes} minutes`;
|
|
4
7
|
/**
|
|
5
8
|
* Better Auth's email OTP plugin for signing in and changing the address: never a sign-up, codes stored hashed, sent
|
|
6
9
|
* with `mail`. A new address gets its own email, with the code that moves the account to it. The plugin's other uses
|
|
@@ -8,7 +11,7 @@ const codeLifetime = "10 minutes";
|
|
|
8
11
|
*/
|
|
9
12
|
const emailCodeOptions = (mail) => ({
|
|
10
13
|
disableSignUp: true,
|
|
11
|
-
expiresIn:
|
|
14
|
+
expiresIn: codeLifetimeSeconds,
|
|
12
15
|
storeOTP: "hashed",
|
|
13
16
|
changeEmail: { enabled: true },
|
|
14
17
|
sendVerificationOTP: ({ email, otp, type }, ctx) => sendAuthEmail(mail, type === "change-email" ? "changeEmailCodeEmail" : "signInCodeEmail", email, {
|
|
@@ -26,7 +29,7 @@ const unusedEmailCodePaths = [
|
|
|
26
29
|
];
|
|
27
30
|
/** The two-factor plugin's emailed code, sent with `mail`; stored hashed. */
|
|
28
31
|
const twoFactorCodeOptions = (mail) => ({
|
|
29
|
-
period:
|
|
32
|
+
period: emailCodeMinutes,
|
|
30
33
|
storeOTP: "hashed",
|
|
31
34
|
sendOTP: ({ user, otp }, ctx) => sendAuthEmail(mail, "twoFactorCodeEmail", user.email, {
|
|
32
35
|
code: otp,
|
|
@@ -34,4 +37,4 @@ const twoFactorCodeOptions = (mail) => ({
|
|
|
34
37
|
}, ctx?.context.baseURL)
|
|
35
38
|
});
|
|
36
39
|
//#endregion
|
|
37
|
-
export { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths };
|
|
40
|
+
export { codeLifetime, codeLifetimeSeconds, emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { codeLifetimeSeconds } from "../sign-in-mail.js";
|
|
1
2
|
import { APIError } from "better-auth/api";
|
|
2
|
-
|
|
3
|
+
//#region src/better-auth/sign-up/sign-up-code.ts
|
|
3
4
|
const allowedAttempts = 3;
|
|
4
5
|
const identifierOf = (email) => `sign-up-otp-${email}`;
|
|
5
6
|
/** The SHA-256 of `secret`, base64: what the store keeps of a code or a ticket. */
|
|
@@ -26,7 +27,7 @@ const issueSignUpCode = async (context, email) => {
|
|
|
26
27
|
await context.internalAdapter.createVerificationValue({
|
|
27
28
|
identifier,
|
|
28
29
|
value: `${await hashOf(code)}:0`,
|
|
29
|
-
expiresAt: new Date(Date.now() +
|
|
30
|
+
expiresAt: new Date(Date.now() + codeLifetimeSeconds * 1e3)
|
|
30
31
|
});
|
|
31
32
|
return code;
|
|
32
33
|
};
|
|
@@ -50,4 +51,4 @@ const consumeSignUpCode = async (context, email, code) => {
|
|
|
50
51
|
throw invalidCode();
|
|
51
52
|
};
|
|
52
53
|
//#endregion
|
|
53
|
-
export { consumeSignUpCode, hashOf, issueSignUpCode
|
|
54
|
+
export { consumeSignUpCode, hashOf, issueSignUpCode };
|
|
@@ -2,8 +2,9 @@ import { effectiveSignInPolicy } from "../sign-in-policy.js";
|
|
|
2
2
|
import { readSignInPolicy } from "../policy-store.js";
|
|
3
3
|
import { checkNewAccountPassword } from "../new-account-password.js";
|
|
4
4
|
import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
|
|
5
|
+
import { codeLifetime } from "../sign-in-mail.js";
|
|
5
6
|
import { createSignedUpAccount } from "./sign-up-account.js";
|
|
6
|
-
import { consumeSignUpCode, issueSignUpCode
|
|
7
|
+
import { consumeSignUpCode, issueSignUpCode } from "./sign-up-code.js";
|
|
7
8
|
import { consumeSignUpTicket, issueSignUpTicket } from "./sign-up-ticket.js";
|
|
8
9
|
import * as z$1 from "zod";
|
|
9
10
|
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
@@ -40,7 +41,7 @@ const signUpPlugin = ({ role, mail, defaultPolicy }) => ({
|
|
|
40
41
|
if (await adapter.count({ model: "user" }) === 0) throw noAdminYet();
|
|
41
42
|
const sending = await internalAdapter.findUserByEmail(email) ? sendAuthEmail(mail, "accountExistsEmail", email, {}, ctx.context.baseURL) : sendAuthEmail(mail, "signUpCodeEmail", email, {
|
|
42
43
|
code: await issueSignUpCode(ctx.context, email),
|
|
43
|
-
lifetime:
|
|
44
|
+
lifetime: codeLifetime
|
|
44
45
|
}, ctx.context.baseURL);
|
|
45
46
|
await ctx.context.runInBackgroundOrAwait(sending);
|
|
46
47
|
return ctx.json({ sent: true });
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { EventsOptions } from "./changes/events-options.js";
|
|
2
|
+
import { ResourceModel } from "@protobase/schema";
|
|
3
|
+
//#region src/built-in-resources.d.ts
|
|
4
|
+
export type BuiltInInput = {
|
|
5
|
+
models: ResourceModel[];
|
|
6
|
+
/** Whether the app declares jobs of its own. */
|
|
7
|
+
jobs: boolean;
|
|
8
|
+
/** Whether the app sends notifications. */
|
|
9
|
+
notifications: boolean;
|
|
10
|
+
events?: EventsOptions;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The resources and views the API adds beside the app's own, by what the app uses: Failed jobs once anything runs
|
|
14
|
+
* jobs (events, history, comments, AI fields, notifications or the app's jobs), Activity with history and Comments
|
|
15
|
+
* with comments.
|
|
16
|
+
*/
|
|
17
|
+
export declare const builtInResources: ({ models, jobs, notifications, events }: BuiltInInput) => {
|
|
18
|
+
resources: (import("@protobase/schema").ResourceBuilder<{
|
|
19
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
20
|
+
readonly name: import("@protobase/schema").Field<string>;
|
|
21
|
+
readonly error: import("@protobase/schema").Field<string | null>;
|
|
22
|
+
readonly attempts: import("@protobase/schema").Field<number>;
|
|
23
|
+
readonly failed_at: import("@protobase/schema").Field<string>;
|
|
24
|
+
readonly created_at: import("@protobase/schema").Field<string>;
|
|
25
|
+
readonly key: import("@protobase/schema").Field<string | null>;
|
|
26
|
+
readonly payload: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
27
|
+
readonly output: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
28
|
+
readonly retry: import("@protobase/schema").Field<boolean>;
|
|
29
|
+
}, import("@protobase/schema").Ref<"id", string>> | import("@protobase/schema").ResourceBuilder<{
|
|
30
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
31
|
+
readonly resource: import("@protobase/schema").Field<string>;
|
|
32
|
+
readonly record_key: import("@protobase/schema").Field<string>;
|
|
33
|
+
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
34
|
+
readonly operation: import("@protobase/schema").Field<"create" | "update" | "delete" | "undelete">;
|
|
35
|
+
readonly before: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
36
|
+
readonly after: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
37
|
+
readonly changed: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
38
|
+
readonly actor_id: import("@protobase/schema").Field<string | null>;
|
|
39
|
+
readonly actor_roles: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
40
|
+
readonly database_role: import("@protobase/schema").Field<string | null>;
|
|
41
|
+
readonly transaction: import("@protobase/schema").Field<string>;
|
|
42
|
+
readonly at: import("@protobase/schema").Field<string>;
|
|
43
|
+
}, import("@protobase/schema").Ref<"id", string>> | import("@protobase/schema").ResourceBuilder<{
|
|
44
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
45
|
+
readonly resource: import("@protobase/schema").Field<string>;
|
|
46
|
+
readonly record_key: import("@protobase/schema").Field<string>;
|
|
47
|
+
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
48
|
+
readonly author_id: import("@protobase/schema").Field<string>;
|
|
49
|
+
readonly body: import("@protobase/schema").Field<string>;
|
|
50
|
+
readonly mentions: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
51
|
+
readonly created_at: import("@protobase/schema").Field<string>;
|
|
52
|
+
}, import("@protobase/schema").Ref<"id", string>>)[];
|
|
53
|
+
views: (import("@protobase/schema").ViewBuilder<import("@protobase/schema").ResourceBuilder<{
|
|
54
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
55
|
+
readonly name: import("@protobase/schema").Field<string>;
|
|
56
|
+
readonly error: import("@protobase/schema").Field<string | null>;
|
|
57
|
+
readonly attempts: import("@protobase/schema").Field<number>;
|
|
58
|
+
readonly failed_at: import("@protobase/schema").Field<string>;
|
|
59
|
+
readonly created_at: import("@protobase/schema").Field<string>;
|
|
60
|
+
readonly key: import("@protobase/schema").Field<string | null>;
|
|
61
|
+
readonly payload: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
62
|
+
readonly output: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
63
|
+
readonly retry: import("@protobase/schema").Field<boolean>;
|
|
64
|
+
}, import("@protobase/schema").Ref<"id", string>>> | import("@protobase/schema").ViewBuilder<import("@protobase/schema").ResourceBuilder<{
|
|
65
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
66
|
+
readonly resource: import("@protobase/schema").Field<string>;
|
|
67
|
+
readonly record_key: import("@protobase/schema").Field<string>;
|
|
68
|
+
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
69
|
+
readonly operation: import("@protobase/schema").Field<"create" | "update" | "delete" | "undelete">;
|
|
70
|
+
readonly before: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
71
|
+
readonly after: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
72
|
+
readonly changed: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
73
|
+
readonly actor_id: import("@protobase/schema").Field<string | null>;
|
|
74
|
+
readonly actor_roles: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
75
|
+
readonly database_role: import("@protobase/schema").Field<string | null>;
|
|
76
|
+
readonly transaction: import("@protobase/schema").Field<string>;
|
|
77
|
+
readonly at: import("@protobase/schema").Field<string>;
|
|
78
|
+
}, import("@protobase/schema").Ref<"id", string>>> | import("@protobase/schema").ViewBuilder<import("@protobase/schema").ResourceBuilder<{
|
|
79
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
80
|
+
readonly resource: import("@protobase/schema").Field<string>;
|
|
81
|
+
readonly record_key: import("@protobase/schema").Field<string>;
|
|
82
|
+
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
83
|
+
readonly author_id: import("@protobase/schema").Field<string>;
|
|
84
|
+
readonly body: import("@protobase/schema").Field<string>;
|
|
85
|
+
readonly mentions: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
86
|
+
readonly created_at: import("@protobase/schema").Field<string>;
|
|
87
|
+
}, import("@protobase/schema").Ref<"id", string>>>)[];
|
|
88
|
+
};
|
|
89
|
+
//#endregion
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { hasAiFields } from "./ai/ai-fields.js";
|
|
2
|
+
import { isCaptured } from "./changes/capture-sql.js";
|
|
3
|
+
import { changesSchema } from "./changes/events-options.js";
|
|
4
|
+
import { commentsResource, commentsView } from "./comments/record-comments.js";
|
|
5
|
+
import { activityResource, activityView } from "./history/activity.js";
|
|
6
|
+
import { failedJobs, failedJobsView } from "./jobs/failed-jobs.js";
|
|
7
|
+
//#region src/built-in-resources.ts
|
|
8
|
+
/**
|
|
9
|
+
* The resources and views the API adds beside the app's own, by what the app uses: Failed jobs once anything runs
|
|
10
|
+
* jobs (events, history, comments, AI fields, notifications or the app's jobs), Activity with history and Comments
|
|
11
|
+
* with comments.
|
|
12
|
+
*/
|
|
13
|
+
const builtInResources = ({ models, jobs, notifications, events }) => {
|
|
14
|
+
const withComments = models.some((model) => model.comments);
|
|
15
|
+
const withJobs = jobs || models.some(isCaptured) || withComments || hasAiFields(models) || notifications;
|
|
16
|
+
const withHistory = models.some((model) => model.history);
|
|
17
|
+
return {
|
|
18
|
+
resources: [
|
|
19
|
+
...withJobs ? [failedJobs] : [],
|
|
20
|
+
...withHistory ? [activityResource(changesSchema(events), { tenant: models.some((model) => model.history && model.tenant) })] : [],
|
|
21
|
+
...withComments ? [commentsResource({ tenant: models.some((model) => model.comments && model.tenant) })] : []
|
|
22
|
+
],
|
|
23
|
+
views: [
|
|
24
|
+
...withJobs ? [failedJobsView] : [],
|
|
25
|
+
...withHistory ? [activityView] : [],
|
|
26
|
+
...withComments ? [commentsView] : []
|
|
27
|
+
]
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { builtInResources };
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { isCaptured, quoteIdent, tableSql } from "./capture-sql.js";
|
|
2
|
+
import { historyIndexes } from "../history/history-sql.js";
|
|
2
3
|
import { capturePlan, planSql, uncaptured } from "./capture-plan.js";
|
|
3
4
|
import { changesSchema } from "./events-options.js";
|
|
5
|
+
import { hasIndexes } from "../has-indexes.js";
|
|
4
6
|
import { sql } from "kysely";
|
|
5
7
|
//#region src/changes/capture-schema.ts
|
|
6
8
|
/** The command that writes what the config needs as a migration file of the project. */
|
|
@@ -25,7 +27,7 @@ const readCaptureState = async (db, schema, models) => {
|
|
|
25
27
|
where not t.tgisinternal and (t.tgfoid::text = ${capture?.oid ?? ""} or t.tgrelid::text = any(${oids}::text[]))`);
|
|
26
28
|
return {
|
|
27
29
|
outbox: await exists("outbox"),
|
|
28
|
-
history: await exists("history"),
|
|
30
|
+
history: await exists("history") && await hasIndexes(db, schema, "history", historyIndexes.map(([name]) => name)),
|
|
29
31
|
notices: await exists("notices"),
|
|
30
32
|
...capture && { capture },
|
|
31
33
|
triggers: triggers.map((trigger) => ({
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { ResourceModel } from "@protobase/schema";
|
|
2
2
|
import { Db } from "@protobase/query";
|
|
3
3
|
//#region src/comments/comments-sql.d.ts
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* The SQL that adds the `comments` schema and its table, or the indexes it lacks, `undefined` when it has them all. The
|
|
6
|
+
* table is never dropped.
|
|
7
|
+
*/
|
|
5
8
|
export declare const commentsMigration: (db: Db) => Promise<string | undefined>;
|
|
6
9
|
/** Refuses to go on while a resource has `.comments()` and the database lacks their table; the project's migrations add it. */
|
|
7
10
|
export declare const checkComments: (db: Db, models: readonly ResourceModel[]) => Promise<void>;
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
import { hasIndexes } from "../has-indexes.js";
|
|
1
2
|
import { migrationCommand } from "../changes/capture-schema.js";
|
|
2
3
|
import { sql } from "kysely";
|
|
3
4
|
//#region src/comments/comments-sql.ts
|
|
4
5
|
/** The table the comments of every resource with `.comments()` are kept in, in a schema of its own. */
|
|
5
6
|
const commentsTable = "\"comments\".\"comments\"";
|
|
7
|
+
/** An index for each read of the comments: the record pages', the admin list's filters, by name. */
|
|
8
|
+
const commentsIndexes = [
|
|
9
|
+
["comments_record", "\"resource\", \"record_key\", \"id\""],
|
|
10
|
+
["comments_tenant", "\"tenant_id\", \"id\""],
|
|
11
|
+
["comments_author", "\"author_id\", \"id\""],
|
|
12
|
+
["comments_created_at", "\"created_at\""],
|
|
13
|
+
["comments_record_key", "\"record_key\""]
|
|
14
|
+
];
|
|
6
15
|
/**
|
|
7
16
|
* A row per comment: the record it is on by resource and encoded key, its tenant, its author and the user ids it
|
|
8
17
|
* mentions (`[]` for none). Every column the admin list filters on has an index.
|
|
@@ -17,12 +26,12 @@ const commentsTableSql = () => `create table if not exists ${commentsTable} (
|
|
|
17
26
|
"mentions" jsonb not null,
|
|
18
27
|
"created_at" timestamptz not null default now()
|
|
19
28
|
);
|
|
20
|
-
create index if not exists "
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
${commentsIndexes.map(([name, columns]) => `create index if not exists "${name}" on ${commentsTable} (${columns})`).join(";\n")}`;
|
|
30
|
+
const hasCommentsTable = async (db) => (await sql`select to_regclass('comments.comments') is not null as found`.execute(db)).rows[0].found && await hasIndexes(db, "comments", "comments", commentsIndexes.map(([name]) => name));
|
|
31
|
+
/**
|
|
32
|
+
* The SQL that adds the `comments` schema and its table, or the indexes it lacks, `undefined` when it has them all. The
|
|
33
|
+
* table is never dropped.
|
|
34
|
+
*/
|
|
26
35
|
const commentsMigration = async (db) => {
|
|
27
36
|
if (await hasCommentsTable(db)) return void 0;
|
|
28
37
|
return `create schema if not exists "comments";\n\n${commentsTableSql()};`;
|
|
@@ -31,7 +40,7 @@ const commentsMigration = async (db) => {
|
|
|
31
40
|
const checkComments = async (db, models) => {
|
|
32
41
|
if (!models.some((model) => model.comments)) return;
|
|
33
42
|
if (await hasCommentsTable(db)) return;
|
|
34
|
-
throw new Error(`The comments schema is missing, and resources with .comments() keep their comments in comments.comments. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
43
|
+
throw new Error(`The comments schema is missing or behind this version of Protobase, and resources with .comments() keep their comments in comments.comments. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
35
44
|
};
|
|
36
45
|
//#endregion
|
|
37
46
|
export { checkComments, commentsMigration, commentsTable, commentsTableSql };
|
|
@@ -15,7 +15,7 @@ const commentsResource = ({ tenant }) => {
|
|
|
15
15
|
author_id: f.text().readOnly().filterable(),
|
|
16
16
|
body: f.text().readOnly(),
|
|
17
17
|
mentions: f.json().readOnly(),
|
|
18
|
-
created_at: f.timestamp().readOnly().filterable()
|
|
18
|
+
created_at: f.timestamp().readOnly().dbDefault().filterable()
|
|
19
19
|
}).primaryKey((r) => r.id).access({
|
|
20
20
|
list: admins,
|
|
21
21
|
read: admins,
|
package/dist/config-exports.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import { configExports } from "./resource-source.js";
|
|
2
|
-
|
|
2
|
+
import { builtInResources } from "./built-in-resources.js";
|
|
3
|
+
import { webhooksModule } from "./webhooks/table/webhooks-module.js";
|
|
4
|
+
export { builtInResources, configExports, webhooksModule };
|
package/dist/config-exports.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
+
import { builtInResources } from "./built-in-resources.js";
|
|
1
2
|
import { configExports } from "./resource-source.js";
|
|
2
|
-
|
|
3
|
+
import { webhooksModule } from "./webhooks/table/webhooks-module.js";
|
|
4
|
+
export { builtInResources, configExports, webhooksModule };
|
package/dist/create-admin.js
CHANGED
|
@@ -50,8 +50,6 @@ import { appOrigin } from "./comments/app-origin.js";
|
|
|
50
50
|
import { betterAuthActorNames } from "./history/actor-names.js";
|
|
51
51
|
import { betterAuthCommentUsers } from "./comments/comment-users.js";
|
|
52
52
|
import { mentionJob } from "./comments/mention-job.js";
|
|
53
|
-
import { commentsResource, commentsView } from "./comments/record-comments.js";
|
|
54
|
-
import { activityResource, activityView } from "./history/activity.js";
|
|
55
53
|
import { historyDays } from "./history/options.js";
|
|
56
54
|
import { pruneHistoryJob } from "./history/prune-history.js";
|
|
57
55
|
import { callerRecords } from "./functions/records.js";
|
|
@@ -113,7 +111,7 @@ import { mcpIcons } from "./web-app/mcp-icons.js";
|
|
|
113
111
|
import { recordRoutes } from "./routes/record.js";
|
|
114
112
|
import { firstAdminUrl } from "./better-auth/first-admin-url.js";
|
|
115
113
|
import { createScanGuard } from "./scan-guard.js";
|
|
116
|
-
import {
|
|
114
|
+
import { builtInResources } from "./built-in-resources.js";
|
|
117
115
|
import { Hono } from "hono";
|
|
118
116
|
import { accountPage, accountPageName, checkPages, settingsResources, withSettingsFields } from "@protobase/layout";
|
|
119
117
|
import { guestCallsPath, ringsPath, statusPath } from "@protobase/schema/internal";
|
|
@@ -129,22 +127,17 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
129
127
|
const appModels = appResources.map((resource) => resource.toModel());
|
|
130
128
|
const withComments = appModels.some((model) => model.comments);
|
|
131
129
|
const withAiFields = hasAiFields(appModels);
|
|
132
|
-
const withJobs = jobs.length > 0 || appModels.some(isCaptured) || withComments || withAiFields || notifications !== void 0;
|
|
133
130
|
const withHistory = appModels.some((model) => model.history);
|
|
134
131
|
const changes = changesSchema(events);
|
|
135
132
|
const days = historyDays(historyOptions);
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
...
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
...withJobs ? [failedJobsView] : [],
|
|
145
|
-
...withHistory ? [activityView] : [],
|
|
146
|
-
...withComments ? [commentsView] : []
|
|
147
|
-
];
|
|
133
|
+
const builtIn = builtInResources({
|
|
134
|
+
models: appModels,
|
|
135
|
+
jobs: jobs.length > 0,
|
|
136
|
+
notifications: notifications !== void 0,
|
|
137
|
+
...events && { events }
|
|
138
|
+
});
|
|
139
|
+
const resources = [...appResources, ...builtIn.resources];
|
|
140
|
+
const views = [...appViews, ...builtIn.views];
|
|
148
141
|
const commentUsers = withComments ? givenCommentUsers ?? (auth && betterAuthCommentUsers(auth)) : void 0;
|
|
149
142
|
const mentions = withComments ? mentionJob(commentUsers, shownAppName(auth, globalThis.process?.env ?? {})) : void 0;
|
|
150
143
|
const registry = buildRegistry(resources);
|
|
@@ -24,9 +24,10 @@ const logoutScopes = [
|
|
|
24
24
|
/**
|
|
25
25
|
* The signed-in user's own endpoints, with their access token: `GET /user`, `PUT /user` for a new password or metadata,
|
|
26
26
|
* and `POST /logout`. A token whose session has ended, by signing out or a ban, is GoTrue's `session_not_found`.
|
|
27
|
-
* With MCP on, signing out everywhere or the other sessions
|
|
28
|
-
*
|
|
29
|
-
* password
|
|
27
|
+
* With MCP on, signing out everywhere or the other sessions disconnects the user's MCP clients, as the same steps do
|
|
28
|
+
* through Better Auth. A new password signs out the other sessions, with the MCP clients, and mails the owner, as a
|
|
29
|
+
* change through Better Auth does: GoTrue has no way to tell a password reset or a first password from a change, so
|
|
30
|
+
* every new password does.
|
|
30
31
|
*/
|
|
31
32
|
const userRoutes = ({ auth, context }) => {
|
|
32
33
|
const claimsOf = accessClaimsOf(goTrueTokens({ auth }));
|
|
@@ -85,7 +86,8 @@ const userRoutes = ({ auth, context }) => {
|
|
|
85
86
|
accountId: user.id,
|
|
86
87
|
password: hash
|
|
87
88
|
});
|
|
88
|
-
|
|
89
|
+
await signOut(found.claims, "others");
|
|
90
|
+
await auth.api.passwordChanged({ body: { email: user.email } });
|
|
89
91
|
}
|
|
90
92
|
const merged = data && Object.fromEntries(Object.entries({
|
|
91
93
|
...userMetadata(user.metadata),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/has-indexes.ts
|
|
3
|
+
/** Whether the table `schema`.`table` has an index of each name in `names`. Only reads the catalogue. */
|
|
4
|
+
const hasIndexes = async (db, schema, table, names) => {
|
|
5
|
+
const { rows } = await sql`select indexname as name from pg_indexes where schemaname = ${schema} and tablename = ${table}`.execute(db);
|
|
6
|
+
return names.every((name) => rows.some((row) => row.name === name));
|
|
7
|
+
};
|
|
8
|
+
//#endregion
|
|
9
|
+
export { hasIndexes };
|
|
@@ -6,6 +6,17 @@ import { quoteIdent } from "../changes/capture-sql.js";
|
|
|
6
6
|
* ones included. `actor_id` and `actor_roles` are the API's caller, `null` for a write from elsewhere; `database_role`
|
|
7
7
|
* is the role that wrote, `null` for a change captured before Protobase kept it.
|
|
8
8
|
*/
|
|
9
|
+
/** An index for each read the record pages and the Activity list take, by name, so a migration can add the ones a table lacks. */
|
|
10
|
+
const historyIndexes = [
|
|
11
|
+
["history_record", "\"resource\", \"record_key\", \"id\""],
|
|
12
|
+
["history_tenant", "\"tenant_id\", \"id\""],
|
|
13
|
+
["history_at", "\"at\""],
|
|
14
|
+
["history_record_key", "\"record_key\""],
|
|
15
|
+
["history_operation", "\"operation\""],
|
|
16
|
+
["history_actor", "\"actor_id\""],
|
|
17
|
+
["history_database_role", "\"database_role\""],
|
|
18
|
+
["history_transaction", "\"transaction\""]
|
|
19
|
+
];
|
|
9
20
|
const historyTableSql = (schema) => {
|
|
10
21
|
const table = `${quoteIdent(schema)}."history"`;
|
|
11
22
|
return `create table if not exists ${table} (
|
|
@@ -23,9 +34,7 @@ const historyTableSql = (schema) => {
|
|
|
23
34
|
"transaction" bigint not null,
|
|
24
35
|
"at" timestamptz not null
|
|
25
36
|
);
|
|
26
|
-
create index if not exists "
|
|
27
|
-
create index if not exists "history_tenant" on ${table} ("tenant_id", "id");
|
|
28
|
-
create index if not exists "history_at" on ${table} ("at")`;
|
|
37
|
+
${historyIndexes.map(([name, columns]) => `create index if not exists "${name}" on ${table} (${columns})`).join(";\n")}`;
|
|
29
38
|
};
|
|
30
39
|
//#endregion
|
|
31
|
-
export { historyTableSql };
|
|
40
|
+
export { historyIndexes, historyTableSql };
|
package/dist/internal.js
CHANGED
|
@@ -8,8 +8,8 @@ import { appPool, longQueryPool } from "./app-pool.js";
|
|
|
8
8
|
import { betterAuthAuthenticator } from "./better-auth/authenticator.js";
|
|
9
9
|
import { fileProviderOf } from "./files/provider-settings.js";
|
|
10
10
|
import { declaredJob } from "./jobs/declared-job.js";
|
|
11
|
+
import { webhooksModule } from "./webhooks/table/webhooks-module.js";
|
|
11
12
|
import { appAuthOptions, buildAppAuth } from "./better-auth/auth-settings.js";
|
|
12
13
|
import { buildAuthenticator } from "./better-auth/authenticate-settings.js";
|
|
13
|
-
import { webhooksModule } from "./webhooks/table/webhooks-module.js";
|
|
14
14
|
import { checkCallsOptions } from "./calls/calls-options.js";
|
|
15
15
|
export { appAuthOptions, appPool, betterAuthAuthenticator, buildAppAuth, buildAuth, buildAuthenticator, checkCallsOptions, checkNotificationsOptions, checkStatusOptions, createAuth, declaredJob, fileProviderOf, isSubscription, loadAuthPlugins, localFiles, longQueryPool, s3Files, webhooksModule };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { fromJsonSchema } from "@modelcontextprotocol/server";
|
|
2
|
+
//#region src/mcp/mcp-input-schemas.ts
|
|
3
|
+
/**
|
|
4
|
+
* Turns a tool's parameters object into its MCP input schema, keeping the last `limit` it turned, least recently used
|
|
5
|
+
* dropped first. The SDK compiles each schema object it is given into a cache that never lets go, so a server built per
|
|
6
|
+
* request from the same tools gets the same input schema and adds nothing to it.
|
|
7
|
+
*/
|
|
8
|
+
const inputSchemaMemo = (limit) => {
|
|
9
|
+
const schemas = /* @__PURE__ */ new Map();
|
|
10
|
+
return (parameters) => {
|
|
11
|
+
const known = schemas.get(parameters);
|
|
12
|
+
if (known) {
|
|
13
|
+
schemas.delete(parameters);
|
|
14
|
+
schemas.set(parameters, known);
|
|
15
|
+
return known;
|
|
16
|
+
}
|
|
17
|
+
const schema = fromJsonSchema(parameters);
|
|
18
|
+
schemas.set(parameters, schema);
|
|
19
|
+
if (schemas.size > limit) schemas.delete(schemas.keys().next().value);
|
|
20
|
+
return schema;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
//#endregion
|
|
24
|
+
export { inputSchemaMemo };
|
package/dist/mcp/mcp-server.js
CHANGED
|
@@ -3,18 +3,12 @@ import { checkedTools } from "../assistant/checked-tools.js";
|
|
|
3
3
|
import { refusalOf } from "../assistant/record-tool-args.js";
|
|
4
4
|
import { ApprovalNeeded, approvalAsked, approvedUnasked, mcpApproval, writesUnasked } from "./mcp-approval.js";
|
|
5
5
|
import { callHash, callScope, scopeHash } from "./mcp-call-hash.js";
|
|
6
|
+
import { inputSchemaMemo } from "./mcp-input-schemas.js";
|
|
6
7
|
import { mcpInstructions } from "./mcp-instructions.js";
|
|
7
|
-
import { CLIENT_CAPABILITIES_META_KEY, McpServer
|
|
8
|
+
import { CLIENT_CAPABILITIES_META_KEY, McpServer } from "@modelcontextprotocol/server";
|
|
8
9
|
//#region src/mcp/mcp-server.ts
|
|
9
10
|
const capabilitiesOf = (envelope) => envelope?.[CLIENT_CAPABILITIES_META_KEY];
|
|
10
|
-
const
|
|
11
|
-
const inputSchemaOf = (parameters) => {
|
|
12
|
-
const known = inputSchemas.get(parameters);
|
|
13
|
-
if (known) return known;
|
|
14
|
-
const schema = fromJsonSchema(parameters);
|
|
15
|
-
inputSchemas.set(parameters, schema);
|
|
16
|
-
return schema;
|
|
17
|
-
};
|
|
11
|
+
const inputSchemaOf = inputSchemaMemo(1e3);
|
|
18
12
|
const failed = {
|
|
19
13
|
content: [{
|
|
20
14
|
type: "text",
|
package/dist/package.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jobsSchema } from "../../jobs/schema.js";
|
|
2
2
|
import { quoteIdent } from "../../changes/capture-sql.js";
|
|
3
3
|
/** The comment the schema's functions carry, which the startup check reads. */
|
|
4
|
-
const webhooksComment = `Protobase webhooks
|
|
4
|
+
const webhooksComment = `Protobase webhooks 2`;
|
|
5
5
|
/** The delivery jobs, whose waiting and failed ones the deliveries view lists. */
|
|
6
6
|
const deliveryJobs = ["webhooks.deliver", "webhooks.deliver-ordered"];
|
|
7
7
|
/** The job that sends a subscription a test event. */
|
|
@@ -34,8 +34,23 @@ const subscriptionsSql = (schema, changesSchema) => `create table if not exists
|
|
|
34
34
|
"rotate_secret" boolean not null default false,
|
|
35
35
|
"send_test" boolean not null default false,
|
|
36
36
|
"resend_failed" boolean not null default false
|
|
37
|
-
)
|
|
38
|
-
|
|
37
|
+
)`;
|
|
38
|
+
/**
|
|
39
|
+
* An index for each read of the subscriptions the API takes: the tenant and owner every caller's rows keep to, the
|
|
40
|
+
* sorts and filter the list offers, and pg_trgm ones for its search over the name and URL.
|
|
41
|
+
*/
|
|
42
|
+
const subscriptionsIndexesSql = (schema) => {
|
|
43
|
+
const table = `${quoteIdent(schema)}."subscriptions"`;
|
|
44
|
+
return `create extension if not exists pg_trgm;
|
|
45
|
+
create index if not exists "subscriptions_tenant" on ${table} ("tenant_id");
|
|
46
|
+
create index if not exists "subscriptions_owner" on ${table} ("owner_id");
|
|
47
|
+
create index if not exists "subscriptions_created_at" on ${table} ("created_at");
|
|
48
|
+
create index if not exists "subscriptions_name" on ${table} ("name");
|
|
49
|
+
create index if not exists "subscriptions_enabled" on ${table} ("enabled");
|
|
50
|
+
create index if not exists "subscriptions_last_success_at" on ${table} ("last_success_at");
|
|
51
|
+
create index if not exists "subscriptions_name_search" on ${table} using gin ("name" gin_trgm_ops);
|
|
52
|
+
create index if not exists "subscriptions_url_search" on ${table} using gin ("url" gin_trgm_ops)`;
|
|
53
|
+
};
|
|
39
54
|
const retryJobs = (where) => `update ${jobsSchema}.job
|
|
40
55
|
set state = 'retry', retry_limit = retry_limit + 1, completed_on = null,
|
|
41
56
|
start_after = greatest(start_after, ${jobsSchema}.job_now()), keep_until = greatest(keep_until, ${jobsSchema}.job_now() + interval '14 days')
|
|
@@ -110,7 +125,16 @@ const deliveryLogSql = (schema) => `create table if not exists ${quoteIdent(sche
|
|
|
110
125
|
"response" text,
|
|
111
126
|
"duration_ms" integer not null,
|
|
112
127
|
"at" timestamptz not null default now()
|
|
113
|
-
)
|
|
114
|
-
|
|
128
|
+
)`;
|
|
129
|
+
/** An index for each filter and sort of the delivery log the API takes. */
|
|
130
|
+
const deliveryLogIndexesSql = (schema) => {
|
|
131
|
+
const table = `${quoteIdent(schema)}."delivery_log"`;
|
|
132
|
+
return `create index if not exists "delivery_log_subscription" on ${table} ("subscription_id", "at");
|
|
133
|
+
create index if not exists "delivery_log_at" on ${table} ("at");
|
|
134
|
+
create index if not exists "delivery_log_event_id" on ${table} ("event_id");
|
|
135
|
+
create index if not exists "delivery_log_event_type" on ${table} ("event_type");
|
|
136
|
+
create index if not exists "delivery_log_status" on ${table} ("status");
|
|
137
|
+
create index if not exists "delivery_log_status_code" on ${table} ("status_code")`;
|
|
138
|
+
};
|
|
115
139
|
//#endregion
|
|
116
|
-
export { commandsSql, deliveriesSql, deliveryJobs, deliveryLogSql, sendTestJob, subscriptionsSql, webhooksComment };
|
|
140
|
+
export { commandsSql, deliveriesSql, deliveryJobs, deliveryLogIndexesSql, deliveryLogSql, sendTestJob, subscriptionsIndexesSql, subscriptionsSql, webhooksComment };
|
|
@@ -2,7 +2,7 @@ import { quoteIdent } from "../../changes/capture-sql.js";
|
|
|
2
2
|
import { changesSchema } from "../../changes/events-options.js";
|
|
3
3
|
import { migrationCommand } from "../../changes/capture-schema.js";
|
|
4
4
|
import { webhooksSchema } from "./options.js";
|
|
5
|
-
import { commandsSql, deliveriesSql, deliveryLogSql, subscriptionsSql, webhooksComment } from "./schema-sql.js";
|
|
5
|
+
import { commandsSql, deliveriesSql, deliveryLogIndexesSql, deliveryLogSql, subscriptionsIndexesSql, subscriptionsSql, webhooksComment } from "./schema-sql.js";
|
|
6
6
|
import { sql } from "kysely";
|
|
7
7
|
//#region src/webhooks/table/schema.ts
|
|
8
8
|
const functionNames = ["subscription_commands", "deliveries_resend"];
|
|
@@ -25,12 +25,18 @@ const webhooksMigration = async (db, options = {}, events) => {
|
|
|
25
25
|
const schema = webhooksSchema(options);
|
|
26
26
|
const found = await readWebhooksSchema(db, schema);
|
|
27
27
|
const needsLog = Boolean(options.deliveryLog) && !found.deliveryLog;
|
|
28
|
-
|
|
28
|
+
const current = found.subscriptions && found.current;
|
|
29
|
+
if (current && !needsLog) return void 0;
|
|
29
30
|
return `${[
|
|
30
31
|
`create schema if not exists ${quoteIdent(schema)}`,
|
|
31
32
|
...found.subscriptions ? [] : [subscriptionsSql(schema, changesSchema(events))],
|
|
32
|
-
...
|
|
33
|
-
|
|
33
|
+
...current ? [] : [
|
|
34
|
+
subscriptionsIndexesSql(schema),
|
|
35
|
+
commandsSql(schema),
|
|
36
|
+
deliveriesSql(schema)
|
|
37
|
+
],
|
|
38
|
+
...needsLog ? [deliveryLogSql(schema)] : [],
|
|
39
|
+
...needsLog || !current && found.deliveryLog ? [deliveryLogIndexesSql(schema)] : []
|
|
34
40
|
].join(";\n\n")};`;
|
|
35
41
|
};
|
|
36
42
|
/** Refuses to go on while the `webhooks` schema lacks what `webhooks` needs; the project's migrations add it. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.66.
|
|
3
|
+
"version": "0.66.7",
|
|
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": {
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"@better-auth/sso": "^1.7.12",
|
|
44
44
|
"@livekit/protocol": "1.53.1",
|
|
45
45
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
46
|
-
"@protobase/layout": "0.66.
|
|
47
|
-
"@protobase/query": "0.66.
|
|
48
|
-
"@protobase/schema": "0.66.
|
|
46
|
+
"@protobase/layout": "0.66.7",
|
|
47
|
+
"@protobase/query": "0.66.7",
|
|
48
|
+
"@protobase/schema": "0.66.7",
|
|
49
49
|
"@react-email/render": "^2.1.0",
|
|
50
50
|
"@react-pdf/primitives": "^4.4.0",
|
|
51
51
|
"@react-pdf/renderer": "^4.9.0",
|