@protobase/server 0.68.5 → 0.69.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 (81) hide show
  1. package/dist/better-auth/auth-settings.d.ts +155 -28
  2. package/dist/better-auth/auth-settings.js +3 -2
  3. package/dist/better-auth/connections/connection-account-hooks.js +62 -0
  4. package/dist/better-auth/connections/connection-events.d.ts +11 -0
  5. package/dist/better-auth/connections/connection-events.js +16 -0
  6. package/dist/better-auth/connections/connection-oauth-provider.js +129 -0
  7. package/dist/better-auth/connections/connections-plugin.js +98 -0
  8. package/dist/better-auth/connections/declared-connections.d.ts +21 -0
  9. package/dist/better-auth/connections/declared-connections.js +54 -0
  10. package/dist/better-auth/connections/revoke-token.js +34 -0
  11. package/dist/better-auth/create-auth.d.ts +162 -17
  12. package/dist/better-auth/create-auth.js +59 -5
  13. package/dist/better-auth/refresh-refusals.js +43 -0
  14. package/dist/better-auth/staff-checks.js +1 -0
  15. package/dist/create-admin.js +11 -1
  16. package/dist/forms/rate-limit.js +33 -11
  17. package/dist/forms/routes.js +1 -1
  18. package/dist/functions/verify-signature.js +4 -2
  19. package/dist/index.d.ts +2 -1
  20. package/dist/index.js +3 -2
  21. package/dist/locale/en.js +3 -0
  22. package/dist/locale/nl.js +3 -0
  23. package/dist/mcp/mcp-tools.d.ts +2 -1
  24. package/dist/mcp/mcp-tools.js +4 -2
  25. package/dist/notifications/history-routes.js +1 -1
  26. package/dist/package.js +1 -1
  27. package/dist/personal-mail/linked-accounts.js +17 -13
  28. package/dist/types.d.ts +2 -0
  29. package/dist/webhooks/attempt.js +2 -2
  30. package/dist/webhooks/table/webhook-table.js +1 -1
  31. package/dist/workflows/connections/connection-accounts.js +18 -0
  32. package/dist/workflows/connections/connection-consent.js +49 -0
  33. package/dist/workflows/connections/connection-provider.js +10 -0
  34. package/dist/workflows/connections/connection-tokens.js +54 -0
  35. package/dist/workflows/connections/connection-triggers.js +37 -0
  36. package/dist/workflows/connections/connection-wiring.js +73 -0
  37. package/dist/workflows/connections/enabler-authority.js +35 -0
  38. package/dist/workflows/connections/enabler-mailbox.js +15 -0
  39. package/dist/workflows/connections/single-flight.js +17 -0
  40. package/dist/workflows/hooks/hook-auth.js +40 -0
  41. package/dist/workflows/hooks/hook-event.js +51 -0
  42. package/dist/workflows/hooks/hook-paths.d.ts +16 -0
  43. package/dist/workflows/hooks/hook-paths.js +32 -0
  44. package/dist/workflows/hooks/hook-route.js +99 -0
  45. package/dist/workflows/hooks/hook-runs.js +32 -0
  46. package/dist/workflows/hooks/hook-secrets.js +42 -0
  47. package/dist/workflows/hooks/hook-store.js +35 -0
  48. package/dist/workflows/hooks/hooks-sql.js +20 -0
  49. package/dist/workflows/hooks/issue-body.js +21 -0
  50. package/dist/workflows/hooks/issue-route.js +23 -0
  51. package/dist/workflows/hooks/issue-webhook.js +46 -0
  52. package/dist/workflows/hooks/read-body.js +32 -0
  53. package/dist/workflows/may-update-workflow.js +26 -0
  54. package/dist/workflows/mcp/webhook-tools.d.ts +9 -0
  55. package/dist/workflows/mcp/webhook-tools.js +103 -0
  56. package/dist/workflows/mcp/workflow-instructions.js +1 -1
  57. package/dist/workflows/mcp/workflow-read-tools.d.ts +3 -0
  58. package/dist/workflows/plugin-records.js +1 -1
  59. package/dist/workflows/plugin-services.js +1 -1
  60. package/dist/workflows/ready/ready-start.js +1 -1
  61. package/dist/workflows/run-files.js +7 -1
  62. package/dist/workflows/save-hook.js +7 -2
  63. package/dist/workflows/schema-sql.js +17 -3
  64. package/dist/workflows/schema.js +3 -1
  65. package/dist/workflows/start-workflow.d.ts +3 -1
  66. package/dist/workflows/start-workflow.js +10 -4
  67. package/dist/workflows/triggers/delete-hook.js +15 -0
  68. package/dist/workflows/triggers/plugin-call.js +45 -0
  69. package/dist/workflows/triggers/poll-job.js +120 -0
  70. package/dist/workflows/triggers/poll-result.js +32 -0
  71. package/dist/workflows/triggers/poll-schedule.js +32 -0
  72. package/dist/workflows/triggers/trigger-claim.js +81 -0
  73. package/dist/workflows/triggers/trigger-jobs.js +53 -7
  74. package/dist/workflows/triggers/trigger-start.js +112 -0
  75. package/dist/workflows/triggers/trigger-sync.js +15 -10
  76. package/dist/workflows/workflow-engine.js +57 -9
  77. package/dist/workflows/workflow-types.d.ts +11 -4
  78. package/dist/workflows/workflow-types.js +21 -15
  79. package/dist/write-shared.js +1 -0
  80. package/package.json +6 -6
  81. package/dist/better-auth/end-replaced-session.d.ts +0 -17
