diff --git a/apps/host-selfhost/src/auth/better-auth.test.ts b/apps/host-selfhost/src/auth/better-auth.test.ts index eebc68e5c..968a6902d 100644 --- a/apps/host-selfhost/src/auth/better-auth.test.ts +++ b/apps/host-selfhost/src/auth/better-auth.test.ts @@ -89,6 +89,47 @@ test("sign-up issues a bearer token and resolves to a per-user org-pinned identi expect(body.organization!.id).toBeTruthy(); }); +test("self-host API keys are not capped by Better Auth's default request limit", async () => { + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "key-user@test.local", + password: "member-password-123", + name: "Key User", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(200); + const token = signUp.headers.get("set-auth-token"); + expect(token).toBeTruthy(); + + const createKey = await handler( + new Request(`${BASE}/api/account/api-keys`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ name: "MCP bootstrap" }), + }), + ); + expect(createKey.status).toBe(200); + const keyBody = (await createKey.json()) as { value: string }; + + for (let i = 0; i < 12; i++) { + const me = await handler( + new Request(`${BASE}/api/account/me`, { + headers: { "x-api-key": keyBody.value }, + }), + ); + expect(me.status).toBe(200); + } +}); + test("an unauthenticated request is rejected with 401", async () => { const res = await handler(new Request("http://localhost/api/account/me")); expect(res.status).toBe(401); diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 578304090..7a7fc861a 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -83,7 +83,7 @@ const makeAuthOptions = (url: string, organizationId: string, gate?: SignupGate) plugins: [ organization(), admin(), - apiKey({ enableSessionForAPIKeys: true }), + apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), bearer(), mcp({ loginPage: "/login" }), ], diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index f185a4149..a53ccbec3 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -18,8 +18,10 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' import { Route as ResumeExecutionIdRouteImport } from './routes/resume.$executionId' import { Route as JoinCodeRouteImport } from './routes/join.$code' +import { Route as IntegrationsNamespaceRouteImport } from './routes/integrations.$namespace' import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' import { Route as PluginsPluginIdSplatRouteImport } from './routes/plugins.$pluginId.$' +import { Route as IntegrationsAddPluginKeyRouteImport } from './routes/integrations.add.$pluginKey' const ToolsRoute = ToolsRouteImport.update({ id: '/tools', @@ -66,6 +68,11 @@ const JoinCodeRoute = JoinCodeRouteImport.update({ path: '/join/$code', getParentRoute: () => rootRouteImport, } as any) +const IntegrationsNamespaceRoute = IntegrationsNamespaceRouteImport.update({ + id: '/integrations/$namespace', + path: '/integrations/$namespace', + getParentRoute: () => rootRouteImport, +} as any) const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ id: '/sources/add/$pluginKey', path: '/sources/add/$pluginKey', @@ -76,6 +83,12 @@ const PluginsPluginIdSplatRoute = PluginsPluginIdSplatRouteImport.update({ path: '/plugins/$pluginId/$', getParentRoute: () => rootRouteImport, } as any) +const IntegrationsAddPluginKeyRoute = + IntegrationsAddPluginKeyRouteImport.update({ + id: '/integrations/add/$pluginKey', + path: '/integrations/add/$pluginKey', + getParentRoute: () => rootRouteImport, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -84,9 +97,11 @@ export interface FileRoutesByFullPath { '/policies': typeof PoliciesRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute + '/integrations/$namespace': typeof IntegrationsNamespaceRoute '/join/$code': typeof JoinCodeRoute '/resume/$executionId': typeof ResumeExecutionIdRoute '/sources/$namespace': typeof SourcesNamespaceRoute + '/integrations/add/$pluginKey': typeof IntegrationsAddPluginKeyRoute '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute } @@ -97,9 +112,11 @@ export interface FileRoutesByTo { '/policies': typeof PoliciesRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute + '/integrations/$namespace': typeof IntegrationsNamespaceRoute '/join/$code': typeof JoinCodeRoute '/resume/$executionId': typeof ResumeExecutionIdRoute '/sources/$namespace': typeof SourcesNamespaceRoute + '/integrations/add/$pluginKey': typeof IntegrationsAddPluginKeyRoute '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute } @@ -111,9 +128,11 @@ export interface FileRoutesById { '/policies': typeof PoliciesRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute + '/integrations/$namespace': typeof IntegrationsNamespaceRoute '/join/$code': typeof JoinCodeRoute '/resume/$executionId': typeof ResumeExecutionIdRoute '/sources/$namespace': typeof SourcesNamespaceRoute + '/integrations/add/$pluginKey': typeof IntegrationsAddPluginKeyRoute '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute } @@ -126,9 +145,11 @@ export interface FileRouteTypes { | '/policies' | '/secrets' | '/tools' + | '/integrations/$namespace' | '/join/$code' | '/resume/$executionId' | '/sources/$namespace' + | '/integrations/add/$pluginKey' | '/plugins/$pluginId/$' | '/sources/add/$pluginKey' fileRoutesByTo: FileRoutesByTo @@ -139,9 +160,11 @@ export interface FileRouteTypes { | '/policies' | '/secrets' | '/tools' + | '/integrations/$namespace' | '/join/$code' | '/resume/$executionId' | '/sources/$namespace' + | '/integrations/add/$pluginKey' | '/plugins/$pluginId/$' | '/sources/add/$pluginKey' id: @@ -152,9 +175,11 @@ export interface FileRouteTypes { | '/policies' | '/secrets' | '/tools' + | '/integrations/$namespace' | '/join/$code' | '/resume/$executionId' | '/sources/$namespace' + | '/integrations/add/$pluginKey' | '/plugins/$pluginId/$' | '/sources/add/$pluginKey' fileRoutesById: FileRoutesById @@ -166,9 +191,11 @@ export interface RootRouteChildren { PoliciesRoute: typeof PoliciesRoute SecretsRoute: typeof SecretsRoute ToolsRoute: typeof ToolsRoute + IntegrationsNamespaceRoute: typeof IntegrationsNamespaceRoute JoinCodeRoute: typeof JoinCodeRoute ResumeExecutionIdRoute: typeof ResumeExecutionIdRoute SourcesNamespaceRoute: typeof SourcesNamespaceRoute + IntegrationsAddPluginKeyRoute: typeof IntegrationsAddPluginKeyRoute PluginsPluginIdSplatRoute: typeof PluginsPluginIdSplatRoute SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute } @@ -238,6 +265,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof JoinCodeRouteImport parentRoute: typeof rootRouteImport } + '/integrations/$namespace': { + id: '/integrations/$namespace' + path: '/integrations/$namespace' + fullPath: '/integrations/$namespace' + preLoaderRoute: typeof IntegrationsNamespaceRouteImport + parentRoute: typeof rootRouteImport + } '/sources/add/$pluginKey': { id: '/sources/add/$pluginKey' path: '/sources/add/$pluginKey' @@ -252,6 +286,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PluginsPluginIdSplatRouteImport parentRoute: typeof rootRouteImport } + '/integrations/add/$pluginKey': { + id: '/integrations/add/$pluginKey' + path: '/integrations/add/$pluginKey' + fullPath: '/integrations/add/$pluginKey' + preLoaderRoute: typeof IntegrationsAddPluginKeyRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -262,9 +303,11 @@ const rootRouteChildren: RootRouteChildren = { PoliciesRoute: PoliciesRoute, SecretsRoute: SecretsRoute, ToolsRoute: ToolsRoute, + IntegrationsNamespaceRoute: IntegrationsNamespaceRoute, JoinCodeRoute: JoinCodeRoute, ResumeExecutionIdRoute: ResumeExecutionIdRoute, SourcesNamespaceRoute: SourcesNamespaceRoute, + IntegrationsAddPluginKeyRoute: IntegrationsAddPluginKeyRoute, PluginsPluginIdSplatRoute: PluginsPluginIdSplatRoute, SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, } diff --git a/apps/host-selfhost/web/routes/integrations.$namespace.tsx b/apps/host-selfhost/web/routes/integrations.$namespace.tsx new file mode 100644 index 000000000..49a458104 --- /dev/null +++ b/apps/host-selfhost/web/routes/integrations.$namespace.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { IntegrationDetailPage } from "@executor-js/react/pages/integration-detail"; + +export const Route = createFileRoute("/integrations/$namespace")({ + component: () => { + const { namespace } = Route.useParams(); + return ; + }, +}); diff --git a/apps/host-selfhost/web/routes/integrations.add.$pluginKey.tsx b/apps/host-selfhost/web/routes/integrations.add.$pluginKey.tsx new file mode 100644 index 000000000..cdf2b8a8a --- /dev/null +++ b/apps/host-selfhost/web/routes/integrations.add.$pluginKey.tsx @@ -0,0 +1,22 @@ +import { Schema } from "effect"; +import { createFileRoute } from "@tanstack/react-router"; +import { AddIntegrationPage } from "@executor-js/react/pages/integration-add"; + +const SearchParams = Schema.toStandardSchemaV1( + Schema.Struct({ + url: Schema.optional(Schema.String), + preset: Schema.optional(Schema.String), + namespace: Schema.optional(Schema.String), + }), +); + +export const Route = createFileRoute("/integrations/add/$pluginKey")({ + validateSearch: SearchParams, + component: () => { + const { pluginKey } = Route.useParams(); + const { url, preset, namespace } = Route.useSearch(); + return ( + + ); + }, +}); diff --git a/apps/host-selfhost/web/routes/sources.$namespace.tsx b/apps/host-selfhost/web/routes/sources.$namespace.tsx index b8fcd190b..2df401d30 100644 --- a/apps/host-selfhost/web/routes/sources.$namespace.tsx +++ b/apps/host-selfhost/web/routes/sources.$namespace.tsx @@ -1,9 +1,9 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { IntegrationDetailPage } from "@executor-js/react/pages/integration-detail"; +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/sources/$namespace")({ - component: () => { - const { namespace } = Route.useParams(); - return ; + beforeLoad: ({ params }) => { + const { namespace } = params; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router redirects are modeled as thrown values + throw redirect({ to: "/integrations/$namespace", params: { namespace } }); }, }); diff --git a/apps/host-selfhost/web/routes/sources.add.$pluginKey.tsx b/apps/host-selfhost/web/routes/sources.add.$pluginKey.tsx index b984e7994..ec809b401 100644 --- a/apps/host-selfhost/web/routes/sources.add.$pluginKey.tsx +++ b/apps/host-selfhost/web/routes/sources.add.$pluginKey.tsx @@ -1,6 +1,5 @@ import { Schema } from "effect"; -import { createFileRoute } from "@tanstack/react-router"; -import { AddIntegrationPage } from "@executor-js/react/pages/integration-add"; +import { createFileRoute, redirect } from "@tanstack/react-router"; const SearchParams = Schema.toStandardSchemaV1( Schema.Struct({ @@ -12,11 +11,9 @@ const SearchParams = Schema.toStandardSchemaV1( export const Route = createFileRoute("/sources/add/$pluginKey")({ validateSearch: SearchParams, - component: () => { - const { pluginKey } = Route.useParams(); - const { url, preset, namespace } = Route.useSearch(); - return ( - - ); + beforeLoad: ({ params, search }) => { + const { pluginKey } = params; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router redirects are modeled as thrown values + throw redirect({ to: "/integrations/add/$pluginKey", params: { pluginKey }, search }); }, });