MCP API key support (hosted mode) (#438)
This commit is contained in:
parent
7a1748af4e
commit
16eb599270
@ -1,12 +1,16 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { createApiKeyPlugin } from "./src/lib/auth-api-key";
|
||||
import { createBaseAuthConfig } from "./src/lib/auth-config";
|
||||
|
||||
const CLI_DEV_BASE_URL = "http://localhost:3000";
|
||||
const baseUrl = process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL;
|
||||
|
||||
const baseAuthConfig = createBaseAuthConfig();
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: baseUrl,
|
||||
secret: process.env.BETTER_AUTH_SECRET ?? randomUUID(),
|
||||
...createBaseAuthConfig(),
|
||||
...baseAuthConfig,
|
||||
plugins: [...baseAuthConfig.plugins, createApiKeyPlugin()],
|
||||
});
|
||||
|
||||
28
drizzle-pg/0019_clammy_selene.sql
Normal file
28
drizzle-pg/0019_clammy_selene.sql
Normal file
@ -0,0 +1,28 @@
|
||||
CREATE TABLE "apikey" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"config_id" text DEFAULT 'default' NOT NULL,
|
||||
"name" text,
|
||||
"start" text,
|
||||
"prefix" text,
|
||||
"key" text NOT NULL,
|
||||
"reference_id" text NOT NULL,
|
||||
"refill_interval" integer,
|
||||
"refill_amount" integer,
|
||||
"last_refill_at" timestamp with time zone,
|
||||
"enabled" boolean DEFAULT true,
|
||||
"rate_limit_enabled" boolean DEFAULT true,
|
||||
"rate_limit_time_window" integer DEFAULT 60000,
|
||||
"rate_limit_max" integer DEFAULT 120,
|
||||
"request_count" integer DEFAULT 0,
|
||||
"remaining" integer,
|
||||
"last_request" timestamp with time zone,
|
||||
"expires_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone NOT NULL,
|
||||
"updated_at" timestamp with time zone NOT NULL,
|
||||
"permissions" text,
|
||||
"metadata" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "apikey_configId_idx" ON "apikey" USING btree ("config_id");--> statement-breakpoint
|
||||
CREATE INDEX "apikey_referenceId_idx" ON "apikey" USING btree ("reference_id");--> statement-breakpoint
|
||||
CREATE INDEX "apikey_key_idx" ON "apikey" USING btree ("key");
|
||||
3805
drizzle-pg/meta/0019_snapshot.json
Normal file
3805
drizzle-pg/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -134,6 +134,13 @@
|
||||
"when": 1786208325786,
|
||||
"tag": "0018_drop_reddit_attributions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1786209409297,
|
||||
"tag": "0019_clammy_selene",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
28
drizzle/0041_free_marvex.sql
Normal file
28
drizzle/0041_free_marvex.sql
Normal file
@ -0,0 +1,28 @@
|
||||
CREATE TABLE `apikey` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`config_id` text DEFAULT 'default' NOT NULL,
|
||||
`name` text,
|
||||
`start` text,
|
||||
`prefix` text,
|
||||
`key` text NOT NULL,
|
||||
`reference_id` text NOT NULL,
|
||||
`refill_interval` integer,
|
||||
`refill_amount` integer,
|
||||
`last_refill_at` integer,
|
||||
`enabled` integer DEFAULT true,
|
||||
`rate_limit_enabled` integer DEFAULT true,
|
||||
`rate_limit_time_window` integer DEFAULT 60000,
|
||||
`rate_limit_max` integer DEFAULT 120,
|
||||
`request_count` integer DEFAULT 0,
|
||||
`remaining` integer,
|
||||
`last_request` integer,
|
||||
`expires_at` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`permissions` text,
|
||||
`metadata` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `apikey_configId_idx` ON `apikey` (`config_id`);--> statement-breakpoint
|
||||
CREATE INDEX `apikey_referenceId_idx` ON `apikey` (`reference_id`);--> statement-breakpoint
|
||||
CREATE INDEX `apikey_key_idx` ON `apikey` (`key`);
|
||||
3469
drizzle/meta/0041_snapshot.json
Normal file
3469
drizzle/meta/0041_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -288,6 +288,13 @@
|
||||
"when": 1786208324064,
|
||||
"tag": "0040_drop_reddit_attributions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1786209407629,
|
||||
"tag": "0041_free_marvex",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -75,6 +75,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.211",
|
||||
"@better-auth/api-key": "1.6.22",
|
||||
"@cloudflare/ai-chat": "^0.10.1",
|
||||
"@cloudflare/think": "0.15.1",
|
||||
"@cloudflare/workers-oauth-provider": "^0.10.2",
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@ -27,6 +27,9 @@ importers:
|
||||
'@ai-sdk/react':
|
||||
specifier: ^3.0.211
|
||||
version: 3.0.211(react@19.2.4)(zod@4.3.6)
|
||||
'@better-auth/api-key':
|
||||
specifier: 1.6.22
|
||||
version: 1.6.22(8d706e213363f271470d19db6adbe48a)
|
||||
'@cloudflare/ai-chat':
|
||||
specifier: ^0.10.1
|
||||
version: 0.10.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(agents@0.20.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(@cloudflare/workers-types@4.20260702.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@modelcontextprotocol/server@2.0.0)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(react@19.2.4)(zod@4.3.6)
|
||||
@ -567,6 +570,14 @@ packages:
|
||||
resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
|
||||
'@better-auth/api-key@1.6.22':
|
||||
resolution: {integrity: sha512-HDiiLYF0ov0zqhKv4CMTyLwpjTZ3UWl2dug451uTw40VM9zGxWSNxwILDcMDZ6hS5evaTHmmk7R5gciskEk2nQ==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.6.22
|
||||
'@better-auth/utils': 0.4.2
|
||||
better-auth: ^1.6.22
|
||||
better-call: 1.3.7
|
||||
|
||||
'@better-auth/core@1.6.22':
|
||||
resolution: {integrity: sha512-aFH/5nzmR501jAJPKjJfiVg4BrkcjVCqq9WS9JnhTruE/2PIWopv1QGMiRIRqxXaPbHczri7cqRdhep3Lg5PMw==}
|
||||
peerDependencies:
|
||||
@ -6571,6 +6582,14 @@ snapshots:
|
||||
'@babel/helper-string-parser': 8.0.0
|
||||
'@babel/helper-validator-identifier': 8.0.2
|
||||
|
||||
'@better-auth/api-key@1.6.22(8d706e213363f271470d19db6adbe48a)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1)
|
||||
'@better-auth/utils': 0.4.2
|
||||
better-auth: 1.6.22(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
better-call: 1.3.7(zod@4.3.6)
|
||||
zod: 4.4.3
|
||||
|
||||
'@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1)':
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.4.2
|
||||
|
||||
@ -305,6 +305,10 @@ async function buildInventory(db: Db, user: UserRow) {
|
||||
schema.ga4Connections,
|
||||
eq(schema.ga4Connections.connectedByUserId, user.id),
|
||||
),
|
||||
api_keys: await db.$count(
|
||||
schema.apikey,
|
||||
eq(schema.apikey.referenceId, user.id),
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
@ -486,6 +490,11 @@ async function erasePostgres(db: Db, user: UserRow, organizationIds: string[]) {
|
||||
await tx
|
||||
.delete(schema.ga4Connections)
|
||||
.where(eq(schema.ga4Connections.connectedByUserId, user.id));
|
||||
// apikey.reference_id mirrors the plugin's polymorphic schema and has no
|
||||
// user FK, so keys don't cascade with the user row.
|
||||
await tx
|
||||
.delete(schema.apikey)
|
||||
.where(eq(schema.apikey.referenceId, user.id));
|
||||
await tx
|
||||
.update(schema.audits)
|
||||
.set({ startedByUserId: "gdpr-deleted-user" })
|
||||
|
||||
96
src/client/components/PortalMenu.tsx
Normal file
96
src/client/components/PortalMenu.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Kebab actions menu rendered through a portal in fixed position, so it can't
|
||||
* be clipped by overflow containers (scrollable tables, overflow-hidden
|
||||
* cards). Opens below the trigger, right-aligned. Closes on outside click,
|
||||
* Escape, scroll, and resize.
|
||||
*/
|
||||
export function PortalMenu({
|
||||
ariaLabel,
|
||||
triggerClassName = "btn btn-ghost btn-xs btn-square",
|
||||
triggerContent = <MoreHorizontal className="size-3.5" />,
|
||||
menuClassName = "w-40",
|
||||
children,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
triggerClassName?: string;
|
||||
triggerContent?: ReactNode;
|
||||
menuClassName?: string;
|
||||
/** Menu <li> items; call `close` before running an item's action. */
|
||||
children: (close: () => void) => ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuRef = useRef<HTMLUListElement | null>(null);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof Node &&
|
||||
(buttonRef.current?.contains(target) ||
|
||||
menuRef.current?.contains(target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
const close = () => setIsOpen(false);
|
||||
const closeOnScroll = (event: Event) => {
|
||||
// Scrolling inside the menu itself shouldn't dismiss it.
|
||||
const target = event.target;
|
||||
if (target instanceof Node && menuRef.current?.contains(target)) return;
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", closeOnOutsideClick);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
window.addEventListener("scroll", closeOnScroll, true);
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", closeOnOutsideClick);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
window.removeEventListener("scroll", closeOnScroll, true);
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={triggerClassName}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => {
|
||||
const rect = buttonRef.current?.getBoundingClientRect();
|
||||
if (rect) setPosition({ top: rect.bottom + 4, left: rect.right });
|
||||
setIsOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
{triggerContent}
|
||||
</button>
|
||||
{isOpen && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<ul
|
||||
ref={menuRef}
|
||||
className={`menu fixed z-[1000] -translate-x-full rounded-box border border-base-300 bg-base-100 p-2 shadow-lg ${menuClassName}`}
|
||||
style={{ top: position.top, left: position.left }}
|
||||
>
|
||||
{children(() => setIsOpen(false))}
|
||||
</ul>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { MoreHorizontal, ScanSearch, Trash2 } from "lucide-react";
|
||||
import { ScanSearch, Trash2 } from "lucide-react";
|
||||
import type { getAuditHistory } from "@/serverFunctions/audit";
|
||||
import { PortalMenu } from "@/client/components/PortalMenu";
|
||||
import { formatDate, StatusBadge } from "@/client/features/audit/shared";
|
||||
|
||||
export function AuditHistorySection({
|
||||
@ -95,24 +96,13 @@ function HistoryActions({
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
<div className="dropdown dropdown-end">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="btn btn-ghost btn-xs btn-square"
|
||||
aria-label="Audit actions"
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-40"
|
||||
>
|
||||
<PortalMenu ariaLabel="Audit actions">
|
||||
{(close) => (
|
||||
<li>
|
||||
<button
|
||||
className="text-error"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick={() => {
|
||||
close();
|
||||
onDelete(auditId);
|
||||
}}
|
||||
>
|
||||
@ -120,8 +110,8 @@ function HistoryActions({
|
||||
Delete audit
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</PortalMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
Sheet,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { PortalMenu } from "@/client/components/PortalMenu";
|
||||
import type {
|
||||
CategoryTab,
|
||||
ExportPayload,
|
||||
@ -191,23 +192,30 @@ function ExportMenu({
|
||||
visibleIssues: LighthouseIssue[];
|
||||
}) {
|
||||
return (
|
||||
<div className="dropdown dropdown-end">
|
||||
<div tabIndex={0} role="button" className="btn btn-sm gap-1">
|
||||
<PortalMenu
|
||||
ariaLabel="Export Lighthouse issues"
|
||||
triggerClassName="btn btn-sm gap-1"
|
||||
triggerContent={
|
||||
<>
|
||||
<Download className="size-4" />
|
||||
Export
|
||||
<ChevronDown className="size-3 opacity-60" />
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-72"
|
||||
</>
|
||||
}
|
||||
menuClassName="w-72 max-h-[min(30rem,70vh)] flex-nowrap overflow-y-auto"
|
||||
>
|
||||
{(close) => (
|
||||
<>
|
||||
<li className="menu-title">
|
||||
<span>Export to Sheets</span>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
disabled={!visibleIssues.length}
|
||||
onClick={() => onExportSheets(visibleIssues, "current")}
|
||||
onClick={() => {
|
||||
close();
|
||||
onExportSheets(visibleIssues, "current");
|
||||
}}
|
||||
>
|
||||
<Sheet className="size-4" />
|
||||
Open in Sheets — {categoryLabelLower}
|
||||
@ -216,7 +224,10 @@ function ExportMenu({
|
||||
<li>
|
||||
<button
|
||||
disabled={!allIssues.length}
|
||||
onClick={() => onExportSheets(allIssues, "all")}
|
||||
onClick={() => {
|
||||
close();
|
||||
onExportSheets(allIssues, "all");
|
||||
}}
|
||||
>
|
||||
<Sheet className="size-4" />
|
||||
Open in Sheets — all actionable
|
||||
@ -228,12 +239,13 @@ function ExportMenu({
|
||||
<li>
|
||||
<button
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
close();
|
||||
onCopy(
|
||||
exportCurrentCategory,
|
||||
`Copied ${categoryLabelLower} issues`,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
Copy {categoryLabelLower} issues
|
||||
@ -242,9 +254,10 @@ function ExportMenu({
|
||||
<li>
|
||||
<button
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
onCopy({ mode: "issues" }, "Copied all actionable issues")
|
||||
}
|
||||
onClick={() => {
|
||||
close();
|
||||
onCopy({ mode: "issues" }, "Copied all actionable issues");
|
||||
}}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
Copy all actionable issues
|
||||
@ -253,9 +266,10 @@ function ExportMenu({
|
||||
<li>
|
||||
<button
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
onCopy({ mode: "full" }, "Copied saved Lighthouse payload")
|
||||
}
|
||||
onClick={() => {
|
||||
close();
|
||||
onCopy({ mode: "full" }, "Copied saved Lighthouse payload");
|
||||
}}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
Copy saved Lighthouse payload
|
||||
@ -267,7 +281,10 @@ function ExportMenu({
|
||||
<li>
|
||||
<button
|
||||
disabled={isBusy}
|
||||
onClick={() => onExport(exportCurrentCategory)}
|
||||
onClick={() => {
|
||||
close();
|
||||
onExport(exportCurrentCategory);
|
||||
}}
|
||||
>
|
||||
Download {categoryLabelLower} issues
|
||||
</button>
|
||||
@ -275,13 +292,22 @@ function ExportMenu({
|
||||
<li>
|
||||
<button
|
||||
disabled={isBusy}
|
||||
onClick={() => onExport({ mode: "issues" })}
|
||||
onClick={() => {
|
||||
close();
|
||||
onExport({ mode: "issues" });
|
||||
}}
|
||||
>
|
||||
Download all actionable issues
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button disabled={isBusy} onClick={() => onExport({ mode: "full" })}>
|
||||
<button
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
close();
|
||||
onExport({ mode: "full" });
|
||||
}}
|
||||
>
|
||||
Download saved Lighthouse payload
|
||||
</button>
|
||||
</li>
|
||||
@ -291,7 +317,10 @@ function ExportMenu({
|
||||
<li>
|
||||
<button
|
||||
disabled={!visibleIssues.length}
|
||||
onClick={() => onExportCsv(visibleIssues, "current")}
|
||||
onClick={() => {
|
||||
close();
|
||||
onExportCsv(visibleIssues, "current");
|
||||
}}
|
||||
>
|
||||
Download {categoryLabelLower} issues
|
||||
</button>
|
||||
@ -299,13 +328,17 @@ function ExportMenu({
|
||||
<li>
|
||||
<button
|
||||
disabled={!allIssues.length}
|
||||
onClick={() => onExportCsv(allIssues, "all")}
|
||||
onClick={() => {
|
||||
close();
|
||||
onExportCsv(allIssues, "all");
|
||||
}}
|
||||
>
|
||||
Download all actionable issues
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PortalMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
270
src/client/features/settings/ApiKeySettings.tsx
Normal file
270
src/client/features/settings/ApiKeySettings.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PortalMenu } from "@/client/components/PortalMenu";
|
||||
import { CopyButton } from "@/client/features/ai-mcp/SetupControls";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
// Better Auth rejects longer names with INVALID_NAME_LENGTH.
|
||||
const MAX_KEY_NAME_LENGTH = 32;
|
||||
|
||||
export function ApiKeySettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
|
||||
const mcpUrl =
|
||||
typeof window === "undefined"
|
||||
? "https://app.openseo.so/mcp"
|
||||
: `${window.location.origin}/mcp`;
|
||||
|
||||
const apiKeysQuery = useQuery({
|
||||
queryKey: ["apiKeys"],
|
||||
queryFn: async () => {
|
||||
const result = await authClient.apiKey.list();
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message ?? "Failed to load API keys");
|
||||
}
|
||||
return result.data.apiKeys.map((key) => ({
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
start: key.start,
|
||||
createdAt: new Date(key.createdAt),
|
||||
lastRequest: key.lastRequest ? new Date(key.lastRequest) : null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (keyName: string) => {
|
||||
const result = await authClient.apiKey.create({ name: keyName });
|
||||
if (result.error || !result.data?.key) {
|
||||
throw new Error(result.error?.message ?? "Failed to create the key");
|
||||
}
|
||||
return result.data.key;
|
||||
},
|
||||
onSuccess: (key) => {
|
||||
setCreatedKey(key);
|
||||
setName("");
|
||||
captureClientEvent("mcp:api_key_created");
|
||||
void queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getStandardErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: async (keyId: string) => {
|
||||
const result = await authClient.apiKey.delete({ keyId });
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message ?? "Failed to revoke the key");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
captureClientEvent("mcp:api_key_revoked");
|
||||
toast.success("API key revoked");
|
||||
void queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getStandardErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const apiKeys = apiKeysQuery.data ?? [];
|
||||
|
||||
const closeCreateModal = () => {
|
||||
setIsCreateOpen(false);
|
||||
setCreatedKey(null);
|
||||
setName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-base-content/50">API keys</h2>
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
Authenticate MCP clients when OAuth doesn't work
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-base-content/60">
|
||||
Use this for remote agents like Hermes where the normal login flow
|
||||
doesn't work.
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
<a
|
||||
className="link link-primary"
|
||||
href="https://openseo.so/docs/mcp"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Setup guide
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
>
|
||||
Create API key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{apiKeysQuery.isError ? (
|
||||
<p className="text-sm text-error">We couldn't load your API keys.</p>
|
||||
) : apiKeys.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-base-300">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Key</th>
|
||||
<th>Created</th>
|
||||
<th>Last used</th>
|
||||
<th className="w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apiKeys.map((key) => (
|
||||
<tr key={key.id} className="hover">
|
||||
<td className="max-w-[220px] truncate font-medium">
|
||||
{key.name || "Unnamed key"}
|
||||
</td>
|
||||
<td className="font-mono text-xs text-base-content/70">
|
||||
{key.start || "oseo_"}…
|
||||
</td>
|
||||
<td className="text-xs text-base-content/70">
|
||||
{key.createdAt.toLocaleDateString()}
|
||||
</td>
|
||||
<td className="text-xs text-base-content/70">
|
||||
{key.lastRequest
|
||||
? key.lastRequest.toLocaleDateString()
|
||||
: "Never"}
|
||||
</td>
|
||||
<td>
|
||||
<PortalMenu
|
||||
ariaLabel={`Actions for ${key.name || "API key"}`}
|
||||
>
|
||||
{(close) => (
|
||||
<li>
|
||||
<button
|
||||
className="text-error"
|
||||
disabled={
|
||||
revokeMutation.isPending &&
|
||||
revokeMutation.variables === key.id
|
||||
}
|
||||
onClick={() => {
|
||||
close();
|
||||
if (
|
||||
window.confirm(
|
||||
`Revoke "${key.name || "Unnamed key"}"? Clients using it will stop working.`,
|
||||
)
|
||||
) {
|
||||
revokeMutation.mutate(key.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Revoke key
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</PortalMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isCreateOpen ? (
|
||||
<div className="modal modal-open">
|
||||
<div className="modal-box max-w-md">
|
||||
{createdKey ? (
|
||||
<>
|
||||
<h3 className="text-lg font-bold">Copy your new API key</h3>
|
||||
<p className="mt-2 text-sm text-base-content/60">
|
||||
It won't be shown again. Send it as{" "}
|
||||
<span className="font-mono text-xs">
|
||||
Authorization: Bearer
|
||||
</span>{" "}
|
||||
to <span className="font-mono text-xs">{mcpUrl}</span>.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto rounded bg-base-200 px-2.5 py-2 font-mono text-xs">
|
||||
{createdKey}
|
||||
</code>
|
||||
<CopyButton
|
||||
value={createdKey}
|
||||
successMessage="API key copied"
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={closeCreateModal}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (name.trim()) createMutation.mutate(name.trim());
|
||||
}}
|
||||
>
|
||||
<h3 className="text-lg font-bold">Create API key</h3>
|
||||
<label className="form-control mt-4 w-full">
|
||||
<span className="label-text pb-1 text-xs text-base-content/60">
|
||||
Name
|
||||
</span>
|
||||
<input
|
||||
className="input input-sm input-bordered w-full"
|
||||
placeholder="Claude Code on laptop"
|
||||
value={name}
|
||||
maxLength={MAX_KEY_NAME_LENGTH}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<div className="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={closeCreateModal}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={createMutation.isPending || !name.trim()}
|
||||
>
|
||||
{createMutation.isPending ? "Creating…" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
{/* No backdrop close on the reveal step: the key is shown once. */}
|
||||
{createdKey ? (
|
||||
<div className="modal-backdrop" />
|
||||
) : (
|
||||
<div className="modal-backdrop" onClick={closeCreateModal} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -164,6 +164,41 @@ export const invitation = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const apikey = sqliteTable(
|
||||
"apikey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
configId: text("config_id").default("default").notNull(),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
referenceId: text("reference_id").notNull(),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: integer("last_refill_at", { mode: "timestamp_ms" }),
|
||||
enabled: integer("enabled", { mode: "boolean" }).default(true),
|
||||
rateLimitEnabled: integer("rate_limit_enabled", {
|
||||
mode: "boolean",
|
||||
}).default(true),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window").default(60000),
|
||||
rateLimitMax: integer("rate_limit_max").default(120),
|
||||
requestCount: integer("request_count").default(0),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: integer("last_request", { mode: "timestamp_ms" }),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [
|
||||
index("apikey_configId_idx").on(table.configId),
|
||||
index("apikey_referenceId_idx").on(table.referenceId),
|
||||
index("apikey_key_idx").on(table.key),
|
||||
],
|
||||
);
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
|
||||
@ -2,6 +2,7 @@ import { relations } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
@ -152,6 +153,39 @@ export const invitation = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const apikey = pgTable(
|
||||
"apikey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
configId: text("config_id").default("default").notNull(),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
referenceId: text("reference_id").notNull(),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: timestampColumn("last_refill_at"),
|
||||
enabled: boolean("enabled").default(true),
|
||||
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window").default(60000),
|
||||
rateLimitMax: integer("rate_limit_max").default(120),
|
||||
requestCount: integer("request_count").default(0),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: timestampColumn("last_request"),
|
||||
expiresAt: timestampColumn("expires_at"),
|
||||
createdAt: timestampColumn("created_at").notNull(),
|
||||
updatedAt: timestampColumn("updated_at").notNull(),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [
|
||||
index("apikey_configId_idx").on(table.configId),
|
||||
index("apikey_referenceId_idx").on(table.referenceId),
|
||||
index("apikey_key_idx").on(table.key),
|
||||
],
|
||||
);
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
|
||||
@ -84,6 +84,7 @@ export const {
|
||||
user,
|
||||
session,
|
||||
account,
|
||||
apikey,
|
||||
verification,
|
||||
organization,
|
||||
member,
|
||||
|
||||
19
src/lib/auth-api-key.ts
Normal file
19
src/lib/auth-api-key.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
|
||||
// Also the routing discriminator on /mcp: a credential starting with this
|
||||
// prefix is treated as an API key, anything else flows to the OAuth provider.
|
||||
export const API_KEY_PREFIX = "oseo_";
|
||||
|
||||
export function createApiKeyPlugin() {
|
||||
return apiKey({
|
||||
defaultPrefix: API_KEY_PREFIX,
|
||||
// Stored display prefix ("oseo_" + 4 key chars) shown in Settings so keys
|
||||
// are tellable apart; the plugin default of 6 barely clears the prefix.
|
||||
startingCharactersConfig: { shouldStore: true, charactersLength: 9 },
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
timeWindow: 60 * 1000,
|
||||
maxRequests: 500,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { apiKeyClient } from "@better-auth/api-key/client";
|
||||
import {
|
||||
genericOAuthClient,
|
||||
inferAdditionalFields,
|
||||
@ -11,6 +12,7 @@ import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
||||
plugins: [
|
||||
apiKeyClient(),
|
||||
organizationClient(),
|
||||
genericOAuthClient(),
|
||||
inferAdditionalFields({ user: userAdditionalFields }),
|
||||
|
||||
@ -34,7 +34,24 @@ export function createBaseAuthConfig() {
|
||||
// server-side at signup via `auth.api.createOrganization({ body: { userId }})`
|
||||
// — that's a "system action" (no session + userId in body) which better-auth
|
||||
// exempts from this flag, so the bootstrap keeps working.
|
||||
organization({ allowUserToCreateOrganization: false }),
|
||||
//
|
||||
// invitationLimit: 0 closes the other path to multi-org membership.
|
||||
// "One user, one workspace" is a billing invariant: MCP API keys bill the
|
||||
// user's first org, sessions bill the active org — identical only while
|
||||
// users can't be invited into a second workspace. Remove this when teams
|
||||
// ship, in the same change that moves API-key requests to project-level
|
||||
// authz (org derived per tool call from the project; keys stay
|
||||
// user-scoped, no key→workspace binding).
|
||||
//
|
||||
// disableOrganizationDeletion closes the delete side of the same loop:
|
||||
// POST /api/auth/organization/delete (owner-callable by default) would
|
||||
// cascade-delete the workspace, and the next request auto-creates a fresh
|
||||
// org id — a fresh Autumn customer with a fresh credit grant.
|
||||
organization({
|
||||
allowUserToCreateOrganization: false,
|
||||
invitationLimit: 0,
|
||||
disableOrganizationDeletion: true,
|
||||
}),
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
|
||||
@ -12,6 +12,7 @@ import * as pgSchema from "@/db/pg/schema";
|
||||
import { getDatabaseProvider } from "@/db/provider";
|
||||
import { z } from "zod";
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import { createApiKeyPlugin } from "@/lib/auth-api-key";
|
||||
import { createBaseAuthConfig } from "@/lib/auth-config";
|
||||
import {
|
||||
getHostedTurnstileSecretKey,
|
||||
@ -96,6 +97,7 @@ function createAuth() {
|
||||
database,
|
||||
plugins: [
|
||||
...baseAuthConfig.plugins,
|
||||
...(isHostedAuthMode(env.AUTH_MODE) ? [createApiKeyPlugin()] : []),
|
||||
...(turnstileSecretKey
|
||||
? [
|
||||
captcha({
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { ArrowUpRight, ShieldAlert } from "lucide-react";
|
||||
import { getAuthMode } from "@/lib/auth-mode";
|
||||
import { getAuthMode, isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { ClaudeIcon, CodexIcon } from "@/client/features/ai-mcp/AgentIcons";
|
||||
import { AvailableTools } from "@/client/features/ai-mcp/AvailableTools";
|
||||
@ -94,6 +94,15 @@ function AiPage() {
|
||||
instance you are using now, whether hosted, self-hosted, or local.
|
||||
Sign in with OpenSEO when prompted.
|
||||
</p>
|
||||
{isHostedClientAuthMode() ? (
|
||||
<p className="mt-2 text-xs text-base-content/55">
|
||||
For headless or CI setups, use an API key from{" "}
|
||||
<Link className="link link-primary" to="/settings">
|
||||
Settings
|
||||
</Link>{" "}
|
||||
instead of the OAuth login.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
|
||||
@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ApiKeySettings } from "@/client/features/settings/ApiKeySettings";
|
||||
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
|
||||
import { authClient, useSession } from "@/lib/auth-client";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
@ -49,7 +50,7 @@ function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-base-100 px-4 py-8 pb-24 md:px-6 md:py-12 md:pb-8">
|
||||
<div className="mx-auto max-w-xl space-y-10">
|
||||
<div className="mx-auto max-w-3xl space-y-10">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||
|
||||
<section className="space-y-3">
|
||||
@ -90,6 +91,9 @@ function SettingsPage() {
|
||||
</section>
|
||||
|
||||
{isHosted ? (
|
||||
<>
|
||||
<ApiKeySettings />
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-base-content/50">
|
||||
Analytics
|
||||
@ -113,6 +117,7 @@ function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-base-content/50">About</h2>
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository";
|
||||
|
||||
// Orgs whose first external MCP tool call is already recorded (or in flight)
|
||||
// in this isolate. Only *first* timestamps matter, so after one successful
|
||||
// write the tool-call hot path never touches the DB again for that org.
|
||||
// Orgs whose activation milestones are already recorded (or in flight) in
|
||||
// this isolate. Only *first* timestamps matter, so after one successful write
|
||||
// these hot paths (every API-key /mcp request, every external tool call)
|
||||
// never touch the DB again for that org.
|
||||
const recordedAuthorizedOrgs = new Set<string>();
|
||||
const recordedToolCallOrgs = new Set<string>();
|
||||
|
||||
/**
|
||||
@ -14,9 +16,13 @@ const recordedToolCallOrgs = new Set<string>();
|
||||
export async function recordMcpAuthorized(
|
||||
organizationId: string,
|
||||
): Promise<void> {
|
||||
if (recordedAuthorizedOrgs.has(organizationId)) return;
|
||||
recordedAuthorizedOrgs.add(organizationId);
|
||||
try {
|
||||
await ActivationRepository.recordFirstMcpAuthorized(organizationId);
|
||||
} catch (error) {
|
||||
// Allow a retry on a later call rather than losing the milestone.
|
||||
recordedAuthorizedOrgs.delete(organizationId);
|
||||
console.error("activation: recordMcpAuthorized failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
232
src/server/mcp/api-key-auth.test.ts
Normal file
232
src/server/mcp/api-key-auth.test.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
||||
import { MCP_OAUTH_SCOPES } from "@/lib/oauth-resource";
|
||||
import type { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
verifyApiKey: vi.fn(),
|
||||
getHostedUser: vi.fn(),
|
||||
getOrCreateDefaultHostedOrganization: vi.fn(),
|
||||
recordMcpAuthorized: vi.fn(),
|
||||
handleAuthenticatedOpenSeoMcpRequest:
|
||||
vi.fn<typeof handleAuthenticatedOpenSeoMcpRequest>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuth: () => ({
|
||||
api: {
|
||||
verifyApiKey: mocks.verifyApiKey,
|
||||
createOrganization: vi.fn(),
|
||||
},
|
||||
}),
|
||||
getHostedBaseUrl: () => "https://app.openseo.so",
|
||||
}));
|
||||
|
||||
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
|
||||
AuthRepository: {
|
||||
getHostedUser: mocks.getHostedUser,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/server/auth/default-hosted-organization", () => ({
|
||||
getOrCreateDefaultHostedOrganization:
|
||||
mocks.getOrCreateDefaultHostedOrganization,
|
||||
}));
|
||||
|
||||
vi.mock("@/server/features/activation/mcpActivation", () => ({
|
||||
recordMcpAuthorized: mocks.recordMcpAuthorized,
|
||||
}));
|
||||
|
||||
vi.mock("@/server/mcp/transport", () => ({
|
||||
handleAuthenticatedOpenSeoMcpRequest:
|
||||
mocks.handleAuthenticatedOpenSeoMcpRequest,
|
||||
}));
|
||||
|
||||
import { handleMcpApiKeyRequest } from "@/server/mcp/api-key-auth";
|
||||
|
||||
const env = {};
|
||||
const ctx: ExecutionContext = {
|
||||
waitUntil() {},
|
||||
passThroughOnException() {},
|
||||
props: {},
|
||||
};
|
||||
|
||||
function request(headers?: HeadersInit, method = "POST") {
|
||||
return new Request("https://app.openseo.so/mcp", { method, headers });
|
||||
}
|
||||
|
||||
describe("handleMcpApiKeyRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getHostedUser.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "person@example.com",
|
||||
name: "Person",
|
||||
});
|
||||
mocks.getOrCreateDefaultHostedOrganization.mockResolvedValue("org-1");
|
||||
mocks.recordMcpAuthorized.mockResolvedValue(undefined);
|
||||
mocks.handleAuthenticatedOpenSeoMcpRequest.mockResolvedValue(
|
||||
new Response("mcp response"),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a valid key with the hosted user, organization, and MCP scopes", async () => {
|
||||
mocks.verifyApiKey.mockResolvedValue({
|
||||
valid: true,
|
||||
error: null,
|
||||
key: { referenceId: "user-1" },
|
||||
});
|
||||
const mcpRequest = request({ Authorization: "Bearer oseo_secret" });
|
||||
|
||||
const response = await handleMcpApiKeyRequest(mcpRequest, env, ctx);
|
||||
|
||||
expect(await response?.text()).toBe("mcp response");
|
||||
expect(mocks.verifyApiKey).toHaveBeenCalledWith({
|
||||
body: { key: "oseo_secret" },
|
||||
});
|
||||
expect(mocks.getOrCreateDefaultHostedOrganization).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mocks.recordMcpAuthorized).toHaveBeenCalledWith("org-1");
|
||||
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).toHaveBeenCalledTimes(1);
|
||||
const [passedRequest, props, passedEnv, passedCtx] =
|
||||
mocks.handleAuthenticatedOpenSeoMcpRequest.mock.calls[0];
|
||||
expect(passedRequest).toBe(mcpRequest);
|
||||
expect(passedEnv).toBe(env);
|
||||
expect(passedCtx).toBe(ctx);
|
||||
expect(props).toMatchObject({
|
||||
[MCP_AUTH_CONTEXT_PROP]: {
|
||||
userId: "user-1",
|
||||
userEmail: "person@example.com",
|
||||
organizationId: "org-1",
|
||||
scopes: [...MCP_OAUTH_SCOPES],
|
||||
clientId: "api_key",
|
||||
baseUrl: "https://app.openseo.so",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the key via a case-insensitive bearer scheme", async () => {
|
||||
mocks.verifyApiKey.mockResolvedValue({
|
||||
valid: true,
|
||||
error: null,
|
||||
key: { referenceId: "user-1" },
|
||||
});
|
||||
|
||||
await handleMcpApiKeyRequest(
|
||||
request({ Authorization: "bearer oseo_secret" }),
|
||||
env,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(mocks.verifyApiKey).toHaveBeenCalledWith({
|
||||
body: { key: "oseo_secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 401 for an invalid key without invoking the transport", async () => {
|
||||
mocks.verifyApiKey.mockResolvedValue({
|
||||
valid: false,
|
||||
error: { code: "INVALID_API_KEY" },
|
||||
key: null,
|
||||
});
|
||||
|
||||
const response = await handleMcpApiKeyRequest(
|
||||
request({ "x-api-key": "oseo_revoked" }),
|
||||
env,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(401);
|
||||
expect(response?.headers.has("WWW-Authenticate")).toBe(false);
|
||||
await expect(response?.json()).resolves.toMatchObject({
|
||||
error: "invalid_api_key",
|
||||
});
|
||||
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 with Retry-After when Better Auth rate-limits the key", async () => {
|
||||
mocks.verifyApiKey.mockResolvedValue({
|
||||
valid: false,
|
||||
error: {
|
||||
code: "RATE_LIMITED",
|
||||
message: "Rate limit exceeded",
|
||||
details: { tryAgainIn: 30500 },
|
||||
},
|
||||
key: null,
|
||||
});
|
||||
|
||||
const response = await handleMcpApiKeyRequest(
|
||||
request({ "x-api-key": "oseo_limited" }),
|
||||
env,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(429);
|
||||
expect(response?.headers.get("Retry-After")).toBe("31");
|
||||
await expect(response?.json()).resolves.toMatchObject({
|
||||
error: "rate_limited",
|
||||
});
|
||||
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a JSON 500 when auth resolution throws", async () => {
|
||||
mocks.verifyApiKey.mockRejectedValue(new Error("db down"));
|
||||
|
||||
const response = await handleMcpApiKeyRequest(
|
||||
request({ Authorization: "Bearer oseo_secret" }),
|
||||
env,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(500);
|
||||
await expect(response?.json()).resolves.toMatchObject({
|
||||
error: "internal_error",
|
||||
});
|
||||
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves non-OpenSEO bearer tokens for OAuth", async () => {
|
||||
await expect(
|
||||
handleMcpApiKeyRequest(
|
||||
request({ Authorization: "Bearer oauth-access-token" }),
|
||||
env,
|
||||
ctx,
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(mocks.verifyApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves non-OpenSEO x-api-key values for OAuth", async () => {
|
||||
await expect(
|
||||
handleMcpApiKeyRequest(
|
||||
request({
|
||||
"x-api-key": "some-foreign-key",
|
||||
Authorization: "Bearer oauth-access-token",
|
||||
}),
|
||||
env,
|
||||
ctx,
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(mocks.verifyApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves requests without credentials for OAuth", async () => {
|
||||
await expect(
|
||||
handleMcpApiKeyRequest(request(), env, ctx),
|
||||
).resolves.toBeNull();
|
||||
expect(mocks.verifyApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves OPTIONS requests with API keys for the CORS handler", async () => {
|
||||
await expect(
|
||||
handleMcpApiKeyRequest(
|
||||
request({ "x-api-key": "oseo_secret" }, "OPTIONS"),
|
||||
env,
|
||||
ctx,
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(mocks.verifyApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
140
src/server/mcp/api-key-auth.ts
Normal file
140
src/server/mcp/api-key-auth.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { getAuth, getHostedBaseUrl } from "@/lib/auth";
|
||||
import { API_KEY_PREFIX } from "@/lib/auth-api-key";
|
||||
import { MCP_OAUTH_SCOPES } from "@/lib/oauth-resource";
|
||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||
import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation";
|
||||
import { createWorkersOAuthMcpProps, MCP_ROUTE } from "@/server/mcp/context";
|
||||
import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport";
|
||||
|
||||
function getApiKey(request: Request) {
|
||||
// Both branches require the oseo_ prefix: anything else (a Cloudflare OAuth
|
||||
// access token, a stray foreign x-api-key header) falls through to the
|
||||
// OAuth provider instead of being consumed here.
|
||||
const headerKey = request.headers.get("x-api-key");
|
||||
if (headerKey?.startsWith(API_KEY_PREFIX)) return headerKey;
|
||||
|
||||
const bearerToken = request.headers
|
||||
.get("Authorization")
|
||||
?.replace(/^Bearer /i, "");
|
||||
if (bearerToken?.startsWith(API_KEY_PREFIX)) return bearerToken;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function apiKeyErrorResponse(
|
||||
error: { code?: string | null; message?: unknown; details?: unknown } | null,
|
||||
) {
|
||||
const headers = new Headers({ "Content-Type": "application/json" });
|
||||
|
||||
const isLimited =
|
||||
error?.code === "RATE_LIMITED" || error?.code === "USAGE_EXCEEDED";
|
||||
if (isLimited) {
|
||||
// The plugin reports tryAgainIn (milliseconds) for RATE_LIMITED, but its
|
||||
// published error type omits `details`, so narrow at runtime.
|
||||
const details = error?.details;
|
||||
const tryAgainInMs =
|
||||
typeof details === "object" && details !== null && "tryAgainIn" in details
|
||||
? details.tryAgainIn
|
||||
: undefined;
|
||||
if (typeof tryAgainInMs === "number" && Number.isFinite(tryAgainInMs)) {
|
||||
headers.set(
|
||||
"Retry-After",
|
||||
String(Math.max(1, Math.ceil(tryAgainInMs / 1000))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const code = isLimited
|
||||
? error?.code === "RATE_LIMITED"
|
||||
? "rate_limited"
|
||||
: "usage_exceeded"
|
||||
: "invalid_api_key";
|
||||
const description = isLimited
|
||||
? typeof error?.message === "string"
|
||||
? error.message
|
||||
: "API key request limit reached"
|
||||
: "The provided API key is invalid, expired, or disabled";
|
||||
const status = isLimited ? 429 : 401;
|
||||
|
||||
// Bad credentials are client-side noise, so 401 logs at debug (mirroring the
|
||||
// OAuth path); a 429 means we actually cut a caller off, so warn.
|
||||
const line = `[mcp-api-key] ${status} ${code} (${error?.code ?? "no_hosted_user"})`;
|
||||
if (status === 429) {
|
||||
console.warn(line);
|
||||
} else {
|
||||
console.debug(line);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: code, error_description: description }),
|
||||
{ status, headers },
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleMcpApiKeyRequest(
|
||||
request: Request,
|
||||
env: unknown,
|
||||
ctx: ExecutionContext,
|
||||
): Promise<Response | null> {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname !== MCP_ROUTE || request.method === "OPTIONS") return null;
|
||||
|
||||
const apiKey = getApiKey(request);
|
||||
if (!apiKey) return null;
|
||||
|
||||
try {
|
||||
// Keep API keys scoped to /mcp: verifyApiKey (rather than Better Auth's
|
||||
// enableSessionForAPIKeys mock sessions) means a key never becomes a
|
||||
// session that could reach account or organization endpoints.
|
||||
const authApi = getAuth().api;
|
||||
const result = await authApi.verifyApiKey({ body: { key: apiKey } });
|
||||
|
||||
if (!result.valid || !result.key) {
|
||||
return apiKeyErrorResponse(result.error);
|
||||
}
|
||||
|
||||
const userId = result.key.referenceId;
|
||||
const user = await AuthRepository.getHostedUser(userId);
|
||||
if (!user?.email) return apiKeyErrorResponse(null);
|
||||
|
||||
// API keys bill the user's default hosted workspace (their first org).
|
||||
// The hosted product provisions exactly one org per user. Decided
|
||||
// direction for multi-org: keys stay user-scoped and the org derives from
|
||||
// the project each tool call names (project-level authz) — not key→org
|
||||
// binding.
|
||||
const organizationId = await getOrCreateDefaultHostedOrganization(
|
||||
userId,
|
||||
(body) => authApi.createOrganization({ body }),
|
||||
);
|
||||
|
||||
// clientId "api_key" satisfies the hosted transport's fail-closed props
|
||||
// schema and counts these calls as external MCP clients in telemetry.
|
||||
const props = createWorkersOAuthMcpProps({
|
||||
userId,
|
||||
userEmail: user.email,
|
||||
organizationId,
|
||||
baseUrl: getHostedBaseUrl(),
|
||||
scopes: [...MCP_OAUTH_SCOPES],
|
||||
clientId: "api_key",
|
||||
});
|
||||
|
||||
await recordMcpAuthorized(organizationId);
|
||||
|
||||
return await handleAuthenticatedOpenSeoMcpRequest(request, props, env, ctx);
|
||||
} catch (error) {
|
||||
// Without this, a throw here surfaces as a bare platform 500 with no log
|
||||
// breadcrumb.
|
||||
console.error("[mcp-api-key] 500 (internal):", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "internal_error",
|
||||
error_description: "API key authentication failed",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,9 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("cloudflare:workers", () => ({
|
||||
waitUntil: (promise: Promise<unknown>) => void promise,
|
||||
// api-key-auth pulls the db client into this module graph; it reads env at
|
||||
// module load.
|
||||
env: {},
|
||||
}));
|
||||
|
||||
// The whole module is doubled because the real one imports "cloudflare:workers"
|
||||
|
||||
@ -27,6 +27,7 @@ import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registrat
|
||||
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||
import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport";
|
||||
import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
|
||||
import { handleMcpApiKeyRequest } from "@/server/mcp/api-key-auth";
|
||||
|
||||
const OAUTH_AUTHORIZE_PATH = "/api/auth/oauth2/authorize";
|
||||
const OAUTH_TOKEN_PATH = "/api/auth/oauth2/token";
|
||||
@ -446,6 +447,9 @@ export function createOpenSeoOAuthProvider(appFetch: AppFetch) {
|
||||
async fetch(request: Request, env: OpenSeoOAuthEnv, ctx: ExecutionContext) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const apiKeyResponse = await handleMcpApiKeyRequest(request, env, ctx);
|
||||
if (apiKeyResponse) return apiKeyResponse;
|
||||
|
||||
if (url.pathname === OAUTH_REGISTER_PATH) {
|
||||
return getProvider().fetch(
|
||||
await normalizeClientRegistrationRequest(request),
|
||||
|
||||
@ -20,6 +20,9 @@ vi.mock("cloudflare:workers", () => ({
|
||||
// support; this suite uses object handlers, so a bare stand-in suffices.
|
||||
// oxlint-disable-next-line typescript/no-extraneous-class
|
||||
WorkerEntrypoint: class {},
|
||||
// api-key-auth pulls the db client into this module graph; it reads env at
|
||||
// module load.
|
||||
env: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
|
||||
@ -11,7 +11,7 @@ The hosted MCP server URL is:
|
||||
https://app.openseo.so/mcp
|
||||
```
|
||||
|
||||
The first connection sends you through OpenSEO login. After authorization, your MCP client can call OpenSEO tools with the project context and account scopes you approved.
|
||||
The first connection sends you through OpenSEO login. After authorization, your MCP client can call OpenSEO tools with the project context and account scopes you approved. For headless environments and CI, [connect with an API key](#connect-with-an-api-key) instead.
|
||||
|
||||
For the most current setup UI and a copyable endpoint, open [AI & MCP in OpenSEO](https://app.openseo.so/ai).
|
||||
|
||||
@ -69,6 +69,42 @@ Approve the login when prompted.
|
||||
3. Paste `https://app.openseo.so/mcp`.
|
||||
4. Approve the OpenSEO login when prompted.
|
||||
|
||||
## Connect with an API key
|
||||
|
||||
Use an API key in headless environments, CI, or clients where OAuth is inconvenient. API keys are personal: anything an agent does with your key acts as you in your workspace.
|
||||
|
||||
In the [OpenSEO app](https://app.openseo.so/settings), open **Settings -> API keys**, create a key, and copy it when it appears. It won't be shown again.
|
||||
|
||||
For Claude Code, run:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http --scope user openseo https://app.openseo.so/mcp --header "Authorization: Bearer oseo_YOUR_KEY"
|
||||
```
|
||||
|
||||
For Cursor, add `headers` to the server entry in `mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openseo": {
|
||||
"url": "https://app.openseo.so/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer oseo_YOUR_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Codex CLI, put the key in an environment variable and reference it:
|
||||
|
||||
```bash
|
||||
export OPENSEO_API_KEY=oseo_YOUR_KEY
|
||||
codex mcp add openseo --url https://app.openseo.so/mcp --bearer-token-env-var OPENSEO_API_KEY
|
||||
```
|
||||
|
||||
Any other client that supports custom HTTP headers can send `Authorization: Bearer oseo_YOUR_KEY` or `x-api-key: oseo_YOUR_KEY`.
|
||||
|
||||
## Available tools
|
||||
|
||||
OpenSEO MCP exposes tools for SEO research workflows:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user