@@ -0,0 +1,54 @@
1
+ import { connectionProviderId } from "../../workflows/connections/connection-provider.js";
2
+ import { isIP } from "node:net";
3
+ //#region src/better-auth/connections/declared-connections.ts
4
+ const clientOf = (value) => typeof value?.clientId === "string" && value.clientId !== "" && typeof value.clientSecret === "string" && value.clientSecret !== "" ? {
5
+ clientId: value.clientId,
6
+ clientSecret: value.clientSecret
7
+ } : void 0;
8
+ const literalHost = (url) => {
9
+ const host = new URL(url).hostname.replace(/^\[|\]$/g, "");
10
+ return host === "localhost" || isIP(host) !== 0;
11
+ };
12
+ /**
13
+ * The connections of an app's workflow plugins, each with its provider: the sign-in provider it names, whose client
14
+ * revokes its tokens, or one of its own, whose client `clients` gives by `<plugin>:<connection>`. Startup stops on a
15
+ * client no `oauth` connection declares, and on an endpoint by address unless `allowPrivateAddresses`.
16
+ */
17
+ const resolveConnections = (declared, clients, socialProviders, allowPrivateAddresses) => {
18
+ const keys = new Set(declared.filter(({ declaration }) => "oauth" in declaration).map(({ plugin, name }) => `${plugin}:${name}`));
19
+ const unknown = Object.keys(clients).find((key) => !keys.has(key));
20
+ if (unknown !== void 0) throw new Error(`auth.connections: "${unknown}" is no workflow plugin's connection that signs in through a provider of its own`);
21
+ return declared.map((connection) => {
22
+ const { plugin, name, declaration } = connection;
23
+ const key = `${plugin}:${name}`;
24
+ const providerId = connectionProviderId(plugin, name, declaration);
25
+ const urls = "signIn" in declaration ? [declaration.revocation_url] : [
26
+ declaration.oauth.authorization_url,
27
+ declaration.oauth.token_url,
28
+ declaration.oauth.user_info_url,
29
+ declaration.oauth.revocation_url
30
+ ];
31
+ if (!allowPrivateAddresses && urls.some((url) => url !== void 0 && literalHost(url))) throw new Error(`Workflow plugin "${plugin}": the connection "${name}" names a host by its address; give its provider's name, or allow private addresses for development (workflows.allowPrivateAddresses)`);
32
+ if ("signIn" in declaration) {
33
+ const client = clientOf(socialProviders[declaration.signIn]);
34
+ return {
35
+ ...connection,
36
+ key,
37
+ providerId,
38
+ ...client && { client },
39
+ ...declaration.revocation_url && { revocationUrl: declaration.revocation_url }
40
+ };
41
+ }
42
+ const client = clientOf(clients[key]);
43
+ if (Object.hasOwn(clients, key) && !client) throw new Error(`auth.connections: "${key}" needs its clientId and clientSecret`);
44
+ return {
45
+ ...connection,
46
+ key,
47
+ providerId,
48
+ ...client && { client },
49
+ ...declaration.oauth.revocation_url && { revocationUrl: declaration.oauth.revocation_url }
50
+ };
51
+ });
52
+ };
53
+ //#endregion
54
+ export { resolveConnections };
@@ -0,0 +1,34 @@
1
+ import { AddressRefusedError, pinnedRequest } from "../../webhooks/table/pinned-request.js";
2
+ //#region src/better-auth/connections/revoke-token.ts
3
+ /** The longest a provider gets to revoke a token: disconnecting does not wait on it any longer. */
4
+ const revokeTimeoutMs = 1e4;
5
+ const unreachable = (error) => error instanceof AddressRefusedError || error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError" || typeof error.code === "string");
6
+ /**
7
+ * Revokes a token at its provider (RFC 7009), through the guarded request, which refuses a private address unless the
8
+ * app allows them: whether the provider took it. A provider out of reach or refusing is false, so disconnecting still
9
+ * deletes the token; nothing of the request or its answer is logged or returned, as both carry the token.
10
+ */
11
+ const revokeToken = async ({ url, client, token, hint, allowPrivateAddresses }) => {
12
+ const init = {
13
+ method: "POST",
14
+ headers: { "content-type": "application/x-www-form-urlencoded" },
15
+ body: new URLSearchParams({
16
+ token,
17
+ token_type_hint: hint,
18
+ ...client && {
19
+ client_id: client.clientId,
20
+ client_secret: client.clientSecret
21
+ }
22
+ }).toString(),
23
+ signal: AbortSignal.timeout(revokeTimeoutMs)
24
+ };
25
+ const response = await pinnedRequest(url, init, { allowPrivateAddresses }).catch((error) => {
26
+ if (unreachable(error)) return;
27
+ throw error;
28
+ });
29
+ if (!response) return false;
30
+ await response.body?.cancel();
31
+ return response.ok;
32
+ };
33
+ //#endregion
34
+ export { revokeToken };
@@ -5,12 +5,13 @@ import { StaffSignIn } from "./staff-log.js";
5
5
  import { Person } from "./organizations/people-endpoint.js";
6
6
  import { RoleDefinitions, RolesInput } from "./organizations/role-definitions.js";
7
7
  import { ConnectedApp } from "./mcp/connected-apps.js";
8
- import { ReplacingContext } from "./end-replaced-session.js";
9
8
  import { VersionedSecret } from "./secret-versions.js";
10
9
  import { UserDeletionListener } from "./user-deletion.js";
11
10
  import { OrganizationRole } from "./organizations/store.js";
12
11
  import { OrganizationsOptions, ResolvedOrganizations } from "./organizations/options.js";
13
12
  import { PersonalMailProvider } from "../personal-mail/personal-mail-provider.js";
13
+ import { ConnectionClient, DeclaredConnection, ResolvedConnection } from "./connections/declared-connections.js";
14
+ import { ConnectionListener } from "./connections/connection-events.js";
14
15
  import { McpAuthOptions } from "./mcp/mcp-plugins.js";
15
16
  import { OperatorProvider } from "./operator-provider.js";
16
17
  import { PlatformSignIn } from "./platform-sign-in.js";
@@ -117,8 +118,22 @@ export type CreateAuthOptions = {
117
118
  * `socialProviders` must have: Gmail through `google`. Needs `encryptOAuthTokens`. Default none.
118
119
  */
119
120
  personalMail?: PersonalMailProviderId[];
120
- /** Encrypt the OAuth access and refresh tokens Better Auth stores for social sign-ins, with `secret`; `personalMail` needs it. Default off. */
121
+ /** Encrypt the OAuth access and refresh tokens Better Auth stores for social sign-ins, with `secret`; `personalMail` and workflow connections need it. Default off. */
121
122
  encryptOAuthTokens?: boolean;
123
+ /**
124
+ * The OAuth client of each workflow plugin's connection that signs in through a provider of its own, by
125
+ * `<plugin>:<connection>`, such as `{ 'acme:account': { clientId, clientSecret } }`; its redirect URI is
126
+ * `{baseURL}/api/auth/callback/workflow-<plugin>-<connection>`. A connection through one of `socialProviders` needs none.
127
+ */
128
+ connections?: Record<string, ConnectionClient>;
129
+ /**
130
+ * The connections the app's workflow plugins declare, which people connect accounts through (`createAdmin` passes
131
+ * them from `workflows.plugins`), and whether their providers may be at private addresses, for development and tests.
132
+ */
133
+ workflowConnections?: {
134
+ declared: DeclaredConnection[];
135
+ allowPrivateAddresses?: boolean;
136
+ };
122
137
  /** Runs after a user is created, by any route: `createUser`, an admin, or a social sign-up. Default none. */
123
138
  onUserCreated?: UserCreatedHook;
124
139
  /**
@@ -299,6 +314,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
299
314
  account: {
300
315
  encryptOAuthTokens?: true | undefined;
301
316
  accountLinking: {
317
+ trustedProviders?: string[] | undefined;
302
318
  allowDifferentEmails: true;
303
319
  };
304
320
  fields: {
@@ -590,17 +606,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
590
606
  userId: string;
591
607
  expiresAt: Date;
592
608
  token: string;
593
- ipAddress?
594
- /**
595
- * What the sign-in page leads with: `password`, the email address and a password, with emailing a code below it; or
596
- * `emailCode`, the address and "Email me a sign-in code", with the password behind "Use a password", for apps whose
597
- * people mostly never set one. Only while the policy leaves both on; default `password`.
598
- */
599
- /**
600
- * What the sign-in page leads with: `password`, the email address and a password, with emailing a code below it; or
601
- * `emailCode`, the address and "Email me a sign-in code", with the password behind "Use a password", for apps whose
602
- * people mostly never set one. Only while the policy leaves both on; default `password`.
603
- */ : string | null | undefined;
609
+ ipAddress?: string | null | undefined;
604
610
  userAgent?: string | null | undefined;
605
611
  };
606
612
  };
@@ -3419,6 +3425,102 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
3419
3425
  INVALID_OAUTH_CONFIGURATION: import("better-auth").RawError<"INVALID_OAUTH_CONFIGURATION">;
3420
3426
  TOKEN_URL_NOT_FOUND: import("better-auth").RawError<"TOKEN_URL_NOT_FOUND">;
3421
3427
  };
3428
+ } | {
3429
+ id: "protobase-connection-providers";
3430
+ init: (ctx: import("better-auth").AuthContext) => {
3431
+ context: {
3432
+ socialProviders: import("better-auth").OAuthProvider<object, Partial<import("better-auth").ProviderOptions<object>>>[];
3433
+ };
3434
+ };
3435
+ } | {
3436
+ id: "protobase-connections";
3437
+ endpoints: {
3438
+ listConnections: import("better-call").StrictEndpoint<string, {
3439
+ method: "GET";
3440
+ use: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
3441
+ session: {
3442
+ session: Record<string, any> & {
3443
+ id: string;
3444
+ createdAt: Date;
3445
+ updatedAt: Date;
3446
+ userId: string;
3447
+ expiresAt: Date;
3448
+ token: string;
3449
+ ipAddress?: string | null | undefined;
3450
+ userAgent?: string | null | undefined;
3451
+ };
3452
+ user: Record<string, any> & {
3453
+ id: string;
3454
+ createdAt: Date;
3455
+ updatedAt: Date;
3456
+ email: string;
3457
+ emailVerified: boolean;
3458
+ name: string;
3459
+ image?: string | null | undefined;
3460
+ };
3461
+ };
3462
+ }>>[];
3463
+ }, {
3464
+ connections: {
3465
+ key: string;
3466
+ label: string;
3467
+ provider: string;
3468
+ scopes: string[];
3469
+ link_params: Record<string, string>;
3470
+ accounts: {
3471
+ id: string;
3472
+ scopes: string[];
3473
+ created_at: Date;
3474
+ needs_sign_in: boolean;
3475
+ }[];
3476
+ }[];
3477
+ }>;
3478
+ disconnectConnection: import("better-call").StrictEndpoint<string, {
3479
+ method: "POST";
3480
+ use: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
3481
+ session: {
3482
+ session: Record<string, any> & {
3483
+ id: string;
3484
+ createdAt: Date;
3485
+ updatedAt: Date;
3486
+ userId: string;
3487
+ expiresAt: Date;
3488
+ token: string;
3489
+ ipAddress?: string | null | undefined;
3490
+ userAgent?: string | null | undefined;
3491
+ };
3492
+ user: Record<string, any> & {
3493
+ id: string;
3494
+ createdAt: Date;
3495
+ updatedAt: Date;
3496
+ email: string;
3497
+ emailVerified: boolean;
3498
+ name: string;
3499
+ image?: string | null | undefined;
3500
+ };
3501
+ };
3502
+ }>>[];
3503
+ body: import("zod").ZodObject<{
3504
+ accountId: import("zod").ZodString;
3505
+ }, import("zod/v4/core").$strip>;
3506
+ }, {
3507
+ disconnected: boolean;
3508
+ revoked: boolean;
3509
+ }>;
3510
+ };
3511
+ hooks: {
3512
+ before: {
3513
+ matcher: (context: import("better-auth").HookEndpointContext) => boolean;
3514
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
3515
+ }[];
3516
+ };
3517
+ } | {
3518
+ id: "protobase-refresh-refusals";
3519
+ init: (ctx: import("better-auth").AuthContext) => {
3520
+ context: {
3521
+ socialProviders: import("better-auth").OAuthProvider<object, Partial<import("better-auth").ProviderOptions<object>>>[];
3522
+ };
3523
+ };
3422
3524
  } | {
3423
3525
  id: "sso";
3424
3526
  version: string;
@@ -8978,10 +9080,40 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
8978
9080
  };
8979
9081
  };
8980
9082
  session: {
8981
- create: {
8982
- after: (session: {
8983
- token: string;
8984
- }, ctx: ReplacingContext | null) => Promise<void>;
9083
+ create?: {
9084
+ before?: (session: import("better-auth").Session & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<boolean | void | {
9085
+ data: {
9086
+ id?: string | undefined;
9087
+ createdAt?: Date | undefined;
9088
+ updatedAt?: Date | undefined;
9089
+ userId?: string | undefined;
9090
+ expiresAt?: Date | undefined;
9091
+ token?: string | undefined;
9092
+ ipAddress?: string | null | undefined;
9093
+ userAgent?: string | null | undefined;
9094
+ } & Record<string, any>;
9095
+ }>;
9096
+ after?: (session: import("better-auth").Session & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<void>;
9097
+ };
9098
+ update?: {
9099
+ before?: (session: Partial<import("better-auth").Session> & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<boolean | void | {
9100
+ data: {
9101
+ [x: string]: any;
9102
+ id?: string | undefined;
9103
+ createdAt?: Date | undefined;
9104
+ updatedAt?: Date | undefined;
9105
+ userId?: string | undefined;
9106
+ expiresAt?: Date | undefined;
9107
+ token?: string | undefined;
9108
+ ipAddress?: string | null | undefined;
9109
+ userAgent?: string | null | undefined;
9110
+ };
9111
+ }>;
9112
+ after?: (session: import("better-auth").Session & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<void>;
9113
+ };
9114
+ delete?: {
9115
+ before?: (session: import("better-auth").Session & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<boolean | void>;
9116
+ after?: (session: import("better-auth").Session & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<void>;
8985
9117
  };
8986
9118
  };
8987
9119
  account: {
@@ -9201,6 +9333,19 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
9201
9333
  personalMail: PersonalMailProvider[];
9202
9334
  /** Mail from `userId`'s own mailbox, through the mail provider they linked (see `personalMailer`). */
9203
9335
  personalMailer: (userId: string) => Mailer;
9336
+ /** What workflow connections can sign in with: whether tokens are stored encrypted, the sign-in providers, the connections with a client. */
9337
+ connectionSupport: {
9338
+ encrypted: boolean;
9339
+ signInProviders: string[];
9340
+ clients: string[];
9341
+ };
9342
+ /** The workflow connections people connect accounts through, and the listener (`set`) the workflows give for their links and disconnects. */
9343
+ connections: {
9344
+ declared: ResolvedConnection[];
9345
+ events: {
9346
+ set: (next: ConnectionListener) => undefined;
9347
+ };
9348
+ };
9204
9349
  /** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
9205
9350
  mcp: {
9206
9351
  resource: string;
@@ -5,10 +5,16 @@ import { defaultSignInPolicy } from "./sign-in-policy.js";
5
5
  import { roleDefinitions } from "./organizations/role-definitions.js";
6
6
  import { signInPolicyPlugin } from "./policy-plugin.js";
7
7
  import { goTrueSessionsPlugin } from "../gotrue/sessions-plugin.js";
8
+ import { refreshRefusalsPlugin } from "./refresh-refusals.js";
8
9
  import { betterAuthLinkedAccounts } from "../personal-mail/linked-accounts.js";
9
10
  import { resolvePersonalMail } from "../personal-mail/personal-mail-providers.js";
10
11
  import { personalMailer } from "../personal-mail/personal-mailer.js";
11
12
  import { mailLinkHooks } from "../personal-mail/mail-link-hooks.js";
13
+ import { bothAccountHooks, connectionAccountHooks } from "./connections/connection-account-hooks.js";
14
+ import { connectionEvents } from "./connections/connection-events.js";
15
+ import { connectionProvidersPlugin } from "./connections/connection-oauth-provider.js";
16
+ import { connectionsPlugin } from "./connections/connections-plugin.js";
17
+ import { resolveConnections } from "./connections/declared-connections.js";
12
18
  import { endReplacedSession } from "./end-replaced-session.js";
13
19
  import { authBasePath } from "./auth-base-path.js";
14
20
  import { authLogger } from "./auth-logger.js";
@@ -69,6 +75,20 @@ const withEndedReplaced = (hooks) => ({
69
75
  after: endReplacedSession
70
76
  }
71
77
  });
78
+ const refusingConnectionSessions = (hooks, refuse) => {
79
+ if (!refuse) return hooks;
80
+ const before = hooks.create?.before;
81
+ return {
82
+ ...hooks,
83
+ create: {
84
+ ...hooks.create,
85
+ before: async (session, context) => {
86
+ await refuse(session, context);
87
+ return before?.(session, context);
88
+ }
89
+ }
90
+ };
91
+ };
72
92
  const platformOperator = () => readOperatorSettings(globalThis.process?.env ?? {});
73
93
  const platformSignInSettings = () => readPlatformSignIn(globalThis.process?.env ?? {});
74
94
  const platformOrigins = () => readOrigins(globalThis.process?.env ?? {});
@@ -101,7 +121,9 @@ const organizationPaths = ["/organization/set-active", "/organization/accept-inv
101
121
  * The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
102
122
  * sign-in provider already resolved.
103
123
  */
104
- const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, appName = defaultAppName, mailer, signUpRole, operator, platformSignIn, saml = [], organizations, mcp, context = noContext, signInPolicy = defaultSignInPolicy, mobile, deletion, lockedAccounts = [], ...plugins }) => {
124
+ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, appName = defaultAppName, mailer, signUpRole, operator, platformSignIn, saml = [], organizations, mcp, context = noContext, signInPolicy = defaultSignInPolicy, mobile, deletion, lockedAccounts = [], connections, ...plugins }) => {
125
+ const connectionHooks = connections && connections.resolved.length > 0 ? connectionAccountHooks(connections.resolved, connections.events) : void 0;
126
+ const ownProviders = (connections?.resolved ?? []).filter(({ declaration }) => "oauth" in declaration).map(({ providerId }) => providerId);
105
127
  const secure = options.baseURL.startsWith("https://");
106
128
  const signIn = platformSignInFor(platformSignIn, Object.keys(options.socialProviders ?? {}));
107
129
  const hosts = passkeyHosts([options.baseURL, ...origins]);
@@ -201,7 +223,10 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
201
223
  verification: coreNames.verification,
202
224
  account: {
203
225
  ...coreNames.account,
204
- accountLinking: { allowDifferentEmails: true },
226
+ accountLinking: {
227
+ allowDifferentEmails: true,
228
+ ...ownProviders.length > 0 && { trustedProviders: ownProviders }
229
+ },
205
230
  ...options.encryptOAuthTokens && { encryptOAuthTokens: true }
206
231
  },
207
232
  plugins: [
@@ -238,6 +263,12 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
238
263
  defaultPolicy: signInPolicy
239
264
  })] : [],
240
265
  ...signIn ? [genericOAuth({ config: [platformSignInProvider(signIn)] })] : [],
266
+ ...connections && connections.resolved.length > 0 ? [connectionProvidersPlugin(connections.resolved, { allowPrivateAddresses: connections.allowPrivateAddresses }), connectionsPlugin({
267
+ connections: connections.resolved,
268
+ events: connections.events,
269
+ allowPrivateAddresses: connections.allowPrivateAddresses
270
+ })] : [],
271
+ refreshRefusalsPlugin(),
241
272
  ...saml.length > 0 ? [loaded(plugins.samlPlugin, "sso.saml")(saml, options.baseURL), samlGuardPlugin(samlIds)] : [],
242
273
  signInPolicyPlugin(policy),
243
274
  passwordChecksPlugin(),
@@ -320,8 +351,8 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
320
351
  },
321
352
  update: { before: async (user) => ({ data: confirmedOnUpdate(user, /* @__PURE__ */ new Date()) }) }
322
353
  },
323
- session: withEndedReplaced(organizations ? sessionOrganizationHooks(context) : {}),
324
- account: mailLinkHooks(resolvePersonalMail(options.personalMail ?? [], Object.keys(options.socialProviders ?? {}), options.encryptOAuthTokens === true))
354
+ session: refusingConnectionSessions(withEndedReplaced(organizations ? sessionOrganizationHooks(context) : {}), connectionHooks?.session.create?.before),
355
+ account: ((mail) => connectionHooks ? bothAccountHooks(mail, connectionHooks.account) : mail)(mailLinkHooks(resolvePersonalMail(options.personalMail ?? [], Object.keys(options.socialProviders ?? {}), options.encryptOAuthTokens === true)))
325
356
  },
326
357
  rateLimit: {
327
358
  enabled: true,
@@ -408,6 +439,13 @@ const createAuth = (options, plugins = {}) => {
408
439
  const platformSignIn = platformSignInFor(signInSettings && resolvePlatformSignIn(signInSettings), socialProviders);
409
440
  const saml = resolveSamlProviders(options.sso?.saml ?? [], [...socialProviders, ...platformSignIn ? [platformSignIn.provider] : []]);
410
441
  const personalMail = resolvePersonalMail(options.personalMail ?? [], socialProviders, options.encryptOAuthTokens === true);
442
+ if (Object.keys(options.connections ?? {}).length > 0 && options.encryptOAuthTokens !== true) throw new Error("auth.connections: the connected accounts' tokens would be stored in plain text; turn on auth.encryptOAuthTokens");
443
+ const allowPrivateAddresses = options.workflowConnections?.allowPrivateAddresses === true;
444
+ const resolvedConnections = resolveConnections(options.workflowConnections?.declared ?? [], options.connections ?? {}, options.socialProviders ?? {}, allowPrivateAddresses);
445
+ const clash = resolvedConnections.find(({ declaration, providerId }) => "oauth" in declaration && socialProviders.includes(providerId));
446
+ if (clash) throw new Error(`Workflow plugin "${clash.plugin}": its connection "${clash.name}" signs in as "${clash.providerId}", which is a sign-in provider's id too`);
447
+ const connectionClients = resolvedConnections.filter(({ declaration, client }) => "oauth" in declaration && client).map(({ key }) => key);
448
+ const events = connectionEvents();
411
449
  const origins = resolveOrigins(options.baseURL, options.origins ?? platformOrigins());
412
450
  const passkeyDomain = resolvePasskeyDomain(options.passkeyDomain ?? platformPasskeyDomain(), [options.baseURL, ...origins]);
413
451
  const organizations = options.organizations && resolveOrganizations(options.organizations, definitions);
@@ -436,7 +474,12 @@ const createAuth = (options, plugins = {}) => {
436
474
  signInPolicy,
437
475
  ...organizations && { organizations },
438
476
  ...mcp && { mcp },
439
- context
477
+ context,
478
+ connections: {
479
+ resolved: resolvedConnections,
480
+ events,
481
+ allowPrivateAddresses
482
+ }
440
483
  });
441
484
  assertNoEndpointKeyClashes(authOptions.plugins);
442
485
  const auth = betterAuth(authOptions);
@@ -503,6 +546,17 @@ const createAuth = (options, plugins = {}) => {
503
546
  personalMail,
504
547
  /** Mail from `userId`'s own mailbox, through the mail provider they linked (see `personalMailer`). */
505
548
  personalMailer: (userId) => personalMailer(betterAuthLinkedAccounts(auth), personalMail, userId),
549
+ /** What workflow connections can sign in with: whether tokens are stored encrypted, the sign-in providers, the connections with a client. */
550
+ connectionSupport: {
551
+ encrypted: options.encryptOAuthTokens === true,
552
+ signInProviders: socialProviders,
553
+ clients: connectionClients
554
+ },
555
+ /** The workflow connections people connect accounts through, and the listener (`set`) the workflows give for their links and disconnects. */
556
+ connections: {
557
+ declared: resolvedConnections,
558
+ events: { set: events.set }
559
+ },
506
560
  /** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
507
561
  mcp: mcp && {
508
562
  resource: mcp.resource,
@@ -0,0 +1,43 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ //#region src/better-auth/refresh-refusals.ts
3
+ /**
4
+ * The token endpoint's error (RFC 6749, section 5.2) that refuses the refresh token itself, as for a revoked grant or
5
+ * an expired refresh token: the link no longer signs in, and only connecting it again helps.
6
+ */
7
+ const refusal = "invalid_grant";
8
+ /** Whether a provider's refresh threw for a refused grant: Better Auth's own fetch's error, or a connection provider's. */
9
+ const refreshRefused = (error) => typeof error === "object" && error !== null && error.error === refusal;
10
+ const watched = new AsyncLocalStorage();
11
+ /** Runs `work` and tells whether a provider refused a refresh token in it, whether `work` resolved or threw. */
12
+ const watchRefusals = async (work) => {
13
+ const outcome = { refused: false };
14
+ return {
15
+ ...await watched.run(outcome, () => work().then((value) => ({ value }), (error) => ({ error }))),
16
+ refused: outcome.refused
17
+ };
18
+ };
19
+ const marking = (provider) => {
20
+ const refresh = provider.refreshAccessToken;
21
+ if (!refresh) return provider;
22
+ return {
23
+ ...provider,
24
+ refreshAccessToken: async (...args) => {
25
+ try {
26
+ return await refresh(...args);
27
+ } catch (error) {
28
+ if (refreshRefused(error)) {
29
+ const outcome = watched.getStore();
30
+ if (outcome) outcome.refused = true;
31
+ }
32
+ throw error;
33
+ }
34
+ }
35
+ };
36
+ };
37
+ /** Has every provider's refresh mark a refused grant for `watchRefusals`; after the plugins that add providers. */
38
+ const refreshRefusalsPlugin = () => ({
39
+ id: "protobase-refresh-refusals",
40
+ init: (ctx) => ({ context: { socialProviders: ctx.socialProviders.map(marking) } })
41
+ });
42
+ //#endregion
43
+ export { refreshRefusalsPlugin, refreshRefused, watchRefusals };
@@ -46,6 +46,7 @@ const accountChanges = [
46
46
  "/delete-user",
47
47
  "/link-social",
48
48
  "/unlink-account",
49
+ "/connections/disconnect",
49
50
  "/revoke-sessions",
50
51
  "/revoke-other-sessions",
51
52
  "/two-factor/enable",
@@ -59,6 +59,8 @@ import { toApiFunction } from "./functions/define-function.js";
59
59
  import { createWebhooks } from "./webhooks/webhooks.js";
60
60
  import { webhookTable } from "./webhooks/table/webhook-table.js";
61
61
  import { subscriptionUrlCheck, subscriptionWriteCheck } from "./webhooks/table/validate-hook.js";
62
+ import { hooksBase } from "./workflows/hooks/hook-paths.js";
63
+ import { issueWebhook } from "./workflows/hooks/issue-webhook.js";
62
64
  import { workflowEngine } from "./workflows/workflow-engine.js";
63
65
  import { workflowTypesRoute } from "./workflows/workflow-types-route.js";
64
66
  import { callsService } from "./calls/calls-service.js";
@@ -387,7 +389,13 @@ const createAdmin = ({ resources: appResources, views: appViews = [], userTables
387
389
  sql: options.mcp !== false && options.mcp?.sql === true,
388
390
  writes,
389
391
  commented,
390
- ...engine && { workflows: engine.types },
392
+ ...engine && { workflows: {
393
+ ...engine.types,
394
+ issueWebhook: (session, id, secret) => issueWebhook(engine.issue, session, id, {
395
+ ...secret !== void 0 && { secret },
396
+ origin: new URL(auth.mcp.resource).origin
397
+ })
398
+ } },
391
399
  own: options.mcp !== false && options.mcp?.tools || []
392
400
  });
393
401
  const instructions = engine ? `${mcpInstructions}\n${workflowInstructions}` : mcpInstructions;
@@ -555,7 +563,9 @@ const createAdmin = ({ resources: appResources, views: appViews = [], userTables
555
563
  }));
556
564
  }
557
565
  }
566
+ if (engine) app.route(hooksBase(systemPath), engine.routes.hooks());
558
567
  if (appModels.some((model) => model.live) || deps.calls) app.route(systemPath || "/", guarded((live) => void live.route("/", liveRoutes(feed, (session) => visibleNotices(deps, session))), withAnonymous(authenticate, options.roles)));
568
+ if (engine) app.route(`${systemPath}/workflows`, guarded((routes) => void routes.route("/", engine.routes.issue())));
559
569
  app.route(basePath, api);
560
570
  app.route(`${deps.systemPath}/meta`, metaApp);
561
571
  app.route(systemPath || "/", webManifestRoute(appName, identity, icons));
@@ -8,32 +8,54 @@ const windowMs = 6e4;
8
8
  * sent). In development and tests a request without one is `127.0.0.1`.
9
9
  */
10
10
  const clientAddress = (headers) => getIP(headers, {}) ?? "unknown";
11
+ /** The most keys a limit counts at once: a new key past it forgets the one whose window began first. */
12
+ const rateLimitKeys = 1e4;
11
13
  /**
12
14
  * At most `perMinute` takes per key in a fixed window of a minute, kept in this process. Keys whose window has passed are
13
- * dropped as new ones come, so memory follows the clients of the last minute.
15
+ * dropped as new ones come, and at most `maxKeys` are kept, the oldest window dropped first, so memory stays bounded
16
+ * however many clients come within a minute.
14
17
  */
15
- const createRateLimit = ({ now = Date.now } = {}) => {
18
+ const createRateLimit = ({ now = Date.now, maxKeys = rateLimitKeys } = {}) => {
16
19
  const windows = /* @__PURE__ */ new Map();
17
20
  const prune = (at) => {
18
- for (const [key, window] of windows) if (at - window.start >= windowMs) windows.delete(key);
21
+ for (const [key, window] of windows) {
22
+ if (at - window.start < windowMs) return;
23
+ windows.delete(key);
24
+ }
25
+ };
26
+ const open = (key, at) => {
27
+ prune(at);
28
+ windows.delete(key);
29
+ if (windows.size >= maxKeys) windows.delete(windows.keys().next().value);
30
+ windows.set(key, {
31
+ start: at,
32
+ count: 1
33
+ });
34
+ };
35
+ const wait = (key, perMinute, at) => {
36
+ const current = windows.get(key);
37
+ if (!current || at - current.start >= windowMs || current.count < perMinute) return 0;
38
+ return Math.ceil((current.start + windowMs - at) / 1e3);
19
39
  };
20
40
  /** Counts a take for `key`: `0` when allowed, else the seconds until its window lets it through again. */
21
41
  const take = (key, perMinute) => {
22
42
  const at = now();
23
43
  const current = windows.get(key);
24
44
  if (!current || at - current.start >= windowMs) {
25
- prune(at);
26
- windows.set(key, {
27
- start: at,
28
- count: 1
29
- });
45
+ open(key, at);
30
46
  return 0;
31
47
  }
32
- if (current.count >= perMinute) return Math.ceil((current.start + windowMs - at) / 1e3);
48
+ const seconds = wait(key, perMinute, at);
49
+ if (seconds > 0) return seconds;
33
50
  current.count += 1;
34
51
  return 0;
35
52
  };
36
- return { take };
53
+ /** Whether `key` has spent its `perMinute` takes, without taking one: the seconds to wait, or `0`. */
54
+ const spent = (key, perMinute) => wait(key, perMinute, now());
55
+ return {
56
+ take,
57
+ spent
58
+ };
37
59
  };
38
60
  //#endregion
39
- export { clientAddress, createRateLimit };
61
+ export { clientAddress, createRateLimit, rateLimitKeys };
@@ -1,8 +1,8 @@
1
1
  import { HttpProblem, badRequest, notFound } from "../problem.js";
2
2
  import { runWrite } from "../write-pipeline.js";
3
3
  import { anonymousSession } from "../anonymous.js";
4
- import { pruneView } from "../view-access.js";
5
4
  import { clientAddress } from "./rate-limit.js";
5
+ import { pruneView } from "../view-access.js";
6
6
  import { Hono } from "hono";
7
7
  import { restrictModel } from "@protobase/schema";
8
8
  //#region src/forms/routes.ts
@@ -1,4 +1,5 @@
1
1
  import { HttpProblem } from "../problem.js";
2
+ import { timingSafeEqual } from "node:crypto";
2
3
  //#region src/functions/verify-signature.ts
3
4
  const invalid = (detail) => new HttpProblem(401, "invalid-signature", "Unauthorized", detail);
4
5
  const decodeHex = (text) => /^(?:[0-9a-f]{2})+$/i.test(text) ? new Uint8Array(text.match(/../g).map((pair) => parseInt(pair, 16))) : void 0;
@@ -7,8 +8,9 @@ const verifyHmac = async (key, hash, signature, content) => {
7
8
  const cryptoKey = await crypto.subtle.importKey("raw", key, {
8
9
  name: "HMAC",
9
10
  hash
10
- }, false, ["verify"]);
11
- return crypto.subtle.verify("HMAC", cryptoKey, signature, new TextEncoder().encode(content));
11
+ }, false, ["sign"]);
12
+ const expected = new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(content)));
13
+ return signature.length === expected.length && timingSafeEqual(signature, expected);
12
14
  };
13
15
  /**
14
16
  * The request's body as text, once its HMAC signature checks out against `secret`, as GitHub, Shopify and many others